From ccad35f36fae084faee92b79adf7d282aa5d85d7 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Mon, 16 Feb 2026 20:34:53 -0500 Subject: [PATCH 1/3] Integrations v2 --- tools/asana/src/asana.ts | 145 +++--- tools/gmail/src/gmail.ts | 195 +++---- tools/google-calendar/src/google-api.ts | 4 +- tools/google-calendar/src/google-calendar.ts | 477 ++++++------------ tools/google-contacts/src/google-contacts.ts | 195 +++---- tools/google-contacts/src/types.ts | 17 +- tools/google-drive/src/google-drive.ts | 209 +++----- tools/jira/src/jira.ts | 194 +++---- tools/linear/src/linear.ts | 148 +++--- .../outlook-calendar/src/outlook-calendar.ts | 451 ++++------------- tools/slack/src/slack.ts | 153 ++---- twister/src/common/calendar.ts | 118 +---- twister/src/common/documents.ts | 70 +-- twister/src/common/messaging.ts | 69 +-- twister/src/common/projects.ts | 84 +-- twister/src/tools/integrations.ts | 155 ++++-- twister/src/tools/plot.ts | 2 +- twists/calendar-sync/src/index.ts | 294 +---------- twists/document-actions/src/index.ts | 238 ++------- twists/message-tasks/src/index.ts | 282 +---------- twists/project-sync/src/index.ts | 303 +---------- 21 files changed, 971 insertions(+), 2832 deletions(-) diff --git a/tools/asana/src/asana.ts b/tools/asana/src/asana.ts index 46d1bd5..62ba430 100644 --- a/tools/asana/src/asana.ts +++ b/tools/asana/src/asana.ts @@ -6,7 +6,6 @@ import { ActivityLinkType, ActivityMeta, ActivityType, - type ActorId, type NewActivity, type NewActivityWithNotes, type NewNote, @@ -14,7 +13,6 @@ import { } from "@plotday/twister"; import type { Project, - ProjectAuth, ProjectSyncOptions, ProjectTool, } from "@plotday/twister/common/projects"; @@ -23,8 +21,10 @@ import { Tool, type ToolBuilder } from "@plotday/twister/tool"; import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; import { AuthProvider, + type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; @@ -44,9 +44,20 @@ type SyncState = { * with Plot activities. */ export class Asana extends Tool implements ProjectTool { + static readonly PROVIDER = AuthProvider.Asana; + static readonly SCOPES = ["default"]; + build(build: ToolBuilder) { return { - integrations: build(Integrations), + integrations: build(Integrations, { + providers: [{ + provider: Asana.PROVIDER, + scopes: Asana.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }], + }), network: build(Network, { urls: ["https://app.asana.com/*"] }), callbacks: build(Callbacks), tasks: build(Tasks), @@ -57,69 +68,50 @@ export class Asana extends Tool implements ProjectTool { /** * Create Asana API client with auth token */ - private async getClient(authToken: string): Promise { - // Try new flow: look up by provider + actor ID - let token = await this.tools.integrations.get(AuthProvider.Asana, authToken as ActorId); - - // Fall back to legacy authorization lookup + private async getClient(projectId: string): Promise { + const token = await this.tools.integrations.get(Asana.PROVIDER, projectId); if (!token) { - const authorization = await this.get( - `authorization:${authToken}` - ); - if (!authorization) { - throw new Error("Authorization no longer available"); - } - - token = await this.tools.integrations.get(authorization.provider, authorization.actor.id); - if (!token) { - throw new Error("Authorization no longer available"); - } + throw new Error("No Asana authentication token available"); } - return asana.Client.create().useAccessToken(token.token); } /** - * Request Asana OAuth authorization + * Returns available Asana projects as syncable resources. */ - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: ProjectAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - const asanaScopes = ["default"]; - - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + async getSyncables(_auth: Authorization, token: AuthToken): Promise { + const client = asana.Client.create().useAccessToken(token.token); + const workspaces = await client.workspaces.getWorkspaces(); + const allProjects: Syncable[] = []; + for (const workspace of workspaces.data) { + const projects = await client.projects.findByWorkspace(workspace.gid, { limit: 100 }); + for (const project of projects.data) { + allProjects.push({ id: project.gid, title: project.name }); + } + } + return allProjects; + } - // Request auth and return the activity link - return await this.tools.integrations.request( - { - provider: AuthProvider.Asana, - scopes: asanaScopes, - }, - this.onAuthSuccess, - callbackToken - ); + /** + * Handle syncable enabled + */ + async onSyncEnabled(syncable: Syncable): Promise { + await this.set(`sync_enabled_${syncable.id}`, true); } /** - * Handle successful OAuth authorization + * Handle syncable disabled */ - private async onAuthSuccess( - authorization: Authorization, - callbackToken: Callback - ): Promise { - // Execute the callback with the actor ID as auth token - await this.run(callbackToken, { authToken: authorization.actor.id as string }); + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + await this.clear(`sync_enabled_${syncable.id}`); } /** * Get list of Asana projects */ - async getProjects(authToken: string): Promise { - const client = await this.getClient(authToken); + async getProjects(projectId: string): Promise { + const client = await this.getClient(projectId); // Get user's workspaces first const workspaces = await client.workspaces.getWorkspaces(); @@ -153,16 +145,15 @@ export class Asana extends Tool implements ProjectTool { TCallback extends (task: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; projectId: string; } & ProjectSyncOptions, callback: TCallback, ...extraArgs: TArgs ): Promise { - const { authToken, projectId, timeMin } = options; + const { projectId, timeMin } = options; // Setup webhook for real-time updates - await this.setupAsanaWebhook(authToken, projectId); + await this.setupAsanaWebhook(projectId); // Store callback for webhook processing const callbackToken = await this.tools.callbacks.createFromParent( @@ -172,25 +163,23 @@ export class Asana extends Tool implements ProjectTool { await this.set(`callback_${projectId}`, callbackToken); // Start initial batch sync - await this.startBatchSync(authToken, projectId, { timeMin }); + await this.startBatchSync(projectId, { timeMin }); } /** * Setup Asana webhook for real-time updates */ private async setupAsanaWebhook( - authToken: string, projectId: string ): Promise { try { - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); // Create webhook URL first const webhookUrl = await this.tools.network.createWebhook( {}, this.onWebhook, - projectId, - authToken + projectId ); // Skip webhook setup for localhost (development mode) @@ -224,7 +213,6 @@ export class Asana extends Tool implements ProjectTool { * Initialize batch sync process */ private async startBatchSync( - authToken: string, projectId: string, options?: ProjectSyncOptions ): Promise { @@ -239,7 +227,6 @@ export class Asana extends Tool implements ProjectTool { // Queue first batch const batchCallback = await this.callback( this.syncBatch, - authToken, projectId, options ); @@ -251,7 +238,6 @@ export class Asana extends Tool implements ProjectTool { * Process a batch of tasks */ private async syncBatch( - authToken: string, projectId: string, options?: ProjectSyncOptions ): Promise { @@ -266,7 +252,7 @@ export class Asana extends Tool implements ProjectTool { throw new Error(`Callback token not found for project ${projectId}`); } - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); // Build request params const batchSize = 50; @@ -336,7 +322,6 @@ export class Asana extends Tool implements ProjectTool { // Queue next batch const nextBatch = await this.callback( this.syncBatch, - authToken, projectId, options ); @@ -430,17 +415,20 @@ export class Asana extends Tool implements ProjectTool { /** * Update task with new values * - * @param authToken - Authorization token * @param activity - The updated activity */ - async updateIssue(authToken: string, activity: Activity): Promise { - // Extract Asana task GID from meta + async updateIssue(activity: Activity): Promise { + // Extract Asana task GID and project ID from meta const taskGid = activity.meta?.taskGid as string | undefined; if (!taskGid) { throw new Error("Asana task GID not found in activity meta"); } + const projectId = activity.meta?.projectId as string | undefined; + if (!projectId) { + throw new Error("Asana project ID not found in activity meta"); + } - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); const updateFields: any = {}; // Handle title @@ -465,12 +453,10 @@ export class Asana extends Tool implements ProjectTool { /** * Add a comment (story) to an Asana task * - * @param authToken - Authorization token - * @param meta - Activity metadata containing taskGid + * @param meta - Activity metadata containing taskGid and projectId * @param body - Comment text (markdown not directly supported, plain text) */ async addIssueComment( - authToken: string, meta: ActivityMeta, body: string ): Promise { @@ -478,7 +464,11 @@ export class Asana extends Tool implements ProjectTool { if (!taskGid) { throw new Error("Asana task GID not found in activity meta"); } - const client = await this.getClient(authToken); + const projectId = meta.projectId as string | undefined; + if (!projectId) { + throw new Error("Asana project ID not found in activity meta"); + } + const client = await this.getClient(projectId); const result = await client.tasks.addComment(taskGid, { text: body, @@ -532,8 +522,7 @@ export class Asana extends Tool implements ProjectTool { */ private async onWebhook( request: WebhookRequest, - projectId: string, - authToken: string + projectId: string ): Promise { const payload = request.body as any; @@ -580,7 +569,6 @@ export class Asana extends Tool implements ProjectTool { await this.handleStoryWebhook( event, projectId, - authToken, callbackToken ); } else { @@ -588,7 +576,6 @@ export class Asana extends Tool implements ProjectTool { await this.handleTaskWebhook( event, projectId, - authToken, callbackToken ); } @@ -603,11 +590,10 @@ export class Asana extends Tool implements ProjectTool { private async handleTaskWebhook( event: any, projectId: string, - authToken: string, callbackToken: Callback ): Promise { - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); try { // Fetch only task metadata (no stories) @@ -692,11 +678,10 @@ export class Asana extends Tool implements ProjectTool { private async handleStoryWebhook( event: any, projectId: string, - authToken: string, callbackToken: Callback ): Promise { - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); const taskGid = event.resource.gid; try { @@ -766,12 +751,12 @@ export class Asana extends Tool implements ProjectTool { /** * Stop syncing an Asana project */ - async stopSync(authToken: string, projectId: string): Promise { + async stopSync(projectId: string): Promise { // Delete webhook const webhookId = await this.get(`webhook_id_${projectId}`); if (webhookId) { try { - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); await client.webhooks.deleteById(webhookId); } catch (error) { console.warn("Failed to delete Asana webhook:", error); diff --git a/tools/gmail/src/gmail.ts b/tools/gmail/src/gmail.ts index be712a1..d0d70cd 100644 --- a/tools/gmail/src/gmail.ts +++ b/tools/gmail/src/gmail.ts @@ -1,6 +1,4 @@ import { - type ActorId, - type ActivityLink, type NewActivityWithNotes, Serializable, Tool, @@ -9,14 +7,15 @@ import { import { type MessageChannel, type MessageSyncOptions, - type MessagingAuth, type MessagingTool, } from "@plotday/twister/common/messaging"; import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, + type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { @@ -37,6 +36,8 @@ import { * Gmail integration tool implementing the MessagingTool interface. * * Supports inbox, labels, and search filters as channels. + * Auth is managed declaratively via provider config in build() and + * handled through the twist edit modal. * * **Required OAuth Scopes:** * - `https://www.googleapis.com/auth/gmail.readonly` - Read emails @@ -52,31 +53,15 @@ import { * this.gmail = tools.get(Gmail); * } * - * async activate() { - * const authLink = await this.gmail.requestAuth(this.onGmailAuth); + * // Auth is handled via the twist edit modal. + * // When sync is enabled on a channel, onSyncEnabled fires and + * // the twist can start syncing: * - * await this.plot.createActivity({ - * type: ActivityType.Action, - * title: "Connect Gmail", - * links: [authLink] - * }); - * } - * - * async onGmailAuth(auth: MessagingAuth) { - * const channels = await this.gmail.getChannels(auth.authToken); - * - * // Start syncing inbox - * const inbox = channels.find(c => c.primary); - * if (inbox) { - * await this.gmail.startSync( - * auth.authToken, - * inbox.id, - * this.onGmailThread, - * { - * timeMin: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // Last 7 days - * } - * ); - * } + * async onGmailSyncEnabled(channelId: string) { + * await this.gmail.startSync( + * { channelId, timeMin: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + * this.onGmailThread + * ); * } * * async onGmailThread(thread: ActivityWithNotes) { @@ -94,9 +79,25 @@ import { * ``` */ export class Gmail extends Tool implements MessagingTool { + static readonly PROVIDER = AuthProvider.Google; + static readonly SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.modify", + ]; + build(build: ToolBuilder) { return { - integrations: build(Integrations), + integrations: build(Integrations, { + providers: [ + { + provider: Gmail.PROVIDER, + scopes: Gmail.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }, + ], + }), network: build(Network, { urls: ["https://gmail.googleapis.com/gmail/v1/*"], }), @@ -111,58 +112,40 @@ export class Gmail extends Tool implements MessagingTool { }; } - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: MessagingAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - // Gmail OAuth scopes for read-only access - const gmailScopes = [ - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.modify", - ]; - - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + async getSyncables(_auth: Authorization, token: AuthToken): Promise { + const api = new GmailApi(token.token); + const labels = await api.getLabels(); + return labels + .filter( + (l: any) => + l.type !== "system" || + ["INBOX", "SENT", "DRAFT", "IMPORTANT", "STARRED"].includes(l.id) + ) + .map((l: any) => ({ id: l.id, title: l.name })); + } - // Request auth and return the activity link - // Use User level for user-scoped Gmail authorization - return await this.tools.integrations.request( - { - provider: AuthProvider.Google, - scopes: gmailScopes, - }, - this.onAuthSuccess, - callbackToken - ); + async onSyncEnabled(syncable: Syncable): Promise { + await this.set(`sync_enabled_${syncable.id}`, true); } - private async getApi(authToken: string): Promise { - // Try new flow: authToken is an ActorId - const token = await this.tools.integrations.get(AuthProvider.Google, authToken as ActorId); - if (token) { - return new GmailApi(token.token); - } + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + await this.clear(`sync_enabled_${syncable.id}`); + } - // Fall back to legacy flow: authToken is an opaque key - const authorization = await this.get( - `authorization:${authToken}` + private async getApi(channelId: string): Promise { + const token = await this.tools.integrations.get( + Gmail.PROVIDER, + channelId ); - if (!authorization) { - throw new Error("Authorization no longer available"); - } - - const legacyToken = await this.tools.integrations.get(authorization.provider, authorization.actor.id); - if (!legacyToken) { - throw new Error("Authorization no longer available"); + if (!token) { + throw new Error("No Google authentication token available"); } - - return new GmailApi(legacyToken.token); + return new GmailApi(token.token); } - async getChannels(authToken: string): Promise { - const api = await this.getApi(authToken); + async getChannels(channelId: string): Promise { + const api = await this.getApi(channelId); const labels = await api.getLabels(); const channels: MessageChannel[] = []; @@ -203,13 +186,12 @@ export class Gmail extends Tool implements MessagingTool { TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; channelId: string; } & MessageSyncOptions, callback: TCallback, ...extraArgs: TArgs ): Promise { - const { authToken, channelId, timeMin } = options; + const { channelId, timeMin } = options; // Create callback token for parent const callbackToken = await this.tools.callbacks.createFromParent( @@ -218,11 +200,8 @@ export class Gmail extends Tool implements MessagingTool { ); await this.set(`thread_callback_token_${channelId}`, callbackToken); - // Store auth token for channel - await this.set(`auth_token_${channelId}`, authToken); - // Setup webhook for this channel (Gmail Push Notifications) - await this.setupChannelWebhook(authToken, channelId); + await this.setupChannelWebhook(channelId); const initialState: SyncState = { channelId, @@ -240,15 +219,14 @@ export class Gmail extends Tool implements MessagingTool { this.syncBatch, 1, "full", - authToken, channelId ); await this.run(syncCallback); } - async stopSync(authToken: string, channelId: string): Promise { + async stopSync(channelId: string): Promise { // Stop watching for push notifications - const api = await this.getApi(authToken); + const api = await this.getApi(channelId); try { await api.stopWatch(); } catch (error) { @@ -263,36 +241,18 @@ export class Gmail extends Tool implements MessagingTool { // Clear callback token await this.clear(`thread_callback_token_${channelId}`); - - // Clear auth token - await this.clear(`auth_token_${channelId}`); } - private async setupChannelWebhook( - authToken: string, - channelId: string - ): Promise { - // Retrieve the authorization for this auth token - const authorization = await this.get( - `authorization:${authToken}` - ); - if (!authorization) { - throw new Error("Authorization not found for Gmail webhook setup"); - } - + private async setupChannelWebhook(channelId: string): Promise { // Create Gmail webhook (returns Pub/Sub topic name, not a URL) // When provider is Google with Gmail scopes, createWebhook returns a Pub/Sub topic name const topicName = await this.tools.network.createWebhook( - { - provider: AuthProvider.Google, - authorization, - }, + {}, this.onGmailWebhook, - channelId, - authToken + channelId ); - const api = await this.getApi(authToken); + const api = await this.getApi(channelId); try { // Setup Gmail watch with the Pub/Sub topic name @@ -315,7 +275,6 @@ export class Gmail extends Tool implements MessagingTool { async syncBatch( batchNumber: number, mode: "full" | "incremental", - authToken: string, channelId: string ): Promise { try { @@ -324,13 +283,13 @@ export class Gmail extends Tool implements MessagingTool { throw new Error("No sync state found"); } - const api = await this.getApi(authToken); + const api = await this.getApi(channelId); // Use smaller batch size for Gmail (20 threads) to avoid timeouts const result = await syncGmailChannel(api, state, 20); if (result.threads.length > 0) { - await this.processEmailThreads(result.threads, channelId, authToken); + await this.processEmailThreads(result.threads, channelId); } await this.set(`sync_state_${channelId}`, result.state); @@ -340,7 +299,6 @@ export class Gmail extends Tool implements MessagingTool { this.syncBatch, batchNumber + 1, mode, - authToken, channelId ); await this.run(syncCallback); @@ -361,8 +319,7 @@ export class Gmail extends Tool implements MessagingTool { private async processEmailThreads( threads: GmailThread[], - channelId: string, - _authToken: string + channelId: string ): Promise { const callbackToken = await this.get( `thread_callback_token_${channelId}` @@ -391,8 +348,7 @@ export class Gmail extends Tool implements MessagingTool { async onGmailWebhook( request: WebhookRequest, - channelId: string, - authToken: string + channelId: string ): Promise { // Gmail sends push notifications via Cloud Pub/Sub // The message body is base64-encoded @@ -415,13 +371,12 @@ export class Gmail extends Tool implements MessagingTool { // Gmail notifications contain historyId for incremental sync if (data.historyId) { - await this.startIncrementalSync(channelId, authToken, data.historyId); + await this.startIncrementalSync(channelId, data.historyId); } } private async startIncrementalSync( channelId: string, - authToken: string, historyId: string ): Promise { const webhookData = await this.get(`channel_webhook_${channelId}`); @@ -433,7 +388,7 @@ export class Gmail extends Tool implements MessagingTool { // Check if watch has expired and renew if needed const expiration = new Date(webhookData.expiration); if (expiration < new Date()) { - await this.setupChannelWebhook(authToken, channelId); + await this.setupChannelWebhook(channelId); } // For incremental sync, use the historyId from the notification @@ -448,22 +403,10 @@ export class Gmail extends Tool implements MessagingTool { this.syncBatch, 1, "incremental", - authToken, channelId ); await this.run(syncCallback); } - - async onAuthSuccess( - authResult: Authorization, - callback: Callback - ): Promise { - const authSuccessResult: MessagingAuth = { - authToken: authResult.actor.id as string, - }; - - await this.run(callback, authSuccessResult); - } } export default Gmail; diff --git a/tools/google-calendar/src/google-api.ts b/tools/google-calendar/src/google-api.ts index 244dbdb..6fa3a32 100644 --- a/tools/google-calendar/src/google-api.ts +++ b/tools/google-calendar/src/google-api.ts @@ -68,8 +68,8 @@ export type SyncState = { calendarId: string; state?: string; more?: boolean; - min?: Date; - max?: Date; + min?: Date | null; + max?: Date | null; sequence?: number; }; diff --git a/tools/google-calendar/src/google-calendar.ts b/tools/google-calendar/src/google-calendar.ts index a688801..86fbe59 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/tools/google-calendar/src/google-calendar.ts @@ -1,8 +1,8 @@ import GoogleContacts from "@plotday/tool-google-contacts"; import { type Activity, - type ActivityLink, ActivityLinkType, + type ActivityLink, type ActivityOccurrence, ActivityType, type ActorId, @@ -19,15 +19,16 @@ import { } from "@plotday/twister"; import { type Calendar, - type CalendarAuth, type CalendarTool, type SyncOptions, } from "@plotday/twister/common/calendar"; import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, + type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { @@ -46,13 +47,6 @@ import { transformGoogleEvent, } from "./google-api"; -type PendingSyncItem = { - type: "rsvp"; - calendarId: string; - eventId: string; - status: "accepted" | "declined" | "tentative" | "needsAction"; - activityId: string; -}; /** * Google Calendar integration tool. @@ -125,6 +119,7 @@ export class GoogleCalendar extends Tool implements CalendarTool { + static readonly PROVIDER = AuthProvider.Google; static readonly SCOPES = [ "https://www.googleapis.com/auth/calendar.calendarlist.readonly", "https://www.googleapis.com/auth/calendar.events", @@ -132,7 +127,20 @@ export class GoogleCalendar build(build: ToolBuilder) { return { - integrations: build(Integrations), + integrations: build(Integrations, { + providers: [ + { + provider: GoogleCalendar.PROVIDER, + scopes: Integrations.MergeScopes( + GoogleCalendar.SCOPES, + GoogleContacts.SCOPES + ), + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }, + ], + }), network: build(Network, { urls: ["https://www.googleapis.com/calendar/*"], }), @@ -149,49 +157,38 @@ export class GoogleCalendar }; } - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: CalendarAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - // Combine calendar and contacts scopes for single OAuth flow - const combinedScopes = [...GoogleCalendar.SCOPES, ...GoogleContacts.SCOPES]; + /** + * Returns available calendars as syncable resources after authorization. + */ + async getSyncables(_auth: Authorization, token: AuthToken): Promise { + const api = new GoogleApi(token.token); + const calendars = await this.listCalendarsWithApi(api); + return calendars.map((c) => ({ id: c.id, title: c.name })); + } - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + /** + * Called when a syncable calendar is enabled for syncing. + */ + async onSyncEnabled(syncable: Syncable): Promise { + // Store the syncable ID for later use (e.g., webhook handling) + await this.set(`sync_enabled_${syncable.id}`, true); + } - // Request auth and return the activity link - return await this.tools.integrations.request( - { - provider: AuthProvider.Google, - scopes: combinedScopes, - }, - this.onAuthSuccess, - callbackToken - ); + /** + * Called when a syncable calendar is disabled. + */ + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + await this.clear(`sync_enabled_${syncable.id}`); } - private async getApi(authToken: string): Promise { - // Try new pattern: authToken is the actorId directly - let token = await this.tools.integrations.get( - AuthProvider.Google, - authToken as ActorId + private async getApi(calendarId: string): Promise { + // Get token for the syncable (calendar) from integrations + const token = await this.tools.integrations.get( + GoogleCalendar.PROVIDER, + calendarId ); - // Fall back to legacy opaque token pattern for existing callbacks - if (!token) { - const authorization = await this.get( - `authorization:${authToken}` - ); - if (authorization) { - token = await this.tools.integrations.get( - authorization.provider, - authorization.actor.id - ); - } - } - if (!token) { throw new Error("Authorization no longer available"); } @@ -199,8 +196,29 @@ export class GoogleCalendar return new GoogleApi(token.token); } - private async getUserEmail(authToken: string): Promise { - const api = await this.getApi(authToken); + private async listCalendarsWithApi(api: GoogleApi): Promise { + const data = (await api.call( + "GET", + "https://www.googleapis.com/calendar/v3/users/me/calendarList" + )) as { + items: Array<{ + id: string; + summary: string; + description?: string; + primary?: boolean; + }>; + }; + + return data.items.map((item) => ({ + id: item.id, + name: item.summary, + description: item.description || null, + primary: item.primary || false, + })); + } + + private async getUserEmail(calendarId: string): Promise { + const api = await this.getApi(calendarId); // Use the Calendar API's primary calendar to get the email const calendarList = (await api.call( @@ -211,7 +229,7 @@ export class GoogleCalendar return calendarList.id; // The primary calendar ID is the user's email } - private async ensureUserIdentity(authToken: string): Promise { + private async ensureUserIdentity(calendarId: string): Promise { // Check if we already have the user email stored const stored = await this.get("user_email"); if (stored) { @@ -219,7 +237,7 @@ export class GoogleCalendar } // Fetch user email from Google - const email = await this.getUserEmail(authToken); + const email = await this.getUserEmail(calendarId); // Store for future use await this.set("user_email", email); @@ -230,16 +248,13 @@ export class GoogleCalendar * Resolves "primary" calendar ID to the actual calendar ID (user's email). * Returns the calendarId unchanged if it's not "primary". */ - private async resolveCalendarId( - authToken: string, - calendarId: string - ): Promise { + private async resolveCalendarId(calendarId: string): Promise { if (calendarId !== "primary") { return calendarId; } // Get actual calendar ID from Google - const api = await this.getApi(authToken); + const api = await this.getApi(calendarId); const calendar = (await api.call( "GET", `https://www.googleapis.com/calendar/v3/calendars/primary` @@ -275,19 +290,15 @@ export class GoogleCalendar TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; calendarId: string; } & SyncOptions, callback: TCallback, ...extraArgs: TArgs ): Promise { - const { authToken, calendarId, timeMin, timeMax } = options; + const { calendarId, timeMin, timeMax } = options; // Resolve "primary" to actual calendar ID to ensure consistent storage keys - const resolvedCalendarId = await this.resolveCalendarId( - authToken, - calendarId - ); + const resolvedCalendarId = await this.resolveCalendarId(calendarId); // Check if sync is already in progress for this calendar const syncInProgress = await this.get( @@ -307,28 +318,21 @@ export class GoogleCalendar ); await this.set("event_callback_token", callbackToken); - // Store auth token for calendar for later RSVP updates - await this.set(`auth_token_${resolvedCalendarId}`, authToken); - // Setup webhook for this calendar - await this.setupCalendarWatch(authToken, resolvedCalendarId, authToken); + await this.setupCalendarWatch(resolvedCalendarId); // Determine sync range - let min: Date | undefined; + let min: Date | null; if (timeMin === null) { - // null means sync all history - min = undefined; + min = null; } else if (timeMin !== undefined) { - // User provided a specific minimum date min = timeMin; } else { - // Default to 2 years into the past const now = new Date(); min = new Date(now.getFullYear() - 2, 0, 1); } - // Handle timeMax (null means no limit, same as undefined) - let max: Date | undefined; + let max: Date | null = null; if (timeMax !== null && timeMax !== undefined) { max = timeMax; } @@ -342,62 +346,39 @@ export class GoogleCalendar await this.set(`sync_state_${resolvedCalendarId}`, initialState); - // Start sync batch using run tool for long-running operation + // Start sync batch const syncCallback = await this.callback( this.syncBatch, 1, "full", - authToken, resolvedCalendarId, - true // initialSync = true for initial sync + true // initialSync = true ); await this.runTask(syncCallback); } - async stopSync(_authToken: string, calendarId: string): Promise { - // Get auth token first so we can resolve the calendar ID - let authToken = await this.get(`auth_token_${calendarId}`); - - // Resolve calendar ID to handle "primary" alias - let resolvedCalendarId = calendarId; - if (authToken) { - try { - resolvedCalendarId = await this.resolveCalendarId( - authToken, - calendarId - ); - } catch (error) { - console.warn( - "Failed to resolve calendar ID, using provided value", - error - ); - } - } - + async stopSync(calendarId: string): Promise { // 1. Cancel scheduled renewal task const renewalTask = await this.get( - `watch_renewal_task_${resolvedCalendarId}` + `watch_renewal_task_${calendarId}` ); if (renewalTask) { await this.cancelTask(renewalTask); - await this.clear(`watch_renewal_task_${resolvedCalendarId}`); + await this.clear(`watch_renewal_task_${calendarId}`); } - // 2. Stop watch via Google API - if (authToken) { - try { - await this.stopCalendarWatch(authToken, resolvedCalendarId); - } catch (error) { - console.error("Failed to stop calendar watch:", error); - // Continue with cleanup even if API call fails - } + // 2. Stop watch via Google API (best effort) + try { + await this.stopCalendarWatch(calendarId); + } catch (error) { + console.error("Failed to stop calendar watch:", error); } // 3. Clear sync-related storage - await this.clear(`calendar_watch_${resolvedCalendarId}`); - await this.clear(`sync_state_${resolvedCalendarId}`); - await this.clear(`sync_lock_${resolvedCalendarId}`); - // NOTE: We keep auth_token_${resolvedCalendarId} so RSVP callbacks on existing activities continue to work + await this.clear(`calendar_watch_${calendarId}`); + await this.clear(`sync_state_${calendarId}`); + await this.clear(`sync_lock_${calendarId}`); + await this.clear(`auth_token_${calendarId}`); } /** @@ -407,15 +388,15 @@ export class GoogleCalendar * @private */ private async stopCalendarWatch( - authToken: string, - calendarId: string + calendarId: string, + existingApi?: GoogleApi ): Promise { const watchData = await this.get(`calendar_watch_${calendarId}`); if (!watchData) { return; } - const api = await this.getApi(authToken); + const api = existingApi ?? (await this.getApi(calendarId)); // Call Google Calendar API to stop the watch // https://developers.google.com/calendar/api/v3/reference/channels/stop @@ -479,15 +460,6 @@ export class GoogleCalendar */ private async renewCalendarWatch(calendarId: string): Promise { try { - // Get auth token - const authToken = await this.get(`auth_token_${calendarId}`); - if (!authToken) { - console.error( - `No auth token found for calendar ${calendarId}, cannot renew watch` - ); - return; - } - // Get existing watch data const oldWatchData = await this.get(`calendar_watch_${calendarId}`); if (!oldWatchData) { @@ -499,30 +471,23 @@ export class GoogleCalendar // Stop the old watch (best effort - don't fail if this errors) try { - await this.stopCalendarWatch(authToken, calendarId); + await this.stopCalendarWatch(calendarId); } catch (error) { console.warn(`Failed to stop old watch for ${calendarId}:`, error); - // Continue with renewal anyway } - // Create new watch (reuses existing webhook URL and callback) - await this.setupCalendarWatch(authToken, calendarId, authToken); + // Create new watch + await this.setupCalendarWatch(calendarId); } catch (error) { console.error(`Failed to renew watch for calendar ${calendarId}:`, error); - // Don't throw - let reactive checking handle it as fallback } } - private async setupCalendarWatch( - authToken: string, - calendarId: string, - opaqueAuthToken: string - ): Promise { + private async setupCalendarWatch(calendarId: string): Promise { const webhookUrl = await this.tools.network.createWebhook( {}, this.onCalendarWebhook, - calendarId, - opaqueAuthToken + calendarId ); // Check if webhook URL is localhost @@ -531,7 +496,7 @@ export class GoogleCalendar } try { - const api = await this.getApi(authToken); + const api = await this.getApi(calendarId); // Setup watch for calendar const watchId = crypto.randomUUID(); @@ -571,24 +536,21 @@ export class GoogleCalendar async syncBatch( batchNumber: number, mode: "full" | "incremental", - authToken: string, calendarId: string, initialSync: boolean ): Promise { try { // Ensure we have the user's identity for RSVP tagging if (batchNumber === 1) { - await this.ensureUserIdentity(authToken); + await this.ensureUserIdentity(calendarId); } const state = await this.get(`sync_state_${calendarId}`); if (!state) { - // Check if sync lock is also cleared - if so, sync completed normally const syncLock = await this.get(`sync_lock_${calendarId}`); if (!syncLock) { - // Both state and lock are cleared - sync completed normally, this is a stale callback + // Both state and lock are cleared - sync completed normally, stale callback } else { - // State missing but lock still set - sync may have been superseded console.warn( `No sync state found for calendar ${calendarId}, sync may have been superseded` ); @@ -601,8 +563,11 @@ export class GoogleCalendar if (state.min && typeof state.min === "string") { state.min = new Date(state.min); } + if (state.max && typeof state.max === "string") { + state.max = new Date(state.max); + } - const api = await this.getApi(authToken); + const api = await this.getApi(calendarId); const result = await syncGoogleCalendar(api, calendarId, state); if (result.events.length > 0) { @@ -620,9 +585,8 @@ export class GoogleCalendar this.syncBatch, batchNumber + 1, mode, - authToken, calendarId, - initialSync // Pass through the initialSync boolean + initialSync ); await this.runTask(syncCallback); } else { @@ -999,11 +963,8 @@ export class GoogleCalendar async onCalendarWebhook( request: WebhookRequest, - calendarId: string, - authToken: string + calendarId: string ): Promise { - // Validate webhook authenticity - // Headers are normalized to lowercase by HTTP standards const channelId = request.headers["x-goog-channel-id"]; const channelToken = request.headers["x-goog-channel-token"]; @@ -1026,14 +987,13 @@ export class GoogleCalendar return; } - // Reactive expiry check (backup for missed scheduled renewal) + // Reactive expiry check const expiration = new Date(watchData.expiry); const now = new Date(); const hoursUntilExpiry = (expiration.getTime() - now.getTime()) / (1000 * 60 * 60); if (hoursUntilExpiry < 24) { - // Don't await - let renewal happen async (don't block webhook) this.renewCalendarWatch(calendarId).catch((error) => { console.error( `Failed to reactively renew watch for ${calendarId}:`, @@ -1042,15 +1002,10 @@ export class GoogleCalendar }); } - // Trigger incremental sync - await this.startIncrementalSync(calendarId, authToken); + await this.startIncrementalSync(calendarId); } - private async startIncrementalSync( - calendarId: string, - authToken: string - ): Promise { - // Check if initial sync is still in progress + private async startIncrementalSync(calendarId: string): Promise { const syncInProgress = await this.get(`sync_lock_${calendarId}`); if (syncInProgress) { return; @@ -1070,9 +1025,8 @@ export class GoogleCalendar state: syncToken, } : { - // No stored sync token - fall back to recent time window calendarId: watchData.calendarId, - min: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Last 7 days + min: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), sequence: 1, }; @@ -1081,64 +1035,12 @@ export class GoogleCalendar this.syncBatch, 1, "incremental", - authToken, calendarId, - false // initialSync = false for incremental updates + false ); await this.runTask(syncCallback); } - async onAuthSuccess( - authResult: Authorization, - callbackToken: Callback - ): Promise { - // Use actor ID as the auth token — integrations.get(provider, actorId) is all we need - const actorId = authResult.actor.id as string; - - // Trigger contacts sync with the same authorization - // This happens automatically when calendar auth succeeds - try { - const token = await this.tools.integrations.get( - authResult.provider, - authResult.actor.id - ); - if (token) { - await this.tools.googleContacts.syncWithAuth( - authResult, - token, - this.onContactsSynced - ); - } else { - console.error("Failed to retrieve auth token for contacts sync"); - } - } catch (error) { - // Log error but don't fail calendar auth - console.error("Failed to start contacts sync:", error); - } - - const authSuccessResult: CalendarAuth = { - authToken: actorId, - }; - - await this.run(callbackToken, authSuccessResult); - } - - /** - * Callback invoked when contacts are synced from Google Contacts. - * Adds the synced contacts to Plot for enriching calendar event attendees. - */ - async onContactsSynced(contacts: NewContact[]): Promise { - if (contacts.length === 0) { - return; - } - - try { - await this.tools.plot.addContacts(contacts); - } catch (error) { - console.error("Failed to add contacts to Plot:", error); - } - } - /** * Constructs a Google Calendar instance ID for a recurring event occurrence. * @param baseEventId - The recurring event ID @@ -1286,40 +1188,20 @@ export class GoogleCalendar ); } - // For each actor who changed RSVP, try to sync with their credentials + // For each actor who changed RSVP, use actAs() to sync with their credentials. + // If the actor has auth, the callback fires immediately. + // If not, actAs() creates a private auth note automatically. for (const actorId of actorIds) { - const token = await this.tools.integrations.get( - AuthProvider.Google, - actorId + await this.tools.integrations.actAs( + GoogleCalendar.PROVIDER, + actorId, + activity.id, + this.syncActorRSVP, + calendarId as string, + eventId, + newStatus, + actorId as string ); - - if (token) { - // Actor has auth — sync their RSVP directly - try { - const api = new GoogleApi(token.token); - await this.updateEventRSVPWithApi( - api, - calendarId, - eventId, - newStatus, - actorId - ); - } catch (error) { - console.error("[RSVP Sync] Failed to update RSVP for actor", { - actor_id: actorId, - error: error instanceof Error ? error.message : String(error), - }); - } - } else { - // Actor lacks auth — queue pending sync and create auth request - await this.queuePendingSync(actorId, { - type: "rsvp", - calendarId, - eventId, - status: newStatus, - activityId: activity.id, - }); - } } } catch (error) { console.error("[RSVP Sync] Error in callback", { @@ -1331,99 +1213,32 @@ export class GoogleCalendar } /** - * Queue a pending write-back action for an unauthenticated actor. - * Creates a private auth-request activity if one doesn't exist (deduped by source). + * Sync RSVP for an actor. If the actor has auth, this is called immediately. + * If not, actAs() creates a private auth note and calls this when they authorize. */ - private async queuePendingSync( - actorId: ActorId, - action: PendingSyncItem - ): Promise { - // Add to pending queue - const key = `pending_sync:${actorId}`; - const pending = (await this.get(key)) || []; - pending.push(action); - await this.set(key, pending); - - // Create auth-request activity (upsert by source to dedup) - const authLink = await this.tools.integrations.request( - { - provider: AuthProvider.Google, - scopes: GoogleCalendar.SCOPES, - }, - this.onActorAuth, - actorId as string - ); - - await this.tools.plot.createActivity({ - source: `auth:${actorId}`, - type: ActivityType.Action, - title: "Connect your Google Calendar", - private: true, - start: new Date(), - end: null, - notes: [ - { - content: - "To sync your RSVP responses, please connect your Google Calendar.", - links: [authLink], - mentions: [{ id: actorId }], - }, - ], - }); - } - - /** - * Callback for when an additional user authorizes. - * Applies any pending write-back actions. Does NOT start a sync. - */ - async onActorAuth( - authorization: Authorization, - actorIdStr: string + async syncActorRSVP( + token: AuthToken, + calendarId: string, + eventId: string, + status: "accepted" | "declined" | "tentative" | "needsAction", + actorId: string ): Promise { - const actorId = actorIdStr as ActorId; - const key = `pending_sync:${actorId}`; - const pending = await this.get(key); - - if (!pending || pending.length === 0) { - return; - } - - const token = await this.tools.integrations.get( - authorization.provider, - authorization.actor.id - ); - if (!token) { - console.error("[RSVP Sync] Failed to get token after actor auth", { + try { + const api = new GoogleApi(token.token); + await this.updateEventRSVPWithApi( + api, + calendarId, + eventId, + status, + actorId as ActorId + ); + } catch (error) { + console.error("[RSVP Sync] Failed to sync RSVP", { actor_id: actorId, + event_id: eventId, + error: error instanceof Error ? error.message : String(error), }); - return; } - - const api = new GoogleApi(token.token); - - // Apply pending write-backs - for (const item of pending) { - if (item.type === "rsvp") { - try { - await this.updateEventRSVPWithApi( - api, - item.calendarId, - item.eventId, - item.status, - actorId - ); - } catch (error) { - console.error("[RSVP Sync] Failed to apply pending RSVP", { - actor_id: actorId, - event_id: item.eventId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - } - - // Clear pending queue - await this.clear(key); } /** diff --git a/tools/google-contacts/src/google-contacts.ts b/tools/google-contacts/src/google-contacts.ts index e33b8ff..d8d39e4 100644 --- a/tools/google-contacts/src/google-contacts.ts +++ b/tools/google-contacts/src/google-contacts.ts @@ -1,5 +1,4 @@ import { - type ActorId, type NewContact, Serializable, Tool, @@ -11,10 +10,11 @@ import { type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network } from "@plotday/twister/tools/network"; -import type { ContactAuth, GoogleContacts as IGoogleContacts } from "./types"; +import type { GoogleContacts as IGoogleContacts } from "./types"; type ContactTokens = { connections?: { @@ -256,6 +256,8 @@ export default class GoogleContacts { static readonly id = "google-contacts"; + static readonly PROVIDER = AuthProvider.Google; + static readonly SCOPES = [ "https://www.googleapis.com/auth/contacts.readonly", "https://www.googleapis.com/auth/contacts.other.readonly", @@ -263,50 +265,46 @@ export default class GoogleContacts build(build: ToolBuilder) { return { - integrations: build(Integrations), + integrations: build(Integrations, { + providers: [{ + provider: GoogleContacts.PROVIDER, + scopes: GoogleContacts.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }], + }), network: build(Network, { urls: ["https://people.googleapis.com/*"], }), }; } - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: ContactAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + async getSyncables(_auth: Authorization, _token: AuthToken): Promise { + return [{ id: "contacts", title: "Contacts" }]; + } - // Request auth and return the activity link - return await this.tools.integrations.request( - { - provider: AuthProvider.Google, - scopes: GoogleContacts.SCOPES, - }, - this.onAuthSuccess, - callbackToken - ); + async onSyncEnabled(_syncable: Syncable): Promise { + // Syncable is now enabled; sync will start when startSync is called } - async getContacts(authToken: string): Promise { - let storedAuthToken = await this.tools.integrations.get( - AuthProvider.Google, - authToken as ActorId + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + } + + async getContacts(syncableId: string): Promise { + const token = await this.tools.integrations.get( + GoogleContacts.PROVIDER, + syncableId ); - if (!storedAuthToken) { - storedAuthToken = await this.get(`auth_token:${authToken}`); - } - if (!storedAuthToken) { + if (!token) { throw new Error( - "No Google authentication token available for the provided authToken" + "No Google authentication token available for the provided syncableId" ); } - const api = new GoogleApi(storedAuthToken.token); - const result = await getGoogleContacts(api, storedAuthToken.scopes, { + const api = new GoogleApi(token.token); + const result = await getGoogleContacts(api, token.scopes, { more: false, }); @@ -316,17 +314,14 @@ export default class GoogleContacts async startSync< TArgs extends Serializable[], TCallback extends (contacts: NewContact[], ...args: TArgs) => any - >(authToken: string, callback: TCallback, ...extraArgs: TArgs): Promise { - let storedAuthToken = await this.tools.integrations.get( - AuthProvider.Google, - authToken as ActorId + >(syncableId: string, callback: TCallback, ...extraArgs: TArgs): Promise { + const token = await this.tools.integrations.get( + GoogleContacts.PROVIDER, + syncableId ); - if (!storedAuthToken) { - storedAuthToken = await this.get(`auth_token:${authToken}`); - } - if (!storedAuthToken) { + if (!token) { throw new Error( - "No Google authentication token available for the provided authToken" + "No Google authentication token available for the provided syncableId" ); } @@ -335,128 +330,65 @@ export default class GoogleContacts callback, ...extraArgs ); - await this.set(`contacts_callback_token:${authToken}`, callbackToken); + await this.set(`contacts_callback_token:${syncableId}`, callbackToken); // Start initial sync const initialState: ContactSyncState = { more: false, }; - await this.set(`sync_state:${authToken}`, initialState); + await this.set(`sync_state:${syncableId}`, initialState); // Start sync batch using run tool for long-running operation - const syncCallback = await this.callback(this.syncBatch, 1, authToken); + const syncCallback = await this.callback(this.syncBatch, 1, syncableId); await this.run(syncCallback); } - /** - * Start contact sync using an existing Authorization and AuthToken from another tool. - * This enables other Google tools (like calendar) to trigger contact syncing - * after they've obtained auth with combined scopes. - * - * @param authorization - Authorization object containing provider and scopes - * @param authToken - Actual auth token data retrieved by the calling tool - * @param callback - Optional callback to invoke with synced contacts - * @param extraArgs - Additional arguments to pass to the callback - */ - async syncWithAuth< - TArgs extends Serializable[], - TCallback extends (contacts: NewContact[], ...args: TArgs) => any - >( - authorization: Authorization, - authToken: AuthToken, - callback?: TCallback, - ...extraArgs: TArgs - ): Promise { - // Validate authorization has required contacts scopes - const hasRequiredScopes = GoogleContacts.SCOPES.every((scope) => - authorization.scopes.includes(scope) - ); - - if (!hasRequiredScopes) { - throw new Error( - `Authorization missing required contacts scopes. Required: ${GoogleContacts.SCOPES.join( - ", " - )}. Got: ${authorization.scopes.join(", ")}` - ); - } - - // Use actor ID as the auth token identifier - const authTokenId = authorization.actor.id as string; - - // Store the auth token data (passed directly from caller) - await this.set(`auth_token:${authTokenId}`, authToken); - - // Setup callback if provided - if (callback) { - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`contacts_callback_token:${authTokenId}`, callbackToken); - } - - // Initialize sync state - const initialState: ContactSyncState = { - more: false, - }; - await this.set(`sync_state:${authTokenId}`, initialState); - - // Start sync batch - const syncCallback = await this.callback(this.syncBatch, 1, authTokenId); - await this.runTask(syncCallback); - } - - async stopSync(authToken: string): Promise { - // Clear sync state for this specific auth token - await this.clear(`sync_state:${authToken}`); - await this.clear(`contacts_callback_token:${authToken}`); + async stopSync(syncableId: string): Promise { + // Clear sync state for this specific syncable + await this.clear(`sync_state:${syncableId}`); + await this.clear(`contacts_callback_token:${syncableId}`); } - async syncBatch(batchNumber: number, authToken: string): Promise { + async syncBatch(batchNumber: number, syncableId: string): Promise { try { - let storedAuthToken = await this.tools.integrations.get( - AuthProvider.Google, - authToken as ActorId + const token = await this.tools.integrations.get( + GoogleContacts.PROVIDER, + syncableId ); - if (!storedAuthToken) { - storedAuthToken = await this.get( - `auth_token:${authToken}` - ); - } - if (!storedAuthToken) { + if (!token) { throw new Error( - "No authentication token available for the provided authToken" + "No authentication token available for the provided syncableId" ); } - const state = await this.get(`sync_state:${authToken}`); + const state = await this.get(`sync_state:${syncableId}`); if (!state) { throw new Error("No sync state found"); } - const api = new GoogleApi(storedAuthToken.token); + const api = new GoogleApi(token.token); const result = await getGoogleContacts( api, - storedAuthToken.scopes, + token.scopes, state ); if (result.contacts.length > 0) { - await this.processContacts(result.contacts, authToken); + await this.processContacts(result.contacts, syncableId); } - await this.set(`sync_state:${authToken}`, result.state); + await this.set(`sync_state:${syncableId}`, result.state); if (result.state.more) { const nextCallback = await this.callback( this.syncBatch, batchNumber + 1, - authToken + syncableId ); await this.run(nextCallback); } else { - await this.clear(`sync_state:${authToken}`); + await this.clear(`sync_state:${syncableId}`); } } catch (error) { console.error(`Error in sync batch ${batchNumber}:`, error); @@ -467,24 +399,13 @@ export default class GoogleContacts private async processContacts( contacts: NewContact[], - authToken: string + syncableId: string ): Promise { const callbackToken = await this.get( - `contacts_callback_token:${authToken}` + `contacts_callback_token:${syncableId}` ); if (callbackToken) { await this.run(callbackToken, contacts); } } - - async onAuthSuccess( - authResult: Authorization, - callbackToken: Callback - ): Promise { - const authSuccessResult: ContactAuth = { - authToken: authResult.actor.id as string, - }; - - await this.run(callbackToken, authSuccessResult); - } } diff --git a/tools/google-contacts/src/types.ts b/tools/google-contacts/src/types.ts index 45f29be..6872a8f 100644 --- a/tools/google-contacts/src/types.ts +++ b/tools/google-contacts/src/types.ts @@ -1,22 +1,13 @@ -import type { ActivityLink, ITool, NewContact } from "@plotday/twister"; - -export type ContactAuth = { - authToken: string; -}; +import type { ITool, NewContact } from "@plotday/twister"; export interface GoogleContacts extends ITool { - requestAuth any>( - callback: TCallback, - ...extraArgs: any[] - ): Promise; - - getContacts(authToken: string): Promise; + getContacts(syncableId: string): Promise; startSync any>( - authToken: string, + syncableId: string, callback: TCallback, ...extraArgs: any[] ): Promise; - stopSync(authToken: string): Promise; + stopSync(syncableId: string): Promise; } diff --git a/tools/google-drive/src/google-drive.ts b/tools/google-drive/src/google-drive.ts index 623e1b3..41f56f4 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/tools/google-drive/src/google-drive.ts @@ -4,7 +4,6 @@ import { ActivityLinkType, ActivityKind, ActivityType, - type ActorId, type NewActivityWithNotes, type NewContact, type NewNote, @@ -14,7 +13,6 @@ import { type ToolBuilder, } from "@plotday/twister"; import { - type DocumentAuth, type DocumentFolder, type DocumentSyncOptions, type DocumentTool, @@ -22,8 +20,10 @@ import { import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, + type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { @@ -66,13 +66,27 @@ export class GoogleDrive extends Tool implements DocumentTool { + static readonly PROVIDER = AuthProvider.Google; static readonly SCOPES = [ "https://www.googleapis.com/auth/drive", ]; build(build: ToolBuilder) { return { - integrations: build(Integrations), + integrations: build(Integrations, { + providers: [ + { + provider: GoogleDrive.PROVIDER, + scopes: Integrations.MergeScopes( + GoogleDrive.SCOPES, + GoogleContacts.SCOPES + ), + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }, + ], + }), network: build(Network, { urls: ["https://www.googleapis.com/drive/*"], }), @@ -85,49 +99,37 @@ export class GoogleDrive }; } - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: DocumentAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - // Combine drive and contacts scopes for single OAuth flow - const combinedScopes = [...GoogleDrive.SCOPES, ...GoogleContacts.SCOPES]; + /** + * Returns available Google Drive folders as syncable resources. + */ + async getSyncables(_auth: Authorization, token: AuthToken): Promise { + const api = new GoogleApi(token.token); + const files = await listFolders(api); + return files.map((f) => ({ id: f.id, title: f.name })); + } - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + /** + * Called when a syncable folder is enabled for syncing. + */ + async onSyncEnabled(syncable: Syncable): Promise { + await this.set(`sync_enabled_${syncable.id}`, true); + } - // Request auth and return the activity link - return await this.tools.integrations.request( - { - provider: AuthProvider.Google, - scopes: combinedScopes, - }, - this.onAuthSuccess, - callbackToken - ); + /** + * Called when a syncable folder is disabled. + */ + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + await this.clear(`sync_enabled_${syncable.id}`); } - private async getApi(authToken: string): Promise { - // Try new pattern: authToken is the actorId directly - let token = await this.tools.integrations.get( - AuthProvider.Google, - authToken as ActorId + private async getApi(folderId: string): Promise { + // Get token for the syncable (folder) from integrations + const token = await this.tools.integrations.get( + GoogleDrive.PROVIDER, + folderId ); - // Fall back to legacy opaque token pattern for existing callbacks - if (!token) { - const authorization = await this.get( - `authorization:${authToken}` - ); - if (authorization) { - token = await this.tools.integrations.get( - authorization.provider, - authorization.actor.id - ); - } - } - if (!token) { throw new Error("Authorization no longer available"); } @@ -135,11 +137,10 @@ export class GoogleDrive return new GoogleApi(token.token); } - async getFolders(authToken: string): Promise { - const api = await this.getApi(authToken); + async getFolders(folderId: string): Promise { + const api = await this.getApi(folderId); const files = await listFolders(api); - // Determine root folders (those without parents or with "My Drive" as parent) return files.map((file) => ({ id: file.id, name: file.name, @@ -153,13 +154,12 @@ export class GoogleDrive TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; folderId: string; } & DocumentSyncOptions, callback: TCallback, ...extraArgs: TArgs ): Promise { - const { authToken, folderId } = options; + const { folderId } = options; // Check if sync is already in progress for this folder const syncInProgress = await this.get( @@ -179,11 +179,8 @@ export class GoogleDrive ); await this.set(`callback_${folderId}`, callbackToken); - // Store auth token for this folder - await this.set(`auth_token_${folderId}`, authToken); - // Get changes start token for future incremental syncs - const api = await this.getApi(authToken); + const api = await this.getApi(folderId); const changesToken = await getChangesStartToken(api); const initialState: SyncState = { @@ -196,20 +193,19 @@ export class GoogleDrive await this.set(`sync_state_${folderId}`, initialState); // Setup webhook for change notifications - await this.setupDriveWatch(authToken, folderId); + await this.setupDriveWatch(folderId); // Start initial sync batch const syncCallback = await this.callback( this.syncBatch, 1, - authToken, folderId, true // initialSync ); await this.runTask(syncCallback); } - async stopSync(_authToken: string, folderId: string): Promise { + async stopSync(folderId: string): Promise { // Cancel scheduled renewal task const renewalTask = await this.get( `watch_renewal_task_${folderId}` @@ -220,13 +216,10 @@ export class GoogleDrive } // Stop watch via Google API - const authToken = await this.get(`auth_token_${folderId}`); - if (authToken) { - try { - await this.stopDriveWatch(authToken, folderId); - } catch (error) { - console.error("Failed to stop drive watch:", error); - } + try { + await this.stopDriveWatch(folderId); + } catch (error) { + console.error("Failed to stop drive watch:", error); } // Clear sync-related storage @@ -234,99 +227,52 @@ export class GoogleDrive await this.clear(`sync_state_${folderId}`); await this.clear(`sync_lock_${folderId}`); await this.clear(`callback_${folderId}`); - await this.clear(`auth_token_${folderId}`); } async addDocumentComment( - authToken: string, meta: Record, body: string, _noteId?: string ): Promise { const fileId = meta.fileId as string | undefined; - if (!fileId) { - console.warn("No fileId in activity meta, cannot add comment"); + const folderId = meta.folderId as string | undefined; + if (!fileId || !folderId) { + console.warn("No fileId/folderId in activity meta, cannot add comment"); return; } - const api = await this.getApi(authToken); + const api = await this.getApi(folderId); const comment = await createComment(api, fileId, body); return `comment-${comment.id}`; } async addDocumentReply( - authToken: string, meta: Record, commentId: string, body: string, _noteId?: string ): Promise { const fileId = meta.fileId as string | undefined; - if (!fileId) { - console.warn("No fileId in activity meta, cannot add reply"); + const folderId = meta.folderId as string | undefined; + if (!fileId || !folderId) { + console.warn("No fileId/folderId in activity meta, cannot add reply"); return; } - const api = await this.getApi(authToken); + const api = await this.getApi(folderId); const reply = await createReply(api, fileId, commentId, body); return `reply-${commentId}-${reply.id}`; } - // --- Auth --- - - async onAuthSuccess( - authResult: Authorization, - callbackToken: Callback - ): Promise { - const actorId = authResult.actor.id as string; - - // Trigger contacts sync with the same authorization - try { - const token = await this.tools.integrations.get( - authResult.provider, - authResult.actor.id - ); - if (token) { - await this.tools.googleContacts.syncWithAuth( - authResult, - token, - this.onContactsSynced - ); - } - } catch (error) { - console.error("Failed to start contacts sync:", error); - } - - const authSuccessResult: DocumentAuth = { - authToken: actorId, - }; - - await this.run(callbackToken, authSuccessResult); - } - - async onContactsSynced(contacts: NewContact[]): Promise { - if (contacts.length === 0) { - return; - } - - try { - await this.tools.plot.addContacts(contacts); - } catch (error) { - console.error("Failed to add contacts to Plot:", error); - } - } - // --- Webhooks --- private async setupDriveWatch( - authToken: string, folderId: string ): Promise { const webhookUrl = await this.tools.network.createWebhook( {}, this.onDriveWebhook, - folderId, - authToken + folderId ); // Skip webhook setup for localhost (local development) @@ -335,7 +281,7 @@ export class GoogleDrive } try { - const api = await this.getApi(authToken); + const api = await this.getApi(folderId); const watchId = crypto.randomUUID(); // Watch for changes using the Drive changes API @@ -372,7 +318,6 @@ export class GoogleDrive } private async stopDriveWatch( - authToken: string, folderId: string ): Promise { const watchData = await this.get(`drive_watch_${folderId}`); @@ -380,7 +325,7 @@ export class GoogleDrive return; } - const api = await this.getApi(authToken); + const api = await this.getApi(folderId); await api.call( "POST", "https://www.googleapis.com/drive/v3/channels/stop", @@ -414,7 +359,6 @@ export class GoogleDrive console.log(`[GDRIVE] scheduleWatchRenewal: expiry=${expiry.toISOString()}, renewalTime=${renewalTime.toISOString()}`); // Always schedule as a task to avoid recursive loops - // (renewDriveWatch -> setupDriveWatch -> scheduleWatchRenewal -> renewDriveWatch) const renewalCallback = await this.callback( this.renewDriveWatch, folderId @@ -432,19 +376,13 @@ export class GoogleDrive private async renewDriveWatch(folderId: string): Promise { console.log(`[GDRIVE] renewDriveWatch called for folder ${folderId}`); try { - const authToken = await this.get(`auth_token_${folderId}`); - if (!authToken) { - console.error(`No auth token found for folder ${folderId}`); - return; - } - try { - await this.stopDriveWatch(authToken, folderId); + await this.stopDriveWatch(folderId); } catch { // Expected if old watch already expired } - await this.setupDriveWatch(authToken, folderId); + await this.setupDriveWatch(folderId); } catch (error) { console.error(`Failed to renew watch for folder ${folderId}:`, error); } @@ -452,8 +390,7 @@ export class GoogleDrive async onDriveWebhook( _request: WebhookRequest, - folderId: string, - authToken: string + folderId: string ): Promise { console.log(`[GDRIVE] Webhook received for folder ${folderId}`); const watchData = await this.get(`drive_watch_${folderId}`); @@ -463,12 +400,11 @@ export class GoogleDrive } // Trigger incremental sync - await this.startIncrementalSync(folderId, authToken); + await this.startIncrementalSync(folderId); } private async startIncrementalSync( - folderId: string, - authToken: string + folderId: string ): Promise { // Check if initial sync is still in progress const syncInProgress = await this.get(`sync_lock_${folderId}`); @@ -491,7 +427,6 @@ export class GoogleDrive const syncCallback = await this.callback( this.incrementalSyncBatch, - authToken, folderId, state.changesToken ); @@ -502,7 +437,6 @@ export class GoogleDrive async syncBatch( batchNumber: number, - authToken: string, folderId: string, initialSync: boolean ): Promise { @@ -517,7 +451,7 @@ export class GoogleDrive return; } - const api = await this.getApi(authToken); + const api = await this.getApi(folderId); const result = await listFilesInFolder(api, folderId, state.pageToken); // Process files in this batch @@ -551,7 +485,6 @@ export class GoogleDrive const syncCallback = await this.callback( this.syncBatch, batchNumber + 1, - authToken, folderId, initialSync ); @@ -574,12 +507,11 @@ export class GoogleDrive } async incrementalSyncBatch( - authToken: string, folderId: string, changesToken: string ): Promise { try { - const api = await this.getApi(authToken); + const api = await this.getApi(folderId); const result = await listChanges(api, changesToken); console.log(`[GDRIVE] Incremental sync: ${result.changes.length} changes, token=${changesToken.substring(0, 20)}...`); @@ -625,7 +557,6 @@ export class GoogleDrive // More change pages const syncCallback = await this.callback( this.incrementalSyncBatch, - authToken, folderId, result.nextPageToken ); diff --git a/tools/jira/src/jira.ts b/tools/jira/src/jira.ts index d68cc13..19e3911 100644 --- a/tools/jira/src/jira.ts +++ b/tools/jira/src/jira.ts @@ -5,7 +5,6 @@ import { type ActivityLink, ActivityLinkType, ActivityType, - type ActorId, type NewActivity, type NewActivityWithNotes, NewContact, @@ -13,7 +12,6 @@ import { } from "@plotday/twister"; import type { Project, - ProjectAuth, ProjectSyncOptions, ProjectTool, } from "@plotday/twister/common/projects"; @@ -21,8 +19,10 @@ import { Tool, type ToolBuilder } from "@plotday/twister/tool"; import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; import { AuthProvider, + type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; @@ -42,9 +42,20 @@ type SyncState = { * with Plot activities. */ export class Jira extends Tool implements ProjectTool { + static readonly PROVIDER = AuthProvider.Atlassian; + static readonly SCOPES = ["read:jira-work", "write:jira-work", "read:jira-user"]; + build(build: ToolBuilder) { return { - integrations: build(Integrations), + integrations: build(Integrations, { + providers: [{ + provider: Jira.PROVIDER, + scopes: Jira.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }], + }), network: build(Network, { urls: ["https://*.atlassian.net/*"] }), callbacks: build(Callbacks), tasks: build(Tasks), @@ -53,34 +64,17 @@ export class Jira extends Tool implements ProjectTool { } /** - * Create Jira API client with auth token + * Create Jira API client using syncable-based auth */ - private async getClient(authToken: string): Promise { - // Try new pattern first (authToken is an ActorId) - let token = await this.tools.integrations.get(AuthProvider.Atlassian, authToken as ActorId); - - // Fall back to legacy authorization lookup - if (!token) { - const authorization = await this.get( - `authorization:${authToken}` - ); - if (!authorization) { - throw new Error("Authorization no longer available"); - } - - token = await this.tools.integrations.get(authorization.provider, authorization.actor.id); - } - + private async getClient(projectId: string): Promise { + const token = await this.tools.integrations.get(Jira.PROVIDER, projectId); if (!token) { - throw new Error("Authorization no longer available"); + throw new Error("No Jira authentication token available"); } - - // Get the cloud ID from provider metadata const cloudId = token.provider?.cloud_id; if (!cloudId) { throw new Error("Jira cloud ID not found in authorization"); } - return new Version3Client({ host: `https://api.atlassian.com/ex/jira/${cloudId}`, authentication: { @@ -92,46 +86,44 @@ export class Jira extends Tool implements ProjectTool { } /** - * Request Jira OAuth authorization + * Returns available Jira projects as syncable resources. */ - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: ProjectAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - const jiraScopes = ["read:jira-work", "write:jira-work", "read:jira-user"]; - - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + async getSyncables(_auth: Authorization, token: AuthToken): Promise { + const cloudId = token.provider?.cloud_id; + if (!cloudId) { + throw new Error("No Jira cloud ID in authorization"); + } + const client = new Version3Client({ + host: `https://api.atlassian.com/ex/jira/${cloudId}`, + authentication: { oauth2: { accessToken: token.token } }, + }); + const projects = await client.projects.searchProjects({ maxResults: 100 }); + return (projects.values || []).map((p) => ({ + id: p.id, + title: p.name, + })); + } - // Request auth and return the activity link - return await this.tools.integrations.request( - { - provider: AuthProvider.Atlassian, - scopes: jiraScopes, - }, - this.onAuthSuccess, - callbackToken - ); + /** + * Handle syncable resource being enabled + */ + async onSyncEnabled(syncable: Syncable): Promise { + await this.set(`sync_enabled_${syncable.id}`, true); } /** - * Handle successful OAuth authorization + * Handle syncable resource being disabled */ - private async onAuthSuccess( - authorization: Authorization, - callbackToken: Callback - ): Promise { - // Execute the callback with the auth token (actor ID) - await this.run(callbackToken, { authToken: authorization.actor.id as string }); + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + await this.clear(`sync_enabled_${syncable.id}`); } /** * Get list of Jira projects */ - async getProjects(authToken: string): Promise { - const client = await this.getClient(authToken); + async getProjects(projectId: string): Promise { + const client = await this.getClient(projectId); // Get all projects the user has access to const projects = await client.projects.searchProjects({ @@ -154,16 +146,15 @@ export class Jira extends Tool implements ProjectTool { TCallback extends (issue: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; projectId: string; } & ProjectSyncOptions, callback: TCallback, ...extraArgs: TArgs ): Promise { - const { authToken, projectId, timeMin } = options; + const { projectId, timeMin } = options; // Setup webhook for real-time updates - await this.setupJiraWebhook(authToken, projectId); + await this.setupJiraWebhook(projectId); // Store callback for webhook processing const callbackToken = await this.tools.callbacks.createFromParent( @@ -173,7 +164,7 @@ export class Jira extends Tool implements ProjectTool { await this.set(`callback_${projectId}`, callbackToken); // Start initial batch sync - await this.startBatchSync(authToken, projectId, { timeMin }); + await this.startBatchSync(projectId, { timeMin }); } /** @@ -190,7 +181,6 @@ export class Jira extends Tool implements ProjectTool { * 5. Set JQL filter: project = {projectId} */ private async setupJiraWebhook( - authToken: string, projectId: string ): Promise { try { @@ -198,8 +188,7 @@ export class Jira extends Tool implements ProjectTool { const webhookUrl = await this.tools.network.createWebhook( {}, this.onWebhook, - projectId, - authToken + projectId ); // Store webhook URL for reference @@ -217,7 +206,6 @@ export class Jira extends Tool implements ProjectTool { * Initialize batch sync process */ private async startBatchSync( - authToken: string, projectId: string, options?: ProjectSyncOptions ): Promise { @@ -233,7 +221,6 @@ export class Jira extends Tool implements ProjectTool { // Queue first batch const batchCallback = await this.callback( this.syncBatch, - authToken, projectId, options ); @@ -245,7 +232,6 @@ export class Jira extends Tool implements ProjectTool { * Process a batch of issues */ private async syncBatch( - authToken: string, projectId: string, options?: ProjectSyncOptions ): Promise { @@ -260,7 +246,7 @@ export class Jira extends Tool implements ProjectTool { throw new Error(`Callback token not found for project ${projectId}`); } - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); // Build JQL query let jql = `project = ${projectId}`; @@ -293,8 +279,7 @@ export class Jira extends Tool implements ProjectTool { for (const issue of searchResult.issues || []) { const activityWithNotes = await this.convertIssueToActivity( issue, - projectId, - authToken + projectId ); // Set unread based on sync type (false for initial sync to avoid notification overload) activityWithNotes.unread = !state.initialSync; @@ -318,7 +303,6 @@ export class Jira extends Tool implements ProjectTool { // Queue next batch const nextBatch = await this.callback( this.syncBatch, - authToken, projectId, options ); @@ -330,33 +314,13 @@ export class Jira extends Tool implements ProjectTool { } /** - * Get the cloud ID from stored authorization + * Get the cloud ID using syncable-based auth */ - private async getCloudId(authToken: string): Promise { - // Try new pattern first (authToken is an ActorId) - let token = await this.tools.integrations.get(AuthProvider.Atlassian, authToken as ActorId); - - // Fall back to legacy authorization lookup - if (!token) { - const authorization = await this.get( - `authorization:${authToken}` - ); - if (!authorization) { - throw new Error("Authorization no longer available"); - } - - token = await this.tools.integrations.get(authorization.provider, authorization.actor.id); - } - - if (!token) { - throw new Error("Authorization no longer available"); - } - + private async getCloudId(projectId: string): Promise { + const token = await this.tools.integrations.get(Jira.PROVIDER, projectId); + if (!token) throw new Error("No Jira token available"); const cloudId = token.provider?.cloud_id; - if (!cloudId) { - throw new Error("Jira cloud ID not found in authorization"); - } - + if (!cloudId) throw new Error("Jira cloud ID not found"); return cloudId; } @@ -365,8 +329,7 @@ export class Jira extends Tool implements ProjectTool { */ private async convertIssueToActivity( issue: any, - projectId: string, - authToken?: string + projectId: string ): Promise { const fields = issue.fields || {}; const comments = fields.comment?.comments || []; @@ -397,13 +360,11 @@ export class Jira extends Tool implements ProjectTool { // Get cloud ID for constructing stable source identifier and issue URL let cloudId: string | undefined; let issueUrl: string | undefined; - if (authToken) { - try { - cloudId = await this.getCloudId(authToken); - issueUrl = `https://api.atlassian.com/ex/jira/${cloudId}/browse/${issue.key}`; - } catch (error) { - console.error("Failed to get cloud ID for issue URL:", error); - } + try { + cloudId = await this.getCloudId(projectId); + issueUrl = `https://api.atlassian.com/ex/jira/${cloudId}/browse/${issue.key}`; + } catch (error) { + console.error("Failed to get cloud ID for issue URL:", error); } // Build notes array: always create initial note with description and link @@ -521,17 +482,17 @@ export class Jira extends Tool implements ProjectTool { /** * Update issue with new values * - * @param authToken - Authorization token * @param activity - The updated activity */ - async updateIssue(authToken: string, activity: Activity): Promise { - // Extract Jira issue key from meta + async updateIssue(activity: Activity): Promise { + // Extract Jira issue key and project ID from meta const issueKey = activity.meta?.issueKey as string | undefined; if (!issueKey) { throw new Error("Jira issue key not found in activity meta"); } + const projectId = activity.meta?.projectId as string; - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); // Handle field updates (title, assignee) const updateFields: any = {}; @@ -606,12 +567,11 @@ export class Jira extends Tool implements ProjectTool { /** * Add a comment to a Jira issue * - * @param authToken - Authorization token - * @param meta - Activity metadata containing issueKey + * @param meta - Activity metadata containing issueKey and projectId * @param body - Comment text (converted to ADF format) + * @param noteId - Optional Plot note ID for dedup */ async addIssueComment( - authToken: string, meta: import("@plotday/twister").ActivityMeta, body: string, noteId?: string, @@ -620,7 +580,8 @@ export class Jira extends Tool implements ProjectTool { if (!issueKey) { throw new Error("Jira issue key not found in activity meta"); } - const client = await this.getClient(authToken); + const projectId = meta.projectId as string; + const client = await this.getClient(projectId); // Convert plain text to Atlassian Document Format (ADF) const adfBody = this.convertTextToADF(body); @@ -663,8 +624,7 @@ export class Jira extends Tool implements ProjectTool { */ private async onWebhook( request: WebhookRequest, - projectId: string, - authToken: string + projectId: string ): Promise { const payload = request.body as any; @@ -680,14 +640,12 @@ export class Jira extends Tool implements ProjectTool { await this.handleIssueWebhook( payload, projectId, - authToken, callbackToken ); } else if (payload.webhookEvent?.startsWith("comment_")) { await this.handleCommentWebhook( payload, projectId, - authToken, callbackToken ); } else { @@ -701,10 +659,8 @@ export class Jira extends Tool implements ProjectTool { private async handleIssueWebhook( payload: any, projectId: string, - authToken: string, callbackToken: Callback ): Promise { - const issue = payload.issue; if (!issue) { console.error("No issue in webhook payload"); @@ -739,7 +695,7 @@ export class Jira extends Tool implements ProjectTool { // Get cloud ID for constructing stable source identifier let cloudId: string | undefined; try { - cloudId = await this.getCloudId(authToken); + cloudId = await this.getCloudId(projectId); } catch (error) { console.error("Failed to get cloud ID for source identifier:", error); } @@ -786,10 +742,8 @@ export class Jira extends Tool implements ProjectTool { private async handleCommentWebhook( payload: any, projectId: string, - authToken: string, callbackToken: Callback ): Promise { - const comment = payload.comment; const issue = payload.issue; @@ -801,7 +755,7 @@ export class Jira extends Tool implements ProjectTool { // Get cloud ID for constructing stable source identifier let cloudId: string | undefined; try { - cloudId = await this.getCloudId(authToken); + cloudId = await this.getCloudId(projectId); } catch (error) { console.error("Failed to get cloud ID for source identifier:", error); } @@ -862,7 +816,7 @@ export class Jira extends Tool implements ProjectTool { /** * Stop syncing a Jira project */ - async stopSync(_authToken: string, projectId: string): Promise { + async stopSync(projectId: string): Promise { // Cleanup webhook URL await this.clear(`webhook_url_${projectId}`); await this.clear(`webhook_id_${projectId}`); diff --git a/tools/linear/src/linear.ts b/tools/linear/src/linear.ts index b24b87f..12424bb 100644 --- a/tools/linear/src/linear.ts +++ b/tools/linear/src/linear.ts @@ -15,11 +15,9 @@ import { type NewActivityWithNotes, type NewNote, Serializable, - type ActorId, } from "@plotday/twister"; import type { Project, - ProjectAuth, ProjectSyncOptions, ProjectTool, } from "@plotday/twister/common/projects"; @@ -28,8 +26,10 @@ import { Tool, type ToolBuilder } from "@plotday/twister/tool"; import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; import { AuthProvider, + type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; @@ -57,9 +57,20 @@ type SyncState = { * with Plot activities. */ export class Linear extends Tool implements ProjectTool { + static readonly PROVIDER = AuthProvider.Linear; + static readonly SCOPES = ["read", "write", "admin"]; + build(build: ToolBuilder) { return { - integrations: build(Integrations), + integrations: build(Integrations, { + providers: [{ + provider: Linear.PROVIDER, + scopes: Linear.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }], + }), network: build(Network, { urls: ["https://api.linear.app/*"] }), callbacks: build(Callbacks), tasks: build(Tasks), @@ -68,72 +79,48 @@ export class Linear extends Tool implements ProjectTool { } /** - * Create Linear API client with auth token + * Create Linear API client using syncable-based auth */ - private async getClient(authToken: string): Promise { - // Try new flow: authToken is an ActorId - const token = await this.tools.integrations.get(AuthProvider.Linear, authToken as ActorId); - if (token) { - return new LinearClient({ accessToken: token.token }); - } - - // Fall back to legacy flow: authToken is a UUID mapped to stored authorization - const authorization = await this.get( - `authorization:${authToken}` - ); - if (!authorization) { - throw new Error("Authorization no longer available"); + private async getClient(projectId: string): Promise { + const token = await this.tools.integrations.get(Linear.PROVIDER, projectId); + if (!token) { + throw new Error("No Linear authentication token available"); } - - const legacyToken = await this.tools.integrations.get(authorization.provider, authorization.actor.id); - if (!legacyToken) { - throw new Error("Authorization no longer available"); - } - - return new LinearClient({ accessToken: legacyToken.token }); + return new LinearClient({ accessToken: token.token }); } /** - * Request Linear OAuth authorization + * Returns available Linear teams as syncable resources. */ - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: ProjectAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - const linearScopes = ["read", "write", "admin"]; - - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + async getSyncables(_auth: Authorization, token: AuthToken): Promise { + const client = new LinearClient({ accessToken: token.token }); + const teams = await client.teams(); + return teams.nodes.map((team) => ({ + id: team.id, + title: team.name, + })); + } - // Request auth and return the activity link - return await this.tools.integrations.request( - { - provider: AuthProvider.Linear, - scopes: linearScopes, - }, - this.onAuthSuccess, - callbackToken - ); + /** + * Called when a syncable resource is enabled for syncing + */ + async onSyncEnabled(syncable: Syncable): Promise { + await this.set(`sync_enabled_${syncable.id}`, true); } /** - * Handle successful OAuth authorization + * Called when a syncable resource is disabled */ - private async onAuthSuccess( - authorization: Authorization, - callbackToken: Callback - ): Promise { - // Execute the callback with the auth token - await this.run(callbackToken, { authToken: authorization.actor.id as string }); + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + await this.clear(`sync_enabled_${syncable.id}`); } /** * Get list of Linear teams (projects) */ - async getProjects(authToken: string): Promise { - const client = await this.getClient(authToken); + async getProjects(projectId: string): Promise { + const client = await this.getClient(projectId); const teams = await client.teams(); return teams.nodes.map((team) => ({ @@ -152,16 +139,15 @@ export class Linear extends Tool implements ProjectTool { TCallback extends (issue: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; projectId: string; } & ProjectSyncOptions, callback: TCallback, ...extraArgs: TArgs ): Promise { - const { authToken, projectId, timeMin } = options; + const { projectId, timeMin } = options; // Setup webhook for real-time updates - await this.setupLinearWebhook(authToken, projectId); + await this.setupLinearWebhook(projectId); // Store callback for webhook processing const callbackToken = await this.tools.callbacks.createFromParent( @@ -171,25 +157,23 @@ export class Linear extends Tool implements ProjectTool { await this.set(`callback_${projectId}`, callbackToken); // Start initial batch sync - await this.startBatchSync(authToken, projectId, { timeMin }); + await this.startBatchSync(projectId, { timeMin }); } /** * Setup Linear webhook for real-time updates */ private async setupLinearWebhook( - authToken: string, projectId: string ): Promise { try { - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); // Create webhook URL first (Linear requires valid URL at creation time) const webhookUrl = await this.tools.network.createWebhook( {}, this.onWebhook, - projectId, - authToken + projectId ); // Skip webhook setup for localhost (development mode) @@ -227,7 +211,6 @@ export class Linear extends Tool implements ProjectTool { * Initialize batch sync process */ private async startBatchSync( - authToken: string, projectId: string, options?: ProjectSyncOptions ): Promise { @@ -242,7 +225,6 @@ export class Linear extends Tool implements ProjectTool { // Queue first batch const batchCallback = await this.callback( this.syncBatch, - authToken, projectId, options ); @@ -254,7 +236,6 @@ export class Linear extends Tool implements ProjectTool { * Process a batch of issues */ private async syncBatch( - authToken: string, projectId: string, options?: ProjectSyncOptions ): Promise { @@ -269,7 +250,7 @@ export class Linear extends Tool implements ProjectTool { throw new Error(`Callback token not found for project ${projectId}`); } - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); const team = await client.team(projectId); // Build filter @@ -311,7 +292,6 @@ export class Linear extends Tool implements ProjectTool { // Queue next batch const nextBatch = await this.callback( this.syncBatch, - authToken, projectId, options ); @@ -460,11 +440,9 @@ export class Linear extends Tool implements ProjectTool { /** * Update issue with new values * - * @param authToken - Authorization token * @param activity - The updated activity */ async updateIssue( - authToken: string, activity: import("@plotday/twister").Activity ): Promise { // Get the Linear issue ID from activity meta @@ -473,7 +451,12 @@ export class Linear extends Tool implements ProjectTool { throw new Error("Linear issue ID not found in activity meta"); } - const client = await this.getClient(authToken); + const projectId = activity.meta?.projectId as string | undefined; + if (!projectId) { + throw new Error("Project ID not found in activity meta"); + } + + const client = await this.getClient(projectId); const issue = await client.issue(issueId); const updateFields: any = {}; @@ -570,12 +553,10 @@ export class Linear extends Tool implements ProjectTool { /** * Add a comment to a Linear issue * - * @param authToken - Authorization token - * @param meta - Activity metadata containing linearId + * @param meta - Activity metadata containing linearId and projectId * @param body - Comment text (markdown supported) */ async addIssueComment( - authToken: string, meta: ActivityMeta, body: string ): Promise { @@ -583,7 +564,13 @@ export class Linear extends Tool implements ProjectTool { if (!issueId) { throw new Error("Linear issue ID not found in activity meta"); } - const client = await this.getClient(authToken); + + const projectId = meta.projectId as string | undefined; + if (!projectId) { + throw new Error("Project ID not found in activity meta"); + } + + const client = await this.getClient(projectId); const payload = await client.createComment({ issueId, @@ -601,13 +588,10 @@ export class Linear extends Tool implements ProjectTool { */ private async onWebhook( request: WebhookRequest, - projectId: string, - authToken: string, - webhookSecret?: string + projectId: string ): Promise { // Retrieve secret - const secret = - webhookSecret || (await this.get(`webhook_secret_${projectId}`)); + const secret = await this.get(`webhook_secret_${projectId}`); if (!secret) { console.warn("Linear webhook secret not found, skipping verification"); @@ -654,14 +638,12 @@ export class Linear extends Tool implements ProjectTool { await this.handleIssueWebhook( payload as EntityWebhookPayloadWithIssueData, projectId, - authToken, callbackToken ); } else if (payload.type === "Comment") { await this.handleCommentWebhook( payload as EntityWebhookPayloadWithCommentData, projectId, - authToken, callbackToken ); } @@ -673,7 +655,6 @@ export class Linear extends Tool implements ProjectTool { private async handleIssueWebhook( payload: EntityWebhookPayloadWithIssueData, projectId: string, - _authToken: string, callbackToken: Callback ): Promise { const issue = payload.data; @@ -741,7 +722,6 @@ export class Linear extends Tool implements ProjectTool { private async handleCommentWebhook( payload: EntityWebhookPayloadWithCommentData, projectId: string, - authToken: string, callbackToken: Callback ): Promise { const comment = payload.data; @@ -795,12 +775,12 @@ export class Linear extends Tool implements ProjectTool { /** * Stop syncing a Linear team */ - async stopSync(authToken: string, projectId: string): Promise { + async stopSync(projectId: string): Promise { // Remove webhook const webhookId = await this.get(`webhook_id_${projectId}`); if (webhookId) { try { - const client = await this.getClient(authToken); + const client = await this.getClient(projectId); await client.deleteWebhook(webhookId); } catch (error) { console.warn("Failed to delete Linear webhook:", error); diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/tools/outlook-calendar/src/outlook-calendar.ts index 629a1e5..034866b 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/tools/outlook-calendar/src/outlook-calendar.ts @@ -19,15 +19,16 @@ import { } from "@plotday/twister"; import type { Calendar, - CalendarAuth, CalendarTool, SyncOptions, } from "@plotday/twister/common/calendar"; import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, + type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { @@ -69,14 +70,6 @@ function detectConferencingProvider(url: string): ConferencingProvider { return ConferencingProvider.microsoftTeams; } -type OutlookPendingSyncItem = { - type: "rsvp"; - calendarId: string; - eventId: string; - status: "accepted" | "declined" | "tentativelyAccepted"; - activityId: string; -}; - type WatchState = { subscriptionId: string; calendarId: string; @@ -104,48 +97,15 @@ type WatchState = { * @example * ```typescript * class CalendarSyncTwist extends Twist { - * private outlookCalendar: OutlookCalendar; - * - * constructor(id: string, tools: Tools) { - * super(); - * this.outlookCalendar = tools.get(OutlookCalendar); - * } - * - * async activate() { - * const authLink = await this.outlookCalendar.requestAuth("onOutlookAuth", { - * provider: "outlook" - * }); - * - * await this.plot.createActivity({ - * type: ActivityType.Action, - * title: "Connect Outlook Calendar", - * links: [authLink] - * }); + * build(build: ToolBuilder) { + * return { + * outlookCalendar: build(OutlookCalendar), + * plot: build(Plot, { activity: { access: ActivityAccess.Create } }), + * }; * } * - * async onOutlookAuth(auth: CalendarAuth, context: any) { - * const calendars = await this.outlookCalendar.getCalendars(auth.authToken); - * - * // Start syncing primary calendar - * const primary = calendars.find(c => c.primary); - * if (primary) { - * await this.outlookCalendar.startSync( - * auth.authToken, - * primary.id, - * "onCalendarEvent", - * { - * options: { - * timeMin: new Date(), // Only sync future events - * } - * } - * ); - * } - * } - * - * async onCalendarEvent(activity: Activity, context: any) { - * // Process Outlook Calendar events - * await this.plot.createActivity(activity); - * } + * // Auth and calendar selection handled in the twist edit modal. + * // Events are delivered via the startSync callback. * } * ``` */ @@ -153,77 +113,62 @@ export class OutlookCalendar extends Tool implements CalendarTool { + static readonly PROVIDER = AuthProvider.Microsoft; static readonly SCOPES = ["https://graph.microsoft.com/calendars.readwrite"]; build(build: ToolBuilder) { return { - integrations: build(Integrations), - network: build(Network, { - urls: ["https://graph.microsoft.com/*"], + integrations: build(Integrations, { + providers: [{ + provider: OutlookCalendar.PROVIDER, + scopes: OutlookCalendar.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }], }), + network: build(Network, { urls: ["https://graph.microsoft.com/*"] }), plot: build(Plot, { - contact: { - access: ContactAccess.Write, - }, - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, - }, + contact: { access: ContactAccess.Write }, + activity: { access: ActivityAccess.Create, updated: this.onActivityUpdated }, }), }; } - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: CalendarAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + /** + * Returns available Outlook calendars as syncable resources. + */ + async getSyncables(_auth: Authorization, token: AuthToken): Promise { + const api = new GraphApi(token.token); + const calendars = await api.getCalendars(); + return calendars.map((c) => ({ id: c.id, title: c.name })); + } - // Request Microsoft authentication and return the activity link - return await this.tools.integrations.request( - { - provider: AuthProvider.Microsoft, - scopes: ["https://graph.microsoft.com/calendars.readwrite"], - }, - this.onAuthSuccess, - callbackToken - ); + /** + * Called when a syncable calendar is enabled for syncing. + */ + async onSyncEnabled(syncable: Syncable): Promise { + await this.set(`sync_enabled_${syncable.id}`, true); } - private async getApi(authToken: string): Promise { - // Try new flow: authToken is an ActorId, look up directly - let token = await this.tools.integrations.get( - AuthProvider.Microsoft, - authToken as ActorId - ); + /** + * Called when a syncable calendar is disabled. + */ + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + await this.clear(`sync_enabled_${syncable.id}`); + } - // Fall back to legacy flow: authToken is opaque UUID mapped to stored authorization + private async getApi(calendarId: string): Promise { + const token = await this.tools.integrations.get(OutlookCalendar.PROVIDER, calendarId); if (!token) { - const authorization = await this.get( - `authorization:${authToken}` - ); - if (!authorization) { - throw new Error("Authorization no longer available"); - } - - token = await this.tools.integrations.get( - authorization.provider, - authorization.actor.id - ); - if (!token) { - throw new Error("Authorization no longer available"); - } + throw new Error("No Microsoft authentication token available"); } - return new GraphApi(token.token); } - private async getUserEmail(authToken: string): Promise { - const api = await this.getApi(authToken); + private async getUserEmail(calendarId: string): Promise { + const api = await this.getApi(calendarId); const data = (await api.call( "GET", "https://graph.microsoft.com/v1.0/me" @@ -232,7 +177,7 @@ export class OutlookCalendar return data.mail || data.userPrincipalName || ""; } - private async ensureUserIdentity(authToken: string): Promise { + private async ensureUserIdentity(calendarId: string): Promise { // Check if we already have the user email stored const stored = await this.get("user_email"); if (stored) { @@ -240,15 +185,15 @@ export class OutlookCalendar } // Fetch user email from Microsoft Graph - const email = await this.getUserEmail(authToken); + const email = await this.getUserEmail(calendarId); // Store for future use await this.set("user_email", email); return email; } - async getCalendars(authToken: string): Promise { - const api = await this.getApi(authToken); + async getCalendars(calendarId: string): Promise { + const api = await this.getApi(calendarId); return await api.getCalendars(); } @@ -257,13 +202,12 @@ export class OutlookCalendar TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; calendarId: string; } & SyncOptions, callback: TCallback, ...extraArgs: TArgs ): Promise { - const { authToken, calendarId, timeMin, timeMax } = options; + const { calendarId, timeMin, timeMax } = options; // Create callback token for parent const callbackToken = await this.tools.callbacks.createFromParent( callback, @@ -271,11 +215,8 @@ export class OutlookCalendar ); await this.set("event_callback_token", callbackToken); - // Store auth token for calendar for later RSVP updates - await this.set(`auth_token_${calendarId}`, authToken); - // Setup webhook for this calendar - await this.setupOutlookWatch(authToken, calendarId, authToken); + await this.setupOutlookWatch(calendarId); // Determine sync range let min: Date | undefined; @@ -309,19 +250,18 @@ export class OutlookCalendar const syncCallback = await this.callback( this.syncOutlookBatch, calendarId, - authToken, true, // initialSync = true for initial sync 1 // batchNumber = 1 for first batch ); await this.runTask(syncCallback); } - async stopSync(authToken: string, calendarId: string): Promise { + async stopSync(calendarId: string): Promise { // Stop webhook const watchData = await this.get(`outlook_watch_${calendarId}`); if (watchData?.subscriptionId) { try { - const api = await this.getApi(authToken); + const api = await this.getApi(calendarId); await api.deleteSubscription(watchData.subscriptionId); } catch (error) { console.error("Failed to delete Outlook subscription:", error); @@ -335,17 +275,14 @@ export class OutlookCalendar } private async setupOutlookWatch( - authToken: string, - calendarId: string, - opaqueAuthToken: string + calendarId: string ): Promise { - const api = await this.getApi(authToken); + const api = await this.getApi(calendarId); const webhookUrl = await this.tools.network.createWebhook( {}, this.onOutlookWebhook, - calendarId, - opaqueAuthToken + calendarId ); // Skip webhook setup for localhost (development mode) @@ -379,17 +316,16 @@ export class OutlookCalendar async syncOutlookBatch( calendarId: string, - authToken: string, initialSync: boolean, batchNumber: number = 1 ): Promise { let api: GraphApi; try { - api = await this.getApi(authToken); + api = await this.getApi(calendarId); } catch (error) { console.error( - "No Microsoft credentials found for the provided authToken:", + "No Microsoft credentials found for calendar:", error ); return; @@ -397,7 +333,7 @@ export class OutlookCalendar // Ensure we have the user's identity for RSVP tagging (only on first batch) if (batchNumber === 1) { - await this.ensureUserIdentity(authToken); + await this.ensureUserIdentity(calendarId); } // Hoist callback token retrieval outside event loop - saves N-1 subrequests @@ -441,7 +377,6 @@ export class OutlookCalendar const syncCallback = await this.callback( this.syncOutlookBatch, calendarId, - authToken, initialSync, batchNumber + 1 ); @@ -791,8 +726,7 @@ export class OutlookCalendar async onOutlookWebhook( request: WebhookRequest, - calendarId: string, - authToken: string + calendarId: string ): Promise { if (request.params?.validationToken) { // Return validation token for webhook verification @@ -814,20 +748,19 @@ export class OutlookCalendar for (const notification of notifications) { if (notification.changeType) { // Trigger incremental sync - await this.startIncrementalSync(calendarId, authToken); + await this.startIncrementalSync(calendarId); } } } private async startIncrementalSync( - calendarId: string, - authToken: string + calendarId: string ): Promise { try { - await this.getApi(authToken); + await this.getApi(calendarId); } catch (error) { console.error( - "No Microsoft credentials found for the provided authToken:", + "No Microsoft credentials found for calendar:", error ); return; @@ -836,24 +769,12 @@ export class OutlookCalendar const callback = await this.callback( this.syncOutlookBatch, calendarId, - authToken, false, // initialSync = false for incremental updates 1 // batchNumber = 1 for first batch ); await this.runTask(callback); } - async onAuthSuccess( - authResult: Authorization, - callbackToken: Callback - ): Promise { - const authSuccessResult: CalendarAuth = { - authToken: authResult.actor.id as string, - }; - - await this.run(callbackToken, authSuccessResult); - } - async onActivityUpdated( activity: Activity, changes: { @@ -973,106 +894,42 @@ export class OutlookCalendar ? changes.occurrence.occurrence : new Date(changes.occurrence.occurrence); - // Use the first actor's token to look up the instance ID - const firstActorId = actorIds.values().next().value; - if (!firstActorId) { - return; - } - - const lookupToken = await this.tools.integrations.get( - AuthProvider.Microsoft, - firstActorId - ); - - if (!lookupToken) { - // Fall back to stored auth token for instance lookup - const storedAuthToken = await this.get( - `auth_token_${calendarId}` + try { + const api = await this.getApi(calendarId as string); + const instanceId = await this.getEventInstanceIdWithApi( + api, + calendarId as string, + baseEventId, + occurrenceDate ); - if (storedAuthToken) { - try { - const instanceId = await this.getEventInstanceId( - storedAuthToken, - calendarId, - baseEventId, - occurrenceDate - ); - if (instanceId) { - eventId = instanceId; - } else { - console.warn( - `Could not find instance ID for occurrence ${occurrenceDate.toISOString()}` - ); - return; - } - } catch (error) { - console.error(`Failed to look up instance ID:`, error); - return; - } + if (instanceId) { + eventId = instanceId; } else { console.warn( - "[RSVP Sync] No auth available for instance ID lookup" - ); - return; - } - } else { - try { - const api = new GraphApi(lookupToken.token); - const instanceId = await this.getEventInstanceIdWithApi( - api, - calendarId, - baseEventId, - occurrenceDate + `Could not find instance ID for occurrence ${occurrenceDate.toISOString()}` ); - if (instanceId) { - eventId = instanceId; - } else { - console.warn( - `Could not find instance ID for occurrence ${occurrenceDate.toISOString()}` - ); - return; - } - } catch (error) { - console.error(`Failed to look up instance ID:`, error); return; } + } catch (error) { + console.error(`Failed to look up instance ID:`, error); + return; } } - // For each actor who changed RSVP, try to sync with their credentials + // For each actor who changed RSVP, use actAs() to sync with their credentials. + // If the actor has auth, the callback fires immediately. + // If not, actAs() creates a private auth note automatically. for (const actorId of actorIds) { - const token = await this.tools.integrations.get( - AuthProvider.Microsoft, - actorId + await this.tools.integrations.actAs( + OutlookCalendar.PROVIDER, + actorId, + activity.id, + this.syncActorRSVP, + calendarId as string, + eventId, + newStatus, + actorId as string ); - - if (token) { - // Actor has auth - sync their RSVP directly - try { - const api = new GraphApi(token.token); - await this.updateEventRSVPWithApi( - api, - calendarId, - eventId, - newStatus, - actorId - ); - } catch (error) { - console.error("[RSVP Sync] Failed to update RSVP for actor", { - actor_id: actorId, - error: error instanceof Error ? error.message : String(error), - }); - } - } else { - // Actor lacks auth - queue pending sync and create auth request - await this.queuePendingSync(actorId, { - type: "rsvp", - calendarId, - eventId, - status: newStatus, - activityId: activity.id, - }); - } } } catch (error) { console.error("[RSVP Sync] Error in callback", { @@ -1084,118 +941,32 @@ export class OutlookCalendar } /** - * Queue a pending write-back action for an unauthenticated actor. - * Creates a private auth-request activity if one doesn't exist (deduped by source). - */ - private async queuePendingSync( - actorId: ActorId, - action: OutlookPendingSyncItem - ): Promise { - // Add to pending queue - const key = `pending_sync:${actorId}`; - const pending = (await this.get(key)) || []; - pending.push(action); - await this.set(key, pending); - - // Create auth-request activity (upsert by source to dedup) - const authLink = await this.tools.integrations.request( - { - provider: AuthProvider.Microsoft, - scopes: OutlookCalendar.SCOPES, - }, - this.onActorAuth, - actorId as string - ); - - await this.tools.plot.createActivity({ - source: `auth:${actorId}`, - type: ActivityType.Action, - title: "Connect your Outlook Calendar", - private: true, - start: new Date(), - end: null, - notes: [ - { - content: - "To sync your RSVP responses, please connect your Outlook Calendar.", - links: [authLink], - mentions: [{ id: actorId }], - }, - ], - }); - } - - /** - * Callback for when an additional user authorizes. - * Applies any pending write-back actions. Does NOT start a sync. + * Sync RSVP for an actor. If the actor has auth, this is called immediately. + * If not, actAs() creates a private auth note and calls this when they authorize. */ - async onActorAuth( - authorization: Authorization, - actorIdStr: string + async syncActorRSVP( + token: AuthToken, + calendarId: string, + eventId: string, + status: "accepted" | "declined" | "tentativelyAccepted", + actorId: string ): Promise { - const actorId = actorIdStr as ActorId; - const key = `pending_sync:${actorId}`; - const pending = await this.get(key); - - if (!pending || pending.length === 0) { - return; - } - - const token = await this.tools.integrations.get( - authorization.provider, - authorization.actor.id - ); - if (!token) { - console.error("[RSVP Sync] Failed to get token after actor auth", { + try { + const api = new GraphApi(token.token); + await this.updateEventRSVPWithApi( + api, + calendarId, + eventId, + status, + actorId as ActorId + ); + } catch (error) { + console.error("[RSVP Sync] Failed to sync RSVP", { actor_id: actorId, + event_id: eventId, + error: error instanceof Error ? error.message : String(error), }); - return; - } - - const api = new GraphApi(token.token); - - // Apply pending write-backs - for (const item of pending) { - if (item.type === "rsvp") { - try { - await this.updateEventRSVPWithApi( - api, - item.calendarId, - item.eventId, - item.status, - actorId - ); - } catch (error) { - console.error("[RSVP Sync] Failed to apply pending RSVP", { - actor_id: actorId, - event_id: item.eventId, - error: error instanceof Error ? error.message : String(error), - }); - } - } } - - // Clear pending queue - await this.clear(key); - } - - /** - * Look up the instance ID for a specific occurrence of a recurring event. - * Uses the Microsoft Graph instances endpoint to find the matching occurrence. - */ - private async getEventInstanceId( - authToken: string, - calendarId: string, - seriesMasterId: string, - occurrenceDate: Date - ): Promise { - const api = await this.getApi(authToken); - return this.getEventInstanceIdWithApi( - api, - calendarId, - seriesMasterId, - occurrenceDate - ); } /** diff --git a/tools/slack/src/slack.ts b/tools/slack/src/slack.ts index d065d02..67b7a88 100644 --- a/tools/slack/src/slack.ts +++ b/tools/slack/src/slack.ts @@ -1,6 +1,4 @@ import { - type ActorId, - type ActivityLink, type NewActivityWithNotes, Serializable, Tool, @@ -9,14 +7,15 @@ import { import { type MessageChannel, type MessageSyncOptions, - type MessagingAuth, type MessagingTool, } from "@plotday/twister/common/messaging"; import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, + type AuthToken, type Authorization, Integrations, + type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { @@ -108,82 +107,65 @@ import { * ``` */ export class Slack extends Tool implements MessagingTool { + static readonly PROVIDER = AuthProvider.Slack; + static readonly SCOPES = [ + "channels:history", + "channels:read", + "groups:history", + "groups:read", + "users:read", + "users:read.email", + "chat:write", + "im:history", + "mpim:history", + ]; + build(build: ToolBuilder) { return { - integrations: build(Integrations), - network: build(Network, { - urls: ["https://slack.com/api/*"], + integrations: build(Integrations, { + providers: [{ + provider: Slack.PROVIDER, + scopes: Slack.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }], }), + network: build(Network, { urls: ["https://slack.com/api/*"] }), plot: build(Plot, { - contact: { - access: ContactAccess.Write, - }, - activity: { - access: ActivityAccess.Create, - }, + contact: { access: ContactAccess.Write }, + activity: { access: ActivityAccess.Create }, }), }; } - async requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: MessagingAuth, ...args: TArgs) => any - >(callback: TCallback, ...extraArgs: TArgs): Promise { - // Bot scopes for workspace-level "Add to Slack" installation - // These are the scopes the bot token will have - const slackScopes = [ - "channels:history", // Read messages in public channels - "channels:read", // View basic channel info - "groups:history", // Read messages in private channels (if bot is added) - "groups:read", // View basic private channel info - "users:read", // View users in workspace - "users:read.email", // View user email addresses - "chat:write", // Send messages as the bot - "im:history", // Read direct messages with the bot - "mpim:history", // Read group direct messages - ]; - - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); + async getSyncables(_auth: Authorization, token: AuthToken): Promise { + const api = new SlackApi(token.token); + const channels = await api.getChannels(); + return channels + .filter((c: SlackChannel) => c.is_member && !c.is_archived) + .map((c: SlackChannel) => ({ id: c.id, title: c.name })); + } - // Request auth and return the activity link - // Use Priority level for workspace-scoped authorization - return await this.tools.integrations.request( - { - provider: AuthProvider.Slack, - scopes: slackScopes, - }, - this.onAuthSuccess, - callbackToken - ); + async onSyncEnabled(syncable: Syncable): Promise { + await this.set(`sync_enabled_${syncable.id}`, true); } - private async getApi(authToken: string): Promise { - // Try new flow: authToken is an ActorId - let token = await this.tools.integrations.get(AuthProvider.Slack, authToken as ActorId); + async onSyncDisabled(syncable: Syncable): Promise { + await this.stopSync(syncable.id); + await this.clear(`sync_enabled_${syncable.id}`); + } - // Fall back to legacy authorization lookup + private async getApi(channelId: string): Promise { + const token = await this.tools.integrations.get(Slack.PROVIDER, channelId); if (!token) { - const authorization = await this.get( - `authorization:${authToken}` - ); - if (!authorization) { - throw new Error("Authorization no longer available"); - } - - token = await this.tools.integrations.get(authorization.provider, authorization.actor.id); - if (!token) { - throw new Error("Authorization no longer available"); - } + throw new Error("No Slack authentication token available"); } - return new SlackApi(token.token); } - async getChannels(authToken: string): Promise { - const api = await this.getApi(authToken); + async getChannels(channelId: string): Promise { + const api = await this.getApi(channelId); const channels = await api.getChannels(); return channels @@ -203,13 +185,12 @@ export class Slack extends Tool implements MessagingTool { TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; channelId: string; } & MessageSyncOptions, callback: TCallback, ...extraArgs: TArgs ): Promise { - const { authToken, channelId } = options; + const { channelId } = options; // Create callback token for parent const callbackToken = await this.tools.callbacks.createFromParent( @@ -218,11 +199,8 @@ export class Slack extends Tool implements MessagingTool { ); await this.set(`thread_callback_token_${channelId}`, callbackToken); - // Store auth token for channel - await this.set(`auth_token_${channelId}`, authToken); - // Setup webhook for this channel (Slack Events API) - await this.setupChannelWebhook(authToken, channelId); + await this.setupChannelWebhook(channelId); // Calculate oldest timestamp for sync let oldest: string | undefined; @@ -246,13 +224,12 @@ export class Slack extends Tool implements MessagingTool { this.syncBatch, 1, "full", - authToken, channelId ); await this.run(syncCallback); } - async stopSync(authToken: string, channelId: string): Promise { + async stopSync(channelId: string): Promise { // Clear webhook await this.clear(`channel_webhook_${channelId}`); @@ -261,20 +238,15 @@ export class Slack extends Tool implements MessagingTool { // Clear callback token await this.clear(`thread_callback_token_${channelId}`); - - // Clear auth token - await this.clear(`auth_token_${channelId}`); } private async setupChannelWebhook( - authToken: string, channelId: string ): Promise { const webhookUrl = await this.tools.network.createWebhook( {}, this.onSlackWebhook, - channelId, - authToken + channelId ); // Check if webhook URL is localhost @@ -296,7 +268,6 @@ export class Slack extends Tool implements MessagingTool { async syncBatch( batchNumber: number, mode: "full" | "incremental", - authToken: string, channelId: string ): Promise { try { @@ -305,11 +276,11 @@ export class Slack extends Tool implements MessagingTool { throw new Error("No sync state found"); } - const api = await this.getApi(authToken); + const api = await this.getApi(channelId); const result = await syncSlackChannel(api, state); if (result.threads.length > 0) { - await this.processMessageThreads(result.threads, channelId, authToken); + await this.processMessageThreads(result.threads, channelId); } await this.set(`sync_state_${channelId}`, result.state); @@ -319,7 +290,6 @@ export class Slack extends Tool implements MessagingTool { this.syncBatch, batchNumber + 1, mode, - authToken, channelId ); await this.run(syncCallback); @@ -340,10 +310,8 @@ export class Slack extends Tool implements MessagingTool { private async processMessageThreads( threads: SlackMessage[][], - channelId: string, - authToken: string + channelId: string ): Promise { - const api = await this.getApi(authToken); const callbackToken = await this.get( `thread_callback_token_${channelId}` ); @@ -371,8 +339,7 @@ export class Slack extends Tool implements MessagingTool { async onSlackWebhook( request: WebhookRequest, - channelId: string, - authToken: string + channelId: string ): Promise { const body = request.body; if (!body || typeof body !== "object" || Array.isArray(body)) { @@ -404,13 +371,12 @@ export class Slack extends Tool implements MessagingTool { !event.subtype // Ignore bot messages and special subtypes ) { // Trigger incremental sync - await this.startIncrementalSync(channelId, authToken); + await this.startIncrementalSync(channelId); } } private async startIncrementalSync( - channelId: string, - authToken: string + channelId: string ): Promise { const webhookData = await this.get(`channel_webhook_${channelId}`); if (!webhookData) { @@ -430,22 +396,11 @@ export class Slack extends Tool implements MessagingTool { this.syncBatch, 1, "incremental", - authToken, channelId ); await this.run(syncCallback); } - async onAuthSuccess( - authResult: Authorization, - callback: Callback - ): Promise { - const authSuccessResult: MessagingAuth = { - authToken: authResult.actor.id as string, - }; - - await this.run(callback, authSuccessResult); - } } export default Slack; diff --git a/twister/src/common/calendar.ts b/twister/src/common/calendar.ts index 3b0495d..00ad218 100644 --- a/twister/src/common/calendar.ts +++ b/twister/src/common/calendar.ts @@ -1,16 +1,4 @@ -import type { ActivityLink, NewActivityWithNotes, Serializable } from "../index"; - -/** - * Represents successful calendar authorization. - * - * Returned by calendar tools when authorization completes successfully. - * The auth token is an opaque identifier that can be used for subsequent - * calendar operations. - */ -export type CalendarAuth = { - /** Opaque token for calendar operations */ - authToken: string; -}; +import type { NewActivityWithNotes, Serializable } from "../index"; /** * Represents a calendar from an external calendar service. @@ -77,13 +65,12 @@ export type SyncOptions = { * - Easier testing of tools in isolation * * **Implementation Pattern:** - * 1. Request an ActivityLink for authorization - * 2. Create an Activity with the ActivityLink to prompt user (via twist) - * 3. Receive a CalendarAuth in the specified callback - * 4. Fetch list of available calendars - * 5. Start sync for selected calendars - * 6. **Tool builds NewActivity objects** and passes them to the twist via callback - * 7. **Twist decides** whether to save using createActivity/updateActivity + * 1. Authorization is handled via the twist edit modal (Integrations provider config) + * 2. Tool declares providers and lifecycle callbacks in build() + * 3. onAuthorized lists available calendars and calls setSyncables() + * 4. User enables calendars in the modal → onSyncEnabled fires + * 5. **Tool builds NewActivity objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createActivity/updateActivity * * **Tool Implementation Rules:** * - **DO** build Activity/Note objects from external data @@ -97,77 +84,28 @@ export type SyncOptions = { * * @example * ```typescript - * // Typical calendar integration flow using source/key upserts * class MyCalendarTwist extends Twist { - * private googleCalendar: GoogleCalendar; - * - * async activate() { - * // Step 1: Request authorization - * const authLink = await this.googleCalendar.requestAuth("onAuthComplete"); - * await this.plot.createActivity({ - * type: ActivityType.Action, - * title: "Connect Google Calendar", - * links: [authLink], - * }); + * build(build: ToolBuilder) { + * return { + * googleCalendar: build(GoogleCalendar), + * plot: build(Plot, { activity: { access: ActivityAccess.Create } }), + * }; * } * - * async onAuthComplete(auth: CalendarAuth) { - * // Step 2: Get available calendars - * const calendars = await this.googleCalendar.getCalendars(auth.authToken); - * - * // Step 3: Start sync for primary calendar - * const primaryCalendar = calendars.find(c => c.primary); - * if (primaryCalendar) { - * await this.googleCalendar.startSync( - * { - * authToken: auth.authToken, - * calendarId: primaryCalendar.id - * }, - * this.onCalendarEvent, // Callback receives data from tool - * { initialSync: true } - * ); - * } - * } - * - * async onCalendarEvent( - * activity: NewActivityWithNotes, - * syncMeta: { initialSync: boolean } - * ) { - * // Step 4: Twist decides what to do with the data - * // Tool built the NewActivity, twist saves it - * await this.plot.createActivity(activity); - * } + * // Auth and calendar selection handled in the twist edit modal. + * // Events are delivered via the startSync callback. * } * ``` */ export type CalendarTool = { - /** - * Initiates the authorization flow for the calendar service. - * - * @param callback - Function receiving (auth, ...extraArgs) when auth completes - * @param extraArgs - Additional arguments to pass to the callback (type-checked) - * @returns Promise resolving to an ActivityLink to initiate the auth flow - */ - requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: CalendarAuth, ...args: TArgs) => any - >( - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - /** * Retrieves the list of calendars accessible to the authenticated user. * - * Returns metadata for all calendars the user has access to, including - * their primary calendar and any shared calendars. This list can be - * presented to users for calendar selection. - * - * @param authToken - Authorization token from successful auth flow + * @param calendarId - A calendar ID to use for auth lookup * @returns Promise resolving to array of available calendars - * @throws When the auth token is invalid or expired + * @throws When no valid authorization is available */ - getCalendars(authToken: string): Promise; + getCalendars(calendarId: string): Promise; /** * Begins synchronizing events from a specific calendar. @@ -176,33 +114,22 @@ export type CalendarTool = { * event import and ongoing change notifications. The callback function * will be invoked for each synced event. * - * **Recommended Implementation** (Strategy 2 - Upsert via Source/Key): - * - Set Activity.source to the event's canonical URL (e.g., event.htmlLink) - * - Use Note.key for event details (description, attendees, etc.) to enable upserts - * - No manual ID tracking needed - Plot handles deduplication automatically - * - Send NewActivityWithNotes for all events (creates new or updates existing) - * - Set activity.unread = false for initial sync, omit for incremental updates - * - * **Alternative** (Strategy 3 - Advanced cases): - * - Use Uuid.Generate() and store ID mappings when creating multiple activities per event - * - See SYNC_STRATEGIES.md for when this is appropriate + * Auth is obtained automatically via integrations.get(provider, calendarId). * * @param options - Sync configuration options - * @param options.authToken - Authorization token for calendar access * @param options.calendarId - ID of the calendar to sync * @param options.timeMin - Earliest date to sync events from (inclusive) * @param options.timeMax - Latest date to sync events to (exclusive) * @param callback - Function receiving (activity, ...extraArgs) for each synced event * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete - * @throws When auth token is invalid or calendar doesn't exist + * @throws When no valid authorization or calendar doesn't exist */ startSync< TArgs extends Serializable[], TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; calendarId: string; } & SyncOptions, callback: TCallback, @@ -212,13 +139,8 @@ export type CalendarTool = { /** * Stops synchronizing events from a specific calendar. * - * Disables real-time sync and cleans up any webhooks or polling - * mechanisms for the specified calendar. No further events will - * be synced after this call. - * - * @param authToken - Authorization token for calendar access * @param calendarId - ID of the calendar to stop syncing * @returns Promise that resolves when sync is stopped */ - stopSync(authToken: string, calendarId: string): Promise; + stopSync(calendarId: string): Promise; }; diff --git a/twister/src/common/documents.ts b/twister/src/common/documents.ts index fa6e0dc..40d1306 100644 --- a/twister/src/common/documents.ts +++ b/twister/src/common/documents.ts @@ -1,22 +1,9 @@ import type { - ActivityLink, ActivityMeta, NewActivityWithNotes, Serializable, } from "../index"; -/** - * Represents a successful document service authorization. - * - * Returned by document tools when authorization completes successfully. - * The auth token is an opaque identifier that can be used for subsequent - * document operations. - */ -export type DocumentAuth = { - /** Opaque token for document operations */ - authToken: string; -}; - /** * Represents a folder from an external document service. * @@ -52,6 +39,20 @@ export type DocumentSyncOptions = { * All synced documents are converted to ActivityWithNotes objects. * Each document becomes an Activity with Notes for the description and comments. * + * **Architecture: Tools Build, Twists Save** + * + * Document tools follow Plot's core architectural principle: + * - **Tools**: Fetch external data and transform it into Plot format (NewActivity objects) + * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) + * + * **Implementation Pattern:** + * 1. Authorization is handled via the twist edit modal (Integrations provider config) + * 2. Tool declares providers and lifecycle callbacks in build() + * 3. onAuthorized lists available folders and calls setSyncables() + * 4. User enables folders in the modal → onSyncEnabled fires + * 5. **Tool builds NewActivity objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createActivity/updateActivity + * * **Recommended Data Sync Strategy:** * Use Activity.source and Note.key for automatic upserts. * @@ -65,45 +66,22 @@ export type DocumentSyncOptions = { * - Set `activity.unread = false` for initial sync, omit for incremental updates */ export type DocumentTool = { - /** - * Initiates the authorization flow for the document service. - * - * @param callback - Function receiving (auth, ...extraArgs) when auth completes - * @param extraArgs - Additional arguments to pass to the callback (type-checked) - * @returns Promise resolving to an ActivityLink to initiate the auth flow - */ - requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: DocumentAuth, ...args: TArgs) => any - >( - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - /** * Retrieves the list of folders accessible to the user. * - * @param authToken - Authorization token from successful auth flow + * @param folderId - A folder ID to use for auth lookup * @returns Promise resolving to array of available folders */ - getFolders(authToken: string): Promise; + getFolders(folderId: string): Promise; /** * Begins synchronizing documents from a specific folder. * * Documents are converted to NewActivityWithNotes objects. * - * **Recommended Implementation:** - * - Set Activity.source to `"{provider}:file:{fileId}"` - * - Use Note.key for document details: - * - key: "summary" for description (upserts on changes) - * - key: "comment-{commentId}" for individual comments (unique per comment) - * - key: "reply-{commentId}-{replyId}" for comment replies - * - Send NewActivityWithNotes for all documents (creates new or updates existing) - * - Set activity.unread = false for initial sync, omit for incremental updates + * Auth is obtained automatically via integrations.get(provider, folderId). * * @param options - Sync configuration options - * @param options.authToken - Authorization token for access * @param options.folderId - ID of the folder to sync * @param options.timeMin - Earliest date to sync documents from (inclusive) * @param callback - Function receiving (activity, ...extraArgs) for each synced document @@ -115,7 +93,6 @@ export type DocumentTool = { TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; folderId: string; } & DocumentSyncOptions, callback: TCallback, @@ -125,11 +102,10 @@ export type DocumentTool = { /** * Stops synchronizing documents from a specific folder. * - * @param authToken - Authorization token for access * @param folderId - ID of the folder to stop syncing * @returns Promise that resolves when sync is stopped */ - stopSync(authToken: string, folderId: string): Promise; + stopSync(folderId: string): Promise; /** * Adds a comment to a document. @@ -137,16 +113,15 @@ export type DocumentTool = { * Optional method for bidirectional sync. When implemented, allows Plot to * sync notes added to activities back as comments on the external document. * - * The tool should extract its own ID from meta (e.g., fileId). + * Auth is obtained automatically. The tool should extract its own ID + * from meta (e.g., fileId). * - * @param authToken - Authorization token for access * @param meta - Activity metadata containing the tool's document identifier * @param body - The comment text content * @param noteId - Optional Plot note ID for deduplication * @returns The external comment key (e.g. "comment-123") for dedup, or void */ addDocumentComment?( - authToken: string, meta: ActivityMeta, body: string, noteId?: string, @@ -155,7 +130,9 @@ export type DocumentTool = { /** * Adds a reply to an existing comment thread on a document. * - * @param authToken - Authorization token for access + * Auth is obtained automatically. The tool should extract its own ID + * from meta (e.g., fileId). + * * @param meta - Activity metadata containing the tool's document identifier * @param commentId - The external comment ID to reply to * @param body - The reply text content @@ -163,7 +140,6 @@ export type DocumentTool = { * @returns The external reply key (e.g. "reply-123-456") for dedup, or void */ addDocumentReply?( - authToken: string, meta: ActivityMeta, commentId: string, body: string, diff --git a/twister/src/common/messaging.ts b/twister/src/common/messaging.ts index aea9998..559ef1b 100644 --- a/twister/src/common/messaging.ts +++ b/twister/src/common/messaging.ts @@ -1,22 +1,11 @@ -import type { ActivityLink, NewActivityWithNotes, Serializable } from "../index"; - -/** - * Represents a successful messaging service authorization. - * - * Returned by messaging tools when authorization completes successfully. - * The auth token is an opaque identifier that can be used for subsequent - * messaging operations. - */ -export type MessagingAuth = { - /** Opaque token for messaging operations */ - authToken: string; -}; +import type { NewActivityWithNotes, Serializable } from "../index"; /** * Represents a channel from an external messaging service. * * Contains metadata about a specific channel that can be synced - * with Plot. + * with Plot. Different messaging providers may have additional + * provider-specific properties. */ export type MessageChannel = { /** Unique identifier for the channel within the provider */ @@ -46,53 +35,39 @@ export type MessageSyncOptions = { * All synced messages/emails are converted to ActivityWithNotes objects. * Each email thread or chat conversation becomes an Activity with Notes for each message. * + * **Architecture: Tools Build, Twists Save** + * + * Messaging tools follow Plot's core architectural principle: + * - **Tools**: Fetch external data and transform it into Plot format (NewActivity objects) + * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) + * + * **Implementation Pattern:** + * 1. Authorization is handled via the twist edit modal (Integrations provider config) + * 2. Tool declares providers and lifecycle callbacks in build() + * 3. onAuthorized lists available channels and calls setSyncables() + * 4. User enables channels in the modal → onSyncEnabled fires + * 5. **Tool builds NewActivity objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createActivity/updateActivity + * * **Recommended Data Sync Strategy:** * Use Activity.source (thread URL or ID) and Note.key (message ID) for automatic upserts. * See SYNC_STRATEGIES.md for detailed patterns. */ export type MessagingTool = { - /** - * Initiates the authorization flow for the service. - * - * @param callback - Function receiving (auth, ...extraArgs) when auth completes - * @param extraArgs - Additional arguments to pass to the callback (type-checked) - * @returns Promise resolving to an ActivityLink to initiate the auth flow - */ - requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: MessagingAuth, ...args: TArgs) => any - >( - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - /** * Retrieves the list of conversation channels (inboxes, channels) accessible to the user. * - * @param authToken - Authorization token from successful auth flow + * @param channelId - A channel ID to use for auth lookup * @returns Promise resolving to array of available conversation channels */ - getChannels(authToken: string): Promise; + getChannels(channelId: string): Promise; /** * Begins synchronizing messages from a specific channel. * - * Email threads and chat conversations are converted to NewActivityWithNotes objects. - * - * **Recommended Implementation** (Strategy 2 - Upsert via Source/Key): - * - Set Activity.source to the thread/conversation URL or stable ID (e.g., "slack:{channelId}:{threadTs}") - * - Use Note.key for individual messages (e.g., "message-{messageId}") - * - Each message becomes a separate note with unique key for upserts - * - No manual ID tracking needed - Plot handles deduplication automatically - * - Send NewActivityWithNotes for all threads (creates new or updates existing) - * - Set activity.unread = false for initial sync, omit for incremental updates - * - * **Alternative** (Strategy 3 - Advanced cases): - * - Use Uuid.Generate() and store ID mappings when creating multiple activities per thread - * - See SYNC_STRATEGIES.md for when this is appropriate + * Auth is obtained automatically via integrations.get(provider, channelId). * * @param options - Sync configuration options - * @param options.authToken - Authorization token for access * @param options.channelId - ID of the channel (e.g., channel, inbox) to sync * @param options.timeMin - Earliest date to sync events from (inclusive) * @param callback - Function receiving (thread, ...extraArgs) for each synced conversation @@ -104,7 +79,6 @@ export type MessagingTool = { TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; channelId: string; } & MessageSyncOptions, callback: TCallback, @@ -114,9 +88,8 @@ export type MessagingTool = { /** * Stops synchronizing messages from a specific channel. * - * @param authToken - Authorization token for access * @param channelId - ID of the channel to stop syncing * @returns Promise that resolves when sync is stopped */ - stopSync(authToken: string, channelId: string): Promise; + stopSync(channelId: string): Promise; }; diff --git a/twister/src/common/projects.ts b/twister/src/common/projects.ts index d020067..47bdb45 100644 --- a/twister/src/common/projects.ts +++ b/twister/src/common/projects.ts @@ -1,28 +1,16 @@ import type { Activity, - ActivityLink, ActivityMeta, NewActivityWithNotes, Serializable, } from "../index"; -/** - * Represents a successful project management service authorization. - * - * Returned by project management tools when authorization completes successfully. - * The auth token is an opaque identifier that can be used for subsequent - * project operations. - */ -export type ProjectAuth = { - /** Opaque token for project management operations */ - authToken: string; -}; - /** * Represents a project from an external project management service. * * Contains metadata about a specific project/board/workspace that can be synced - * with Plot. + * with Plot. Different project providers may have additional + * provider-specific properties. */ export type Project = { /** Unique identifier for the project within the provider */ @@ -52,55 +40,39 @@ export type ProjectSyncOptions = { * All synced issues/tasks are converted to ActivityWithNotes objects. * Each issue becomes an Activity with Notes for the description and comments. * + * **Architecture: Tools Build, Twists Save** + * + * Project tools follow Plot's core architectural principle: + * - **Tools**: Fetch external data and transform it into Plot format (NewActivity objects) + * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) + * + * **Implementation Pattern:** + * 1. Authorization is handled via the twist edit modal (Integrations provider config) + * 2. Tool declares providers and lifecycle callbacks in build() + * 3. onAuthorized lists available projects and calls setSyncables() + * 4. User enables projects in the modal → onSyncEnabled fires + * 5. **Tool builds NewActivity objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createActivity/updateActivity + * * **Recommended Data Sync Strategy:** * Use Activity.source (issue URL) and Note.key for automatic upserts. * See SYNC_STRATEGIES.md for detailed patterns. */ export type ProjectTool = { - /** - * Initiates the authorization flow for the service. - * - * @param callback - Function receiving (auth, ...extraArgs) when auth completes - * @param extraArgs - Additional arguments to pass to the callback (type-checked) - * @returns Promise resolving to an ActivityLink to initiate the auth flow - */ - requestAuth< - TArgs extends Serializable[], - TCallback extends (auth: ProjectAuth, ...args: TArgs) => any - >( - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - /** * Retrieves the list of projects accessible to the user. * - * @param authToken - Authorization token from successful auth flow + * @param projectId - A project ID to use for auth lookup * @returns Promise resolving to array of available projects */ - getProjects(authToken: string): Promise; + getProjects(projectId: string): Promise; /** * Begins synchronizing issues from a specific project. * - * Issues and tasks are converted to NewActivityWithNotes objects. - * - * **Recommended Implementation** (Strategy 2 - Upsert via Source/Key): - * - Set Activity.source to the issue's canonical URL (e.g., Linear issue URL, Jira issue URL) - * - Use Note.key for issue details: - * - key: "description" for issue description (upserts on changes) - * - key: "metadata" for status, priority, assignee, etc. - * - key: "comment-{commentId}" for individual comments (unique per comment) - * - No manual ID tracking needed - Plot handles deduplication automatically - * - Send NewActivityWithNotes for all issues (creates new or updates existing) - * - Set activity.unread = false for initial sync, omit for incremental updates - * - * **Alternative** (Strategy 3 - Advanced cases): - * - Use Uuid.Generate() and store ID mappings when creating multiple activities per issue - * - See SYNC_STRATEGIES.md for when this is appropriate + * Auth is obtained automatically via integrations.get(provider, projectId). * * @param options - Sync configuration options - * @param options.authToken - Authorization token for access * @param options.projectId - ID of the project to sync * @param options.timeMin - Earliest date to sync issues from (inclusive) * @param callback - Function receiving (activity, ...extraArgs) for each synced issue @@ -112,7 +84,6 @@ export type ProjectTool = { TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any >( options: { - authToken: string; projectId: string; } & ProjectSyncOptions, callback: TCallback, @@ -122,11 +93,10 @@ export type ProjectTool = { /** * Stops synchronizing issues from a specific project. * - * @param authToken - Authorization token for access * @param projectId - ID of the project to stop syncing * @returns Promise that resolves when sync is stopped */ - stopSync(authToken: string, projectId: string): Promise; + stopSync(projectId: string): Promise; /** * Updates an issue/task with new values. @@ -134,16 +104,13 @@ export type ProjectTool = { * Optional method for bidirectional sync. When implemented, allows Plot to * sync activity updates back to the external service. * - * Uses the combination of start and done to determine workflow state: - * - done set → Completed/Done state - * - done null + start set → In Progress/Active state - * - done null + start null → Backlog/Todo state + * Auth is obtained automatically via integrations.get(provider, projectId) + * using the projectId from activity.meta. * - * @param authToken - Authorization token for access * @param activity - The updated activity * @returns Promise that resolves when the update is synced */ - updateIssue?(authToken: string, activity: Activity): Promise; + updateIssue?(activity: Activity): Promise; /** * Adds a comment to an issue/task. @@ -151,16 +118,15 @@ export type ProjectTool = { * Optional method for bidirectional sync. When implemented, allows Plot to * sync notes added to activities back as comments on the external service. * - * The tool should extract its own ID from meta (e.g., linearId, taskGid, issueKey). + * Auth is obtained automatically. The tool should extract its own ID + * from meta (e.g., linearId, taskGid, issueKey). * - * @param authToken - Authorization token for access * @param meta - Activity metadata containing the tool's issue/task identifier * @param body - The comment text content * @param noteId - Optional Plot note ID, used by tools that support comment metadata (e.g. Jira) * @returns The external comment key (e.g. "comment-123") for dedup, or void */ addIssueComment?( - authToken: string, meta: ActivityMeta, body: string, noteId?: string, diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index d5038c9..9fefebd 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -1,81 +1,130 @@ -import { type Actor, type ActorId, type ActivityLink, ITool, Serializable } from ".."; +import { type Actor, type ActorId, ITool, Serializable } from ".."; +import type { Uuid } from "../utils/uuid"; /** - * Built-in tool for managing OAuth authentication flows. + * A resource that can be synced (e.g., a calendar, project, channel). + * Returned by getSyncables() and managed by users in the twist setup/edit modal. + */ +export type Syncable = { + /** External ID shared across users (e.g., Google calendar ID) */ + id: string; + /** Display name shown in the UI */ + title: string; +}; + +/** + * Configuration for an OAuth provider in a tool's build options. + * Declares the provider, scopes, and lifecycle callbacks. + */ +export type IntegrationProviderConfig = { + /** The OAuth provider */ + provider: AuthProvider; + /** OAuth scopes to request */ + scopes: string[]; + /** Returns available syncables for the authorized actor. Must not use Plot tool. */ + getSyncables: (auth: Authorization, token: AuthToken) => Promise; + /** Called when a syncable resource is enabled for syncing */ + onSyncEnabled: (syncable: Syncable) => Promise; + /** Called when a syncable resource is disabled */ + onSyncDisabled: (syncable: Syncable) => Promise; +}; + +/** + * Options passed to Integrations in the build() method. + */ +export type IntegrationOptions = { + /** Provider configurations with lifecycle callbacks */ + providers: IntegrationProviderConfig[]; +}; + +/** + * Built-in tool for managing OAuth authentication and syncable resources. * - * The Integrations tool provides a unified interface for requesting user authorization - * from external service providers like Google and Microsoft. It handles the - * OAuth flow creation, token management, and callback integration. + * The redesigned Integrations tool: + * 1. Declares providers/scopes in build options with lifecycle callbacks + * 2. Manages syncable resources (calendars, projects, etc.) per actor + * 3. Returns tokens for the user who enabled sync on a syncable + * 4. Supports per-actor auth via actAs() for write-back operations + * + * Auth and syncable management is handled in the twist edit modal in Flutter, + * removing the need for tools to create auth activities or selection UIs. * * @example * ```typescript - * class CalendarTool extends Tool { - * private auth: Integrations; + * class CalendarTool extends Tool { + * static readonly PROVIDER = AuthProvider.Google; + * static readonly SCOPES = ["https://www.googleapis.com/auth/calendar"]; * - * constructor(id: string, tools: ToolBuilder) { - * super(); - * this.integrations = tools.get(Integrations); + * build(build: ToolBuilder) { + * return { + * integrations: build(Integrations, { + * providers: [{ + * provider: AuthProvider.Google, + * scopes: CalendarTool.SCOPES, + * getSyncables: this.getSyncables, + * onSyncEnabled: this.onSyncEnabled, + * onSyncDisabled: this.onSyncDisabled, + * }] + * }), + * }; * } * - * async requestAuth() { - * return await this.integrations.request({ - * provider: AuthProvider.Google, - * scopes: ["https://www.googleapis.com/auth/calendar.readonly"] - * }, { - * functionName: "onAuthComplete", - * context: { provider: "google" } - * }); - * } - * - * async onAuthComplete(authResult: Authorization, context: any) { - * const authToken = await this.integrations.get(authResult.provider, authResult.actor.id); + * async getSyncables(auth: Authorization, token: AuthToken): Promise { + * const calendars = await this.listCalendars(token); + * return calendars.map(c => ({ id: c.id, title: c.name })); * } * } * ``` */ export abstract class Integrations extends ITool { /** - * Initiates an OAuth authentication flow. + * Merge scopes from multiple tools, deduplicating. + * + * @param scopeArrays - Arrays of scopes to merge + * @returns Deduplicated array of scopes + */ + static MergeScopes(...scopeArrays: string[][]): string[] { + return Array.from(new Set(scopeArrays.flat())); + } + + /** + * Retrieves an access token for a syncable resource. * - * Creates an authentication link that users can click to authorize access - * to the specified provider with the requested scopes. When authorization - * completes, the callback will be invoked with the Authorization and any extraArgs. + * Returns the token of the user who enabled sync on the given syncable. + * If the syncable is not enabled or the token is expired/invalid, returns null. * - * @param auth - Authentication configuration - * @param auth.provider - The OAuth provider to authenticate with - * @param auth.scopes - Array of OAuth scopes to request - * @param callback - Function receiving (authorization, ...extraArgs) - * @param extraArgs - Additional arguments to pass to the callback (type-checked, must be serializable) - * @returns Promise resolving to an ActivityLink for the auth flow + * @param provider - The OAuth provider + * @param syncableId - The syncable resource ID (e.g., calendar ID) + * @returns Promise resolving to the access token or null */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract request< - TArgs extends Serializable[], - TCallback extends (auth: Authorization, ...args: TArgs) => any - >( - auth: { - provider: AuthProvider; - scopes: string[]; - }, - callback: TCallback, - ...extraArgs: TArgs - ): Promise; + abstract get(provider: AuthProvider, syncableId: string): Promise; /** - * Retrieves an access token (refreshing it first if necessary). - * - * Looks up the token by provider and actor ID. If the given actor hasn't - * directly authenticated but is linked (same user_id) to a contact that has, - * returns that linked contact's token. + * Execute a callback as a specific actor, requesting auth if needed. * - * Returns null if no valid token is found. + * If the actor has a valid token, calls the callback immediately with it. + * If the actor has no token, creates a private auth note in the specified + * activity prompting them to connect. Once they authorize, this callback fires. * - * @param provider - The OAuth provider to retrieve a token for - * @param actorId - The actor (contact) ID to look up - * @returns Promise resolving to the access token or null if no longer available + * @param provider - The OAuth provider + * @param actorId - The actor to act as + * @param activityId - The activity to create an auth note in (if needed) + * @param callback - Function to call with the token + * @param extraArgs - Additional arguments to pass to the callback */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract get(provider: AuthProvider, actorId: ActorId): Promise; + abstract actAs< + TArgs extends Serializable[], + TCallback extends (token: AuthToken, ...args: TArgs) => any + >( + provider: AuthProvider, + actorId: ActorId, + activityId: Uuid, + callback: TCallback, + ...extraArgs: TArgs + ): Promise; + } /** diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index 96931d2..d17d032 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -447,7 +447,7 @@ export abstract class Plot extends ITool { * @returns Promise resolving to the complete created priority */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract createPriority(priority: NewPriority): Promise; + abstract createPriority(priority: NewPriority): Promise; /** * Retrieves a priority by ID or key. diff --git a/twists/calendar-sync/src/index.ts b/twists/calendar-sync/src/index.ts index 08bd55e..e9e30ab 100644 --- a/twists/calendar-sync/src/index.ts +++ b/twists/calendar-sync/src/index.ts @@ -1,31 +1,12 @@ import { GoogleCalendar } from "@plotday/tool-google-calendar"; import { OutlookCalendar } from "@plotday/tool-outlook-calendar"; import { - type Activity, - type ActivityLink, - ActivityLinkType, - ActivityType, - type Actor, type NewActivityWithNotes, type Priority, type ToolBuilder, Twist, } from "@plotday/twister"; -import type { - Calendar, - CalendarAuth, - CalendarTool, - SyncOptions, -} from "@plotday/twister/common/calendar"; import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; -import { Uuid } from "@plotday/twister/utils/uuid"; - -type CalendarProvider = "google" | "outlook"; - -type StoredCalendarAuth = { - provider: CalendarProvider; - authToken: string; -}; export default class CalendarSyncTwist extends Twist { build(build: ToolBuilder) { @@ -40,286 +21,17 @@ export default class CalendarSyncTwist extends Twist { }; } - private getProviderTool(provider: CalendarProvider): CalendarTool { - switch (provider) { - case "google": - return this.tools.googleCalendar; - case "outlook": - return this.tools.outlookCalendar; - default: - throw new Error(`Unknown calendar provider: ${provider}`); - } - } - - private async getStoredAuths(): Promise { - const stored = await this.get("calendar_auths"); - return stored || []; - } - - private async addStoredAuth( - provider: CalendarProvider, - authToken: string - ): Promise { - const auths = await this.getStoredAuths(); - const existingIndex = auths.findIndex((auth) => auth.provider === provider); - - if (existingIndex >= 0) { - auths[existingIndex].authToken = authToken; - } else { - auths.push({ provider, authToken }); - } - - await this.set("calendar_auths", auths); - } - - private async getAuthToken( - provider: CalendarProvider - ): Promise { - const auths = await this.getStoredAuths(); - const auth = auths.find((auth) => auth.provider === provider); - return auth?.authToken || null; - } - - private async getParentActivity(): Promise | undefined> { - const id = await this.get("connect_calendar_activity_id"); - return id ? { id } : undefined; - } - - async activate(_priority: Pick, context?: { actor: Actor }) { - // Get auth links from both calendar tools - const googleAuthLink = await this.tools.googleCalendar.requestAuth( - this.onAuthComplete, - "google" - ); - const outlookAuthLink = await this.tools.outlookCalendar.requestAuth( - this.onAuthComplete, - "outlook" - ); - - // Create onboarding activity — private so only the installing user sees it - const connectActivityId = await this.tools.plot.createActivity({ - type: ActivityType.Action, - title: "Connect your calendar", - private: true, - start: new Date(), - end: null, - notes: [ - { - content: - "Connect a calendar account to get started. You can connect as many as you like.", - links: [googleAuthLink, outlookAuthLink], - ...(context?.actor ? { mentions: [{ id: context.actor.id }] } : {}), - }, - ], - }); - - // Store the original activity ID for use as parent - await this.set("connect_calendar_activity_id", connectActivityId); - } - - async getCalendars(provider: CalendarProvider): Promise { - const authToken = await this.getAuthToken(provider); - if (!authToken) { - throw new Error(`${provider} Calendar not authenticated`); - } - - const tool = this.getProviderTool(provider); - return await tool.getCalendars(authToken); - } - - async startSync( - provider: CalendarProvider, - calendarId: string, - _options?: SyncOptions - ): Promise { - const authToken = await this.getAuthToken(provider); - if (!authToken) { - throw new Error(`${provider} Calendar not authenticated`); - } - - const tool = this.getProviderTool(provider); - - // Start sync with event handling callback - await tool.startSync( - { - authToken, - calendarId, - }, - this.handleEvent, - provider, - calendarId - ); - } - - async stopSync( - provider: CalendarProvider, - calendarId: string - ): Promise { - const authToken = await this.getAuthToken(provider); - if (!authToken) { - throw new Error(`${provider} Calendar not authenticated`); - } - - const tool = this.getProviderTool(provider); - await tool.stopSync(authToken, calendarId); - } - - async getAllCalendars(): Promise< - { provider: CalendarProvider; calendars: Calendar[] }[] - > { - const results = []; - const auths = await this.getStoredAuths(); - - for (const auth of auths) { - try { - const calendars = await this.getCalendars(auth.provider); - results.push({ provider: auth.provider, calendars }); - } catch (error) { - console.warn(`Failed to get ${auth.provider} calendars:`, error); - } - } - - return results; + async activate(_priority: Pick) { + // Auth and calendar selection are now handled in the twist edit modal. } async handleEvent( activity: NewActivityWithNotes, - provider: CalendarProvider, + _provider: string, _calendarId: string ): Promise { - - // Add provider to meta for routing updates back to the correct tool - activity.meta = { ...activity.meta, provider }; - // Just create/upsert - database handles everything automatically // Note: The unread field is already set by the tool based on sync type await this.tools.plot.createActivity(activity); } - - async onAuthComplete( - authResult: CalendarAuth, - provider: CalendarProvider - ): Promise { - if (!provider) { - console.error("No provider specified in auth context"); - return; - } - - // Store the auth token for later use - await this.addStoredAuth(provider, authResult.authToken); - - try { - // Fetch available calendars for this provider - const tool = this.getProviderTool(provider); - const calendars = await tool.getCalendars(authResult.authToken); - - if (calendars.length === 0) { - const activity = await this.getParentActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `I couldn't find any calendars for that account.`, - }); - } else { - console.warn("No parent activity found for no calendars note"); - } - return; - } - - // Create calendar selection activity - await this.createCalendarSelectionActivity( - provider, - calendars, - authResult.authToken - ); - } catch (error) { - console.error(`Failed to fetch calendars for ${provider}:`, error); - } - } - - private async createCalendarSelectionActivity( - provider: CalendarProvider, - calendars: Calendar[], - authToken: string - ): Promise { - const activity = await this.getParentActivity(); - if (!activity) { - console.error("No parent activity found for calendar selection note"); - return; - } - - const links: ActivityLink[] = []; - - // Create callback links for each calendar - for (const calendar of calendars) { - const token = await this.linkCallback( - this.onCalendarSelected, - provider, - calendar.id, - calendar.name, - authToken - ); - - if (calendar.primary) { - links.unshift({ - title: `📅 ${calendar.name} (Primary)`, - type: ActivityLinkType.callback, - callback: token, - }); - } else { - links.push({ - title: `📅 ${calendar.name}`, - type: ActivityLinkType.callback, - callback: token, - }); - } - } - - // Create the calendar selection activity - const providerName = provider === "google" ? "Google" : "Outlook"; - await this.tools.plot.createNote({ - activity, - content: `Which ${providerName} calendars you'd like to sync?`, - links, - }); - } - - async onCalendarSelected( - _link: ActivityLink, - provider: CalendarProvider, - calendarId: string, - calendarName: string, - authToken: string - ): Promise { - try { - // Start sync for the selected calendar - const tool = this.getProviderTool(provider); - - // Start sync with event handling callback - await tool.startSync( - { - authToken, - calendarId, - }, - this.handleEvent, - provider, - calendarId - ); - - const activity = await this.getParentActivity(); - if (!activity) { - console.warn("No parent activity found for calendar sync note"); - return; - } - await this.tools.plot.createNote({ - activity, - content: `Reading your ${calendarName} calendar.`, - }); - } catch (error) { - console.error( - `Failed to start sync for calendar ${calendarName}:`, - error - ); - } - } } diff --git a/twists/document-actions/src/index.ts b/twists/document-actions/src/index.ts index 9ac8361..6d666b2 100644 --- a/twists/document-actions/src/index.ts +++ b/twists/document-actions/src/index.ts @@ -1,23 +1,14 @@ import { GoogleDrive } from "@plotday/tool-google-drive"; import { - type Activity, - type ActivityLink, - ActivityLinkType, - ActivityType, - ActorType, type NewActivityWithNotes, type Note, type Priority, - type Actor, + ActorType, type ToolBuilder, Twist, } from "@plotday/twister"; -import type { - DocumentAuth, - DocumentTool, -} from "@plotday/twister/common/documents"; +import type { DocumentTool } from "@plotday/twister/common/documents"; import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; -import { Uuid } from "@plotday/twister/utils/uuid"; /** * Document Actions Twist @@ -50,111 +41,8 @@ export default class DocumentActions extends Twist { return this.tools.googleDrive; } - // --- Lifecycle --- - - /** - * Called when twist is activated. - * Creates a private onboarding activity with Google Drive auth link. - */ - async activate(_priority: Pick, context?: { actor: Actor }) { - const authLink = await this.tools.googleDrive.requestAuth( - this.onAuthComplete, - "google-drive" - ); - - const activityId = await this.tools.plot.createActivity({ - type: ActivityType.Action, - title: "Connect Google Drive", - private: true, - notes: [ - { - content: - "Connect your Google Drive account to sync documents and comments to Plot.", - links: [authLink], - ...(context?.actor ? { mentions: [{ id: context.actor.id }] } : {}), - }, - ], - }); - - await this.set("onboarding_activity_id", activityId); - } - - /** - * Called when OAuth completes. - * Fetches available folders and presents selection UI. - */ - async onAuthComplete(auth: DocumentAuth, _provider: string) { - await this.set("auth_token", auth.authToken); - - // Fetch folders - const folders = await this.tools.googleDrive.getFolders(auth.authToken); - - if (folders.length === 0) { - await this.updateOnboardingActivity("No Google Drive folders found."); - return; - } - - // Create folder selection links - const links: Array = await Promise.all( - folders.map(async (folder) => ({ - type: ActivityLinkType.callback as const, - title: folder.name, - callback: await this.linkCallback( - this.onFolderSelected, - folder.id, - folder.name - ), - })) - ); - - // Add folder selection to onboarding activity - const activity = await this.getParentActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: "Choose which Google Drive folders to sync:", - links, - }); - } - } - - /** - * Called when user selects a folder to sync. - */ - async onFolderSelected( - _link: ActivityLink, - folderId: string, - folderName: string - ) { - const authToken = await this.get("auth_token"); - if (!authToken) { - throw new Error("No auth token found"); - } - - // Track synced folders - const synced = (await this.get("synced_folders")) || []; - if (!synced.includes(folderId)) { - synced.push(folderId); - await this.set("synced_folders", synced); - } - - // Notify user - const activity = await this.getParentActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `Syncing documents from "${folderName}". They will appear shortly.`, - }); - } - - // Start sync - await this.tools.googleDrive.startSync( - { - authToken, - folderId, - }, - this.onDocument - ); + async activate(_priority: Pick) { + // Auth and folder selection are now handled in the twist edit modal. } /** @@ -198,57 +86,32 @@ export default class DocumentActions extends Twist { commentId = await this.resolveCommentId(note); } - // Try the note author's credentials first, then fall back to installer auth - const actorId = note.author.id as string; - const installerAuthToken = await this.get("auth_token"); - - const authTokensToTry = [ - actorId, - ...(installerAuthToken && installerAuthToken !== actorId - ? [installerAuthToken] - : []), - ]; - - for (const authToken of authTokensToTry) { - try { - let commentKey: string | void; - if (commentId && tool.addDocumentReply) { - // Reply to existing comment thread - commentKey = await tool.addDocumentReply( - authToken, - activity.meta, - commentId, - note.content, - note.id - ); - } else if (tool.addDocumentComment) { - // Top-level comment - commentKey = await tool.addDocumentComment( - authToken, - activity.meta, - note.content, - note.id - ); - } else { - return; - } - if (commentKey) { - await this.tools.plot.updateNote({ id: note.id, key: commentKey }); - } - return; // Success - } catch (error) { - if ( - authToken === actorId && - installerAuthToken && - installerAuthToken !== actorId - ) { - console.warn( - `Actor ${actorId} has no auth, falling back to installer` - ); - continue; - } - console.error("Failed to sync note to provider:", error); + try { + // Tool resolves auth token internally via integrations + let commentKey: string | void; + if (commentId && tool.addDocumentReply) { + // Reply to existing comment thread + commentKey = await tool.addDocumentReply( + activity.meta, + commentId, + note.content, + note.id + ); + } else if (tool.addDocumentComment) { + // Top-level comment + commentKey = await tool.addDocumentComment( + activity.meta, + note.content, + note.id + ); + } else { + return; + } + if (commentKey) { + await this.tools.plot.updateNote({ id: note.id, key: commentKey }); } + } catch (error) { + console.error("Failed to sync note to provider:", error); } } @@ -293,47 +156,4 @@ export default class DocumentActions extends Twist { return null; } - - /** - * Called when twist is deactivated. - * Stops all syncs and cleans up state. - */ - async deactivate() { - const synced = (await this.get("synced_folders")) || []; - const authToken = await this.get("auth_token"); - - if (authToken) { - for (const folderId of synced) { - try { - await this.tools.googleDrive.stopSync(authToken, folderId); - } catch (error) { - console.warn( - `Failed to stop sync for folder ${folderId}:`, - error - ); - } - } - } - - await this.clear("auth_token"); - await this.clear("synced_folders"); - await this.clear("onboarding_activity_id"); - } - - // --- Helpers --- - - private async getParentActivity(): Promise | undefined> { - const id = await this.get("onboarding_activity_id"); - return id ? { id } : undefined; - } - - private async updateOnboardingActivity(message: string) { - const activity = await this.getParentActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: message, - }); - } - } } diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index 61745cf..cd2b8ab 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -2,39 +2,18 @@ import { Type } from "typebox"; import { Slack } from "@plotday/tool-slack"; import { - type Activity, - type ActivityLink, - ActivityLinkType, ActivityType, - type Actor, type NewActivityWithNotes, type Priority, type ToolBuilder, Twist, } from "@plotday/twister"; -import type { - MessageChannel, - MessagingAuth, - MessagingTool, -} from "@plotday/twister/common/messaging"; import { AI, type AIMessage } from "@plotday/twister/tools/ai"; import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; import { Uuid } from "@plotday/twister/utils/uuid"; type MessageProvider = "slack"; -type StoredMessagingAuth = { - provider: MessageProvider; - authToken: string; -}; - -type ChannelConfig = { - provider: MessageProvider; - channelId: string; - channelName: string; - authToken: string; -}; - type ThreadTask = { threadId: string; taskId: Uuid; @@ -55,69 +34,14 @@ export default class MessageTasksTwist extends Twist { }; } - // ============================================================================ - // Provider Tool Helper - // ============================================================================ - - private getProviderTool(provider: MessageProvider): MessagingTool { - switch (provider) { - case "slack": - return this.tools.slack; - default: - throw new Error(`Unknown messaging provider: ${provider}`); - } + async activate(_priority: Pick) { + // Auth and channel selection are now handled in the twist edit modal. } // ============================================================================ - // Storage Helpers + // Thread Task Storage // ============================================================================ - private async getOnboardingActivity(): Promise< - Pick | undefined - > { - const id = await this.get("onboarding_activity_id"); - return id ? { id } : undefined; - } - - private async getStoredAuths(): Promise { - return (await this.get("messaging_auths")) || []; - } - - private async addStoredAuth( - provider: MessageProvider, - authToken: string - ): Promise { - const auths = await this.getStoredAuths(); - const existingIndex = auths.findIndex((a) => a.provider === provider); - - if (existingIndex >= 0) { - auths[existingIndex].authToken = authToken; - } else { - auths.push({ provider, authToken }); - } - - await this.set("messaging_auths", auths); - } - - private async getChannelConfigs(): Promise { - return (await this.get("channel_configs")) || []; - } - - private async addChannelConfig(config: ChannelConfig): Promise { - const configs = await this.getChannelConfigs(); - const existingIndex = configs.findIndex( - (c) => c.provider === config.provider && c.channelId === config.channelId - ); - - if (existingIndex >= 0) { - configs[existingIndex] = config; - } else { - configs.push(config); - } - - await this.set("channel_configs", configs); - } - private async getThreadTask(threadId: string): Promise { const tasks = (await this.get("thread_tasks")) || []; return tasks.find((t) => t.threadId === threadId) || null; @@ -143,183 +67,6 @@ export default class MessageTasksTwist extends Twist { } } - // ============================================================================ - // Activation & Onboarding - // ============================================================================ - - async activate(_priority: Pick, context?: { actor: Actor }) { - // Request auth from Slack - const slackAuthLink = await this.tools.slack.requestAuth( - this.onAuthComplete, - "slack" - ); - - // Create onboarding activity — private so only the installing user sees it - const connectActivityId = await this.tools.plot.createActivity({ - type: ActivityType.Action, - title: "Connect messaging to create tasks", - private: true, - start: new Date(), - notes: [ - { - content: - "I'll analyze your message threads and create tasks when action is needed.", - links: [slackAuthLink], - ...(context?.actor ? { mentions: [{ id: context.actor.id }] } : {}), - }, - ], - }); - - // Store for parent relationship - await this.set("onboarding_activity_id", connectActivityId); - } - - // ============================================================================ - // Auth Flow - // ============================================================================ - - async onAuthComplete( - authResult: MessagingAuth, - provider: MessageProvider - ): Promise { - if (!provider) { - console.error("No provider specified in auth context"); - return; - } - - // Store auth token - await this.addStoredAuth(provider, authResult.authToken); - - try { - // Fetch available channels - const tool = this.getProviderTool(provider); - const channels = await tool.getChannels(authResult.authToken); - - if (channels.length === 0) { - const activity = await this.getOnboardingActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `No channels found for ${provider}.`, - }); - } - return; - } - - // Create channel selection activity - await this.createChannelSelectionActivity( - provider, - channels, - authResult.authToken - ); - } catch (error) { - console.error(`Failed to fetch channels for ${provider}:`, error); - const activity = await this.getOnboardingActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `Failed to connect to ${provider}. Please try again.`, - }); - } - } - } - - private async createChannelSelectionActivity( - provider: MessageProvider, - channels: MessageChannel[], - authToken: string - ): Promise { - const links: ActivityLink[] = []; - - // Create callback link for each channel - for (const channel of channels) { - const token = await this.linkCallback( - this.onChannelSelected, - provider, - channel.id, - channel.name, - authToken - ); - - if (channel.primary) { - links.unshift({ - title: `💬 ${channel.name} (Primary)`, - type: ActivityLinkType.callback, - callback: token, - }); - } else { - links.push({ - title: `💬 ${channel.name}`, - type: ActivityLinkType.callback, - callback: token, - }); - } - } - - // Create the channel selection activity - const activity = await this.getOnboardingActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `Which ${provider} channels should I monitor?`, - links, - }); - } - } - - async onChannelSelected( - _link: ActivityLink, - provider: MessageProvider, - channelId: string, - channelName: string, - authToken: string - ): Promise { - try { - // Store channel config - await this.addChannelConfig({ - provider, - channelId, - channelName, - authToken, - }); - - // Start syncing the channel - const tool = this.getProviderTool(provider); - - await tool.startSync( - { - authToken, - channelId, - timeMin: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Last 7 days - }, - this.onMessageThread, - provider, - channelId - ); - - - const activity = await this.getOnboardingActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `Now monitoring #${channelName} for actionable threads`, - }); - } - } catch (error) { - console.error( - `Failed to start monitoring channel ${channelName}:`, - error - ); - const activity = await this.getOnboardingActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `Failed to monitor #${channelName}. Please try again.`, - }); - } - } - } - // ============================================================================ // Message Thread Processing // ============================================================================ @@ -337,7 +84,6 @@ export default class MessageTasksTwist extends Twist { return; } - // Check if we already have a task for this thread const existingTask = await this.getThreadTask(threadId); @@ -351,9 +97,7 @@ export default class MessageTasksTwist extends Twist { // Analyze thread with AI to see if it needs a task const analysis = await this.analyzeThread(thread); - if (!analysis.needsTask || analysis.confidence < 0.6) { - // No task needed or low confidence return; } @@ -468,7 +212,7 @@ If a task is needed, create a clear, actionable title that describes what the us taskNote: string | null; confidence: number; }, - provider: MessageProvider, + _provider: MessageProvider, channelId: string ): Promise { const threadId = "source" in thread ? thread.source : undefined; @@ -477,13 +221,6 @@ If a task is needed, create a clear, actionable title that describes what the us return; } - // Get channel name for context - const configs = await this.getChannelConfigs(); - const channelConfig = configs.find( - (c) => c.provider === provider && c.channelId === channelId - ); - const channelName = channelConfig?.channelName || channelId; - // Create task activity - database handles upsert automatically const taskId = await this.tools.plot.createActivity({ source: `message-tasks:${threadId}`, @@ -493,28 +230,25 @@ If a task is needed, create a clear, actionable title that describes what the us notes: analysis.taskNote ? [ { - content: `${analysis.taskNote}\n\n---\nFrom #${channelName}`, + content: `${analysis.taskNote}\n\n---\nFrom #${channelId}`, }, ] : [ { - content: `From #${channelName}`, + content: `From #${channelId}`, }, ], preview: analysis.taskNote - ? `${analysis.taskNote}\n\n---\nFrom #${channelName}` - : `From #${channelName}`, + ? `${analysis.taskNote}\n\n---\nFrom #${channelId}` + : `From #${channelId}`, meta: { originalThreadId: threadId, - provider, channelId, - channelName, }, // Use pickPriority for automatic priority matching pickPriority: { content: 50, mentions: 50 }, }); - // Store mapping await this.storeThreadTask(threadId, taskId); } diff --git a/twists/project-sync/src/index.ts b/twists/project-sync/src/index.ts index e58a513..955c52d 100644 --- a/twists/project-sync/src/index.ts +++ b/twists/project-sync/src/index.ts @@ -3,10 +3,6 @@ import { Jira } from "@plotday/tool-jira"; import { Linear } from "@plotday/tool-linear"; import { type Activity, - ActivityLink, - ActivityLinkType, - ActivityType, - type Actor, ActorType, type NewActivityWithNotes, type Note, @@ -14,20 +10,11 @@ import { type ToolBuilder, Twist, } from "@plotday/twister"; -import type { - ProjectAuth, - ProjectTool, -} from "@plotday/twister/common/projects"; +import type { ProjectTool } from "@plotday/twister/common/projects"; import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; -import { Uuid } from "@plotday/twister/utils/uuid"; type ProjectProvider = "linear" | "jira" | "asana"; -type StoredProjectAuth = { - provider: ProjectProvider; - authToken: string; -}; - /** * Project Sync Twist * @@ -68,171 +55,8 @@ export default class ProjectSync extends Twist { } } - /** - * Get stored auth for a provider - */ - private async getAuthToken( - provider: ProjectProvider - ): Promise { - const auths = await this.getStoredAuths(); - const auth = auths.find((a) => a.provider === provider); - return auth?.authToken || null; - } - - /** - * Store auth for a provider - */ - private async addStoredAuth( - provider: ProjectProvider, - authToken: string - ): Promise { - const auths = await this.getStoredAuths(); - const existingIndex = auths.findIndex((a) => a.provider === provider); - - if (existingIndex >= 0) { - auths[existingIndex].authToken = authToken; - } else { - auths.push({ provider, authToken }); - } - - await this.set("project_auths", auths); - } - - /** - * Get all stored auths - */ - private async getStoredAuths(): Promise { - return (await this.get("project_auths")) || []; - } - - /** - * Called when twist is activated - * Presents auth options for all supported providers - */ - async activate(_priority: Pick, context?: { actor: Actor }) { - // Get auth links from all providers - const linearAuthLink = await this.tools.linear.requestAuth( - this.onAuthComplete, - "linear" - ); - const jiraAuthLink = await this.tools.jira.requestAuth( - this.onAuthComplete, - "jira" - ); - const asanaAuthLink = await this.tools.asana.requestAuth( - this.onAuthComplete, - "asana" - ); - - // Create onboarding activity — private so only the installing user sees it - const activityId = await this.tools.plot.createActivity({ - type: ActivityType.Action, - title: "Connect a project management tool", - private: true, - notes: [ - { - content: - "Connect your project management account to start syncing projects and issues to Plot. Choose one:", - links: [linearAuthLink, jiraAuthLink, asanaAuthLink], - ...(context?.actor ? { mentions: [{ id: context.actor.id }] } : {}), - }, - ], - }); - - // Store for later updates - await this.set("onboarding_activity_id", activityId); - } - - /** - * Called when OAuth completes for any provider - * Fetches available projects and presents selection UI - */ - async onAuthComplete(auth: ProjectAuth, provider: ProjectProvider) { - // Store auth token for this provider - await this.addStoredAuth(provider, auth.authToken); - - // Get the tool for this provider - const tool = this.getProviderTool(provider); - - // Fetch projects - const projects = await tool.getProjects(auth.authToken); - - if (projects.length === 0) { - await this.updateOnboardingActivity(`No ${provider} projects found.`); - return; - } - - // Create project selection links - const links: Array = await Promise.all( - projects.map(async (project) => ({ - type: ActivityLinkType.callback as const, - title: project.key ? `${project.key}: ${project.name}` : project.name, - callback: await this.linkCallback( - this.onProjectSelected, - provider, - project.id, - project.name - ), - })) - ); - - // Add project selection to parent activity - const activity = await this.getParentActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `Choose which ${provider} projects you'd like to sync to Plot:`, - links, - }); - } - } - - /** - * Called when user selects a project to sync - * Initiates the sync process for that project - */ - async onProjectSelected( - _link: ActivityLink, - provider: ProjectProvider, - projectId: string, - projectName: string - ) { - const authToken = await this.getAuthToken(provider); - if (!authToken) { - throw new Error(`No ${provider} auth token found`); - } - - // Track synced projects with provider - const syncKey = `${provider}:${projectId}`; - const synced = (await this.get("synced_projects")) || []; - if (!synced.includes(syncKey)) { - synced.push(syncKey); - await this.set("synced_projects", synced); - } - - // Notify user that sync is starting - const activity = await this.getParentActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: `Great! Your issues from ${projectName} will appear shortly.`, - }); - } - - // Get the tool for this provider - const tool = this.getProviderTool(provider); - - // Start sync (full history as requested) - await tool.startSync( - { - authToken, - projectId, - // No time filter - sync all issues - }, - this.onIssue, - provider, - projectId - ); + async activate(_priority: Pick) { + // Auth and project selection are now handled in the twist edit modal. } /** @@ -251,8 +75,8 @@ export default class ProjectSync extends Twist { } /** - * Called for each issue synced from any provider - * Creates or updates Plot activities based on issue state + * Called for each issue synced from any provider. + * Creates or updates Plot activities based on issue state. */ async onIssue( issue: NewActivityWithNotes, @@ -271,8 +95,8 @@ export default class ProjectSync extends Twist { } /** - * Called when an activity created by this twist is updated - * Syncs changes back to the external service + * Called when an activity created by this twist is updated. + * Syncs changes back to the external service. */ private async onActivityUpdated( activity: Activity, @@ -285,20 +109,14 @@ export default class ProjectSync extends Twist { const provider = activity.meta?.provider as ProjectProvider | undefined; if (!provider) return; - // Get auth token for this provider - const authToken = await this.getAuthToken(provider); - if (!authToken) { - console.warn(`No auth token found for ${provider}, skipping sync`); - return; - } - const tool = this.getProviderTool(provider); try { // Sync all changes using the generic updateIssue method // Tool reads its own IDs from activity.meta (e.g., linearId, taskGid, issueKey) + // Tool resolves auth token internally via integrations if (tool.updateIssue) { - await tool.updateIssue(authToken, activity); + await tool.updateIssue(activity); } } catch (error) { console.error(`Failed to sync activity update to ${provider}:`, error); @@ -306,11 +124,10 @@ export default class ProjectSync extends Twist { } /** - * Called when a note is created on an activity created by this twist - * Syncs the note as a comment to the external service + * Called when a note is created on an activity created by this twist. + * Syncs the note as a comment to the external service. */ private async onNoteCreated(note: Note): Promise { - // Get parent activity from note const activity = note.activity; // Filter out notes created by twists to prevent loops @@ -335,94 +152,18 @@ export default class ProjectSync extends Twist { return; } - // Try the note author's credentials first (per-user auth), then fall back - // to the installer's stored auth. The tool's getClient() handles lookup - // via integrations.get(provider, actorId). - const actorId = note.author.id as string; - const installerAuthToken = await this.getAuthToken(provider); - - const authTokensToTry = [ - actorId, - ...(installerAuthToken && installerAuthToken !== actorId - ? [installerAuthToken] - : []), - ]; - - for (const authToken of authTokensToTry) { - try { - const commentKey = await tool.addIssueComment( - authToken, activity.meta, note.content, note.id - ); - if (commentKey) { - await this.tools.plot.updateNote({ id: note.id, key: commentKey }); - } - return; // Success - } catch (error) { - // If this was the actor's token, try the installer's next - if (authToken === actorId && installerAuthToken && installerAuthToken !== actorId) { - console.warn( - `Actor ${actorId} has no ${provider} auth, falling back to installer auth` - ); - continue; - } - console.error(`Failed to sync note to ${provider}:`, error); - } - } - } - - /** - * Called when twist is deactivated - * Stops all syncs and cleans up state - */ - async deactivate() { - // Stop all syncs - const synced = (await this.get("synced_projects")) || []; - - for (const syncKey of synced) { - // Parse provider:projectId format - const [provider, projectId] = syncKey.split(":") as [ - ProjectProvider, - string - ]; - - const authToken = await this.getAuthToken(provider); - if (authToken) { - const tool = this.getProviderTool(provider); - try { - await tool.stopSync(authToken, projectId); - } catch (error) { - console.warn( - `Failed to stop sync for ${provider}:${projectId}:`, - error - ); - } + try { + // Tool resolves auth token internally via integrations + const commentKey = await tool.addIssueComment( + activity.meta, + note.content, + note.id + ); + if (commentKey) { + await this.tools.plot.updateNote({ id: note.id, key: commentKey }); } - } - - // Cleanup - await this.clear("project_auths"); - await this.clear("synced_projects"); - await this.clear("onboarding_activity_id"); - } - - /** - * Get the parent onboarding activity reference - */ - private async getParentActivity(): Promise | undefined> { - const id = await this.get("onboarding_activity_id"); - return id ? { id } : undefined; - } - - /** - * Helper to update the onboarding activity with status messages - */ - private async updateOnboardingActivity(message: string) { - const activity = await this.getParentActivity(); - if (activity) { - await this.tools.plot.createNote({ - activity, - content: message, - }); + } catch (error) { + console.error(`Failed to sync note to ${provider}:`, error); } } } From 9990860bf76d6e69ce02670ffee020607fc69b1e Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 17 Feb 2026 14:22:41 -0500 Subject: [PATCH 2/3] New sync callbacks --- tools/asana/src/asana.ts | 68 +++++++++++-- tools/gmail/src/gmail.ts | 68 ++++++++++++- tools/google-calendar/src/google-calendar.ts | 97 ++++++++++++++++++- tools/google-contacts/src/google-contacts.ts | 45 +++++++-- tools/google-contacts/src/types.ts | 5 + tools/google-drive/src/google-drive.ts | 77 ++++++++++++++- tools/jira/src/jira.ts | 56 +++++++++-- tools/linear/src/linear.ts | 69 +++++++++++-- .../outlook-calendar/src/outlook-calendar.ts | 69 ++++++++++++- tools/slack/src/slack.ts | 74 +++++++++++++- twister/src/plot.ts | 82 +++++++++------- twister/src/tool.ts | 22 ++++- 12 files changed, 652 insertions(+), 80 deletions(-) diff --git a/tools/asana/src/asana.ts b/tools/asana/src/asana.ts index 62ba430..118c03f 100644 --- a/tools/asana/src/asana.ts +++ b/tools/asana/src/asana.ts @@ -2,6 +2,7 @@ import * as asana from "asana"; import { type Activity, + type ActivityFilter, type ActivityLink, ActivityLinkType, ActivityMeta, @@ -10,6 +11,7 @@ import { type NewActivityWithNotes, type NewNote, type Serializable, + type SyncToolOptions, } from "@plotday/twister"; import type { Project, @@ -46,6 +48,8 @@ type SyncState = { export class Asana extends Tool implements ProjectTool { static readonly PROVIDER = AuthProvider.Asana; static readonly SCOPES = ["default"]; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -93,17 +97,61 @@ export class Asana extends Tool implements ProjectTool { } /** - * Handle syncable enabled + * Called when a syncable project is enabled for syncing. + * Creates callback tokens from options and auto-starts sync. */ async onSyncEnabled(syncable: Syncable): Promise { await this.set(`sync_enabled_${syncable.id}`, true); + + // Create item callback token from parent's onItem handler + const itemCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + + // Create disable callback if parent provided onSyncableDisabled + if (this.options.onSyncableDisabled) { + const filter: ActivityFilter = { + meta: { syncProvider: "asana", syncableId: syncable.id }, + }; + const disableCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onSyncableDisabled, + filter + ); + await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + } + + // Auto-start sync: setup webhook and begin batch sync + await this.setupAsanaWebhook(syncable.id); + await this.startBatchSync(syncable.id); } /** - * Handle syncable disabled + * Called when a syncable project is disabled. + * Stops sync, runs disable callback, and cleans up stored tokens. */ async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); + + // Run and clean up disable callback + const disableCallbackToken = await this.get( + `disable_callback_${syncable.id}` + ); + if (disableCallbackToken) { + await this.tools.callbacks.run(disableCallbackToken); + await this.deleteCallback(disableCallbackToken); + await this.clear(`disable_callback_${syncable.id}`); + } + + // Clean up item callback + const itemCallbackToken = await this.get( + `item_callback_${syncable.id}` + ); + if (itemCallbackToken) { + await this.deleteCallback(itemCallbackToken); + await this.clear(`item_callback_${syncable.id}`); + } + await this.clear(`sync_enabled_${syncable.id}`); } @@ -160,7 +208,7 @@ export class Asana extends Tool implements ProjectTool { callback, ...extraArgs ); - await this.set(`callback_${projectId}`, callbackToken); + await this.set(`item_callback_${projectId}`, callbackToken); // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); @@ -247,7 +295,7 @@ export class Asana extends Tool implements ProjectTool { } // Retrieve callback token from storage - const callbackToken = await this.get(`callback_${projectId}`); + const callbackToken = await this.get(`item_callback_${projectId}`); if (!callbackToken) { throw new Error(`Callback token not found for project ${projectId}`); } @@ -400,6 +448,8 @@ export class Asana extends Tool implements ProjectTool { meta: { taskGid: task.gid, projectId, + syncProvider: "asana", + syncableId: projectId, }, author: authorContact, assignee: assigneeContact ?? null, // Explicitly set to null for unassigned tasks @@ -551,7 +601,7 @@ export class Asana extends Tool implements ProjectTool { } // Get callback token (needed by both handlers) - const callbackToken = await this.get(`callback_${projectId}`); + const callbackToken = await this.get(`item_callback_${projectId}`); if (!callbackToken) { console.warn("No callback token found for project:", projectId); return; @@ -656,6 +706,8 @@ export class Asana extends Tool implements ProjectTool { meta: { taskGid: task.gid, projectId, + syncProvider: "asana", + syncableId: projectId, }, author: authorContact, assignee: assigneeContact ?? null, @@ -739,6 +791,8 @@ export class Asana extends Tool implements ProjectTool { meta: { taskGid, projectId, + syncProvider: "asana", + syncableId: projectId, }, }; @@ -765,10 +819,10 @@ export class Asana extends Tool implements ProjectTool { } // Cleanup callback - const callbackToken = await this.get(`callback_${projectId}`); + const callbackToken = await this.get(`item_callback_${projectId}`); if (callbackToken) { await this.deleteCallback(callbackToken); - await this.clear(`callback_${projectId}`); + await this.clear(`item_callback_${projectId}`); } // Cleanup sync state diff --git a/tools/gmail/src/gmail.ts b/tools/gmail/src/gmail.ts index d0d70cd..bdac29e 100644 --- a/tools/gmail/src/gmail.ts +++ b/tools/gmail/src/gmail.ts @@ -1,6 +1,8 @@ import { + type ActivityFilter, type NewActivityWithNotes, Serializable, + type SyncToolOptions, Tool, type ToolBuilder, } from "@plotday/twister"; @@ -80,6 +82,8 @@ import { */ export class Gmail extends Tool implements MessagingTool { static readonly PROVIDER = AuthProvider.Google; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; static readonly SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.modify", @@ -126,10 +130,65 @@ export class Gmail extends Tool implements MessagingTool { async onSyncEnabled(syncable: Syncable): Promise { await this.set(`sync_enabled_${syncable.id}`, true); + + // Create item callback token from parent's onItem handler + const itemCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + + // Create disable callback if parent provided onSyncableDisabled + if (this.options.onSyncableDisabled) { + const filter: ActivityFilter = { + meta: { syncProvider: "google", syncableId: syncable.id }, + }; + const disableCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onSyncableDisabled, + filter + ); + await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + } + + // Auto-start sync: setup webhook and queue first batch + await this.setupChannelWebhook(syncable.id); + + const initialState: SyncState = { + channelId: syncable.id, + }; + + await this.set(`sync_state_${syncable.id}`, initialState); + + const syncCallback = await this.callback( + this.syncBatch, + 1, + "full", + syncable.id + ); + await this.run(syncCallback); } async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); + + // Run and clean up disable callback + const disableCallbackToken = await this.get( + `disable_callback_${syncable.id}` + ); + if (disableCallbackToken) { + await this.tools.callbacks.run(disableCallbackToken); + await this.deleteCallback(disableCallbackToken); + await this.clear(`disable_callback_${syncable.id}`); + } + + // Clean up item callback + const itemCallbackToken = await this.get( + `item_callback_${syncable.id}` + ); + if (itemCallbackToken) { + await this.deleteCallback(itemCallbackToken); + await this.clear(`item_callback_${syncable.id}`); + } + await this.clear(`sync_enabled_${syncable.id}`); } @@ -198,7 +257,7 @@ export class Gmail extends Tool implements MessagingTool { callback, ...extraArgs ); - await this.set(`thread_callback_token_${channelId}`, callbackToken); + await this.set(`item_callback_${channelId}`, callbackToken); // Setup webhook for this channel (Gmail Push Notifications) await this.setupChannelWebhook(channelId); @@ -240,7 +299,7 @@ export class Gmail extends Tool implements MessagingTool { await this.clear(`sync_state_${channelId}`); // Clear callback token - await this.clear(`thread_callback_token_${channelId}`); + await this.clear(`item_callback_${channelId}`); } private async setupChannelWebhook(channelId: string): Promise { @@ -322,7 +381,7 @@ export class Gmail extends Tool implements MessagingTool { channelId: string ): Promise { const callbackToken = await this.get( - `thread_callback_token_${channelId}` + `item_callback_${channelId}` ); if (!callbackToken) { @@ -337,6 +396,9 @@ export class Gmail extends Tool implements MessagingTool { if (activityThread.notes.length === 0) continue; + // Inject sync metadata for the parent to identify the source + activityThread.meta = { ...activityThread.meta, syncProvider: "google", syncableId: channelId }; + // Call parent callback with the thread (contacts will be created by the API) await this.run(callbackToken, activityThread); } catch (error) { diff --git a/tools/google-calendar/src/google-calendar.ts b/tools/google-calendar/src/google-calendar.ts index 86fbe59..1a8fca6 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/tools/google-calendar/src/google-calendar.ts @@ -13,6 +13,7 @@ import { type NewContact, type NewNote, Serializable, + type SyncToolOptions, Tag, Tool, type ToolBuilder, @@ -124,6 +125,8 @@ export class GoogleCalendar "https://www.googleapis.com/auth/calendar.calendarlist.readonly", "https://www.googleapis.com/auth/calendar.events", ]; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -168,18 +171,90 @@ export class GoogleCalendar /** * Called when a syncable calendar is enabled for syncing. + * Creates callback tokens and auto-starts sync for the calendar. */ async onSyncEnabled(syncable: Syncable): Promise { - // Store the syncable ID for later use (e.g., webhook handling) - await this.set(`sync_enabled_${syncable.id}`, true); + // Create item callback token from parent's onItem handler + const itemCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + + // Create disable callback token if parent provided onSyncableDisabled + if (this.options.onSyncableDisabled) { + const disableCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onSyncableDisabled, + { meta: { syncProvider: "google", syncableId: syncable.id } } + ); + await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + } + + // Resolve "primary" to actual calendar ID for consistent storage keys + const resolvedCalendarId = await this.resolveCalendarId(syncable.id); + + // Check if sync is already in progress + const syncInProgress = await this.get( + `sync_lock_${resolvedCalendarId}` + ); + if (syncInProgress) { + return; + } + + // Set sync lock + await this.set(`sync_lock_${resolvedCalendarId}`, true); + + // Setup webhook for this calendar + await this.setupCalendarWatch(resolvedCalendarId); + + // Default sync range: 2 years back + const now = new Date(); + const min = new Date(now.getFullYear() - 2, 0, 1); + + const initialState: SyncState = { + calendarId: resolvedCalendarId, + min, + max: null, + sequence: 1, + }; + + await this.set(`sync_state_${resolvedCalendarId}`, initialState); + + // Start first sync batch + const syncCallback = await this.callback( + this.syncBatch, + 1, + "full", + resolvedCalendarId, + true // initialSync = true + ); + await this.runTask(syncCallback); } /** * Called when a syncable calendar is disabled. + * Stops sync, runs the disable callback, and cleans up stored tokens. */ async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); - await this.clear(`sync_enabled_${syncable.id}`); + + // Run and clean up the disable callback if it exists + const disableCallbackToken = await this.get( + `disable_callback_${syncable.id}` + ); + if (disableCallbackToken) { + await this.tools.callbacks.run(disableCallbackToken); + await this.tools.callbacks.delete(disableCallbackToken); + await this.clear(`disable_callback_${syncable.id}`); + } + + // Clean up the item callback token + const itemCallbackToken = await this.get( + `item_callback_${syncable.id}` + ); + if (itemCallbackToken) { + await this.tools.callbacks.delete(itemCallbackToken); + await this.clear(`item_callback_${syncable.id}`); + } } private async getApi(calendarId: string): Promise { @@ -617,7 +692,13 @@ export class GoogleCalendar initialSync: boolean ): Promise { // Hoist callback token retrieval outside loop - saves N-1 subrequests - const callbackToken = await this.get("event_callback_token"); + // Try per-syncable key first, fall back to legacy key for backward compatibility + let callbackToken = await this.get( + `item_callback_${calendarId}` + ); + if (!callbackToken) { + callbackToken = await this.get("event_callback_token"); + } if (!callbackToken) { console.warn("No callback token found, skipping event processing"); return; @@ -691,6 +772,9 @@ export class GoogleCalendar ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only }; + // Inject sync metadata for the parent to identify the source + activity.meta = { ...activity.meta, syncProvider: "google", syncableId: calendarId }; + // Send activity - database handles upsert automatically await this.tools.callbacks.run(callbackToken, activity); continue; @@ -828,6 +912,9 @@ export class GoogleCalendar ? { type: ActivityType.Event, ...shared } : { type: ActivityType.Note, ...shared }; + // Inject sync metadata for the parent to identify the source + activity.meta = { ...activity.meta, syncProvider: "google", syncableId: calendarId }; + // Send activity - database handles upsert automatically await this.tools.callbacks.run(callbackToken, activity); } @@ -887,6 +974,7 @@ export class GoogleCalendar source: masterCanonicalUrl, start: start, end: end, + meta: { syncProvider: "google", syncableId: calendarId }, addRecurrenceExdates: [new Date(originalStartTime)], }; @@ -955,6 +1043,7 @@ export class GoogleCalendar const occurrenceUpdate = { type: ActivityType.Event, source: masterCanonicalUrl, + meta: { syncProvider: "google", syncableId: calendarId }, occurrences: [occurrence], }; diff --git a/tools/google-contacts/src/google-contacts.ts b/tools/google-contacts/src/google-contacts.ts index d8d39e4..b7531a0 100644 --- a/tools/google-contacts/src/google-contacts.ts +++ b/tools/google-contacts/src/google-contacts.ts @@ -14,7 +14,7 @@ import { } from "@plotday/twister/tools/integrations"; import { Network } from "@plotday/twister/tools/network"; -import type { GoogleContacts as IGoogleContacts } from "./types"; +import type { GoogleContacts as IGoogleContacts, GoogleContactsOptions } from "./types"; type ContactTokens = { connections?: { @@ -257,6 +257,8 @@ export default class GoogleContacts static readonly id = "google-contacts"; static readonly PROVIDER = AuthProvider.Google; + static readonly Options: GoogleContactsOptions; + declare readonly Options: GoogleContactsOptions; static readonly SCOPES = [ "https://www.googleapis.com/auth/contacts.readonly", @@ -284,12 +286,43 @@ export default class GoogleContacts return [{ id: "contacts", title: "Contacts" }]; } - async onSyncEnabled(_syncable: Syncable): Promise { - // Syncable is now enabled; sync will start when startSync is called + async onSyncEnabled(syncable: Syncable): Promise { + // Create item callback token from parent's onItem handler + const itemCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + + // Auto-start sync + const token = await this.tools.integrations.get( + GoogleContacts.PROVIDER, + syncable.id + ); + if (!token) { + throw new Error("No Google authentication token available"); + } + + const initialState: ContactSyncState = { + more: false, + }; + + await this.set(`sync_state:${syncable.id}`, initialState); + + const syncCallback = await this.callback(this.syncBatch, 1, syncable.id); + await this.run(syncCallback); } async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); + + // Clean up item callback + const itemCallbackToken = await this.get( + `item_callback_${syncable.id}` + ); + if (itemCallbackToken) { + await this.deleteCallback(itemCallbackToken); + await this.clear(`item_callback_${syncable.id}`); + } } async getContacts(syncableId: string): Promise { @@ -330,7 +363,7 @@ export default class GoogleContacts callback, ...extraArgs ); - await this.set(`contacts_callback_token:${syncableId}`, callbackToken); + await this.set(`item_callback_${syncableId}`, callbackToken); // Start initial sync const initialState: ContactSyncState = { @@ -347,7 +380,7 @@ export default class GoogleContacts async stopSync(syncableId: string): Promise { // Clear sync state for this specific syncable await this.clear(`sync_state:${syncableId}`); - await this.clear(`contacts_callback_token:${syncableId}`); + await this.clear(`item_callback_${syncableId}`); } async syncBatch(batchNumber: number, syncableId: string): Promise { @@ -402,7 +435,7 @@ export default class GoogleContacts syncableId: string ): Promise { const callbackToken = await this.get( - `contacts_callback_token:${syncableId}` + `item_callback_${syncableId}` ); if (callbackToken) { await this.run(callbackToken, contacts); diff --git a/tools/google-contacts/src/types.ts b/tools/google-contacts/src/types.ts index 6872a8f..c6178a5 100644 --- a/tools/google-contacts/src/types.ts +++ b/tools/google-contacts/src/types.ts @@ -1,5 +1,10 @@ import type { ITool, NewContact } from "@plotday/twister"; +export type GoogleContactsOptions = { + /** Callback invoked for each batch of synced contacts. */ + onItem: (contacts: NewContact[]) => Promise; +}; + export interface GoogleContacts extends ITool { getContacts(syncableId: string): Promise; diff --git a/tools/google-drive/src/google-drive.ts b/tools/google-drive/src/google-drive.ts index 41f56f4..4c112cd 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/tools/google-drive/src/google-drive.ts @@ -1,5 +1,6 @@ import GoogleContacts from "@plotday/tool-google-contacts"; import { + type ActivityFilter, type ActivityLink, ActivityLinkType, ActivityKind, @@ -8,6 +9,7 @@ import { type NewContact, type NewNote, Serializable, + type SyncToolOptions, Tag, Tool, type ToolBuilder, @@ -67,6 +69,8 @@ export class GoogleDrive implements DocumentTool { static readonly PROVIDER = AuthProvider.Google; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; static readonly SCOPES = [ "https://www.googleapis.com/auth/drive", ]; @@ -110,16 +114,79 @@ export class GoogleDrive /** * Called when a syncable folder is enabled for syncing. + * Creates callback tokens from options and auto-starts sync. */ async onSyncEnabled(syncable: Syncable): Promise { await this.set(`sync_enabled_${syncable.id}`, true); + + // Create item callback token from parent's onItem handler + const itemCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + + // Create disable callback if parent provided onSyncableDisabled + if (this.options.onSyncableDisabled) { + const filter: ActivityFilter = { + meta: { syncProvider: "google", syncableId: syncable.id }, + }; + const disableCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onSyncableDisabled, + filter + ); + await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + } + + // Auto-start sync: setup watch and queue first batch + await this.set(`sync_lock_${syncable.id}`, true); + + const api = await this.getApi(syncable.id); + const changesToken = await getChangesStartToken(api); + + const initialState: SyncState = { + folderId: syncable.id, + changesToken, + sequence: 1, + }; + + await this.set(`sync_state_${syncable.id}`, initialState); + await this.setupDriveWatch(syncable.id); + + const syncCallback = await this.callback( + this.syncBatch, + 1, + syncable.id, + true // initialSync + ); + await this.runTask(syncCallback); } /** * Called when a syncable folder is disabled. + * Stops sync, runs disable callback, and cleans up stored tokens. */ async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); + + // Run and clean up disable callback + const disableCallbackToken = await this.get( + `disable_callback_${syncable.id}` + ); + if (disableCallbackToken) { + await this.tools.callbacks.run(disableCallbackToken); + await this.deleteCallback(disableCallbackToken); + await this.clear(`disable_callback_${syncable.id}`); + } + + // Clean up item callback + const itemCallbackToken = await this.get( + `item_callback_${syncable.id}` + ); + if (itemCallbackToken) { + await this.deleteCallback(itemCallbackToken); + await this.clear(`item_callback_${syncable.id}`); + } + await this.clear(`sync_enabled_${syncable.id}`); } @@ -177,7 +244,7 @@ export class GoogleDrive callback, ...extraArgs ); - await this.set(`callback_${folderId}`, callbackToken); + await this.set(`item_callback_${folderId}`, callbackToken); // Get changes start token for future incremental syncs const api = await this.getApi(folderId); @@ -226,7 +293,7 @@ export class GoogleDrive await this.clear(`drive_watch_${folderId}`); await this.clear(`sync_state_${folderId}`); await this.clear(`sync_lock_${folderId}`); - await this.clear(`callback_${folderId}`); + await this.clear(`item_callback_${folderId}`); } async addDocumentComment( @@ -455,7 +522,7 @@ export class GoogleDrive const result = await listFilesInFolder(api, folderId, state.pageToken); // Process files in this batch - const callbackToken = await this.get(`callback_${folderId}`); + const callbackToken = await this.get(`item_callback_${folderId}`); if (!callbackToken) { console.warn("No callback token found, skipping file processing"); return; @@ -516,7 +583,7 @@ export class GoogleDrive console.log(`[GDRIVE] Incremental sync: ${result.changes.length} changes, token=${changesToken.substring(0, 20)}...`); - const callbackToken = await this.get(`callback_${folderId}`); + const callbackToken = await this.get(`item_callback_${folderId}`); if (!callbackToken) { console.warn("No callback token found, skipping incremental sync"); await this.clear(`sync_lock_${folderId}`); @@ -672,6 +739,8 @@ export class GoogleDrive folderId, mimeType: file.mimeType, webViewLink: file.webViewLink || null, + syncProvider: "google", + syncableId: folderId, }, notes, preview: file.description || null, diff --git a/tools/jira/src/jira.ts b/tools/jira/src/jira.ts index 19e3911..f5cac7c 100644 --- a/tools/jira/src/jira.ts +++ b/tools/jira/src/jira.ts @@ -9,6 +9,7 @@ import { type NewActivityWithNotes, NewContact, Serializable, + type SyncToolOptions, } from "@plotday/twister"; import type { Project, @@ -44,6 +45,8 @@ type SyncState = { export class Jira extends Tool implements ProjectTool { static readonly PROVIDER = AuthProvider.Atlassian; static readonly SCOPES = ["read:jira-work", "write:jira-work", "read:jira-user"]; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -105,17 +108,54 @@ export class Jira extends Tool implements ProjectTool { } /** - * Handle syncable resource being enabled + * Handle syncable resource being enabled. + * Creates callback tokens for the sync lifecycle and auto-starts sync. */ async onSyncEnabled(syncable: Syncable): Promise { await this.set(`sync_enabled_${syncable.id}`, true); + + // Create item callback token from parent's onItem handler + const itemCallback = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallback); + + // Create disable callback if parent provided onSyncableDisabled + if (this.options.onSyncableDisabled) { + const disableCallback = await this.tools.callbacks.createFromParent( + this.options.onSyncableDisabled, + { meta: { syncProvider: "atlassian", syncableId: syncable.id } } + ); + await this.set(`disable_callback_${syncable.id}`, disableCallback); + } + + // Auto-start sync: setup webhook and queue first batch + await this.setupJiraWebhook(syncable.id); + await this.startBatchSync(syncable.id); } /** - * Handle syncable resource being disabled + * Handle syncable resource being disabled. + * Stops sync, runs disable callback, and cleans up all stored tokens. */ async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); + + // Run and clean up disable callback + const disableCallback = await this.get(`disable_callback_${syncable.id}`); + if (disableCallback) { + await this.tools.callbacks.run(disableCallback); + await this.deleteCallback(disableCallback); + await this.clear(`disable_callback_${syncable.id}`); + } + + // Clean up item callback + const itemCallback = await this.get(`item_callback_${syncable.id}`); + if (itemCallback) { + await this.deleteCallback(itemCallback); + await this.clear(`item_callback_${syncable.id}`); + } + await this.clear(`sync_enabled_${syncable.id}`); } @@ -161,7 +201,7 @@ export class Jira extends Tool implements ProjectTool { callback, ...extraArgs ); - await this.set(`callback_${projectId}`, callbackToken); + await this.set(`item_callback_${projectId}`, callbackToken); // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); @@ -241,7 +281,7 @@ export class Jira extends Tool implements ProjectTool { } // Retrieve callback token from storage - const callbackToken = await this.get(`callback_${projectId}`); + const callbackToken = await this.get(`item_callback_${projectId}`); if (!callbackToken) { throw new Error(`Callback token not found for project ${projectId}`); } @@ -283,6 +323,8 @@ export class Jira extends Tool implements ProjectTool { ); // Set unread based on sync type (false for initial sync to avoid notification overload) activityWithNotes.unread = !state.initialSync; + // Inject sync metadata for filtering on disable + activityWithNotes.meta = { ...activityWithNotes.meta, syncProvider: "atlassian", syncableId: projectId }; // Execute the callback using the callback token await this.tools.callbacks.run(callbackToken, activityWithNotes); } @@ -629,7 +671,7 @@ export class Jira extends Tool implements ProjectTool { const payload = request.body as any; // Get callback token (needed by both handlers) - const callbackToken = await this.get(`callback_${projectId}`); + const callbackToken = await this.get(`item_callback_${projectId}`); if (!callbackToken) { console.warn("No callback token found for project:", projectId); return; @@ -822,10 +864,10 @@ export class Jira extends Tool implements ProjectTool { await this.clear(`webhook_id_${projectId}`); // Cleanup callback - const callbackToken = await this.get(`callback_${projectId}`); + const callbackToken = await this.get(`item_callback_${projectId}`); if (callbackToken) { await this.deleteCallback(callbackToken); - await this.clear(`callback_${projectId}`); + await this.clear(`item_callback_${projectId}`); } // Cleanup sync state diff --git a/tools/linear/src/linear.ts b/tools/linear/src/linear.ts index 12424bb..da8847d 100644 --- a/tools/linear/src/linear.ts +++ b/tools/linear/src/linear.ts @@ -15,6 +15,7 @@ import { type NewActivityWithNotes, type NewNote, Serializable, + type SyncToolOptions, } from "@plotday/twister"; import type { Project, @@ -59,6 +60,8 @@ type SyncState = { export class Linear extends Tool implements ProjectTool { static readonly PROVIDER = AuthProvider.Linear; static readonly SCOPES = ["read", "write", "admin"]; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -102,17 +105,58 @@ export class Linear extends Tool implements ProjectTool { } /** - * Called when a syncable resource is enabled for syncing + * Called when a syncable resource is enabled for syncing. + * Creates callback tokens from options and auto-starts sync. */ async onSyncEnabled(syncable: Syncable): Promise { await this.set(`sync_enabled_${syncable.id}`, true); + + // Create item callback token from parent's onItem handler + const itemCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + + // Create disable callback if parent provided onSyncableDisabled + if (this.options.onSyncableDisabled) { + const disableCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onSyncableDisabled, + { meta: { syncProvider: "linear", syncableId: syncable.id } } + ); + await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + } + + // Auto-start sync: setup webhook and begin batch sync + await this.setupLinearWebhook(syncable.id); + await this.startBatchSync(syncable.id); } /** - * Called when a syncable resource is disabled + * Called when a syncable resource is disabled. + * Runs the disable callback, then cleans up all stored state. */ async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); + + // Run and clean up disable callback + const disableCallbackToken = await this.get( + `disable_callback_${syncable.id}` + ); + if (disableCallbackToken) { + await this.tools.callbacks.run(disableCallbackToken); + await this.tools.callbacks.delete(disableCallbackToken); + await this.clear(`disable_callback_${syncable.id}`); + } + + // Clean up item callback + const itemCallbackToken = await this.get( + `item_callback_${syncable.id}` + ); + if (itemCallbackToken) { + await this.tools.callbacks.delete(itemCallbackToken); + await this.clear(`item_callback_${syncable.id}`); + } + await this.clear(`sync_enabled_${syncable.id}`); } @@ -154,7 +198,7 @@ export class Linear extends Tool implements ProjectTool { callback, ...extraArgs ); - await this.set(`callback_${projectId}`, callbackToken); + await this.set(`item_callback_${projectId}`, callbackToken); // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); @@ -245,7 +289,7 @@ export class Linear extends Tool implements ProjectTool { } // Retrieve callback token from storage - const callbackToken = await this.get(`callback_${projectId}`); + const callbackToken = await this.get(`item_callback_${projectId}`); if (!callbackToken) { throw new Error(`Callback token not found for project ${projectId}`); } @@ -275,6 +319,8 @@ export class Linear extends Tool implements ProjectTool { ); if (activity) { + // Inject sync metadata for bulk operations (e.g. disable filtering) + activity.meta = { ...activity.meta, syncProvider: "linear", syncableId: projectId }; // Execute the callback using the callback token await this.tools.callbacks.run(callbackToken, activity); } @@ -627,7 +673,7 @@ export class Linear extends Tool implements ProjectTool { } // Get callback token - const callbackToken = await this.get(`callback_${projectId}`); + const callbackToken = await this.get(`item_callback_${projectId}`); if (!callbackToken) { console.warn("No callback token found for project:", projectId); return; @@ -709,6 +755,8 @@ export class Linear extends Tool implements ProjectTool { meta: { linearId: issue.id, projectId, + syncProvider: "linear", + syncableId: projectId, }, preview: issue.description || null, }; @@ -766,6 +814,8 @@ export class Linear extends Tool implements ProjectTool { meta: { linearId: issueId, projectId, + syncProvider: "linear", + syncableId: projectId, }, }; @@ -791,13 +841,20 @@ export class Linear extends Tool implements ProjectTool { // Cleanup webhook secret await this.clear(`webhook_secret_${projectId}`); - // Cleanup callback + // Cleanup callback (legacy key for backward compatibility) const callbackToken = await this.get(`callback_${projectId}`); if (callbackToken) { await this.deleteCallback(callbackToken); await this.clear(`callback_${projectId}`); } + // Cleanup item callback (new key) + const itemCallbackToken = await this.get(`item_callback_${projectId}`); + if (itemCallbackToken) { + await this.deleteCallback(itemCallbackToken); + await this.clear(`item_callback_${projectId}`); + } + // Cleanup sync state await this.clear(`sync_state_${projectId}`); } diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/tools/outlook-calendar/src/outlook-calendar.ts index 034866b..9b56f2e 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/tools/outlook-calendar/src/outlook-calendar.ts @@ -13,6 +13,7 @@ import { type NewContact, type NewNote, Serializable, + type SyncToolOptions, Tag, Tool, type ToolBuilder, @@ -115,6 +116,8 @@ export class OutlookCalendar { static readonly PROVIDER = AuthProvider.Microsoft; static readonly SCOPES = ["https://graph.microsoft.com/calendars.readwrite"]; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -146,17 +149,74 @@ export class OutlookCalendar /** * Called when a syncable calendar is enabled for syncing. + * Creates callback tokens from options and auto-starts sync. */ async onSyncEnabled(syncable: Syncable): Promise { await this.set(`sync_enabled_${syncable.id}`, true); + + // Create item callback token from parent's onItem option + const itemCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + + // Create disable callback if the parent provided one + if (this.options.onSyncableDisabled) { + const disableCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onSyncableDisabled, + { meta: { syncProvider: "microsoft", syncableId: syncable.id } } + ); + await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + } + + // Auto-start sync: setup watch and queue first batch + await this.setupOutlookWatch(syncable.id); + + // Determine default sync range (2 years into the past) + const now = new Date(); + const min = new Date(now.getFullYear() - 2, 0, 1); + + await this.set(`outlook_sync_state_${syncable.id}`, { + calendarId: syncable.id, + min, + sequence: 1, + } as SyncState); + + const syncCallback = await this.callback( + this.syncOutlookBatch, + syncable.id, + true, // initialSync + 1 // batchNumber + ); + await this.runTask(syncCallback); } /** * Called when a syncable calendar is disabled. + * Cleans up callback tokens and stops sync. */ async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); await this.clear(`sync_enabled_${syncable.id}`); + + // Run and clean up disable callback + const disableCallbackToken = await this.get( + `disable_callback_${syncable.id}` + ); + if (disableCallbackToken) { + await this.tools.callbacks.run(disableCallbackToken); + await this.deleteCallback(disableCallbackToken); + await this.clear(`disable_callback_${syncable.id}`); + } + + // Clean up item callback + const itemCallbackToken = await this.get( + `item_callback_${syncable.id}` + ); + if (itemCallbackToken) { + await this.deleteCallback(itemCallbackToken); + await this.clear(`item_callback_${syncable.id}`); + } } private async getApi(calendarId: string): Promise { @@ -213,7 +273,7 @@ export class OutlookCalendar callback, ...extraArgs ); - await this.set("event_callback_token", callbackToken); + await this.set(`item_callback_${calendarId}`, callbackToken); // Setup webhook for this calendar await this.setupOutlookWatch(calendarId); @@ -337,7 +397,7 @@ export class OutlookCalendar } // Hoist callback token retrieval outside event loop - saves N-1 subrequests - const callbackToken = await this.get("event_callback_token"); + const callbackToken = await this.get(`item_callback_${calendarId}`); if (!callbackToken) { console.warn("No callback token found, skipping event processing"); return; @@ -436,6 +496,7 @@ export class OutlookCalendar : new Date(), preview: "Cancelled", source, + meta: { syncProvider: "microsoft", syncableId: calendarId }, notes: [cancelNote], ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only @@ -587,7 +648,7 @@ export class OutlookCalendar const activityWithNotes: NewActivityWithNotes = { ...activity, author: authorContact, - meta: activity.meta, + meta: { ...activity.meta, syncProvider: "microsoft", syncableId: calendarId }, tags: tags && Object.keys(tags).length > 0 ? tags : activity.tags, notes, preview: hasDescription ? outlookEvent.body!.content! : null, @@ -644,6 +705,7 @@ export class OutlookCalendar const occurrenceUpdate = { type: ActivityType.Event, source: masterCanonicalUrl, + meta: { syncProvider: "microsoft", syncableId: calendarId }, start: start, end: end, addRecurrenceExdates: [new Date(originalStart)], @@ -718,6 +780,7 @@ export class OutlookCalendar const occurrenceUpdate = { type: ActivityType.Event, source: masterCanonicalUrl, + meta: { syncProvider: "microsoft", syncableId: calendarId }, occurrences: [occurrence], }; diff --git a/tools/slack/src/slack.ts b/tools/slack/src/slack.ts index 67b7a88..b96a6fd 100644 --- a/tools/slack/src/slack.ts +++ b/tools/slack/src/slack.ts @@ -1,6 +1,8 @@ import { + type ActivityFilter, type NewActivityWithNotes, Serializable, + type SyncToolOptions, Tool, type ToolBuilder, } from "@plotday/twister"; @@ -108,6 +110,8 @@ import { */ export class Slack extends Tool implements MessagingTool { static readonly PROVIDER = AuthProvider.Slack; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; static readonly SCOPES = [ "channels:history", "channels:read", @@ -149,10 +153,71 @@ export class Slack extends Tool implements MessagingTool { async onSyncEnabled(syncable: Syncable): Promise { await this.set(`sync_enabled_${syncable.id}`, true); + + // Create item callback token from parent's onItem handler + const itemCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onItem + ); + await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + + // Create disable callback if parent provided onSyncableDisabled + if (this.options.onSyncableDisabled) { + const filter: ActivityFilter = { + meta: { syncProvider: "slack", syncableId: syncable.id }, + }; + const disableCallbackToken = await this.tools.callbacks.createFromParent( + this.options.onSyncableDisabled, + filter + ); + await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + } + + // Auto-start sync: setup webhook and queue first batch + await this.setupChannelWebhook(syncable.id); + + let oldest: string | undefined; + // Default to 30 days of history + const timeMin = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + oldest = (timeMin.getTime() / 1000).toString(); + + const initialState: SyncState = { + channelId: syncable.id, + oldest, + }; + + await this.set(`sync_state_${syncable.id}`, initialState); + + const syncCallback = await this.callback( + this.syncBatch, + 1, + "full", + syncable.id + ); + await this.run(syncCallback); } async onSyncDisabled(syncable: Syncable): Promise { await this.stopSync(syncable.id); + + // Run and clean up disable callback + const disableCallbackToken = await this.get( + `disable_callback_${syncable.id}` + ); + if (disableCallbackToken) { + await this.tools.callbacks.run(disableCallbackToken); + await this.deleteCallback(disableCallbackToken); + await this.clear(`disable_callback_${syncable.id}`); + } + + // Clean up item callback + const itemCallbackToken = await this.get( + `item_callback_${syncable.id}` + ); + if (itemCallbackToken) { + await this.deleteCallback(itemCallbackToken); + await this.clear(`item_callback_${syncable.id}`); + } + await this.clear(`sync_enabled_${syncable.id}`); } @@ -197,7 +262,7 @@ export class Slack extends Tool implements MessagingTool { callback, ...extraArgs ); - await this.set(`thread_callback_token_${channelId}`, callbackToken); + await this.set(`item_callback_${channelId}`, callbackToken); // Setup webhook for this channel (Slack Events API) await this.setupChannelWebhook(channelId); @@ -237,7 +302,7 @@ export class Slack extends Tool implements MessagingTool { await this.clear(`sync_state_${channelId}`); // Clear callback token - await this.clear(`thread_callback_token_${channelId}`); + await this.clear(`item_callback_${channelId}`); } private async setupChannelWebhook( @@ -313,7 +378,7 @@ export class Slack extends Tool implements MessagingTool { channelId: string ): Promise { const callbackToken = await this.get( - `thread_callback_token_${channelId}` + `item_callback_${channelId}` ); if (!callbackToken) { @@ -328,6 +393,9 @@ export class Slack extends Tool implements MessagingTool { if (activityThread.notes.length === 0) continue; + // Inject sync metadata for the parent to identify the source + activityThread.meta = { ...activityThread.meta, syncProvider: "slack", syncableId: channelId }; + // Call parent callback with the thread (contacts will be created by the API) await this.run(callbackToken, activityThread); } catch (error) { diff --git a/twister/src/plot.ts b/twister/src/plot.ts index ad9367d..40d2f14 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -786,13 +786,7 @@ export type NewActivity = ( Partial< Omit< ActivityFields, - | "author" - | "assignee" - | "priority" - | "tags" - | "mentions" - | "id" - | "source" + "author" | "assignee" | "priority" | "tags" | "mentions" | "id" | "source" > > & ( @@ -923,45 +917,46 @@ export type NewActivity = ( removeRecurrenceExdates?: Date[]; }; -export type ActivityUpdate = ( - | { - /** - * Unique identifier for the activity. - */ - id: Uuid; - } - | { - /** - * Canonical URL for the item in an external system. - */ - source: string; - } -) & +export type ActivityFilter = { + type?: ActorType; + meta?: { + [key: string]: JSONValue; + }; +}; + +/** + * Fields supported by bulk updates via `match`. Only simple scalar fields + * that can be applied uniformly across many activities are included. + */ +type ActivityBulkUpdateFields = Partial< + Pick +> & { + /** Update the type of all matching activities. */ + type?: ActivityType; + /** + * Timestamp when the activities were marked as complete. Null to clear. + * Setting done will automatically set the type to Action if not already. + */ + done?: Date | null; +}; + +/** + * Fields supported by single-activity updates via `id` or `source`. + * Includes all bulk fields plus scheduling, recurrence, tags, and occurrences. + */ +type ActivitySingleUpdateFields = ActivityBulkUpdateFields & Partial< Pick< ActivityFields, - | "kind" | "start" | "end" - | "title" | "assignee" - | "private" - | "archived" - | "meta" - | "order" | "recurrenceRule" | "recurrenceExdates" | "recurrenceUntil" | "recurrenceCount" > > & { - /** Update the type of the activity. */ - type?: ActivityType; - /** - * Timestamp when the activity was marked as complete. Null if not completed. - * Setting done will automatically set the type to Action if not already. - */ - done?: Date | null; /** * Tags to change on the activity. Use an empty array of NewActor to remove a tag. * Use twistTags to add/remove the twist from tags to avoid clearing other actors' tags. @@ -1032,6 +1027,16 @@ export type ActivityUpdate = ( removeRecurrenceExdates?: Date[]; }; +export type ActivityUpdate = + | (({ id: Uuid } | { source: string }) & ActivitySingleUpdateFields) + | ({ + /** + * Update all activities matching the specified criteria. Only activities + * that match all provided fields and were created by the twist will be updated. + */ + match: ActivityFilter; + } & ActivityBulkUpdateFields); + /** * Represents a note within an activity. * @@ -1075,7 +1080,10 @@ export type Note = ActivityCommon & { * - neither: A new note with auto-generated UUID will be created */ export type NewNote = Partial< - Omit + Omit< + Note, + "author" | "activity" | "tags" | "mentions" | "id" | "key" | "reNote" + > > & ({ id: Uuid } | { key: string } | {}) & { /** Reference to the parent activity (required) */ @@ -1136,7 +1144,9 @@ export type NewNote = Partial< * Must provide either id or key to identify the note to update. */ export type NoteUpdate = ({ id: Uuid; key?: string } | { key: string }) & - Partial> & { + Partial< + Pick + > & { /** * Format of the note content. Determines how the note is processed: * - 'text': Plain text that will be converted to markdown (auto-links URLs, preserves line breaks) diff --git a/twister/src/tool.ts b/twister/src/tool.ts index d5e3010..25dfce9 100644 --- a/twister/src/tool.ts +++ b/twister/src/tool.ts @@ -1,4 +1,9 @@ -import { type Actor, type Priority } from "./plot"; +import { + type Actor, + type ActivityFilter, + type NewActivityWithNotes, + type Priority, +} from "./plot"; import type { Callback } from "./tools/callbacks"; import type { InferOptions, @@ -10,6 +15,21 @@ import type { export type { ToolBuilder }; +/** + * Options for tools that sync activities from external services. + * + * @example + * ```typescript + * static readonly Options: SyncToolOptions; + * ``` + */ +export type SyncToolOptions = { + /** Callback invoked for each synced item. The tool adds sync metadata before passing it. */ + onItem: (item: NewActivityWithNotes) => Promise; + /** Callback invoked when a syncable is disabled, receiving an ActivityFilter for bulk operations. */ + onSyncableDisabled?: (filter: ActivityFilter) => Promise; +}; + /** * Abstrtact parent for both built-in tools and regular Tools. * Regular tools extend Tool. From f3ccb2f91344b927536d367cea467e4cc2efefe3 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 17 Feb 2026 22:03:31 -0500 Subject: [PATCH 3/3] Fixes for new Integrations tool --- .changeset/itchy-colts-smile.md | 5 + .changeset/silly-candies-enjoy.md | 14 +++ tools/gmail/src/gmail.ts | 16 +-- tools/google-calendar/src/google-api.ts | 2 +- tools/google-calendar/src/google-calendar.ts | 12 ++- tools/google-drive/src/google-drive.ts | 98 ++++++++----------- tools/linear/src/linear.ts | 49 ++++++---- .../outlook-calendar/src/outlook-calendar.ts | 59 ++++++----- tools/slack/src/slack.ts | 37 +++---- twister/src/tool.ts | 10 ++ twister/src/tools/store.ts | 12 +++ twists/calendar-sync/src/index.ts | 21 ++-- twists/document-actions/src/index.ts | 10 +- twists/message-tasks/src/index.ts | 15 ++- twists/project-sync/src/index.ts | 35 ++++++- 15 files changed, 257 insertions(+), 138 deletions(-) create mode 100644 .changeset/itchy-colts-smile.md create mode 100644 .changeset/silly-candies-enjoy.md diff --git a/.changeset/itchy-colts-smile.md b/.changeset/itchy-colts-smile.md new file mode 100644 index 0000000..2d75ba1 --- /dev/null +++ b/.changeset/itchy-colts-smile.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Batch updates using ActivityUpdate.match with updateActivity() diff --git a/.changeset/silly-candies-enjoy.md b/.changeset/silly-candies-enjoy.md new file mode 100644 index 0000000..183de4b --- /dev/null +++ b/.changeset/silly-candies-enjoy.md @@ -0,0 +1,14 @@ +--- +"@plotday/tool-outlook-calendar": minor +"@plotday/tool-google-calendar": minor +"@plotday/tool-google-contacts": minor +"@plotday/tool-google-drive": minor +"@plotday/tool-linear": minor +"@plotday/tool-asana": minor +"@plotday/tool-gmail": minor +"@plotday/tool-slack": minor +"@plotday/tool-jira": minor +"@plotday/twister": minor +--- + +Changed: BREAKING: Rewrite of the Integrations tool and all sync tools to support much improved sync configuration when installing or editing a twist diff --git a/tools/gmail/src/gmail.ts b/tools/gmail/src/gmail.ts index bdac29e..1ef1209 100644 --- a/tools/gmail/src/gmail.ts +++ b/tools/gmail/src/gmail.ts @@ -116,7 +116,10 @@ export class Gmail extends Tool implements MessagingTool { }; } - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getSyncables( + _auth: Authorization, + token: AuthToken + ): Promise { const api = new GmailApi(token.token); const labels = await api.getLabels(); return labels @@ -193,10 +196,7 @@ export class Gmail extends Tool implements MessagingTool { } private async getApi(channelId: string): Promise { - const token = await this.tools.integrations.get( - Gmail.PROVIDER, - channelId - ); + const token = await this.tools.integrations.get(Gmail.PROVIDER, channelId); if (!token) { throw new Error("No Google authentication token available"); } @@ -397,7 +397,11 @@ export class Gmail extends Tool implements MessagingTool { if (activityThread.notes.length === 0) continue; // Inject sync metadata for the parent to identify the source - activityThread.meta = { ...activityThread.meta, syncProvider: "google", syncableId: channelId }; + activityThread.meta = { + ...activityThread.meta, + syncProvider: "google", + syncableId: channelId, + }; // Call parent callback with the thread (contacts will be created by the API) await this.run(callbackToken, activityThread); diff --git a/tools/google-calendar/src/google-api.ts b/tools/google-calendar/src/google-api.ts index 6fa3a32..bd84fab 100644 --- a/tools/google-calendar/src/google-api.ts +++ b/tools/google-calendar/src/google-api.ts @@ -108,7 +108,7 @@ export class GoogleApi { case 200: return await response.json(); default: - throw new Error(await response.text()); + throw new Error(`HTTP ${response.status}: ${await response.text()}`); } } } diff --git a/tools/google-calendar/src/google-calendar.ts b/tools/google-calendar/src/google-calendar.ts index 1a8fca6..b31d4a6 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/tools/google-calendar/src/google-calendar.ts @@ -160,6 +160,13 @@ export class GoogleCalendar }; } + async preUpgrade(): Promise { + const keys = await this.list("sync_lock_"); + for (const key of keys) { + await this.clear(key); + } + } + /** * Returns available calendars as syncable resources after authorization. */ @@ -446,7 +453,10 @@ export class GoogleCalendar try { await this.stopCalendarWatch(calendarId); } catch (error) { - console.error("Failed to stop calendar watch:", error); + console.warn( + "Failed to stop calendar watch:", + error instanceof Error ? error.message : error + ); } // 3. Clear sync-related storage diff --git a/tools/google-drive/src/google-drive.ts b/tools/google-drive/src/google-drive.ts index 4c112cd..201fb17 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/tools/google-drive/src/google-drive.ts @@ -1,9 +1,9 @@ import GoogleContacts from "@plotday/tool-google-contacts"; import { type ActivityFilter, + ActivityKind, type ActivityLink, ActivityLinkType, - ActivityKind, ActivityType, type NewActivityWithNotes, type NewContact, @@ -28,10 +28,7 @@ import { type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; +import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { GoogleApi, @@ -64,16 +61,11 @@ import { * **Required OAuth Scopes:** * - `https://www.googleapis.com/auth/drive` - Read/write files, folders, comments */ -export class GoogleDrive - extends Tool - implements DocumentTool -{ +export class GoogleDrive extends Tool implements DocumentTool { static readonly PROVIDER = AuthProvider.Google; static readonly Options: SyncToolOptions; declare readonly Options: SyncToolOptions; - static readonly SCOPES = [ - "https://www.googleapis.com/auth/drive", - ]; + static readonly SCOPES = ["https://www.googleapis.com/auth/drive"]; build(build: ToolBuilder) { return { @@ -106,7 +98,10 @@ export class GoogleDrive /** * Returns available Google Drive folders as syncable resources. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getSyncables( + _auth: Authorization, + token: AuthToken + ): Promise { const api = new GoogleApi(token.token); const files = await listFolders(api); return files.map((f) => ({ id: f.id, title: f.name })); @@ -229,9 +224,7 @@ export class GoogleDrive const { folderId } = options; // Check if sync is already in progress for this folder - const syncInProgress = await this.get( - `sync_lock_${folderId}` - ); + const syncInProgress = await this.get(`sync_lock_${folderId}`); if (syncInProgress) { return; } @@ -333,9 +326,7 @@ export class GoogleDrive // --- Webhooks --- - private async setupDriveWatch( - folderId: string - ): Promise { + private async setupDriveWatch(folderId: string): Promise { const webhookUrl = await this.tools.network.createWebhook( {}, this.onDriveWebhook, @@ -365,8 +356,8 @@ export class GoogleDrive )) as { expiration: string; resourceId: string }; const expiry = new Date(parseInt(watchData.expiration)); - const hoursUntilExpiry = (expiry.getTime() - Date.now()) / (1000 * 60 * 60); - console.log(`[GDRIVE] Watch created for folder ${folderId}: watchId=${watchId}, expiry=${expiry.toISOString()} (${hoursUntilExpiry.toFixed(1)}h from now)`); + const hoursUntilExpiry = + (expiry.getTime() - Date.now()) / (1000 * 60 * 60); await this.set(`drive_watch_${folderId}`, { watchId, @@ -379,14 +370,15 @@ export class GoogleDrive // Schedule proactive renewal await this.scheduleWatchRenewal(folderId); } catch (error) { - console.error(`Failed to setup drive watch for folder ${folderId}:`, error); + console.error( + `Failed to setup drive watch for folder ${folderId}:`, + error + ); throw error; } } - private async stopDriveWatch( - folderId: string - ): Promise { + private async stopDriveWatch(folderId: string): Promise { const watchData = await this.get(`drive_watch_${folderId}`); if (!watchData) { return; @@ -419,17 +411,11 @@ export class GoogleDrive // Don't schedule if the watch has already expired if (renewalTime <= new Date()) { - console.log(`[GDRIVE] Watch already expired or expiring too soon, skipping renewal scheduling`); return; } - console.log(`[GDRIVE] scheduleWatchRenewal: expiry=${expiry.toISOString()}, renewalTime=${renewalTime.toISOString()}`); - // Always schedule as a task to avoid recursive loops - const renewalCallback = await this.callback( - this.renewDriveWatch, - folderId - ); + const renewalCallback = await this.callback(this.renewDriveWatch, folderId); const taskToken = await this.runTask(renewalCallback, { runAt: renewalTime, @@ -441,7 +427,6 @@ export class GoogleDrive } private async renewDriveWatch(folderId: string): Promise { - console.log(`[GDRIVE] renewDriveWatch called for folder ${folderId}`); try { try { await this.stopDriveWatch(folderId); @@ -459,10 +444,8 @@ export class GoogleDrive _request: WebhookRequest, folderId: string ): Promise { - console.log(`[GDRIVE] Webhook received for folder ${folderId}`); const watchData = await this.get(`drive_watch_${folderId}`); if (!watchData) { - console.log(`[GDRIVE] No watch data found, ignoring webhook`); return; } @@ -470,18 +453,13 @@ export class GoogleDrive await this.startIncrementalSync(folderId); } - private async startIncrementalSync( - folderId: string - ): Promise { + private async startIncrementalSync(folderId: string): Promise { // Check if initial sync is still in progress const syncInProgress = await this.get(`sync_lock_${folderId}`); if (syncInProgress) { - console.log(`[GDRIVE] Sync lock active for folder ${folderId}, skipping`); return; } - console.log(`[GDRIVE] Starting incremental sync for folder ${folderId}`); - // Set sync lock for incremental await this.set(`sync_lock_${folderId}`, true); @@ -522,7 +500,9 @@ export class GoogleDrive const result = await listFilesInFolder(api, folderId, state.pageToken); // Process files in this batch - const callbackToken = await this.get(`item_callback_${folderId}`); + const callbackToken = await this.get( + `item_callback_${folderId}` + ); if (!callbackToken) { console.warn("No callback token found, skipping file processing"); return; @@ -581,9 +561,9 @@ export class GoogleDrive const api = await this.getApi(folderId); const result = await listChanges(api, changesToken); - console.log(`[GDRIVE] Incremental sync: ${result.changes.length} changes, token=${changesToken.substring(0, 20)}...`); - - const callbackToken = await this.get(`item_callback_${folderId}`); + const callbackToken = await this.get( + `item_callback_${folderId}` + ); if (!callbackToken) { console.warn("No callback token found, skipping incremental sync"); await this.clear(`sync_lock_${folderId}`); @@ -599,10 +579,10 @@ export class GoogleDrive if (!change.file.parents?.includes(folderId)) continue; // Skip folders - if (change.file.mimeType === "application/vnd.google-apps.folder") continue; + if (change.file.mimeType === "application/vnd.google-apps.folder") + continue; processedCount++; - console.log(`[GDRIVE] Processing changed file: ${change.file.name} (${change.file.id})`); try { const activity = await this.buildActivityFromFile( @@ -611,15 +591,15 @@ export class GoogleDrive folderId, false // incremental sync ); - console.log(`[GDRIVE] Built activity with ${activity.notes?.length ?? 0} notes for file ${change.file.name}`); await this.tools.callbacks.run(callbackToken, activity); } catch (error) { - console.error(`Failed to process changed file ${change.fileId}:`, error); + console.error( + `Failed to process changed file ${change.fileId}:`, + error + ); } } - console.log(`[GDRIVE] Processed ${processedCount} files in folder from ${result.changes.length} total changes`); - if (result.nextPageToken) { // More change pages const syncCallback = await this.callback( @@ -631,7 +611,6 @@ export class GoogleDrive } else { // Update stored changes token for next incremental sync const newToken = result.newStartPageToken || changesToken; - console.log(`[GDRIVE] Sync complete, new token=${newToken.substring(0, 20)}... (changed=${newToken !== changesToken})`); const state = await this.get(`sync_state_${folderId}`); if (state) { await this.set(`sync_state_${folderId}`, { @@ -698,13 +677,20 @@ export class GoogleDrive try { const comments = await listComments(api, file.id); for (const comment of comments) { - notes.push(this.buildCommentNote(canonicalSource, comment, emailByName)); + notes.push( + this.buildCommentNote(canonicalSource, comment, emailByName) + ); // Add replies if (comment.replies) { for (const reply of comment.replies) { notes.push( - this.buildReplyNote(canonicalSource, comment.id, reply, emailByName) + this.buildReplyNote( + canonicalSource, + comment.id, + reply, + emailByName + ) ); } } @@ -785,7 +771,9 @@ export class GoogleDrive private buildReplyNote( canonicalSource: string, commentId: string, - reply: GoogleDriveComment["replies"] extends (infer R)[] | undefined ? R : never, + reply: GoogleDriveComment["replies"] extends (infer R)[] | undefined + ? R + : never, emailByName: Map ): NewNote { const email = diff --git a/tools/linear/src/linear.ts b/tools/linear/src/linear.ts index da8847d..70ee612 100644 --- a/tools/linear/src/linear.ts +++ b/tools/linear/src/linear.ts @@ -66,13 +66,15 @@ export class Linear extends Tool implements ProjectTool { build(build: ToolBuilder) { return { integrations: build(Integrations, { - providers: [{ - provider: Linear.PROVIDER, - scopes: Linear.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }], + providers: [ + { + provider: Linear.PROVIDER, + scopes: Linear.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }, + ], }), network: build(Network, { urls: ["https://api.linear.app/*"] }), callbacks: build(Callbacks), @@ -95,7 +97,10 @@ export class Linear extends Tool implements ProjectTool { /** * Returns available Linear teams as syncable resources. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getSyncables( + _auth: Authorization, + token: AuthToken + ): Promise { const client = new LinearClient({ accessToken: token.token }); const teams = await client.teams(); return teams.nodes.map((team) => ({ @@ -207,9 +212,7 @@ export class Linear extends Tool implements ProjectTool { /** * Setup Linear webhook for real-time updates */ - private async setupLinearWebhook( - projectId: string - ): Promise { + private async setupLinearWebhook(projectId: string): Promise { try { const client = await this.getClient(projectId); @@ -270,7 +273,7 @@ export class Linear extends Tool implements ProjectTool { const batchCallback = await this.callback( this.syncBatch, projectId, - options + options ?? null ); await this.tools.tasks.runTask(batchCallback); @@ -281,7 +284,7 @@ export class Linear extends Tool implements ProjectTool { */ private async syncBatch( projectId: string, - options?: ProjectSyncOptions + options?: ProjectSyncOptions | null ): Promise { const state = await this.get(`sync_state_${projectId}`); if (!state) { @@ -289,7 +292,9 @@ export class Linear extends Tool implements ProjectTool { } // Retrieve callback token from storage - const callbackToken = await this.get(`item_callback_${projectId}`); + const callbackToken = await this.get( + `item_callback_${projectId}` + ); if (!callbackToken) { throw new Error(`Callback token not found for project ${projectId}`); } @@ -320,7 +325,11 @@ export class Linear extends Tool implements ProjectTool { if (activity) { // Inject sync metadata for bulk operations (e.g. disable filtering) - activity.meta = { ...activity.meta, syncProvider: "linear", syncableId: projectId }; + activity.meta = { + ...activity.meta, + syncProvider: "linear", + syncableId: projectId, + }; // Execute the callback using the callback token await this.tools.callbacks.run(callbackToken, activity); } @@ -339,7 +348,7 @@ export class Linear extends Tool implements ProjectTool { const nextBatch = await this.callback( this.syncBatch, projectId, - options + options ?? null ); await this.tools.tasks.runTask(nextBatch); } else { @@ -673,7 +682,9 @@ export class Linear extends Tool implements ProjectTool { } // Get callback token - const callbackToken = await this.get(`item_callback_${projectId}`); + const callbackToken = await this.get( + `item_callback_${projectId}` + ); if (!callbackToken) { console.warn("No callback token found for project:", projectId); return; @@ -849,7 +860,9 @@ export class Linear extends Tool implements ProjectTool { } // Cleanup item callback (new key) - const itemCallbackToken = await this.get(`item_callback_${projectId}`); + const itemCallbackToken = await this.get( + `item_callback_${projectId}` + ); if (itemCallbackToken) { await this.deleteCallback(itemCallbackToken); await this.clear(`item_callback_${projectId}`); diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/tools/outlook-calendar/src/outlook-calendar.ts index 9b56f2e..3f52574 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/tools/outlook-calendar/src/outlook-calendar.ts @@ -122,18 +122,23 @@ export class OutlookCalendar build(build: ToolBuilder) { return { integrations: build(Integrations, { - providers: [{ - provider: OutlookCalendar.PROVIDER, - scopes: OutlookCalendar.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }], + providers: [ + { + provider: OutlookCalendar.PROVIDER, + scopes: OutlookCalendar.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }, + ], }), network: build(Network, { urls: ["https://graph.microsoft.com/*"] }), plot: build(Plot, { contact: { access: ContactAccess.Write }, - activity: { access: ActivityAccess.Create, updated: this.onActivityUpdated }, + activity: { + access: ActivityAccess.Create, + updated: this.onActivityUpdated, + }, }), }; } @@ -141,7 +146,10 @@ export class OutlookCalendar /** * Returns available Outlook calendars as syncable resources. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getSyncables( + _auth: Authorization, + token: AuthToken + ): Promise { const api = new GraphApi(token.token); const calendars = await api.getCalendars(); return calendars.map((c) => ({ id: c.id, title: c.name })); @@ -220,7 +228,10 @@ export class OutlookCalendar } private async getApi(calendarId: string): Promise { - const token = await this.tools.integrations.get(OutlookCalendar.PROVIDER, calendarId); + const token = await this.tools.integrations.get( + OutlookCalendar.PROVIDER, + calendarId + ); if (!token) { throw new Error("No Microsoft authentication token available"); } @@ -334,9 +345,7 @@ export class OutlookCalendar await this.clear(`outlook_sync_state_${calendarId}`); } - private async setupOutlookWatch( - calendarId: string - ): Promise { + private async setupOutlookWatch(calendarId: string): Promise { const api = await this.getApi(calendarId); const webhookUrl = await this.tools.network.createWebhook( @@ -384,10 +393,7 @@ export class OutlookCalendar try { api = await this.getApi(calendarId); } catch (error) { - console.error( - "No Microsoft credentials found for calendar:", - error - ); + console.error("No Microsoft credentials found for calendar:", error); return; } @@ -397,7 +403,9 @@ export class OutlookCalendar } // Hoist callback token retrieval outside event loop - saves N-1 subrequests - const callbackToken = await this.get(`item_callback_${calendarId}`); + const callbackToken = await this.get( + `item_callback_${calendarId}` + ); if (!callbackToken) { console.warn("No callback token found, skipping event processing"); return; @@ -648,7 +656,11 @@ export class OutlookCalendar const activityWithNotes: NewActivityWithNotes = { ...activity, author: authorContact, - meta: { ...activity.meta, syncProvider: "microsoft", syncableId: calendarId }, + meta: { + ...activity.meta, + syncProvider: "microsoft", + syncableId: calendarId, + }, tags: tags && Object.keys(tags).length > 0 ? tags : activity.tags, notes, preview: hasDescription ? outlookEvent.body!.content! : null, @@ -816,16 +828,11 @@ export class OutlookCalendar } } - private async startIncrementalSync( - calendarId: string - ): Promise { + private async startIncrementalSync(calendarId: string): Promise { try { await this.getApi(calendarId); } catch (error) { - console.error( - "No Microsoft credentials found for calendar:", - error - ); + console.error("No Microsoft credentials found for calendar:", error); return; } diff --git a/tools/slack/src/slack.ts b/tools/slack/src/slack.ts index b96a6fd..20deff9 100644 --- a/tools/slack/src/slack.ts +++ b/tools/slack/src/slack.ts @@ -127,13 +127,15 @@ export class Slack extends Tool implements MessagingTool { build(build: ToolBuilder) { return { integrations: build(Integrations, { - providers: [{ - provider: Slack.PROVIDER, - scopes: Slack.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }], + providers: [ + { + provider: Slack.PROVIDER, + scopes: Slack.SCOPES, + getSyncables: this.getSyncables, + onSyncEnabled: this.onSyncEnabled, + onSyncDisabled: this.onSyncDisabled, + }, + ], }), network: build(Network, { urls: ["https://slack.com/api/*"] }), plot: build(Plot, { @@ -143,7 +145,10 @@ export class Slack extends Tool implements MessagingTool { }; } - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getSyncables( + _auth: Authorization, + token: AuthToken + ): Promise { const api = new SlackApi(token.token); const channels = await api.getChannels(); return channels @@ -305,9 +310,7 @@ export class Slack extends Tool implements MessagingTool { await this.clear(`item_callback_${channelId}`); } - private async setupChannelWebhook( - channelId: string - ): Promise { + private async setupChannelWebhook(channelId: string): Promise { const webhookUrl = await this.tools.network.createWebhook( {}, this.onSlackWebhook, @@ -327,7 +330,6 @@ export class Slack extends Tool implements MessagingTool { channelId, created: new Date().toISOString(), }); - } async syncBatch( @@ -394,7 +396,11 @@ export class Slack extends Tool implements MessagingTool { if (activityThread.notes.length === 0) continue; // Inject sync metadata for the parent to identify the source - activityThread.meta = { ...activityThread.meta, syncProvider: "slack", syncableId: channelId }; + activityThread.meta = { + ...activityThread.meta, + syncProvider: "slack", + syncableId: channelId, + }; // Call parent callback with the thread (contacts will be created by the API) await this.run(callbackToken, activityThread); @@ -443,9 +449,7 @@ export class Slack extends Tool implements MessagingTool { } } - private async startIncrementalSync( - channelId: string - ): Promise { + private async startIncrementalSync(channelId: string): Promise { const webhookData = await this.get(`channel_webhook_${channelId}`); if (!webhookData) { console.error("No channel webhook data found"); @@ -468,7 +472,6 @@ export class Slack extends Tool implements MessagingTool { ); await this.run(syncCallback); } - } export default Slack; diff --git a/twister/src/tool.ts b/twister/src/tool.ts index 25dfce9..e6f2cac 100644 --- a/twister/src/tool.ts +++ b/twister/src/tool.ts @@ -219,6 +219,16 @@ export abstract class Tool implements ITool { return this.tools.store.set(key, value); } + /** + * Lists all storage keys matching a prefix. + * + * @param prefix - The prefix to match keys against + * @returns Promise resolving to an array of matching key strings + */ + protected async list(prefix: string): Promise { + return this.tools.store.list(prefix); + } + /** * Removes a specific key from persistent storage. * diff --git a/twister/src/tools/store.ts b/twister/src/tools/store.ts index 09cc643..7b17711 100644 --- a/twister/src/tools/store.ts +++ b/twister/src/tools/store.ts @@ -113,6 +113,18 @@ export abstract class Store extends ITool { // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract set(key: string, value: T): Promise; + /** + * Lists all storage keys matching a prefix. + * + * Returns an array of key strings that start with the given prefix. + * Useful for finding all keys in a namespace (e.g., all sync locks). + * + * @param prefix - The prefix to match keys against + * @returns Promise resolving to an array of matching key strings + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract list(prefix: string): Promise; + /** * Removes a specific key from storage. * diff --git a/twists/calendar-sync/src/index.ts b/twists/calendar-sync/src/index.ts index e9e30ab..f322bcd 100644 --- a/twists/calendar-sync/src/index.ts +++ b/twists/calendar-sync/src/index.ts @@ -1,6 +1,7 @@ import { GoogleCalendar } from "@plotday/tool-google-calendar"; import { OutlookCalendar } from "@plotday/tool-outlook-calendar"; import { + type ActivityFilter, type NewActivityWithNotes, type Priority, type ToolBuilder, @@ -11,8 +12,14 @@ import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; export default class CalendarSyncTwist extends Twist { build(build: ToolBuilder) { return { - googleCalendar: build(GoogleCalendar), - outlookCalendar: build(OutlookCalendar), + googleCalendar: build(GoogleCalendar, { + onItem: this.handleEvent, + onSyncableDisabled: this.handleSyncableDisabled, + }), + outlookCalendar: build(OutlookCalendar, { + onItem: this.handleEvent, + onSyncableDisabled: this.handleSyncableDisabled, + }), plot: build(Plot, { activity: { access: ActivityAccess.Create, @@ -25,11 +32,11 @@ export default class CalendarSyncTwist extends Twist { // Auth and calendar selection are now handled in the twist edit modal. } - async handleEvent( - activity: NewActivityWithNotes, - _provider: string, - _calendarId: string - ): Promise { + async handleSyncableDisabled(filter: ActivityFilter): Promise { + await this.tools.plot.updateActivity({ match: filter, archived: true }); + } + + async handleEvent(activity: NewActivityWithNotes): Promise { // Just create/upsert - database handles everything automatically // Note: The unread field is already set by the tool based on sync type await this.tools.plot.createActivity(activity); diff --git a/twists/document-actions/src/index.ts b/twists/document-actions/src/index.ts index 6d666b2..0b17ebe 100644 --- a/twists/document-actions/src/index.ts +++ b/twists/document-actions/src/index.ts @@ -1,5 +1,6 @@ import { GoogleDrive } from "@plotday/tool-google-drive"; import { + type ActivityFilter, type NewActivityWithNotes, type Note, type Priority, @@ -21,7 +22,10 @@ import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; export default class DocumentActions extends Twist { build(build: ToolBuilder) { return { - googleDrive: build(GoogleDrive), + googleDrive: build(GoogleDrive, { + onItem: this.onDocument, + onSyncableDisabled: this.onSyncableDisabled, + }), plot: build(Plot, { activity: { access: ActivityAccess.Create, @@ -45,6 +49,10 @@ export default class DocumentActions extends Twist { // Auth and folder selection are now handled in the twist edit modal. } + async onSyncableDisabled(filter: ActivityFilter): Promise { + await this.tools.plot.updateActivity({ match: filter, archived: true }); + } + /** * Called for each document synced from Google Drive. */ diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index cd2b8ab..b084e5a 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -2,6 +2,7 @@ import { Type } from "typebox"; import { Slack } from "@plotday/tool-slack"; import { + type ActivityFilter, ActivityType, type NewActivityWithNotes, type Priority, @@ -24,7 +25,10 @@ type ThreadTask = { export default class MessageTasksTwist extends Twist { build(build: ToolBuilder) { return { - slack: build(Slack), + slack: build(Slack, { + onItem: this.onSlackThread, + onSyncableDisabled: this.onSyncableDisabled, + }), ai: build(AI), plot: build(Plot, { activity: { @@ -38,6 +42,15 @@ export default class MessageTasksTwist extends Twist { // Auth and channel selection are now handled in the twist edit modal. } + async onSlackThread(thread: NewActivityWithNotes): Promise { + const channelId = thread.meta?.syncableId as string; + return this.onMessageThread(thread, "slack", channelId); + } + + async onSyncableDisabled(filter: ActivityFilter): Promise { + await this.tools.plot.updateActivity({ match: filter, archived: true }); + } + // ============================================================================ // Thread Task Storage // ============================================================================ diff --git a/twists/project-sync/src/index.ts b/twists/project-sync/src/index.ts index 955c52d..9a29374 100644 --- a/twists/project-sync/src/index.ts +++ b/twists/project-sync/src/index.ts @@ -3,6 +3,7 @@ import { Jira } from "@plotday/tool-jira"; import { Linear } from "@plotday/tool-linear"; import { type Activity, + type ActivityFilter, ActorType, type NewActivityWithNotes, type Note, @@ -24,9 +25,18 @@ type ProjectProvider = "linear" | "jira" | "asana"; export default class ProjectSync extends Twist { build(build: ToolBuilder) { return { - linear: build(Linear), - jira: build(Jira), - asana: build(Asana), + linear: build(Linear, { + onItem: this.onLinearItem, + onSyncableDisabled: this.onSyncableDisabled, + }), + jira: build(Jira, { + onItem: this.onJiraItem, + onSyncableDisabled: this.onSyncableDisabled, + }), + asana: build(Asana, { + onItem: this.onAsanaItem, + onSyncableDisabled: this.onSyncableDisabled, + }), plot: build(Plot, { activity: { access: ActivityAccess.Create, @@ -59,6 +69,22 @@ export default class ProjectSync extends Twist { // Auth and project selection are now handled in the twist edit modal. } + async onLinearItem(item: NewActivityWithNotes) { + return this.onIssue(item, "linear"); + } + + async onJiraItem(item: NewActivityWithNotes) { + return this.onIssue(item, "jira"); + } + + async onAsanaItem(item: NewActivityWithNotes) { + return this.onIssue(item, "asana"); + } + + async onSyncableDisabled(filter: ActivityFilter): Promise { + await this.tools.plot.updateActivity({ match: filter, archived: true }); + } + /** * Check if a note is fully empty (no content, no links, no mentions) */ @@ -80,8 +106,7 @@ export default class ProjectSync extends Twist { */ async onIssue( issue: NewActivityWithNotes, - provider: ProjectProvider, - _projectId: string + provider: ProjectProvider ) { // Add provider to meta for routing updates back to the correct tool issue.meta = { ...issue.meta, provider };