diff --git a/.changeset/all-knives-build.md b/.changeset/all-knives-build.md deleted file mode 100644 index aeb5ee2..0000000 --- a/.changeset/all-knives-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@plotday/twister": minor ---- - -Added: SourceControl interface for services that work with code pull requests diff --git a/.changeset/bold-stars-glow.md b/.changeset/bold-stars-glow.md new file mode 100644 index 0000000..a4ee135 --- /dev/null +++ b/.changeset/bold-stars-glow.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Refactor Source base class to own provider identity and channel lifecycle diff --git a/.changeset/brave-foxes-dance.md b/.changeset/brave-foxes-dance.md new file mode 100644 index 0000000..2bf9dd2 --- /dev/null +++ b/.changeset/brave-foxes-dance.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Rename Activity to Thread and Link to Action throughout the SDK (types, methods, filters) diff --git a/.changeset/bright-trees-wave.md b/.changeset/bright-trees-wave.md new file mode 100644 index 0000000..5ba44b4 --- /dev/null +++ b/.changeset/bright-trees-wave.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Twist lifecycle hooks onThreadUpdated, onNoteCreated moved from Plot options to Twist base class methods; added onLinkCreated, onLinkUpdated, onLinkNoteCreated, onOptionsChanged diff --git a/.changeset/calm-waves-shine.md b/.changeset/calm-waves-shine.md new file mode 100644 index 0000000..beae31b --- /dev/null +++ b/.changeset/calm-waves-shine.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Schedule type with ScheduleContact for event scheduling, recurring events, and per-user schedules diff --git a/.changeset/config.json b/.changeset/config.json index a722d25..b929d3a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -13,6 +13,7 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@plotday/twist-*" + "@plotday/twist-*", + "@plotday/source-*" ] } diff --git a/.changeset/cool-lakes-hum.md b/.changeset/cool-lakes-hum.md new file mode 100644 index 0000000..228add4 --- /dev/null +++ b/.changeset/cool-lakes-hum.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Source.onThreadRead() hook for writing back read/unread status to external services diff --git a/.changeset/deep-pens-rest.md b/.changeset/deep-pens-rest.md new file mode 100644 index 0000000..bd40f4c --- /dev/null +++ b/.changeset/deep-pens-rest.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Removed deprecated IntegrationProviderConfig and IntegrationOptions types; added archiveLinks(filter) for bulk-archiving links diff --git a/.changeset/fresh-plants-grow.md b/.changeset/fresh-plants-grow.md new file mode 100644 index 0000000..5aadb13 --- /dev/null +++ b/.changeset/fresh-plants-grow.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Source base class for building service integrations with provider, scopes, and lifecycle management diff --git a/.changeset/full-ends-rescue.md b/.changeset/full-ends-rescue.md deleted file mode 100644 index c87ee28..0000000 --- a/.changeset/full-ends-rescue.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@plotday/tool-outlook-calendar": minor -"@plotday/tool-google-calendar": minor -"@plotday/tool-github-issues": minor -"@plotday/tool-google-drive": minor -"@plotday/tool-github": minor -"@plotday/tool-linear": minor -"@plotday/tool-asana": minor -"@plotday/tool-jira": minor -"@plotday/twister": minor ---- - -Added: Activity.links, better for activity-scoped links such as the link to the original item diff --git a/.changeset/green-doors-open.md b/.changeset/green-doors-open.md new file mode 100644 index 0000000..8f77e7b --- /dev/null +++ b/.changeset/green-doors-open.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: LinkType config for sources and channelId on Link for account-based priority routing diff --git a/.changeset/kind-dogs-clap.md b/.changeset/kind-dogs-clap.md new file mode 100644 index 0000000..01b11df --- /dev/null +++ b/.changeset/kind-dogs-clap.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Removed thread.updated and note.created callbacks from Plot options (use Twist.onThreadUpdated/onNoteCreated instead); added `link: true` option and `getLinks(filter?)` method for link processing diff --git a/.changeset/quiet-rivers-flow.md b/.changeset/quiet-rivers-flow.md new file mode 100644 index 0000000..03c026e --- /dev/null +++ b/.changeset/quiet-rivers-flow.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Rename Syncable to Channel in Integrations tool, add saveLink() and saveContacts() methods diff --git a/.changeset/silver-moons-rise.md b/.changeset/silver-moons-rise.md new file mode 100644 index 0000000..c812992 --- /dev/null +++ b/.changeset/silver-moons-rise.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Removed: BREAKING — Deprecated twister functions and types diff --git a/.changeset/swift-hawks-soar.md b/.changeset/swift-hawks-soar.md new file mode 100644 index 0000000..7a77cbc --- /dev/null +++ b/.changeset/swift-hawks-soar.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Package exports for ./source and ./schedule modules diff --git a/.changeset/tall-clouds-rest.md b/.changeset/tall-clouds-rest.md new file mode 100644 index 0000000..09d5515 --- /dev/null +++ b/.changeset/tall-clouds-rest.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Removed: BREAKING — Common interfaces (calendar, documents, messaging, projects, source-control) replaced by individual source implementations diff --git a/.changeset/warm-birds-sing.md b/.changeset/warm-birds-sing.md new file mode 100644 index 0000000..d802bd5 --- /dev/null +++ b/.changeset/warm-birds-sing.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Removed: BREAKING — RSVP tags (Attend, Skip, Undecided) from Tag enum, replaced by ScheduleContact diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index e8a7276..197419f 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -36,16 +36,15 @@ jobs: git fetch origin main CHANGED_FILES=$(git diff --name-only origin/main...HEAD) - # Check if Twister or tools directories were modified + # Check if Twister was modified (sources are private and don't need changesets) TWISTER_CHANGED=$(echo "$CHANGED_FILES" | grep -E '^twister/' || true) - TOOLS_CHANGED=$(echo "$CHANGED_FILES" | grep -E '^tools/' || true) - if [ -n "$TWISTER_CHANGED" ] || [ -n "$TOOLS_CHANGED" ]; then + if [ -n "$TWISTER_CHANGED" ]; then echo "needs-changeset=true" >> $GITHUB_OUTPUT - echo "Twister or tools packages were modified" + echo "Twister package was modified" else echo "needs-changeset=false" >> $GITHUB_OUTPUT - echo "No Twister or tools changes detected" + echo "No Twister changes detected" fi - name: Check for changesets @@ -55,7 +54,7 @@ jobs: CHANGESET_COUNT=$(ls -1 .changeset/*.md 2>/dev/null | grep -v README.md | wc -l | tr -d ' ') if [ "$CHANGESET_COUNT" -eq 0 ]; then - echo "❌ Error: Changes detected in Twister or tools packages, but no changeset found." + echo "❌ Error: Changes detected in Twister package, but no changeset found." echo "" echo "Please add a changeset by running:" echo " pnpm changeset" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46dac1f..c24846e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,11 +67,8 @@ jobs: # Determine package directory if [[ "$name" == "@plotday/twister" ]]; then pkg_dir="twister" - elif [[ "$name" == @plotday/tool-* ]]; then - tool_name="${name#@plotday/tool-}" - pkg_dir="tools/$tool_name" else - echo "Unknown package: $name" + echo "Skipping unknown package: $name" continue fi diff --git a/AGENTS.md b/AGENTS.md index 0109a93..733388b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,18 @@ # Plot Development Guide for AI Assistants -This guide helps AI assistants build Plot tools and twists correctly. +This guide helps AI assistants build Plot sources and twists correctly. ## What Are You Building? -### Building a Tool (service integration) +### Building a Source (service integration) -Tools are reusable packages that connect to external services (Linear, Slack, Google Calendar, etc.). They implement a standard interface and are consumed by twists. +Sources are packages that connect to external services (Linear, Slack, Google Calendar, etc.). They extend Source and save data directly via `integrations.saveThread()`. -**Start here:** `tools/AGENTS.md` — Complete tool development guide with scaffold, patterns, and checklist. - -**Choose your interface:** - -| Interface | For | Import | -|-----------|-----|--------| -| `CalendarTool` | Calendar/scheduling | `@plotday/twister/common/calendar` | -| `ProjectTool` | Project/task management | `@plotday/twister/common/projects` | -| `MessagingTool` | Email and chat | `@plotday/twister/common/messaging` | -| `DocumentTool` | Document/file storage | `@plotday/twister/common/documents` | +**Start here:** `sources/AGENTS.md` — Complete source development guide with scaffold, patterns, and checklist. ### Building a Twist (orchestrator) -Twists are the entry point that users install. They declare which tools to use and implement domain logic (filtering, enrichment, two-way sync). +Twists are the entry point that users install. They declare which tools to use and implement domain logic. **Start here:** `twister/cli/templates/AGENTS.template.md` — Twist implementation guide. @@ -29,35 +20,33 @@ Twists are the entry point that users install. They declare which tools to use a All types in `twister/src/` with full JSDoc: +- **Source base**: `twister/src/source.ts` - **Tool base**: `twister/src/tool.ts` - **Twist base**: `twister/src/twist.ts` - **Built-in tools**: `twister/src/tools/*.ts` - `callbacks.ts`, `store.ts`, `tasks.ts`, `plot.ts`, `ai.ts`, `network.ts`, `integrations.ts`, `twists.ts` -- **Common interfaces**: `twister/src/common/*.ts` - - `calendar.ts`, `messaging.ts`, `projects.ts`, `documents.ts` - **Core types**: `twister/src/plot.ts`, `twister/src/tag.ts` ## Additional Resources - **Full Documentation**: -- **Building Tools Guide**: `twister/docs/BUILDING_TOOLS.md` +- **Building Sources Guide**: `sources/AGENTS.md` - **Runtime Environment**: `twister/docs/RUNTIME.md` - **Tools Guide**: `twister/docs/TOOLS_GUIDE.md` - **Multi-User Auth**: `twister/docs/MULTI_USER_AUTH.md` - **Sync Strategies**: `twister/docs/SYNC_STRATEGIES.md` -- **Working Tool Examples**: `tools/linear/`, `tools/google-calendar/`, `tools/slack/`, `tools/jira/` -- **Working Twist Examples**: `twists/calendar-sync/`, `twists/project-sync/` +- **Working Source Examples**: `sources/linear/`, `sources/google-calendar/`, `sources/slack/`, `sources/jira/` ## Common Pitfalls 1. **❌ Using instance variables for state** — Use `this.set()`/`this.get()` (state doesn't persist between executions) 2. **❌ Long-running operations without batching** — Break into chunks with `runTask()` (~1000 requests per execution) -3. **❌ Passing functions to `this.callback()`** — See `tools/AGENTS.md` for callback serialization pattern -4. **❌ Calling `plot.createActivity()` from a tool** — Tools build data, twists save it -5. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `syncableId` into `activity.meta` -6. **❌ Not handling initial vs incremental sync** — `unread: false` for initial, omit for incremental -7. **❌ Missing localhost guard in webhooks** — Skip webhook registration when URL contains "localhost" +3. **❌ Passing functions to `this.callback()`** — See `sources/AGENTS.md` for callback serialization pattern +4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `thread.meta` +5. **❌ Not handling initial vs incremental sync** — Propagate `initialSync` flag from entry point (`onChannelEnabled` → `true`, webhook → `false`) through all batch callbacks. Set `unread: false` and `archived: false` for initial, omit for incremental +6. **❌ Missing localhost guard in webhooks** — Skip webhook registration when URL contains "localhost" +7. **❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side markdown conversion --- -**Remember**: When in doubt, check the type definitions in `twister/src/` and study the working examples in `tools/`. +**Remember**: When in doubt, check the type definitions in `twister/src/` and study the working examples in `sources/`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c89423d..1010c98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/asana: + sources/asana: dependencies: '@plotday/twister': specifier: workspace:^ @@ -43,7 +43,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/github: + sources/github: dependencies: '@plotday/twister': specifier: workspace:^ @@ -53,11 +53,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/github-issues: + sources/gmail: dependencies: - '@octokit/rest': - specifier: ^21.1.1 - version: 21.1.1 '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -66,19 +63,9 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/gmail: + sources/google-calendar: dependencies: - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - - tools/google-calendar: - dependencies: - '@plotday/tool-google-contacts': + '@plotday/source-google-contacts': specifier: workspace:^ version: link:../google-contacts '@plotday/twister': @@ -89,7 +76,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/google-contacts: + sources/google-contacts: dependencies: '@plotday/twister': specifier: workspace:^ @@ -99,9 +86,9 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/google-drive: + sources/google-drive: dependencies: - '@plotday/tool-google-contacts': + '@plotday/source-google-contacts': specifier: workspace:^ version: link:../google-contacts '@plotday/twister': @@ -112,7 +99,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/jira: + sources/jira: dependencies: '@plotday/twister': specifier: workspace:^ @@ -125,7 +112,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/linear: + sources/linear: dependencies: '@linear/sdk': specifier: ^72.0.0 @@ -138,7 +125,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/outlook-calendar: + sources/outlook-calendar: dependencies: '@plotday/twister': specifier: workspace:^ @@ -148,7 +135,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/slack: + sources/slack: dependencies: '@plotday/twister': specifier: workspace:^ @@ -207,22 +194,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - twists/calendar-sync: - dependencies: - '@plotday/tool-google-calendar': - specifier: workspace:^ - version: link:../../tools/google-calendar - '@plotday/tool-outlook-calendar': - specifier: workspace:^ - version: link:../../tools/outlook-calendar - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - twists/chat: dependencies: '@plotday/twister': @@ -236,40 +207,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 - twists/code-review: - dependencies: - '@plotday/tool-github': - specifier: workspace:^ - version: link:../../tools/github - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - - twists/document-actions: - dependencies: - '@plotday/tool-google-drive': - specifier: workspace:^ - version: link:../../tools/google-drive - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - twists/message-tasks: dependencies: - '@plotday/tool-gmail': - specifier: workspace:^ - version: link:../../tools/gmail - '@plotday/tool-slack': - specifier: workspace:^ - version: link:../../tools/slack '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -281,28 +220,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - twists/project-sync: - dependencies: - '@plotday/tool-asana': - specifier: workspace:^ - version: link:../../tools/asana - '@plotday/tool-github-issues': - specifier: workspace:^ - version: link:../../tools/github-issues - '@plotday/tool-jira': - specifier: workspace:^ - version: link:../../tools/jira - '@plotday/tool-linear': - specifier: workspace:^ - version: link:../../tools/linear - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - packages: '@babel/cli@7.28.3': @@ -808,64 +725,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@octokit/auth-token@5.1.2': - resolution: {integrity: sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==} - engines: {node: '>= 18'} - - '@octokit/core@6.1.6': - resolution: {integrity: sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==} - engines: {node: '>= 18'} - - '@octokit/endpoint@10.1.4': - resolution: {integrity: sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==} - engines: {node: '>= 18'} - - '@octokit/graphql@8.2.2': - resolution: {integrity: sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==} - engines: {node: '>= 18'} - - '@octokit/openapi-types@24.2.0': - resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} - - '@octokit/openapi-types@25.1.0': - resolution: {integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==} - - '@octokit/plugin-paginate-rest@11.6.0': - resolution: {integrity: sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-request-log@5.3.1': - resolution: {integrity: sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-rest-endpoint-methods@13.5.0': - resolution: {integrity: sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/request-error@6.1.8': - resolution: {integrity: sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==} - engines: {node: '>= 18'} - - '@octokit/request@9.2.4': - resolution: {integrity: sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==} - engines: {node: '>= 18'} - - '@octokit/rest@21.1.1': - resolution: {integrity: sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==} - engines: {node: '>= 18'} - - '@octokit/types@13.10.0': - resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} - - '@octokit/types@14.1.0': - resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - '@shikijs/engine-oniguruma@3.14.0': resolution: {integrity: sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug==} @@ -947,9 +806,6 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true - before-after-hook@3.0.2: - resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} - better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -1119,9 +975,6 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - fast-content-type-parse@2.0.1: - resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1208,7 +1061,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} @@ -1651,9 +1504,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - universal-user-agent@7.0.3: - resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -2198,74 +2048,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@octokit/auth-token@5.1.2': {} - - '@octokit/core@6.1.6': - dependencies: - '@octokit/auth-token': 5.1.2 - '@octokit/graphql': 8.2.2 - '@octokit/request': 9.2.4 - '@octokit/request-error': 6.1.8 - '@octokit/types': 14.1.0 - before-after-hook: 3.0.2 - universal-user-agent: 7.0.3 - - '@octokit/endpoint@10.1.4': - dependencies: - '@octokit/types': 14.1.0 - universal-user-agent: 7.0.3 - - '@octokit/graphql@8.2.2': - dependencies: - '@octokit/request': 9.2.4 - '@octokit/types': 14.1.0 - universal-user-agent: 7.0.3 - - '@octokit/openapi-types@24.2.0': {} - - '@octokit/openapi-types@25.1.0': {} - - '@octokit/plugin-paginate-rest@11.6.0(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/types': 13.10.0 - - '@octokit/plugin-request-log@5.3.1(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - - '@octokit/plugin-rest-endpoint-methods@13.5.0(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/types': 13.10.0 - - '@octokit/request-error@6.1.8': - dependencies: - '@octokit/types': 14.1.0 - - '@octokit/request@9.2.4': - dependencies: - '@octokit/endpoint': 10.1.4 - '@octokit/request-error': 6.1.8 - '@octokit/types': 14.1.0 - fast-content-type-parse: 2.0.1 - universal-user-agent: 7.0.3 - - '@octokit/rest@21.1.1': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/plugin-paginate-rest': 11.6.0(@octokit/core@6.1.6) - '@octokit/plugin-request-log': 5.3.1(@octokit/core@6.1.6) - '@octokit/plugin-rest-endpoint-methods': 13.5.0(@octokit/core@6.1.6) - - '@octokit/types@13.10.0': - dependencies: - '@octokit/openapi-types': 24.2.0 - - '@octokit/types@14.1.0': - dependencies: - '@octokit/openapi-types': 25.1.0 - '@shikijs/engine-oniguruma@3.14.0': dependencies: '@shikijs/types': 3.14.0 @@ -2355,8 +2137,6 @@ snapshots: baseline-browser-mapping@2.9.11: {} - before-after-hook@3.0.2: {} - better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -2558,8 +2338,6 @@ snapshots: extendable-error@0.1.7: {} - fast-content-type-parse@2.0.1: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3044,8 +2822,6 @@ snapshots: undici-types@7.16.0: {} - universal-user-agent@7.0.3: {} - universalify@0.1.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8ef7d4d..e222aee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - twister - - tools/* + - sources/* - twists/* onlyBuiltDependencies: diff --git a/tools/AGENTS.md b/sources/AGENTS.md similarity index 64% rename from tools/AGENTS.md rename to sources/AGENTS.md index 83050f5..4a6c7f5 100644 --- a/tools/AGENTS.md +++ b/sources/AGENTS.md @@ -1,20 +1,20 @@ -# Tool Development Guide +# Source Development Guide -This guide covers everything needed to build a Plot tool correctly. +This guide covers everything needed to build a Plot source correctly. **For twist development**: See `../twister/cli/templates/AGENTS.template.md` **For general navigation**: See `../AGENTS.md` **For type definitions**: See `../twister/src/tools/*.ts` (comprehensive JSDoc) -## Quick Start: Complete Tool Scaffold +## Quick Start: Complete Source Scaffold -Every tool follows this structure: +Every source follows this structure: ``` -tools// +sources// src/ index.ts # Re-exports: export { default, ClassName } from "./class-file" - .ts # Main Tool class + .ts # Main Source class .ts # (optional) Separate API client + transform functions package.json tsconfig.json @@ -26,7 +26,7 @@ tools// ```json { - "name": "@plotday/tool-", + "name": "@plotday/source-", "displayName": "Human Name", "description": "One-line purpose statement", "author": "Plot (https://plot.day)", @@ -56,10 +56,10 @@ tools// "repository": { "type": "git", "url": "https://github.com/plotday/plot.git", - "directory": "tools/" + "directory": "sources/" }, "homepage": "https://plot.day", - "keywords": ["plot", "tool", ""], + "keywords": ["plot", "source", ""], "publishConfig": { "access": "public" } } ``` @@ -67,7 +67,7 @@ tools// **Notes:** - `"@plotday/source"` export condition resolves to TypeScript source during workspace development - Add third-party SDKs to `dependencies` (e.g., `"@linear/sdk": "^72.0.0"`) -- Add `@plotday/tool-google-contacts` as `"workspace:^"` if your tool syncs contacts (Google tools only) +- Add `@plotday/source-google-contacts` as `"workspace:^"` if your source syncs contacts (Google sources only) ### tsconfig.json @@ -85,10 +85,10 @@ tools// ### src/index.ts ```typescript -export { default, ToolName } from "./tool-name"; +export { default, SourceName } from "./source-name"; ``` -## Tool Class Template +## Source Class Template ```typescript import { @@ -98,27 +98,22 @@ import { type NewActivityWithNotes, type NewNote, type SyncToolOptions, + Source, + type SourceBuilder, } from "@plotday/twister"; import type { NewContact } from "@plotday/twister/plot"; -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, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; -// Choose the correct common interface for your tool category: -// import type { CalendarTool, SyncOptions } from "@plotday/twister/common/calendar"; -// import type { ProjectTool, ProjectSyncOptions } from "@plotday/twister/common/projects"; -// import type { MessagingTool, MessageSyncOptions } from "@plotday/twister/common/messaging"; -// import type { DocumentTool, DocumentSyncOptions } from "@plotday/twister/common/documents"; - type SyncState = { cursor: string | null; batchNumber: number; @@ -126,7 +121,7 @@ type SyncState = { initialSync: boolean; }; -export class MyTool extends Tool implements ProjectTool { +export class MySource extends Source { // 1. Static constants static readonly PROVIDER = AuthProvider.Linear; // Use appropriate provider static readonly SCOPES = ["read", "write"]; @@ -134,15 +129,15 @@ export class MyTool extends Tool implements ProjectTool { declare readonly Options: SyncToolOptions; // 2. Declare dependencies - build(build: ToolBuilder) { + build(build: SourceBuilder) { return { integrations: build(Integrations, { providers: [{ - provider: MyTool.PROVIDER, - scopes: MyTool.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + provider: MySource.PROVIDER, + scopes: MySource.SCOPES, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }], }), network: build(Network, { urls: ["https://api.example.com/*"] }), @@ -152,61 +147,61 @@ export class MyTool extends Tool implements ProjectTool { }; } - // 3. Create API client using syncable-based auth - private async getClient(syncableId: string): Promise { - const token = await this.tools.integrations.get(MyTool.PROVIDER, syncableId); + // 3. Create API client using channel-based auth + private async getClient(channelId: string): Promise { + const token = await this.tools.integrations.get(MySource.PROVIDER, channelId); if (!token) throw new Error("No authentication token available"); return new SomeApiClient({ accessToken: token.token }); } // 4. Return available resources for the user to select - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getChannels(_auth: Authorization, token: AuthToken): Promise { const client = new SomeApiClient({ accessToken: token.token }); const resources = await client.listResources(); return resources.map(r => ({ id: r.id, title: r.name })); } // 5. Called when user enables a resource - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Store parent callback tokens const itemCallbackToken = await this.tools.callbacks.createFromParent( this.options.onItem ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + await this.set(`item_callback_${channel.id}`, itemCallbackToken); - if (this.options.onSyncableDisabled) { + if (this.options.onChannelDisabled) { const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - { meta: { syncProvider: "myprovider", syncableId: syncable.id } } + this.options.onChannelDisabled, + { meta: { syncProvider: "myprovider", channelId: channel.id } } ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + await this.set(`disable_callback_${channel.id}`, disableCallbackToken); } // Setup webhook and start initial sync - await this.setupWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupWebhook(channel.id); + await this.startBatchSync(channel.id); } // 6. Called when user disables a resource - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - const disableCallbackToken = await this.get(`disable_callback_${syncable.id}`); + const disableCallbackToken = await this.get(`disable_callback_${channel.id}`); if (disableCallbackToken) { await this.tools.callbacks.run(disableCallbackToken); await this.tools.callbacks.delete(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); + await this.clear(`disable_callback_${channel.id}`); } - const itemCallbackToken = await this.get(`item_callback_${syncable.id}`); + const itemCallbackToken = await this.get(`item_callback_${channel.id}`); if (itemCallbackToken) { await this.tools.callbacks.delete(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); + await this.clear(`item_callback_${channel.id}`); } - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } // 7. Public interface methods (from common interface) @@ -308,7 +303,7 @@ export class MyTool extends Tool implements ProjectTool { activity.meta = { ...activity.meta, syncProvider: "myprovider", - syncableId: resourceId, + channelId: resourceId, }; await this.tools.callbacks.run(callbackToken, activity); } @@ -345,6 +340,7 @@ export class MyTool extends Tool implements ProjectTool { notes: [{ key: "description", // Enables note upsert content: item.description || null, + contentType: item.descriptionHtml ? "html" as const : "text" as const, links: item.url ? [{ type: LinkType.external, title: "Open in Service", @@ -369,41 +365,27 @@ export class MyTool extends Tool implements ProjectTool { activity.meta = { ...activity.meta, syncProvider: "myprovider", - syncableId: resourceId, + channelId: resourceId, }; await this.tools.callbacks.run(callbackToken, activity); } } -export default MyTool; +export default MySource; ``` -## Common Tool Interfaces - -Choose the correct interface based on what your service provides. Import from `@plotday/twister/common/*`. - -| Interface | For | Examples | Key resource | -|-----------|-----|----------|-------------| -| `CalendarTool` | Calendar/scheduling services | Google Calendar, Outlook, Apple Calendar | Calendars with events | -| `ProjectTool` | Project/task management | Linear, Jira, Asana, GitHub Issues, Todoist, ClickUp, Trello, Monday | Projects with issues/tasks | -| `MessagingTool` | Email and chat services | Gmail, Slack, Discord, Microsoft Teams, Intercom | Channels/inboxes with threads | -| `DocumentTool` | Document/file services | Google Drive, Notion, Dropbox, OneDrive, Confluence | Folders with documents | -| None | Services that don't fit above | CRM, analytics, monitoring | Define your own interface | - -Each interface requires these methods: `get[Resources]()`, `startSync()`, `stopSync()`. Some have optional methods for bidirectional sync (`updateIssue`, `addIssueComment`, `addDocumentComment`, etc.). - -## The Integrations Pattern (Auth + Syncables) +## The Integrations Pattern (Auth + Channels) -**This is how ALL authentication works.** Auth is handled in the Flutter edit modal, not in code. Tools declare their provider config in `build()`. +**This is how ALL authentication works.** Auth is handled in the Flutter edit modal, not in code. Sources declare their provider config in `build()`. ### How It Works -1. Tool declares providers in `build()` with `getSyncables`, `onSyncEnabled`, `onSyncDisabled` callbacks +1. Source declares providers in `build()` with `getChannels`, `onChannelEnabled`, `onChannelDisabled` callbacks 2. User clicks "Connect" in the twist edit modal → OAuth flow happens automatically -3. After auth, the runtime calls your `getSyncables()` to list available resources -4. User enables resources in the modal → `onSyncEnabled()` fires -5. User disables resources → `onSyncDisabled()` fires -6. Get tokens via `this.tools.integrations.get(PROVIDER, syncableId)` +3. After auth, the runtime calls your `getChannels()` to list available resources +4. User enables resources in the modal → `onChannelEnabled()` fires +5. User disables resources → `onChannelDisabled()` fires +6. Get tokens via `this.tools.integrations.get(PROVIDER, channelId)` ### Available Providers @@ -415,7 +397,7 @@ For bidirectional sync where actions should be attributed to the acting user: ```typescript await this.tools.integrations.actAs( - MyTool.PROVIDER, + MySource.PROVIDER, actorId, // The user who performed the action activityId, // Activity to create auth prompt in (if user hasn't connected) this.performWriteBack, @@ -429,25 +411,25 @@ async performWriteBack(token: AuthToken, ...extraArgs: any[]): Promise { } ``` -### Cross-Tool Auth Sharing (Google Tools) +### Cross-Source Auth Sharing (Google Sources) -When building a Google tool that should also sync contacts, merge scopes: +When building a Google source that should also sync contacts, merge scopes: ```typescript -import GoogleContacts from "@plotday/tool-google-contacts"; +import GoogleContacts from "@plotday/source-google-contacts"; -build(build: ToolBuilder) { +build(build: SourceBuilder) { return { integrations: build(Integrations, { providers: [{ provider: AuthProvider.Google, scopes: Integrations.MergeScopes( - MyGoogleTool.SCOPES, + MyGoogleSource.SCOPES, GoogleContacts.SCOPES ), - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }], }), googleContacts: build(GoogleContacts), @@ -456,18 +438,18 @@ build(build: ToolBuilder) { } ``` -## Architecture: Tools Build, Twists Save +## Architecture: Sources Save Directly -**Tools NEVER call `plot.createActivity()` directly.** Tools build `NewActivityWithNotes` objects and deliver them to the parent twist via `this.tools.callbacks.run(callbackToken, activity)`. The parent twist decides what to save. +**Sources save data directly** via `integrations.saveLink()`. Sources build `NewLinkWithNotes` objects and save them, rather than passing them through a parent twist. This means: -- Tools request `Plot` with `ContactAccess.Write` (for contacts on activities), not `ActivityAccess.Create` -- Tools declare `static readonly Options: SyncToolOptions` to receive the `onItem` callback from the parent -- The parent twist's `onItem` callback calls `this.tools.plot.createActivity(activity)` +- Sources request `Plot` with `ContactAccess.Write` (for contacts on threads) +- Sources declare providers via `Integrations` with lifecycle callbacks +- Sources call save methods directly to persist synced data ## Critical: Callback Serialization Pattern -**The #1 mistake when building tools is passing function references as callback arguments.** Functions cannot be serialized across worker boundaries. +**The #1 mistake when building sources is passing function references as callback arguments.** Functions cannot be serialized across worker boundaries. ### ❌ WRONG - Passing Function as Callback Argument @@ -518,7 +500,7 @@ async syncBatch(resourceId: string): Promise { ## Callback Backward Compatibility -**All callbacks automatically upgrade to new tool versions on deployment.** You MUST maintain backward compatibility. +**All callbacks automatically upgrade to new source versions on deployment.** You MUST maintain backward compatibility. - ❌ Don't change function signatures (remove/reorder params, change types) - ✅ Do add optional parameters at the end @@ -551,12 +533,12 @@ async preUpgrade(): Promise { ## Storage Key Conventions -All tools use consistent key prefixes: +All sources use consistent key prefixes: | Key Pattern | Purpose | |------------|---------| | `item_callback_` | Serialized callback to parent's `onItem` | -| `disable_callback_` | Serialized callback to parent's `onSyncableDisabled` | +| `disable_callback_` | Serialized callback to parent's `onChannelDisabled` | | `sync_state_` | Current batch pagination state | | `sync_enabled_` | Boolean tracking enabled state | | `webhook_id_` | External webhook registration ID | @@ -572,7 +554,7 @@ The `activity.source` field is the idempotency key for automatic upserts. Use a :: — When provider has multiple entity types ``` -Examples from existing tools: +Examples from existing sources: ``` linear:issue: asana:task: @@ -599,6 +581,61 @@ https://slack.com/app_redirect?channel=&message_ts= — Slack uses full "reply--" — Reply to a comment ``` +## HTML Content Handling + +**Never strip HTML tags locally.** When external APIs return HTML content, pass it through with `contentType: "html"` and let the server convert it to clean markdown. Local regex-based tag stripping produces broken encoding, loses link structure, and collapses whitespace. + +### Pattern + +```typescript +// ✅ CORRECT: Pass raw HTML with contentType +const note = { + key: "description", + content: item.bodyHtml, // Raw HTML from API + contentType: "html" as const, // Server converts to markdown +}; + +// ✅ CORRECT: Use plain text when that's what you have +const note = { + key: "description", + content: item.bodyText, + contentType: "text" as const, +}; + +// ❌ WRONG: Stripping HTML locally +const stripped = html.replace(/<[^>]+>/g, " ").trim(); +const note = { content: stripped }; // Broken encoding, lost links +``` + +### When APIs provide both HTML and plain text + +Prefer HTML — the server-side `toMarkdown()` conversion (via Cloudflare AI) produces cleaner output with proper links, formatting, and character encoding. Only use plain text if no HTML is available. + +```typescript +function extractBody(part: MessagePart): { content: string; contentType: "text" | "html" } { + // Prefer HTML for server-side conversion + const htmlPart = findPart(part, "text/html"); + if (htmlPart) return { content: decode(htmlPart), contentType: "html" }; + + const textPart = findPart(part, "text/plain"); + if (textPart) return { content: decode(textPart), contentType: "text" }; + + return { content: "", contentType: "text" }; +} +``` + +### Previews + +For `preview` fields on threads/links, use a plain-text source (like Gmail's `snippet` or a truncated title) — never raw HTML. Previews are displayed directly and are not processed by the server. + +### ContentType values + +| Value | Meaning | +|-------|---------| +| `"text"` | Plain text — auto-links URLs, preserves line breaks | +| `"markdown"` | Already markdown (default if omitted) | +| `"html"` | HTML — converted to markdown server-side | + ## Sync Metadata Injection **Every synced activity MUST include sync metadata** in `activity.meta` for bulk operations (e.g., archiving all activities when a sync is disabled): @@ -607,20 +644,22 @@ https://slack.com/app_redirect?channel=&message_ts= — Slack uses full activity.meta = { ...activity.meta, syncProvider: "myprovider", // Provider identifier - syncableId: resourceId, // Resource being synced + channelId: resourceId, // Resource being synced }; ``` -This metadata is used by the twist's `onSyncableDisabled` callback to match and archive activities: +This metadata is used by the twist's `onChannelDisabled` callback to match and archive activities: ```typescript // In the twist: -async onSyncableDisabled(filter: ActivityFilter): Promise { +async onChannelDisabled(filter: ActivityFilter): Promise { await this.tools.plot.updateActivity({ match: filter, archived: true }); } ``` -## Initial vs. Incremental Sync +## Initial vs. Incremental Sync (REQUIRED) + +**Every source MUST track whether it is performing an initial sync (first import) or an incremental sync (ongoing updates).** Omitting this causes notification spam from bulk historical imports. | Field | Initial Sync | Incremental Sync | Reason | |-------|-------------|------------------|--------| @@ -635,11 +674,48 @@ const activity = { }; ``` +### How to propagate the flag + +The `initialSync` flag must flow from the entry point (`onChannelEnabled` / `startSync`) through every batch to the point where activities are created. There are two patterns: + +**Pattern A: Store in SyncState** (used in the scaffold above) + +The scaffold's `SyncState` type includes `initialSync: boolean`. Set it to `true` in `startBatchSync`, read it in `syncBatch`, and preserve it across batches. Webhook/incremental handlers pass `false`. + +**Pattern B: Pass as callback argument** (used by sources like Gmail that don't store `initialSync` in state) + +Pass `initialSync` as an explicit argument through `this.callback()`: + +```typescript +// onChannelEnabled — initial sync +const syncCallback = await this.callback(this.syncBatch, 1, "full", channel.id, true); + +// startIncrementalSync — not initial +const syncCallback = await this.callback(this.syncBatch, 1, "incremental", channelId, false); + +// syncBatch — accept and propagate the flag +async syncBatch( + batchNumber: number, + mode: "full" | "incremental", + channelId: string, + initialSync?: boolean // optional for backward compat with old serialized callbacks +): Promise { + const isInitial = initialSync ?? (mode === "full"); // safe default for old callbacks + // ... pass isInitial to processItems and to next batch callback +} +``` + +**Whichever pattern you use, verify that ALL entry points set the flag correctly:** +- `onChannelEnabled` → `true` (first import) +- `startSync` → `true` (manual full sync) +- Webhook / incremental handler → `false` +- Next batch callback → propagate current value + ## Webhook Patterns ### Localhost Guard (REQUIRED) -All tools MUST skip webhook registration in local development: +All sources MUST skip webhook registration in local development: ```typescript const webhookUrl = await this.tools.network.createWebhook({}, this.onWebhook, resourceId); @@ -678,7 +754,7 @@ private async scheduleWatchRenewal(resourceId: string): Promise { ## Bidirectional Sync -For tools that support write-backs (updating external items from Plot): +For sources that support write-backs (updating external items from Plot): ### Issue/Task Updates (`updateIssue`) @@ -713,7 +789,7 @@ async addIssueComment(meta: ActivityMeta, body: string, noteId?: string): Promis The parent twist prevents infinite loops by checking note authorship: ```typescript -// In the twist (not the tool): +// In the twist (not the source): async onNoteCreated(note: Note): Promise { if (note.author.type === ActorType.Twist) return; // Prevent loops // ... sync note to external service @@ -722,7 +798,7 @@ async onNoteCreated(note: Note): Promise { ## Contacts Pattern -Tools that sync user data should create contacts for authors and assignees: +Sources that sync user data should create contacts for authors and assignees: ```typescript import type { NewContact } from "@plotday/twister/plot"; @@ -765,25 +841,25 @@ declare const Buffer: { ## Building and Testing ```bash -# Build the tool -cd public/tools/ && pnpm build +# Build the source +cd public/sources/ && pnpm build # Type-check without building -cd public/tools/ && pnpm exec tsc --noEmit +cd public/sources/ && pnpm exec tsc --noEmit # Install dependencies (from repo root) pnpm install ``` -After creating a new tool, add it to `pnpm-workspace.yaml` if not already covered by the glob pattern. +After creating a new source, add it to `pnpm-workspace.yaml` if not already covered by the glob pattern. -## Tool Development Checklist +## Source Development Checklist -- [ ] Extend `Tool` and implement the correct common interface +- [ ] Extend `Source` - [ ] Declare `static readonly PROVIDER`, `static readonly SCOPES` - [ ] Declare `static readonly Options: SyncToolOptions` and `declare readonly Options: SyncToolOptions` - [ ] Declare all dependencies in `build()`: Integrations, Network, Callbacks, Tasks, Plot -- [ ] Implement `getSyncables()`, `onSyncEnabled()`, `onSyncDisabled()` +- [ ] Implement `getChannels()`, `onChannelEnabled()`, `onChannelDisabled()` - [ ] Convert parent callbacks to tokens with `createFromParent()` — **never pass functions to `this.callback()`** - [ ] Store callback tokens with `this.set()`, retrieve with `this.get()` - [ ] Pass only serializable values (no functions, no undefined) to `this.callback()` @@ -792,38 +868,43 @@ After creating a new tool, add it to `pnpm-workspace.yaml` if not already covere - [ ] Verify webhook signatures - [ ] Use canonical `source` URLs for activity upserts (immutable IDs) - [ ] Use `note.key` for note-level upserts -- [ ] Inject `syncProvider` and `syncableId` into `activity.meta` -- [ ] Handle `initialSync` flag: `unread: false` and `archived: false` for initial, omit both for incremental +- [ ] Set `contentType: "html"` on notes with HTML content — **never strip HTML locally** +- [ ] Inject `syncProvider` and `channelId` into `activity.meta` +- [ ] Set `created` on notes using the external system's timestamp (not sync time) +- [ ] Handle `initialSync` flag in **every sync entry point**: `onChannelEnabled`/`startSync` set `true`, webhooks/incremental set `false`, and the flag is propagated through all batch callbacks to where activities are created. Set `unread: false` and `archived: false` for initial, omit both for incremental - [ ] Create contacts for authors/assignees with `NewContact` -- [ ] Clean up all stored state and callbacks in `stopSync()` and `onSyncDisabled()` +- [ ] Clean up all stored state and callbacks in `stopSync()` and `onChannelDisabled()` - [ ] Add `package.json` with correct structure, `tsconfig.json`, and `src/index.ts` re-export -- [ ] Verify the tool builds: `pnpm build` +- [ ] Verify the source builds: `pnpm build` ## Common Pitfalls 1. **❌ Passing functions to `this.callback()`** — Convert to tokens first with `createFromParent()` 2. **❌ Storing functions with `this.set()`** — Convert to tokens first 3. **❌ Not validating callback token exists** — Always check before `callbacks.run()` -4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `syncableId` into `activity.meta` -5. **❌ Using mutable IDs in `source`** — Use immutable IDs (Jira issue ID, not issue key) -6. **❌ Not breaking loops into batches** — Each execution has ~1000 request limit -7. **❌ Missing localhost guard** — Webhook registration fails silently on localhost -8. **❌ Calling `plot.createActivity()` from a tool** — Tools build data, twists save it -9. **❌ Breaking callback signatures** — Old callbacks auto-upgrade; add optional params at end only -10. **❌ Passing `undefined` in serializable values** — Use `null` instead -11. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state -12. **❌ Two-way sync without metadata correlation** — Embed Plot ID in external item metadata to prevent duplicates from race conditions (see SYNC_STRATEGIES.md §6) +4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `activity.meta` +5. **❌ Not propagating `initialSync` through the full sync pipeline** — The flag must flow from the entry point (`onChannelEnabled`/`startSync` → `true`, webhook → `false`) through every batch callback to where activities are created. Missing this causes notification spam from bulk historical imports +6. **❌ Using mutable IDs in `source`** — Use immutable IDs (Jira issue ID, not issue key) +7. **❌ Not breaking loops into batches** — Each execution has ~1000 request limit +8. **❌ Missing localhost guard** — Webhook registration fails silently on localhost +9. **❌ Calling `plot.createThread()` from a source** — Sources save data directly via `integrations.saveLink()` +10. **❌ Breaking callback signatures** — Old callbacks auto-upgrade; add optional params at end only +11. **❌ Passing `undefined` in serializable values** — Use `null` instead +12. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state +13. **❌ Two-way sync without metadata correlation** — Embed Plot ID in external item metadata to prevent duplicates from race conditions (see SYNC_STRATEGIES.md §6) +14. **❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side conversion. Local regex stripping breaks encoding and loses links +15. **❌ Not setting `created` on notes from external data** — Always pass the external system's timestamp (e.g., `internalDate` from Gmail, `created_at` from an API) as the note's `created` field. Omitting it defaults to sync time, making all notes appear to have been created "just now" ## Study These Examples -| Tool | Category | Key Patterns | -|------|----------|-------------| -| `linear/` | ProjectTool | Clean reference implementation, webhook handling, bidirectional sync | -| `google-calendar/` | CalendarTool | Recurring events, RSVP write-back, watch renewal, cross-tool auth sharing | -| `slack/` | MessagingTool | Team-sharded webhooks, thread model, Slack-specific auth | -| `gmail/` | MessagingTool | PubSub webhooks, email thread transformation | -| `google-drive/` | DocumentTool | Document comments, reply threading, file watching | -| `jira/` | ProjectTool | Immutable vs mutable IDs, comment metadata for dedup | -| `asana/` | ProjectTool | HMAC webhook verification, section-based projects | -| `outlook-calendar/` | CalendarTool | Microsoft Graph API, subscription management | -| `google-contacts/` | (Supporting) | Contact sync, cross-tool `syncWithAuth()` pattern | +| Source | Category | Key Patterns | +|--------|----------|-------------| +| `linear/` | ProjectSource | Clean reference implementation, webhook handling, bidirectional sync | +| `google-calendar/` | CalendarSource | Recurring events, RSVP write-back, watch renewal, cross-source auth sharing | +| `slack/` | MessagingSource | Team-sharded webhooks, thread model, Slack-specific auth | +| `gmail/` | MessagingSource | PubSub webhooks, email thread transformation, HTML contentType, callback-arg initialSync pattern | +| `google-drive/` | DocumentSource | Document comments, reply threading, file watching | +| `jira/` | ProjectSource | Immutable vs mutable IDs, comment metadata for dedup | +| `asana/` | ProjectSource | HMAC webhook verification, section-based projects | +| `outlook-calendar/` | CalendarSource | Microsoft Graph API, subscription management | +| `google-contacts/` | (Supporting) | Contact sync, cross-source `syncWithAuth()` pattern | diff --git a/tools/CLAUDE.md b/sources/CLAUDE.md similarity index 100% rename from tools/CLAUDE.md rename to sources/CLAUDE.md diff --git a/tools/asana/CHANGELOG.md b/sources/asana/CHANGELOG.md similarity index 100% rename from tools/asana/CHANGELOG.md rename to sources/asana/CHANGELOG.md diff --git a/tools/asana/package.json b/sources/asana/package.json similarity index 75% rename from tools/asana/package.json rename to sources/asana/package.json index 650e9d1..b2b2f5f 100644 --- a/tools/asana/package.json +++ b/sources/asana/package.json @@ -1,7 +1,9 @@ { - "name": "@plotday/tool-asana", + "name": "@plotday/source-asana", + "plotTwistId": "d8f4e839-c152-41a2-926c-700e23d4fc77", "displayName": "Asana", - "description": "Sync with Asana project management", + "description": "Tasks from Asana", + "logoUrl": "https://api.iconify.design/logos/asana.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.7.1", @@ -15,14 +17,12 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^", @@ -47,8 +47,5 @@ "tool", "asana", "project-management" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/asana/src/asana.ts b/sources/asana/src/asana.ts similarity index 66% rename from tools/asana/src/asana.ts rename to sources/asana/src/asana.ts index 4843fae..4665767 100644 --- a/tools/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -1,37 +1,35 @@ import * as asana from "asana"; import { - type Activity, - type ActivityFilter, - type Link, - LinkType, - ActivityMeta, - ActivityType, - type NewActivity, - type NewActivityWithNotes, - type NewNote, - type Serializable, - type SyncToolOptions, + type Action, + ActionType, + ThreadMeta, + type NewLinkWithNotes, } from "@plotday/twister"; -import type { - Project, - ProjectSyncOptions, - ProjectTool, -} from "@plotday/twister/common/projects"; import type { NewContact } from "@plotday/twister/plot"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; -import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; +import { Source } from "@plotday/twister/source"; +import type { ToolBuilder } from "@plotday/twister/tool"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; +type Project = { + id: string; + name: string; + description: string | null; + key: string | null; +}; + +type ProjectSyncOptions = { + timeMin?: Date; +}; + type SyncState = { offset: number; batchNumber: number; @@ -40,32 +38,35 @@ type SyncState = { }; /** - * Asana project management tool + * Asana project management source * - * Implements the ProjectTool interface for syncing Asana projects and tasks - * with Plot activities. + * Implements the ProjectSource interface for syncing Asana projects and tasks + * with Plot threads. */ -export class Asana extends Tool implements ProjectTool { +export class Asana extends Source { static readonly PROVIDER = AuthProvider.Asana; static readonly SCOPES = ["default"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; + + readonly provider = AuthProvider.Asana; + readonly scopes = Asana.SCOPES; + readonly linkTypes = [ + { + type: "task", + label: "Task", + logo: "https://api.iconify.design/logos/asana.svg", + logoMono: "https://api.iconify.design/simple-icons/asana.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ]; build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [{ - provider: Asana.PROVIDER, - scopes: Asana.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://app.asana.com/*"] }), - callbacks: build(Callbacks), tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), }; } @@ -73,7 +74,7 @@ export class Asana extends Tool implements ProjectTool { * Create Asana API client with auth token */ private async getClient(projectId: string): Promise { - const token = await this.tools.integrations.get(Asana.PROVIDER, projectId); + const token = await this.tools.integrations.get(projectId); if (!token) { throw new Error("No Asana authentication token available"); } @@ -81,78 +82,40 @@ export class Asana extends Tool implements ProjectTool { } /** - * Returns available Asana projects as syncable resources. + * Returns available Asana projects as channel resources. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getChannels(_auth: Authorization, token: AuthToken): Promise { const client = asana.Client.create().useAccessToken(token.token); const workspaces = await client.workspaces.getWorkspaces(); - const allProjects: Syncable[] = []; + const allChannels: Channel[] = []; 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 }); + allChannels.push({ id: project.gid, title: project.name }); } } - return allProjects; + return allChannels; } /** - * Called when a syncable project is enabled for syncing. - * Creates callback tokens from options and auto-starts sync. + * Called when a channel is enabled for syncing. + * Sets up webhook 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); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and begin batch sync - await this.setupAsanaWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupAsanaWebhook(channel.id); + await this.startBatchSync(channel.id); } /** - * Called when a syncable project is disabled. - * Stops sync, runs disable callback, and cleans up stored tokens. + * Called when a channel is disabled. + * Stops sync and archives all threads from this channel. */ - 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}`); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.id}`); } /** @@ -188,28 +151,16 @@ export class Asana extends Tool implements ProjectTool { /** * Start syncing tasks from an Asana project */ - async startSync< - TArgs extends Serializable[], - TCallback extends (task: NewActivityWithNotes, ...args: TArgs) => any - >( + async startSync( options: { projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs + } & ProjectSyncOptions ): Promise { const { projectId, timeMin } = options; // Setup webhook for real-time updates await this.setupAsanaWebhook(projectId); - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${projectId}`, callbackToken); - // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); } @@ -294,12 +245,6 @@ export class Asana extends Tool implements ProjectTool { throw new Error(`Sync state not found for project ${projectId}`); } - // Retrieve callback token from storage - const callbackToken = await this.get(`item_callback_${projectId}`); - if (!callbackToken) { - throw new Error(`Callback token not found for project ${projectId}`); - } - const client = await this.getClient(projectId); // Build request params @@ -342,18 +287,17 @@ export class Asana extends Tool implements ProjectTool { } } - const activityWithNotes = await this.convertTaskToActivity( + const linkWithNotes = await this.convertTaskToLink( task, projectId ); // Set unread based on sync type (false for initial sync to avoid notification overload) - activityWithNotes.unread = !state.initialSync; + linkWithNotes.unread = !state.initialSync; // Unarchive on initial sync only (preserve user's archive state on incremental syncs) if (state.initialSync) { - activityWithNotes.archived = false; + linkWithNotes.archived = false; } - // Execute the callback using the callback token - await this.tools.callbacks.run(callbackToken, activityWithNotes); + await this.tools.integrations.saveLink(linkWithNotes); } // Check if more pages by checking if we got a full batch @@ -381,12 +325,12 @@ export class Asana extends Tool implements ProjectTool { } /** - * Convert an Asana task to a Plot Activity + * Convert an Asana task to a Plot Link */ - private async convertTaskToActivity( + private async convertTaskToLink( task: any, projectId: string - ): Promise { + ): Promise { const createdBy = task.created_by; const assignee = task.assignee; @@ -410,7 +354,7 @@ export class Asana extends Tool implements ProjectTool { } // Build notes array: always create initial note with description and link - const notes: NewNote[] = []; + const notes: any[] = []; // Extract description (if any) let description: string | null = null; @@ -419,83 +363,76 @@ export class Asana extends Tool implements ProjectTool { } // Use stable identifier for source - const activitySource = `asana:task:${task.gid}`; + const threadSource = `asana:task:${task.gid}`; // Construct Asana task URL for link const taskUrl = `https://app.asana.com/0/${projectId}/${task.gid}`; - // Build activity-level links - const activityLinks: Link[] = []; - activityLinks.push({ - type: LinkType.external, + // Build thread-level actions + const threadActions: Action[] = []; + threadActions.push({ + type: ActionType.external, title: `Open in Asana`, url: taskUrl, }); - // Create initial note with description (links moved to activity level) + // Create initial note with description (actions moved to thread level) notes.push({ - activity: { source: activitySource }, key: "description", content: description, created: task.created_at ? new Date(task.created_at) : undefined, }); return { - source: activitySource, - type: ActivityType.Action, + source: threadSource, + type: "task", title: task.name, created: task.created_at ? new Date(task.created_at) : undefined, + channelId: projectId, meta: { taskGid: task.gid, projectId, syncProvider: "asana", syncableId: projectId, }, - links: activityLinks.length > 0 ? activityLinks : undefined, + actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: taskUrl, author: authorContact, assignee: assigneeContact ?? null, // Explicitly set to null for unassigned tasks - done: - task.completed && task.completed_at - ? new Date(task.completed_at) - : null, + status: task.completed && task.completed_at ? "done" : "open", notes, preview: description || null, }; } /** - * Update task with new values - * - * @param activity - The updated activity + * Update task with new values from the app */ - async updateIssue(activity: Activity): Promise { - // Extract Asana task GID and project ID from meta - const taskGid = activity.meta?.taskGid as string | undefined; + async updateIssue(link: import("@plotday/twister").Link): Promise { + const taskGid = link.meta?.taskGid as string | undefined; if (!taskGid) { - throw new Error("Asana task GID not found in activity meta"); + throw new Error("Asana task GID not found in link meta"); } - const projectId = activity.meta?.projectId as string | undefined; + const projectId = link.meta?.projectId as string | undefined; if (!projectId) { - throw new Error("Asana project ID not found in activity meta"); + throw new Error("Asana project ID not found in link meta"); } const client = await this.getClient(projectId); const updateFields: any = {}; // Handle title - if (activity.title !== null) { - updateFields.name = activity.title; + if (link.title) { + updateFields.name = link.title; } // Handle assignee - updateFields.assignee = activity.assignee?.id || null; + updateFields.assignee = link.assignee?.id || null; - // Handle completion status based on done - // Asana only has completed boolean (no In Progress state) - updateFields.completed = - activity.type === ActivityType.Action && activity.done !== null; + // Handle completion status based on link status + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed"; + updateFields.completed = isDone; - // Apply updates if any fields changed if (Object.keys(updateFields).length > 0) { await client.tasks.updateTask(taskGid, updateFields); } @@ -504,20 +441,20 @@ export class Asana extends Tool implements ProjectTool { /** * Add a comment (story) to an Asana task * - * @param meta - Activity metadata containing taskGid and projectId + * @param meta - Thread metadata containing taskGid and projectId * @param body - Comment text (markdown not directly supported, plain text) */ async addIssueComment( - meta: ActivityMeta, + meta: ThreadMeta, body: string ): Promise { const taskGid = meta.taskGid as string | undefined; if (!taskGid) { - throw new Error("Asana task GID not found in activity meta"); + throw new Error("Asana task GID not found in thread meta"); } const projectId = meta.projectId as string | undefined; if (!projectId) { - throw new Error("Asana project ID not found in activity meta"); + throw new Error("Asana project ID not found in thread meta"); } const client = await this.getClient(projectId); @@ -601,13 +538,6 @@ export class Asana extends Tool implements ProjectTool { } } - // Get callback token (needed by both handlers) - const callbackToken = await this.get(`item_callback_${projectId}`); - if (!callbackToken) { - console.warn("No callback token found for project:", projectId); - return; - } - // Process events if (payload.events && Array.isArray(payload.events)) { for (const event of payload.events) { @@ -617,18 +547,10 @@ export class Asana extends Tool implements ProjectTool { if (changedField === "stories") { // Story/comment event - handle separately - await this.handleStoryWebhook( - event, - projectId, - callbackToken - ); + await this.handleStoryWebhook(event, projectId); } else { // Task property changed - update metadata only - await this.handleTaskWebhook( - event, - projectId, - callbackToken - ); + await this.handleTaskWebhook(event, projectId); } } } @@ -640,8 +562,7 @@ export class Asana extends Tool implements ProjectTool { */ private async handleTaskWebhook( event: any, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const client = await this.getClient(projectId); @@ -690,7 +611,7 @@ export class Asana extends Tool implements ProjectTool { } // Use stable identifier for source - const activitySource = `asana:task:${task.gid}`; + const threadSource = `asana:task:${task.gid}`; // Extract description let description: string | null = null; @@ -698,12 +619,13 @@ export class Asana extends Tool implements ProjectTool { description = task.notes; } - // Create partial activity update (no notes = doesn't touch existing notes) - const activity: NewActivity = { - source: activitySource, - type: ActivityType.Action, + // Create partial link update (empty notes = doesn't touch existing notes) + const link: NewLinkWithNotes = { + source: threadSource, + type: "task", title: task.name, created: task.created_at ? new Date(task.created_at) : undefined, + channelId: projectId, meta: { taskGid: task.gid, projectId, @@ -712,14 +634,12 @@ export class Asana extends Tool implements ProjectTool { }, author: authorContact, assignee: assigneeContact ?? null, - done: - task.completed && task.completed_at - ? new Date(task.completed_at) - : null, + status: task.completed && task.completed_at ? "done" : "open", preview: description || null, + notes: [], }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(link); } catch (error) { console.warn("Failed to process Asana task webhook:", error); } @@ -730,8 +650,7 @@ export class Asana extends Tool implements ProjectTool { */ private async handleStoryWebhook( event: any, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const client = await this.getClient(projectId); @@ -739,7 +658,7 @@ export class Asana extends Tool implements ProjectTool { try { // Use stable identifier for source - const activitySource = `asana:task:${taskGid}`; + const threadSource = `asana:task:${taskGid}`; // Fetch stories (comments) for this task // We fetch all stories since Asana doesn't provide the specific story GID in the webhook @@ -774,21 +693,22 @@ export class Asana extends Tool implements ProjectTool { }; } - // Create activity update with single story note - const activity: NewActivityWithNotes = { - source: activitySource, - type: ActivityType.Action, // Required field (will match existing activity) + // Create link update with single story note + const link: NewLinkWithNotes = { + source: threadSource, + type: "task", + title: taskGid, // Placeholder; upsert by source will preserve existing title notes: [ { key: `story-${latestStory.gid}`, - activity: { source: activitySource }, content: latestStory.text || "", created: latestStory.created_at ? new Date(latestStory.created_at) : undefined, author: storyAuthor, - } as NewNote, + } as any, ], + channelId: projectId, meta: { taskGid, projectId, @@ -797,7 +717,7 @@ export class Asana extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(link); } catch (error) { console.warn("Failed to process Asana story webhook:", error); } @@ -819,13 +739,6 @@ export class Asana extends Tool implements ProjectTool { await this.clear(`webhook_id_${projectId}`); } - // Cleanup callback - const callbackToken = await this.get(`item_callback_${projectId}`); - if (callbackToken) { - await this.deleteCallback(callbackToken); - await this.clear(`item_callback_${projectId}`); - } - // Cleanup sync state await this.clear(`sync_state_${projectId}`); } diff --git a/tools/asana/src/index.ts b/sources/asana/src/index.ts similarity index 100% rename from tools/asana/src/index.ts rename to sources/asana/src/index.ts diff --git a/tools/asana/tsconfig.json b/sources/asana/tsconfig.json similarity index 100% rename from tools/asana/tsconfig.json rename to sources/asana/tsconfig.json diff --git a/tools/github/package.json b/sources/github/package.json similarity index 67% rename from tools/github/package.json rename to sources/github/package.json index 636b854..19fcb4e 100644 --- a/tools/github/package.json +++ b/sources/github/package.json @@ -1,7 +1,10 @@ { - "name": "@plotday/tool-github", + "name": "@plotday/source-github", + "plotTwistId": "ba3afb64-af6e-4c64-bcff-5fa8575d21f0", "displayName": "GitHub", - "description": "Sync with GitHub pull requests and code reviews", + "description": "Pull requests, issues, and code reviews", + "logoUrl": "https://api.iconify.design/logos/github-icon.svg", + "logoUrlDark": "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.1.0", @@ -15,14 +18,12 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" @@ -45,8 +46,5 @@ "github", "source-control", "code-review" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts new file mode 100644 index 0000000..814677e --- /dev/null +++ b/sources/github/src/github.ts @@ -0,0 +1,569 @@ +import { + type Link, + type NewLinkWithNotes, + type Note, + Source, + type ThreadMeta, + type ToolBuilder, +} from "@plotday/twister"; +import type { NewContact } from "@plotday/twister/plot"; +import { Options } from "@plotday/twister/options"; +import { + AuthProvider, + type AuthToken, + type Authorization, + Integrations, + type Channel, +} from "@plotday/twister/tools/integrations"; +import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +import { Tasks } from "@plotday/twister/tools/tasks"; +import { + startPRBatchSync, + syncPRBatch, + handlePRWebhook, + handleReviewWebhook, + handlePRCommentWebhook, + addPRComment, + updatePRStatus, +} from "./pr-sync"; +import { + startIssueBatchSync, + syncIssueBatch, + handleIssueWebhook, + handleIssueCommentWebhook, + updateIssue, + addIssueComment, +} from "./issue-sync"; + +// ---------- Exported types (used by pr-sync.ts and issue-sync.ts) ---------- + +export type GitHubUser = { + id: number; + login: string; + avatar_url?: string; + name?: string; + email?: string; +}; + +export type GitHubPullRequest = { + id: number; + number: number; + title: string; + body: string | null; + state: "open" | "closed"; + html_url: string; + created_at: string; + updated_at: string; + closed_at: string | null; + merged_at: string | null; + user: GitHubUser; + assignee: GitHubUser | null; + draft: boolean; + base: { repo: { full_name: string; owner: { login: string }; name: string } }; +}; + +export type GitHubIssueComment = { + id: number; + body: string; + created_at: string; + updated_at: string; + user: GitHubUser; + html_url: string; +}; + +export type GitHubReview = { + id: number; + body: string; + state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING"; + submitted_at: string; + user: GitHubUser; + html_url: string; +}; + +type GitHubRepo = { + id: number; + full_name: string; + name: string; + description: string | null; + html_url: string; + owner: { login: string }; + default_branch: string; + private: boolean; +}; + +/** + * GitHub source — syncs pull requests and issues from GitHub repositories. + * + * Options: + * - syncPullRequests: boolean (default: true) — sync PRs, reviews, and PR comments + * - syncIssues: boolean (default: true) — sync issues and issue comments + */ +export class GitHub extends Source { + static readonly PROVIDER = AuthProvider.GitHub; + static readonly SCOPES = ["repo"]; + + readonly provider = AuthProvider.GitHub; + readonly scopes = GitHub.SCOPES; + readonly linkTypes = [ + { + type: "pull_request", + label: "Pull Request", + logo: "https://api.iconify.design/logos/github-icon.svg", + logoDark: "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", + logoMono: "https://api.iconify.design/simple-icons/github.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + { status: "merged", label: "Merged" }, + ], + }, + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/logos/github-icon.svg", + logoDark: "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", + logoMono: "https://api.iconify.design/simple-icons/github.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + ], + }, + ]; + + build(build: ToolBuilder) { + return { + options: build(Options, { + syncPullRequests: { + type: "boolean" as const, + label: "Sync Pull Requests", + description: "Sync pull requests, reviews, and PR comments", + default: true, + }, + syncIssues: { + type: "boolean" as const, + label: "Sync Issues", + description: "Sync issues and issue comments", + default: true, + }, + }), + integrations: build(Integrations), + network: build(Network, { urls: ["https://api.github.com/*"] }), + tasks: build(Tasks), + }; + } + + // ---------- Public helpers (used by pr-sync.ts / issue-sync.ts) ---------- + + /** + * Make an authenticated GitHub API request + */ + async githubFetch( + token: string, + path: string, + options?: RequestInit, + ): Promise { + return fetch(`https://api.github.com${path}`, { + ...options, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...options?.headers, + }, + }); + } + + /** + * Get an authenticated token for a channel repository + */ + async getToken(channelId: string): Promise { + const authToken = await this.tools.integrations.get(channelId); + if (!authToken) { + throw new Error("No GitHub authentication token available"); + } + return authToken.token; + } + + /** + * Convert a GitHub user to a NewContact using noreply email + */ + userToContact(user: GitHubUser): NewContact { + return { + email: `${user.id}+${user.login}@users.noreply.github.com`, + name: user.login, + avatar: user.avatar_url ?? undefined, + }; + } + + /** + * Save a link via integrations + */ + async saveLink(link: NewLinkWithNotes): Promise { + await this.tools.integrations.saveLink(link); + } + + /** + * Create a persistent callback (public wrapper for this.callback) + */ + // @ts-ignore - simplified signature for public access + async createCallback(fn: any, ...extraArgs: any[]): Promise { + return this.callback(fn, ...extraArgs); + } + + // Public wrappers for protected Twist methods (used by helper files) + override async get(key: string): Promise { + return super.get(key); + } + override async set(key: string, value: T): Promise { + return super.set(key, value); + } + override async clear(key: string): Promise { + return super.clear(key); + } + override async runTask(callback: any, options?: { runAt?: Date }): Promise { + return super.runTask(callback, options); + } + + // ---------- Public batch sync entry points (called by helper files via callback) ---------- + + /** + * Callback entry point for PR batch sync + */ + async syncPRBatch(repositoryId: string): Promise { + await syncPRBatch(this, repositoryId); + } + + /** + * Callback entry point for issue batch sync + */ + async syncIssueBatch(repositoryId: string): Promise { + await syncIssueBatch(this, repositoryId); + } + + // ---------- Channel lifecycle ---------- + + /** + * Returns available GitHub repositories as channel resources. + */ + async getChannels( + _auth: Authorization, + token: AuthToken, + ): Promise { + const repos: GitHubRepo[] = []; + let page = 1; + + while (true) { + const response = await fetch( + `https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=100&page=${page}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token.token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (!response.ok) break; + + const batch: GitHubRepo[] = await response.json(); + if (batch.length === 0) break; + + repos.push(...batch); + if (batch.length < 100) break; + page++; + } + + return repos.map((repo) => ({ + id: repo.full_name, + title: repo.full_name, + })); + } + + /** + * Called when a channel repository is enabled for syncing. + */ + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); + + // Setup webhook subscribing to all event types + await this.setupWebhook(channel.id); + + // Start initial sync for enabled types + const options = this.tools.options as { syncPullRequests: boolean; syncIssues: boolean }; + if (options.syncPullRequests) { + await startPRBatchSync(this, channel.id, true); + } + if (options.syncIssues) { + await startIssueBatchSync(this, channel.id, true); + } + } + + /** + * Called when a channel repository is disabled. + */ + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.id}`); + } + + /** + * Called when options are changed (e.g. toggling PR or issue sync). + * Starts sync for newly enabled types on all active channels. + * Disabled types simply stop receiving webhook events — existing items remain. + */ + async onOptionsChanged( + oldOptions: Record, + newOptions: Record, + ): Promise { + // Find all enabled channels + const channelKeys = await this.tools.store.list("sync_enabled_"); + + for (const key of channelKeys) { + const channelId = key.replace("sync_enabled_", ""); + + // PRs toggled on → start PR sync + if (!oldOptions.syncPullRequests && newOptions.syncPullRequests) { + await startPRBatchSync(this, channelId, true); + } + + // Issues toggled on → start issue sync + if (!oldOptions.syncIssues && newOptions.syncIssues) { + await startIssueBatchSync(this, channelId, true); + } + } + } + + // ---------- Write-back hooks ---------- + + /** + * Called when a link created by this source is updated by the user. + */ + async onLinkUpdated(link: Link): Promise { + if (link.type === "pull_request") { + await updatePRStatus(this, link); + } else if (link.type === "issue") { + await updateIssue(this, link); + } + } + + /** + * Called when a note is created on a thread owned by this source. + */ + async onNoteCreated(note: Note, meta: ThreadMeta): Promise { + if (meta.prNumber) { + await addPRComment(this, meta, note.content ?? ""); + } else if (meta.issueNumber) { + await addIssueComment(this, meta, note.content ?? ""); + } + } + + // ---------- Webhook ---------- + + /** + * Setup GitHub webhook for real-time updates. + * Subscribes to all event types regardless of options, + * so toggling on later works without re-creating the webhook. + */ + private async setupWebhook(repositoryId: string): Promise { + try { + const secret = crypto.randomUUID(); + + const webhookUrl = await this.tools.network.createWebhook( + {}, + this.onWebhook, + repositoryId, + ); + + if ( + webhookUrl.includes("localhost") || + webhookUrl.includes("127.0.0.1") + ) { + return; + } + + await this.set(`webhook_secret_${repositoryId}`, secret); + + const token = await this.getToken(repositoryId); + const [owner, repo] = repositoryId.split("/"); + + const response = await this.githubFetch( + token, + `/repos/${owner}/${repo}/hooks`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "web", + active: true, + events: [ + "pull_request", + "pull_request_review", + "issues", + "issue_comment", + ], + config: { + url: webhookUrl, + content_type: "json", + secret, + insecure_ssl: "0", + }, + }), + }, + ); + + if (response.ok) { + const webhook = await response.json(); + if (webhook.id) { + await this.set(`webhook_id_${repositoryId}`, String(webhook.id)); + } + } else { + console.error( + "Failed to create GitHub webhook:", + response.status, + await response.text(), + ); + } + } catch (error) { + console.error( + "Failed to set up GitHub webhook - real-time updates will not work:", + error, + ); + } + } + + /** + * Verify GitHub webhook signature using HMAC-SHA256 + */ + private async verifyWebhookSignature( + secret: string, + body: string, + signature: string, + ): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + const signed = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(body), + ); + + const expected = + "sha256=" + + Array.from(new Uint8Array(signed)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + if (expected.length !== signature.length) return false; + let result = 0; + for (let i = 0; i < expected.length; i++) { + result |= expected.charCodeAt(i) ^ signature.charCodeAt(i); + } + return result === 0; + } + + /** + * Handle incoming webhook events from GitHub. + * Routes to PR or issue handlers based on event type and current options. + */ + private async onWebhook( + request: WebhookRequest, + repositoryId: string, + ): Promise { + const secret = await this.get(`webhook_secret_${repositoryId}`); + if (!secret) { + console.warn("GitHub webhook secret not found, skipping verification"); + return; + } + + if (!request.rawBody) { + console.warn("GitHub webhook missing raw body"); + return; + } + + const signature = request.headers["x-hub-signature-256"]; + if (!signature) { + console.warn("GitHub webhook missing signature header"); + return; + } + + const valid = await this.verifyWebhookSignature( + secret, + request.rawBody, + signature, + ); + if (!valid) { + console.warn("GitHub webhook signature verification failed"); + return; + } + + const event = request.headers["x-github-event"]; + const payload = + typeof request.body === "string" + ? JSON.parse(request.body) + : request.body; + + const options = this.tools.options as { syncPullRequests: boolean; syncIssues: boolean }; + + if (event === "pull_request") { + if (options.syncPullRequests) { + await handlePRWebhook(this, payload, repositoryId); + } + } else if (event === "pull_request_review") { + if (options.syncPullRequests) { + await handleReviewWebhook(this, payload, repositoryId); + } + } else if (event === "issues") { + if (options.syncIssues) { + await handleIssueWebhook(this, payload, repositoryId); + } + } else if (event === "issue_comment") { + // issue_comment fires for both issues and PRs + if (payload.issue?.pull_request) { + if (options.syncPullRequests) { + await handlePRCommentWebhook(this, payload, repositoryId); + } + } else { + if (options.syncIssues) { + await handleIssueCommentWebhook(this, payload, repositoryId); + } + } + } + } + + // ---------- Sync management ---------- + + /** + * Stop syncing a repository (cleanup webhooks and state) + */ + async stopSync(repositoryId: string): Promise { + const webhookId = await this.get(`webhook_id_${repositoryId}`); + if (webhookId) { + try { + const token = await this.getToken(repositoryId); + const [owner, repo] = repositoryId.split("/"); + await this.githubFetch( + token, + `/repos/${owner}/${repo}/hooks/${webhookId}`, + { method: "DELETE" }, + ); + } catch (error) { + console.warn("Failed to delete GitHub webhook:", error); + } + await this.clear(`webhook_id_${repositoryId}`); + } + + await this.clear(`webhook_secret_${repositoryId}`); + await this.clear(`pr_sync_state_${repositoryId}`); + await this.clear(`issue_sync_state_${repositoryId}`); + } +} + +export default GitHub; diff --git a/tools/github/src/index.ts b/sources/github/src/index.ts similarity index 100% rename from tools/github/src/index.ts rename to sources/github/src/index.ts diff --git a/sources/github/src/issue-sync.ts b/sources/github/src/issue-sync.ts new file mode 100644 index 0000000..024b9f9 --- /dev/null +++ b/sources/github/src/issue-sync.ts @@ -0,0 +1,405 @@ +import { + type Action, + ActionType, + type NewLinkWithNotes, +} from "@plotday/twister"; +import type { GitHub, GitHubIssueComment } from "./github"; + +/** Issues per page for batch sync */ +const PAGE_SIZE = 50; + +type IssueSyncState = { + page: number; + batchNumber: number; + issuesProcessed: number; + initialSync: boolean; + phase: "open" | "closed"; +}; + +/** + * Initialize batch sync process for issues + */ +export async function startIssueBatchSync( + source: GitHub, + repositoryId: string, + initialSync: boolean, +): Promise { + await source.set(`issue_sync_state_${repositoryId}`, { + page: 1, + batchNumber: 1, + issuesProcessed: 0, + initialSync, + phase: "open", + } satisfies IssueSyncState); + + const batchCallback = await source.createCallback(source.syncIssueBatch, repositoryId); + await source.runTask(batchCallback); +} + +/** + * Process a batch of issues + */ +export async function syncIssueBatch( + source: GitHub, + repositoryId: string, +): Promise { + const state = await source.get(`issue_sync_state_${repositoryId}`); + if (!state) { + throw new Error(`Issue sync state not found for repository ${repositoryId}`); + } + + const token = await source.getToken(repositoryId); + const [owner, repo] = repositoryId.split("/"); + + // Build request URL based on phase + let url = `/repos/${owner}/${repo}/issues?state=${state.phase}&per_page=${PAGE_SIZE}&page=${state.page}&sort=updated&direction=desc`; + + // For closed phase, only fetch recently closed (last 30 days) + if (state.phase === "closed") { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + url += `&since=${thirtyDaysAgo.toISOString()}`; + } + + const response = await source.githubFetch(token, url); + + if (!response.ok) { + throw new Error( + `Failed to fetch issues: ${response.status} ${await response.text()}`, + ); + } + + const issues: any[] = await response.json(); + + // Process each issue (filter out PRs — GitHub returns PRs in issues endpoint) + let processedInBatch = 0; + for (const issue of issues) { + if (issue.pull_request) continue; + + const link = await convertIssueToLink( + source, + token, + owner, + repo, + issue, + repositoryId, + state.initialSync, + ); + + if (link) { + link.channelId = repositoryId; + link.meta = { + ...link.meta, + syncProvider: "github", + syncableId: repositoryId, + }; + await source.saveLink(link); + processedInBatch++; + } + } + + const hasMorePages = issues.length === PAGE_SIZE; + + if (hasMorePages) { + await source.set(`issue_sync_state_${repositoryId}`, { + page: state.page + 1, + batchNumber: state.batchNumber + 1, + issuesProcessed: state.issuesProcessed + processedInBatch, + initialSync: state.initialSync, + phase: state.phase, + } satisfies IssueSyncState); + + const nextBatch = await source.createCallback(source.syncIssueBatch, repositoryId); + await source.runTask(nextBatch); + } else if (state.phase === "open") { + // Move to closed phase + await source.set(`issue_sync_state_${repositoryId}`, { + page: 1, + batchNumber: state.batchNumber + 1, + issuesProcessed: state.issuesProcessed + processedInBatch, + initialSync: state.initialSync, + phase: "closed", + } satisfies IssueSyncState); + + const closedBatch = await source.createCallback(source.syncIssueBatch, repositoryId); + await source.runTask(closedBatch); + } else { + // Both phases complete + await source.clear(`issue_sync_state_${repositoryId}`); + } +} + +/** + * Convert a GitHub issue to a NewLinkWithNotes + */ +async function convertIssueToLink( + source: GitHub, + token: string, + owner: string, + repo: string, + issue: any, + repositoryId: string, + initialSync: boolean, +): Promise { + const authorContact = issue.user ? source.userToContact(issue.user) : undefined; + + const assignee = issue.assignees?.[0] || issue.assignee; + const assigneeContact = assignee ? source.userToContact(assignee) : undefined; + + const description = issue.body || ""; + const hasDescription = description.trim().length > 0; + + const threadActions: Action[] = []; + if (issue.html_url) { + threadActions.push({ + type: ActionType.external, + title: "Open in GitHub", + url: issue.html_url, + }); + } + + const notes: any[] = []; + + notes.push({ + key: "description", + content: hasDescription ? description : null, + created: issue.created_at, + author: authorContact, + }); + + // Fetch comments + try { + let commentPage = 1; + let hasMoreComments = true; + + while (hasMoreComments) { + const commentsResponse = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${issue.number}/comments?per_page=100&page=${commentPage}`, + ); + + if (!commentsResponse.ok) break; + + const comments: GitHubIssueComment[] = await commentsResponse.json(); + for (const comment of comments) { + const commentAuthor = source.userToContact(comment.user); + notes.push({ + key: `comment-${comment.id}`, + content: comment.body ?? null, + created: new Date(comment.created_at), + author: commentAuthor, + }); + } + + hasMoreComments = comments.length === 100; + commentPage++; + } + } catch (error) { + console.error("Error fetching issue comments:", error); + } + + const link: NewLinkWithNotes = { + source: `github:issue:${owner}/${repo}/${issue.number}`, + type: "issue", + title: issue.title, + created: issue.created_at, + author: authorContact, + assignee: assigneeContact ?? null, + status: issue.closed_at ? "closed" : "open", + meta: { + provider: "github", + owner, + repo, + issueNumber: issue.number, + }, + actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: issue.html_url ?? null, + notes, + preview: hasDescription ? description : null, + ...(initialSync ? { unread: false } : {}), + ...(initialSync ? { archived: false } : {}), + }; + + return link; +} + +/** + * Handle issues webhook event + */ +export async function handleIssueWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const issue = payload.issue; + if (!issue) return; + + // Skip pull requests + if (issue.pull_request) return; + + const [owner, repo] = repositoryId.split("/"); + + const authorContact = issue.user ? source.userToContact(issue.user) : undefined; + const assignee = issue.assignees?.[0] || issue.assignee; + const assigneeContact = assignee ? source.userToContact(assignee) : undefined; + + const link: NewLinkWithNotes = { + source: `github:issue:${owner}/${repo}/${issue.number}`, + type: "issue", + title: issue.title, + created: issue.created_at, + author: authorContact, + assignee: assigneeContact ?? null, + status: issue.closed_at ? "closed" : "open", + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + issueNumber: issue.number, + syncProvider: "github", + syncableId: repositoryId, + }, + preview: issue.body || null, + notes: [], + }; + + await source.saveLink(link); +} + +/** + * Handle issue_comment webhook event (for issue comments, not PR comments) + */ +export async function handleIssueCommentWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const comment: GitHubIssueComment = payload.comment; + const issue = payload.issue; + if (!comment || !issue) return; + + // Skip comments on pull requests + if (issue.pull_request) return; + + const [owner, repo] = repositoryId.split("/"); + const commentAuthor = source.userToContact(comment.user); + + const link: NewLinkWithNotes = { + source: `github:issue:${owner}/${repo}/${issue.number}`, + type: "issue", + title: issue.title, + notes: [ + { + key: `comment-${comment.id}`, + content: comment.body ?? null, + created: comment.created_at, + author: commentAuthor, + } as any, + ], + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + issueNumber: issue.number, + syncProvider: "github", + syncableId: repositoryId, + }, + }; + + await source.saveLink(link); +} + +/** + * Update an issue's status and assignee + */ +export async function updateIssue( + source: GitHub, + link: import("@plotday/twister").Link, +): Promise { + if (!link.meta) return; + + const owner = link.meta.owner as string; + const repo = link.meta.repo as string; + const issueNumber = link.meta.issueNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !issueNumber) { + throw new Error("Owner, repo, and issueNumber required in link meta"); + } + + const token = await source.getToken(syncableId); + + const updateFields: Record = {}; + + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed"; + updateFields.state = isDone ? "closed" : "open"; + + if (link.assignee) { + if (link.assignee.name) { + updateFields.assignees = [link.assignee.name]; + } + } else { + updateFields.assignees = []; + } + + if (Object.keys(updateFields).length > 0) { + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${issueNumber}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updateFields), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to update issue: ${response.status} ${await response.text()}`, + ); + } + } +} + +/** + * Add a comment to a GitHub issue + */ +export async function addIssueComment( + source: GitHub, + meta: import("@plotday/twister").ThreadMeta, + body: string, +): Promise { + const owner = meta.owner as string; + const repo = meta.repo as string; + const issueNumber = meta.issueNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !issueNumber) { + throw new Error("Owner, repo, and issueNumber required in thread meta"); + } + + const token = await source.getToken(syncableId); + + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to add issue comment: ${response.status} ${await response.text()}`, + ); + } + + const comment = await response.json(); + if (comment?.id) { + return `comment-${comment.id}`; + } +} diff --git a/sources/github/src/pr-sync.ts b/sources/github/src/pr-sync.ts new file mode 100644 index 0000000..5e95783 --- /dev/null +++ b/sources/github/src/pr-sync.ts @@ -0,0 +1,515 @@ +import { + type Action, + ActionType, + type NewLinkWithNotes, +} from "@plotday/twister"; +import type { GitHub, GitHubPullRequest, GitHubReview, GitHubIssueComment } from "./github"; + +/** Days of recently closed/merged PRs to include in sync */ +const RECENT_DAYS = 30; +/** PRs per page for batch sync */ +const PAGE_SIZE = 50; + +type PRSyncState = { + page: number; + batchNumber: number; + prsProcessed: number; + initialSync: boolean; +}; + +/** + * Initialize batch sync process for pull requests + */ +export async function startPRBatchSync( + source: GitHub, + repositoryId: string, + initialSync: boolean, +): Promise { + await source.set(`pr_sync_state_${repositoryId}`, { + page: 1, + batchNumber: 1, + prsProcessed: 0, + initialSync, + } satisfies PRSyncState); + + const batchCallback = await source.createCallback(source.syncPRBatch, repositoryId); + await source.runTask(batchCallback); +} + +/** + * Process a batch of pull requests + */ +export async function syncPRBatch( + source: GitHub, + repositoryId: string, +): Promise { + const state = await source.get(`pr_sync_state_${repositoryId}`); + if (!state) { + throw new Error(`PR sync state not found for repository ${repositoryId}`); + } + + const token = await source.getToken(repositoryId); + const [owner, repo] = repositoryId.split("/"); + + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=${PAGE_SIZE}&page=${state.page}`, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch PRs: ${response.status} ${await response.text()}`, + ); + } + + const prs: GitHubPullRequest[] = await response.json(); + + // Filter: open PRs + recently closed/merged (within RECENT_DAYS) + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - RECENT_DAYS); + + const relevantPRs = prs.filter((pr) => { + if (pr.state === "open") return true; + const closedDate = pr.merged_at || pr.closed_at; + if (closedDate && new Date(closedDate) >= cutoff) return true; + return false; + }); + + const allBeyondCutoff = + prs.length > 0 && + prs.every((pr) => { + if (pr.state === "open") return false; + const closedDate = pr.merged_at || pr.closed_at; + return closedDate && new Date(closedDate) < cutoff; + }); + + for (const pr of relevantPRs) { + const thread = await convertPRToThread( + source, + token, + owner, + repo, + pr, + repositoryId, + state.initialSync, + ); + + if (thread) { + thread.channelId = repositoryId; + thread.meta = { + ...thread.meta, + syncProvider: "github", + syncableId: repositoryId, + }; + await source.saveLink(thread); + } + } + + if (prs.length === PAGE_SIZE && !allBeyondCutoff) { + await source.set(`pr_sync_state_${repositoryId}`, { + page: state.page + 1, + batchNumber: state.batchNumber + 1, + prsProcessed: state.prsProcessed + relevantPRs.length, + initialSync: state.initialSync, + } satisfies PRSyncState); + + const nextBatch = await source.createCallback(source.syncPRBatch, repositoryId); + await source.runTask(nextBatch); + } else { + await source.clear(`pr_sync_state_${repositoryId}`); + } +} + +/** + * Convert a GitHub PR to a NewLinkWithNotes + */ +async function convertPRToThread( + source: GitHub, + token: string, + owner: string, + repo: string, + pr: GitHubPullRequest, + repositoryId: string, + initialSync: boolean, +): Promise { + const authorContact = source.userToContact(pr.user); + const assigneeContact = pr.assignee + ? source.userToContact(pr.assignee) + : null; + + const threadActions: Action[] = [ + { + type: ActionType.external, + title: `Open in GitHub`, + url: pr.html_url, + }, + ]; + + const notes: any[] = []; + + const hasDescription = pr.body && pr.body.trim().length > 0; + notes.push({ + key: "description", + content: hasDescription ? pr.body : null, + created: new Date(pr.created_at), + author: authorContact, + }); + + // Fetch general comments + try { + const commentsResponse = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${pr.number}/comments?per_page=100`, + ); + if (commentsResponse.ok) { + const comments: GitHubIssueComment[] = await commentsResponse.json(); + for (const comment of comments) { + const commentAuthor = source.userToContact(comment.user); + notes.push({ + key: `comment-${comment.id}`, + content: comment.body, + created: new Date(comment.created_at), + author: commentAuthor, + }); + } + } + } catch (error) { + console.error("Error fetching PR comments:", error); + } + + // Fetch review summaries + try { + const reviewsResponse = await source.githubFetch( + token, + `/repos/${owner}/${repo}/pulls/${pr.number}/reviews?per_page=100`, + ); + if (reviewsResponse.ok) { + const reviews: GitHubReview[] = await reviewsResponse.json(); + for (const review of reviews) { + if (review.state === "COMMENTED" && !review.body) continue; + + const reviewAuthor = source.userToContact(review.user); + const prefix = reviewStatePrefix(review.state); + const content = prefix + ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` + : review.body || null; + + if (content) { + notes.push({ + key: `review-${review.id}`, + content, + created: new Date(review.submitted_at), + author: reviewAuthor, + }); + } + } + } + } catch (error) { + console.error("Error fetching PR reviews:", error); + } + + const thread: NewLinkWithNotes = { + source: `github:pr:${owner}/${repo}/${pr.number}`, + type: "pull_request", + title: pr.title, + created: new Date(pr.created_at), + author: authorContact, + assignee: assigneeContact, + status: pr.merged_at + ? "merged" + : pr.state === "closed" + ? "closed" + : "open", + meta: { + provider: "github", + owner, + repo, + prNumber: pr.number, + prNodeId: pr.id, + }, + actions: threadActions, + sourceUrl: pr.html_url, + notes, + preview: hasDescription ? pr.body : null, + ...(initialSync ? { unread: false } : {}), + ...(initialSync ? { archived: false } : {}), + ...(!initialSync && pr.state === "closed" && !pr.merged_at + ? { archived: true } + : {}), + }; + + return thread; +} + +/** + * Handle pull_request webhook event + */ +export async function handlePRWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const pr: GitHubPullRequest = payload.pull_request; + if (!pr) return; + + const [owner, repo] = repositoryId.split("/"); + + const authorContact = source.userToContact(pr.user); + const assigneeContact = pr.assignee + ? source.userToContact(pr.assignee) + : null; + + const thread: NewLinkWithNotes = { + source: `github:pr:${owner}/${repo}/${pr.number}`, + type: "pull_request", + title: pr.title, + created: new Date(pr.created_at), + author: authorContact, + assignee: assigneeContact, + status: pr.merged_at + ? "merged" + : pr.state === "closed" + ? "closed" + : "open", + ...(pr.state === "closed" && !pr.merged_at ? { archived: true } : {}), + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + prNumber: pr.number, + prNodeId: pr.id, + syncProvider: "github", + syncableId: repositoryId, + }, + preview: pr.body || null, + notes: [], + }; + + await source.saveLink(thread); +} + +/** + * Handle pull_request_review webhook event + */ +export async function handleReviewWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const review: GitHubReview = payload.review; + const pr: GitHubPullRequest = payload.pull_request; + if (!review || !pr) return; + + if (review.state === "COMMENTED" && !review.body) return; + + const [owner, repo] = repositoryId.split("/"); + const reviewAuthor = source.userToContact(review.user); + + const prefix = reviewStatePrefix(review.state); + const content = prefix + ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` + : review.body || null; + + const thread: NewLinkWithNotes = { + source: `github:pr:${owner}/${repo}/${pr.number}`, + type: "pull_request", + title: pr.title, + notes: [ + { + key: `review-${review.id}`, + content, + created: new Date(review.submitted_at), + author: reviewAuthor, + } as any, + ], + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + prNumber: pr.number, + prNodeId: pr.id, + syncProvider: "github", + syncableId: repositoryId, + }, + }; + + await source.saveLink(thread); +} + +/** + * Handle issue_comment webhook event (for PR comments) + */ +export async function handlePRCommentWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const comment: GitHubIssueComment = payload.comment; + const issue = payload.issue; + if (!comment || !issue) return; + + const [owner, repo] = repositoryId.split("/"); + const prNumber = issue.number; + const commentAuthor = source.userToContact(comment.user); + + const thread: NewLinkWithNotes = { + source: `github:pr:${owner}/${repo}/${prNumber}`, + type: "pull_request", + title: issue.title, + notes: [ + { + key: `comment-${comment.id}`, + content: comment.body, + created: new Date(comment.created_at), + author: commentAuthor, + } as any, + ], + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + prNumber, + syncProvider: "github", + syncableId: repositoryId, + }, + }; + + await source.saveLink(thread); +} + +/** + * Add a general comment to a pull request + */ +export async function addPRComment( + source: GitHub, + meta: import("@plotday/twister").ThreadMeta, + body: string, + noteId?: string, +): Promise { + const owner = meta.owner as string; + const repo = meta.repo as string; + const prNumber = meta.prNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !prNumber) { + throw new Error("Owner, repo, and prNumber required in thread meta"); + } + + const token = await source.getToken(syncableId); + + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${prNumber}/comments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to add PR comment: ${response.status} ${await response.text()}`, + ); + } + + const comment = await response.json(); + if (comment?.id) { + return `comment-${comment.id}`; + } +} + +/** + * Update a PR's review status (approve or request changes) + */ +export async function updatePRStatus( + source: GitHub, + link: import("@plotday/twister").Link, +): Promise { + if (!link.meta) return; + + const owner = link.meta.owner as string; + const repo = link.meta.repo as string; + const prNumber = link.meta.prNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !prNumber) { + throw new Error("Owner, repo, and prNumber required in link meta"); + } + + const token = await source.getToken(syncableId); + + const isDone = link.status === "done" || link.status === "closed" || link.status === "approved"; + if (isDone) { + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event: "APPROVE", + }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to update PR status: ${response.status}`, + ); + } + } +} + +/** + * Close a pull request without merging + */ +export async function closePR( + source: GitHub, + meta: import("@plotday/twister").ThreadMeta, +): Promise { + const owner = meta.owner as string; + const repo = meta.repo as string; + const prNumber = meta.prNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !prNumber) { + throw new Error("Owner, repo, and prNumber required in thread meta"); + } + + const token = await source.getToken(syncableId); + + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/pulls/${prNumber}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "closed" }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to close PR: ${response.status} ${await response.text()}`, + ); + } +} + +function reviewStatePrefix( + state: GitHubReview["state"], +): string | null { + switch (state) { + case "APPROVED": + return "**Approved**"; + case "CHANGES_REQUESTED": + return "**Changes Requested**"; + case "DISMISSED": + return "**Dismissed**"; + default: + return null; + } +} diff --git a/tools/github-issues/tsconfig.json b/sources/github/tsconfig.json similarity index 100% rename from tools/github-issues/tsconfig.json rename to sources/github/tsconfig.json diff --git a/tools/gmail/CHANGELOG.md b/sources/gmail/CHANGELOG.md similarity index 100% rename from tools/gmail/CHANGELOG.md rename to sources/gmail/CHANGELOG.md diff --git a/tools/gmail/package.json b/sources/gmail/package.json similarity index 73% rename from tools/gmail/package.json rename to sources/gmail/package.json index 3b4982d..10720f8 100644 --- a/tools/gmail/package.json +++ b/sources/gmail/package.json @@ -1,7 +1,9 @@ { - "name": "@plotday/tool-gmail", + "name": "@plotday/source-gmail", + "plotTwistId": "7176b853-4495-4bba-82ee-645ae5d398d7", "displayName": "Gmail", - "description": "Sync with Gmail inbox and messages", + "description": "Email threads from Gmail", + "logoUrl": "https://api.iconify.design/logos/google-gmail.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.9.1", @@ -15,14 +17,12 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" @@ -46,8 +46,5 @@ "gmail", "messaging", "email" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/gmail/src/gmail-api.ts b/sources/gmail/src/gmail-api.ts similarity index 72% rename from tools/gmail/src/gmail-api.ts rename to sources/gmail/src/gmail-api.ts index 694cc3a..002c42d 100644 --- a/tools/gmail/src/gmail-api.ts +++ b/sources/gmail/src/gmail-api.ts @@ -1,6 +1,5 @@ -import { ActivityType } from "@plotday/twister"; import type { - NewActivityWithNotes, + NewLinkWithNotes, NewActor, } from "@plotday/twister/plot"; @@ -177,6 +176,34 @@ export class GmailApi { await this.call("/stop", { method: "POST" }); } + public async sendMessage( + raw: string, + threadId: string + ): Promise<{ id: string; threadId: string }> { + return await this.call("/messages/send", { + method: "POST", + body: { raw, threadId }, + }); + } + + public async getProfile(): Promise<{ emailAddress: string }> { + return await this.call("/profile"); + } + + public async modifyThread( + threadId: string, + addLabelIds?: string[], + removeLabelIds?: string[] + ): Promise { + const body: Record = {}; + if (addLabelIds?.length) body.addLabelIds = addLabelIds; + if (removeLabelIds?.length) body.removeLabelIds = removeLabelIds; + await this.call(`/threads/${threadId}/modify`, { + method: "POST", + body, + }); + } + public async getHistory( startHistoryId: string, labelId?: string, @@ -238,6 +265,20 @@ export function parseEmailAddress(headerValue: string): EmailAddress { } +/** + * Parses a comma-separated email header value into an array of email address strings. + */ +export function parseEmailAddresses(headerValue: string | null): string[] { + if (!headerValue) return []; + return headerValue + .split(",") + .map((addr) => { + const parsed = parseEmailAddress(addr.trim()); + return parsed.email; + }) + .filter((email) => email.length > 0); +} + /** * Parses email addresses and returns NewActor[] for mentions. */ @@ -260,7 +301,7 @@ function parseEmailAddressesToNewActors(headerValue: string | null): NewActor[] /** * Gets a specific header value from a message */ -function getHeader(message: GmailMessage, name: string): string | null { +export function getHeader(message: GmailMessage, name: string): string | null { const header = message.payload.headers.find( (h) => h.name.toLowerCase() === name.toLowerCase() ); @@ -268,51 +309,38 @@ function getHeader(message: GmailMessage, name: string): string | null { } /** - * Extracts the body from a Gmail message (handles multipart messages) + * Extracts the body from a Gmail message (handles multipart messages). + * Returns raw content with its type so HTML can be converted server-side. */ -function extractBody(part: GmailMessagePart): string { +function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } { // If this part has a body with data, return it if (part.body?.data) { // Gmail API returns base64url-encoded data const decoded = atob(part.body.data.replace(/-/g, "+").replace(/_/g, "/")); - return decoded; + const contentType = part.mimeType === "text/html" ? "html" as const : "text" as const; + return { content: decoded, contentType }; } // If multipart, recursively search parts if (part.parts) { - // Prefer plain text over HTML + // Prefer HTML over plain text — server-side conversion produces cleaner output + const htmlPart = part.parts.find((p) => p.mimeType === "text/html"); + if (htmlPart) { + return extractBody(htmlPart); + } + const textPart = part.parts.find((p) => p.mimeType === "text/plain"); if (textPart) { return extractBody(textPart); } - const htmlPart = part.parts.find((p) => p.mimeType === "text/html"); - if (htmlPart) { - // For HTML, strip tags for plain text representation - const html = extractBody(htmlPart); - return stripHtmlTags(html); - } - // Try first part as fallback if (part.parts.length > 0) { return extractBody(part.parts[0]); } } - return ""; -} - -/** - * Strips HTML tags for plain text representation - * This is a simple implementation - could be enhanced with a proper HTML parser - */ -function stripHtmlTags(html: string): string { - return html - .replace(/]*>.*?<\/style>/gi, "") - .replace(/]*>.*?<\/script>/gi, "") - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); + return { content: "", contentType: "text" }; } /** @@ -343,14 +371,14 @@ function extractAttachments( } /** - * Transforms a Gmail thread into a NewActivityWithNotes structure. - * The subject becomes the Activity title, and each email becomes a Note. + * Transforms a Gmail thread into a NewLinkWithNotes structure. + * The subject becomes the link title, and each email becomes a Note. */ -export function transformGmailThread(thread: GmailThread): NewActivityWithNotes { +export function transformGmailThread(thread: GmailThread): NewLinkWithNotes { if (!thread.messages || thread.messages.length === 0) { // Return empty structure for invalid threads return { - type: ActivityType.Note, + type: "email", title: "", notes: [], }; @@ -362,20 +390,20 @@ export function transformGmailThread(thread: GmailThread): NewActivityWithNotes // Canonical URL for the thread const canonicalUrl = `https://mail.google.com/mail/u/0/#inbox/${thread.id}`; - // Extract preview from first message - const firstMessageBody = extractBody(parentMessage.payload); - const preview = firstMessageBody || parentMessage.snippet || null; + // Use Gmail's plain-text snippet for preview (avoids HTML in previews) + const preview = parentMessage.snippet || null; - // Create Activity - const activity: NewActivityWithNotes = { + // Create link + const plotThread: NewLinkWithNotes = { source: canonicalUrl, - type: ActivityType.Note, + type: "email", title: subject || "Email", - start: new Date(parseInt(parentMessage.internalDate)), + created: new Date(parseInt(parentMessage.internalDate)), meta: { threadId: thread.id, historyId: thread.historyId, }, + sourceUrl: canonicalUrl, notes: [], preview, }; @@ -389,7 +417,7 @@ export function transformGmailThread(thread: GmailThread): NewActivityWithNotes const sender = from ? parseEmailAddress(from) : null; if (!sender) continue; // Skip messages without sender - const body = extractBody(message.payload); + const { content: body, contentType } = extractBody(message.payload); // Combine to and cc for mentions - convert to NewActor[] const mentions: NewActor[] = [ @@ -399,20 +427,21 @@ export function transformGmailThread(thread: GmailThread): NewActivityWithNotes // Create NewNote with idempotent key const note = { - activity: { source: canonicalUrl }, key: message.id, author: { email: sender.email, name: sender.name || undefined, } as NewActor, content: body || message.snippet, + contentType, mentions: mentions.length > 0 ? mentions : undefined, + created: new Date(parseInt(message.internalDate)), }; - activity.notes.push(note); + plotThread.notes!.push(note); } - return activity; + return plotThread; } /** @@ -473,3 +502,57 @@ export async function syncGmailChannel( hasMore: !!nextPageToken, }; } + +/** + * Builds an RFC 2822 email message for replying to a Gmail thread. + * Returns the base64url-encoded raw message string for the Gmail API. + */ +export function buildReplyMessage(options: { + to: string[]; + cc: string[]; + from: string; + subject: string; + body: string; + messageId: string; + references: string; +}): string { + const { to, cc, from, subject, body, messageId, references } = options; + + // Ensure subject has "Re:" prefix + const reSubject = subject.startsWith("Re:") ? subject : `Re: ${subject}`; + + // Build RFC 2822 headers + const lines: string[] = [ + `From: ${from}`, + `To: ${to.join(", ")}`, + ]; + + if (cc.length > 0) { + lines.push(`Cc: ${cc.join(", ")}`); + } + + lines.push(`Subject: ${reSubject}`); + lines.push(`In-Reply-To: ${messageId}`); + + // Build References chain + const refChain = references + ? `${references} ${messageId}` + : messageId; + lines.push(`References: ${refChain}`); + + lines.push(`Content-Type: text/plain; charset="UTF-8"`); + lines.push(""); // Empty line separates headers from body + lines.push(body); + + const rawMessage = lines.join("\r\n"); + + // Base64url encode + const encoded = btoa( + String.fromCharCode(...new TextEncoder().encode(rawMessage)) + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + return encoded; +} diff --git a/tools/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts similarity index 55% rename from tools/gmail/src/gmail.ts rename to sources/gmail/src/gmail.ts index 1ef1209..3b5d2b5 100644 --- a/tools/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -1,41 +1,38 @@ -import { - type ActivityFilter, - type NewActivityWithNotes, - Serializable, - type SyncToolOptions, - Tool, - type ToolBuilder, -} from "@plotday/twister"; -import { - type MessageChannel, - type MessageSyncOptions, - type MessagingTool, -} from "@plotday/twister/common/messaging"; -import { type Callback } from "@plotday/twister/tools/callbacks"; +import { Source, type ToolBuilder } from "@plotday/twister"; +import type { Actor, Note, Thread, ThreadMeta } from "@plotday/twister/plot"; import { AuthProvider, type AuthToken, type Authorization, + type Channel, Integrations, - type Syncable, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ActivityAccess, - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; import { GmailApi, type GmailThread, type SyncState, + buildReplyMessage, + getHeader, + parseEmailAddresses, syncGmailChannel, transformGmailThread, } from "./gmail-api"; +type MessageChannel = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +type MessageSyncOptions = { + timeMin?: Date; +}; + /** - * Gmail integration tool implementing the MessagingTool interface. + * Gmail integration source implementing the MessagingSource interface. * * Supports inbox, labels, and search filters as channels. * Auth is managed declaratively via provider config in build() and @@ -44,82 +41,39 @@ import { * **Required OAuth Scopes:** * - `https://www.googleapis.com/auth/gmail.readonly` - Read emails * - `https://www.googleapis.com/auth/gmail.modify` - Modify labels - * - * @example - * ```typescript - * class MessagesTwist extends Twist { - * private gmail: Gmail; - * - * constructor(id: string, tools: Tools) { - * super(); - * this.gmail = tools.get(Gmail); - * } - * - * // Auth is handled via the twist edit modal. - * // When sync is enabled on a channel, onSyncEnabled fires and - * // the twist can start syncing: - * - * 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) { - * // Process Gmail email thread - * // Each thread is an Activity with Notes for each email - * console.log(`Email thread: ${thread.title}`); - * console.log(`${thread.notes.length} messages`); - * - * // Access individual messages as Notes - * for (const note of thread.notes) { - * console.log(`From: ${note.author.email}, To: ${note.mentions?.join(", ")}`); - * } - * } - * } - * ``` */ -export class Gmail extends Tool implements MessagingTool { +export class Gmail extends Source { 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", + "https://www.googleapis.com/auth/gmail.send", + ]; + + readonly provider = AuthProvider.Google; + readonly scopes = Gmail.SCOPES; + readonly linkTypes = [ + { + type: "email", + label: "Email", + logo: "https://api.iconify.design/logos/google-gmail.svg", + logoMono: "https://api.iconify.design/simple-icons/gmail.svg", + }, ]; build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: Gmail.PROVIDER, - scopes: Gmail.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://gmail.googleapis.com/gmail/v1/*"], }), - plot: build(Plot, { - contact: { - access: ContactAccess.Write, - }, - activity: { - access: ActivityAccess.Create, - }, - }), }; } - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const api = new GmailApi(token.token); const labels = await api.getLabels(); return labels @@ -131,79 +85,42 @@ export class Gmail extends Tool implements MessagingTool { .map((l: any) => ({ id: l.id, title: l.name })); } - 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); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and queue first batch - await this.setupChannelWebhook(syncable.id); + await this.setupChannelWebhook(channel.id); const initialState: SyncState = { - channelId: syncable.id, + channelId: channel.id, }; - await this.set(`sync_state_${syncable.id}`, initialState); + await this.set(`sync_state_${channel.id}`, initialState); const syncCallback = await this.callback( this.syncBatch, 1, "full", - syncable.id + channel.id, + true ); 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}`); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.id}`); } private async getApi(channelId: string): Promise { - const token = await this.tools.integrations.get(Gmail.PROVIDER, channelId); + const token = await this.tools.integrations.get(channelId); if (!token) { throw new Error("No Google authentication token available"); } return new GmailApi(token.token); } - async getChannels(channelId: string): Promise { + async listLabels(channelId: string): Promise { const api = await this.getApi(channelId); const labels = await api.getLabels(); @@ -240,25 +157,13 @@ export class Gmail extends Tool implements MessagingTool { return channels; } - async startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any - >( + async startSync( options: { channelId: string; - } & MessageSyncOptions, - callback: TCallback, - ...extraArgs: TArgs + } & MessageSyncOptions ): Promise { const { channelId, timeMin } = options; - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${channelId}`, callbackToken); - // Setup webhook for this channel (Gmail Push Notifications) await this.setupChannelWebhook(channelId); @@ -278,7 +183,8 @@ export class Gmail extends Tool implements MessagingTool { this.syncBatch, 1, "full", - channelId + channelId, + true ); await this.run(syncCallback); } @@ -297,9 +203,6 @@ export class Gmail extends Tool implements MessagingTool { // Clear sync state await this.clear(`sync_state_${channelId}`); - - // Clear callback token - await this.clear(`item_callback_${channelId}`); } private async setupChannelWebhook(channelId: string): Promise { @@ -334,8 +237,10 @@ export class Gmail extends Tool implements MessagingTool { async syncBatch( batchNumber: number, mode: "full" | "incremental", - channelId: string + channelId: string, + initialSync?: boolean ): Promise { + const isInitial = initialSync ?? mode === "full"; try { const state = await this.get(`sync_state_${channelId}`); if (!state) { @@ -348,7 +253,7 @@ export class Gmail extends Tool implements MessagingTool { const result = await syncGmailChannel(api, state, 20); if (result.threads.length > 0) { - await this.processEmailThreads(result.threads, channelId); + await this.processEmailThreads(result.threads, channelId, isInitial); } await this.set(`sync_state_${channelId}`, result.state); @@ -358,7 +263,8 @@ export class Gmail extends Tool implements MessagingTool { this.syncBatch, batchNumber + 1, mode, - channelId + channelId, + isInitial ); await this.run(syncCallback); } else { @@ -378,33 +284,48 @@ export class Gmail extends Tool implements MessagingTool { private async processEmailThreads( threads: GmailThread[], - channelId: string + channelId: string, + initialSync: boolean ): Promise { - const callbackToken = await this.get( - `item_callback_${channelId}` - ); - - if (!callbackToken) { - console.error("No callback token found for channel", channelId); - return; - } - for (const thread of threads) { try { - // Transform Gmail thread to NewActivityWithNotes - const activityThread = transformGmailThread(thread); + // Transform Gmail thread to NewLinkWithNotes + const plotThread = transformGmailThread(thread); + + if (!plotThread.notes || plotThread.notes.length === 0) continue; + + // Filter out notes for messages we sent (dedup) + const filtered = []; + for (const note of plotThread.notes) { + const noteKey = "key" in note ? (note as { key: string }).key : null; + if (noteKey) { + const wasSent = await this.get(`sent:${noteKey}`); + if (wasSent) { + await this.clear(`sent:${noteKey}`); + continue; + } + } + filtered.push(note); + } + plotThread.notes = filtered; + + if (plotThread.notes.length === 0) continue; - if (activityThread.notes.length === 0) continue; + if (initialSync) { + plotThread.unread = false; + plotThread.archived = false; + } - // Inject sync metadata for the parent to identify the source - activityThread.meta = { - ...activityThread.meta, + // Inject channel ID for priority routing and sync metadata + plotThread.channelId = channelId; + plotThread.meta = { + ...plotThread.meta, syncProvider: "google", syncableId: channelId, }; - // Call parent callback with the thread (contacts will be created by the API) - await this.run(callbackToken, activityThread); + // Save link directly via integrations + await this.tools.integrations.saveLink(plotThread); } catch (error) { console.error(`Failed to process Gmail thread ${thread.id}:`, error); // Continue processing other threads @@ -412,6 +333,123 @@ export class Gmail extends Tool implements MessagingTool { } } + async onNoteCreated(note: Note, meta: ThreadMeta): Promise { + const channelId = (meta.channelId ?? meta.syncableId) as string; + if (!channelId) { + console.error("No channelId in meta for Gmail reply"); + return; + } + + const threadId = meta.threadId as string; + if (!threadId) { + console.error("No threadId in meta for Gmail reply"); + return; + } + + const api = await this.getApi(channelId); + + // Fetch the full Gmail thread to get message headers + const gmailThread = await api.getThread(threadId); + if (!gmailThread.messages || gmailThread.messages.length === 0) { + console.error("Gmail thread has no messages"); + return; + } + + // Determine target message: specific replied-to note or last message in thread + let targetMessage = gmailThread.messages[gmailThread.messages.length - 1]; + if (meta.reNoteKey) { + const found = gmailThread.messages.find( + (m) => m.id === meta.reNoteKey + ); + if (found) { + targetMessage = found; + } + } + + // Extract headers from target message + const messageId = getHeader(targetMessage, "Message-ID"); + const references = getHeader(targetMessage, "References"); + const subject = getHeader(targetMessage, "Subject") ?? "Email"; + const fromHeader = getHeader(targetMessage, "From"); + const toHeader = getHeader(targetMessage, "To"); + const ccHeader = getHeader(targetMessage, "Cc"); + + if (!messageId) { + console.error("Target message has no Message-ID header"); + return; + } + + // Get sender's email to exclude from reply-all recipients + const profile = await api.getProfile(); + const senderEmail = profile.emailAddress.toLowerCase(); + + // Build reply-all recipients: all From + To + Cc minus sender, deduplicated + const allRecipients = new Set(); + for (const email of parseEmailAddresses(fromHeader)) { + allRecipients.add(email.toLowerCase()); + } + for (const email of parseEmailAddresses(toHeader)) { + allRecipients.add(email.toLowerCase()); + } + + const ccRecipients = new Set(); + for (const email of parseEmailAddresses(ccHeader)) { + ccRecipients.add(email.toLowerCase()); + } + + // Remove sender from all sets + allRecipients.delete(senderEmail); + ccRecipients.delete(senderEmail); + + // To = all direct recipients (From + To minus sender), Cc = remaining Cc + const to = Array.from(allRecipients).filter( + (email) => !ccRecipients.has(email) + ); + const cc = Array.from(ccRecipients); + + if (to.length === 0 && cc.length === 0) { + console.error("No recipients for Gmail reply"); + return; + } + + // Build and send the reply + const raw = buildReplyMessage({ + to, + cc, + from: senderEmail, + subject, + body: note.content ?? "", + messageId, + references: references ?? "", + }); + + const result = await api.sendMessage(raw, threadId); + + // Store sent message ID for dedup when synced back + await this.set(`sent:${result.id}`, true); + } + + async onThreadRead( + _thread: Thread, + _actor: Actor, + unread: boolean, + meta: ThreadMeta + ): Promise { + const channelId = (meta.channelId ?? meta.syncableId) as string; + if (!channelId) return; + + const threadId = meta.threadId as string; + if (!threadId) return; + + const api = await this.getApi(channelId); + + if (unread) { + await api.modifyThread(threadId, ["UNREAD"]); + } else { + await api.modifyThread(threadId, undefined, ["UNREAD"]); + } + } + async onGmailWebhook( request: WebhookRequest, channelId: string @@ -469,7 +507,8 @@ export class Gmail extends Tool implements MessagingTool { this.syncBatch, 1, "incremental", - channelId + channelId, + false ); await this.run(syncCallback); } diff --git a/tools/gmail/src/index.ts b/sources/gmail/src/index.ts similarity index 100% rename from tools/gmail/src/index.ts rename to sources/gmail/src/index.ts diff --git a/tools/github/tsconfig.json b/sources/gmail/tsconfig.json similarity index 100% rename from tools/github/tsconfig.json rename to sources/gmail/tsconfig.json diff --git a/tools/google-calendar/CHANGELOG.md b/sources/google-calendar/CHANGELOG.md similarity index 100% rename from tools/google-calendar/CHANGELOG.md rename to sources/google-calendar/CHANGELOG.md diff --git a/tools/google-calendar/LICENSE b/sources/google-calendar/LICENSE similarity index 100% rename from tools/google-calendar/LICENSE rename to sources/google-calendar/LICENSE diff --git a/tools/google-calendar/README.md b/sources/google-calendar/README.md similarity index 100% rename from tools/google-calendar/README.md rename to sources/google-calendar/README.md diff --git a/tools/google-calendar/package.json b/sources/google-calendar/package.json similarity index 69% rename from tools/google-calendar/package.json rename to sources/google-calendar/package.json index 473faae..5b545e8 100644 --- a/tools/google-calendar/package.json +++ b/sources/google-calendar/package.json @@ -1,7 +1,9 @@ { - "name": "@plotday/tool-google-calendar", + "name": "@plotday/source-google-calendar", + "plotTwistId": "2ed4fcf8-6524-410f-b318-f9316e71c8b0", "displayName": "Google Calendar", - "description": "Sync with Google Calendar", + "description": "Events from Google Calendar", + "logoUrl": "https://api.iconify.design/logos/google-calendar.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.14.1", @@ -15,17 +17,15 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { - "@plotday/tool-google-contacts": "workspace:^", + "@plotday/source-google-contacts": "workspace:^", "@plotday/twister": "workspace:^" }, "devDependencies": { @@ -46,8 +46,5 @@ "tool", "google-calendar", "calendar" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/google-calendar/src/google-api.ts b/sources/google-calendar/src/google-api.ts similarity index 84% rename from tools/google-calendar/src/google-api.ts rename to sources/google-calendar/src/google-api.ts index bd84fab..0659113 100644 --- a/tools/google-calendar/src/google-api.ts +++ b/sources/google-calendar/src/google-api.ts @@ -1,5 +1,18 @@ -import type { NewActivity } from "@plotday/twister"; -import { ActivityType, ConferencingProvider } from "@plotday/twister"; +import { ConferencingProvider } from "@plotday/twister"; +import type { NewSchedule, NewScheduleOccurrence } from "@plotday/twister/schedule"; + +/** + * Intermediate representation of a transformed Google Calendar event. + * Contains the fields needed to construct a `NewLinkWithNotes` in the calendar source. + */ +export type TransformedEvent = { + title: string; + meta: Record; + /** "event" for timed events, undefined for cancelled/all-day events */ + type?: string; + schedules?: Array>; + scheduleOccurrences?: NewScheduleOccurrence[]; +}; export type GoogleEvent = { id: string; @@ -339,7 +352,7 @@ export function extractConferencingLinks( export function transformGoogleEvent( event: GoogleEvent, calendarId: string -): NewActivity { +): TransformedEvent { // Determine if this is an all-day event const isAllDay = event.start?.date && !event.start?.dateTime; @@ -358,11 +371,8 @@ export function transformGoogleEvent( // Handle cancelled events differently const isCancelled = event.status === "cancelled"; - const shared = { - source: `google-calendar:${event.id}`, + const result: TransformedEvent = { title: event.summary || (isCancelled ? "Cancelled event" : ""), - start: isCancelled ? null : start, - end: isCancelled ? null : end, meta: { id: event.id, calendarId: calendarId, @@ -381,46 +391,53 @@ export function transformGoogleEvent( : null, description: event.description || null, }, - } as const; + // Timed events get type "event"; cancelled/all-day events get no type + type: isCancelled || isAllDay ? undefined : "event", + }; - const activity: NewActivity = - isCancelled || isAllDay - ? { type: ActivityType.Note, ...shared } - : { type: ActivityType.Event, ...shared }; + // Build schedule from start/end if not cancelled + if (!isCancelled && start) { + const schedule: Omit = { + start, + end: end ?? null, + }; - // Handle recurrence for master events (not instances) - if (event.recurrence && !event.recurringEventId) { - activity.recurrenceRule = parseRRule(event.recurrence); + // Handle recurrence for master events (not instances) + if (event.recurrence && !event.recurringEventId) { + schedule.recurrenceRule = parseRRule(event.recurrence) ?? null; + + // Parse recurrence count (takes precedence over UNTIL) + const recurrenceCount = parseGoogleRecurrenceCount(event.recurrence); + if (recurrenceCount) { + schedule.recurrenceCount = recurrenceCount; + } else { + // Parse recurrence end date for recurring schedules if no count + const recurrenceUntil = parseGoogleRecurrenceEnd(event.recurrence); + if (recurrenceUntil) { + schedule.recurrenceUntil = recurrenceUntil; + } + } - // Parse recurrence count (takes precedence over UNTIL) - const recurrenceCount = parseGoogleRecurrenceCount(event.recurrence); - if (recurrenceCount) { - activity.recurrenceCount = recurrenceCount; - } else { - // Parse recurrence end date for recurring activities if no count - const recurrenceUntil = parseGoogleRecurrenceEnd(event.recurrence); - if (recurrenceUntil) { - activity.recurrenceUntil = recurrenceUntil; + const exdates = parseExDates(event.recurrence); + if (exdates.length > 0) { + schedule.recurrenceExdates = exdates; } - } - const exdates = parseExDates(event.recurrence); - if (exdates.length > 0) { - activity.recurrenceExdates = exdates; + // Parse RDATEs (additional occurrence dates not in the recurrence rule) + // and create schedule occurrence entries for each + const rdates = parseRDates(event.recurrence); + if (rdates.length > 0) { + result.scheduleOccurrences = rdates.map((rdate) => ({ + occurrence: rdate, + start: rdate, + })); + } } - // Parse RDATEs (additional occurrence dates not in the recurrence rule) - // and create ActivityOccurrenceUpdate entries for each - const rdates = parseRDates(event.recurrence); - if (rdates.length > 0) { - activity.occurrences = rdates.map((rdate) => ({ - occurrence: rdate, - start: rdate, - })); - } + result.schedules = [schedule]; } - return activity; + return result; } export async function syncGoogleCalendar( diff --git a/tools/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts similarity index 61% rename from tools/google-calendar/src/google-calendar.ts rename to sources/google-calendar/src/google-calendar.ts index b323937..2baea47 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -1,42 +1,43 @@ -import GoogleContacts from "@plotday/tool-google-contacts"; +import GoogleContacts from "@plotday/source-google-contacts"; import { - type Activity, - LinkType, - type Link, - type ActivityOccurrence, - ActivityType, - type ActorId, + ActionType, + type Action, ConferencingProvider, - type NewActivityOccurrence, - type NewActivityWithNotes, - type NewActor, + type NewLinkWithNotes, type NewContact, type NewNote, - Serializable, - type SyncToolOptions, - Tag, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; -import { - type Calendar, - type CalendarTool, - type SyncOptions, -} from "@plotday/twister/common/calendar"; -import { type Callback } from "@plotday/twister/tools/callbacks"; +import type { + NewScheduleContact, + NewScheduleOccurrence, +} from "@plotday/twister/schedule"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ActivityAccess, - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; + +type Calendar = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +type SyncOptions = { + timeMin?: Date | null; + timeMax?: Date | null; +}; + +type PendingOccurrence = { + occurrence: NewScheduleOccurrence; + cancelled: boolean; +}; import { GoogleApi, @@ -83,10 +84,8 @@ import { * provider: "google" * }); * - * await this.plot.createActivity({ - * type: ActivityType.Action, + * await this.plot.createThread({ * title: "Connect Google Calendar", - * links: [authLink] * }); * } * @@ -109,95 +108,57 @@ import { * } * } * - * async onCalendarEvent(activity: NewActivityWithNotes, context: any) { + * async onCalendarEvent(thread: NewThreadWithNotes, context: any) { * // Process Google Calendar events - * await this.plot.createActivity(activity); + * await this.plot.createThread(thread); * } * } * ``` */ -export class GoogleCalendar - extends Tool - implements CalendarTool -{ +export class GoogleCalendar extends Source { static readonly PROVIDER = AuthProvider.Google; static readonly SCOPES = [ "https://www.googleapis.com/auth/calendar.calendarlist.readonly", "https://www.googleapis.com/auth/calendar.events", ]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; + + readonly provider = AuthProvider.Google; + readonly scopes = Integrations.MergeScopes(GoogleCalendar.SCOPES, GoogleContacts.SCOPES); + readonly linkTypes = [{ type: "event", label: "Event", logo: "https://api.iconify.design/logos/google-calendar.svg", logoMono: "https://api.iconify.design/simple-icons/googlecalendar.svg" }]; build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: GoogleCalendar.PROVIDER, - scopes: Integrations.MergeScopes( - GoogleCalendar.SCOPES, - GoogleContacts.SCOPES - ), - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://www.googleapis.com/calendar/*"], }), - plot: build(Plot, { - contact: { - access: ContactAccess.Write, - }, - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, - }, - }), googleContacts: build(GoogleContacts), }; } - async preUpgrade(): Promise { - const keys = await this.list("sync_lock_"); + async upgrade(): Promise { + const keys = await this.tools.store.list("sync_lock_"); for (const key of keys) { await this.clear(key); } } /** - * Returns available calendars as syncable resources after authorization. + * Returns available calendars as channel resources after authorization. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getChannels(_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 })); } /** - * Called when a syncable calendar is enabled for syncing. - * Creates callback tokens and auto-starts sync for the calendar. + * Called when a channel calendar is enabled for syncing. + * Auto-starts sync for the calendar. */ - 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); - - // 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); - } - + async onChannelEnabled(channel: Channel): Promise { // Resolve "primary" to actual calendar ID for consistent storage keys - const resolvedCalendarId = await this.resolveCalendarId(syncable.id); + const resolvedCalendarId = await this.resolveCalendarId(channel.id); // Check if sync is already in progress const syncInProgress = await this.get( @@ -238,38 +199,16 @@ export class GoogleCalendar } /** - * Called when a syncable calendar is disabled. - * Stops sync, runs the disable callback, and cleans up stored tokens. + * Called when a channel calendar is disabled. + * Stops sync and archives threads from this channel. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(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}`); - } + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); } private async getApi(calendarId: string): Promise { // Get token for the syncable (calendar) from integrations - const token = await this.tools.integrations.get( - GoogleCalendar.PROVIDER, - calendarId - ); + const token = await this.tools.integrations.get(calendarId); if (!token) { throw new Error("Authorization no longer available"); @@ -367,15 +306,10 @@ export class GoogleCalendar })); } - async startSync< - TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any - >( + async startSync( options: { calendarId: string; } & SyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { calendarId, timeMin, timeMax } = options; @@ -393,13 +327,6 @@ export class GoogleCalendar // Set sync lock await this.set(`sync_lock_${resolvedCalendarId}`, true); - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set("event_callback_token", callbackToken); - // Setup webhook for this calendar await this.setupCalendarWatch(resolvedCalendarId); @@ -681,6 +608,13 @@ export class GoogleCalendar } if (mode === "full") { + // Discard buffered occurrences whose masters never appeared + // (e.g. instances of cancelled/deleted recurring events) + const pendingKeys = await this.tools.store.list("pending_occ:"); + for (const key of pendingKeys) { + await this.clear(key); + } + await this.clear(`sync_state_${calendarId}`); } // Always clear lock when sync completes (no more batches) @@ -701,20 +635,6 @@ export class GoogleCalendar calendarId: string, initialSync: boolean ): Promise { - // Hoist callback token retrieval outside loop - saves N-1 subrequests - // 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; - } - - // Get user email for RSVP tagging for (const event of events) { try { // Extract contacts from organizer and attendees @@ -742,8 +662,7 @@ export class GoogleCalendar await this.processEventInstance( event, calendarId, - initialSync, - callbackToken + initialSync ); } else { // Regular or master recurring event @@ -759,23 +678,20 @@ export class GoogleCalendar const canonicalUrl = `google-calendar:${event.id}`; // Create cancellation note - const cancelNote: NewNote = { - activity: { source: canonicalUrl }, - key: "cancellation", + const cancelNote = { + key: "cancellation" as const, content: "This event was cancelled.", - contentType: "text", + contentType: "text" as const, created: event.updated ? new Date(event.updated) : new Date(), }; - // Convert to Note type with blocked tag and cancellation note - const activity: NewActivityWithNotes = { + // Convert to link with cancellation note + const link: NewLinkWithNotes = { source: canonicalUrl, created: event.created ? new Date(event.created) : undefined, - type: ActivityType.Note, - title: activityData.title, + type: "event", + title: activityData.title || "Cancelled event", preview: "Cancelled", - start: activityData.start || null, - end: activityData.end || null, meta: activityData.meta ?? null, notes: [cancelNote], ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates @@ -783,56 +699,36 @@ export class GoogleCalendar }; // Inject sync metadata for the parent to identify the source - activity.meta = { ...activity.meta, syncProvider: "google", syncableId: calendarId }; + link.channelId = calendarId; + link.meta = { ...link.meta, syncProvider: "google", syncableId: calendarId }; - // Send activity - database handles upsert automatically - await this.tools.callbacks.run(callbackToken, activity); + // Send link - database handles upsert automatically + await this.tools.integrations.saveLink(link); continue; } - // For recurring events, DON'T add tags at series level - // Tags (RSVPs) should be per-occurrence via the occurrences array - // For non-recurring events, add tags normally - let tags: Partial> | null = null; - if (validAttendees.length > 0 && !activityData.recurrenceRule) { - const attendTags: NewActor[] = []; - const skipTags: NewActor[] = []; - const undecidedTags: NewActor[] = []; - - // Iterate through valid attendees and group by response status - validAttendees.forEach((attendee) => { - const newActor: NewActor = { + // For recurring events, DON'T add contacts at series level + // Contacts (RSVPs) should be per-occurrence via the scheduleOccurrences array + // For non-recurring events, add contacts to the schedule + const isRecurring = !!activityData.schedules?.[0]?.recurrenceRule; + if (validAttendees.length > 0 && !isRecurring && activityData.schedules?.[0]) { + const contacts: NewScheduleContact[] = validAttendees.map((attendee) => ({ + contact: { email: attendee.email!, name: attendee.displayName, - }; - - if (attendee.responseStatus === "accepted") { - attendTags.push(newActor); - } else if (attendee.responseStatus === "declined") { - skipTags.push(newActor); - } else if ( - attendee.responseStatus === "tentative" || - attendee.responseStatus === "needsAction" - ) { - undecidedTags.push(newActor); - } - }); - - // Only set tags if we have at least one - if ( - attendTags.length > 0 || - skipTags.length > 0 || - undecidedTags.length > 0 - ) { - tags = {}; - if (attendTags.length > 0) tags[Tag.Attend] = attendTags; - if (skipTags.length > 0) tags[Tag.Skip] = skipTags; - if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; - } + }, + status: attendee.responseStatus === "accepted" ? "attend" as const + : attendee.responseStatus === "declined" ? "skip" as const + : null, + role: attendee.organizer ? "organizer" as const + : attendee.optional ? "optional" as const + : "required" as const, + })); + activityData.schedules[0].contacts = contacts; } - // Build links array for videoconferencing and calendar links - const links: Link[] = []; + // Build actions array for videoconferencing and calendar links + const actions: Action[] = []; const seenUrls = new Set(); // Extract all conferencing links (Zoom, Teams, Webex, etc.) @@ -840,8 +736,8 @@ export class GoogleCalendar for (const link of conferencingLinks) { if (!seenUrls.has(link.url)) { seenUrls.add(link.url); - links.push({ - type: LinkType.conferencing, + actions.push({ + type: ActionType.conferencing, url: link.url, provider: link.provider, }); @@ -851,8 +747,8 @@ export class GoogleCalendar // Add Google Meet link from hangoutLink if not already added if (event.hangoutLink && !seenUrls.has(event.hangoutLink)) { seenUrls.add(event.hangoutLink); - links.push({ - type: LinkType.conferencing, + actions.push({ + type: ActionType.conferencing, url: event.hangoutLink, provider: ConferencingProvider.googleMeet, }); @@ -860,8 +756,8 @@ export class GoogleCalendar // Add calendar link if (event.htmlLink) { - links.push({ - type: LinkType.external, + actions.push({ + type: ActionType.external, title: "View in Calendar", url: event.htmlLink, }); @@ -873,7 +769,7 @@ export class GoogleCalendar const description = typeof descriptionValue === "string" ? descriptionValue : null; const hasDescription = description && description.trim().length > 0; - const hasLinks = links.length > 0; + const hasActions = actions.length > 0; if (!activityData.type) { continue; @@ -882,51 +778,49 @@ export class GoogleCalendar // Canonical source for this event (required for upsert) const canonicalUrl = `google-calendar:${event.id}`; - // Create note with description (links moved to activity level) - const notes: NewNote[] = []; - if (hasDescription) { - notes.push({ - activity: { source: canonicalUrl }, - key: "description", - content: description, - contentType: - description && containsHtml(description) ? "html" : "text", - created: event.created ? new Date(event.created) : new Date(), - }); - } + // Build description note if available + const descriptionNote = hasDescription ? { + key: "description", + content: description, + contentType: + description && containsHtml(description) ? "html" as const : "text" as const, + created: event.created ? new Date(event.created) : new Date(), + } : null; - const shared = { + const link: NewLinkWithNotes = { source: canonicalUrl, created: event.created ? new Date(event.created) : undefined, - start: activityData.start || null, - end: activityData.end || null, - recurrenceUntil: activityData.recurrenceUntil || null, - recurrenceCount: activityData.recurrenceCount || null, + type: "event", title: activityData.title || "", author: authorContact, - recurrenceRule: activityData.recurrenceRule || null, - recurrenceExdates: activityData.recurrenceExdates || null, meta: activityData.meta ?? null, - tags: tags || undefined, - links: hasLinks ? links : undefined, - notes, + actions: hasActions ? actions : undefined, + sourceUrl: event.htmlLink ?? null, + notes: descriptionNote ? [descriptionNote] : [], preview: hasDescription ? description : null, + schedules: activityData.schedules, + scheduleOccurrences: activityData.scheduleOccurrences, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only - } as const; - - const activity: NewActivityWithNotes = - activityData.type === ActivityType.Action - ? { type: ActivityType.Action, ...shared } - : activityData.type === ActivityType.Event - ? { 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 }; + link.channelId = calendarId; + link.meta = { ...link.meta, syncProvider: "google", syncableId: calendarId }; + + // Merge any buffered occurrences that arrived before this master event + const pendingKey = `pending_occ:google-calendar:${event.id}`; + const pendingOccurrences = await this.get(pendingKey); + if (pendingOccurrences) { + link.scheduleOccurrences = [ + ...(link.scheduleOccurrences || []), + ...pendingOccurrences.map(p => p.occurrence), + ]; + await this.clear(pendingKey); + } - // Send activity - database handles upsert automatically - await this.tools.callbacks.run(callbackToken, activity); + // Send link - database handles upsert automatically + await this.tools.integrations.saveLink(link); } } catch (error) { console.error(`Failed to process event ${event.id}:`, error); @@ -937,13 +831,12 @@ export class GoogleCalendar /** * Process a recurring event instance (occurrence) from Google Calendar. - * This updates the master recurring activity with occurrence-specific data. + * This updates the master recurring thread with occurrence-specific data. */ private async processEventInstance( event: GoogleEvent, calendarId: string, - initialSync: boolean, - callbackToken: Callback + initialSync: boolean ): Promise { const originalStartTime = event.originalStartTime?.dateTime || event.originalStartTime?.date; @@ -952,21 +845,21 @@ export class GoogleCalendar return; } - // The recurring event ID points to the master activity + // The recurring event ID points to the master thread if (!event.recurringEventId) { console.warn(`No recurring event ID for instance: ${event.id}`); return; } // Canonical URL for the master recurring event - const masterCanonicalUrl = `google-calendar:${calendarId}:${event.recurringEventId}`; + const masterCanonicalUrl = `google-calendar:${event.recurringEventId}`; // Transform the instance data const instanceData = transformGoogleEvent(event, calendarId); - // Handle cancelled recurring instances by adding to recurrence exdates + // Handle cancelled recurring instances via archived schedule occurrence if (event.status === "cancelled") { - // Extract start/end from the event (they're present even for cancelled events) + // Extract start from the event for the occurrence const start = event.start?.dateTime ? new Date(event.start.dateTime) : event.start?.date @@ -979,85 +872,96 @@ export class GoogleCalendar ? event.end.date : null; - const occurrenceUpdate = { - type: ActivityType.Event, - source: masterCanonicalUrl, + const cancelledOccurrence: NewScheduleOccurrence = { + occurrence: new Date(originalStartTime), start: start, end: end, + archived: true, + }; + + // During initial sync, buffer the occurrence for later merging with its master + if (initialSync) { + const pendingKey = `pending_occ:${masterCanonicalUrl}`; + const existing = await this.get(pendingKey) || []; + existing.push({ occurrence: cancelledOccurrence, cancelled: true }); + await this.set(pendingKey, existing); + return; + } + + const occurrenceUpdate: NewLinkWithNotes = { + type: "event", + title: "", + source: masterCanonicalUrl, + channelId: calendarId, meta: { syncProvider: "google", syncableId: calendarId }, - addRecurrenceExdates: [new Date(originalStartTime)], + scheduleOccurrences: [cancelledOccurrence], + notes: [], }; - await this.tools.callbacks.run(callbackToken, occurrenceUpdate); + await this.tools.integrations.saveLink(occurrenceUpdate); return; } - // Determine RSVP status for attendees + // Build contacts from attendees for this occurrence const validAttendees = event.attendees?.filter((att) => att.email && !att.resource) || []; - let tags: Partial> = {}; - if (validAttendees.length > 0) { - const attendTags: import("@plotday/twister").NewActor[] = []; - const skipTags: import("@plotday/twister").NewActor[] = []; - const undecidedTags: import("@plotday/twister").NewActor[] = []; - - validAttendees.forEach((attendee) => { - const newActor: import("@plotday/twister").NewActor = { - email: attendee.email!, - name: attendee.displayName, - }; - - if (attendee.responseStatus === "accepted") { - attendTags.push(newActor); - } else if (attendee.responseStatus === "declined") { - skipTags.push(newActor); - } else if ( - attendee.responseStatus === "tentative" || - attendee.responseStatus === "needsAction" - ) { - undecidedTags.push(newActor); - } - }); - - if (attendTags.length > 0) tags[Tag.Attend] = attendTags; - if (skipTags.length > 0) tags[Tag.Skip] = skipTags; - if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; - } - - // Build occurrence object - // Always include start to ensure upsert_activity can infer scheduling when - // creating a new master activity. Use instanceData.start if available (for - // rescheduled instances), otherwise fall back to originalStartTime. - const occurrenceStart = instanceData.start ?? new Date(originalStartTime); - - const occurrence: Omit = { + const contacts: NewScheduleContact[] | undefined = + validAttendees.length > 0 + ? validAttendees.map((attendee) => ({ + contact: { + email: attendee.email!, + name: attendee.displayName, + }, + status: attendee.responseStatus === "accepted" ? "attend" as const + : attendee.responseStatus === "declined" ? "skip" as const + : null, + role: attendee.organizer ? "organizer" as const + : attendee.optional ? "optional" as const + : "required" as const, + })) + : undefined; + + // Build schedule occurrence object + // Always include start to ensure upsert can infer scheduling when + // creating a new master thread. Use instanceData schedule start if available + // (for rescheduled instances), otherwise fall back to originalStartTime. + const instanceSchedule = instanceData.schedules?.[0]; + const occurrenceStart = instanceSchedule?.start ?? new Date(originalStartTime); + + const occurrence: NewScheduleOccurrence = { occurrence: new Date(originalStartTime), start: occurrenceStart, - tags: Object.keys(tags).length > 0 ? tags : undefined, + contacts, ...(initialSync ? { unread: false } : {}), }; - // Add additional field overrides if present - if (instanceData.end !== undefined && instanceData.end !== null) { - occurrence.end = instanceData.end; + // Add end override if present on the instance + if (instanceSchedule?.end !== undefined && instanceSchedule?.end !== null) { + occurrence.end = instanceSchedule.end; } - if (instanceData.title) occurrence.title = instanceData.title; - if (instanceData.meta) occurrence.meta = instanceData.meta; - // Send occurrence data to the twist via callback - // The twist will decide whether to create or update the master activity + // During initial sync, buffer the occurrence for later merging with its master + if (initialSync) { + const pendingKey = `pending_occ:${masterCanonicalUrl}`; + const existing = await this.get(pendingKey) || []; + existing.push({ occurrence, cancelled: false }); + await this.set(pendingKey, existing); + return; + } - // Build a minimal NewActivity with source and occurrences - // The twist's createActivity will upsert the master activity - const occurrenceUpdate = { - type: ActivityType.Event, + // For incremental sync, save immediately (master should exist) + const occurrenceUpdate: NewLinkWithNotes = { + type: "event", + title: "", source: masterCanonicalUrl, + channelId: calendarId, meta: { syncProvider: "google", syncableId: calendarId }, - occurrences: [occurrence], + scheduleOccurrences: [occurrence], + notes: [], }; - await this.tools.callbacks.run(callbackToken, occurrenceUpdate); + await this.tools.integrations.saveLink(occurrenceUpdate); } async onCalendarWebhook( @@ -1169,171 +1073,22 @@ export class GoogleCalendar return `${baseEventId}_${instanceDateStr}`; } - async onActivityUpdated( - activity: Activity, - changes: { - tagsAdded: Record; - tagsRemoved: Record; - occurrence?: ActivityOccurrence; - } - ): Promise { - try { - // Only process calendar events - const source = activity.source; - if ( - !source || - typeof source !== "string" || - !source.startsWith("google-calendar:") - ) { - return; - } - - // Check if RSVP tags changed - const attendChanged = - Tag.Attend in changes.tagsAdded || Tag.Attend in changes.tagsRemoved; - const skipChanged = - Tag.Skip in changes.tagsAdded || Tag.Skip in changes.tagsRemoved; - const undecidedChanged = - Tag.Undecided in changes.tagsAdded || - Tag.Undecided in changes.tagsRemoved; - - if (!attendChanged && !skipChanged && !undecidedChanged) { - return; // No RSVP-related tag changes - } - - // Collect unique actor IDs from RSVP tag changes - const actorIds = new Set(); - for (const tag of [Tag.Attend, Tag.Skip, Tag.Undecided]) { - if (tag in changes.tagsAdded) { - for (const id of changes.tagsAdded[tag]) actorIds.add(id); - } - if (tag in changes.tagsRemoved) { - for (const id of changes.tagsRemoved[tag]) actorIds.add(id); - } - } - - // Determine new RSVP status based on most recent tag change - const hasAttend = - activity.tags?.[Tag.Attend] && activity.tags[Tag.Attend].length > 0; - const hasSkip = - activity.tags?.[Tag.Skip] && activity.tags[Tag.Skip].length > 0; - const hasUndecided = - activity.tags?.[Tag.Undecided] && - activity.tags[Tag.Undecided].length > 0; - - let newStatus: "accepted" | "declined" | "tentative" | "needsAction"; - - // Priority: Attend > Skip > Undecided, using most recent from tagsAdded - if (hasAttend && (hasSkip || hasUndecided)) { - if (Tag.Attend in changes.tagsAdded) { - newStatus = "accepted"; - } else if (Tag.Skip in changes.tagsAdded) { - newStatus = "declined"; - } else if (Tag.Undecided in changes.tagsAdded) { - newStatus = "tentative"; - } else { - return; - } - } else if (hasSkip && hasUndecided) { - if (Tag.Skip in changes.tagsAdded) { - newStatus = "declined"; - } else if (Tag.Undecided in changes.tagsAdded) { - newStatus = "tentative"; - } else { - return; - } - } else if (hasAttend) { - newStatus = "accepted"; - } else if (hasSkip) { - newStatus = "declined"; - } else if (hasUndecided) { - newStatus = "tentative"; - } else { - newStatus = "needsAction"; - } - - // Extract calendar info from metadata - if (!activity.meta) { - console.error("[RSVP Sync] Missing activity metadata", { - activity_id: activity.id, - }); - return; - } - - const baseEventId = activity.meta.id; - const calendarId = activity.meta.calendarId; - - if ( - !baseEventId || - !calendarId || - typeof baseEventId !== "string" || - typeof calendarId !== "string" - ) { - console.error("[RSVP Sync] Missing or invalid event/calendar ID", { - has_event_id: !!baseEventId, - has_calendar_id: !!calendarId, - event_id_type: typeof baseEventId, - calendar_id_type: typeof calendarId, - }); - return; - } - - // Determine the event ID to update - let eventId = baseEventId; - if (changes.occurrence) { - eventId = this.constructInstanceId( - baseEventId, - changes.occurrence.occurrence - ); - } - - // 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) { - await this.tools.integrations.actAs( - GoogleCalendar.PROVIDER, - actorId, - activity.id, - this.syncActorRSVP, - calendarId as string, - eventId, - newStatus, - actorId as string - ); - } - } catch (error) { - console.error("[RSVP Sync] Error in callback", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - activity_id: activity.id, - }); - } - } - /** - * 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. + * Sync a schedule contact RSVP change back to Google Calendar. + * Called via actAs() which provides the actor's auth token. */ async syncActorRSVP( token: AuthToken, calendarId: string, eventId: string, status: "accepted" | "declined" | "tentative" | "needsAction", - actorId: string + _actorId: string ): Promise { try { const api = new GoogleApi(token.token); - await this.updateEventRSVPWithApi( - api, - calendarId, - eventId, - status, - actorId as ActorId - ); + await this.updateEventRSVPWithApi(api, calendarId, eventId, status); } catch (error) { console.error("[RSVP Sync] Failed to sync RSVP", { - actor_id: actorId, event_id: eventId, error: error instanceof Error ? error.message : String(error), }); @@ -1341,15 +1096,14 @@ export class GoogleCalendar } /** - * Update RSVP status for a specific actor using a pre-authenticated GoogleApi instance. - * Looks up the actor's email from the calendar API to find the correct attendee. + * Update RSVP status for the authenticated user on a Google Calendar event. + * Looks up the user's email from the calendar API to find the correct attendee. */ private async updateEventRSVPWithApi( api: GoogleApi, calendarId: string, eventId: string, - status: "accepted" | "declined" | "needsAction" | "tentative", - actorId: ActorId + status: "accepted" | "declined" | "needsAction" | "tentative" ): Promise { // Fetch the current event to get attendees list const event = (await api.call( @@ -1378,7 +1132,6 @@ export class GoogleCalendar if (actorAttendeeIndex === -1) { console.warn("[RSVP Sync] Actor is not an attendee of this event", { - actor_id: actorId, event_id: eventId, }); return; diff --git a/tools/google-calendar/src/index.ts b/sources/google-calendar/src/index.ts similarity index 100% rename from tools/google-calendar/src/index.ts rename to sources/google-calendar/src/index.ts diff --git a/tools/gmail/tsconfig.json b/sources/google-calendar/tsconfig.json similarity index 100% rename from tools/gmail/tsconfig.json rename to sources/google-calendar/tsconfig.json diff --git a/tools/google-contacts/CHANGELOG.md b/sources/google-contacts/CHANGELOG.md similarity index 100% rename from tools/google-contacts/CHANGELOG.md rename to sources/google-contacts/CHANGELOG.md diff --git a/tools/google-contacts/LICENSE b/sources/google-contacts/LICENSE similarity index 100% rename from tools/google-contacts/LICENSE rename to sources/google-contacts/LICENSE diff --git a/tools/google-contacts/README.md b/sources/google-contacts/README.md similarity index 100% rename from tools/google-contacts/README.md rename to sources/google-contacts/README.md diff --git a/tools/google-contacts/package.json b/sources/google-contacts/package.json similarity index 72% rename from tools/google-contacts/package.json rename to sources/google-contacts/package.json index a92c232..f8962ac 100644 --- a/tools/google-contacts/package.json +++ b/sources/google-contacts/package.json @@ -1,7 +1,9 @@ { - "name": "@plotday/tool-google-contacts", + "name": "@plotday/source-google-contacts", + "plotTwistId": "748bb590-5da3-4782-aa6f-6a98fc2d3555", "displayName": "Google Contacts", - "description": "Sync with Google Contacts", + "description": "Contacts from Google", + "logoUrl": "https://api.iconify.design/logos/google-contacts.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.6.1", @@ -15,14 +17,12 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" @@ -45,8 +45,5 @@ "tool", "google-contacts", "contacts" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/google-contacts/src/google-contacts.ts b/sources/google-contacts/src/google-contacts.ts similarity index 76% rename from tools/google-contacts/src/google-contacts.ts rename to sources/google-contacts/src/google-contacts.ts index b7531a0..6037e99 100644 --- a/tools/google-contacts/src/google-contacts.ts +++ b/sources/google-contacts/src/google-contacts.ts @@ -1,21 +1,17 @@ import { type NewContact, - Serializable, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network } from "@plotday/twister/tools/network"; -import type { GoogleContacts as IGoogleContacts, GoogleContactsOptions } from "./types"; - type ContactTokens = { connections?: { nextPageToken?: string; @@ -251,53 +247,35 @@ async function getGoogleContacts( } export default class GoogleContacts - extends Tool - implements IGoogleContacts + extends Source { 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", "https://www.googleapis.com/auth/contacts.other.readonly", ]; + readonly provider = AuthProvider.Google; + readonly scopes = GoogleContacts.SCOPES; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [{ - provider: GoogleContacts.PROVIDER, - scopes: GoogleContacts.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://people.googleapis.com/*"], }), }; } - async getSyncables(_auth: Authorization, _token: AuthToken): Promise { + async getChannels(_auth: Authorization, _token: AuthToken): Promise { return [{ id: "contacts", title: "Contacts" }]; } - 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 - ); + async onChannelEnabled(channel: Channel): Promise { + const token = await this.tools.integrations.get(channel.id); if (!token) { throw new Error("No Google authentication token available"); } @@ -306,30 +284,18 @@ export default class GoogleContacts more: false, }; - await this.set(`sync_state:${syncable.id}`, initialState); + await this.set(`sync_state:${channel.id}`, initialState); - const syncCallback = await this.callback(this.syncBatch, 1, syncable.id); + const syncCallback = await this.callback(this.syncBatch, 1, channel.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 onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); } async getContacts(syncableId: string): Promise { - const token = await this.tools.integrations.get( - GoogleContacts.PROVIDER, - syncableId - ); + const token = await this.tools.integrations.get(syncableId); if (!token) { throw new Error( "No Google authentication token available for the provided syncableId" @@ -344,51 +310,31 @@ export default class GoogleContacts return result.contacts; } - async startSync< - TArgs extends Serializable[], - TCallback extends (contacts: NewContact[], ...args: TArgs) => any - >(syncableId: string, callback: TCallback, ...extraArgs: TArgs): Promise { - const token = await this.tools.integrations.get( - GoogleContacts.PROVIDER, - syncableId - ); + async startSync(syncableId: string): Promise { + const token = await this.tools.integrations.get(syncableId); if (!token) { throw new Error( "No Google authentication token available for the provided syncableId" ); } - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${syncableId}`, callbackToken); - - // Start initial sync const initialState: ContactSyncState = { more: false, }; 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, syncableId); await this.run(syncCallback); } async stopSync(syncableId: string): Promise { - // Clear sync state for this specific syncable await this.clear(`sync_state:${syncableId}`); - await this.clear(`item_callback_${syncableId}`); } async syncBatch(batchNumber: number, syncableId: string): Promise { try { - const token = await this.tools.integrations.get( - GoogleContacts.PROVIDER, - syncableId - ); + const token = await this.tools.integrations.get(syncableId); if (!token) { throw new Error( "No authentication token available for the provided syncableId" @@ -408,7 +354,7 @@ export default class GoogleContacts ); if (result.contacts.length > 0) { - await this.processContacts(result.contacts, syncableId); + await this.processContacts(result.contacts); } await this.set(`sync_state:${syncableId}`, result.state); @@ -432,13 +378,7 @@ export default class GoogleContacts private async processContacts( contacts: NewContact[], - syncableId: string ): Promise { - const callbackToken = await this.get( - `item_callback_${syncableId}` - ); - if (callbackToken) { - await this.run(callbackToken, contacts); - } + await this.tools.integrations.saveContacts(contacts); } } diff --git a/tools/google-contacts/src/index.ts b/sources/google-contacts/src/index.ts similarity index 100% rename from tools/google-contacts/src/index.ts rename to sources/google-contacts/src/index.ts diff --git a/sources/google-contacts/src/types.ts b/sources/google-contacts/src/types.ts new file mode 100644 index 0000000..cb19b21 --- /dev/null +++ b/sources/google-contacts/src/types.ts @@ -0,0 +1,9 @@ +import type { NewContact } from "@plotday/twister"; + +export interface GoogleContacts { + getContacts(channelId: string): Promise; + + startSync(channelId: string): Promise; + + stopSync(channelId: string): Promise; +} diff --git a/tools/google-calendar/tsconfig.json b/sources/google-contacts/tsconfig.json similarity index 100% rename from tools/google-calendar/tsconfig.json rename to sources/google-contacts/tsconfig.json diff --git a/tools/google-drive/CHANGELOG.md b/sources/google-drive/CHANGELOG.md similarity index 100% rename from tools/google-drive/CHANGELOG.md rename to sources/google-drive/CHANGELOG.md diff --git a/tools/google-drive/LICENSE b/sources/google-drive/LICENSE similarity index 100% rename from tools/google-drive/LICENSE rename to sources/google-drive/LICENSE diff --git a/tools/google-drive/README.md b/sources/google-drive/README.md similarity index 100% rename from tools/google-drive/README.md rename to sources/google-drive/README.md diff --git a/tools/google-drive/package.json b/sources/google-drive/package.json similarity index 68% rename from tools/google-drive/package.json rename to sources/google-drive/package.json index 2df882d..054b4c1 100644 --- a/tools/google-drive/package.json +++ b/sources/google-drive/package.json @@ -1,7 +1,9 @@ { - "name": "@plotday/tool-google-drive", + "name": "@plotday/source-google-drive", + "plotTwistId": "c27741c2-0f26-444c-9e50-e1d70dab0dee", "displayName": "Google Drive", - "description": "Sync documents comments from Google Drive", + "description": "Comment threads from your Google Docs", + "logoUrl": "https://api.iconify.design/logos/google-drive.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.2.2", @@ -15,17 +17,15 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { - "@plotday/tool-google-contacts": "workspace:^", + "@plotday/source-google-contacts": "workspace:^", "@plotday/twister": "workspace:^" }, "devDependencies": { @@ -46,8 +46,5 @@ "tool", "google-drive", "documents" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/google-drive/src/google-api.ts b/sources/google-drive/src/google-api.ts similarity index 100% rename from tools/google-drive/src/google-api.ts rename to sources/google-drive/src/google-api.ts diff --git a/tools/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts similarity index 75% rename from tools/google-drive/src/google-drive.ts rename to sources/google-drive/src/google-drive.ts index 1999a8a..a033592 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -1,34 +1,33 @@ -import GoogleContacts from "@plotday/tool-google-contacts"; +import GoogleContacts from "@plotday/source-google-contacts"; import { - type ActivityFilter, - ActivityKind, - type Link, - LinkType, - ActivityType, - type NewActivityWithNotes, + type Action, + ActionType, + type NewLinkWithNotes, type NewContact, type NewNote, - Serializable, - type SyncToolOptions, - Tag, - Tool, + Source, type ToolBuilder, + Tag, } from "@plotday/twister"; -import { - type DocumentFolder, - type DocumentSyncOptions, - type DocumentTool, -} from "@plotday/twister/common/documents"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; + +type DocumentFolder = { + id: string; + name: string; + description: string | null; + root: boolean; +}; + +type DocumentSyncOptions = { + timeMin?: Date; +}; import { GoogleApi, @@ -46,7 +45,7 @@ import { } from "./google-api"; /** - * Google Drive integration tool. + * Google Drive integration source. * * Provides integration with Google Drive, supporting document * synchronization, comment syncing, and real-time updates via webhooks. @@ -62,49 +61,33 @@ 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 Source { static readonly PROVIDER = AuthProvider.Google; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; static readonly SCOPES = ["https://www.googleapis.com/auth/drive"]; + readonly provider = AuthProvider.Google; + readonly scopes = Integrations.MergeScopes(GoogleDrive.SCOPES, GoogleContacts.SCOPES); + readonly linkTypes = [{ type: "document", label: "Document", logo: "https://api.iconify.design/logos/google-drive.svg", logoMono: "https://api.iconify.design/simple-icons/googledrive.svg" }]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: GoogleDrive.PROVIDER, - scopes: Integrations.MergeScopes( - GoogleDrive.SCOPES, - GoogleContacts.SCOPES - ), - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://www.googleapis.com/drive/*"], }), - plot: build(Plot, { - contact: { - access: ContactAccess.Write, - }, - }), googleContacts: build(GoogleContacts), }; } /** - * Returns available Google Drive folders as a syncable tree. + * Returns available Google Drive folders as a channel tree. * Shared drives and root-level My Drive folders appear at the top level, * with subfolders nested under their parents. */ - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const api = new GoogleApi(token.token); const [folders, sharedDrives] = await Promise.all([ listFolders(api), @@ -112,14 +95,14 @@ export class GoogleDrive extends Tool implements DocumentTool { ]); // Build node map for all folders - type SyncableNode = { id: string; title: string; children: SyncableNode[] }; - const nodeMap = new Map(); + type ChannelNode = { id: string; title: string; children: ChannelNode[] }; + const nodeMap = new Map(); for (const f of folders) { nodeMap.set(f.id, { id: f.id, title: f.name, children: [] }); } // Build shared drive node map - const sharedDriveMap = new Map(); + const sharedDriveMap = new Map(); for (const drive of sharedDrives) { sharedDriveMap.set(drive.id, { id: drive.id, @@ -129,7 +112,7 @@ export class GoogleDrive extends Tool implements DocumentTool { } // Link children to parents - const roots: SyncableNode[] = []; + const roots: ChannelNode[] = []; for (const f of folders) { const node = nodeMap.get(f.id)!; const parentId = f.parents?.[0]; @@ -145,7 +128,7 @@ export class GoogleDrive extends Tool implements DocumentTool { continue; } } - // No known parent in our set → root folder (My Drive) + // No known parent in our set -> root folder (My Drive) roots.push(node); } @@ -153,7 +136,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const allRoots = [...sharedDriveMap.values(), ...roots]; // Strip empty children arrays for clean output - const clean = (nodes: SyncableNode[]): Syncable[] => { + const clean = (nodes: ChannelNode[]): Channel[] => { return nodes.map((n) => { if (n.children.length > 0) { return { id: n.id, title: n.title, children: clean(n.children) }; @@ -166,89 +149,46 @@ export class GoogleDrive extends Tool implements DocumentTool { } /** - * Called when a syncable folder is enabled for syncing. - * Creates callback tokens from options and auto-starts sync. + * Called when a channel folder is enabled for syncing. */ - 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); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup watch and queue first batch - await this.set(`sync_lock_${syncable.id}`, true); + await this.set(`sync_lock_${channel.id}`, true); - const api = await this.getApi(syncable.id); + const api = await this.getApi(channel.id); const changesToken = await getChangesStartToken(api); const initialState: SyncState = { - folderId: syncable.id, + folderId: channel.id, changesToken, sequence: 1, }; - await this.set(`sync_state_${syncable.id}`, initialState); - await this.setupDriveWatch(syncable.id); + await this.set(`sync_state_${channel.id}`, initialState); + await this.setupDriveWatch(channel.id); const syncCallback = await this.callback( this.syncBatch, 1, - syncable.id, + channel.id, true // initialSync ); await this.runTask(syncCallback); } /** - * Called when a syncable folder is disabled. - * Stops sync, runs disable callback, and cleans up stored tokens. + * Called when a channel folder is disabled. */ - 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}`); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.id}`); } private async getApi(folderId: string): Promise { - // Get token for the syncable (folder) from integrations - const token = await this.tools.integrations.get( - GoogleDrive.PROVIDER, - folderId - ); + // Get token for the channel (folder) from integrations + const token = await this.tools.integrations.get(folderId); if (!token) { throw new Error("Authorization no longer available"); @@ -277,15 +217,10 @@ export class GoogleDrive extends Tool implements DocumentTool { })); } - async startSync< - TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any - >( + async startSync( options: { folderId: string; } & DocumentSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { folderId } = options; @@ -298,13 +233,6 @@ export class GoogleDrive extends Tool implements DocumentTool { // Set sync lock await this.set(`sync_lock_${folderId}`, true); - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${folderId}`, callbackToken); - // Get changes start token for future incremental syncs const api = await this.getApi(folderId); const changesToken = await getChangesStartToken(api); @@ -348,7 +276,6 @@ export class GoogleDrive extends Tool implements DocumentTool { await this.clear(`drive_watch_${folderId}`); await this.clear(`sync_state_${folderId}`); await this.clear(`sync_lock_${folderId}`); - await this.clear(`item_callback_${folderId}`); } async addDocumentComment( @@ -359,7 +286,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const fileId = meta.fileId as string | undefined; const folderId = meta.folderId as string | undefined; if (!fileId || !folderId) { - console.warn("No fileId/folderId in activity meta, cannot add comment"); + console.warn("No fileId/folderId in thread meta, cannot add comment"); return; } @@ -377,7 +304,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const fileId = meta.fileId as string | undefined; const folderId = meta.folderId as string | undefined; if (!fileId || !folderId) { - console.warn("No fileId/folderId in activity meta, cannot add reply"); + console.warn("No fileId/folderId in thread meta, cannot add reply"); return; } @@ -418,8 +345,6 @@ export class GoogleDrive extends Tool implements DocumentTool { )) as { expiration: string; resourceId: string }; const expiry = new Date(parseInt(watchData.expiration)); - const hoursUntilExpiry = - (expiry.getTime() - Date.now()) / (1000 * 60 * 60); await this.set(`drive_watch_${folderId}`, { watchId, @@ -577,24 +502,15 @@ export class GoogleDrive extends Tool implements DocumentTool { const api = await this.getApi(folderId); const result = await listFilesInFolder(api, folderId, state.pageToken); - // Process files in this batch - const callbackToken = await this.get( - `item_callback_${folderId}` - ); - if (!callbackToken) { - console.warn("No callback token found, skipping file processing"); - return; - } - for (const file of result.files) { try { - const activity = await this.buildActivityFromFile( + const thread = await this.buildThreadFromFile( api, file, folderId, initialSync ); - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(thread); } catch (error) { console.error(`Failed to process file ${file.id}:`, error); } @@ -639,17 +555,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const api = await this.getApi(folderId); const result = await listChanges(api, changesToken); - 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}`); - return; - } - // Filter changes to files in our synced folder - let processedCount = 0; for (const change of result.changes) { if (change.removed || !change.file) continue; @@ -660,16 +566,14 @@ export class GoogleDrive extends Tool implements DocumentTool { if (change.file.mimeType === "application/vnd.google-apps.folder") continue; - processedCount++; - try { - const activity = await this.buildActivityFromFile( + const thread = await this.buildThreadFromFile( api, change.file, folderId, false // incremental sync ); - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(thread); } catch (error) { console.error( `Failed to process changed file ${change.fileId}:`, @@ -705,14 +609,14 @@ export class GoogleDrive extends Tool implements DocumentTool { } } - // --- Activity Building --- + // --- Thread Building --- - private async buildActivityFromFile( + private async buildThreadFromFile( api: GoogleApi, file: GoogleDriveFile, folderId: string, initialSync: boolean - ): Promise { + ): Promise { const canonicalSource = `google-drive:file:${file.id}`; // Build author contact from file owner @@ -727,7 +631,7 @@ export class GoogleDrive extends Tool implements DocumentTool { } } - // Build displayName → email lookup from file permissions + // Build displayName -> email lookup from file permissions // (Drive API doesn't return emailAddress on comment authors) const emailByName = new Map(); if (file.permissions) { @@ -743,7 +647,7 @@ export class GoogleDrive extends Tool implements DocumentTool { // Summary note with description if available notes.push({ - activity: { source: canonicalSource }, + thread: { source: canonicalSource }, key: "summary", content: file.description || null, contentType: "text", @@ -777,23 +681,24 @@ export class GoogleDrive extends Tool implements DocumentTool { console.error(`Failed to fetch comments for file ${file.id}:`, error); } - // Build external link - const links: Link[] = []; + // Build external action + const actions: Action[] = []; if (file.webViewLink) { - links.push({ - type: LinkType.external, + actions.push({ + type: ActionType.external, title: "View in Drive", url: file.webViewLink, }); } - const activity: NewActivityWithNotes = { + const thread: NewLinkWithNotes = { source: canonicalSource, - type: ActivityType.Note, - kind: ActivityKind.document, + type: "document", title: file.name, author, - links: links.length > 0 ? links : null, + sourceUrl: file.webViewLink ?? null, + actions: actions.length > 0 ? actions : null, + channelId: folderId, meta: { fileId: file.id, folderId, @@ -809,7 +714,7 @@ export class GoogleDrive extends Tool implements DocumentTool { ...(initialSync ? { archived: false } : {}), }; - return activity; + return thread; } private buildCommentNote( @@ -830,14 +735,14 @@ export class GoogleDrive extends Tool implements DocumentTool { : undefined; return { - activity: { source: canonicalSource }, + thread: { source: canonicalSource }, key: `comment-${comment.id}`, content: comment.content, contentType: comment.htmlContent ? "html" : "text", author: commentAuthor, created: new Date(comment.createdTime), ...(comment.assigneeEmailAddress - ? { tags: { [Tag.Now]: [{ email: comment.assigneeEmailAddress }] } } + ? { tags: { [Tag.Todo]: [{ email: comment.assigneeEmailAddress }] } } : {}), }; } @@ -863,7 +768,7 @@ export class GoogleDrive extends Tool implements DocumentTool { : undefined; return { - activity: { source: canonicalSource }, + thread: { source: canonicalSource }, key: `reply-${commentId}-${reply.id}`, reNote: { key: `comment-${commentId}` }, content: reply.content, diff --git a/tools/google-drive/src/index.ts b/sources/google-drive/src/index.ts similarity index 100% rename from tools/google-drive/src/index.ts rename to sources/google-drive/src/index.ts diff --git a/tools/google-contacts/tsconfig.json b/sources/google-drive/tsconfig.json similarity index 100% rename from tools/google-contacts/tsconfig.json rename to sources/google-drive/tsconfig.json diff --git a/tools/jira/CHANGELOG.md b/sources/jira/CHANGELOG.md similarity index 100% rename from tools/jira/CHANGELOG.md rename to sources/jira/CHANGELOG.md diff --git a/tools/jira/package.json b/sources/jira/package.json similarity index 74% rename from tools/jira/package.json rename to sources/jira/package.json index 7618209..79b6358 100644 --- a/tools/jira/package.json +++ b/sources/jira/package.json @@ -1,7 +1,9 @@ { - "name": "@plotday/tool-jira", + "name": "@plotday/source-jira", + "plotTwistId": "07f11ed7-9555-431d-b01a-99aff550d778", "displayName": "Jira", - "description": "Sync with Jira project management", + "description": "Issues from Jira", + "logoUrl": "https://api.iconify.design/logos/jira.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.8.1", @@ -15,14 +17,12 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^", @@ -46,8 +46,5 @@ "jira", "atlassian", "project-management" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/jira/src/index.ts b/sources/jira/src/index.ts similarity index 100% rename from tools/jira/src/index.ts rename to sources/jira/src/index.ts diff --git a/tools/jira/src/jira.ts b/sources/jira/src/jira.ts similarity index 68% rename from tools/jira/src/jira.ts rename to sources/jira/src/jira.ts index ef3e992..55d862f 100644 --- a/tools/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -1,34 +1,34 @@ import { Version3Client } from "jira.js"; import { - type Activity, - type Link, - LinkType, - ActivityType, - type NewActivity, - type NewActivityWithNotes, + type Action, + ActionType, + type NewLinkWithNotes, NewContact, - Serializable, - type SyncToolOptions, } from "@plotday/twister"; -import type { - Project, - ProjectSyncOptions, - ProjectTool, -} from "@plotday/twister/common/projects"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; -import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; +import { Source } from "@plotday/twister/source"; +import type { ToolBuilder } from "@plotday/twister/tool"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; +type Project = { + id: string; + name: string; + description: string | null; + key: string | null; +}; + +type ProjectSyncOptions = { + timeMin?: Date; +}; + type SyncState = { startAt: number; batchNumber: number; @@ -37,40 +37,43 @@ type SyncState = { }; /** - * Jira project management tool + * Jira project management source * - * Implements the ProjectTool interface for syncing Jira projects and issues - * with Plot activities. + * Implements the ProjectSource interface for syncing Jira projects and issues + * with Plot threads. */ -export class Jira extends Tool implements ProjectTool { +export class Jira extends Source { 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; + + readonly provider = AuthProvider.Atlassian; + readonly scopes = Jira.SCOPES; + readonly linkTypes = [ + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/logos/jira.svg", + logoMono: "https://api.iconify.design/simple-icons/jira.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ]; build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [{ - provider: Jira.PROVIDER, - scopes: Jira.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://*.atlassian.net/*"] }), - callbacks: build(Callbacks), tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), }; } /** - * Create Jira API client using syncable-based auth + * Create Jira API client using channel-based auth */ private async getClient(projectId: string): Promise { - const token = await this.tools.integrations.get(Jira.PROVIDER, projectId); + const token = await this.tools.integrations.get(projectId); if (!token) { throw new Error("No Jira authentication token available"); } @@ -89,9 +92,9 @@ export class Jira extends Tool implements ProjectTool { } /** - * Returns available Jira projects as syncable resources. + * Returns available Jira projects as channel resources. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getChannels(_auth: Authorization, token: AuthToken): Promise { const cloudId = token.provider?.cloud_id; if (!cloudId) { throw new Error("No Jira cloud ID in authorization"); @@ -108,55 +111,24 @@ export class Jira extends Tool implements ProjectTool { } /** - * Handle syncable resource being enabled. - * Creates callback tokens for the sync lifecycle and auto-starts sync. + * Called when a channel is enabled for syncing. + * Sets up webhook 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); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and queue first batch - await this.setupJiraWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupJiraWebhook(channel.id); + await this.startBatchSync(channel.id); } /** - * Handle syncable resource being disabled. - * Stops sync, runs disable callback, and cleans up all stored tokens. + * Called when a channel is disabled. + * Stops sync and archives all threads from this channel. */ - 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}`); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.id}`); } /** @@ -181,28 +153,16 @@ export class Jira extends Tool implements ProjectTool { /** * Start syncing issues from a Jira project */ - async startSync< - TArgs extends Serializable[], - TCallback extends (issue: NewActivityWithNotes, ...args: TArgs) => any - >( + async startSync( options: { projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs + } & ProjectSyncOptions ): Promise { const { projectId, timeMin } = options; // Setup webhook for real-time updates await this.setupJiraWebhook(projectId); - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${projectId}`, callbackToken); - // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); } @@ -233,10 +193,6 @@ export class Jira extends Tool implements ProjectTool { // Store webhook URL for reference await this.set(`webhook_url_${projectId}`, webhookUrl); - - // TODO: Implement programmatic webhook creation when Jira API access is available - // The jira.js library doesn't expose webhook creation methods - // Manual configuration is required for now } catch (error) { console.error("Failed to create webhook URL:", error); } @@ -280,12 +236,6 @@ export class Jira extends Tool implements ProjectTool { throw new Error(`Sync state not found for project ${projectId}`); } - // Retrieve callback token from storage - const callbackToken = await this.get(`item_callback_${projectId}`); - if (!callbackToken) { - throw new Error(`Callback token not found for project ${projectId}`); - } - const client = await this.getClient(projectId); // Build JQL query @@ -317,16 +267,16 @@ export class Jira extends Tool implements ProjectTool { // Process each issue for (const issue of searchResult.issues || []) { - const activityWithNotes = await this.convertIssueToActivity( + const linkWithNotes = await this.convertIssueToLink( issue, projectId ); // Set unread based on sync type (false for initial sync to avoid notification overload) - activityWithNotes.unread = !state.initialSync; + linkWithNotes.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); + linkWithNotes.channelId = projectId; + linkWithNotes.meta = { ...linkWithNotes.meta, syncProvider: "atlassian", syncableId: projectId }; + await this.tools.integrations.saveLink(linkWithNotes); } // Check if more pages @@ -356,10 +306,10 @@ export class Jira extends Tool implements ProjectTool { } /** - * Get the cloud ID using syncable-based auth + * Get the cloud ID using channel-based auth */ private async getCloudId(projectId: string): Promise { - const token = await this.tools.integrations.get(Jira.PROVIDER, projectId); + const token = await this.tools.integrations.get(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"); @@ -367,12 +317,12 @@ export class Jira extends Tool implements ProjectTool { } /** - * Convert a Jira issue to a Plot Activity + * Convert a Jira issue to a Plot Link */ - private async convertIssueToActivity( + private async convertIssueToLink( issue: any, projectId: string - ): Promise { + ): Promise { const fields = issue.fields || {}; const comments = fields.comment?.comments || []; const reporter = fields.reporter || fields.creator; @@ -430,17 +380,17 @@ export class Jira extends Tool implements ProjectTool { ? `jira:${cloudId}:issue:${issue.id}` : undefined; - // Build activity-level links - const activityLinks: Link[] = []; + // Build thread-level actions + const threadActions: Action[] = []; if (issueUrl) { - activityLinks.push({ - type: LinkType.external, + threadActions.push({ + type: ActionType.external, title: `Open in Jira`, url: issueUrl, }); } - // Create initial note with description (links moved to activity level) + // Create initial note with description (actions moved to thread level) notes.push({ key: "description", content: description, @@ -476,7 +426,7 @@ export class Jira extends Tool implements ProjectTool { return { ...(source ? { source } : {}), - type: ActivityType.Action, + type: "issue", title: fields.summary || issue.key, created: fields.created ? new Date(fields.created) : undefined, meta: { @@ -485,8 +435,9 @@ export class Jira extends Tool implements ProjectTool { }, author: authorContact, assignee: assigneeContact ?? null, // Explicitly set to null for unassigned issues - done: fields.resolutiondate ? new Date(fields.resolutiondate) : null, - links: activityLinks.length > 0 ? activityLinks : undefined, + status: fields.resolutiondate ? "done" : "open", + actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: issueUrl ?? null, notes, preview: description || null, }; @@ -523,32 +474,28 @@ export class Jira extends Tool implements ProjectTool { } /** - * Update issue with new values - * - * @param activity - The updated activity + * Update issue with new values from the app */ - async updateIssue(activity: Activity): Promise { - // Extract Jira issue key and project ID from meta - const issueKey = activity.meta?.issueKey as string | undefined; + async updateIssue(link: import("@plotday/twister").Link): Promise { + const issueKey = link.meta?.issueKey as string | undefined; if (!issueKey) { - throw new Error("Jira issue key not found in activity meta"); + throw new Error("Jira issue key not found in link meta"); } - const projectId = activity.meta?.projectId as string; + const projectId = link.meta?.projectId as string; const client = await this.getClient(projectId); // Handle field updates (title, assignee) const updateFields: any = {}; - if (activity.title !== null) { - updateFields.summary = activity.title; + if (link.title) { + updateFields.summary = link.title; } - updateFields.assignee = activity.assignee - ? { id: activity.assignee.id } + updateFields.assignee = link.assignee + ? { id: link.assignee.id } : null; - // Apply field updates if any if (Object.keys(updateFields).length > 0) { await client.issues.editIssue({ issueIdOrKey: issueKey, @@ -556,47 +503,42 @@ export class Jira extends Tool implements ProjectTool { }); } - // Handle workflow state transitions based on start + done combination - // Get available transitions for this issue + // Handle workflow state transitions based on link status and assignee const transitions = await client.issues.getTransitions({ issueIdOrKey: issueKey, }); let targetTransition; - // Determine target state based on combination - if (activity.type === ActivityType.Action && activity.done !== null) { - // Completed - look for "Done", "Close", or "Resolve" transition + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed" || link.status === "resolved"; + if (isDone) { targetTransition = transitions.transitions?.find( - (t) => - t.name?.toLowerCase() === "done" || - t.name?.toLowerCase() === "close" || - t.name?.toLowerCase() === "resolve" || - t.to?.name?.toLowerCase() === "done" || - t.to?.name?.toLowerCase() === "closed" || - t.to?.name?.toLowerCase() === "resolved" + (tr) => + tr.name?.toLowerCase() === "done" || + tr.name?.toLowerCase() === "close" || + tr.name?.toLowerCase() === "resolve" || + tr.to?.name?.toLowerCase() === "done" || + tr.to?.name?.toLowerCase() === "closed" || + tr.to?.name?.toLowerCase() === "resolved" ); - } else if (activity.start !== null) { - // In Progress - look for "Start Progress" or "In Progress" transition + } else if (link.assignee) { targetTransition = transitions.transitions?.find( - (t) => - t.name?.toLowerCase() === "start progress" || - t.name?.toLowerCase() === "in progress" || - t.to?.name?.toLowerCase() === "in progress" + (tr) => + tr.name?.toLowerCase() === "start progress" || + tr.name?.toLowerCase() === "in progress" || + tr.to?.name?.toLowerCase() === "in progress" ); } else { - // Backlog/Todo - look for "To Do", "Open", or "Reopen" transition targetTransition = transitions.transitions?.find( - (t) => - t.name?.toLowerCase() === "reopen" || - t.name?.toLowerCase() === "to do" || - t.name?.toLowerCase() === "open" || - t.to?.name?.toLowerCase() === "to do" || - t.to?.name?.toLowerCase() === "open" + (tr) => + tr.name?.toLowerCase() === "reopen" || + tr.name?.toLowerCase() === "to do" || + tr.name?.toLowerCase() === "open" || + tr.to?.name?.toLowerCase() === "to do" || + tr.to?.name?.toLowerCase() === "open" ); } - // Execute transition if found if (targetTransition) { await client.issues.doTransition({ issueIdOrKey: issueKey, @@ -610,18 +552,18 @@ export class Jira extends Tool implements ProjectTool { /** * Add a comment to a Jira issue * - * @param meta - Activity metadata containing issueKey and projectId + * @param meta - Thread metadata containing issueKey and projectId * @param body - Comment text (converted to ADF format) * @param noteId - Optional Plot note ID for dedup */ async addIssueComment( - meta: import("@plotday/twister").ActivityMeta, + meta: import("@plotday/twister").ThreadMeta, body: string, noteId?: string, ): Promise { const issueKey = meta.issueKey as string | undefined; if (!issueKey) { - throw new Error("Jira issue key not found in activity meta"); + throw new Error("Jira issue key not found in thread meta"); } const projectId = meta.projectId as string; const client = await this.getClient(projectId); @@ -671,26 +613,11 @@ export class Jira extends Tool implements ProjectTool { ): Promise { const payload = request.body as any; - // Get callback token (needed by both handlers) - const callbackToken = await this.get(`item_callback_${projectId}`); - if (!callbackToken) { - console.warn("No callback token found for project:", projectId); - return; - } - // Split handling by webhook event type for efficiency if (payload.webhookEvent?.startsWith("jira:issue_")) { - await this.handleIssueWebhook( - payload, - projectId, - callbackToken - ); + await this.handleIssueWebhook(payload, projectId); } else if (payload.webhookEvent?.startsWith("comment_")) { - await this.handleCommentWebhook( - payload, - projectId, - callbackToken - ); + await this.handleCommentWebhook(payload, projectId); } else { console.log("Ignoring webhook event:", payload.webhookEvent); } @@ -701,8 +628,7 @@ export class Jira extends Tool implements ProjectTool { */ private async handleIssueWebhook( payload: any, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const issue = payload.issue; if (!issue) { @@ -760,10 +686,10 @@ export class Jira extends Tool implements ProjectTool { } } - // Create partial activity update (no notes = doesn't touch existing notes) - const activity: NewActivity = { + // Create partial link update (empty notes = doesn't touch existing notes) + const link: NewLinkWithNotes = { ...(source ? { source } : {}), - type: ActivityType.Action, + type: "issue", title: fields.summary || issue.key, created: fields.created ? new Date(fields.created) : undefined, meta: { @@ -772,11 +698,12 @@ export class Jira extends Tool implements ProjectTool { }, author: authorContact, assignee: assigneeContact ?? null, - done: fields.resolutiondate ? new Date(fields.resolutiondate) : null, + status: fields.resolutiondate ? "done" : "open", preview: description || null, + notes: [], }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(link); } /** @@ -784,8 +711,7 @@ export class Jira extends Tool implements ProjectTool { */ private async handleCommentWebhook( payload: any, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const comment = payload.comment; const issue = payload.issue; @@ -831,17 +757,17 @@ export class Jira extends Tool implements ProjectTool { (p: any) => p.key === "plotNoteId" )?.value; - // Create activity update with single comment note - const activity: NewActivityWithNotes = { + // Create link update with single comment note + const link: NewLinkWithNotes = { ...(source ? { source } : {}), - type: ActivityType.Action, // Required field (will match existing activity) + type: "issue", + title: issue.key, // Placeholder; upsert by source will preserve existing title notes: [ { key: `comment-${comment.id}`, // If this comment originated from Plot, identify by note ID so we update the existing note // rather than creating a duplicate ...(plotNoteId ? { id: plotNoteId } : {}), - activity: source ? { source } : undefined, content: commentText, created: comment.created ? new Date(comment.created) : undefined, author: commentAuthor, @@ -853,7 +779,7 @@ export class Jira extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(link); } /** @@ -864,13 +790,6 @@ export class Jira extends Tool implements ProjectTool { await this.clear(`webhook_url_${projectId}`); await this.clear(`webhook_id_${projectId}`); - // Cleanup callback - const callbackToken = await this.get(`item_callback_${projectId}`); - if (callbackToken) { - await this.deleteCallback(callbackToken); - await this.clear(`item_callback_${projectId}`); - } - // Cleanup sync state await this.clear(`sync_state_${projectId}`); } diff --git a/tools/google-drive/tsconfig.json b/sources/jira/tsconfig.json similarity index 100% rename from tools/google-drive/tsconfig.json rename to sources/jira/tsconfig.json diff --git a/tools/linear/CHANGELOG.md b/sources/linear/CHANGELOG.md similarity index 100% rename from tools/linear/CHANGELOG.md rename to sources/linear/CHANGELOG.md diff --git a/tools/linear/LICENSE b/sources/linear/LICENSE similarity index 100% rename from tools/linear/LICENSE rename to sources/linear/LICENSE diff --git a/tools/linear/README.md b/sources/linear/README.md similarity index 100% rename from tools/linear/README.md rename to sources/linear/README.md diff --git a/tools/linear/package.json b/sources/linear/package.json similarity index 68% rename from tools/linear/package.json rename to sources/linear/package.json index 7abc19a..e00e20a 100644 --- a/tools/linear/package.json +++ b/sources/linear/package.json @@ -1,7 +1,10 @@ { - "name": "@plotday/tool-linear", + "name": "@plotday/source-linear", + "plotTwistId": "d23218d6-1e26-4a4d-9a24-63898a494c51", "displayName": "Linear", - "description": "Sync with Linear project management", + "description": "Issues from Linear", + "logoUrl": "https://api.iconify.design/logos/linear-icon.svg", + "logoUrlDark": "https://api.iconify.design/simple-icons/linear.svg?color=%235E6AD2", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.7.1", @@ -15,14 +18,12 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^", @@ -45,8 +46,5 @@ "tool", "linear", "project-management" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/linear/src/index.ts b/sources/linear/src/index.ts similarity index 100% rename from tools/linear/src/index.ts rename to sources/linear/src/index.ts diff --git a/tools/linear/src/linear.ts b/sources/linear/src/linear.ts similarity index 65% rename from tools/linear/src/linear.ts rename to sources/linear/src/linear.ts index 010eeb1..80d431b 100644 --- a/tools/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -7,35 +7,36 @@ import type { import { LinearWebhookClient } from "@linear/sdk/webhooks"; import { + type Action, + ActionType, type Link, - LinkType, - ActivityMeta, - ActivityType, - type NewActivity, - type NewActivityWithNotes, - type NewNote, - Serializable, - type SyncToolOptions, + ThreadMeta, + type NewLinkWithNotes, } from "@plotday/twister"; -import type { - Project, - ProjectSyncOptions, - ProjectTool, -} from "@plotday/twister/common/projects"; import type { NewContact } from "@plotday/twister/plot"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; -import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; +import { Source } from "@plotday/twister/source"; +import type { ToolBuilder } from "@plotday/twister/tool"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; +type Project = { + id: string; + name: string; + description: string | null; + key: string | null; +}; + +type ProjectSyncOptions = { + timeMin?: Date; +}; + // Cloudflare Workers provides Buffer global declare const Buffer: { from( @@ -52,42 +53,44 @@ type SyncState = { }; /** - * Linear project management tool + * Linear project management source * - * Implements the ProjectTool interface for syncing Linear teams and issues - * with Plot activities. + * Implements the ProjectSource interface for syncing Linear teams and issues + * with Plot threads. */ -export class Linear extends Tool implements ProjectTool { +export class Linear extends Source { static readonly PROVIDER = AuthProvider.Linear; static readonly SCOPES = ["read", "write", "admin"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; + + readonly provider = AuthProvider.Linear; + readonly scopes = Linear.SCOPES; + readonly linkTypes = [ + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/logos/linear-icon.svg", + logoDark: "https://api.iconify.design/simple-icons/linear.svg?color=%235E6AD2", + logoMono: "https://api.iconify.design/simple-icons/linear.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ]; build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: Linear.PROVIDER, - scopes: Linear.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://api.linear.app/*"] }), - callbacks: build(Callbacks), tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), }; } /** - * Create Linear API client using syncable-based auth + * Create Linear API client using channel-based auth */ private async getClient(projectId: string): Promise { - const token = await this.tools.integrations.get(Linear.PROVIDER, projectId); + const token = await this.tools.integrations.get(projectId); if (!token) { throw new Error("No Linear authentication token available"); } @@ -95,12 +98,12 @@ export class Linear extends Tool implements ProjectTool { } /** - * Returns available Linear teams as syncable resources. + * Returns available Linear teams as channel resources. */ - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const client = new LinearClient({ accessToken: token.token }); const teams = await client.teams(); return teams.nodes.map((team) => ({ @@ -110,59 +113,63 @@ export class Linear extends Tool implements ProjectTool { } /** - * Called when a syncable resource is enabled for syncing. - * Creates callback tokens from options and auto-starts sync. + * Called when a channel resource is enabled for syncing. + * Sets up webhook 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); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and begin batch sync - await this.setupLinearWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupLinearWebhook(channel.id); + await this.startBatchSync(channel.id); } /** - * Called when a syncable resource is disabled. - * Runs the disable callback, then cleans up all stored state. + * Called when a channel resource is disabled. + * Stops sync and archives all threads from this channel. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.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}`); - } + /** + * Called when a link's status is changed from the Flutter app. + * Maps the link status back to a Linear workflow state. + */ + async onLinkUpdated(link: Link): Promise { + const issueId = link.meta?.linearId as string | undefined; + if (!issueId) return; - // 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}`); + const projectId = link.meta?.projectId as string | undefined; + if (!projectId) return; + + const client = await this.getClient(projectId); + const issue = await client.issue(issueId); + const team = await issue.team; + if (!team) return; + + const states = await team.states(); + let targetState; + + if (link.status === "done") { + targetState = states.nodes.find( + (s) => + s.name === "Done" || + s.name === "Completed" || + s.type === "completed" + ); + } else { + // "open" or any non-done status -> reopen + targetState = states.nodes.find( + (s) => + s.name === "Todo" || s.name === "Backlog" || s.type === "unstarted" + ); } - await this.clear(`sync_enabled_${syncable.id}`); + if (targetState) { + await client.updateIssue(issueId, { stateId: targetState.id }); + } } /** @@ -183,28 +190,16 @@ export class Linear extends Tool implements ProjectTool { /** * Start syncing issues from a Linear team */ - async startSync< - TArgs extends Serializable[], - TCallback extends (issue: NewActivityWithNotes, ...args: TArgs) => any - >( + async startSync( options: { projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs + } & ProjectSyncOptions ): Promise { const { projectId, timeMin } = options; // Setup webhook for real-time updates await this.setupLinearWebhook(projectId); - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${projectId}`, callbackToken); - // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); } @@ -291,14 +286,6 @@ export class Linear extends Tool implements ProjectTool { throw new Error(`Sync state not found for project ${projectId}`); } - // Retrieve callback token from storage - const callbackToken = await this.get( - `item_callback_${projectId}` - ); - if (!callbackToken) { - throw new Error(`Callback token not found for project ${projectId}`); - } - const client = await this.getClient(projectId); const team = await client.team(projectId); @@ -317,21 +304,21 @@ export class Linear extends Tool implements ProjectTool { // Process each issue for (const issue of issuesConnection.nodes) { - const activity = await this.convertIssueToActivity( + const link = await this.convertIssueToLink( issue, projectId, state.initialSync ); - if (activity) { + if (link) { // Inject sync metadata for bulk operations (e.g. disable filtering) - activity.meta = { - ...activity.meta, + link.channelId = projectId; + link.meta = { + ...link.meta, syncProvider: "linear", syncableId: projectId, }; - // Execute the callback using the callback token - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(link); } } @@ -358,13 +345,13 @@ export class Linear extends Tool implements ProjectTool { } /** - * Convert a Linear issue to a NewActivityWithNotes + * Convert a Linear issue to a NewLinkWithNotes */ - private async convertIssueToActivity( + private async convertIssueToLink( issue: Issue, projectId: string, initialSync: boolean - ): Promise { + ): Promise { let creator, assignee, comments; try { @@ -420,11 +407,11 @@ export class Linear extends Tool implements ProjectTool { const description = issue.description || ""; const hasDescription = description.trim().length > 0; - // Build activity-level links - const activityLinks: Link[] = []; + // Build thread-level actions + const threadActions: Action[] = []; if (issue.url) { - activityLinks.push({ - type: LinkType.external, + threadActions.push({ + type: ActionType.external, title: `Open in Linear`, url: issue.url, }); @@ -468,47 +455,43 @@ export class Linear extends Tool implements ProjectTool { }); } - const activity: NewActivityWithNotes = { + const newLink: NewLinkWithNotes = { source: `linear:issue:${issue.id}`, - type: ActivityType.Action, + type: "issue", title: issue.title, created: issue.createdAt, author: authorContact, assignee: assigneeContact ?? null, - done: issue.completedAt ?? issue.canceledAt ?? null, - start: assigneeContact ? undefined : null, - order: issue.sortOrder, + status: issue.completedAt || issue.canceledAt ? "done" : "open", meta: { linearId: issue.id, projectId, }, - links: activityLinks.length > 0 ? activityLinks : undefined, + actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: issue.url ?? null, notes, preview: hasDescription ? description : null, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only }; - return activity; + return newLink; } /** - * Update issue with new values - * - * @param activity - The updated activity + * Update issue with new values from the app */ async updateIssue( - activity: import("@plotday/twister").Activity + link: import("@plotday/twister").Link ): Promise { - // Get the Linear issue ID from activity meta - const issueId = activity.meta?.linearId as string | undefined; + const issueId = link.meta?.linearId as string | undefined; if (!issueId) { - throw new Error("Linear issue ID not found in activity meta"); + throw new Error("Linear issue ID not found in link meta"); } - const projectId = activity.meta?.projectId as string | undefined; + const projectId = link.meta?.projectId as string | undefined; if (!projectId) { - throw new Error("Project ID not found in activity meta"); + throw new Error("Project ID not found in link meta"); } const client = await this.getClient(projectId); @@ -516,42 +499,27 @@ export class Linear extends Tool implements ProjectTool { const updateFields: any = {}; // Handle title - if (activity.title !== null) { - updateFields.title = activity.title; - } - - // Handle order -> sortOrder - if (activity.order !== undefined && activity.order !== null) { - updateFields.sortOrder = activity.order; + if (link.title) { + updateFields.title = link.title; } // Handle assignee - map Plot actor to Linear user via email lookup - const currentAssigneeActorId = activity.assignee?.id || null; - - if (!currentAssigneeActorId) { + if (!link.assignee) { updateFields.assigneeId = null; } else { - const actors = await this.tools.plot.getActors([currentAssigneeActorId]); - const actor = actors[0]; - const email = actor?.email; - + const email = link.assignee.email; if (email) { - // Check cache first let linearUserId = await this.get(`linear_user:${email}`); - if (!linearUserId) { - // Query Linear for user by email const users = await client.users({ filter: { email: { eq: email } }, }); const linearUser = users.nodes[0]; - if (linearUser) { linearUserId = linearUser.id; await this.set(`linear_user:${email}`, linearUserId); } } - if (linearUserId) { updateFields.assigneeId = linearUserId; } else { @@ -561,33 +529,30 @@ export class Linear extends Tool implements ProjectTool { } } else { console.warn( - `No email found for actor ${currentAssigneeActorId}, skipping assignee update` + `No email found for assignee actor, skipping assignee update` ); } } - // Handle state based on start + done combination + // Handle state based on link status and assignee const team = await issue.team; if (team) { const states = await team.states(); let targetState; - // Determine target state based on combination - if (activity.type === ActivityType.Action && activity.done !== null) { - // Completed + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed" || link.status === "resolved"; + if (isDone) { targetState = states.nodes.find( (s) => s.name === "Done" || s.name === "Completed" || s.type === "completed" ); - } else if (activity.start !== null) { - // In Progress (has start date, not done) + } else if (link.assignee) { targetState = states.nodes.find( (s) => s.name === "In Progress" || s.type === "started" ); } else { - // Backlog/Todo (no start date, not done) targetState = states.nodes.find( (s) => s.name === "Todo" || s.name === "Backlog" || s.type === "unstarted" @@ -599,7 +564,6 @@ export class Linear extends Tool implements ProjectTool { } } - // Apply updates if any fields changed if (Object.keys(updateFields).length > 0) { await client.updateIssue(issueId, updateFields); } @@ -608,21 +572,21 @@ export class Linear extends Tool implements ProjectTool { /** * Add a comment to a Linear issue * - * @param meta - Activity metadata containing linearId and projectId + * @param meta - Thread metadata containing linearId and projectId * @param body - Comment text (markdown supported) */ async addIssueComment( - meta: ActivityMeta, + meta: ThreadMeta, body: string ): Promise { const issueId = meta.linearId as string | undefined; if (!issueId) { - throw new Error("Linear issue ID not found in activity meta"); + throw new Error("Linear issue ID not found in thread meta"); } const projectId = meta.projectId as string | undefined; if (!projectId) { - throw new Error("Project ID not found in activity meta"); + throw new Error("Project ID not found in thread meta"); } const client = await this.getClient(projectId); @@ -681,27 +645,16 @@ export class Linear extends Tool implements ProjectTool { return; } - // Get callback token - const callbackToken = await this.get( - `item_callback_${projectId}` - ); - if (!callbackToken) { - console.warn("No callback token found for project:", projectId); - return; - } - // Route by webhook type if (payload.type === "Issue") { await this.handleIssueWebhook( payload as EntityWebhookPayloadWithIssueData, - projectId, - callbackToken + projectId ); } else if (payload.type === "Comment") { await this.handleCommentWebhook( payload as EntityWebhookPayloadWithCommentData, - projectId, - callbackToken + projectId ); } } @@ -711,8 +664,7 @@ export class Linear extends Tool implements ProjectTool { */ private async handleIssueWebhook( payload: EntityWebhookPayloadWithIssueData, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const issue = payload.data; const issueId = issue.id; @@ -728,7 +680,7 @@ export class Linear extends Tool implements ProjectTool { const creator = issue.creator || null; const assignee = issue.assignee || null; - // Build activity update with only issue fields (no notes) + // Build thread update with only issue fields (no notes) let authorContact: NewContact | undefined; let assigneeContact: NewContact | undefined; @@ -747,22 +699,17 @@ export class Linear extends Tool implements ProjectTool { }; } - // Create partial activity update (no notes = doesn't touch existing notes) + // Create partial link update (empty notes = doesn't touch existing notes) // Note: webhook payload dates are JSON strings, must convert to Date - const activity: NewActivity = { + const newLink: NewLinkWithNotes = { source: `linear:issue:${issue.id}`, - type: ActivityType.Action, + type: "issue", title: issue.title, created: new Date(issue.createdAt), author: authorContact, assignee: assigneeContact ?? null, - done: issue.completedAt - ? new Date(issue.completedAt) - : issue.canceledAt - ? new Date(issue.canceledAt) - : null, - start: assigneeContact ? undefined : null, - order: issue.sortOrder, + status: issue.completedAt || issue.canceledAt ? "done" : "open", + channelId: projectId, meta: { linearId: issue.id, projectId, @@ -770,9 +717,10 @@ export class Linear extends Tool implements ProjectTool { syncableId: projectId, }, preview: issue.description || null, + notes: [], }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(newLink); } /** @@ -780,8 +728,7 @@ export class Linear extends Tool implements ProjectTool { */ private async handleCommentWebhook( payload: EntityWebhookPayloadWithCommentData, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const comment = payload.data; const commentId = comment.id; @@ -807,21 +754,22 @@ export class Linear extends Tool implements ProjectTool { }; } - // Create activity update with single comment note - // Type is required by NewActivity, but upsert will use existing activity's type - const activitySource = `linear:issue:${issueId}`; - const note: NewNote = { - key: `comment-${comment.id}`, - activity: { source: activitySource }, - content: comment.body, - created: new Date(comment.createdAt), - author: commentAuthor, - }; - - const activity: NewActivityWithNotes = { - source: activitySource, - type: ActivityType.Action, // Required field (will match existing activity) - notes: [note], + // Create thread update with single comment note + // Type is required by NewThread, but upsert will use existing thread's type + const threadSource = `linear:issue:${issueId}`; + const newLink: NewLinkWithNotes = { + source: threadSource, + type: "issue", + title: issueId, // Placeholder; upsert by source will preserve existing title + notes: [ + { + key: `comment-${comment.id}`, + content: comment.body, + created: new Date(comment.createdAt), + author: commentAuthor, + } as any, + ], + channelId: projectId, meta: { linearId: issueId, projectId, @@ -830,7 +778,7 @@ export class Linear extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.integrations.saveLink(newLink); } /** @@ -852,22 +800,6 @@ export class Linear extends Tool implements ProjectTool { // Cleanup webhook secret await this.clear(`webhook_secret_${projectId}`); - // 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/jira/tsconfig.json b/sources/linear/tsconfig.json similarity index 100% rename from tools/jira/tsconfig.json rename to sources/linear/tsconfig.json diff --git a/tools/outlook-calendar/CHANGELOG.md b/sources/outlook-calendar/CHANGELOG.md similarity index 100% rename from tools/outlook-calendar/CHANGELOG.md rename to sources/outlook-calendar/CHANGELOG.md diff --git a/tools/outlook-calendar/LICENSE b/sources/outlook-calendar/LICENSE similarity index 100% rename from tools/outlook-calendar/LICENSE rename to sources/outlook-calendar/LICENSE diff --git a/tools/outlook-calendar/README.md b/sources/outlook-calendar/README.md similarity index 100% rename from tools/outlook-calendar/README.md rename to sources/outlook-calendar/README.md diff --git a/tools/outlook-calendar/package.json b/sources/outlook-calendar/package.json similarity index 72% rename from tools/outlook-calendar/package.json rename to sources/outlook-calendar/package.json index 347016a..58c1557 100644 --- a/tools/outlook-calendar/package.json +++ b/sources/outlook-calendar/package.json @@ -1,7 +1,9 @@ { - "name": "@plotday/tool-outlook-calendar", + "name": "@plotday/source-outlook-calendar", + "plotTwistId": "cf518010-30c1-4594-b3df-295a19d65459", "displayName": "Outlook Calendar", - "description": "Sync with Microsoft Outlook Calendar", + "description": "Events from Outlook Calendar", + "logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.14.1", @@ -15,14 +17,12 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" @@ -47,8 +47,5 @@ "outlook", "microsoft", "calendar" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/outlook-calendar/src/graph-api.ts b/sources/outlook-calendar/src/graph-api.ts similarity index 86% rename from tools/outlook-calendar/src/graph-api.ts rename to sources/outlook-calendar/src/graph-api.ts index 86342a1..f1598bc 100644 --- a/tools/outlook-calendar/src/graph-api.ts +++ b/sources/outlook-calendar/src/graph-api.ts @@ -1,6 +1,24 @@ -import type { NewActivity } from "@plotday/twister"; -import { ActivityType } from "@plotday/twister"; -import type { Calendar } from "@plotday/twister/common/calendar"; +import type { ThreadMeta } from "@plotday/twister"; +import type { NewSchedule, NewScheduleOccurrence } from "@plotday/twister/schedule"; +type Calendar = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +/** + * Intermediate type returned by transformOutlookEvent. + * Contains the data extracted from an Outlook event that will be + * assembled into a NewLinkWithNotes by the caller. + */ +export type TransformedOutlookEvent = { + title: string; + meta: ThreadMeta; + created?: Date; + schedules?: Array>; + scheduleOccurrences?: NewScheduleOccurrence[]; +}; /** * Microsoft Graph API event type @@ -462,12 +480,13 @@ export function parseOutlookRecurrenceCount( } /** - * Transform Microsoft Graph event to Plot Activity + * Transform Microsoft Graph event into an intermediate representation. + * The caller assembles this into a NewLinkWithNotes. */ export function transformOutlookEvent( event: OutlookEvent, calendarId: string -): NewActivity | null { +): TransformedOutlookEvent | null { // Skip deleted events if (event["@removed"]) { return null; @@ -488,15 +507,12 @@ export function transformOutlookEvent( // Handle cancelled events differently const isCancelled = event.isCancelled === true; - const shared = { - source: `outlook-calendar:${event.id}`, + const result: TransformedOutlookEvent = { title: isCancelled ? event.subject ? `Cancelled: ${event.subject}` : "Cancelled Event" : event.subject || "", - start: isCancelled ? null : start, - end: isCancelled ? null : end, meta: { eventId: event.id, calendarId: calendarId, @@ -506,43 +522,53 @@ export function transformOutlookEvent( originalStart: start instanceof Date ? start.toISOString() : start, originalEnd: end instanceof Date ? end.toISOString() : end, }, - } as const; + created: event.createdDateTime + ? new Date(event.createdDateTime) + : undefined, + }; - const activity: NewActivity = - isCancelled || isAllDay - ? { type: ActivityType.Note, ...shared } - : { type: ActivityType.Event, ...shared }; + // Build the primary schedule from start/end + if (!isCancelled && start) { + const schedule: Omit = { + start, + end: end ?? null, + }; - // Handle recurrence for master events (not instances or exceptions) - if (event.recurrence && event.type === "seriesMaster") { - activity.recurrenceRule = parseOutlookRRule(event.recurrence); + // Handle recurrence for master events (not instances or exceptions) + if (event.recurrence && event.type === "seriesMaster") { + schedule.recurrenceRule = parseOutlookRRule(event.recurrence) ?? null; + + // Parse recurrence count (takes precedence over UNTIL) + const recurrenceCount = parseOutlookRecurrenceCount(event.recurrence); + if (recurrenceCount) { + schedule.recurrenceCount = recurrenceCount; + } else { + // Parse recurrence end date if no count + const recurrenceUntil = parseOutlookRecurrenceEnd(event.recurrence); + if (recurrenceUntil) { + schedule.recurrenceUntil = recurrenceUntil; + } + } - // Parse recurrence count (takes precedence over UNTIL) - const recurrenceCount = parseOutlookRecurrenceCount(event.recurrence); - if (recurrenceCount) { - activity.recurrenceCount = recurrenceCount; - } else { - // Parse recurrence end date if no count - const recurrenceUntil = parseOutlookRecurrenceEnd(event.recurrence); - if (recurrenceUntil) { - activity.recurrenceUntil = recurrenceUntil; + // Parse exception dates (currently not available from Graph API directly) + const exdates = parseOutlookExDates(event.recurrence); + if (exdates.length > 0) { + schedule.recurrenceExdates = exdates; } } - // Parse exception dates (currently not available from Graph API directly) - const exdates = parseOutlookExDates(event.recurrence); - if (exdates.length > 0) { - activity.recurrenceExdates = exdates; - } + result.schedules = [schedule]; // Parse RDATEs (additional occurrence dates not in the recurrence rule) // Note: Microsoft Graph API doesn't support RDATE, so this will always be empty - const rdates = parseOutlookRDates(event.recurrence); - if (rdates.length > 0) { - activity.occurrences = rdates.map((rdate) => ({ - occurrence: rdate, - start: rdate, - })); + if (event.recurrence && event.type === "seriesMaster") { + const rdates = parseOutlookRDates(event.recurrence); + if (rdates.length > 0) { + result.scheduleOccurrences = rdates.map((rdate) => ({ + occurrence: rdate, + start: rdate, + })); + } } } @@ -554,17 +580,13 @@ export function transformOutlookEvent( event.seriesMasterId && event.originalStart ) { - // This is a modified instance of a recurring event - // Store the exception info in metadata - if (activity.meta) { - activity.meta.seriesMasterId = event.seriesMasterId; - activity.meta.originalStartDate = new Date( - event.originalStart - ).toISOString(); - } + result.meta.seriesMasterId = event.seriesMasterId; + result.meta.originalStartDate = new Date( + event.originalStart + ).toISOString(); } - return activity; + return result; } /** diff --git a/tools/outlook-calendar/src/index.ts b/sources/outlook-calendar/src/index.ts similarity index 100% rename from tools/outlook-calendar/src/index.ts rename to sources/outlook-calendar/src/index.ts diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts similarity index 55% rename from tools/outlook-calendar/src/outlook-calendar.ts rename to sources/outlook-calendar/src/outlook-calendar.ts index 378f15c..0ec886e 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -1,42 +1,38 @@ import { - type Activity, - type Link, - LinkType, - type ActivityOccurrence, - ActivityType, + type Action, + ActionType, type ActorId, ConferencingProvider, type ContentType, - type NewActivityOccurrence, - type NewActivityWithNotes, - type NewActor, + type NewLinkWithNotes, type NewContact, - type NewNote, - Serializable, - type SyncToolOptions, - Tag, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; import type { - Calendar, - CalendarTool, - SyncOptions, -} from "@plotday/twister/common/calendar"; -import { type Callback } from "@plotday/twister/tools/callbacks"; + NewScheduleContact, + NewScheduleOccurrence, +} from "@plotday/twister/schedule"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ActivityAccess, - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; + +type Calendar = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +type SyncOptions = { + timeMin?: Date | null; + timeMax?: Date | null; +}; import { GraphApi, @@ -101,7 +97,7 @@ type WatchState = { * build(build: ToolBuilder) { * return { * outlookCalendar: build(OutlookCalendar), - * plot: build(Plot, { activity: { access: ActivityAccess.Create } }), + * plot: build(Plot, { thread: { access: ThreadAccess.Create } }), * }; * } * @@ -110,89 +106,56 @@ type WatchState = { * } * ``` */ -export class OutlookCalendar - extends Tool - implements CalendarTool -{ +export class OutlookCalendar extends Source { static readonly PROVIDER = AuthProvider.Microsoft; static readonly SCOPES = ["https://graph.microsoft.com/calendars.readwrite"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; + + readonly provider = AuthProvider.Microsoft; + readonly scopes = OutlookCalendar.SCOPES; + readonly linkTypes = [{ type: "event", label: "Event", logo: "https://api.iconify.design/logos/microsoft-icon.svg", logoDark: "https://api.iconify.design/simple-icons/microsoftoutlook.svg?color=%230078D4", logoMono: "https://api.iconify.design/simple-icons/microsoftoutlook.svg" }]; build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: OutlookCalendar.PROVIDER, - scopes: OutlookCalendar.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://graph.microsoft.com/*"] }), - plot: build(Plot, { - contact: { access: ContactAccess.Write }, - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, - }, - }), }; } /** - * Returns available Outlook calendars as syncable resources. + * Returns available Outlook calendars as channel resources. */ - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const api = new GraphApi(token.token); const calendars = await api.getCalendars(); return calendars.map((c) => ({ id: c.id, title: c.name })); } /** - * Called when a syncable calendar is enabled for syncing. - * Creates callback tokens from options and auto-starts sync. + * Called when a channel calendar is enabled for syncing. + * Auto-starts sync for the calendar. */ - 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); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup watch and queue first batch - await this.setupOutlookWatch(syncable.id); + await this.setupOutlookWatch(channel.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, + await this.set(`outlook_sync_state_${channel.id}`, { + calendarId: channel.id, min, sequence: 1, } as SyncState); const syncCallback = await this.callback( this.syncOutlookBatch, - syncable.id, + channel.id, true, // initialSync 1 // batchNumber ); @@ -200,38 +163,16 @@ export class OutlookCalendar } /** - * Called when a syncable calendar is disabled. - * Cleans up callback tokens and stops sync. + * Called when a channel calendar is disabled. + * Stops sync and archives threads from this channel. */ - 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}`); - } + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.id}`); } private async getApi(calendarId: string): Promise { - const token = await this.tools.integrations.get( - OutlookCalendar.PROVIDER, - calendarId - ); + const token = await this.tools.integrations.get(calendarId); if (!token) { throw new Error("No Microsoft authentication token available"); } @@ -268,23 +209,12 @@ export class OutlookCalendar return await api.getCalendars(); } - async startSync< - TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any - >( + async startSync( options: { calendarId: string; } & SyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { calendarId, timeMin, timeMax } = options; - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${calendarId}`, callbackToken); // Setup webhook for this calendar await this.setupOutlookWatch(calendarId); @@ -402,15 +332,6 @@ export class OutlookCalendar await this.ensureUserIdentity(calendarId); } - // Hoist callback token retrieval outside event loop - saves N-1 subrequests - const callbackToken = await this.get( - `item_callback_${calendarId}` - ); - if (!callbackToken) { - console.warn("No callback token found, skipping event processing"); - return; - } - // Load existing sync state const savedState = await this.get( `outlook_sync_state_${calendarId}` @@ -425,12 +346,11 @@ export class OutlookCalendar // Process ONE batch (single API page) instead of while loop const result = await syncOutlookCalendar(api, calendarId, syncState); - // Process events with hoisted callback token + // Process events await this.processOutlookEvents( result.events, calendarId, - initialSync, - callbackToken + initialSync ); console.log( @@ -466,19 +386,17 @@ export class OutlookCalendar /** * Process Outlook events from a sync batch. - * Extracted to receive hoisted callback token and reduce subrequests. */ private async processOutlookEvents( events: import("./graph-api").OutlookEvent[], calendarId: string, - initialSync: boolean, - callbackToken: Callback + initialSync: boolean ): Promise { for (const outlookEvent of events) { try { // Handle deleted events if (outlookEvent["@removed"]) { - // On initial sync, skip creating activities for already-deleted events + // On initial sync, skip creating threads for already-deleted events if (initialSync) { continue; } @@ -486,32 +404,33 @@ export class OutlookCalendar const source = `outlook-calendar:${outlookEvent.id}`; // Create cancellation note - const cancelNote: NewNote = { - activity: { source }, - key: "cancellation", + const cancelNote = { + key: "cancellation" as const, content: "This event was cancelled.", - contentType: "text", + contentType: "text" as const, created: outlookEvent.lastModifiedDateTime ? new Date(outlookEvent.lastModifiedDateTime) : new Date(), }; - // Convert to Note type with blocked tag and cancellation note - const activity: NewActivityWithNotes = { - type: ActivityType.Note, + // Convert to link with cancellation note + const link: NewLinkWithNotes = { + type: "event", + title: "Cancelled Event", created: outlookEvent.createdDateTime ? new Date(outlookEvent.createdDateTime) : new Date(), preview: "Cancelled", source, + channelId: calendarId, 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 }; - // Send activity update - await this.tools.callbacks.run(callbackToken, activity); + // Send link update + await this.tools.integrations.saveLink(link); continue; } @@ -545,17 +464,16 @@ export class OutlookCalendar await this.processEventInstance( outlookEvent, calendarId, - initialSync, - callbackToken + initialSync ); continue; } - // Transform the Outlook event to a Plot activity (master or single events) - const activity = transformOutlookEvent(outlookEvent, calendarId); + // Transform the Outlook event to a Plot thread (master or single events) + const threadData = transformOutlookEvent(outlookEvent, calendarId); // Skip deleted events (transformOutlookEvent returns null for deleted) - if (!activity) { + if (!threadData) { continue; } @@ -564,57 +482,33 @@ export class OutlookCalendar continue; } - // For recurring events, DON'T add tags at series level - // Tags (RSVPs) should be per-occurrence via the occurrences array - // For non-recurring events, add tags normally - let tags: Partial> | null = null; - if (validAttendees.length > 0 && !activity.recurrenceRule) { - const attendTags: NewActor[] = []; - const skipTags: NewActor[] = []; - const undecidedTags: NewActor[] = []; - - // Iterate through valid attendees and group by response status - validAttendees.forEach((attendee) => { - const newActor: NewActor = { + // For recurring events, DON'T add contacts at series level + // Contacts (RSVPs) should be per-occurrence via the scheduleOccurrences array + // For non-recurring events, add contacts to the schedule + const hasRecurrence = !!threadData.schedules?.[0]?.recurrenceRule; + if (validAttendees.length > 0 && !hasRecurrence && threadData.schedules?.[0]) { + const contacts: NewScheduleContact[] = validAttendees.map((attendee) => ({ + contact: { email: attendee.emailAddress!.address!, name: attendee.emailAddress!.name, - }; - - const response = attendee.status?.response; - if (response === "accepted") { - attendTags.push(newActor); - } else if (response === "declined") { - skipTags.push(newActor); - } else if ( - response === "tentativelyAccepted" || - response === "none" || - response === "notResponded" - ) { - undecidedTags.push(newActor); - } - // organizer has no response status, so they won't get a tag - }); - - // Only set tags if we have at least one - if ( - attendTags.length > 0 || - skipTags.length > 0 || - undecidedTags.length > 0 - ) { - tags = {}; - if (attendTags.length > 0) tags[Tag.Attend] = attendTags; - if (skipTags.length > 0) tags[Tag.Skip] = skipTags; - if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; - } + }, + status: attendee.status?.response === "accepted" ? "attend" as const + : attendee.status?.response === "declined" ? "skip" as const + : null, + role: attendee.type === "required" ? "required" as const + : attendee.type === "optional" ? "optional" as const + : "required" as const, + })); + threadData.schedules[0].contacts = contacts; } - // Build links array for videoconferencing and calendar links - const links: Link[] = []; + // Build actions array for videoconferencing and calendar links + const actions: Action[] = []; // Add conferencing link if available if (outlookEvent.onlineMeeting?.joinUrl) { - links.push({ - type: LinkType.conferencing, + actions.push({ + type: ActionType.conferencing, url: outlookEvent.onlineMeeting.joinUrl, provider: detectConferencingProvider( outlookEvent.onlineMeeting.joinUrl @@ -624,52 +518,52 @@ export class OutlookCalendar // Add calendar link if (outlookEvent.webLink) { - links.push({ - type: LinkType.external, + actions.push({ + type: ActionType.external, title: "View in Calendar", url: outlookEvent.webLink, }); } - // Create note with description (links moved to activity level) - const notes: NewNote[] = []; + // Build description note if available const hasDescription = outlookEvent.body?.content && outlookEvent.body.content.trim().length > 0; - const hasLinks = links.length > 0; - - if (hasDescription) { - notes.push({ - activity: { - source: `outlook-calendar:${outlookEvent.id}`, - }, - key: "description", - content: outlookEvent.body!.content!, - contentType: (outlookEvent.body?.contentType === "html" - ? "html" - : "text") as ContentType, - }); - } - - // Build NewActivityWithNotes from the transformed activity - const activityWithNotes: NewActivityWithNotes = { - ...activity, + const hasActions = actions.length > 0; + + const descriptionNote = hasDescription ? { + key: "description", + content: outlookEvent.body!.content!, + contentType: (outlookEvent.body?.contentType === "html" + ? "html" + : "text") as ContentType, + } : null; + + // Build NewLinkWithNotes from the transformed thread data + const linkWithNotes: NewLinkWithNotes = { + source: `outlook-calendar:${outlookEvent.id}`, + type: "event", + title: threadData.title || "", + created: threadData.created, author: authorContact, + channelId: calendarId, meta: { - ...activity.meta, + ...threadData.meta, syncProvider: "microsoft", syncableId: calendarId, }, - tags: tags && Object.keys(tags).length > 0 ? tags : activity.tags, - links: hasLinks ? links : undefined, - notes, + sourceUrl: outlookEvent.webLink ?? null, + actions: hasActions ? actions : undefined, + notes: descriptionNote ? [descriptionNote] : [], preview: hasDescription ? outlookEvent.body!.content! : null, + schedules: threadData.schedules, + scheduleOccurrences: threadData.scheduleOccurrences, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only }; - // Call the event callback using hoisted token - await this.tools.callbacks.run(callbackToken, activityWithNotes); + // Save link - database handles upsert automatically + await this.tools.integrations.saveLink(linkWithNotes); } catch (error) { console.error(`Error processing event ${outlookEvent.id}:`, error); // Continue processing other events @@ -679,13 +573,12 @@ export class OutlookCalendar /** * Process a recurring event instance (occurrence or exception) from Outlook Calendar. - * This updates the master recurring activity with occurrence-specific data. + * This updates the master recurring thread with occurrence-specific data. */ private async processEventInstance( event: import("./graph-api").OutlookEvent, calendarId: string, - initialSync: boolean, - callbackToken: Callback + initialSync: boolean ): Promise { const originalStart = event.originalStart; if (!originalStart) { @@ -693,7 +586,7 @@ export class OutlookCalendar return; } - // The seriesMasterId points to the master activity + // The seriesMasterId points to the master thread if (!event.seriesMasterId) { console.warn(`No series master ID for instance: ${event.id}`); return; @@ -709,94 +602,82 @@ export class OutlookCalendar return; // Skip deleted events } - // Handle cancelled recurring instances by adding to recurrence exdates + // Handle cancelled recurring instances by archiving the occurrence if (event.isCancelled) { - const start = instanceData?.start ?? new Date(originalStart); - const end = instanceData?.end ?? null; + const cancelledOccurrence: NewScheduleOccurrence = { + occurrence: new Date(originalStart), + start: new Date(originalStart), + archived: true, + }; - const occurrenceUpdate = { - type: ActivityType.Event, + const occurrenceUpdate: NewLinkWithNotes = { + type: "event", + title: "", source: masterCanonicalUrl, + channelId: calendarId, meta: { syncProvider: "microsoft", syncableId: calendarId }, - start: start, - end: end, - addRecurrenceExdates: [new Date(originalStart)], + scheduleOccurrences: [cancelledOccurrence], + notes: [], }; - await this.tools.callbacks.run(callbackToken, occurrenceUpdate); + await this.tools.integrations.saveLink(occurrenceUpdate); return; } - // Determine RSVP status for attendees + // Build contacts from attendees for this occurrence const validAttendees = event.attendees?.filter( (att) => att.emailAddress?.address && att.type !== "resource" ) || []; - let tags: Partial> = {}; - if (validAttendees.length > 0) { - const attendTags: import("@plotday/twister").NewActor[] = []; - const skipTags: import("@plotday/twister").NewActor[] = []; - const undecidedTags: import("@plotday/twister").NewActor[] = []; - - validAttendees.forEach((attendee) => { - const newActor: import("@plotday/twister").NewActor = { - email: attendee.emailAddress!.address!, - name: attendee.emailAddress!.name, - }; - - const response = attendee.status?.response; - if (response === "accepted") { - attendTags.push(newActor); - } else if (response === "declined") { - skipTags.push(newActor); - } else if ( - response === "tentativelyAccepted" || - response === "none" || - response === "notResponded" - ) { - undecidedTags.push(newActor); - } - }); - - if (attendTags.length > 0) tags[Tag.Attend] = attendTags; - if (skipTags.length > 0) tags[Tag.Skip] = skipTags; - if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; - } - - // Build occurrence object - // Always include start to ensure upsert_activity can infer scheduling when - // creating a new master activity. Use instanceData.start if available (for - // rescheduled instances), otherwise fall back to originalStart. - const occurrenceStart = instanceData.start ?? new Date(originalStart); - - const occurrence: Omit = { + const contacts: NewScheduleContact[] | undefined = + validAttendees.length > 0 + ? validAttendees.map((attendee) => ({ + contact: { + email: attendee.emailAddress!.address!, + name: attendee.emailAddress!.name, + }, + status: attendee.status?.response === "accepted" ? "attend" as const + : attendee.status?.response === "declined" ? "skip" as const + : null, + role: attendee.type === "required" ? "required" as const + : attendee.type === "optional" ? "optional" as const + : "required" as const, + })) + : undefined; + + // Build schedule occurrence object + // Always include start to ensure upsert can infer scheduling when + // creating a new master thread. Use schedule start from instanceData if + // available (for rescheduled instances), otherwise fall back to originalStart. + const instanceSchedule = instanceData.schedules?.[0]; + const occurrenceStart = instanceSchedule?.start ?? new Date(originalStart); + + const occurrence: NewScheduleOccurrence = { occurrence: new Date(originalStart), start: occurrenceStart, - tags: Object.keys(tags).length > 0 ? tags : undefined, + contacts, ...(initialSync ? { unread: false } : {}), }; - // Add additional field overrides if present - if (instanceData.end !== undefined && instanceData.end !== null) { - occurrence.end = instanceData.end; + // Add end time override if present + if (instanceSchedule?.end !== undefined && instanceSchedule?.end !== null) { + occurrence.end = instanceSchedule.end; } - if (instanceData.title) occurrence.title = instanceData.title; - if (instanceData.meta) occurrence.meta = instanceData.meta; - - // Send occurrence data to the twist via callback - // The twist will decide whether to create or update the master activity - // Build a minimal NewActivity with source and occurrences - // The twist's createActivity will upsert the master activity - const occurrenceUpdate = { - type: ActivityType.Event, + // Send occurrence data via saveLink + // Build a minimal link with source and scheduleOccurrences + const occurrenceUpdate: NewLinkWithNotes = { + type: "event", + title: "", source: masterCanonicalUrl, + channelId: calendarId, meta: { syncProvider: "microsoft", syncableId: calendarId }, - occurrences: [occurrence], + scheduleOccurrences: [occurrence], + notes: [], }; - await this.tools.callbacks.run(callbackToken, occurrenceUpdate); + await this.tools.integrations.saveLink(occurrenceUpdate); } async onOutlookWebhook( @@ -845,174 +726,9 @@ export class OutlookCalendar await this.runTask(callback); } - async onActivityUpdated( - activity: Activity, - changes: { - tagsAdded: Record; - tagsRemoved: Record; - occurrence?: ActivityOccurrence; - } - ): Promise { - try { - // Only process calendar events - const source = activity.source; - if ( - !source || - typeof source !== "string" || - !source.startsWith("outlook-calendar:") - ) { - return; - } - - // Check if RSVP tags changed - const attendChanged = - Tag.Attend in changes.tagsAdded || Tag.Attend in changes.tagsRemoved; - const skipChanged = - Tag.Skip in changes.tagsAdded || Tag.Skip in changes.tagsRemoved; - const undecidedChanged = - Tag.Undecided in changes.tagsAdded || - Tag.Undecided in changes.tagsRemoved; - - if (!attendChanged && !skipChanged && !undecidedChanged) { - return; // No RSVP-related tag changes - } - - // Collect unique actor IDs from RSVP tag changes - const actorIds = new Set(); - for (const tag of [Tag.Attend, Tag.Skip, Tag.Undecided]) { - if (tag in changes.tagsAdded) { - for (const id of changes.tagsAdded[tag]) actorIds.add(id); - } - if (tag in changes.tagsRemoved) { - for (const id of changes.tagsRemoved[tag]) actorIds.add(id); - } - } - - // Determine new RSVP status based on most recent tag change - const hasAttend = - activity.tags?.[Tag.Attend] && activity.tags[Tag.Attend].length > 0; - const hasSkip = - activity.tags?.[Tag.Skip] && activity.tags[Tag.Skip].length > 0; - const hasUndecided = - activity.tags?.[Tag.Undecided] && - activity.tags[Tag.Undecided].length > 0; - - let newStatus: "accepted" | "declined" | "tentativelyAccepted"; - - // Priority: Attend > Skip > Undecided, using most recent from tagsAdded - if (hasAttend && (hasSkip || hasUndecided)) { - if (Tag.Attend in changes.tagsAdded) { - newStatus = "accepted"; - } else if (Tag.Skip in changes.tagsAdded) { - newStatus = "declined"; - } else if (Tag.Undecided in changes.tagsAdded) { - newStatus = "tentativelyAccepted"; - } else { - return; - } - } else if (hasSkip && hasUndecided) { - if (Tag.Skip in changes.tagsAdded) { - newStatus = "declined"; - } else if (Tag.Undecided in changes.tagsAdded) { - newStatus = "tentativelyAccepted"; - } else { - return; - } - } else if (hasAttend) { - newStatus = "accepted"; - } else if (hasSkip) { - newStatus = "declined"; - } else if (hasUndecided) { - newStatus = "tentativelyAccepted"; - } else { - // No RSVP tags present - reset to tentativelyAccepted (acts as "needsAction") - newStatus = "tentativelyAccepted"; - } - - // Extract calendar info from metadata - if (!activity.meta) { - console.error("[RSVP Sync] Missing activity metadata", { - activity_id: activity.id, - }); - return; - } - - const baseEventId = activity.meta.id; - const calendarId = activity.meta.calendarId; - - if ( - !baseEventId || - !calendarId || - typeof baseEventId !== "string" || - typeof calendarId !== "string" - ) { - console.error("[RSVP Sync] Missing or invalid event/calendar ID", { - has_event_id: !!baseEventId, - has_calendar_id: !!calendarId, - event_id_type: typeof baseEventId, - calendar_id_type: typeof calendarId, - }); - return; - } - - // Determine the event ID to update - // If this is an occurrence-level change, look up the instance ID - let eventId = baseEventId; - if (changes.occurrence) { - const occurrenceDate = - changes.occurrence.occurrence instanceof Date - ? changes.occurrence.occurrence - : new Date(changes.occurrence.occurrence); - - try { - const api = await this.getApi(calendarId as string); - const instanceId = await this.getEventInstanceIdWithApi( - api, - calendarId as string, - 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; - } - } - - // 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) { - await this.tools.integrations.actAs( - OutlookCalendar.PROVIDER, - actorId, - activity.id, - this.syncActorRSVP, - calendarId as string, - eventId, - newStatus, - actorId as string - ); - } - } catch (error) { - console.error("[RSVP Sync] Error in callback", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - activity_id: activity.id, - }); - } - } - /** - * 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. + * Sync a schedule contact RSVP change back to Outlook Calendar. + * Called via actAs() which provides the actor's auth token. */ async syncActorRSVP( token: AuthToken, @@ -1032,7 +748,6 @@ export class OutlookCalendar ); } catch (error) { console.error("[RSVP Sync] Failed to sync RSVP", { - actor_id: actorId, event_id: eventId, error: error instanceof Error ? error.message : String(error), }); @@ -1100,7 +815,7 @@ export class OutlookCalendar } /** - * Update RSVP status for a specific actor using a pre-authenticated GraphApi instance. + * Update RSVP status for the authenticated user on an Outlook Calendar event. * Looks up the actor's email from the Graph API to find the correct attendee. */ private async updateEventRSVPWithApi( @@ -1108,7 +823,7 @@ export class OutlookCalendar calendarId: string, eventId: string, status: "accepted" | "declined" | "tentativelyAccepted", - actorId: ActorId + _actorId: ActorId ): Promise { // First, fetch the current event to check if status already matches const resource = @@ -1133,9 +848,7 @@ export class OutlookCalendar const actorEmail = meData?.mail || meData?.userPrincipalName; if (!actorEmail) { - console.warn("[RSVP Sync] Could not determine actor email", { - actor_id: actorId, - }); + console.warn("[RSVP Sync] Could not determine actor email"); return; } @@ -1148,7 +861,6 @@ export class OutlookCalendar if (!actorAttendee) { console.warn("[RSVP Sync] Actor is not an attendee of this event", { - actor_id: actorId, event_id: eventId, }); return; diff --git a/tools/linear/tsconfig.json b/sources/outlook-calendar/tsconfig.json similarity index 100% rename from tools/linear/tsconfig.json rename to sources/outlook-calendar/tsconfig.json diff --git a/tools/slack/CHANGELOG.md b/sources/slack/CHANGELOG.md similarity index 100% rename from tools/slack/CHANGELOG.md rename to sources/slack/CHANGELOG.md diff --git a/tools/slack/package.json b/sources/slack/package.json similarity index 72% rename from tools/slack/package.json rename to sources/slack/package.json index af990c3..11ec707 100644 --- a/tools/slack/package.json +++ b/sources/slack/package.json @@ -1,7 +1,9 @@ { - "name": "@plotday/tool-slack", + "name": "@plotday/source-slack", + "plotTwistId": "d8cbc41f-71f5-4cb6-a0bb-3ade462c4084", "displayName": "Slack", - "description": "Sync with Slack channels and messages", + "description": "Messages from your Slack channels", + "logoUrl": "https://api.iconify.design/logos/slack-icon.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.9.1", @@ -15,14 +17,12 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" @@ -45,8 +45,5 @@ "tool", "slack", "messaging" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/tools/slack/src/index.ts b/sources/slack/src/index.ts similarity index 100% rename from tools/slack/src/index.ts rename to sources/slack/src/index.ts diff --git a/tools/slack/src/slack-api.ts b/sources/slack/src/slack-api.ts similarity index 93% rename from tools/slack/src/slack-api.ts rename to sources/slack/src/slack-api.ts index 5b831e5..aff0f38 100644 --- a/tools/slack/src/slack-api.ts +++ b/sources/slack/src/slack-api.ts @@ -1,6 +1,5 @@ -import { ActivityType } from "@plotday/twister"; import type { - NewActivityWithNotes, + NewLinkWithNotes, NewActor, } from "@plotday/twister/plot"; @@ -233,19 +232,19 @@ function formatSlackText(text: string): string { } /** - * Transforms a Slack message thread into a NewActivityWithNotes structure. - * The first message snippet becomes the Activity title, and each message becomes a Note. + * Transforms a Slack message thread into a NewLinkWithNotes structure. + * The first message snippet becomes the link title, and each message becomes a Note. */ export function transformSlackThread( messages: SlackMessage[], channelId: string -): NewActivityWithNotes { +): NewLinkWithNotes { const parentMessage = messages[0]; if (!parentMessage) { // Return empty structure for invalid threads return { - type: ActivityType.Note, + type: "message", title: "Empty thread", notes: [], }; @@ -258,16 +257,17 @@ export function transformSlackThread( // Canonical URL using Slack's app_redirect (works across all workspaces) const canonicalUrl = `https://slack.com/app_redirect?channel=${channelId}&message_ts=${threadTs}`; - // Create Activity - const activity: NewActivityWithNotes = { + // Create link + const thread: NewLinkWithNotes = { source: canonicalUrl, - type: ActivityType.Note, + type: "message", title, - start: new Date(parseFloat(parentMessage.ts) * 1000), + created: new Date(parseFloat(parentMessage.ts) * 1000), meta: { channelId: channelId, threadTs: threadTs, }, + sourceUrl: canonicalUrl, notes: [], preview: firstText || null, }; @@ -282,17 +282,16 @@ export function transformSlackThread( // Create NewNote with idempotent key const note = { - activity: { source: canonicalUrl }, key: message.ts, author: slackUserToNewActor(userId), content: text, mentions: mentions.length > 0 ? mentions : undefined, }; - activity.notes.push(note); + thread.notes!.push(note); } - return activity; + return thread; } /** diff --git a/tools/slack/src/slack.ts b/sources/slack/src/slack.ts similarity index 61% rename from tools/slack/src/slack.ts rename to sources/slack/src/slack.ts index 20deff9..9ab2f6f 100644 --- a/tools/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -1,30 +1,26 @@ import { - type ActivityFilter, - type NewActivityWithNotes, - Serializable, - type SyncToolOptions, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; -import { - type MessageChannel, - type MessageSyncOptions, - type MessagingTool, -} from "@plotday/twister/common/messaging"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ActivityAccess, - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; + +type MessageChannel = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +type MessageSyncOptions = { + timeMin?: Date; +}; import { SlackApi, @@ -36,7 +32,7 @@ import { } from "./slack-api"; /** - * Slack integration tool. + * Slack integration source. * * Provides seamless integration with Slack, supporting message * synchronization, real-time updates via webhooks, and thread handling. @@ -61,57 +57,9 @@ import { * - `chat:write` - Send messages as the bot * - `im:history` - Read direct messages with the bot * - `mpim:history` - Read group direct messages - * - * @example - * ```typescript - * class MessagesTwist extends Twist { - * private slack: Slack; - * - * constructor(id: string, tools: Tools) { - * super(); - * this.slack = tools.get(Slack); - * } - * - * async activate() { - * const authLink = await this.slack.requestAuth(this.onSlackAuth); - * - * await this.plot.createActivity({ - * type: ActivityType.Action, - * title: "Connect Slack", - * links: [authLink] - * }); - * } - * - * async onSlackAuth(auth: MessagingAuth) { - * const channels = await this.slack.getChannels(auth.authToken); - * - * // Start syncing a channel - * const general = channels.find(c => c.name === "general"); - * if (general) { - * await this.slack.startSync( - * auth.authToken, - * general.id, - * this.onSlackThread, - * { - * timeMin: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // Last 7 days - * } - * ); - * } - * } - * - * async onSlackThread(thread: ActivityWithNotes) { - * // Process Slack message thread - * // thread contains the Activity with thread.notes containing each message - * console.log(`Thread: ${thread.title}`); - * console.log(`${thread.notes.length} messages`); - * } - * } - * ``` */ -export class Slack extends Tool implements MessagingTool { +export class Slack extends Source { static readonly PROVIDER = AuthProvider.Slack; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; static readonly SCOPES = [ "channels:history", "channels:read", @@ -124,31 +72,21 @@ export class Slack extends Tool implements MessagingTool { "mpim:history", ]; + readonly provider = AuthProvider.Slack; + readonly scopes = Slack.SCOPES; + readonly linkTypes = [{ type: "message", label: "Message", logo: "https://api.iconify.design/logos/slack-icon.svg", logoMono: "https://api.iconify.design/simple-icons/slack.svg" }]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: Slack.PROVIDER, - scopes: Slack.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://slack.com/api/*"] }), - plot: build(Plot, { - contact: { access: ContactAccess.Write }, - activity: { access: ActivityAccess.Create }, - }), }; } - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const api = new SlackApi(token.token); const channels = await api.getChannels(); return channels @@ -156,85 +94,46 @@ export class Slack extends Tool implements MessagingTool { .map((c: SlackChannel) => ({ id: c.id, title: c.name })); } - 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); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and queue first batch - await this.setupChannelWebhook(syncable.id); + await this.setupChannelWebhook(channel.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 oldest = (timeMin.getTime() / 1000).toString(); const initialState: SyncState = { - channelId: syncable.id, + channelId: channel.id, oldest, }; - await this.set(`sync_state_${syncable.id}`, initialState); + await this.set(`sync_state_${channel.id}`, initialState); const syncCallback = await this.callback( this.syncBatch, 1, "full", - syncable.id + channel.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}`); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.id}`); } private async getApi(channelId: string): Promise { - const token = await this.tools.integrations.get(Slack.PROVIDER, channelId); + const token = await this.tools.integrations.get(channelId); if (!token) { throw new Error("No Slack authentication token available"); } return new SlackApi(token.token); } - async getChannels(channelId: string): Promise { + async listWorkspaceChannels(channelId: string): Promise { const api = await this.getApi(channelId); const channels = await api.getChannels(); @@ -250,25 +149,13 @@ export class Slack extends Tool implements MessagingTool { })); } - async startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any - >( + async startSync( options: { channelId: string; } & MessageSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { channelId } = options; - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${channelId}`, callbackToken); - // Setup webhook for this channel (Slack Events API) await this.setupChannelWebhook(channelId); @@ -305,9 +192,6 @@ export class Slack extends Tool implements MessagingTool { // Clear sync state await this.clear(`sync_state_${channelId}`); - - // Clear callback token - await this.clear(`item_callback_${channelId}`); } private async setupChannelWebhook(channelId: string): Promise { @@ -379,31 +263,23 @@ export class Slack extends Tool implements MessagingTool { threads: SlackMessage[][], channelId: string ): Promise { - const callbackToken = await this.get( - `item_callback_${channelId}` - ); - - if (!callbackToken) { - console.error("No callback token found for channel", channelId); - return; - } - for (const thread of threads) { try { - // Transform Slack thread to NewActivityWithNotes + // Transform Slack thread to NewLinkWithNotes const activityThread = transformSlackThread(thread, channelId); - if (activityThread.notes.length === 0) continue; + if (!activityThread.notes || activityThread.notes.length === 0) continue; // Inject sync metadata for the parent to identify the source + activityThread.channelId = 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); + // Save link directly via integrations + await this.tools.integrations.saveLink(activityThread); } catch (error) { console.error(`Failed to process thread:`, error); // Continue processing other threads diff --git a/tools/outlook-calendar/tsconfig.json b/sources/slack/tsconfig.json similarity index 100% rename from tools/outlook-calendar/tsconfig.json rename to sources/slack/tsconfig.json diff --git a/tools/github-issues/package.json b/tools/github-issues/package.json deleted file mode 100644 index 7c2d9b5..0000000 --- a/tools/github-issues/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@plotday/tool-github-issues", - "displayName": "GitHub Issues", - "description": "Sync with GitHub Issues", - "author": "Plot (https://plot.day)", - "license": "MIT", - "version": "0.1.0", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "@plotday/source": "./src/index.ts", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "tsc", - "clean": "rm -rf dist" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@octokit/rest": "^21.1.1" - }, - "devDependencies": { - "typescript": "^5.9.3" - }, - "repository": { - "type": "git", - "url": "https://github.com/plotday/plot.git", - "directory": "tools/github-issues" - }, - "homepage": "https://plot.day", - "bugs": { - "url": "https://github.com/plotday/plot/issues" - }, - "keywords": [ - "plot", - "tool", - "github", - "issues", - "project-management" - ], - "publishConfig": { - "access": "public" - } -} diff --git a/tools/github-issues/src/github-issues.ts b/tools/github-issues/src/github-issues.ts deleted file mode 100644 index 4df4a36..0000000 --- a/tools/github-issues/src/github-issues.ts +++ /dev/null @@ -1,868 +0,0 @@ -import { Octokit } from "@octokit/rest"; - -import { - type Link, - LinkType, - type ActivityMeta, - ActivityType, - type NewActivity, - type NewActivityWithNotes, - type NewNote, - type Serializable, - type SyncToolOptions, -} from "@plotday/twister"; -import type { - Project, - ProjectSyncOptions, - ProjectTool, -} from "@plotday/twister/common/projects"; -import type { NewContact } from "@plotday/twister/plot"; -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"; -import { Tasks } from "@plotday/twister/tools/tasks"; - -type SyncState = { - page: number; - batchNumber: number; - issuesProcessed: number; - initialSync: boolean; - phase: "open" | "closed"; -}; - -type RepoInfo = { - owner: string; - repo: string; - fullName: string; -}; - -/** - * GitHub Issues tool - * - * Implements the ProjectTool interface for syncing GitHub Issues - * with Plot activities. Explicitly filters out pull requests. - */ -export class GitHubIssues extends Tool implements ProjectTool { - static readonly PROVIDER = AuthProvider.GitHub; - static readonly SCOPES = ["repo"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; - - build(build: ToolBuilder) { - return { - integrations: build(Integrations, { - providers: [ - { - provider: GitHubIssues.PROVIDER, - scopes: GitHubIssues.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }, - ], - }), - network: build(Network, { urls: ["https://api.github.com/*"] }), - callbacks: build(Callbacks), - tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), - }; - } - - /** - * Create GitHub API client using syncable-based auth - */ - private async getClient(syncableId: string): Promise { - const token = await this.tools.integrations.get( - GitHubIssues.PROVIDER, - syncableId - ); - if (!token) { - throw new Error("No GitHub authentication token available"); - } - return new Octokit({ auth: token.token }); - } - - /** - * Parse owner and repo from stored repo info - */ - private async getRepoInfo(repoId: string): Promise { - const info = await this.get(`repo_info_${repoId}`); - if (!info) { - throw new Error(`Repo info not found for ${repoId}`); - } - return info; - } - - /** - * Returns available GitHub repos as syncable resources. - */ - async getSyncables( - _auth: Authorization, - token: AuthToken - ): Promise { - const octokit = new Octokit({ auth: token.token }); - const repos = await octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 100, - }); - return repos.data.map((repo) => ({ - id: repo.id.toString(), - title: repo.full_name, - })); - } - - /** - * Called when a syncable resource is enabled for syncing. - */ - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Store repo info (owner/repo) for API calls - // syncable.title is "owner/repo" (full_name) - const [owner, repo] = (syncable.title ?? "").split("/"); - if (owner && repo) { - await this.set(`repo_info_${syncable.id}`, { - owner, - repo, - fullName: syncable.title ?? "", - }); - } - - // 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: "github-issues", syncableId: syncable.id } } - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } - - // Auto-start sync: setup webhook and begin batch sync - await this.setupGitHubWebhook(syncable.id); - await this.startBatchSync(syncable.id); - } - - /** - * Called when a syncable resource is disabled. - */ - 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}`); - await this.clear(`repo_info_${syncable.id}`); - } - - /** - * Get list of GitHub repos (projects) - */ - async getProjects(projectId: string): Promise { - const octokit = await this.getClient(projectId); - const repos = await octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 100, - }); - - return repos.data.map((repo) => ({ - id: repo.id.toString(), - name: repo.full_name, - description: repo.description || null, - key: null, - })); - } - - /** - * Start syncing issues from a GitHub repo - */ - async startSync< - TArgs extends Serializable[], - TCallback extends ( - issue: NewActivityWithNotes, - ...args: TArgs - ) => any, - >( - options: { - projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs - ): Promise { - const { projectId } = options; - - // Setup webhook for real-time updates - await this.setupGitHubWebhook(projectId); - - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${projectId}`, callbackToken); - - // Start initial batch sync - await this.startBatchSync(projectId, options); - } - - /** - * Setup GitHub webhook for real-time updates - */ - private async setupGitHubWebhook(repoId: string): Promise { - try { - const webhookUrl = await this.tools.network.createWebhook( - {}, - this.onWebhook, - repoId - ); - - // Skip webhook setup for localhost (development mode) - if ( - webhookUrl.includes("localhost") || - webhookUrl.includes("127.0.0.1") - ) { - return; - } - - const octokit = await this.getClient(repoId); - const { owner, repo } = await this.getRepoInfo(repoId); - - // Generate webhook secret for signature verification - const webhookSecret = crypto.randomUUID(); - await this.set(`webhook_secret_${repoId}`, webhookSecret); - - const response = await octokit.rest.repos.createWebhook({ - owner, - repo, - config: { - url: webhookUrl, - content_type: "json", - secret: webhookSecret, - }, - events: ["issues", "issue_comment"], - }); - - if (response.data.id) { - await this.set(`webhook_id_${repoId}`, response.data.id); - } - } catch (error) { - console.error( - "Failed to set up GitHub webhook - real-time updates will not work:", - error - ); - } - } - - /** - * Initialize batch sync process - */ - private async startBatchSync( - repoId: string, - options?: ProjectSyncOptions - ): Promise { - await this.set(`sync_state_${repoId}`, { - page: 1, - batchNumber: 1, - issuesProcessed: 0, - initialSync: true, - phase: "open", - }); - - const batchCallback = await this.callback( - this.syncBatch, - repoId, - options ?? null - ); - await this.tools.tasks.runTask(batchCallback); - } - - /** - * Process a batch of issues - */ - private async syncBatch( - repoId: string, - options?: ProjectSyncOptions | null - ): Promise { - const state = await this.get(`sync_state_${repoId}`); - if (!state) { - throw new Error(`Sync state not found for repo ${repoId}`); - } - - const callbackToken = await this.get( - `item_callback_${repoId}` - ); - if (!callbackToken) { - throw new Error(`Callback token not found for repo ${repoId}`); - } - - const octokit = await this.getClient(repoId); - const { owner, repo, fullName } = await this.getRepoInfo(repoId); - - // Build request params based on phase - const params: Parameters[0] = { - owner, - repo, - state: state.phase, - per_page: 50, - page: state.page, - sort: "updated", - direction: "desc", - }; - - // For closed phase, only fetch recently closed (last 30 days) - if (state.phase === "closed") { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - params.since = thirtyDaysAgo.toISOString(); - } - - if (options?.timeMin) { - params.since = new Date(options.timeMin).toISOString(); - } - - const response = await octokit.rest.issues.listForRepo(params); - const issues = response.data; - - // Process each issue (filter out PRs) - let processedInBatch = 0; - for (const issue of issues) { - // Skip pull requests (GitHub returns PRs in issues endpoint) - if (issue.pull_request) continue; - - const activity = await this.convertIssueToActivity( - octokit, - issue, - repoId, - fullName, - state.initialSync - ); - - if (activity) { - activity.meta = { - ...activity.meta, - syncProvider: "github-issues", - syncableId: repoId, - }; - await this.tools.callbacks.run(callbackToken, activity); - processedInBatch++; - } - } - - // Check if there are more pages (GitHub returns less than per_page when done) - const hasMorePages = issues.length === 50; - - if (hasMorePages) { - await this.set(`sync_state_${repoId}`, { - page: state.page + 1, - batchNumber: state.batchNumber + 1, - issuesProcessed: state.issuesProcessed + processedInBatch, - initialSync: state.initialSync, - phase: state.phase, - }); - - const nextBatch = await this.callback( - this.syncBatch, - repoId, - options ?? null - ); - await this.tools.tasks.runTask(nextBatch); - } else if (state.phase === "open") { - // Move to closed phase - await this.set(`sync_state_${repoId}`, { - page: 1, - batchNumber: state.batchNumber + 1, - issuesProcessed: state.issuesProcessed + processedInBatch, - initialSync: state.initialSync, - phase: "closed", - }); - - const closedBatch = await this.callback( - this.syncBatch, - repoId, - options ?? null - ); - await this.tools.tasks.runTask(closedBatch); - } else { - // Both phases complete - await this.clear(`sync_state_${repoId}`); - } - } - - /** - * Convert a GitHub issue to a NewActivityWithNotes - */ - private async convertIssueToActivity( - octokit: Octokit, - issue: any, - repoId: string, - repoFullName: string, - initialSync: boolean - ): Promise { - // Build author contact (GitHub users may not have email) - let authorContact: NewContact | undefined; - if (issue.user) { - authorContact = { - email: issue.user.email || `${issue.user.login}@users.noreply.github.com`, - name: issue.user.login, - avatar: issue.user.avatar_url ?? undefined, - }; - } - - // Build assignee contact - let assigneeContact: NewContact | undefined; - const assignee = issue.assignees?.[0] || issue.assignee; - if (assignee) { - assigneeContact = { - email: assignee.email || `${assignee.login}@users.noreply.github.com`, - name: assignee.login, - avatar: assignee.avatar_url ?? undefined, - }; - } - - // Prepare description - const description = issue.body || ""; - const hasDescription = description.trim().length > 0; - - // Build activity-level links - const activityLinks: Link[] = []; - if (issue.html_url) { - activityLinks.push({ - type: LinkType.external, - title: "Open in GitHub", - url: issue.html_url, - }); - } - - // Build notes array (inline notes don't require the `activity` field) - const notes: any[] = []; - - notes.push({ - key: "description", - content: hasDescription ? description : null, - created: issue.created_at, - author: authorContact, - }); - - // Fetch comments - const [owner, repo] = repoFullName.split("/"); - try { - let commentPage = 1; - let hasMoreComments = true; - - while (hasMoreComments) { - const commentsResponse = await octokit.rest.issues.listComments({ - owner, - repo, - issue_number: issue.number, - per_page: 100, - page: commentPage, - }); - - for (const comment of commentsResponse.data) { - let commentAuthor: NewContact | undefined; - if (comment.user) { - commentAuthor = { - email: - comment.user.email || - `${comment.user.login}@users.noreply.github.com`, - name: comment.user.login, - avatar: comment.user.avatar_url ?? undefined, - }; - } - - notes.push({ - key: `comment-${comment.id}`, - content: comment.body ?? null, - created: new Date(comment.created_at), - author: commentAuthor, - }); - } - - hasMoreComments = commentsResponse.data.length === 100; - commentPage++; - } - } catch (error) { - console.error( - "Error fetching comments:", - error instanceof Error ? error.message : String(error) - ); - } - - const activity: NewActivityWithNotes = { - source: `github:issue:${repoId}:${issue.number}`, - type: ActivityType.Action, - title: issue.title, - created: issue.created_at, - author: authorContact, - assignee: assigneeContact ?? null, - done: issue.closed_at ?? null, - start: assigneeContact ? undefined : null, - meta: { - githubIssueNumber: issue.number, - githubRepoId: repoId, - githubRepoFullName: repoFullName, - projectId: repoId, - }, - links: activityLinks.length > 0 ? activityLinks : undefined, - notes, - preview: hasDescription ? description : null, - ...(initialSync ? { unread: false } : {}), - ...(initialSync ? { archived: false } : {}), - }; - - return activity; - } - - /** - * Update issue with new values - */ - async updateIssue( - activity: import("@plotday/twister").Activity - ): Promise { - const issueNumber = activity.meta?.githubIssueNumber as number | undefined; - if (!issueNumber) { - throw new Error("GitHub issue number not found in activity meta"); - } - - const repoFullName = activity.meta?.githubRepoFullName as - | string - | undefined; - if (!repoFullName) { - throw new Error("GitHub repo name not found in activity meta"); - } - - const projectId = activity.meta?.projectId as string | undefined; - if (!projectId) { - throw new Error("Project ID not found in activity meta"); - } - - const octokit = await this.getClient(projectId); - const [owner, repo] = repoFullName.split("/"); - - const updateFields: { - state?: "open" | "closed"; - assignees?: string[]; - } = {}; - - // Handle open/close status - if (activity.type === ActivityType.Action && activity.done !== null) { - updateFields.state = "closed"; - } else { - updateFields.state = "open"; - } - - // Handle assignee - if (activity.assignee) { - const actors = await this.tools.plot.getActors([activity.assignee.id]); - const actor = actors[0]; - if (actor?.name) { - // GitHub assignees use login names - updateFields.assignees = [actor.name]; - } - } else { - updateFields.assignees = []; - } - - if (Object.keys(updateFields).length > 0) { - await octokit.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - ...updateFields, - }); - } - } - - /** - * Add a comment to a GitHub issue - */ - async addIssueComment( - meta: ActivityMeta, - body: string - ): Promise { - const issueNumber = meta.githubIssueNumber as number | undefined; - if (!issueNumber) { - throw new Error("GitHub issue number not found in activity meta"); - } - - const repoFullName = meta.githubRepoFullName as string | undefined; - if (!repoFullName) { - throw new Error("GitHub repo name not found in activity meta"); - } - - const projectId = meta.projectId as string | undefined; - if (!projectId) { - throw new Error("Project ID not found in activity meta"); - } - - const octokit = await this.getClient(projectId); - const [owner, repo] = repoFullName.split("/"); - - const response = await octokit.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body, - }); - - if (response.data.id) { - return `comment-${response.data.id}`; - } - } - - /** - * Handle incoming webhook events from GitHub - */ - private async onWebhook( - request: WebhookRequest, - repoId: string - ): Promise { - // Verify signature - const secret = await this.get(`webhook_secret_${repoId}`); - if (!secret) { - console.warn("GitHub webhook secret not found, skipping verification"); - return; - } - - if (!request.rawBody) { - console.warn("GitHub webhook missing raw body"); - return; - } - - const signature = request.headers["x-hub-signature-256"]; - if (!signature) { - console.warn("GitHub webhook missing signature header"); - return; - } - - // Verify HMAC-SHA256 signature - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign( - "HMAC", - key, - encoder.encode(request.rawBody) - ); - const expectedSignature = - "sha256=" + - Array.from(new Uint8Array(sig)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - - if (signature !== expectedSignature) { - console.warn("GitHub webhook signature verification failed"); - return; - } - - // Get callback token - const callbackToken = await this.get( - `item_callback_${repoId}` - ); - if (!callbackToken) { - console.warn("No callback token found for repo:", repoId); - return; - } - - const event = request.headers["x-github-event"]; - const payload = request.body as any; - - if (event === "issues") { - await this.handleIssueWebhook(payload, repoId, callbackToken); - } else if (event === "issue_comment") { - await this.handleCommentWebhook(payload, repoId, callbackToken); - } - } - - /** - * Handle Issue webhook events - */ - private async handleIssueWebhook( - payload: any, - repoId: string, - callbackToken: Callback - ): Promise { - const issue = payload.issue; - if (!issue) return; - - // Skip pull requests - if (issue.pull_request) return; - - const repoFullName = payload.repository?.full_name; - if (!repoFullName) return; - - let authorContact: NewContact | undefined; - if (issue.user) { - authorContact = { - email: - issue.user.email || - `${issue.user.login}@users.noreply.github.com`, - name: issue.user.login, - avatar: issue.user.avatar_url ?? undefined, - }; - } - - let assigneeContact: NewContact | undefined; - const assignee = issue.assignees?.[0] || issue.assignee; - if (assignee) { - assigneeContact = { - email: - assignee.email || - `${assignee.login}@users.noreply.github.com`, - name: assignee.login, - avatar: assignee.avatar_url ?? undefined, - }; - } - - const activity: NewActivity = { - source: `github:issue:${repoId}:${issue.number}`, - type: ActivityType.Action, - title: issue.title, - created: issue.created_at, - author: authorContact, - assignee: assigneeContact ?? null, - done: issue.closed_at ?? null, - start: assigneeContact ? undefined : null, - meta: { - githubIssueNumber: issue.number, - githubRepoId: repoId, - githubRepoFullName: repoFullName, - projectId: repoId, - syncProvider: "github-issues", - syncableId: repoId, - }, - preview: issue.body || null, - }; - - await this.tools.callbacks.run(callbackToken, activity); - } - - /** - * Handle Comment webhook events - */ - private async handleCommentWebhook( - payload: any, - repoId: string, - callbackToken: Callback - ): Promise { - const comment = payload.comment; - const issue = payload.issue; - if (!comment || !issue) return; - - // Skip comments on pull requests - if (issue.pull_request) return; - - const repoFullName = payload.repository?.full_name; - if (!repoFullName) return; - - let commentAuthor: NewContact | undefined; - if (comment.user) { - commentAuthor = { - email: - comment.user.email || - `${comment.user.login}@users.noreply.github.com`, - name: comment.user.login, - avatar: comment.user.avatar_url ?? undefined, - }; - } - - const activitySource = `github:issue:${repoId}:${issue.number}`; - const note: NewNote = { - key: `comment-${comment.id}`, - activity: { source: activitySource }, - content: comment.body ?? null, - created: comment.created_at, - author: commentAuthor, - }; - - const activity: NewActivityWithNotes = { - source: activitySource, - type: ActivityType.Action, - notes: [note], - meta: { - githubIssueNumber: issue.number, - githubRepoId: repoId, - githubRepoFullName: repoFullName, - projectId: repoId, - syncProvider: "github-issues", - syncableId: repoId, - }, - }; - - await this.tools.callbacks.run(callbackToken, activity); - } - - /** - * Stop syncing a GitHub repo - */ - async stopSync(projectId: string): Promise { - // Remove webhook - const webhookId = await this.get(`webhook_id_${projectId}`); - if (webhookId) { - try { - const octokit = await this.getClient(projectId); - const { owner, repo } = await this.getRepoInfo(projectId); - await octokit.rest.repos.deleteWebhook({ - owner, - repo, - hook_id: webhookId, - }); - } catch (error) { - console.warn("Failed to delete GitHub webhook:", error); - } - await this.clear(`webhook_id_${projectId}`); - } - - // Cleanup webhook secret - await this.clear(`webhook_secret_${projectId}`); - - // Cleanup item callback - 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}`); - } -} - -export default GitHubIssues; diff --git a/tools/github-issues/src/index.ts b/tools/github-issues/src/index.ts deleted file mode 100644 index f704d26..0000000 --- a/tools/github-issues/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, GitHubIssues } from "./github-issues"; diff --git a/tools/github/src/github.ts b/tools/github/src/github.ts deleted file mode 100644 index 096073b..0000000 --- a/tools/github/src/github.ts +++ /dev/null @@ -1,1037 +0,0 @@ -import { - type Activity, - type Link, - LinkType, - type ActivityMeta, - ActivityType, - type NewActivity, - type NewActivityWithNotes, - type Serializable, - type SyncToolOptions, -} from "@plotday/twister"; -import type { - Repository, - SourceControlSyncOptions, - SourceControlTool, -} from "@plotday/twister/common/source-control"; -import type { NewContact } from "@plotday/twister/plot"; -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"; -import { Tasks } from "@plotday/twister/tools/tasks"; - -type SyncState = { - page: number; - batchNumber: number; - prsProcessed: number; - initialSync: boolean; -}; - -type GitHubUser = { - id: number; - login: string; - avatar_url?: string; - name?: string; - email?: string; -}; - -type GitHubPullRequest = { - id: number; - number: number; - title: string; - body: string | null; - state: "open" | "closed"; - html_url: string; - created_at: string; - updated_at: string; - closed_at: string | null; - merged_at: string | null; - user: GitHubUser; - assignee: GitHubUser | null; - draft: boolean; - base: { repo: { full_name: string; owner: { login: string }; name: string } }; -}; - -type GitHubIssueComment = { - id: number; - body: string; - created_at: string; - updated_at: string; - user: GitHubUser; - html_url: string; -}; - -type GitHubReview = { - id: number; - body: string; - state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING"; - submitted_at: string; - user: GitHubUser; - html_url: string; -}; - -type GitHubRepo = { - id: number; - full_name: string; - name: string; - description: string | null; - html_url: string; - owner: { login: string }; - default_branch: string; - private: boolean; -}; - -/** - * GitHub source control tool - * - * Implements the SourceControlTool interface for syncing GitHub repositories - * and pull requests with Plot activities. - */ -export class GitHub extends Tool implements SourceControlTool { - static readonly PROVIDER = AuthProvider.GitHub; - static readonly SCOPES = ["repo"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; - - /** Days of recently closed/merged PRs to include in sync */ - private static readonly RECENT_DAYS = 30; - /** PRs per page for batch sync */ - private static readonly PAGE_SIZE = 50; - - build(build: ToolBuilder) { - return { - integrations: build(Integrations, { - providers: [ - { - provider: GitHub.PROVIDER, - scopes: GitHub.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, - }, - ], - }), - network: build(Network, { urls: ["https://api.github.com/*"] }), - callbacks: build(Callbacks), - tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), - }; - } - - /** - * Make an authenticated GitHub API request - */ - private async githubFetch( - token: string, - path: string, - options?: RequestInit, - ): Promise { - return fetch(`https://api.github.com${path}`, { - ...options, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - ...options?.headers, - }, - }); - } - - /** - * Get an authenticated token for a syncable repository - */ - private async getToken(syncableId: string): Promise { - const authToken = await this.tools.integrations.get( - GitHub.PROVIDER, - syncableId, - ); - if (!authToken) { - throw new Error("No GitHub authentication token available"); - } - return authToken.token; - } - - /** - * Returns available GitHub repositories as syncable resources. - */ - async getSyncables( - _auth: Authorization, - token: AuthToken, - ): Promise { - const repos: GitHubRepo[] = []; - let page = 1; - - // Paginate through all repos - while (true) { - const response = await fetch( - `https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=100&page=${page}`, - { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token.token}`, - "X-GitHub-Api-Version": "2022-11-28", - }, - }, - ); - - if (!response.ok) break; - - const batch: GitHubRepo[] = await response.json(); - if (batch.length === 0) break; - - repos.push(...batch); - if (batch.length < 100) break; - page++; - } - - return repos.map((repo) => ({ - id: repo.full_name, - title: repo.full_name, - })); - } - - /** - * Called when a syncable repository is enabled for syncing. - */ - 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: "github", syncableId: syncable.id } }, - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } - - // Setup webhook and start initial sync - await this.setupWebhook(syncable.id); - await this.startBatchSync(syncable.id); - } - - /** - * Called when a syncable repository is disabled. - */ - 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}`); - } - - /** - * Get list of repositories - */ - async getRepositories(repositoryId: string): Promise { - const token = await this.getToken(repositoryId); - - const repos: GitHubRepo[] = []; - let page = 1; - - while (true) { - const response = await this.githubFetch( - token, - `/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=100&page=${page}`, - ); - - if (!response.ok) break; - - const batch: GitHubRepo[] = await response.json(); - if (batch.length === 0) break; - - repos.push(...batch); - if (batch.length < 100) break; - page++; - } - - return repos.map((repo) => ({ - id: repo.full_name, - name: repo.name, - description: repo.description, - url: repo.html_url, - owner: repo.owner.login, - defaultBranch: repo.default_branch, - private: repo.private, - })); - } - - /** - * Start syncing pull requests from a repository - */ - async startSync< - TArgs extends Serializable[], - TCallback extends (pr: NewActivityWithNotes, ...args: TArgs) => any, - >( - options: { - repositoryId: string; - } & SourceControlSyncOptions, - callback: TCallback, - ...extraArgs: TArgs - ): Promise { - const { repositoryId } = options; - - // Setup webhook for real-time updates - await this.setupWebhook(repositoryId); - - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs, - ); - await this.set(`item_callback_${repositoryId}`, callbackToken); - - // Start initial batch sync - await this.startBatchSync(repositoryId); - } - - /** - * Stop syncing a repository - */ - async stopSync(repositoryId: string): Promise { - // Remove webhook - const webhookId = await this.get(`webhook_id_${repositoryId}`); - if (webhookId) { - try { - const token = await this.getToken(repositoryId); - const [owner, repo] = repositoryId.split("/"); - await this.githubFetch( - token, - `/repos/${owner}/${repo}/hooks/${webhookId}`, - { method: "DELETE" }, - ); - } catch (error) { - console.warn("Failed to delete GitHub webhook:", error); - } - await this.clear(`webhook_id_${repositoryId}`); - } - - // Cleanup webhook secret - await this.clear(`webhook_secret_${repositoryId}`); - - // Cleanup item callback - const itemCallbackToken = await this.get( - `item_callback_${repositoryId}`, - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${repositoryId}`); - } - - // Cleanup sync state - await this.clear(`sync_state_${repositoryId}`); - } - - // ---------- Webhook setup ---------- - - /** - * Setup GitHub webhook for real-time PR updates - */ - private async setupWebhook(repositoryId: string): Promise { - try { - // Generate a webhook secret for signature verification - const secret = crypto.randomUUID(); - - const webhookUrl = await this.tools.network.createWebhook( - {}, - this.onWebhook, - repositoryId, - ); - - // Skip webhook setup for localhost (development mode) - if ( - webhookUrl.includes("localhost") || - webhookUrl.includes("127.0.0.1") - ) { - return; - } - - await this.set(`webhook_secret_${repositoryId}`, secret); - - const token = await this.getToken(repositoryId); - const [owner, repo] = repositoryId.split("/"); - - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/hooks`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "web", - active: true, - events: [ - "pull_request", - "pull_request_review", - "issue_comment", - ], - config: { - url: webhookUrl, - content_type: "json", - secret, - insecure_ssl: "0", - }, - }), - }, - ); - - if (response.ok) { - const webhook = await response.json(); - if (webhook.id) { - await this.set(`webhook_id_${repositoryId}`, String(webhook.id)); - } - } else { - console.error( - "Failed to create GitHub webhook:", - response.status, - await response.text(), - ); - } - } catch (error) { - console.error( - "Failed to set up GitHub webhook - real-time updates will not work:", - error, - ); - } - } - - /** - * Verify GitHub webhook signature using HMAC-SHA256 - */ - private async verifyWebhookSignature( - secret: string, - body: string, - signature: string, - ): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - - const signed = await crypto.subtle.sign( - "HMAC", - key, - encoder.encode(body), - ); - - const expected = - "sha256=" + - Array.from(new Uint8Array(signed)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - - // Constant-time comparison - if (expected.length !== signature.length) return false; - let result = 0; - for (let i = 0; i < expected.length; i++) { - result |= expected.charCodeAt(i) ^ signature.charCodeAt(i); - } - return result === 0; - } - - /** - * Handle incoming webhook events from GitHub - */ - private async onWebhook( - request: WebhookRequest, - repositoryId: string, - ): Promise { - // Verify webhook signature - const secret = await this.get(`webhook_secret_${repositoryId}`); - if (!secret) { - console.warn("GitHub webhook secret not found, skipping verification"); - return; - } - - if (!request.rawBody) { - console.warn("GitHub webhook missing raw body"); - return; - } - - const signature = request.headers["x-hub-signature-256"]; - if (!signature) { - console.warn("GitHub webhook missing signature header"); - return; - } - - const valid = await this.verifyWebhookSignature( - secret, - request.rawBody, - signature, - ); - if (!valid) { - console.warn("GitHub webhook signature verification failed"); - return; - } - - // Get callback token - const callbackToken = await this.get( - `item_callback_${repositoryId}`, - ); - if (!callbackToken) { - console.warn("No callback token found for repository:", repositoryId); - return; - } - - const event = request.headers["x-github-event"]; - const payload = - typeof request.body === "string" - ? JSON.parse(request.body) - : request.body; - - if (event === "pull_request") { - await this.handlePRWebhook(payload, repositoryId, callbackToken); - } else if (event === "pull_request_review") { - await this.handleReviewWebhook(payload, repositoryId, callbackToken); - } else if (event === "issue_comment") { - // Only handle comments on PRs (issue_comment fires for both issues and PRs) - if (payload.issue?.pull_request) { - await this.handleCommentWebhook(payload, repositoryId, callbackToken); - } - } - } - - /** - * Handle pull_request webhook event - */ - private async handlePRWebhook( - payload: any, - repositoryId: string, - callbackToken: Callback, - ): Promise { - const pr: GitHubPullRequest = payload.pull_request; - if (!pr) return; - - const [owner, repo] = repositoryId.split("/"); - - const authorContact = this.userToContact(pr.user); - const assigneeContact = pr.assignee - ? this.userToContact(pr.assignee) - : null; - - const activity: NewActivity = { - source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ActivityType.Action, - title: pr.title, - created: new Date(pr.created_at), - author: authorContact, - assignee: assigneeContact, - done: pr.merged_at ? new Date(pr.merged_at) : null, - ...(pr.state === "closed" && !pr.merged_at ? { archived: true } : {}), - meta: { - provider: "github", - owner, - repo, - prNumber: pr.number, - prNodeId: pr.id, - syncProvider: "github", - syncableId: repositoryId, - }, - preview: pr.body || null, - }; - - await this.tools.callbacks.run(callbackToken, activity); - } - - /** - * Handle pull_request_review webhook event - */ - private async handleReviewWebhook( - payload: any, - repositoryId: string, - callbackToken: Callback, - ): Promise { - const review: GitHubReview = payload.review; - const pr: GitHubPullRequest = payload.pull_request; - if (!review || !pr) return; - - // Skip empty COMMENTED reviews (just inline comments with no summary) - if (review.state === "COMMENTED" && !review.body) return; - - const [owner, repo] = repositoryId.split("/"); - const reviewAuthor = this.userToContact(review.user); - - const prefix = this.reviewStatePrefix(review.state); - const content = prefix - ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` - : review.body || null; - - const activity: NewActivityWithNotes = { - source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ActivityType.Action, - notes: [ - { - key: `review-${review.id}`, - content, - created: new Date(review.submitted_at), - author: reviewAuthor, - } as any, - ], - meta: { - provider: "github", - owner, - repo, - prNumber: pr.number, - prNodeId: pr.id, - syncProvider: "github", - syncableId: repositoryId, - }, - }; - - await this.tools.callbacks.run(callbackToken, activity); - } - - /** - * Handle issue_comment webhook event (for PR comments) - */ - private async handleCommentWebhook( - payload: any, - repositoryId: string, - callbackToken: Callback, - ): Promise { - const comment: GitHubIssueComment = payload.comment; - const issue = payload.issue; - if (!comment || !issue) return; - - const [owner, repo] = repositoryId.split("/"); - const prNumber = issue.number; - const commentAuthor = this.userToContact(comment.user); - - const activity: NewActivityWithNotes = { - source: `github:pr:${owner}/${repo}/${prNumber}`, - type: ActivityType.Action, - notes: [ - { - key: `comment-${comment.id}`, - content: comment.body, - created: new Date(comment.created_at), - author: commentAuthor, - } as any, - ], - meta: { - provider: "github", - owner, - repo, - prNumber, - syncProvider: "github", - syncableId: repositoryId, - }, - }; - - await this.tools.callbacks.run(callbackToken, activity); - } - - // ---------- Batch sync ---------- - - /** - * Initialize batch sync process - */ - private async startBatchSync(repositoryId: string): Promise { - await this.set(`sync_state_${repositoryId}`, { - page: 1, - batchNumber: 1, - prsProcessed: 0, - initialSync: true, - }); - - const batchCallback = await this.callback(this.syncBatch, repositoryId); - await this.tools.tasks.runTask(batchCallback); - } - - /** - * Process a batch of pull requests - */ - private async syncBatch(repositoryId: string): Promise { - const state = await this.get(`sync_state_${repositoryId}`); - if (!state) { - throw new Error(`Sync state not found for repository ${repositoryId}`); - } - - const callbackToken = await this.get( - `item_callback_${repositoryId}`, - ); - if (!callbackToken) { - throw new Error( - `Callback token not found for repository ${repositoryId}`, - ); - } - - const token = await this.getToken(repositoryId); - const [owner, repo] = repositoryId.split("/"); - - // Fetch batch of PRs (all states, sorted by updated) - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=${GitHub.PAGE_SIZE}&page=${state.page}`, - ); - - if (!response.ok) { - throw new Error( - `Failed to fetch PRs: ${response.status} ${await response.text()}`, - ); - } - - const prs: GitHubPullRequest[] = await response.json(); - - // Filter: open PRs + recently closed/merged (within RECENT_DAYS) - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - GitHub.RECENT_DAYS); - - const relevantPRs = prs.filter((pr) => { - if (pr.state === "open") return true; - // Closed/merged: include if recently updated - const closedDate = pr.merged_at || pr.closed_at; - if (closedDate && new Date(closedDate) >= cutoff) return true; - return false; - }); - - // If all PRs in this page are beyond the cutoff, stop syncing - const allBeyondCutoff = - prs.length > 0 && - prs.every((pr) => { - if (pr.state === "open") return false; - const closedDate = pr.merged_at || pr.closed_at; - return closedDate && new Date(closedDate) < cutoff; - }); - - // Process each relevant PR - for (const pr of relevantPRs) { - const activity = await this.convertPRToActivity( - token, - owner, - repo, - pr, - repositoryId, - state.initialSync, - ); - - if (activity) { - activity.meta = { - ...activity.meta, - syncProvider: "github", - syncableId: repositoryId, - }; - await this.tools.callbacks.run(callbackToken, activity); - } - } - - // Continue to next page if there are more PRs and not all beyond cutoff - if (prs.length === GitHub.PAGE_SIZE && !allBeyondCutoff) { - await this.set(`sync_state_${repositoryId}`, { - page: state.page + 1, - batchNumber: state.batchNumber + 1, - prsProcessed: state.prsProcessed + relevantPRs.length, - initialSync: state.initialSync, - }); - - const nextBatch = await this.callback(this.syncBatch, repositoryId); - await this.tools.tasks.runTask(nextBatch); - } else { - // Sync complete - await this.clear(`sync_state_${repositoryId}`); - } - } - - /** - * Convert a GitHub PR to a NewActivityWithNotes - */ - private async convertPRToActivity( - token: string, - owner: string, - repo: string, - pr: GitHubPullRequest, - repositoryId: string, - initialSync: boolean, - ): Promise { - const authorContact = this.userToContact(pr.user); - const assigneeContact = pr.assignee - ? this.userToContact(pr.assignee) - : null; - - // Build activity-level links - const activityLinks: Link[] = [ - { - type: LinkType.external, - title: `Open in GitHub`, - url: pr.html_url, - }, - ]; - - const notes: any[] = []; - - const hasDescription = pr.body && pr.body.trim().length > 0; - notes.push({ - key: "description", - content: hasDescription ? pr.body : null, - created: new Date(pr.created_at), - author: authorContact, - }); - - // Fetch general comments (issue comments API) - try { - const commentsResponse = await this.githubFetch( - token, - `/repos/${owner}/${repo}/issues/${pr.number}/comments?per_page=100`, - ); - if (commentsResponse.ok) { - const comments: GitHubIssueComment[] = await commentsResponse.json(); - for (const comment of comments) { - const commentAuthor = this.userToContact(comment.user); - notes.push({ - key: `comment-${comment.id}`, - content: comment.body, - created: new Date(comment.created_at), - author: commentAuthor, - }); - } - } - } catch (error) { - console.error("Error fetching PR comments:", error); - } - - // Fetch review summaries - try { - const reviewsResponse = await this.githubFetch( - token, - `/repos/${owner}/${repo}/pulls/${pr.number}/reviews?per_page=100`, - ); - if (reviewsResponse.ok) { - const reviews: GitHubReview[] = await reviewsResponse.json(); - for (const review of reviews) { - // Skip empty COMMENTED reviews (just inline comments with no summary) - if (review.state === "COMMENTED" && !review.body) continue; - - const reviewAuthor = this.userToContact(review.user); - const prefix = this.reviewStatePrefix(review.state); - const content = prefix - ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` - : review.body || null; - - if (content) { - notes.push({ - key: `review-${review.id}`, - content, - created: new Date(review.submitted_at), - author: reviewAuthor, - }); - } - } - } - } catch (error) { - console.error("Error fetching PR reviews:", error); - } - - const activity: NewActivityWithNotes = { - source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ActivityType.Action, - title: pr.title, - created: new Date(pr.created_at), - author: authorContact, - assignee: assigneeContact, - done: pr.merged_at ? new Date(pr.merged_at) : null, - meta: { - provider: "github", - owner, - repo, - prNumber: pr.number, - prNodeId: pr.id, - }, - links: activityLinks, - notes, - preview: hasDescription ? pr.body : null, - ...(initialSync ? { unread: false } : {}), - ...(initialSync ? { archived: false } : {}), - // Archive closed-without-merge PRs on incremental sync only - ...(!initialSync && pr.state === "closed" && !pr.merged_at - ? { archived: true } - : {}), - }; - - return activity; - } - - // ---------- Bidirectional methods ---------- - - /** - * Add a general comment to a pull request - */ - async addPRComment( - meta: ActivityMeta, - body: string, - noteId?: string, - ): Promise { - const owner = meta.owner as string; - const repo = meta.repo as string; - const prNumber = meta.prNumber as number; - const syncableId = `${owner}/${repo}`; - - if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in activity meta"); - } - - const token = await this.getToken(syncableId); - - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/issues/${prNumber}/comments`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ body }), - }, - ); - - if (!response.ok) { - throw new Error( - `Failed to add PR comment: ${response.status} ${await response.text()}`, - ); - } - - const comment = await response.json(); - if (comment?.id) { - return `comment-${comment.id}`; - } - } - - /** - * Update a PR's review status (approve or request changes) - */ - async updatePRStatus(activity: Activity): Promise { - const meta = activity.meta; - if (!meta) return; - - const owner = meta.owner as string; - const repo = meta.repo as string; - const prNumber = meta.prNumber as number; - const syncableId = `${owner}/${repo}`; - - if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in activity meta"); - } - - const token = await this.getToken(syncableId); - - // Map activity done state to review event - // done = approved, not done = no action (can't undo approval via API easily) - if (activity.type === ActivityType.Action && activity.done !== null) { - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - event: "APPROVE", - }), - }, - ); - - if (!response.ok) { - throw new Error( - `Failed to update PR status: ${response.status} ${await response.text()}`, - ); - } - } - } - - /** - * Close a pull request without merging - */ - async closePR(meta: ActivityMeta): Promise { - const owner = meta.owner as string; - const repo = meta.repo as string; - const prNumber = meta.prNumber as number; - const syncableId = `${owner}/${repo}`; - - if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in activity meta"); - } - - const token = await this.getToken(syncableId); - - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/pulls/${prNumber}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: "closed" }), - }, - ); - - if (!response.ok) { - throw new Error( - `Failed to close PR: ${response.status} ${await response.text()}`, - ); - } - } - - // ---------- Helpers ---------- - - /** - * Convert a GitHub user to a NewContact using noreply email - */ - private userToContact(user: GitHubUser): NewContact { - return { - email: `${user.id}+${user.login}@users.noreply.github.com`, - name: user.login, - avatar: user.avatar_url ?? undefined, - }; - } - - /** - * Get a prefix for review state - */ - private reviewStatePrefix( - state: GitHubReview["state"], - ): string | null { - switch (state) { - case "APPROVED": - return "**Approved**"; - case "CHANGES_REQUESTED": - return "**Changes Requested**"; - case "DISMISSED": - return "**Dismissed**"; - default: - return null; - } - } -} - -export default GitHub; diff --git a/tools/google-contacts/src/types.ts b/tools/google-contacts/src/types.ts deleted file mode 100644 index c6178a5..0000000 --- a/tools/google-contacts/src/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -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; - - startSync any>( - syncableId: string, - callback: TCallback, - ...extraArgs: any[] - ): Promise; - - stopSync(syncableId: string): Promise; -} diff --git a/tools/slack/tsconfig.json b/tools/slack/tsconfig.json deleted file mode 100644 index b98a116..0000000 --- a/tools/slack/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["src/**/*.ts"] -} diff --git a/twister/README.md b/twister/README.md index d22195a..1348196 100644 --- a/twister/README.md +++ b/twister/README.md @@ -115,7 +115,7 @@ async upgrade() // When new version is deployed ### Twist Tools -Twist tools provide capabilities to twists. They are usually unopinionated and do nothing on their own. Use built-in tools or create your own. +Twist tools provide capabilities to twists. They are usually unopinionated and do nothing on their own. **Built-in Tools:** @@ -129,6 +129,8 @@ Twist tools provide capabilities to twists. They are usually unopinionated and d [View all tools →](https://twist.plot.day/documents/Built-in_Tools.html) +External service integrations (Google Calendar, Slack, Linear, etc.) are built as **Sources** — see [Building Sources](https://twist.plot.day/documents/Building_Sources.html). + ### Activities and Notes **Activity** represents something done or to be done (a task, event, or conversation). @@ -206,7 +208,7 @@ plot priority create # Create new priority - [Core Concepts](https://twist.plot.day/documents/Core_Concepts.html) - Twists, tools, and architecture - [Sync Strategies](https://twist.plot.day/documents/Sync_Strategies.html) - Data synchronization patterns (upserts, deduplication, ID management) - [Built-in Tools](https://twist.plot.day/documents/Built-in_Tools.html) - Plot, Store, AI, and more -- [Building Custom Tools](https://twist.plot.day/documents/Building_Custom_Tools.html) - Create reusable twist tools +- [Building Sources](https://twist.plot.day/documents/Building_Sources.html) - Build external service integrations - [Runtime Environment](https://twist.plot.day/documents/Runtime_Environment.html) - Execution constraints and optimization - [Advanced Topics](https://twist.plot.day/documents/Advanced.html) - Complex patterns and techniques diff --git a/twister/cli/commands/create.ts b/twister/cli/commands/create.ts index 7e75ce7..38e48e7 100644 --- a/twister/cli/commands/create.ts +++ b/twister/cli/commands/create.ts @@ -10,10 +10,12 @@ interface CreateOptions { dir?: string; name?: string; displayName?: string; + source?: boolean; } export async function createCommand(options: CreateOptions) { - out.header("Create a new Plot twist"); + const isSource = !!options.source; + out.header(isSource ? "Create a new Plot source" : "Create a new Plot twist"); let response: { name: string; displayName: string }; @@ -94,8 +96,9 @@ export async function createCommand(options: CreateOptions) { const plotTwistId = crypto.randomUUID(); // Create package.json + const packageName = isSource ? `@plotday/source-${response.name}` : response.name; const packageJson: any = { - name: response.name, + name: packageName, displayName: response.displayName || response.name, main: "src/index.ts", types: "src/index.ts", @@ -127,6 +130,39 @@ export async function createCommand(options: CreateOptions) { JSON.stringify(tsconfigJson, null, 2) + "\n" ); + const sourceTemplate = `import { Source, type ToolBuilder } from "@plotday/twister"; +import { + AuthProvider, + type AuthToken, + type Authorization, + Integrations, + type Channel, +} from "@plotday/twister/tools/integrations"; + +export default class MySource extends Source { + readonly provider = AuthProvider.Google; // Change to your provider + readonly scopes = ["https://example.com/scope"]; + + build(build: ToolBuilder) { + return { + integrations: build(Integrations), + }; + } + + async getChannels(_auth: Authorization, _token: AuthToken): Promise { + return []; + } + + async onChannelEnabled(_channel: Channel): Promise { + // Start syncing this channel + } + + async onChannelDisabled(_channel: Channel): Promise { + // Stop syncing and clean up + } +} +`; + const twistTemplate = `import { type Activity, Twist, @@ -151,7 +187,11 @@ export default class MyTwist extends Twist { } } `; - fs.writeFileSync(path.join(twistPath, "src", "index.ts"), twistTemplate); + + fs.writeFileSync( + path.join(twistPath, "src", "index.ts"), + isSource ? sourceTemplate : twistTemplate + ); // Detect and use appropriate package manager const packageManager = detectPackageManager(); diff --git a/twister/cli/commands/deploy.ts b/twister/cli/commands/deploy.ts index f127c77..23c996b 100644 --- a/twister/cli/commands/deploy.ts +++ b/twister/cli/commands/deploy.ts @@ -73,6 +73,8 @@ interface PackageJson { description?: string; author?: string; license?: string; + logoUrl?: string; + logoUrlDark?: string; plotTwistId?: string; plotTwist?: { id?: string; @@ -261,6 +263,8 @@ export async function deployCommand(options: DeployOptions) { let twistId = packageJson?.plotTwistId; const twistName = packageJson?.displayName; const twistDescription = packageJson?.description; + const twistLogoUrl = packageJson?.logoUrl; + const twistLogoUrlDark = packageJson?.logoUrlDark; const environment = options.environment || "personal"; @@ -510,6 +514,8 @@ export async function deployCommand(options: DeployOptions) { sourcemap?: string; name: string; description?: string; + logoUrl?: string; + logoUrlDark?: string; environment: string; publisherId?: number; dryRun?: boolean; @@ -545,6 +551,8 @@ export async function deployCommand(options: DeployOptions) { sourcemap: sourcemapContent, name: deploymentName!, description: deploymentDescription, + logoUrl: twistLogoUrl, + logoUrlDark: twistLogoUrlDark, environment: environment, publisherId, dryRun: options.dryRun, diff --git a/twister/cli/index.ts b/twister/cli/index.ts index b791c73..e785921 100644 --- a/twister/cli/index.ts +++ b/twister/cli/index.ts @@ -55,6 +55,7 @@ program .option("-d, --dir ", "Directory to create the twist in") .option("-n, --name ", "Package name (kebab-case)") .option("--display-name ", "Display name for the twist") + .option("--source", "Create a source instead of a twist") .action(createCommand); // Top-level lint command diff --git a/twister/cli/templates/AGENTS.template.md b/twister/cli/templates/AGENTS.template.md index 6474990..e472e40 100644 --- a/twister/cli/templates/AGENTS.template.md +++ b/twister/cli/templates/AGENTS.template.md @@ -19,78 +19,72 @@ Plot Twists are TypeScript classes that extend the `Twist` base class. Twists in - **Store intermediate state**: Use the Store tool to persist state between batches - **Examples**: Syncing large datasets, processing many API calls, or performing batch operations -## Understanding Activities and Notes +## Understanding Threads and Notes -**CRITICAL CONCEPT**: An **Activity** represents something done or to be done (a task, event, or conversation), while **Notes** represent the updates and details on that activity. +**CRITICAL CONCEPT**: A **Thread** represents something done or to be done (a task, event, or conversation), while **Notes** represent the updates and details on that thread. -**Think of an Activity as a thread** on a messaging platform, and **Notes as the messages in that thread**. +**Think of a Thread as a thread** on a messaging platform, and **Notes as the messages in that thread**. ### Key Guidelines -1. **Always create Activities with an initial Note** - The title is just a summary; detailed content goes in Notes -2. **Add Notes to existing Activities for updates** - Don't create a new Activity for each related message -3. **Use Activity.source and Note.key for automatic upserts (Recommended)** - Set Activity.source to the external item's URL for deduplication, and use Note.key for upsertable note content. No manual ID tracking needed. -4. **For advanced cases, use generated UUIDs** - Only when you need multiple Plot activities per external item (see SYNC_STRATEGIES.md) -5. **Most Activities should be `ActivityType.Note`** - Use `Action` only for tasks with `done`, use `Event` only for items with `start`/`end` +1. **Always create Threads with an initial Note** - The title is just a summary; detailed content goes in Notes +2. **Add Notes to existing Threads for updates** - Don't create a new Thread for each related message +3. **Use Thread.source and Note.key for automatic upserts (Recommended)** - Set Thread.source to the external item's URL for deduplication, and use Note.key for upsertable note content. No manual ID tracking needed. +4. **For advanced cases, use generated UUIDs** - Only when you need multiple Plot threads per external item (see SYNC_STRATEGIES.md) +5. **Most Threads should be `ThreadType.Note`** - Use `Action` only for tasks with `done`, use `Event` only for items with `start`/`end` ### Recommended Decision Tree (Strategy 2: Upsert via Source/Key) ``` New event/task/conversation from external system? ├─ Has stable URL or ID? - │ └─ Yes → Set Activity.source to the canonical URL/ID - │ Create Activity (Plot handles deduplication automatically) + │ └─ Yes → Set Thread.source to the canonical URL/ID + │ Create Thread (Plot handles deduplication automatically) │ Use Note.key for different note types: │ - "description" for main content │ - "metadata" for status/priority/assignee │ - "comment-{id}" for individual comments │ - └─ No stable identifier OR need multiple Plot activities per external item? + └─ No stable identifier OR need multiple Plot threads per external item? └─ Use Advanced Pattern (Strategy 3: Generate and Store IDs) See SYNC_STRATEGIES.md for details ``` ### Advanced Decision Tree (Strategy 3: Generate and Store IDs) -Only use when source/key upserts aren't sufficient (e.g., creating multiple activities from one external item): +Only use when source/key upserts aren't sufficient (e.g., creating multiple threads from one external item): ``` New event/task/conversation? ├─ Yes → Generate UUID with Uuid.Generate() - │ Create new Activity with that UUID - │ Store mapping: external_id → activity_uuid + │ Create new Thread with that UUID + │ Store mapping: external_id → thread_uuid │ └─ No (update/reply/comment) → Look up mapping by external_id - ├─ Found → Add Note to existing Activity using stored UUID - └─ Not found → Create new Activity with UUID + store mapping + ├─ Found → Add Note to existing Thread using stored UUID + └─ Not found → Create new Thread with UUID + store mapping ``` ## Twist Structure Pattern ```typescript import { - type Activity, - type NewActivityWithNotes, - type ActivityFilter, + type Thread, + type NewThreadWithNotes, + type ThreadFilter, type Priority, type ToolBuilder, Twist, - ActivityType, + ThreadType, } from "@plotday/twister"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; -// Import your tools: -// import { GoogleCalendar } from "@plotday/tool-google-calendar"; -// import { Linear } from "@plotday/tool-linear"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; +// Import your sources or tools as needed export default class MyTwist extends Twist { build(build: ToolBuilder) { return { - // myTool: build(MyTool, { - // onItem: this.handleItem, - // onSyncableDisabled: this.onSyncableDisabled, - // }), plot: build(Plot, { - activity: { access: ActivityAccess.Create }, + thread: { access: ThreadAccess.Create }, }), }; } @@ -98,14 +92,6 @@ export default class MyTwist extends Twist { async activate(_priority: Pick) { // Auth and resource selection handled in the twist edit modal. } - - async handleItem(activity: NewActivityWithNotes): Promise { - await this.tools.plot.createActivity(activity); - } - - async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); - } } ``` @@ -145,31 +131,6 @@ For complete API documentation of built-in tools including all methods, types, a **Critical**: Never use instance variables for state. They are lost after function execution. Always use Store methods. -### External Tools (Add to package.json) - -Add tool dependencies to `package.json`: - -```json -{ - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-google-calendar": "workspace:^" - } -} -``` - -#### Available External Tools - -- `@plotday/tool-google-calendar`: Google Calendar sync (CalendarTool) -- `@plotday/tool-outlook-calendar`: Outlook Calendar sync (CalendarTool) -- `@plotday/tool-google-contacts`: Google Contacts sync (supporting tool) -- `@plotday/tool-google-drive`: Google Drive sync (DocumentTool) -- `@plotday/tool-gmail`: Gmail sync (MessagingTool) -- `@plotday/tool-slack`: Slack sync (MessagingTool) -- `@plotday/tool-linear`: Linear sync (ProjectTool) -- `@plotday/tool-jira`: Jira sync (ProjectTool) -- `@plotday/tool-asana`: Asana sync (ProjectTool) - ## Lifecycle Methods ### activate(priority: Pick) @@ -185,18 +146,18 @@ async activate(_priority: Pick) { } ``` -**Store Parent Activity for Later (optional):** +**Store Parent Thread for Later (optional):** ```typescript async activate(_priority: Pick) { - const activityId = await this.tools.plot.createActivity({ - type: ActivityType.Note, + const threadId = await this.tools.plot.createThread({ + type: ThreadType.Note, title: "Setup complete", notes: [{ - content: "Your twist is ready. Activities will appear as they sync.", + content: "Your twist is ready. Threads will appear as they sync.", }], }); - await this.set("setup_activity_id", activityId); + await this.set("setup_thread_id", threadId); } ``` @@ -204,44 +165,22 @@ async activate(_priority: Pick) { Twists respond to events through callbacks declared in `build()`: -**Receive synced items from a tool (most common):** - -```typescript -build(build: ToolBuilder) { - return { - myTool: build(MyTool, { - onItem: this.handleItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - plot: build(Plot, { activity: { access: ActivityAccess.Create } }), - }; -} - -async handleItem(activity: NewActivityWithNotes): Promise { - await this.tools.plot.createActivity(activity); -} - -async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); -} -``` - -**React to activity changes (for two-way sync):** +**React to thread changes (for two-way sync):** ```typescript plot: build(Plot, { - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, + thread: { + access: ThreadAccess.Create, + updated: this.onThreadUpdated, }, note: { created: this.onNoteCreated, }, }), -async onActivityUpdated(activity: Activity, changes: { tagsAdded, tagsRemoved }): Promise { - const tool = this.getToolForActivity(activity); - if (tool?.updateIssue) await tool.updateIssue(activity); +async onThreadUpdated(thread: Thread, changes: { tagsAdded, tagsRemoved }): Promise { + const tool = this.getToolForThread(thread); + if (tool?.updateIssue) await tool.updateIssue(thread); } async onNoteCreated(note: Note): Promise { @@ -254,7 +193,7 @@ async onNoteCreated(note: Note): Promise { ```typescript plot: build(Plot, { - activity: { access: ActivityAccess.Respond }, + thread: { access: ThreadAccess.Respond }, note: { intents: [{ description: "Respond to general questions", @@ -265,43 +204,43 @@ plot: build(Plot, { }), ``` -## Activity Links +## Actions -Activity links enable user interaction: +Actions enable user interaction: ```typescript -import { type ActivityLink, ActivityLinkType } from "@plotday/twister"; +import { type Action, ActionType } from "@plotday/twister"; -// External URL link -const urlLink: ActivityLink = { +// External URL action +const urlAction: Action = { title: "Open website", - type: ActivityLinkType.external, + type: ActionType.external, url: "https://example.com", }; -// Callback link (uses Callbacks tool — use linkCallback, not callback) -const token = await this.linkCallback(this.onLinkClicked, "context"); -const callbackLink: ActivityLink = { +// Callback action (uses Callbacks tool — use linkCallback, not callback) +const token = await this.linkCallback(this.onActionClicked, "context"); +const callbackAction: Action = { title: "Click me", - type: ActivityLinkType.callback, + type: ActionType.callback, callback: token, }; -// Add to activity note -await this.tools.plot.createActivity({ - type: ActivityType.Note, - title: "Task with links", +// Add to thread note +await this.tools.plot.createThread({ + type: ThreadType.Note, + title: "Task with actions", notes: [ { - content: "Click the links below to take action.", - links: [urlLink, callbackLink], + content: "Click the actions below to take action.", + actions: [urlAction, callbackAction], }, ], }); -// Callback handler receives the ActivityLink as first argument -async onLinkClicked(link: ActivityLink, context: string): Promise { - // Handle link click +// Callback handler receives the Action as first argument +async onActionClicked(action: Action, context: string): Promise { + // Handle action click } ``` @@ -317,9 +256,9 @@ build(build: ToolBuilder) { providers: [{ provider: AuthProvider.Google, scopes: ["https://www.googleapis.com/auth/calendar"], - getSyncables: this.getSyncables, // List available resources after auth - onSyncEnabled: this.onSyncEnabled, // User enabled a resource - onSyncDisabled: this.onSyncDisabled, // User disabled a resource + getChannels: this.getChannels, // List available resources after auth + onChannelEnabled: this.onChannelEnabled, // User enabled a resource + onChannelDisabled: this.onChannelDisabled, // User disabled a resource }], }), // ... @@ -327,7 +266,7 @@ build(build: ToolBuilder) { } // Get a token for API calls: -const token = await this.tools.integrations.get(AuthProvider.Google, syncableId); +const token = await this.tools.integrations.get(AuthProvider.Google, channelId); if (!token) throw new Error("No auth token available"); const client = new ApiClient({ accessToken: token.token }); ``` @@ -338,7 +277,7 @@ For per-user write-backs (e.g., RSVP, comments attributed to the acting user): await this.tools.integrations.actAs( AuthProvider.Google, actorId, // The user who performed the action - activityId, // Activity to prompt for auth if needed + threadId, // Thread to prompt for auth if needed this.performWriteBack, ...extraArgs ); @@ -346,70 +285,43 @@ await this.tools.integrations.actAs( ## Sync Pattern -### Recommended: Using External Tools with SyncToolOptions - -Most twists use external tools (CalendarTool, ProjectTool, etc.) that handle sync internally. The twist just receives `NewActivityWithNotes` objects and saves them: - -```typescript -build(build: ToolBuilder) { - return { - calendarTool: build(GoogleCalendar, { - onItem: this.handleEvent, // Receives synced items - onSyncableDisabled: this.onSyncableDisabled, // Clean up when disabled - }), - plot: build(Plot, { activity: { access: ActivityAccess.Create } }), - }; -} - -// Tools deliver NewActivityWithNotes — twist saves them -async handleEvent(activity: NewActivityWithNotes): Promise { - await this.tools.plot.createActivity(activity); -} - -async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); -} -``` - -### Custom Sync: Upsert via Source/Key (Strategy 2) +### Upsert via Source/Key (Strategy 2) -For direct API integration without an external tool, use source/key for automatic upserts: +Use source/key for automatic upserts: ```typescript async handleEvent(event: ExternalEvent): Promise { - const activity: NewActivityWithNotes = { + const thread: NewThreadWithNotes = { source: event.htmlLink, // Canonical URL for automatic deduplication - type: ActivityType.Event, + type: ThreadType.Event, title: event.summary || "(No title)", - start: event.start?.dateTime || event.start?.date || null, - end: event.end?.dateTime || event.end?.date || null, notes: [], }; if (event.description) { - activity.notes.push({ - activity: { source: event.htmlLink }, + thread.notes.push({ + thread: { source: event.htmlLink }, key: "description", // This key enables note-level upserts content: event.description, }); } // Create or update — Plot handles deduplication automatically - await this.tools.plot.createActivity(activity); + await this.tools.plot.createThread(thread); } ``` ### Advanced: Generate and Store IDs (Strategy 3) -Only use this pattern when you need to create multiple Plot activities from a single external item, or when the external system doesn't provide stable identifiers. See SYNC_STRATEGIES.md for details. +Only use this pattern when you need to create multiple Plot threads from a single external item, or when the external system doesn't provide stable identifiers. See SYNC_STRATEGIES.md for details. ```typescript async handleEventAdvanced( - incomingActivity: NewActivityWithNotes, + incomingThread: NewThreadWithNotes, calendarId: string ): Promise { // Extract external event ID from meta (adapt based on your tool's data) - const externalId = incomingActivity.meta?.eventId; + const externalId = incomingThread.meta?.eventId; if (!externalId) { console.error("Event missing external ID"); @@ -418,38 +330,38 @@ async handleEventAdvanced( // Check if we've already synced this event const mappingKey = `event_mapping:${calendarId}:${externalId}`; - const existingActivityId = await this.get(mappingKey); + const existingThreadId = await this.get(mappingKey); - if (existingActivityId) { + if (existingThreadId) { // Event already exists - add update as a Note (add message to thread) - if (incomingActivity.notes?.[0]?.content) { + if (incomingThread.notes?.[0]?.content) { await this.tools.plot.createNote({ - activity: { id: existingActivityId }, - content: incomingActivity.notes[0].content, + thread: { id: existingThreadId }, + content: incomingThread.notes[0].content, }); } return; } // New event - generate UUID and store mapping - const activityId = Uuid.Generate(); - await this.set(mappingKey, activityId); + const threadId = Uuid.Generate(); + await this.set(mappingKey, threadId); - // Create new Activity with initial Note (new thread with first message) - await this.tools.plot.createActivity({ - ...incomingActivity, - id: activityId, + // Create new Thread with initial Note (new thread with first message) + await this.tools.plot.createThread({ + ...incomingThread, + id: threadId, }); } ``` ## Resource Selection -Resource selection (calendars, projects, channels) is handled automatically in the twist edit modal via the Integrations tool. Users see a list of available resources returned by your tool's `getSyncables()` method and toggle them on/off. You do **not** need to build custom selection UI. +Resource selection (calendars, projects, channels) is handled automatically in the twist edit modal via the Integrations tool. Users see a list of available resources returned by your tool's `getChannels()` method and toggle them on/off. You do **not** need to build custom selection UI. ```typescript // In your tool: -async getSyncables(_auth: Authorization, token: AuthToken): Promise { +async getChannels(_auth: Authorization, token: AuthToken): Promise { const client = new ApiClient({ accessToken: token.token }); const calendars = await client.listCalendars(); return calendars.map(c => ({ @@ -501,10 +413,10 @@ async syncBatch(resourceId: string): Promise { // Process results using source/key pattern (automatic upserts, no manual tracking) // If each item makes ~10 requests, keep batch size ≤ 100 items to stay under limit for (const item of result.items) { - // Each createActivity may make ~5-10 requests depending on notes/links - await this.tools.plot.createActivity({ + // Each createThread may make ~5-10 requests depending on notes/links + await this.tools.plot.createThread({ source: item.url, // Use item's canonical URL for automatic deduplication - type: ActivityType.Note, + type: ThreadType.Note, title: item.title, notes: [{ activity: { source: item.url }, @@ -533,8 +445,8 @@ async syncBatch(resourceId: string): Promise { await this.clear(`sync_state_${resourceId}`); // Optionally notify user of completion - await this.tools.plot.createActivity({ - type: ActivityType.Note, + await this.tools.plot.createThread({ + type: ThreadType.Note, title: "Sync complete", notes: [ { @@ -546,9 +458,9 @@ async syncBatch(resourceId: string): Promise { } ``` -## Activity Sync Best Practices +## Thread Sync Best Practices -When syncing activities from external systems, follow these patterns for optimal user experience: +When syncing threads from external systems, follow these patterns for optimal user experience: ### The `initialSync` Flag @@ -561,8 +473,8 @@ All sync-based tools should distinguish between initial sync (first import) and **Example:** ```typescript -const activity: NewActivity = { - type: ActivityType.Event, +const thread: NewThread = { + type: ThreadType.Event, source: event.url, title: event.title, ...(initialSync ? { unread: false } : {}), // false for initial, omit for incremental @@ -577,9 +489,9 @@ const activity: NewActivity = { ### Two-Way Sync: Avoiding Race Conditions -When implementing two-way sync where items created in Plot are pushed to an external system (e.g. Notes becoming comments), a race condition can occur: the external system may send a webhook for the newly created item before you've updated the Activity/Note with the external key. The webhook handler won't find the item by external key and may create a duplicate. +When implementing two-way sync where items created in Plot are pushed to an external system (e.g. Notes becoming comments), a race condition can occur: the external system may send a webhook for the newly created item before you've updated the Thread/Note with the external key. The webhook handler won't find the item by external key and may create a duplicate. -**Solution:** Embed the Plot `Activity.id` / `Note.id` in the external item's metadata when creating it, and update `Activity.source` / `Note.key` after creation. When processing webhooks, check for the Plot ID in metadata first. +**Solution:** Embed the Plot `Thread.id` / `Note.id` in the external item's metadata when creating it, and update `Thread.source` / `Note.key` after creation. When processing webhooks, check for the Plot ID in metadata first. ```typescript async pushNoteAsComment(note: Note, externalItemId: string): Promise { @@ -622,8 +534,8 @@ try { } catch (error) { console.error("Operation failed:", error); - await this.tools.plot.createActivity({ - type: ActivityType.Note, + await this.tools.plot.createThread({ + type: ThreadType.Note, title: "Operation failed", notes: [ { @@ -637,11 +549,11 @@ try { ## Common Pitfalls - **Don't use instance variables for state** - Anything stored in memory is lost after function execution. Always use the Store tool for data that needs to persist. -- **Processing self-created activities** - Other users may change an Activity created by the twist, resulting in an \`activity\` call. Be sure to check the \`changes === null\` and/or \`activity.author.id !== this.id\` to avoid re-processing. -- **Always create Activities with Notes** - See "Understanding Activities and Notes" section above for the thread/message pattern and decision tree. -- **Use correct Activity types** - Most should be `ActivityType.Note`. Only use `Action` for tasks with `done`, and `Event` for items with `start`/`end`. -- **Use Activity.source and Note.key for automatic upserts (Recommended)** - Set Activity.source to the external item's URL for automatic deduplication. Only use UUID generation and storage for advanced cases (see SYNC_STRATEGIES.md). -- **Add Notes to existing Activities** - For source/key pattern, reference activities by source. For UUID pattern, look up stored mappings before creating new Activities. Think thread replies, not new threads. +- **Processing self-created threads** - Other users may change a Thread created by the twist, resulting in a callback. Be sure to check the `changes === null` and/or `thread.author.id !== this.id` to avoid re-processing. +- **Always create Threads with Notes** - See "Understanding Threads and Notes" section above for the thread/message pattern and decision tree. +- **Use correct Thread types** - Most should be `ThreadType.Note`. Only use `Action` for tasks with `done`, and `Event` for items with `start`/`end`. +- **Use Thread.source and Note.key for automatic upserts (Recommended)** - Set Thread.source to the external item's URL for automatic deduplication. Only use UUID generation and storage for advanced cases (see SYNC_STRATEGIES.md). +- **Add Notes to existing Threads** - For source/key pattern, reference threads by source. For UUID pattern, look up stored mappings before creating new Threads. Think thread replies, not new threads. - Tools are declared in the `build` method and accessed via `this.tools.toolName` in twist methods. - **Don't forget request limits** - Each execution has ~1000 requests (HTTP requests, tool calls). Break long loops into batches with `this.runTask()` to get fresh request limits. Calculate requests per item to determine safe batch size (e.g., if each item needs ~10 requests, batch size = ~100 items). - **Always use Callbacks tool for persistent references** - Direct function references don't survive worker restarts. diff --git a/twister/cli/templates/README.template.md b/twister/cli/templates/README.template.md index 017862c..e7f4811 100644 --- a/twister/cli/templates/README.template.md +++ b/twister/cli/templates/README.template.md @@ -68,30 +68,9 @@ build(build: ToolBuilder) { - **Callbacks**: Create persistent function references for webhooks - **Network**: HTTP access permissions and webhook management -#### External Tools +#### Sources -Add external tool dependencies to `package.json`: - -```json -{ - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-google-calendar": "workspace:^" - } -} -``` - -Then use them in your twist: - -```typescript -import GoogleCalendarTool from "@plotday/tool-google-calendar"; - -build(build: ToolBuilder) { - return { - googleCalendar: build(GoogleCalendarTool), - }; -} -``` +External service integrations (Google Calendar, Slack, Linear, etc.) are built as Sources. See the [Building Sources](https://twist.plot.day/documents/Building_Sources.html) guide. ### Activity Types diff --git a/twister/docs/BUILDING_SOURCES.md b/twister/docs/BUILDING_SOURCES.md new file mode 100644 index 0000000..0225997 --- /dev/null +++ b/twister/docs/BUILDING_SOURCES.md @@ -0,0 +1,440 @@ +--- +title: Building Sources +group: Guides +--- + +# Building Sources + +Sources connect Plot to external services like Google Calendar, Slack, Linear, and more. They sync data into Plot and optionally support bidirectional updates. This guide covers everything you need to know about building sources. + +## Table of Contents + +- [Sources vs Twists](#sources-vs-twists) +- [Source Structure](#source-structure) +- [OAuth and Channel Lifecycle](#oauth-and-channel-lifecycle) +- [Data Sync](#data-sync) +- [Batch Processing](#batch-processing) +- [Complete Example](#complete-example) +- [Best Practices](#best-practices) + +--- + +## Sources vs Twists + +| | Sources | Twists | +|---|---|---| +| **Purpose** | Sync data from external services | Implement opinionated workflows | +| **Base class** | `Source` (extends `Twist`) | `Twist` | +| **Auth** | OAuth via `Integrations` with channel lifecycle | Optional | +| **Data flow** | External service -> Plot (and optionally back) | Internal logic, orchestration | +| **Examples** | Google Calendar, Slack, Linear, Jira | Task automation, AI assistants | + +**Build a Source** when you need to integrate an external service — syncing calendars, issues, messages, etc. + +**Build a Twist** when you need workflow logic that doesn't require external service integration, or when you want to orchestrate multiple sources. + +--- + +## Source Structure + +Sources extend the `Source` base class and declare dependencies using `SourceBuilder`: + +```typescript +import { + ActivityType, + Source, + type SourceBuilder, +} from "@plotday/twister"; +import { + AuthProvider, + type AuthToken, + type Authorization, + type Channel, + Integrations, +} from "@plotday/twister/tools/integrations"; +import { Network } from "@plotday/twister/tools/network"; +import { Plot } from "@plotday/twister/tools/plot"; +import { Tasks } from "@plotday/twister/tools/tasks"; +import { Callbacks } from "@plotday/twister/tools/callbacks"; + +export default class MySource extends Source { + static readonly PROVIDER = AuthProvider.Linear; + static readonly SCOPES = ["read", "write"]; + + build(build: SourceBuilder) { + return { + integrations: build(Integrations, { + providers: [{ + provider: MySource.PROVIDER, + scopes: MySource.SCOPES, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, + }], + }), + network: build(Network, { urls: ["https://api.example.com/*"] }), + plot: build(Plot), + tasks: build(Tasks), + callbacks: build(Callbacks), + }; + } + + // ... lifecycle methods below +} +``` + +### Package Structure + +``` +sources/my-source/ + src/ + index.ts # Re-exports: export { default, MySource } from "./my-source" + my-source.ts # Main Source class + package.json + tsconfig.json +``` + +--- + +## OAuth and Channel Lifecycle + +Sources use the Integrations tool for OAuth. Auth is handled automatically in the Flutter edit modal — you don't need to build UI for it. + +### How It Works + +1. Source declares providers in `build()` with `getChannels`, `onChannelEnabled`, `onChannelDisabled` callbacks +2. User clicks "Connect" in the twist edit modal -> OAuth flow happens automatically +3. After auth, the runtime calls `getChannels()` to list available resources +4. User enables/disables resources in the modal + +### getChannels + +Return available resources after authentication: + +```typescript +async getChannels(_auth: Authorization, token: AuthToken): Promise { + const client = new ApiClient({ accessToken: token.token }); + const resources = await client.listResources(); + return resources.map(r => ({ id: r.id, title: r.name })); +} +``` + +### onChannelEnabled + +Called when the user enables a resource. Set up syncing: + +```typescript +async onChannelEnabled(channel: Channel): Promise { + await this.setupWebhook(channel.id); + await this.startBatchSync(channel.id); +} +``` + +### onChannelDisabled + +Called when the user disables a resource. Clean up: + +```typescript +async onChannelDisabled(channel: Channel): Promise { + // Remove webhook + const webhookId = await this.get(`webhook_id_${channel.id}`); + if (webhookId) { + const client = await this.getClient(channel.id); + await client.deleteWebhook(webhookId); + await this.clear(`webhook_id_${channel.id}`); + } + + // Clean up stored state + await this.clear(`sync_state_${channel.id}`); +} +``` + +### Getting Auth Tokens + +Retrieve tokens for API calls using the channel ID: + +```typescript +private async getClient(channelId: string): Promise { + const token = await this.tools.integrations.get(MySource.PROVIDER, channelId); + if (!token) throw new Error("No authentication token available"); + return new ApiClient({ accessToken: token.token }); +} +``` + +--- + +## Data Sync + +Sources sync data using `Activity.source` and `Note.key` for automatic upserts (no manual ID tracking needed). + +### Transforming External Items + +```typescript +private transformItem(item: any, channelId: string, initialSync: boolean) { + return { + source: `myprovider:item:${item.id}`, // Canonical source for deduplication + type: ActivityType.Action, + title: item.title, + meta: { + externalId: item.id, + syncProvider: "myprovider", // Required for bulk operations + channelId, // Required for bulk operations + }, + notes: [{ + key: "description", // Enables note-level upserts + content: item.description || null, + contentType: item.descriptionHtml ? "html" as const : "text" as const, + }], + ...(initialSync ? { unread: false } : {}), // Mark read on initial sync + ...(initialSync ? { archived: false } : {}), // Unarchive on initial sync + }; +} +``` + +### Initial vs Incremental Sync + +All sources **must** distinguish between initial sync (first import) and incremental sync (ongoing updates): + +| Field | Initial Sync | Incremental Sync | Reason | +|-------|-------------|------------------|--------| +| `unread` | `false` | *omit* | Avoid notification spam from historical imports | +| `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates | + +See [Sync Strategies](SYNC_STRATEGIES.md) for detailed patterns on deduplication, upserts, and tag management. + +--- + +## Batch Processing + +Sources run in an ephemeral environment with ~1000 requests per execution. Break long operations into batches using `runTask()`, which creates a new execution with fresh request limits. + +```typescript +private async startBatchSync(channelId: string): Promise { + await this.set(`sync_state_${channelId}`, { + cursor: null, + batchNumber: 1, + initialSync: true, + }); + + const batchCallback = await this.callback(this.syncBatch, channelId); + await this.tools.tasks.runTask(batchCallback); +} + +private async syncBatch(channelId: string): Promise { + const state = await this.get(`sync_state_${channelId}`); + if (!state) return; + + const client = await this.getClient(channelId); + const result = await client.listItems({ cursor: state.cursor, limit: 50 }); + + for (const item of result.items) { + const activity = this.transformItem(item, channelId, state.initialSync); + await this.tools.plot.createActivity(activity); + } + + if (result.nextCursor) { + await this.set(`sync_state_${channelId}`, { + cursor: result.nextCursor, + batchNumber: state.batchNumber + 1, + initialSync: state.initialSync, + }); + const nextBatch = await this.callback(this.syncBatch, channelId); + await this.tools.tasks.runTask(nextBatch); + } else { + await this.clear(`sync_state_${channelId}`); + } +} +``` + +--- + +## Complete Example + +A minimal source that syncs issues from an external service: + +```typescript +import { + ActivityType, + LinkType, + Source, + type SourceBuilder, + type SyncToolOptions, +} from "@plotday/twister"; +import { + AuthProvider, + type AuthToken, + type Authorization, + type Channel, + Integrations, +} from "@plotday/twister/tools/integrations"; +import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +import { Plot } from "@plotday/twister/tools/plot"; +import { Tasks } from "@plotday/twister/tools/tasks"; +import { Callbacks } from "@plotday/twister/tools/callbacks"; + +export default class IssueSource extends Source { + static readonly PROVIDER = AuthProvider.Linear; + static readonly SCOPES = ["read"]; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; + + build(build: SourceBuilder) { + return { + integrations: build(Integrations, { + providers: [{ + provider: IssueSource.PROVIDER, + scopes: IssueSource.SCOPES, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, + }], + }), + network: build(Network, { urls: ["https://api.linear.app/*"] }), + plot: build(Plot), + tasks: build(Tasks), + callbacks: build(Callbacks), + }; + } + + async getChannels(_auth: Authorization, token: AuthToken): Promise { + // Return available projects/teams for the user to select + const client = new LinearClient({ accessToken: token.token }); + const teams = await client.teams(); + return teams.nodes.map(t => ({ id: t.id, title: t.name })); + } + + async onChannelEnabled(channel: Channel): Promise { + // Set up webhook + const webhookUrl = await this.tools.network.createWebhook( + {}, this.onWebhook, channel.id + ); + if (!webhookUrl.includes("localhost")) { + const client = await this.getClient(channel.id); + const webhook = await client.createWebhook({ url: webhookUrl }); + if (webhook?.id) await this.set(`webhook_id_${channel.id}`, webhook.id); + } + + // Start initial sync + await this.set(`sync_state_${channel.id}`, { + cursor: null, batchNumber: 1, initialSync: true, + }); + const batch = await this.callback(this.syncBatch, channel.id); + await this.tools.tasks.runTask(batch); + } + + async onChannelDisabled(channel: Channel): Promise { + const webhookId = await this.get(`webhook_id_${channel.id}`); + if (webhookId) { + try { + const client = await this.getClient(channel.id); + await client.deleteWebhook(webhookId); + } catch { /* ignore */ } + await this.clear(`webhook_id_${channel.id}`); + } + await this.clear(`sync_state_${channel.id}`); + } + + private async getClient(channelId: string) { + const token = await this.tools.integrations.get(IssueSource.PROVIDER, channelId); + if (!token) throw new Error("No auth token"); + return new LinearClient({ accessToken: token.token }); + } + + private async syncBatch(channelId: string): Promise { + const state = await this.get(`sync_state_${channelId}`); + if (!state) return; + + const client = await this.getClient(channelId); + const result = await client.issues({ teamId: channelId, after: state.cursor }); + + for (const issue of result.nodes) { + await this.tools.plot.createActivity({ + source: `linear:issue:${issue.id}`, + type: ActivityType.Action, + title: `${issue.identifier}: ${issue.title}`, + done: issue.completedAt ? new Date(issue.completedAt) : null, + meta: { syncProvider: "linear", channelId }, + notes: [{ + key: "description", + content: issue.description || null, + links: issue.url ? [{ + type: LinkType.external, + title: "Open in Linear", + url: issue.url, + }] : null, + }], + ...(state.initialSync ? { unread: false } : {}), + ...(state.initialSync ? { archived: false } : {}), + }); + } + + if (result.pageInfo.hasNextPage) { + await this.set(`sync_state_${channelId}`, { + cursor: result.pageInfo.endCursor, + batchNumber: state.batchNumber + 1, + initialSync: state.initialSync, + }); + const next = await this.callback(this.syncBatch, channelId); + await this.tools.tasks.runTask(next); + } else { + await this.clear(`sync_state_${channelId}`); + } + } + + private async onWebhook(request: WebhookRequest, channelId: string): Promise { + const payload = JSON.parse(request.rawBody || "{}"); + if (payload.type !== "Issue") return; + + const issue = payload.data; + await this.tools.plot.createActivity({ + source: `linear:issue:${issue.id}`, + type: ActivityType.Action, + title: `${issue.identifier}: ${issue.title}`, + done: issue.completedAt ? new Date(issue.completedAt) : null, + meta: { syncProvider: "linear", channelId }, + notes: [{ + key: "description", + content: issue.description || null, + }], + // Incremental sync: omit unread and archived + }); + } +} +``` + +--- + +## Best Practices + +### 1. Always Inject Sync Metadata + +Every synced activity must include `syncProvider` and `channelId` in `meta` for bulk operations (e.g., archiving all activities when a channel is disabled). + +### 2. Use Canonical Source URLs + +Use immutable IDs in `Activity.source` for deduplication. For services with mutable identifiers (like Jira issue keys), use the immutable ID in `source` and store the mutable key in `meta`. + +### 3. Handle HTML Content Correctly + +Never strip HTML tags locally. Pass raw HTML with `contentType: "html"` for server-side markdown conversion. + +### 4. Add Localhost Guard for Webhooks + +Skip webhook registration in development when the URL contains "localhost". + +### 5. Maintain Callback Backward Compatibility + +All callbacks automatically upgrade to new source versions. Only add optional parameters at the end of callback method signatures. + +### 6. Clean Up on Disable + +Delete webhooks, callbacks, and stored state in `onChannelDisabled()`. + +--- + +## Next Steps + +- **[Source Development Guide](../../sources/AGENTS.md)** - Comprehensive scaffold, patterns, and checklist +- **[Sync Strategies](SYNC_STRATEGIES.md)** - Deduplication, upserts, and tag management +- **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Complete reference for Plot, Store, Integrations, and more +- **[Multi-User Auth](MULTI_USER_AUTH.md)** - Per-user auth for write-backs diff --git a/twister/docs/BUILDING_TOOLS.md b/twister/docs/BUILDING_TOOLS.md deleted file mode 100644 index 5f3b85f..0000000 --- a/twister/docs/BUILDING_TOOLS.md +++ /dev/null @@ -1,928 +0,0 @@ ---- -title: Building Custom Tools -group: Guides ---- - -# Building Custom Tools - -Custom tools let you create reusable functionality that can be shared across twists or published for others to use. This guide covers everything you need to know about building tools. - -## Table of Contents - -- [Why Build Tools?](#why-build-tools) -- [Tool Basics](#tool-basics) -- [Tool Structure](#tool-structure) -- [Lifecycle Methods](#lifecycle-methods) -- [Dependencies](#dependencies) -- [Options and Configuration](#options-and-configuration) -- [Complete Examples](#complete-examples) -- [Testing Tools](#testing-tools) -- [Publishing Tools](#publishing-tools) -- [Best Practices](#best-practices) - ---- - -## Why Build Tools? - -Build custom tools when you need to: - -- **Integrate external services** - GitHub, Slack, Notion, etc. -- **Encapsulate complex logic** - Reusable business logic -- **Share functionality** - Between multiple twists -- **Abstract implementation details** - Clean interfaces for common operations - -### Built-in vs. Custom Tools - -| Built-in Tools | Custom Tools | -| ------------------------------ | ------------------------------------ | -| Plot, Store, AI, Network, etc. | Your integrations and utilities | -| Access to Plot internals | Built on top of built-in tools | -| Provided by twist Builder | Created by you or installed from npm | -| Always available | Declared as dependencies | - ---- - -## Tool Basics - -Tools extend the `Tool` base class and can access other tools through dependencies. - -### Minimal Tool Example - -```typescript -import { Tool, type ToolBuilder } from "@plotday/twister"; - -export class HelloTool extends Tool { - async sayHello(name: string): Promise { - return `Hello, ${name}!`; - } -} -``` - -### Using Your Tool - -```typescript -import { type ToolBuilder, twist } from "@plotday/twister"; - -import { HelloTool } from "./tools/hello"; - -export default class MyTwist extends Twist { - build(build: ToolBuilder) { - return { - hello: build(HelloTool), - }; - } - - async activate() { - const message = await this.tools.hello.sayHello("World"); - console.log(message); // "Hello, World!" - } -} -``` - ---- - -## Tool Structure - -### Class Definition - -```typescript -import { Tool, type ToolBuilder } from "@plotday/twister"; - -// Tool class with type parameter -export class MyTool extends Tool { - // Constructor receives id, options, and toolShed - constructor(id: string, options: InferOptions, toolShed: ToolShed) { - super(id, options, toolShed); - } - - // Public methods - async myMethod(): Promise { - // Implementation - } -} -``` - -### Type Parameter - -The type parameter `` enables: - -- Type-safe options inference -- Type-safe tool dependencies -- Proper TypeScript autocomplete - ---- - -## Lifecycle Methods - -Tools have lifecycle methods that run at specific times during the twist lifecycle. - -### preActivate(priority) - -Called **before** the twist's `activate()` method, depth-first. - -```typescript -async preActivate(priority: Priority): Promise { - // Setup that needs to happen before twist activation - console.log("Tool preparing for activation"); - - // Initialize connections, validate configuration, etc. -} -``` - -**Use for:** - -- Validating configuration -- Setting up connections -- Preparing resources - -### postActivate(priority) - -Called **after** the twist's `activate()` method, reverse order. - -```typescript -async postActivate(priority: Priority): Promise { - // Finalization after twist is activated - console.log("Tool finalizing activation"); - - // Start background processes, register webhooks, etc. -} -``` - -**Use for:** - -- Starting background processes -- Registering webhooks -- Final initialization - -### preUpgrade() - -Called **before** the twist's `upgrade()` method. - -**Use for:** - -- Preparing data migrations -- Checking tool version compatibility -- Handling breaking changes to callback signatures - -```typescript -async preUpgrade(): Promise { - // Prepare for upgrade - const version = await this.get("tool_version"); - - if (version === "1.0.0") { - // Migrate data - } -} -``` - -**IMPORTANT:** Tool callbacks automatically upgrade to the new version. Callbacks are resolved by function name at execution time, so callbacks created in v1.0 will use v2.0's code after upgrade. Maintain backward compatibility in callback signatures or recreate callbacks in `preUpgrade()`: - -```typescript -async preUpgrade(): Promise { - const version = await this.get("tool_version"); - - if (version === "1.0.0") { - // Handle breaking change: recreate callbacks with new signature - const syncs = await this.get("active_syncs"); - for (const syncId of syncs) { - // Delete old callback - const oldCallback = await this.get(`sync_${syncId}`); - if (oldCallback) await this.deleteCallback(oldCallback); - - // Create new callback with updated signature - const newCallback = await this.callback("syncBatchV2", syncId); - await this.set(`sync_${syncId}`, newCallback); - } - } -} -``` - -### postUpgrade() - -Called **after** the twist's `upgrade()` method. - -```typescript -async postUpgrade(): Promise { - // Finalize upgrade - await this.set("tool_version", "2.0.0"); -} -``` - -### preDeactivate() - -Called **before** the twist's `deactivate()` method. - -```typescript -async preDeactivate(): Promise { - // Cleanup before deactivation - await this.stopBackgroundProcesses(); -} -``` - -### postDeactivate() - -Called **after** the twist's `deactivate()` method. - -```typescript -async postDeactivate(): Promise { - // Final cleanup - await this.clearAll(); -} -``` - -### Execution Order - -``` -twist Activation: - 1. Tool.preActivate() (deepest dependencies first) - 2. twist.activate() - 3. Tool.postActivate() (top-level tools first) - -twist Deactivation: - 1. Tool.preDeactivate() (deepest dependencies first) - 2. twist.deactivate() - 3. Tool.postDeactivate() (top-level tools first) -``` - ---- - -## Dependencies - -Tools can depend on other tools, including built-in tools. - -### Declaring Dependencies - -```typescript -import { Tool, type ToolBuilder } from "@plotday/twister"; -import { Network } from "@plotday/twister/tools/network"; -import { Store } from "@plotday/twister/tools/store"; - -export class GitHubTool extends Tool { - // Declare dependencies - build(build: ToolBuilder) { - return { - network: build(Network, { - urls: ["https://api.github.com/*"], - }), - store: build(Store), - }; - } - - // Access dependencies - async getRepository(owner: string, repo: string) { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}` - ); - return await response.json(); - } -} -``` - -### Accessing Dependencies - -Use `this.tools` to access declared dependencies: - -```typescript -async fetchData() { - // Tools are fully typed - const data = await this.tools.network.fetch("https://api.example.com/data"); - await this.tools.store.set("cached_data", data); -} -``` - -### Built-in Tool Access - -Tools have direct access to Store, Tasks, and Callbacks methods: - -```typescript -export class MyTool extends Tool { - async doWork() { - // Store - await this.set("key", "value"); - const value = await this.get("key"); - - // Tasks - const callback = await this.callback("processData"); - await this.runTask(callback); - - // Callbacks - await this.deleteCallback(callback); - } -} -``` - ---- - -## Options and Configuration - -Tools can accept configuration options when declared. - -### Defining Options - -```typescript -import { Tool, type ToolBuilder, type InferOptions } from "@plotday/twister"; - -export class SlackTool extends Tool { - // Define static Options type - static Options = { - workspaceId: "" as string, - defaultChannel?: "" as string | undefined, - }; - - // Access via this.options - async postMessage(message: string, channel?: string) { - const targetChannel = channel || this.options.defaultChannel; - - if (!targetChannel) { - throw new Error("No channel specified"); - } - - console.log(`Posting to ${targetChannel} in ${this.options.workspaceId}`); - // Post message... - } -} -``` - -### Using Options - -```typescript -build(build: ToolBuilder) { - return { - slack: build(SlackTool, { - workspaceId: "T1234567", - defaultChannel: "#general" - }), - }; -} -``` - -### Required vs. Optional Options - -```typescript -static Options = { - // Required - no default value, not undefined - apiKey: "" as string, - workspaceId: "" as string, - - // Optional - has undefined as possible value - defaultChannel?: "" as string | undefined, - timeout?: 0 as number | undefined, - - // Optional with default - retryCount: 3 as number, -}; -``` - ---- - -## Complete Examples - -### Example 1: GitHub Integration Tool - -A complete GitHub integration with webhooks and issue management. - -```typescript -import { type Priority, Tool, type ToolBuilder } from "@plotday/twister"; -import { ActivityLinkType, ActivityType } from "@plotday/twister"; -import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { Plot } from "@plotday/twister/tools/plot"; - -export class GitHubTool extends Tool { - static Options = { - owner: "" as string, - repo: "" as string, - token: "" as string, - }; - - build(build: ToolBuilder) { - return { - network: build(Network, { - urls: ["https://api.github.com/*"], - }), - plot: build(Plot), - }; - } - - async postActivate(priority: Priority): Promise { - // Set up webhook for issue updates - const webhookUrl = await this.tools.network.createWebhook("onIssueUpdate", { - priorityId: priority.id, - }); - - await this.set("webhook_url", webhookUrl); - - // Register webhook with GitHub - await this.registerWebhook(webhookUrl); - } - - async preDeactivate(): Promise { - // Cleanup webhook - const webhookUrl = await this.get("webhook_url"); - if (webhookUrl) { - await this.unregisterWebhook(webhookUrl); - await this.tools.network.deleteWebhook(webhookUrl); - } - } - - async getIssues(): Promise { - const response = await fetch( - `https://api.github.com/repos/${this.options.owner}/${this.options.repo}/issues`, - { - headers: { - Authorization: `Bearer ${this.options.token}`, - Accept: "application/vnd.github.v3+json", - }, - } - ); - - return await response.json(); - } - - async syncIssues(): Promise { - const issues = await this.getIssues(); - - for (const issue of issues) { - // Use source for automatic deduplication - no manual ID tracking needed - await this.tools.plot.createActivity({ - source: issue.html_url, // Enables automatic upserts - type: ActivityType.Action, - title: issue.title, - meta: { - github_issue_id: issue.id.toString(), - github_number: issue.number.toString(), - }, - notes: [ - { - activity: { source: issue.html_url }, - key: "description", // Using key enables upserts - content: issue.body, - links: [ - { - type: ActivityLinkType.external, - title: "View on GitHub", - url: issue.html_url, - }, - ], - }, - ], - }); - } - } - - // Note: For advanced sync patterns (batching, pagination, etc.), - // see the Sync Strategies guide: https://twist.plot.day/documents/Sync_Strategies.html - - async onIssueUpdate( - request: WebhookRequest, - context: { priorityId: string } - ): Promise { - const { action, issue, comment } = request.body; - - if (action === "opened") { - // Create new activity for new issue with initial Note - await this.tools.plot.createActivity({ - type: ActivityType.Action, - title: issue.title, - meta: { - github_issue_id: issue.id.toString(), - }, - notes: [ - { - note: issue.body || "No description provided", - links: [ - { - type: ActivityLinkType.external, - title: "View on GitHub", - url: issue.html_url, - }, - ], - }, - ], - }); - } else if (action === "created" && comment) { - // Add comment as Note to existing Activity - const activity = await this.tools.plot.getActivityBySource({ - github_issue_id: issue.id.toString(), - }); - - if (activity) { - await this.tools.plot.createNote({ - activity: { id: activity.id }, - note: comment.body, - // author could be set if you have user mapping - }); - } - } else if (action === "closed") { - // Mark activity as done - const activity = await this.tools.plot.getActivityBySource({ - github_issue_id: issue.id.toString(), - }); - - if (activity) { - await this.tools.plot.updateActivity(activity.id, { - done: new Date(), - }); - } - } - } - - private async registerWebhook(url: string): Promise { - await fetch( - `https://api.github.com/repos/${this.options.owner}/${this.options.repo}/hooks`, - { - method: "POST", - headers: { - Authorization: `Bearer ${this.options.token}`, - Accept: "application/vnd.github.v3+json", - }, - body: JSON.stringify({ - config: { url, content_type: "json" }, - events: ["issues"], - }), - } - ); - } - - private async unregisterWebhook(url: string): Promise { - // Implementation to remove webhook from GitHub - } -} -``` - -### Example 2: Slack Notification Tool - -A tool for sending Slack notifications. - -```typescript -import { Tool, type ToolBuilder } from "@plotday/twister"; -import { Network } from "@plotday/twister/tools/network"; - -export class SlackTool extends Tool { - static Options = { - webhookUrl: "" as string, - defaultChannel?: "" as string | undefined, - }; - - build(build: ToolBuilder) { - return { - network: build(Network, { - urls: ["https://hooks.slack.com/*"] - }), - }; - } - - async sendMessage(options: { - text: string; - channel?: string; - username?: string; - }): Promise { - const payload = { - text: options.text, - channel: options.channel || this.options.defaultChannel, - username: options.username || "Plot Bot" - }; - - const response = await fetch(this.options.webhookUrl, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - throw new Error(`Slack API error: ${response.statusText}`); - } - } - - async sendAlert(message: string): Promise { - await this.sendMessage({ - text: `:warning: ${message}`, - channel: "#alerts" - }); - } -} -``` - ---- - -## Testing Tools - -### Unit Testing - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; - -import { GitHubTool } from "./github-tool"; - -describe("GitHubTool", () => { - let tool: GitHubTool; - - beforeEach(() => { - tool = new GitHubTool( - "test-id", - { - owner: "test-owner", - repo: "test-repo", - token: "test-token", - }, - mockToolShed - ); - }); - - it("fetches issues", async () => { - const issues = await tool.getIssues(); - expect(issues).toBeInstanceOf(Array); - }); - - it("validates configuration", () => { - expect(tool.options.owner).toBe("test-owner"); - expect(tool.options.repo).toBe("test-repo"); - }); -}); -``` - -### Integration Testing - -Test your tool with a real twist: - -```typescript -import { type ToolBuilder, twist } from "@plotday/twister"; -import { Plot } from "@plotday/twister/tools/plot"; - -import { GitHubTool } from "./github-tool"; - -class TestTwist extends Twist { - build(build: ToolBuilder) { - return { - plot: build(Plot), - github: build(GitHubTool, { - owner: "plotday", - repo: "plot", - token: process.env.GITHUB_TOKEN!, - }), - }; - } - - async activate() { - // Test syncing - await this.tools.github.syncIssues(); - } -} -``` - ---- - -## Publishing Tools - -### Package Structure - -``` -my-plot-tool/ -├── src/ -│ └── index.ts # Tool implementation -├── package.json -├── tsconfig.json -├── README.md -└── LICENSE -``` - -### package.json - -```json -{ - "name": "@mycompany/plot-github-tool", - "version": "1.0.0", - "description": "GitHub integration tool for Plot twists", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "tsc", - "test": "vitest" - }, - "peerDependencies": { - "@plotday/twister": "^0.16.0" - }, - "devDependencies": { - "@plotday/twister": "^0.16.0", - "typescript": "^5.0.0" - } -} -``` - -### Publishing - -```bash -# Build -npm run build - -# Test -npm test - -# Publish -npm publish -``` - -### Documentation - -Include comprehensive README with: - -- Installation instructions -- Configuration options -- Usage examples -- API reference - ---- - -## Best Practices - -### 1. Single Responsibility - -Each tool should have a single, well-defined purpose: - -```typescript -// ✅ GOOD - Focused on GitHub -class GitHubTool extends Tool { - async getIssues() { - /* ... */ - } - async createIssue() { - /* ... */ - } -} - -// ❌ BAD - Mixed concerns -class IntegrationTool extends Tool { - async getGitHubIssues() { - /* ... */ - } - async sendSlackMessage() { - /* ... */ - } - async createJiraTicket() { - /* ... */ - } -} -``` - -### 2. Type Safety - -Use TypeScript features for type safety: - -```typescript -export interface GitHubIssue { - id: number; - title: string; - body: string; - state: "open" | "closed"; -} - -export class GitHubTool extends Tool { - async getIssues(): Promise { - // Return type is enforced - } -} -``` - -### 3. Error Handling - -Handle errors gracefully: - -```typescript -async fetchData(): Promise { - try { - const response = await fetch(this.apiUrl); - - if (!response.ok) { - console.error(`API error: ${response.status}`); - return null; - } - - return await response.json(); - } catch (error) { - console.error("Network error:", error); - return null; - } -} -``` - -### 4. Configuration Validation - -Validate options in preActivate: - -```typescript -async preActivate(priority: Priority): Promise { - if (!this.options.apiKey) { - throw new Error("API key is required"); - } - - if (!this.options.workspaceId.startsWith("T")) { - throw new Error("Invalid workspace ID format"); - } -} -``` - -### 5. Resource Cleanup - -Always clean up resources in deactivation: - -```typescript -async postDeactivate(): Promise { - // Cancel pending tasks - await this.cancelAllTasks(); - - // Delete callbacks - await this.deleteAllCallbacks(); - - // Clear stored data - await this.clearAll(); -} -``` - -### 6. Avoid Instance State - -Use Store instead of instance variables: - -```typescript -// ❌ WRONG - Instance state doesn't persist -class MyTool extends Tool { - private cache: Map = new Map(); -} - -// ✅ CORRECT - Use Store -class MyTool extends Tool { - async getFromCache(key: string) { - return await this.get(`cache:${key}`); - } - - async setInCache(key: string, value: any) { - await this.set(`cache:${key}`, value); - } -} -``` - -### 7. Document Your API - -Add JSDoc comments for documentation: - -````typescript -/** - * Fetches all open issues from the GitHub repository. - * - * @returns Promise resolving to array of GitHub issues - * @throws Error if GitHub API is unavailable - * - * @example - * ```typescript - * const issues = await this.tools.github.getIssues(); - * ``` - */ -async getIssues(): Promise { - // Implementation -} -```` - -### 8. Data Synchronization - -When building tools that sync from external systems, use the recommended patterns for deduplication and updates: - -**Recommended:** Use `Activity.source` and `Note.key` for automatic upserts: - -```typescript -// ✅ GOOD - Automatic deduplication via source -async syncItems(items: ExternalItem[]): Promise { - for (const item of items) { - await this.tools.plot.createActivity({ - source: item.url, // Canonical URL for deduplication - type: ActivityType.Action, - title: item.title, - notes: [{ - activity: { source: item.url }, - key: "description", // Enables note upserts - content: item.description, - }], - }); - } -} - -// ❌ BAD - Manual ID tracking (only use for advanced cases) -async syncItemsManual(items: ExternalItem[]): Promise { - for (const item of items) { - const activityId = await this.get(`item:${item.id}`) || Uuid.Generate(); - await this.set(`item:${item.id}`, activityId); - await this.tools.plot.createActivity({ - id: activityId, - // ... missing automatic deduplication - }); - } -} -``` - -For comprehensive guidance on choosing the right sync strategy (upserts, batching, pagination, tag management, etc.), see the **[Sync Strategies Guide](SYNC_STRATEGIES.md)**. - ---- - -## Next Steps - -- **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Learn from built-in tool patterns -- **API Reference** - Explore the Tool class API diff --git a/twister/docs/CORE_CONCEPTS.md b/twister/docs/CORE_CONCEPTS.md index 754bf40..009a8d8 100644 --- a/twister/docs/CORE_CONCEPTS.md +++ b/twister/docs/CORE_CONCEPTS.md @@ -65,11 +65,12 @@ export default class MyTwist extends Twist { Use twists for: -- **Integrations** - Connecting external services (Google Calendar, GitHub, Slack) - **Automations** - Automatic task creation, reminders, status updates - **Data Processing** - Analyzing and organizing activities - **Notifications** - Sending alerts based on conditions +For external service integrations (Google Calendar, GitHub, Slack, etc.), build a **Source** instead. Sources extend `Source` (which itself extends `Twist`) and provide the OAuth and channel lifecycle needed for syncing external data. See [Building Sources](BUILDING_SOURCES.md). + --- ## Twist Tools @@ -92,15 +93,11 @@ Core Plot functionality provided by the Twist Creator: See the [Built-in Tools Guide](TOOLS_GUIDE.md) for complete documentation. -#### 2. Custom Tools - -Tools you create or install from npm packages: +#### 2. Sources -- **External Service Integrations** - Google Calendar, Slack, GitHub -- **Data Processors** - Text analysis, image processing -- **Utilities** - Date formatting, validation +External service integrations are built as Sources, which extend `Source`. Sources declare OAuth providers, expose channels for users to enable/disable, and sync data from services like Google Calendar, Slack, GitHub, and more. -See [Building Custom Tools](BUILDING_TOOLS.md) to create your own. +See [Building Sources](BUILDING_SOURCES.md) to create your own. ### Declaring Tool Dependencies @@ -758,5 +755,5 @@ await this.tools.plot.createActivity({ ## Next Steps - **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Learn about Plot, Store, AI, and more -- **[Building Custom Tools](BUILDING_TOOLS.md)** - Create reusable tools +- **[Building Sources](BUILDING_SOURCES.md)** - Build external service integrations - **[Runtime Environment](RUNTIME.md)** - Understand execution constraints diff --git a/twister/docs/GETTING_STARTED.md b/twister/docs/GETTING_STARTED.md index 5a3f933..10d4893 100644 --- a/twister/docs/GETTING_STARTED.md +++ b/twister/docs/GETTING_STARTED.md @@ -213,7 +213,7 @@ Now that you have a basic twist running, explore: - **[Core Concepts](CORE_CONCEPTS.md)** - Understand twists, tools, and the Plot architecture - **[Built-in Tools](TOOLS_GUIDE.md)** - Learn about Plot, Store, Integrations, AI, and more -- **[Building Custom Tools](BUILDING_TOOLS.md)** - Create your own reusable twist tools +- **[Building Sources](BUILDING_SOURCES.md)** - Build external service integrations - **[Runtime Environment](RUNTIME.md)** - Understand execution constraints and optimization ## Common First Tasks diff --git a/twister/docs/MULTI_USER_AUTH.md b/twister/docs/MULTI_USER_AUTH.md index a87f62d..bfffcac 100644 --- a/twister/docs/MULTI_USER_AUTH.md +++ b/twister/docs/MULTI_USER_AUTH.md @@ -1,6 +1,6 @@ # Multi-User Priority Auth -Twists and tools operating in shared priorities must handle authentication for multiple users. This guide covers the patterns for per-user auth and private auth activities. +Twists and sources operating in shared priorities must handle authentication for multiple users. This guide covers the patterns for per-user auth and private auth activities. ## Auth Models diff --git a/twister/docs/SYNC_STRATEGIES.md b/twister/docs/SYNC_STRATEGIES.md index de0c3b6..201897d 100644 --- a/twister/docs/SYNC_STRATEGIES.md +++ b/twister/docs/SYNC_STRATEGIES.md @@ -1,6 +1,6 @@ # Sync Strategies -This guide explains good ways to build tools that sync other services with Plot. Choosing the right strategy depends on whether you need to update items, deduplicate them, or simply create them once. +This guide explains good ways to build sources that sync other services with Plot. Choosing the right strategy depends on whether you need to update items, deduplicate them, or simply create them once. ## Table of Contents @@ -115,7 +115,7 @@ interface Activity { ### Example: Calendar Event Sync ```typescript -export default class GoogleCalendarTool extends Tool { +export default class GoogleCalendarSource extends Source { async syncEvent(event: calendar_v3.Schema$Event): Promise { const activity: NewActivityWithNotes = { // Use the event's canonical URL as the source @@ -168,7 +168,7 @@ export default class GoogleCalendarTool extends Tool { ### Example: Task/Issue Sync ```typescript -export default class LinearTool extends Tool { +export default class LinearSource extends Source { async syncIssue(issue: LinearIssue): Promise { const activity: NewActivityWithNotes = { source: issue.url, // Linear provides stable URLs @@ -274,7 +274,7 @@ Use this strategy when: ### Example: Multiple Activities from Single Source ```typescript -export default class EmailTool extends Tool { +export default class GmailSource extends Source { /** * Creates separate activities for email threads and individual messages. * One email thread can have multiple Plot activities. @@ -694,9 +694,9 @@ if (existingId) { ## Best Practices -### 1. Be Consistent Within a Tool +### 1. Be Consistent Within a Source -Choose one strategy per tool and stick with it. Mixing strategies in the same tool can lead to confusion and bugs. +Choose one strategy per source and stick with it. Mixing strategies in the same source can lead to confusion and bugs. ### 2. Use Descriptive Keys @@ -805,4 +805,4 @@ For more information: - [Core Concepts](CORE_CONCEPTS.md) - Understanding activities, notes, and priorities - [Tools Guide](TOOLS_GUIDE.md) - Complete reference for the Plot tool -- [Building Tools](BUILDING_TOOLS.md) - Creating custom tools +- [Building Sources](BUILDING_SOURCES.md) - Creating external service integrations diff --git a/twister/docs/TOOLS_GUIDE.md b/twister/docs/TOOLS_GUIDE.md index 47e346e..263b3b8 100644 --- a/twister/docs/TOOLS_GUIDE.md +++ b/twister/docs/TOOLS_GUIDE.md @@ -1104,8 +1104,61 @@ async triageEmail(emailContent: string) { --- +## Link Type Safety Pattern + +When defining `linkTypes` in your source's provider config, use `as const satisfies` to get type-safe status strings: + +```typescript +import type { LinkTypeConfig } from "@plotday/twister/tools/integrations"; + +const LINK_TYPES = [ + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/simple-icons/linear.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + { + type: "pull_request", + label: "Pull Request", + logo: "https://api.iconify.design/simple-icons/github.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "merged", label: "Merged" }, + { status: "closed", label: "Closed" }, + ], + }, +] as const satisfies LinkTypeConfig[]; + +// Derive type-safe union types from the config +type IssueStatus = (typeof LINK_TYPES)[0]["statuses"][number]["status"]; // "open" | "done" +type PRStatus = (typeof LINK_TYPES)[1]["statuses"][number]["status"]; // "open" | "merged" | "closed" +``` + +Then reference `LINK_TYPES` in your provider config: + +```typescript +build(build: SourceBuilder) { + return { + integrations: build(Integrations, { + providers: [{ + provider: MySource.PROVIDER, + scopes: MySource.SCOPES, + linkTypes: [...LINK_TYPES], + // ... + }], + }), + }; +} +``` + +--- + ## Next Steps -- **[Building Custom Tools](BUILDING_TOOLS.md)** - Create your own reusable tools +- **[Building Sources](BUILDING_SOURCES.md)** - Build external service integrations - **[Runtime Environment](RUNTIME.md)** - Understanding execution constraints - **API Reference** - Explore detailed API docs in the sidebar diff --git a/twister/docs/index.md b/twister/docs/index.md index b974266..d6396bf 100644 --- a/twister/docs/index.md +++ b/twister/docs/index.md @@ -40,12 +40,11 @@ Plot Twists are smart automations that connect, organize, and prioritize your wo - Callbacks - Persistent function references - AI - Language model integration -- **[Building Custom Tools](BUILDING_TOOLS.md)** - Create your own twist tools - - Tool class structure - - Lifecycle methods - - Dependencies and configuration +- **[Building Sources](BUILDING_SOURCES.md)** - Build external service integrations + - Source class structure and lifecycle + - OAuth and channel management + - Data sync and batch processing - Complete examples and best practices - - Publishing and sharing ### Reference @@ -67,7 +66,7 @@ Plot Twists are smart automations that connect, organize, and prioritize your wo Explore the complete API documentation using the navigation on the left: -- **Classes** - Twist, Tool, and all built-in tool classes +- **Classes** - Twist, Source, Tool, and all built-in tool classes - **Interfaces** - Activity, Priority, Contact, and data structures - **Enums** - ActivityType, ActorType, and other enumerations - **Type Aliases** - Helper types and utilities diff --git a/twister/package.json b/twister/package.json index 49d1e78..a60b8e1 100644 --- a/twister/package.json +++ b/twister/package.json @@ -25,11 +25,21 @@ "types": "./dist/tool.d.ts", "default": "./dist/tool.js" }, + "./source": { + "@plotday/source": "./src/source.ts", + "types": "./dist/source.d.ts", + "default": "./dist/source.js" + }, "./plot": { "@plotday/source": "./src/plot.ts", "types": "./dist/plot.d.ts", "default": "./dist/plot.js" }, + "./schedule": { + "@plotday/source": "./src/schedule.ts", + "types": "./dist/schedule.d.ts", + "default": "./dist/schedule.js" + }, "./tag": { "@plotday/source": "./src/tag.ts", "types": "./dist/tag.d.ts", @@ -95,31 +105,6 @@ "types": "./dist/utils/uuid.d.ts", "default": "./dist/utils/uuid.js" }, - "./common/calendar": { - "@plotday/source": "./src/common/calendar.ts", - "types": "./dist/common/calendar.d.ts", - "default": "./dist/common/calendar.js" - }, - "./common/messaging": { - "@plotday/source": "./src/common/messaging.ts", - "types": "./dist/common/messaging.d.ts", - "default": "./dist/common/messaging.js" - }, - "./common/projects": { - "@plotday/source": "./src/common/projects.ts", - "types": "./dist/common/projects.d.ts", - "default": "./dist/common/projects.js" - }, - "./common/documents": { - "@plotday/source": "./src/common/documents.ts", - "types": "./dist/common/documents.d.ts", - "default": "./dist/common/documents.js" - }, - "./common/source-control": { - "@plotday/source": "./src/common/source-control.ts", - "types": "./dist/common/source-control.d.ts", - "default": "./dist/common/source-control.js" - }, "./twist-guide": { "@plotday/source": "./src/twist-guide.ts", "types": "./dist/twist-guide.d.ts", @@ -195,31 +180,6 @@ "types": "./dist/llm-docs/tools/store.d.ts", "default": "./dist/llm-docs/tools/store.js" }, - "./llm-docs/common/calendar": { - "@plotday/source": "./src/llm-docs/common/calendar.ts", - "types": "./dist/llm-docs/common/calendar.d.ts", - "default": "./dist/llm-docs/common/calendar.js" - }, - "./llm-docs/common/messaging": { - "@plotday/source": "./src/llm-docs/common/messaging.ts", - "types": "./dist/llm-docs/common/messaging.d.ts", - "default": "./dist/llm-docs/common/messaging.js" - }, - "./llm-docs/common/projects": { - "@plotday/source": "./src/llm-docs/common/projects.ts", - "types": "./dist/llm-docs/common/projects.d.ts", - "default": "./dist/llm-docs/common/projects.js" - }, - "./llm-docs/common/documents": { - "@plotday/source": "./src/llm-docs/common/documents.ts", - "types": "./dist/llm-docs/common/documents.d.ts", - "default": "./dist/llm-docs/common/documents.js" - }, - "./llm-docs/common/source-control": { - "@plotday/source": "./src/llm-docs/common/source-control.ts", - "types": "./dist/llm-docs/common/source-control.d.ts", - "default": "./dist/llm-docs/common/source-control.js" - }, "./tsconfig.base.json": "./tsconfig.base.json" }, "files": [ diff --git a/twister/src/common/calendar.ts b/twister/src/common/calendar.ts deleted file mode 100644 index 00ad218..0000000 --- a/twister/src/common/calendar.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { NewActivityWithNotes, Serializable } from "../index"; - -/** - * Represents a calendar from an external calendar service. - * - * Contains metadata about a specific calendar that can be synced - * with Plot. Different calendar providers may have additional - * provider-specific properties. - */ -export type Calendar = { - /** Unique identifier for the calendar within the provider */ - id: string; - /** Human-readable name of the calendar */ - name: string; - /** Optional description or additional details about the calendar */ - description: string | null; - /** Whether this is the user's primary/default calendar */ - primary: boolean; -}; - -/** - * Configuration options for calendar synchronization. - * - * Controls the time range and other parameters for calendar sync operations. - * Used to limit sync scope and optimize performance. - */ -export type SyncOptions = { - /** - * Earliest date to sync events from (inclusive). - * - If undefined: defaults to 2 years in the past - * - If null: syncs all history from the beginning of time - * - If Date: syncs from the specified date - */ - timeMin?: Date | null; - /** - * Latest date to sync events to (exclusive). - * - If undefined: no limit (syncs all future events) - * - If null: no limit (syncs all future events) - * - If Date: syncs up to but not including the specified date - * - * Use cases: - * - Daily digest: Set to end of today - * - Week view: Set to end of current week - * - Performance: Limit initial sync range - */ - timeMax?: Date | null; -}; - -/** - * Base interface for calendar integration tools. - * - * Defines the standard operations that all calendar tools must implement - * to integrate with external calendar services like Google Calendar, - * Outlook Calendar, etc. - * - * **Architecture: Tools Build, Twists Save** - * - * Calendar 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.) - * - * This separation allows: - * - Tools to be reusable across different twists with different behaviors - * - Twists to have full control over what gets saved and how - * - Easier testing of tools in isolation - * - * **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 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 - * - **DO** pass them to the twist via the callback - * - **DON'T** call plot.createActivity/updateActivity directly - * - **DON'T** save anything to Plot database - * - * **Recommended Data Sync Strategy:** - * Use Activity.source and Note.key for automatic upserts without manual ID tracking. - * See SYNC_STRATEGIES.md for detailed patterns and when to use alternative approaches. - * - * @example - * ```typescript - * class MyCalendarTwist extends Twist { - * build(build: ToolBuilder) { - * return { - * googleCalendar: build(GoogleCalendar), - * plot: build(Plot, { activity: { access: ActivityAccess.Create } }), - * }; - * } - * - * // Auth and calendar selection handled in the twist edit modal. - * // Events are delivered via the startSync callback. - * } - * ``` - */ -export type CalendarTool = { - /** - * Retrieves the list of calendars accessible to the authenticated user. - * - * @param calendarId - A calendar ID to use for auth lookup - * @returns Promise resolving to array of available calendars - * @throws When no valid authorization is available - */ - getCalendars(calendarId: string): Promise; - - /** - * Begins synchronizing events from a specific calendar. - * - * Sets up real-time sync for the specified calendar, including initial - * event import and ongoing change notifications. The callback function - * will be invoked for each synced event. - * - * Auth is obtained automatically via integrations.get(provider, calendarId). - * - * @param options - Sync configuration options - * @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 no valid authorization or calendar doesn't exist - */ - startSync< - TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any - >( - options: { - calendarId: string; - } & SyncOptions, - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - - /** - * Stops synchronizing events from a specific calendar. - * - * @param calendarId - ID of the calendar to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(calendarId: string): Promise; -}; diff --git a/twister/src/common/documents.ts b/twister/src/common/documents.ts deleted file mode 100644 index 40d1306..0000000 --- a/twister/src/common/documents.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { - ActivityMeta, - NewActivityWithNotes, - Serializable, -} from "../index"; - -/** - * Represents a folder from an external document service. - * - * Contains metadata about a specific folder that can be synced - * with Plot. Different document providers may have additional - * provider-specific properties. - */ -export type DocumentFolder = { - /** Unique identifier for the folder within the provider */ - id: string; - /** Human-readable name of the folder */ - name: string; - /** Optional description or additional details about the folder */ - description: string | null; - /** Whether this is a root-level folder (e.g., "My Drive" in Google Drive) */ - root: boolean; -}; - -/** - * Configuration options for document synchronization. - * - * Controls the time range and other parameters for document sync operations. - * Used to limit sync scope and optimize performance. - */ -export type DocumentSyncOptions = { - /** Earliest date to sync documents from (inclusive) */ - timeMin?: Date; -}; - -/** - * Base interface for document service integration tools. - * - * 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. - * - * - Set `Activity.source` to `"{provider}:file:{fileId}"` (e.g., `"google-drive:file:abc123"`) - * - Use `Note.key` for document details: - * - key: `"summary"` for the document description or metadata summary - * - key: `"comment-{commentId}"` for individual comments (unique per comment) - * - key: `"reply-{commentId}-{replyId}"` for comment replies - * - No manual ID tracking needed - Plot handles deduplication automatically - * - Send NewActivityWithNotes for all documents (creates new or updates existing) - * - Set `activity.unread = false` for initial sync, omit for incremental updates - */ -export type DocumentTool = { - /** - * Retrieves the list of folders accessible to the user. - * - * @param folderId - A folder ID to use for auth lookup - * @returns Promise resolving to array of available folders - */ - getFolders(folderId: string): Promise; - - /** - * Begins synchronizing documents from a specific folder. - * - * Documents are converted to NewActivityWithNotes objects. - * - * Auth is obtained automatically via integrations.get(provider, folderId). - * - * @param options - Sync configuration options - * @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 - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) - * @returns Promise that resolves when sync setup is complete - */ - startSync< - TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any - >( - options: { - folderId: string; - } & DocumentSyncOptions, - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - - /** - * Stops synchronizing documents from a specific folder. - * - * @param folderId - ID of the folder to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(folderId: string): Promise; - - /** - * Adds a comment to a document. - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to activities back as comments on the external document. - * - * 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 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?( - meta: ActivityMeta, - body: string, - noteId?: string, - ): Promise; - - /** - * Adds a reply to an existing comment thread on a document. - * - * 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 - * @param noteId - Optional Plot note ID for deduplication - * @returns The external reply key (e.g. "reply-123-456") for dedup, or void - */ - addDocumentReply?( - meta: ActivityMeta, - commentId: string, - body: string, - noteId?: string, - ): Promise; -}; diff --git a/twister/src/common/messaging.ts b/twister/src/common/messaging.ts deleted file mode 100644 index 559ef1b..0000000 --- a/twister/src/common/messaging.ts +++ /dev/null @@ -1,95 +0,0 @@ -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. Different messaging providers may have additional - * provider-specific properties. - */ -export type MessageChannel = { - /** Unique identifier for the channel within the provider */ - id: string; - /** Human-readable name of the channel (e.g., "Inbox", "#general", "My Team Thread") */ - name: string; - /** Optional description or additional details about the channel */ - description: string | null; - /** Whether this is the user's primary/default channel (e.g. email inbox) */ - primary: boolean; -}; - -/** - * Configuration options for messaging synchronization. - * - * Controls the time range and other parameters for messaging sync operations. - * Used to limit sync scope and optimize performance. - */ -export type MessageSyncOptions = { - /** Earliest date to sync events from (inclusive) */ - timeMin?: Date; -}; - -/** - * Base interface for email and chat integration tools. - * - * 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 = { - /** - * Retrieves the list of conversation channels (inboxes, channels) accessible to the user. - * - * @param channelId - A channel ID to use for auth lookup - * @returns Promise resolving to array of available conversation channels - */ - getChannels(channelId: string): Promise; - - /** - * Begins synchronizing messages from a specific channel. - * - * Auth is obtained automatically via integrations.get(provider, channelId). - * - * @param options - Sync configuration options - * @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 - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) - * @returns Promise that resolves when sync setup is complete - */ - startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any - >( - options: { - channelId: string; - } & MessageSyncOptions, - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - - /** - * Stops synchronizing messages from a specific channel. - * - * @param channelId - ID of the channel to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(channelId: string): Promise; -}; diff --git a/twister/src/common/projects.ts b/twister/src/common/projects.ts deleted file mode 100644 index 47bdb45..0000000 --- a/twister/src/common/projects.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { - Activity, - ActivityMeta, - NewActivityWithNotes, - Serializable, -} from "../index"; - -/** - * Represents a project from an external project management service. - * - * Contains metadata about a specific project/board/workspace that can be synced - * with Plot. Different project providers may have additional - * provider-specific properties. - */ -export type Project = { - /** Unique identifier for the project within the provider */ - id: string; - /** Human-readable name of the project (e.g., "Q1 Roadmap", "Engineering") */ - name: string; - /** Optional description or additional details about the project */ - description: string | null; - /** Optional project key/abbreviation (e.g., "PROJ" in Jira, "ENG" in Linear) */ - key: string | null; -}; - -/** - * Configuration options for project synchronization. - * - * Controls the time range and other parameters for project sync operations. - * Used to limit sync scope and optimize performance. - */ -export type ProjectSyncOptions = { - /** Earliest date to sync issues from (inclusive) */ - timeMin?: Date; -}; - -/** - * Base interface for project management integration tools. - * - * 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 = { - /** - * Retrieves the list of projects accessible to the user. - * - * @param projectId - A project ID to use for auth lookup - * @returns Promise resolving to array of available projects - */ - getProjects(projectId: string): Promise; - - /** - * Begins synchronizing issues from a specific project. - * - * Auth is obtained automatically via integrations.get(provider, projectId). - * - * @param options - Sync configuration options - * @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 - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) - * @returns Promise that resolves when sync setup is complete - */ - startSync< - TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any - >( - options: { - projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - - /** - * Stops synchronizing issues from a specific project. - * - * @param projectId - ID of the project to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(projectId: string): Promise; - - /** - * Updates an issue/task with new values. - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync activity updates back to the external service. - * - * Auth is obtained automatically via integrations.get(provider, projectId) - * using the projectId from activity.meta. - * - * @param activity - The updated activity - * @returns Promise that resolves when the update is synced - */ - updateIssue?(activity: Activity): Promise; - - /** - * Adds a comment to an issue/task. - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to activities back as comments on the external service. - * - * Auth is obtained automatically. The tool should extract its own ID - * from meta (e.g., linearId, taskGid, issueKey). - * - * @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?( - meta: ActivityMeta, - body: string, - noteId?: string, - ): Promise; -}; diff --git a/twister/src/common/source-control.ts b/twister/src/common/source-control.ts deleted file mode 100644 index a8996fb..0000000 --- a/twister/src/common/source-control.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { - Activity, - ActivityMeta, - NewActivityWithNotes, - Serializable, -} from "../index"; - -/** - * Represents a repository from an external source control service. - * - * Contains metadata about a specific repository that can be synced - * with Plot. Different source control providers may have additional - * provider-specific properties. - */ -export type Repository = { - /** Unique identifier for the repository within the provider */ - id: string; - /** Human-readable name of the repository (e.g., "my-app") */ - name: string; - /** Optional description or additional details about the repository */ - description: string | null; - /** URL to view the repository in the browser */ - url: string | null; - /** Owner of the repository (user or organization name) */ - owner: string | null; - /** Default branch name (e.g., "main", "master") */ - defaultBranch: string | null; - /** Whether the repository is private */ - private: boolean; -}; - -/** - * Configuration options for source control synchronization. - * - * Controls the time range and other parameters for source control sync operations. - * Used to limit sync scope and optimize performance. - */ -export type SourceControlSyncOptions = { - /** Earliest date to sync pull requests from (inclusive) */ - timeMin?: Date; -}; - -/** - * Base interface for source control integration tools. - * - * All synced pull requests are converted to ActivityWithNotes objects. - * Each PR becomes an Activity with Notes for the description, comments, - * and review summaries. - * - * **Architecture: Tools Build, Twists Save** - * - * Source control 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 repositories and calls setSyncables() - * 4. User enables repositories 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 (PR URL) and Note.key for automatic upserts. - * See SYNC_STRATEGIES.md for detailed patterns. - */ -export type SourceControlTool = { - /** - * Retrieves the list of repositories accessible to the user. - * - * @param repositoryId - A repository ID to use for auth lookup - * @returns Promise resolving to array of available repositories - */ - getRepositories(repositoryId: string): Promise; - - /** - * Begins synchronizing pull requests from a specific repository. - * - * Auth is obtained automatically via integrations.get(provider, repositoryId). - * - * @param options - Sync configuration options - * @param options.repositoryId - ID of the repository to sync (owner/repo format) - * @param options.timeMin - Earliest date to sync PRs from (inclusive) - * @param callback - Function receiving (activity, ...extraArgs) for each synced PR - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) - * @returns Promise that resolves when sync setup is complete - */ - startSync< - TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any - >( - options: { - repositoryId: string; - } & SourceControlSyncOptions, - callback: TCallback, - ...extraArgs: TArgs - ): Promise; - - /** - * Stops synchronizing pull requests from a specific repository. - * - * @param repositoryId - ID of the repository to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(repositoryId: string): Promise; - - /** - * Adds a general comment to a pull request. - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to activities back as comments on the external service. - * - * Auth is obtained automatically. The tool should extract its own ID - * from meta (e.g., prNumber, owner, repo). - * - * @param meta - Activity metadata containing the tool's PR identifier - * @param body - The comment text content - * @param noteId - Optional Plot note ID for dedup - * @returns The external comment key (e.g. "comment-123") for dedup, or void - */ - addPRComment?( - meta: ActivityMeta, - body: string, - noteId?: string, - ): Promise; - - /** - * Updates a pull request's review status (approve, request changes). - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync activity status changes back to the external service. - * - * @param activity - The updated activity with review status - * @returns Promise that resolves when the update is synced - */ - updatePRStatus?(activity: Activity): Promise; - - /** - * Closes a pull request without merging. - * - * Optional method for bidirectional sync. - * - * @param meta - Activity metadata containing the tool's PR identifier - * @returns Promise that resolves when the PR is closed - */ - closePR?(meta: ActivityMeta): Promise; -}; diff --git a/twister/src/index.ts b/twister/src/index.ts index a0bc135..8f3a172 100644 --- a/twister/src/index.ts +++ b/twister/src/index.ts @@ -1,5 +1,7 @@ export * from "./twist"; +export * from "./source"; export * from "./plot"; +export * from "./schedule"; export * from "./tag"; export * from "./tool"; export * from "./tools"; diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 21c53d7..47a0f00 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -1,3 +1,4 @@ +import type { NewSchedule, NewScheduleOccurrence, Schedule } from "./schedule"; import { type Tag } from "./tag"; import { type Callback } from "./tools/callbacks"; import { type AuthProvider } from "./tools/integrations"; @@ -11,15 +12,15 @@ export { type AuthProvider } from "./tools/integrations"; /** * @fileoverview - * Core Plot entity types for working with activities, notes, priorities, and contacts. + * Core Plot entity types for working with threads, notes, priorities, and contacts. * * ## Type Pattern: Null vs Undefined Semantics * * Plot entity types use a consistent pattern to distinguish between missing, unset, and explicitly cleared values: * - * ### Entity Types (Activity, Priority, Note, Actor) + * ### Entity Types (Thread, Priority, Note, Actor) * - **Required fields**: No `?`, cannot be `undefined` - * - Example: `id: Uuid`, `type: ActivityType` + * - Example: `id: Uuid`, `title: string` * - **Nullable fields**: Use `| null` to allow explicit clearing * - Example: `assignee: ActorId | null`, `done: Date | null` * - `null` = field is explicitly unset/cleared @@ -30,10 +31,10 @@ export { type AuthProvider } from "./tools/integrations"; * - `null` = field included but not set * - Value = field has a value * - * ### New* Types (NewActivity, NewNote, NewPriority) + * ### New* Types (NewThread, NewNote, NewPriority) * Used for creating or updating entities. Support partial updates by distinguishing omitted vs cleared fields: * - **Required fields**: Must be provided (no `?`) - * - Example: `type: ActivityType` in NewActivity + * - Example: `title: string` in NewPriority * - **Optional fields**: Use `?` to make them optional * - Example: `title?: string`, `author?: NewActor` * - `undefined` (omitted) = don't set/update this field @@ -51,20 +52,15 @@ export { type AuthProvider } from "./tools/integrations"; * * @example * ```typescript - * // Creating a new activity - * const newActivity: NewActivity = { - * type: ActivityType.Action, // Required - * title: "Review PR", // Optional, provided - * assignee: null, // Optional nullable, explicitly clearing - * // priority is omitted (undefined), will auto-select or use default + * // Creating a new thread + * const newThread: NewThread = { + * title: "Review pull request", * }; * - * // Updating an activity - only change what's specified - * const update: ActivityUpdate = { - * id: activityId, - * done: new Date(), // Mark as done - * assignee: null, // Clear assignee - * // title is omitted, won't be changed + * // Updating a thread - only change what's specified + * const update: ThreadUpdate = { + * id: threadId, + * archived: true, * }; * ``` */ @@ -168,50 +164,17 @@ export type PriorityUpdate = ({ id: Uuid } | { key: string }) & Partial>; /** - * Enumeration of supported activity types in Plot. + * Enumeration of supported action types. * - * Each activity type has different behaviors and rendering characteristics - * within the Plot application. - */ -export enum ActivityType { - /** A note or piece of information without actionable requirements */ - Note, - /** An actionable item that can be completed */ - Action, - /** A scheduled occurrence with start and optional end time */ - Event, -} - -/** - * Kinds of activities. Used only for visual categorization (icon). - */ -export enum ActivityKind { - document = "document", // any external document or item in an external system - messages = "messages", // emails and chat threads - meeting = "meeting", // in-person meeting - videoconference = "videoconference", - phone = "phone", - focus = "focus", - meal = "meal", - exercise = "exercise", - family = "family", - travel = "travel", - social = "social", - entertainment = "entertainment", -} - -/** - * Enumeration of supported activity link types. - * - * Different link types have different behaviors when clicked by users + * Different action types have different behaviors when clicked by users * and may require different rendering approaches. */ -export enum LinkType { +export enum ActionType { /** External web links that open in browser */ external = "external", /** Authentication flows for connecting services */ auth = "auth", - /** Callback links that trigger twist methods when clicked */ + /** Callback actions that trigger twist methods when clicked */ callback = "callback", /** Video conferencing links with provider-specific handling */ conferencing = "conferencing", @@ -239,64 +202,64 @@ export enum ConferencingProvider { } /** - * Represents a clickable link attached to an activity. + * Represents a clickable action attached to a thread. * - * Activity links are rendered as buttons that enable user interaction with activities. - * Different link types have specific behaviors and required fields for proper functionality. + * Thread actions are rendered as buttons that enable user interaction with threads. + * Different action types have specific behaviors and required fields for proper functionality. * * @example * ```typescript - * // External link - opens URL in browser - * const externalLink: Link = { - * type: LinkType.external, + * // External action - opens URL in browser + * const externalAction: Action = { + * type: ActionType.external, * title: "Open in Google Calendar", * url: "https://calendar.google.com/event/123", * }; * - * // Conferencing link - opens video conference with provider info - * const conferencingLink: Link = { - * type: LinkType.conferencing, + * // Conferencing action - opens video conference with provider info + * const conferencingAction: Action = { + * type: ActionType.conferencing, * url: "https://meet.google.com/abc-defg-hij", * provider: ConferencingProvider.googleMeet, * }; * - * // Integrations link - initiates OAuth flow - * const authLink: Link = { - * type: LinkType.auth, + * // Integrations action - initiates OAuth flow + * const authAction: Action = { + * type: ActionType.auth, * title: "Continue with Google", * provider: AuthProvider.Google, * scopes: ["https://www.googleapis.com/auth/calendar.readonly"], * callback: "callback-token-for-auth-completion" * }; * - * // Callback link - triggers a twist method - * const callbackLink: Link = { - * type: LinkType.callback, + * // Callback action - triggers a twist method + * const callbackAction: Action = { + * type: ActionType.callback, * title: "📅 Primary Calendar", * token: "callback-token-here" * }; * ``` */ -export type Link = +export type Action = | { /** External web link that opens in browser */ - type: LinkType.external; - /** Display text for the link button */ + type: ActionType.external; + /** Display text for the action button */ title: string; /** URL to open when clicked */ url: string; } | { - /** Video conferencing link with provider-specific handling */ - type: LinkType.conferencing; + /** Video conferencing action with provider-specific handling */ + type: ActionType.conferencing; /** URL to join the conference */ url: string; /** Conferencing provider for UI customization */ provider: ConferencingProvider; } | { - /** Authentication link that initiates an OAuth flow */ - type: LinkType.auth; + /** Authentication action that initiates an OAuth flow */ + type: ActionType.auth; /** Display text for the auth button */ title: string; /** OAuth provider (e.g., "google", "microsoft") */ @@ -307,16 +270,16 @@ export type Link = callback: Callback; } | { - /** Callback link that triggers a twist method when clicked */ - type: LinkType.callback; + /** Callback action that triggers a twist method when clicked */ + type: ActionType.callback; /** Display text for the callback button */ title: string; /** Token identifying the callback to execute */ callback: Callback; } | { - /** File attachment link stored in R2 */ - type: LinkType.file; + /** File attachment action stored in R2 */ + type: ActionType.file; /** Unique identifier for the stored file */ fileId: string; /** Original filename */ @@ -328,9 +291,9 @@ export type Link = }; /** - * Represents metadata about an activity, typically from an external system. + * Represents metadata about a thread, typically from an external system. * - * Activity metadata enables storing additional information about activities, + * Thread metadata enables storing additional information about threads, * which is useful for synchronization, linking back to external systems, * and storing tool-specific data. * @@ -340,10 +303,8 @@ export type Link = * @example * ```typescript * // Calendar event metadata - * await plot.createActivity({ - * type: ActivityType.Event, + * await plot.createThread({ * title: "Team Meeting", - * start: new Date("2024-01-15T10:00:00Z"), * meta: { * calendarId: "primary", * htmlLink: "https://calendar.google.com/event/abc123", @@ -352,8 +313,7 @@ export type Link = * }); * * // Project issue metadata - * await plot.createActivity({ - * type: ActivityType.Action, + * await plot.createThread({ * title: "Fix login bug", * meta: { * projectId: "TEAM", @@ -363,7 +323,7 @@ export type Link = * }); * ``` */ -export type ActivityMeta = { +export type ThreadMeta = { /** Source-specific properties and metadata */ [key: string]: JSONValue; }; @@ -379,313 +339,62 @@ export type Tags = { [K in Tag]?: ActorId[] }; export type NewTags = { [K in Tag]?: NewActor[] }; /** - * Common fields shared by both Activity and Note entities. + * Common fields shared by both Thread and Note entities. */ -export type ActivityCommon = { - /** Unique identifier for the activity */ +export type ThreadCommon = { + /** Unique identifier for the thread */ id: Uuid; /** - * When this activity was originally created in its source system. - * - * For activities created in Plot, this is when the user created it. - * For activities synced from external systems (GitHub issues, emails, calendar events), - * this is the original creation time in that system. + * When this item was created. * - * Defaults to the current time when creating new activities. + * **For sources:** Set this to the external system's timestamp (e.g., email + * sent date, comment creation date), NOT the sync time. If omitted, defaults + * to the current time, which is almost never correct for synced data. */ created: Date; - /** Information about who created the activity */ - author: Actor; - /** Whether this activity is private (only visible to author) */ + /** Whether this thread is private (only visible to creator) */ private: boolean; - /** Whether this activity has been archived */ + /** Whether this thread has been archived */ archived: boolean; - /** Tags attached to this activity. Maps tag ID to array of actor IDs who added that tag. */ + /** Tags attached to this thread. Maps tag ID to array of actor IDs who added that tag. */ tags: Tags; - /** Array of actor IDs (users, contacts, or twists) mentioned in this activity via @-mentions */ + /** Array of actor IDs (users, contacts, or twists) mentioned in this thread via @-mentions */ mentions: ActorId[]; }; /** - * Common fields shared by all activity types (Note, Action, Event). - * Does not include the discriminant `type` field or type-specific fields like `done`. + * Fields on a Thread entity. + * Threads are simple containers for links and notes. */ -type ActivityFields = ActivityCommon & { - /** - * Globally unique, stable identifier for the item in an external system. - * MUST use immutable system-generated IDs, not human-readable slugs or titles. - * - * Recommended format: `${domain}:${type}:${id}` - * - * Examples: - * - `linear:issue:549dd8bd-2bc9-43d1-95d5-4b4af0c5af1b` (Linear issue by UUID) - * - `jira:10001:issue:12345` (Jira issue by numeric ID with cloud ID) - * - `gmail:thread:18d4e5f2a3b1c9d7` (Gmail thread by system ID) - * - * ⚠️ AVOID: URLs with mutable components like team names or issue keys - * - Bad: `https://linear.app/team/issue/TEAM-123/title` (team and title can change) - * - Bad: `jira:issue:PROJECT-42` (issue key can change) - * - * When set, uniquely identifies the activity within a priority tree for upsert operations. - */ - source: string | null; - /** The display title/summary of the activity */ +type ThreadFields = ThreadCommon & { + /** The display title/summary of the thread */ title: string; - /** Optional kind for additional categorization within the activity */ - kind: ActivityKind | null; - /** - * The actor assigned to this activity. - * - * **For actions (tasks):** - * - If not provided (undefined), defaults to the user who installed the twist (twist owner) - * - To create an **unassigned action**, explicitly set `assignee: null` - * - For synced tasks from external systems, typically set `assignee: null` for unassigned items - * - * **For notes and events:** Assignee is optional and typically null. - * When marking an activity as done, it becomes an Action; if no assignee is set, - * the twist owner is assigned automatically. - * - * @example - * ```typescript - * // Create action assigned to twist owner (default behavior) - * const task: NewActivity = { - * type: ActivityType.Action, - * title: "Follow up on email" - * // assignee omitted → defaults to twist owner - * }; - * - * // Create UNASSIGNED action (for backlog items) - * const backlogTask: NewActivity = { - * type: ActivityType.Action, - * title: "Review PR #123", - * assignee: null // Explicitly set to null - * }; - * - * // Create action with explicit assignee - * const assignedTask: NewActivity = { - * type: ActivityType.Action, - * title: "Deploy to production", - * assignee: { - * id: userId as ActorId, - * type: ActorType.User, - * name: "Alice" - * } - * }; - * ``` - */ - assignee: Actor | null; - /** - * Start time of a scheduled activity. Notes are not typically scheduled unless they're about specific times. - * For recurring events, this represents the start of the first occurrence. - * Can be a Date object for timed events or a date string in "YYYY-MM-DD" format for all-day events. - * - * **Activity Scheduling States** (for Actions): - * - **Do Now** (current/actionable): When creating an Action, omitting `start` defaults to current time - * - **Do Later** (future scheduled): Set `start` to a future Date or date string - * - **Do Someday** (unscheduled backlog): Explicitly set `start: null` - * - * **Important for synced tasks**: When syncing unassigned backlog items from external systems, - * set BOTH `start: null` AND `assignee: null` to create unscheduled, unassigned actions. - * - * @example - * ```typescript - * // "Do Now" - assigned to twist owner, actionable immediately - * await this.tools.plot.createActivity({ - * type: ActivityType.Action, - * title: "Urgent task" - * // start omitted → defaults to now - * // assignee omitted → defaults to twist owner - * }); - * - * // "Do Later" - scheduled for a specific time - * await this.tools.plot.createActivity({ - * type: ActivityType.Action, - * title: "Future task", - * start: new Date("2025-02-01") - * }); - * - * // "Do Someday" - unassigned backlog item (common for synced tasks) - * await this.tools.plot.createActivity({ - * type: ActivityType.Action, - * title: "Backlog task", - * start: null, // Explicitly unscheduled - * assignee: null // Explicitly unassigned - * }); - * ``` - */ - start: Date | string | null; - /** - * End time of a scheduled activity. Notes are not typically scheduled unless they're about specific times. - * For recurring events, this represents the end of the first occurrence. - * Can be a Date object for timed events or a date string in "YYYY-MM-DD" format for all-day events. - * Null for tasks or activities without defined end times. - */ - end: Date | string | null; - /** - * For recurring activities, the last occurrence date (inclusive). - * Can be a Date object, date string in "YYYY-MM-DD" format, or null if recurring indefinitely. - * When both recurrenceCount and recurrenceUntil are provided, recurrenceCount takes precedence. - */ - recurrenceUntil: Date | string | null; - /** - * For recurring activities, the number of occurrences to generate. - * Takes precedence over recurrenceUntil if both are provided. - * Null for non-recurring activities or indefinite recurrence. - */ - recurrenceCount: number | null; - /** The priority context this activity belongs to */ + /** The priority context this thread belongs to */ priority: Priority; - /** Recurrence rule in RFC 5545 RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR") */ - recurrenceRule: string | null; - /** Array of dates to exclude from the recurrence pattern */ - recurrenceExdates: Date[] | null; - /** Metadata about the activity, typically from an external system that created it */ - meta: ActivityMeta | null; - /** Sort order for the activity (fractional positioning) */ - order: number; - /** Array of interactive links attached to the activity (external, conferencing, callback) */ - links: Array | null; + /** The schedule associated with this thread, if any */ + schedule?: Schedule; }; -export type Activity = ActivityFields & - ( - | { type: ActivityType.Note } - | { - type: ActivityType.Action; - /** - * Timestamp when the activity was marked as complete. Null if not completed. - */ - done: Date | null; - } - | { type: ActivityType.Event } - ); +export type Thread = ThreadFields; -export type ActivityWithNotes = Activity & { +export type ThreadWithNotes = Thread & { notes: Note[]; }; -export type NewActivityWithNotes = NewActivity & { - notes: Omit[]; -}; - -/** - * Represents a specific instance of a recurring activity. - * All field values are computed by merging the recurring activity's - * defaults with any occurrence-specific overrides. - */ -export type ActivityOccurrence = { - /** - * Original date/datetime of this occurrence. - * Use start for the occurrence's current start time. - * Format: Date object or "YYYY-MM-DD" for all-day events. - */ - occurrence: Date | string; - - /** - * The recurring activity of which this is an occurrence. - */ - activity: Activity; - - /** - * Effective values for this occurrence (series defaults + overrides). - * These are the actual values that apply to this specific instance. - */ - start: Date | string; - end: Date | string | null; - done: Date | null; - title: string; - /** - * Meta is merged, with the occurrence's meta taking precedence. - */ - meta: ActivityMeta | null; - - /** - * Tags for this occurrence (merged with the recurring tags). - */ - tags: Tags; - - /** - * True if the occurrence is archived. - */ - archived: boolean; +export type NewThreadWithNotes = NewThread & { + notes: Omit[]; }; /** - * Type for creating or updating activity occurrences. - * - * Follows the same pattern as Activity/NewActivity: - * - Required fields: `occurrence` (key) and `start` (for scheduling) - * - Optional fields: All others from ActivityOccurrence - * - Additional fields: `twistTags` for add/remove, `unread` for notification control - * - * @example - * ```typescript - * const activity: NewActivity = { - * type: ActivityType.Event, - * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", - * occurrences: [ - * { - * occurrence: new Date("2025-01-27T14:00:00Z"), - * start: new Date("2025-01-27T14:00:00Z"), - * tags: { [Tag.Skip]: [user] } - * } - * ] - * }; - * ``` - */ -export type NewActivityOccurrence = Pick< - ActivityOccurrence, - "occurrence" | "start" -> & - Partial< - Omit - > & { - /** - * Tags specific to this occurrence. - * These replace any recurrence-level tags for this occurrence. - */ - tags?: NewTags; - - /** - * Add or remove the twist's tags on this occurrence. - * Maps tag ID to boolean: true = add tag, false = remove tag. - */ - twistTags?: Partial>; - - /** - * Whether this occurrence should be marked as unread for users. - * - undefined/omitted (default): Occurrence is unread for users, except auto-marked - * as read for the author if they are the twist owner (user) - * - true: Occurrence is explicitly unread for ALL users (use sparingly) - * - false: Occurrence is marked as read for all users - * - * For the default behavior, omit this field entirely. - * Use false for initial sync to avoid marking historical items as unread. - */ - unread?: boolean; - }; - -/** - * Inline type for creating/updating occurrences within NewActivity/ActivityUpdate. - * Used to specify occurrence-specific overrides when creating or updating a recurring activity. - */ -export type ActivityOccurrenceUpdate = Pick< - NewActivityOccurrence, - "occurrence" -> & - Partial>; - -/** - * Configuration for automatic priority selection based on activity similarity. + * Configuration for automatic priority selection based on thread similarity. * - * Maps activity fields to scoring weights or required exact matches: + * Maps thread fields to scoring weights or required exact matches: * - Number value: Maximum score for similarity matching on this field - * - `true` value: Required exact match - activities must match exactly or be excluded + * - `true` value: Required exact match - threads must match exactly or be excluded * * Scoring rules: - * - content: Uses vector similarity on activity embedding (cosine similarity) - * - type: Exact match on ActivityType - * - mentions: Percentage of existing activity's mentions that appear in new activity + * - content: Uses vector similarity on thread embedding (cosine similarity) + * - mentions: Percentage of existing thread's mentions that appear in new thread * - meta.field: Exact match on top-level meta fields (e.g., "meta.sourceId") * * When content is `true`, applies a strong similarity threshold to ensure only close matches. @@ -696,8 +405,8 @@ export type ActivityOccurrenceUpdate = Pick< * // Require exact content match with strong similarity * pickPriority: { content: true } * - * // Score based on content (max 100 points) and require exact type match - * pickPriority: { content: 100, type: true } + * // Score based on content (max 100 points) and mentions + * pickPriority: { content: 100, mentions: true } * * // Match on meta and score content * pickPriority: { "meta.projectId": true, content: 50 } @@ -705,111 +414,32 @@ export type ActivityOccurrenceUpdate = Pick< */ export type PickPriorityConfig = { content?: number | true; - type?: number | true; mentions?: number | true; [key: `meta.${string}`]: number | true; }; /** - * Type for creating new activities. - * - * Requires only the activity type, with all other fields optional. - * The author will be automatically assigned by the Plot system based on - * the current execution context. The ID can be optionally provided by - * tools for tracking and update detection purposes. + * Type for creating new threads. * - * **Important: Defaults for Actions** - * - * When creating an Activity of type `Action`: - * - **`start` omitted** → Defaults to current time (now) → "Do Now" - * - **`assignee` omitted** → Defaults to twist owner → Assigned action - * - * To create unassigned backlog items (common for synced tasks), you MUST explicitly set BOTH: - * - `start: null` → "Do Someday" (unscheduled) - * - `assignee: null` → Unassigned - * - * **Scheduling States**: - * - **"Do Now"** (actionable today): Omit `start` or set to current time - * - **"Do Later"** (scheduled): Set `start` to a future Date - * - **"Do Someday"** (backlog): Set `start: null` - * - * Priority can be specified in three ways: - * 1. Explicit priority: `priority: { id: "..." }` - Use specific priority (disables pickPriority) - * 2. Pick priority config: `pickPriority: { ... }` - Auto-select based on similarity - * 3. Neither: Defaults to `pickPriority: { content: true }` for automatic matching + * Threads are simple containers. All other fields are optional. * * @example * ```typescript - * // "Do Now" - Assigned to twist owner, actionable immediately - * const urgentTask: NewActivity = { - * type: ActivityType.Action, + * const thread: NewThread = { * title: "Review pull request" - * // start omitted → defaults to now - * // assignee omitted → defaults to twist owner - * }; - * - * // "Do Someday" - UNASSIGNED backlog item (for synced tasks) - * const backlogTask: NewActivity = { - * type: ActivityType.Action, - * title: "Refactor user service", - * start: null, // Must explicitly set to null - * assignee: null // Must explicitly set to null - * }; - * - * // "Do Later" - Scheduled for specific date - * const futureTask: NewActivity = { - * type: ActivityType.Action, - * title: "Prepare Q1 review", - * start: new Date("2025-03-15") - * }; - * - * // Note (typically unscheduled) - * const note: NewActivity = { - * type: ActivityType.Note, - * title: "Meeting notes", - * content: "Discussed Q4 roadmap...", - * start: null // Notes typically don't have scheduled times - * }; - * - * // Event (always has explicit start/end times) - * const event: NewActivity = { - * type: ActivityType.Event, - * title: "Team standup", - * start: new Date("2025-01-15T10:00:00"), - * end: new Date("2025-01-15T10:30:00") * }; * ``` */ -export type NewActivity = ( - | { type: ActivityType.Note; done?: never } - | { type: ActivityType.Action; done?: Date | null } - | { type: ActivityType.Event; done?: never } -) & - Partial< - Omit< - ActivityFields, - "author" | "assignee" | "priority" | "tags" | "mentions" | "id" | "source" - > - > & +export type NewThread = Partial< + Omit +> & ( | { - /** - * Unique identifier for the activity, generated by Uuid.Generate(). - * Specifying an ID allows tools to track and upsert activities. - */ + /** Unique identifier for the thread, generated by Uuid.Generate(). */ id: Uuid; } | { - /** - * Canonical URL for the item in an external system. - * For example, https://acme.atlassian.net/browse/PROJ-42 could represent a Jira issue. - * When set, it uniquely identifies the activity within a priority tree. This performs - * an upsert. - */ - source: string; - } - | { - /* Neither id nor source is required. An id will be generated and returned. */ + /* id is optional. An id will be generated and returned. */ } ) & ( @@ -823,104 +453,45 @@ export type NewActivity = ( } ) & { /** - * The person that created the item. By default, it will be the twist itself. - */ - author?: NewActor; - - /** - * The person that assigned to the item. - */ - assignee?: NewActor | null; - - /** - * All tags to set on the new activity. + * All tags to set on the new thread. */ tags?: NewTags; /** - * Whether the activity should be marked as unread for users. - * - undefined/omitted (default): Activity is unread for users, except auto-marked + * Whether the thread should be marked as unread for users. + * - undefined/omitted (default): Thread is unread for users, except auto-marked * as read for the author if they are the twist owner (user) - * - true: Activity is explicitly unread for ALL users (use sparingly) - * - false: Activity is marked as read for all users in the priority at creation time - * - * For the default behavior, omit this field entirely. - * Use false for initial sync to avoid marking historical items as unread. + * - true: Thread is explicitly unread for ALL users (use sparingly) + * - false: Thread is marked as read for all users in the priority at creation time */ unread?: boolean; /** - * Whether the activity is archived. - * - true: Archive the activity - * - false: Unarchive the activity + * Whether the thread is archived. + * - true: Archive the thread + * - false: Unarchive the thread * - undefined (default): Preserve current archive state - * - * Best practice: Set to false during initial syncs to ensure activities - * are unarchived. Omit during incremental syncs to preserve user's choice. */ archived?: boolean; /** - * Optional preview content for the activity. Can be Markdown formatted. + * Optional preview content for the thread. Can be Markdown formatted. * The preview will be automatically generated from this content (truncated to 100 chars). - * - * - string: Use this content for preview generation - * - null: Explicitly disable preview (no preview will be shown) - * - undefined (default): Fall back to legacy behavior (generate from first note with content) - * - * This field is write-only and won't be returned when reading activities. */ preview?: string | null; /** - * Create or update specific occurrences of a recurring activity. - * Each entry specifies overrides for a specific occurrence. - * - * When occurrence matches the recurrence rule but only tags are specified, - * the occurrence is created with just tags in activity_tag.occurrence (no activity_exception). - * - * When any other field is specified, creates/updates an activity_exception row. - * - * @example - * ```typescript - * // Create recurring event with per-occurrence RSVPs - * const meeting: NewActivity = { - * type: ActivityType.Event, - * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", - * start: new Date("2025-01-20T14:00:00Z"), - * duration: 1800000, // 30 minutes - * occurrences: [ - * { - * occurrence: new Date("2025-01-27T14:00:00Z"), - * tags: { [Tag.Skip]: [{ email: "user@example.com" }] } - * }, - * { - * occurrence: new Date("2025-02-03T14:00:00Z"), - * start: new Date("2025-02-03T15:00:00Z"), // Reschedule this one - * tags: { [Tag.Attend]: [{ email: "user@example.com" }] } - * } - * ] - * }; - * ``` - */ - occurrences?: NewActivityOccurrence[]; - - /** - * Dates to add to the recurrence exclusion list. - * These are merged with existing exdates. Use this for incremental updates - * (e.g., cancelling a single occurrence) instead of replacing the full list. + * Optional schedules to create alongside the thread. */ - addRecurrenceExdates?: Date[]; + schedules?: Array>; /** - * Dates to remove from the recurrence exclusion list. - * Use this to "uncancel" a previously excluded occurrence. + * Optional schedule occurrence overrides. */ - removeRecurrenceExdates?: Date[]; + scheduleOccurrences?: NewScheduleOccurrence[]; }; -export type ActivityFilter = { - type?: ActorType; +export type ThreadFilter = { meta?: { [key: string]: JSONValue; }; @@ -928,126 +499,64 @@ export type ActivityFilter = { /** * Fields supported by bulk updates via `match`. Only simple scalar fields - * that can be applied uniformly across many activities are included. + * that can be applied uniformly across many threads 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; -}; +type ThreadBulkUpdateFields = Partial< + Pick +>; /** - * Fields supported by single-activity updates via `id` or `source`. - * Includes all bulk fields plus scheduling, recurrence, tags, and occurrences. + * Fields supported by single-thread updates via `id` or `source`. + * Includes all bulk fields plus tags and preview. */ -type ActivitySingleUpdateFields = ActivityBulkUpdateFields & - Partial< - Pick< - ActivityFields, - | "start" - | "end" - | "assignee" - | "recurrenceRule" - | "recurrenceExdates" - | "recurrenceUntil" - | "recurrenceCount" - > - > & { - /** - * 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. - */ - tags?: NewTags; - - /** - * Add or remove the twist's tags. - * Maps tag ID to boolean: true = add tag, false = remove tag. - * This is allowed on all activities the twist has access to. - */ - twistTags?: Partial>; - - /** - * Optional preview content for the activity. Can be Markdown formatted. - * The preview will be automatically generated from this content (truncated to 100 chars). - * - * - string: Use this content for preview generation - * - null: Explicitly disable preview (no preview will be shown) - * - undefined (omitted): Preserve current preview value - * - * This field is write-only and won't be returned when reading activities. - */ - preview?: string | null; - - /** - * Create or update specific occurrences of this recurring activity. - * Each entry specifies overrides for a specific occurrence. - * - * Setting a field to null reverts it to the series default. - * Omitting a field leaves it unchanged. - * - * @example - * ```typescript - * // Update RSVPs for specific occurrences - * await plot.updateActivity({ - * id: meetingId, - * occurrences: [ - * { - * occurrence: new Date("2025-01-27T14:00:00Z"), - * tags: { [Tag.Skip]: [user] } - * }, - * { - * occurrence: new Date("2025-02-03T14:00:00Z"), - * tags: { [Tag.Attend]: [user] } - * }, - * { - * occurrence: new Date("2025-02-10T14:00:00Z"), - * archived: true // Cancel this occurrence - * } - * ] - * }); - * ``` - */ - occurrences?: (NewActivityOccurrence | ActivityOccurrenceUpdate)[]; +type ThreadSingleUpdateFields = ThreadBulkUpdateFields & { + /** + * Tags to change on the thread. 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. + */ + tags?: NewTags; - /** - * Dates to add to the recurrence exclusion list. - * These are merged with existing exdates. Use this for incremental updates - * (e.g., cancelling a single occurrence) instead of replacing the full list. - */ - addRecurrenceExdates?: Date[]; + /** + * Add or remove the twist's tags. + * Maps tag ID to boolean: true = add tag, false = remove tag. + * This is allowed on all threads the twist has access to. + */ + twistTags?: Partial>; - /** - * Dates to remove from the recurrence exclusion list. - * Use this to "uncancel" a previously excluded occurrence. - */ - removeRecurrenceExdates?: Date[]; - }; + /** + * Optional preview content for the thread. Can be Markdown formatted. + * The preview will be automatically generated from this content (truncated to 100 chars). + * + * - string: Use this content for preview generation + * - null: Explicitly disable preview (no preview will be shown) + * - undefined (omitted): Preserve current preview value + * + * This field is write-only and won't be returned when reading threads. + */ + preview?: string | null; +}; -export type ActivityUpdate = - | (({ id: Uuid } | { source: string }) & ActivitySingleUpdateFields) +export type ThreadUpdate = + | (({ id: Uuid } | { source: string }) & ThreadSingleUpdateFields) | ({ /** - * Update all activities matching the specified criteria. Only activities + * Update all threads matching the specified criteria. Only threads * that match all provided fields and were created by the twist will be updated. */ - match: ActivityFilter; - } & ActivityBulkUpdateFields); + match: ThreadFilter; + } & ThreadBulkUpdateFields); /** - * Represents a note within an activity. + * Represents a note within a thread. * - * Notes contain the detailed content (note text, links) associated with an activity. - * They are always ordered by creation time within their parent activity. + * Notes contain the detailed content (note text, actions) associated with a thread. + * They are always ordered by creation time within their parent thread. */ -export type Note = ActivityCommon & { +export type Note = ThreadCommon & { + /** The author of this note */ + author: Actor; /** - * Globally unique, stable identifier for the note within its activity. + * Globally unique, stable identifier for the note within its thread. * Can be used to upsert without knowing the id. * * Use one of these patterns: @@ -1059,15 +568,15 @@ export type Note = ActivityCommon & { * - `"comment:12345"` (for a specific comment by ID) * - `"gmail:msg:18d4e5f2a3b1c9d7"` (for a Gmail message within a thread) * - * ⚠️ Ensure IDs are immutable - avoid human-readable slugs or titles. + * Ensure IDs are immutable - avoid human-readable slugs or titles. */ key: string | null; - /** The parent activity this note belongs to */ - activity: Activity; + /** The parent thread this note belongs to */ + thread: Thread; /** Primary content for the note (markdown) */ content: string | null; - /** Array of interactive links attached to the note */ - links: Array | null; + /** Array of interactive actions attached to the note */ + actions: Array | null; /** The note this is a reply to, or null if not a reply */ reNote: { id: Uuid } | null; }; @@ -1075,22 +584,22 @@ export type Note = ActivityCommon & { /** * Type for creating new notes. * - * Requires the activity reference, with all other fields optional. + * Requires the thread reference, with all other fields optional. * Can provide id, key, or neither for note identification: * - id: Provide a specific UUID for the note - * - key: Provide an external identifier for upsert within the activity + * - key: Provide an external identifier for upsert within the thread * - neither: A new note with auto-generated UUID will be created */ export type NewNote = Partial< Omit< Note, - "author" | "activity" | "tags" | "mentions" | "id" | "key" | "reNote" + "author" | "thread" | "tags" | "mentions" | "id" | "key" | "reNote" > > & ({ id: Uuid } | { key: string } | {}) & { - /** Reference to the parent activity (required) */ - activity: - | Pick + /** Reference to the parent thread (required) */ + thread: + | Pick | { source: string; }; @@ -1109,7 +618,7 @@ export type NewNote = Partial< contentType?: ContentType; /** - * Tags to change on the activity. Use an empty array of NewActor to remove a tag. + * Tags to change on the thread. 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. */ tags?: NewTags; @@ -1120,11 +629,11 @@ export type NewNote = Partial< mentions?: NewActor[]; /** - * Whether the note should mark the parent activity as unread for users. - * - undefined/omitted (default): Activity is unread for users, except auto-marked + * Whether the note should mark the parent thread as unread for users. + * - undefined/omitted (default): Thread is unread for users, except auto-marked * as read for the author if they are the twist owner (user) - * - true: Activity is explicitly unread for ALL users (use sparingly) - * - false: Activity is marked as read for all users in the priority at note creation time + * - true: Thread is explicitly unread for ALL users (use sparingly) + * - false: Thread is marked as read for all users in the priority at note creation time * * For the default behavior, omit this field entirely. * Use false for initial sync to avoid marking historical items as unread. @@ -1147,7 +656,7 @@ export type NewNote = Partial< */ export type NoteUpdate = ({ id: Uuid; key?: string } | { key: string }) & Partial< - Pick + Pick > & { /** * Format of the note content. Determines how the note is processed: @@ -1179,7 +688,7 @@ export type NoteUpdate = ({ id: Uuid; key?: string } | { key: string }) & /** * Represents an actor in Plot - a user, contact, or twist. * - * Actors can be associated with activities as authors, assignees, or mentions. + * Actors can be associated with threads as authors, assignees, or mentions. * The email field is only included when ContactAccess.Read permission is granted. * * @example @@ -1224,17 +733,17 @@ export type NewActor = | NewContact; /** - * Enumeration of author types that can create activities. + * Enumeration of author types that can create threads. * - * The author type affects how activities are displayed and processed + * The author type affects how threads are displayed and processed * within the Plot system. */ export enum ActorType { - /** Activities created by human users */ + /** Threads created by human users */ User, - /** Activities created by external contacts */ + /** Threads created by external contacts */ Contact, - /** Activities created by automated twists */ + /** Threads created by automated twists */ Twist, } @@ -1270,9 +779,125 @@ export type NewContact = { export type ContentType = "text" | "markdown" | "html"; -/** @deprecated Use LinkType instead */ -export const ActivityLinkType = LinkType; -/** @deprecated Use LinkType instead */ -export type ActivityLinkType = LinkType; -/** @deprecated Use Link instead */ -export type ActivityLink = Link; +/** + * Represents an external entity linked to a thread. + * + * Links are created by sources to represent external entities (issues, emails, calendar events) + * attached to a thread container. A thread can have multiple links (1:many). + * Links store source-specific data like type, status, metadata, and embeddings. + * + * @example + * ```typescript + * // A link representing a Linear issue + * const link: Link = { + * id: "..." as Uuid, + * threadId: "..." as Uuid, + * source: "linear:issue:549dd8bd-2bc9-43d1-95d5-4b4af0c5af1b", + * created: new Date(), + * author: { id: "..." as ActorId, type: ActorType.Contact, name: "Alice" }, + * title: "Fix login bug", + * type: "issue", + * status: "open", + * meta: { projectId: "TEAM", url: "https://linear.app/team/TEAM-123" }, + * assignee: null, + * actions: null, + * }; + * ``` + */ +export type Link = { + /** Unique identifier for the link */ + id: Uuid; + /** The thread this link belongs to */ + threadId: Uuid; + /** External source identifier for dedup/upsert */ + source: string | null; + /** When this link was originally created in its source system */ + created: Date; + /** The actor credited with creating this link */ + author: Actor | null; + /** Display title */ + title: string; + /** Truncated preview */ + preview: string | null; + /** The actor assigned to this link */ + assignee: Actor | null; + /** Source-defined type string (e.g., issue, pull_request, email, event) */ + type: string | null; + /** Source-defined status string (e.g., open, done, closed) */ + status: string | null; + /** Interactive action buttons */ + actions: Array | null; + /** Source metadata */ + meta: ThreadMeta | null; + /** URL to open the original item in its source application (e.g., "Open in Linear") */ + sourceUrl: string | null; + /** Channel ID that produced this link (matches source_channel.channel_id) */ + channelId: string | null; +}; + +/** + * Type for creating new links. + * + * Links are created by sources to represent external entities. + * Requires a source identifier for dedup/upsert. + */ +export type NewLink = ( + | { + /** Unique identifier for the link, generated by Uuid.Generate() */ + id: Uuid; + } + | { + /** + * Canonical ID for the item in an external system. + * When set, uniquely identifies the link within a priority tree. This performs + * an upsert. + */ + source: string; + } + | {} +) & + Partial> & { + /** The person that created the item. By default, it will be the twist itself. */ + author?: NewActor; + /** The person assigned to the item. */ + assignee?: NewActor | null; + /** + * Whether the thread should be marked as unread for users. + * - undefined/omitted (default): Thread is unread for users, except auto-marked + * as read for the author if they are the twist owner (user) + * - false: Thread is marked as read for all users in the priority at creation time + */ + unread?: boolean; + /** + * Whether the thread is archived. + * - true: Archive the thread + * - false: Unarchive the thread + * - undefined (default): Preserve current archive state + */ + archived?: boolean; + /** + * Configuration for automatic priority selection based on similarity. + * Only used when the link creates a new thread. + */ + pickPriority?: PickPriorityConfig; + /** + * Explicit priority (disables automatic priority matching). + * Only used when the link creates a new thread. + */ + priority?: Pick; + }; + +/** + * A new link with notes to save via integrations.saveLink(). + * Creates a thread+link pair, with notes attached to the thread. + */ +export type NewLinkWithNotes = NewLink & { + /** Title for the link and its thread container */ + title: string; + /** Notes to attach to the thread */ + notes?: Omit[]; + /** Schedules to create for the link */ + schedules?: Array>; + /** Schedule occurrence overrides */ + scheduleOccurrences?: NewScheduleOccurrence[]; +}; diff --git a/twister/src/schedule.ts b/twister/src/schedule.ts new file mode 100644 index 0000000..07250b8 --- /dev/null +++ b/twister/src/schedule.ts @@ -0,0 +1,203 @@ +import { type Tag } from "./tag"; +import { + type Actor, + type ActorId, + type NewActor, + type NewTags, + type Tags, +} from "./plot"; +import { Uuid } from "./utils/uuid"; + +export { Uuid } from "./utils/uuid"; + +/** + * Represents a schedule entry for a thread. + * + * Schedules define when a thread occurs in time. A thread may have zero or more schedules: + * - Shared schedules (userId is null): visible to all members of the thread's priority + * - Per-user schedules (userId set): private ordering/scheduling for a specific user + * + * For recurring events in the SDK, start/end represent the first occurrence's + * time. In the database, the `at`/`on` range is expanded to span from the first + * occurrence start to the last occurrence end (or open-ended if no fixed end). + * The `duration` column stores the per-occurrence duration, enabling range overlap + * queries to correctly find all recurring events with occurrences in a given window. + */ +export type Schedule = { + /** When this schedule was created */ + created: Date; + /** Whether this schedule has been archived */ + archived: boolean; + /** If set, this is a per-user schedule visible only to this user */ + userId: ActorId | null; + /** Per-user ordering within a day (only set for per-user schedules) */ + order: number | null; + /** + * Start time of the schedule. + * Date object for timed events, date string in "YYYY-MM-DD" format for all-day events. + */ + start: Date | string | null; + /** + * End time of the schedule. + * Date object for timed events, date string in "YYYY-MM-DD" format for all-day events. + */ + end: Date | string | null; + /** Recurrence rule in RFC 5545 RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR") */ + recurrenceRule: string | null; + /** Duration of each occurrence in milliseconds (required for recurring schedules) */ + duration: number | null; + /** Array of dates to exclude from the recurrence pattern */ + recurrenceExdates: Date[] | null; + /** + * For occurrence exceptions: the original date/time of this occurrence in the series. + * Format: Date object or "YYYY-MM-DD" for all-day events. + */ + occurrence: Date | string | null; + /** Contacts invited to this schedule (attendees/participants) */ + contacts: ScheduleContact[]; +}; + +export type ScheduleContactStatus = "attend" | "skip"; +export type ScheduleContactRole = "organizer" | "required" | "optional"; + +export type ScheduleContact = { + contact: Actor; + status: ScheduleContactStatus | null; + role: ScheduleContactRole; + archived: boolean; +}; + +export type NewScheduleContact = { + contact: NewActor; + status?: ScheduleContactStatus | null; + role?: ScheduleContactRole | null; + archived?: boolean; +}; + +/** + * Type for creating new schedules. + * + * Requires `threadId` and `start`. All other fields are optional. + * + * @example + * ```typescript + * // Simple timed event + * const schedule: NewSchedule = { + * threadId: threadId, + * start: new Date("2025-03-15T10:00:00Z"), + * end: new Date("2025-03-15T11:00:00Z") + * }; + * + * // All-day event + * const allDay: NewSchedule = { + * threadId: threadId, + * start: "2025-03-15", + * end: "2025-03-16" + * }; + * + * // Recurring weekly event + * const recurring: NewSchedule = { + * threadId: threadId, + * start: new Date("2025-01-20T14:00:00Z"), + * end: new Date("2025-01-20T15:00:00Z"), + * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", + * recurrenceUntil: new Date("2025-06-30") + * }; + * ``` + */ +export type NewSchedule = { + /** The thread this schedule belongs to */ + threadId: Uuid; + /** + * Start time. Date for timed events, "YYYY-MM-DD" for all-day. + * Determines whether the schedule uses `at` (timed) or `on` (all-day) storage. + */ + start: Date | string; + /** End time. Date for timed events, "YYYY-MM-DD" for all-day. */ + end?: Date | string | null; + /** Recurrence rule in RFC 5545 RRULE format */ + recurrenceRule?: string | null; + /** + * For recurring schedules, the last occurrence date (inclusive). + * When both recurrenceCount and recurrenceUntil are provided, recurrenceCount takes precedence. + */ + recurrenceUntil?: Date | string | null; + /** + * For recurring schedules, the number of occurrences to generate. + * Takes precedence over recurrenceUntil if both are provided. + */ + recurrenceCount?: number | null; + /** Array of dates to exclude from the recurrence pattern */ + recurrenceExdates?: Date[] | null; + /** + * For occurrence exceptions: the original date/time of this occurrence. + */ + occurrence?: Date | string | null; + /** If set, this is a per-user schedule for the specified user */ + userId?: ActorId | null; + /** Per-user ordering (only valid with userId) */ + order?: number | null; + /** Whether to archive this schedule */ + archived?: boolean; + /** Contacts to upsert on this schedule. Upserted by contact identity. */ + contacts?: NewScheduleContact[]; +}; + +/** + * Represents a specific instance of a recurring schedule. + * All field values are computed by merging the recurring schedule's + * defaults with any occurrence-specific overrides. + */ +export type ScheduleOccurrence = { + /** + * Original date/datetime of this occurrence. + * Use start for the occurrence's current start time. + */ + occurrence: Date | string; + + /** The recurring schedule of which this is an occurrence */ + schedule: Schedule; + + /** Effective start for this occurrence (series default + override) */ + start: Date | string; + /** Effective end for this occurrence */ + end: Date | string | null; + + /** Tags for this occurrence */ + tags: Tags; + + /** True if the occurrence is archived */ + archived: boolean; +}; + +/** + * Type for creating or updating schedule occurrences. + */ +export type NewScheduleOccurrence = Pick< + ScheduleOccurrence, + "occurrence" | "start" +> & + Partial< + Omit + > & { + /** Tags for this occurrence */ + tags?: NewTags; + + /** Add or remove the twist's tags on this occurrence */ + twistTags?: Partial>; + + /** Whether this occurrence should be marked as unread */ + unread?: boolean; + + /** Contacts to upsert on this occurrence's schedule */ + contacts?: NewScheduleContact[]; + }; + +/** + * Type for updating schedule occurrences inline. + */ +export type ScheduleOccurrenceUpdate = Pick< + NewScheduleOccurrence, + "occurrence" +> & + Partial>; diff --git a/twister/src/source.ts b/twister/src/source.ts new file mode 100644 index 0000000..af52b4e --- /dev/null +++ b/twister/src/source.ts @@ -0,0 +1,171 @@ +import { type Actor, type Link, type Note, type Thread, type ThreadMeta } from "./plot"; +import { + type AuthProvider, + type AuthToken, + type Authorization, + type Channel, + type LinkTypeConfig, +} from "./tools/integrations"; +import { Twist } from "./twist"; + +/** + * Base class for sources — twists that sync data from external services. + * + * Sources declare a single OAuth provider and scopes, and implement channel + * lifecycle methods for discovering and syncing external resources. They save + * data directly via `integrations.saveLink()` instead of using the Plot tool. + * + * @example + * ```typescript + * class LinearSource extends Source { + * readonly provider = AuthProvider.Linear; + * readonly scopes = ["read", "write"]; + * readonly linkTypes = [{ + * type: "issue", + * label: "Issue", + * statuses: [ + * { status: "open", label: "Open" }, + * { status: "done", label: "Done" }, + * ], + * }]; + * + * build(build: ToolBuilder) { + * return { + * integrations: build(Integrations), + * }; + * } + * + * async getChannels(auth: Authorization, token: AuthToken): Promise { + * const teams = await this.listTeams(token); + * return teams.map(t => ({ id: t.id, title: t.name })); + * } + * + * async onChannelEnabled(channel: Channel) { + * const issues = await this.fetchIssues(channel.id); + * for (const issue of issues) { + * await this.tools.integrations.saveLink(issue); + * } + * } + * + * async onChannelDisabled(channel: Channel) { + * // Clean up webhooks, sync state, etc. + * } + * } + * ``` + */ +export abstract class Source extends Twist { + /** + * Static marker to identify Source subclasses without instanceof checks + * across worker boundaries. + */ + static readonly isSource = true; + + // ---- Identity (abstract — every source must declare) ---- + + /** The OAuth provider this source authenticates with. */ + abstract readonly provider: AuthProvider; + + /** OAuth scopes to request for this source. */ + abstract readonly scopes: string[]; + + // ---- Optional metadata ---- + + /** + * Registry of link types this source creates (e.g., issue, event, message). + * Used for display in the UI (icons, labels, statuses). + */ + readonly linkTypes?: LinkTypeConfig[]; + + // ---- Channel lifecycle (abstract — every source must implement) ---- + + /** + * Returns available channels for the authorized actor. + * Called after OAuth is complete, during the setup/edit modal. + * + * @param auth - The completed authorization with provider and actor info + * @param token - The access token for making API calls + * @returns Promise resolving to available channels for the user to select + */ + abstract getChannels( + auth: Authorization, + token: AuthToken + ): Promise; + + /** + * Called when a channel resource is enabled for syncing. + * Should set up webhooks and start initial sync. + * + * @param channel - The channel that was enabled + */ + abstract onChannelEnabled(channel: Channel): Promise; + + /** + * Called when a channel resource is disabled. + * Should stop sync, clean up webhooks, and remove state. + * + * @param channel - The channel that was disabled + */ + abstract onChannelDisabled(channel: Channel): Promise; + + // ---- Write-back hooks (optional, default no-ops) ---- + + /** + * Called when a link created by this source is updated by the user. + * Override to write back changes to the external service + * (e.g., changing issue status in Linear when marked done in Plot). + * + * @param link - The updated link + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLinkUpdated(link: Link): Promise { + return Promise.resolve(); + } + + /** + * Called when a note is created on a thread owned by this source. + * Override to write back comments to the external service + * (e.g., adding a comment to a Linear issue). + * + * @param note - The created note + * @param meta - Metadata from the thread's link + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onNoteCreated(note: Note, meta: ThreadMeta): Promise { + return Promise.resolve(); + } + + /** + * Called when a user reads or unreads a thread owned by this source. + * Override to write back read status to the external service + * (e.g., marking an email as read in Gmail). + * + * @param thread - The thread that was read/unread + * @param actor - The user who performed the action + * @param unread - false when marked as read, true when marked as unread + * @param meta - Metadata from the thread's link (contains channelId, threadId, etc.) + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onThreadRead(thread: Thread, actor: Actor, unread: boolean, meta: ThreadMeta): Promise { + return Promise.resolve(); + } + + // ---- Activation ---- + + /** + * Called when the source is activated after OAuth is complete. + * + * Unlike Twist.activate() which receives a priority, Source.activate() + * receives the authorization and actor since sources are not installed + * in priorities. + * + * Default implementation does nothing. Override for custom setup. + * + * @param context - The activation context + * @param context.auth - The completed OAuth authorization + * @param context.actor - The actor who activated the source + */ + // @ts-ignore - Source.activate() intentionally has a different signature than Twist.activate() + activate(context: { auth: Authorization; actor: Actor }): Promise { + return Promise.resolve(); + } +} diff --git a/twister/src/tag.ts b/twister/src/tag.ts index fee415a..872739d 100644 --- a/twister/src/tag.ts +++ b/twister/src/tag.ts @@ -1,21 +1,17 @@ /** - * Activity tags. Three types: + * Thread tags. Three types: * 1. Special tags, which trigger other behaviors * 2. Toggle tags, which anyone can toggle a shared value on or off * 3. Count tags, where everyone can add or remove their own */ export enum Tag { // Special tags - Now = 1, - Later = 2, + Todo = 1, Done = 3, - Archived = 4, - Someday = 7, // Toggle tags Pinned = 100, Urgent = 101, - Inbox = 102, Goal = 103, Decision = 104, Waiting = 105, @@ -44,9 +40,4 @@ export enum Tag { Applause = 1016, Cool = 1017, Sad = 1018, - // RSVP tags - mutually exclusive per actor - // When an actor adds one of these tags, the other two are automatically removed - Attend = 1019, - Skip = 1020, - Undecided = 1021, } diff --git a/twister/src/tool.ts b/twister/src/tool.ts index e6f2cac..31bd21e 100644 --- a/twister/src/tool.ts +++ b/twister/src/tool.ts @@ -1,7 +1,5 @@ import { type Actor, - type ActivityFilter, - type NewActivityWithNotes, type Priority, } from "./plot"; import type { Callback } from "./tools/callbacks"; @@ -15,21 +13,6 @@ 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. diff --git a/twister/src/tools/callbacks.ts b/twister/src/tools/callbacks.ts index 821fd02..687476f 100644 --- a/twister/src/tools/callbacks.ts +++ b/twister/src/tools/callbacks.ts @@ -32,7 +32,7 @@ export type Callback = string & { readonly __brand: "Callback" }; * **When to use callbacks:** * - Webhook handlers that need persistent function references * - Scheduled operations that run after worker timeouts - * - User interaction links (LinkType.callback) + * - User interaction actions (ActionType.callback) * - Cross-tool communication that survives restarts * * **Type Safety:** diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index 0993624..0e07051 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -1,80 +1,90 @@ -import { type Actor, type ActorId, ITool, Serializable } from ".."; +import { + type Actor, + type ActorId, + type NewContact, + type NewLinkWithNotes, + ITool, + Serializable, +} from ".."; +import type { JSONValue } from "../utils/types"; import type { Uuid } from "../utils/uuid"; /** * 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. + * Returned by getChannels() and managed by users in the twist setup/edit modal. */ -export type Syncable = { +export type Channel = { /** External ID shared across users (e.g., Google calendar ID) */ id: string; /** Display name shown in the UI */ title: string; - /** Optional nested syncable resources (e.g., subfolders) */ - children?: Syncable[]; + /** Optional nested channel resources (e.g., subfolders) */ + children?: Channel[]; + /** Priority ID this channel is routed to (set when channel is enabled) */ + priorityId?: string; }; /** - * Configuration for an OAuth provider in a tool's build options. - * Declares the provider, scopes, and lifecycle callbacks. + * Describes a link type that a source creates. + * Used for display in the UI (icons, labels). */ -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[]; +export type LinkTypeConfig = { + /** Machine-readable type identifier (e.g., "issue", "pull_request") */ + type: string; + /** Human-readable label (e.g., "Issue", "Pull Request") */ + label: string; + /** URL to an icon for this link type (light mode). Prefer Iconify `logos/*` URLs. */ + logo?: string; + /** URL to an icon for dark mode. Use when the default logo is invisible on dark backgrounds (e.g., Iconify `simple-icons/*` with `?color=`). */ + logoDark?: string; + /** URL to a monochrome icon (uses `currentColor`). Prefer Iconify `simple-icons/*` URLs without a `?color=` param. */ + logoMono?: string; + /** Possible status values for this type */ + statuses?: Array<{ + /** Machine-readable status (e.g., "open", "done") */ + status: string; + /** Human-readable label (e.g., "Open", "Done") */ + label: string; + }>; }; /** - * Built-in tool for managing OAuth authentication and syncable resources. + * Built-in tool for managing OAuth authentication and channel resources. * - * 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 + * The Integrations tool: + * 1. Manages channel resources (calendars, projects, etc.) per actor + * 2. Returns tokens for the user who enabled sync on a channel + * 3. Supports per-actor auth via actAs() for write-back operations + * 4. Provides saveLink/saveContacts for Sources to save data directly * - * 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. + * Sources declare their provider, scopes, and channel lifecycle methods as + * class properties and methods. The Integrations tool reads these automatically. + * Auth and channel management is handled in the twist edit modal in Flutter. * * @example * ```typescript - * class CalendarTool extends Tool { - * static readonly PROVIDER = AuthProvider.Google; - * static readonly SCOPES = ["https://www.googleapis.com/auth/calendar"]; + * class CalendarSource extends Source { + * readonly provider = AuthProvider.Google; + * readonly scopes = ["https://www.googleapis.com/auth/calendar"]; * * build(build: ToolBuilder) { * return { - * integrations: build(Integrations, { - * providers: [{ - * provider: AuthProvider.Google, - * scopes: CalendarTool.SCOPES, - * getSyncables: this.getSyncables, - * onSyncEnabled: this.onSyncEnabled, - * onSyncDisabled: this.onSyncDisabled, - * }] - * }), + * integrations: build(Integrations), * }; * } * - * async getSyncables(auth: Authorization, token: AuthToken): Promise { + * async getChannels(auth: Authorization, token: AuthToken): Promise { * const calendars = await this.listCalendars(token); * return calendars.map(c => ({ id: c.id, title: c.name })); * } + * + * async onChannelEnabled(channel: Channel) { + * // Start syncing + * } + * + * async onChannelDisabled(channel: Channel) { + * // Stop syncing + * } * } * ``` */ @@ -90,17 +100,25 @@ export abstract class Integrations extends ITool { } /** - * Retrieves an access token for a syncable resource. + * Retrieves an access token for a channel resource. * - * 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. + * Returns the token of the user who enabled sync on the given channel. + * If the channel is not enabled or the token is expired/invalid, returns null. * - * @param provider - The OAuth provider - * @param syncableId - The syncable resource ID (e.g., calendar ID) + * @param channelId - The channel resource ID (e.g., calendar ID) + * @returns Promise resolving to the access token or null + */ + abstract get(channelId: string): Promise; + /** + * Retrieves an access token for a channel resource. + * + * @param provider - The OAuth provider (deprecated, ignored for single-provider sources) + * @param channelId - The channel resource ID (e.g., calendar ID) * @returns Promise resolving to the access token or null + * @deprecated Use get(channelId) instead. The provider is implicit from the source. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract get(provider: AuthProvider, syncableId: string): Promise; + abstract get(provider: AuthProvider, channelId: string): Promise; /** * Execute a callback as a specific actor, requesting auth if needed. @@ -127,8 +145,58 @@ export abstract class Integrations extends ITool { ...extraArgs: TArgs ): Promise; + /** + * Saves a link with notes to the source's priority. + * + * Creates a thread+link pair. The thread is a lightweight container; + * the link holds the external entity data (source, meta, type, status, etc.). + * + * This method is available only to Sources (not regular Twists). + * + * @param link - The link with notes to save + * @returns Promise resolving to the saved thread's UUID + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract saveLink(link: NewLinkWithNotes): Promise; + + /** + * Saves contacts to the source's priority. + * + * @param contacts - Array of contacts to save + * @returns Promise resolving to the saved actors + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract saveContacts(contacts: NewContact[]): Promise; + + /** + * Archives links matching the given filter that were created by this source. + * + * For each archived link's thread, if no other non-archived links remain, + * the thread is also archived. + * + * @param filter - Filter criteria for which links to archive + * @returns Promise that resolves when archiving is complete + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract archiveLinks(filter: ArchiveLinkFilter): Promise; + } +/** + * Filter criteria for archiving links. + * All fields are optional; only provided fields are used for matching. + */ +export type ArchiveLinkFilter = { + /** Filter by channel ID */ + channelId?: string; + /** Filter by link type (e.g., "issue", "pull_request") */ + type?: string; + /** Filter by link status (e.g., "open", "closed") */ + status?: string; + /** Filter by metadata fields (uses containment matching) */ + meta?: Record; +}; + /** * Enumeration of supported OAuth providers. * diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index 52b72db..48f8f31 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -1,12 +1,12 @@ import { - type Activity, - type ActivityOccurrence, - type ActivityUpdate, + type Thread, + type ThreadUpdate, type Actor, type ActorId, ITool, - type NewActivity, - type NewActivityWithNotes, + type Link, + type NewThread, + type NewThreadWithNotes, type NewContact, type NewNote, type NewPriority, @@ -14,19 +14,22 @@ import { type NoteUpdate, type Priority, type PriorityUpdate, - type Tag, Uuid, } from ".."; +import { + type Schedule, + type NewSchedule, +} from "../schedule"; -export enum ActivityAccess { +export enum ThreadAccess { /** - * Create new Note on an Activity where the twist was mentioned. - * Add/remove tags on Activity or Note where the twist was mentioned. + * Create new Note on a Thread where the twist was mentioned. + * Add/remove tags on Thread or Note where the twist was mentioned. */ Respond, /** - * Create new Activity. - * Create new Note in an Activity the twist created. + * Create new Thread. + * Create new Note in a Thread the twist created. * All Respond permissions. */ Create, @@ -54,8 +57,8 @@ export enum ContactAccess { } /** - * Intent handler for activity mentions. - * Defines how the twist should respond when mentioned in an activity. + * Intent handler for thread mentions. + * Defines how the twist should respond when mentioned in a thread. */ export type NoteIntentHandler = { /** Human-readable description of what this intent handles */ @@ -66,10 +69,24 @@ export type NoteIntentHandler = { handler: (note: Note) => Promise; }; +/** + * Filter for querying links from connected source channels. + */ +export type LinkFilter = { + /** Only return links from these channel IDs. */ + channelIds?: string[]; + /** Only return links created/updated after this date. */ + since?: Date; + /** Only return links of this type. */ + type?: string; + /** Maximum number of links to return. */ + limit?: number; +}; + /** * Built-in tool for interacting with the core Plot data layer. * - * The Plot tool provides twists with the ability to create and manage activities, + * The Plot tool provides twists with the ability to create and manage threads, * priorities, and contacts within the Plot system. This is the primary interface * for twists to persist data and interact with the Plot database. * @@ -84,13 +101,12 @@ export type NoteIntentHandler = { * } * * async activate(priority) { - * // Create a welcome activity - * await this.plot.createActivity({ - * type: ActivityType.Note, + * // Create a welcome thread + * await this.plot.createThread({ * title: "Welcome to Plot!", - * links: [{ + * actions: [{ * title: "Get Started", - * type: LinkType.external, + * type: ActionType.external, * url: "https://plot.day/docs" * }] * }); @@ -110,8 +126,8 @@ export abstract class Plot extends ITool { * build(build: ToolBuilder) { * return { * plot: build(Plot, { - * activity: { - * access: ActivityAccess.Create + * thread: { + * access: ThreadAccess.Create * } * }) * }; @@ -121,9 +137,8 @@ export abstract class Plot extends ITool { * build(build: ToolBuilder) { * return { * plot: build(Plot, { - * activity: { - * access: ActivityAccess.Create, - * updated: this.onActivityUpdated + * thread: { + * access: ThreadAccess.Create, * }, * note: { * intents: [{ @@ -131,8 +146,8 @@ export abstract class Plot extends ITool { * examples: ["Schedule a meeting tomorrow"], * handler: this.onSchedulingIntent * }], - * created: this.onNoteCreated * }, + * link: true, * priority: { * access: PriorityAccess.Full * }, @@ -145,30 +160,12 @@ export abstract class Plot extends ITool { * ``` */ static readonly Options: { - activity?: { + thread?: { /** * Capability to create Notes and modify tags. * Must be explicitly set to grant permissions. */ - access?: ActivityAccess; - /** - * Called when an activity created by this twist is updated. - * This is often used to implement two-way sync with an external system. - * - * @param activity - The updated activity - * @param changes - Changes to the activity and the previous version - */ - updated?: ( - activity: Activity, - changes: { - tagsAdded: Record; - tagsRemoved: Record; - /** - * If present, this update is for a specific occurrence of a recurring activity. - */ - occurrence?: ActivityOccurrence; - } - ) => Promise; + access?: ThreadAccess; }; note?: { /** @@ -191,18 +188,9 @@ export abstract class Plot extends ITool { * ``` */ intents?: NoteIntentHandler[]; - /** - * Called when a note is created on an activity created by this twist. - * This is often used to implement two-way sync with an external system, - * such as syncing notes as comments back to the source system. - * - * Notes created by the twist itself are automatically filtered out to prevent loops. - * The parent activity is available via note.activity. - * - * @param note - The newly created note - */ - created?: (note: Note) => Promise; }; + /** Enable link processing from connected source channels. */ + link?: true; priority?: { access?: PriorityAccess; }; @@ -212,112 +200,97 @@ export abstract class Plot extends ITool { }; /** - * Creates a new activity in the Plot system. + * Creates a new thread in the Plot system. * - * The activity will be automatically assigned an ID and author information - * based on the current execution context. All other fields from NewActivity - * will be preserved in the created activity. + * The thread will be automatically assigned an ID and author information + * based on the current execution context. All other fields from NewThread + * will be preserved in the created thread. * - * @param activity - The activity data to create - * @returns Promise resolving to the created activity's ID + * @param thread - The thread data to create + * @returns Promise resolving to the created thread's ID */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract createActivity( - activity: NewActivity | NewActivityWithNotes + abstract createThread( + thread: NewThread | NewThreadWithNotes ): Promise; /** - * Creates multiple activities in a single batch operation. + * Creates multiple threads in a single batch operation. * - * This method efficiently creates multiple activities at once, which is - * more performant than calling createActivity() multiple times individually. - * All activities are created with the same author and access control rules. + * This method efficiently creates multiple threads at once, which is + * more performant than calling createThread() multiple times individually. + * All threads are created with the same author and access control rules. * - * @param activities - Array of activity data to create - * @returns Promise resolving to array of created activity IDs + * @param threads - Array of thread data to create + * @returns Promise resolving to array of created thread IDs */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract createActivities( - activities: (NewActivity | NewActivityWithNotes)[] + abstract createThreads( + threads: (NewThread | NewThreadWithNotes)[] ): Promise; /** - * Updates an existing activity in the Plot system. + * Updates an existing thread in the Plot system. * - * **Important**: This method only updates existing activities. It will throw an error - * if the activity does not exist. Use `createActivity()` to create or update (upsert) - * activities. + * **Important**: This method only updates existing threads. It will throw an error + * if the thread does not exist. Use `createThread()` to create or update (upsert) + * threads. * * Only the fields provided in the update object will be modified - all other fields * remain unchanged. This enables partial updates without needing to fetch and resend - * the entire activity object. + * the entire thread object. * * For tags, provide a Record where true adds a tag and false removes it. * Tags not included in the update remain unchanged. * - * When updating the parent, the activity's path will be automatically recalculated to + * When updating the parent, the thread's path will be automatically recalculated to * maintain the correct hierarchical structure. * - * When updating scheduling fields (start, end, recurrence*), the database will - * automatically recalculate duration and range values to maintain consistency. + * Scheduling is handled separately via `createSchedule()` / `updateSchedule()`. * - * @param activity - The activity update containing the ID or source and fields to change + * @param thread - The thread update containing the ID or source and fields to change * @returns Promise that resolves when the update is complete - * @throws Error if the activity does not exist + * @throws Error if the thread does not exist * * @example * ```typescript * // Mark a task as complete - * await this.plot.updateActivity({ + * await this.plot.updateThread({ * id: "task-123", * done: new Date() * }); * - * // Reschedule an event - * await this.plot.updateActivity({ - * id: "event-456", - * start: new Date("2024-03-15T10:00:00Z"), - * end: new Date("2024-03-15T11:00:00Z") - * }); - * * // Add and remove tags - * await this.plot.updateActivity({ - * id: "activity-789", + * await this.plot.updateThread({ + * id: "thread-789", * tags: { * 1: true, // Add tag with ID 1 * 2: false // Remove tag with ID 2 * } * }); - * - * // Update a recurring event exception - * await this.plot.updateActivity({ - * id: "exception-123", - * occurrence: new Date("2024-03-20T09:00:00Z"), - * title: "Rescheduled meeting" - * }); * ``` */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract updateActivity(activity: ActivityUpdate): Promise; + abstract updateThread(thread: ThreadUpdate): Promise; /** - * Retrieves all notes within an activity. + * Retrieves all notes within a thread. * - * Notes are detailed entries within an activity, ordered by creation time. - * Each note can contain markdown content, links, and other detailed information - * related to the parent activity. + * Notes are detailed entries within a thread, ordered by creation time. + * Each note can contain markdown content, actions, and other detailed information + * related to the parent thread. * - * @param activity - The activity whose notes to retrieve - * @returns Promise resolving to array of notes in the activity + * @param thread - The thread whose notes to retrieve + * @returns Promise resolving to array of notes in the thread */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract getNotes(activity: Activity): Promise; + abstract getNotes(thread: Thread): Promise; /** - * Creates a new note in an activity. + * Creates a new note in a thread. * - * Notes provide detailed content within an activity, supporting markdown, - * links, and other rich content. The note will be automatically assigned + * Notes provide detailed content within a thread, supporting markdown, + * actions, and other rich content. The note will be automatically assigned * an ID and author information based on the current execution context. * * @param note - The note data to create @@ -327,17 +300,17 @@ export abstract class Plot extends ITool { * ```typescript * // Create a note with content * await this.plot.createNote({ - * activity: { id: "activity-123" }, + * thread: { id: "thread-123" }, * note: "Discussion notes from the meeting...", * contentType: "markdown" * }); * - * // Create a note with links + * // Create a note with actions * await this.plot.createNote({ - * activity: { id: "activity-456" }, + * thread: { id: "thread-456" }, * note: "Meeting recording available", - * links: [{ - * type: LinkType.external, + * actions: [{ + * type: ActionType.external, * title: "View Recording", * url: "https://example.com/recording" * }] @@ -362,11 +335,11 @@ export abstract class Plot extends ITool { * // Create multiple notes in one batch * await this.plot.createNotes([ * { - * activity: { id: "activity-123" }, + * thread: { id: "thread-123" }, * note: "First message in thread" * }, * { - * activity: { id: "activity-123" }, + * thread: { id: "thread-123" }, * note: "Second message in thread" * } * ]); @@ -410,25 +383,25 @@ export abstract class Plot extends ITool { abstract updateNote(note: NoteUpdate): Promise; /** - * Retrieves an activity by ID or source. + * Retrieves a thread by ID or source. * - * This method enables lookup of activities either by their unique ID or by their - * source identifier (canonical URL from an external system). Archived activities + * This method enables lookup of threads either by their unique ID or by their + * source identifier (canonical URL from an external system). Archived threads * are included in the results. * - * @param activity - Activity lookup by ID or source - * @returns Promise resolving to the matching activity or null if not found + * @param thread - Thread lookup by ID or source + * @returns Promise resolving to the matching thread or null if not found */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract getActivity( - activity: { id: Uuid } | { source: string } - ): Promise; + abstract getThread( + thread: { id: Uuid } | { source: string } + ): Promise; /** * Retrieves a note by ID or key. * * This method enables lookup of notes either by their unique ID or by their - * key (unique identifier within the activity). Archived notes are included + * key (unique identifier within the thread). Archived notes are included * in the results. * * @param note - Note lookup by ID or key @@ -440,7 +413,7 @@ export abstract class Plot extends ITool { /** * Creates a new priority in the Plot system. * - * Priorities serve as organizational containers for activities and twists. + * Priorities serve as organizational containers for threads and twists. * The created priority will be automatically assigned a unique ID. * * @param priority - The priority data to create @@ -477,7 +450,7 @@ export abstract class Plot extends ITool { /** * Adds contacts to the Plot system. * - * Contacts are used for associating people with activities, such as + * Contacts are used for associating people with threads, such as * event attendees or task assignees. Duplicate contacts (by email) * will be merged or updated as appropriate. * This method requires ContactAccess.Write permission. @@ -499,4 +472,50 @@ export abstract class Plot extends ITool { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract getActors(ids: ActorId[]): Promise; + + /** + * Creates a new schedule for a thread. + * + * Schedules define when a thread occurs in time. A thread can have + * multiple schedules (shared and per-user). + * + * @param schedule - The schedule data to create + * @returns Promise resolving to the created schedule + * + * @example + * ```typescript + * // Schedule a timed event + * const threadId = await this.plot.createThread({ + * title: "Team standup" + * }); + * await this.plot.createSchedule({ + * threadId, + * start: new Date("2025-01-15T10:00:00Z"), + * end: new Date("2025-01-15T10:30:00Z"), + * recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" + * }); + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract createSchedule(schedule: NewSchedule): Promise; + + /** + * Retrieves all schedules for a thread. + * + * @param threadId - The thread whose schedules to retrieve + * @returns Promise resolving to array of schedules for the thread + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract getSchedules(threadId: Uuid): Promise; + + /** + * Retrieves links from connected source channels. + * + * Requires `link: true` in Plot options. + * + * @param filter - Optional filter criteria for links + * @returns Promise resolving to array of links with their notes + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract getLinks(filter?: LinkFilter): Promise>; } diff --git a/twister/src/tools/twists.ts b/twister/src/tools/twists.ts index 0e49b27..087a12f 100644 --- a/twister/src/tools/twists.ts +++ b/twister/src/tools/twists.ts @@ -45,7 +45,7 @@ export type Log = { * "https://googleapis.com/*": ["use"] * }, * "plot": { - * "activity:mentioned": ["read", "write", "update"], + * "thread:mentioned": ["read", "write", "update"], * "priority": ["read", "write", "update"] * } * } diff --git a/twister/src/twist.ts b/twister/src/twist.ts index 993f21a..f174985 100644 --- a/twister/src/twist.ts +++ b/twister/src/twist.ts @@ -1,4 +1,5 @@ -import { type Link, type Actor, type Priority, Uuid } from "./plot"; +import { type Action, type Actor, type ActorId, type Link, type Note, type Priority, type Thread, Uuid } from "./plot"; +import type { Tag } from "./tag"; import { type ITool } from "./tool"; import type { Callback } from "./tools/callbacks"; import type { Serializable } from "./utils/serializable"; @@ -23,9 +24,8 @@ import type { InferTools, ToolBuilder, ToolShed } from "./utils/types"; * * async activate(priority: Pick) { * // Initialize twist for the given priority - * await this.tools.plot.createActivity({ - * type: ActivityType.Note, - * note: "Hello, good looking!", + * await this.tools.plot.createThread({ + * title: "Hello, good looking!", * }); * } * } @@ -92,25 +92,25 @@ export abstract class Twist { } /** - * Like callback(), but for a Link, which receives the link as the first argument. + * Like callback(), but for an Action, which receives the action as the first argument. * * @param fn - The method to callback - * @param extraArgs - Additional arguments to pass after the link + * @param extraArgs - Additional arguments to pass after the action * @returns Promise resolving to a persistent callback token * * @example * ```typescript - * const callback = await this.linkCallback(this.doSomething, 123); - * const link: Link = { - * type: LinkType.Callback, + * const callback = await this.actionCallback(this.doSomething, 123); + * const action: Action = { + * type: ActionType.callback, * title: "Do Something", * callback, * }; * ``` */ - protected async linkCallback< + protected async actionCallback< TArgs extends Serializable[], - Fn extends (link: Link, ...extraArgs: TArgs) => any + Fn extends (action: Action, ...extraArgs: TArgs) => any >(fn: Fn, ...extraArgs: TArgs): Promise { return this.tools.callbacks.create(fn, ...extraArgs); } @@ -263,7 +263,7 @@ export abstract class Twist { * Called when the twist is activated for a specific priority. * * This method should contain initialization logic such as setting up - * initial activities, configuring webhooks, or establishing external connections. + * initial threads, configuring webhooks, or establishing external connections. * * @param priority - The priority context containing the priority ID * @param context - Optional context containing the actor who triggered activation @@ -287,6 +287,24 @@ export abstract class Twist { return Promise.resolve(); } + /** + * Called when the twist's options configuration changes. + * + * Override to react to option changes, e.g. archiving items when a sync + * type is toggled off, or starting sync when a type is toggled on. + * + * @param oldOptions - The previously resolved options + * @param newOptions - The newly resolved options + * @returns Promise that resolves when the change is handled + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onOptionsChanged( + oldOptions: Record, + newOptions: Record + ): Promise { + return Promise.resolve(); + } + /** * Called when the twist is removed from a priority. * @@ -299,6 +317,73 @@ export abstract class Twist { return Promise.resolve(); } + /** + * Called when a thread created by this twist is updated. + * Override to implement two-way sync with an external system. + * + * @param thread - The updated thread + * @param changes - Tag additions and removals on the thread + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onThreadUpdated( + thread: Thread, + changes: { + tagsAdded: Record; + tagsRemoved: Record; + } + ): Promise { + return Promise.resolve(); + } + + /** + * Called when a note is created on a thread created by this twist. + * Override to implement two-way sync (e.g. syncing notes as comments). + * + * Notes created by the twist itself are filtered out to prevent loops. + * + * @param note - The newly created note + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onNoteCreated(note: Note, ...args: any[]): Promise { + return Promise.resolve(); + } + + /** + * Called when a link is created in a connected source channel. + * Requires `link: true` in Plot options. + * + * @param link - The newly created link + * @param notes - Notes on the link's thread + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLinkCreated(link: Link, notes: Note[]): Promise { + return Promise.resolve(); + } + + /** + * Called when a link in a connected source channel is updated. + * Requires `link: true` in Plot options. + * + * @param link - The updated link + * @param notes - Notes on the link's thread (optional) + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLinkUpdated(link: Link, notes?: Note[]): Promise { + return Promise.resolve(); + } + + /** + * Called when a note is created on a thread with a link from a connected channel. + * Requires `link: true` in Plot options. + * + * @param note - The newly created note + * @param link - The link associated with the thread + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLinkNoteCreated(note: Note, link: Link): Promise { + return Promise.resolve(); + } + /** * Waits for tool initialization to complete. * Called automatically by the entrypoint before lifecycle methods. diff --git a/twister/typedoc.json b/twister/typedoc.json index f93ac1e..0180eaf 100644 --- a/twister/typedoc.json +++ b/twister/typedoc.json @@ -14,7 +14,6 @@ "src/tools/plot.ts", "src/tools/store.ts", "src/tools/tasks.ts", - "src/common/calendar.ts", "src/utils/types.ts", "src/utils/hash.ts" ], @@ -38,7 +37,7 @@ "docs/GETTING_STARTED.md", "docs/CORE_CONCEPTS.md", "docs/TOOLS_GUIDE.md", - "docs/BUILDING_TOOLS.md", + "docs/BUILDING_SOURCES.md", "docs/CLI_REFERENCE.md", "docs/RUNTIME.md" ], diff --git a/twists/calendar-sync/package.json b/twists/calendar-sync/package.json deleted file mode 100644 index 32ce7f2..0000000 --- a/twists/calendar-sync/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@plotday/twist-calendar-sync", - "plotTwistId": "0199b6f4-85b8-7b21-aeb2-ac4169e351af", - "displayName": "Calendar Sync", - "description": "Sync calendar events", - "main": "src/index.ts", - "types": "src/index.ts", - "sideEffects": false, - "version": "0.1.0", - "private": true, - "scripts": { - "deploy": "plot deploy", - "lint": "plot lint", - "logs": "plot logs" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-google-calendar": "workspace:^", - "@plotday/tool-outlook-calendar": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/twists/calendar-sync/src/index.ts b/twists/calendar-sync/src/index.ts deleted file mode 100644 index f322bcd..0000000 --- a/twists/calendar-sync/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { GoogleCalendar } from "@plotday/tool-google-calendar"; -import { OutlookCalendar } from "@plotday/tool-outlook-calendar"; -import { - type ActivityFilter, - type NewActivityWithNotes, - type Priority, - type ToolBuilder, - Twist, -} from "@plotday/twister"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; - -export default class CalendarSyncTwist extends Twist { - build(build: ToolBuilder) { - return { - 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, - }, - }), - }; - } - - async activate(_priority: Pick) { - // Auth and calendar selection are now handled in the twist edit modal. - } - - 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/calendar-sync/tsconfig.json b/twists/calendar-sync/tsconfig.json deleted file mode 100644 index 280de0f..0000000 --- a/twists/calendar-sync/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "include": ["src/**/*.ts"] -} diff --git a/twists/chat/src/index.ts b/twists/chat/src/index.ts index b272c09..d56de6d 100644 --- a/twists/chat/src/index.ts +++ b/twists/chat/src/index.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { - type Link, - LinkType, + type Action, + ActionType, ActorType, type Note, Tag, @@ -11,7 +11,7 @@ import { } from "@plotday/twister"; import { Options } from "@plotday/twister/options"; import { AI, type AIMessage, AIModel } from "@plotday/twister/tools/ai"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; export default class ChatTwist extends Twist { build(build: ToolBuilder) { @@ -33,8 +33,8 @@ export default class ChatTwist extends Twist { }), ai: build(AI), plot: build(Plot, { - activity: { - access: ActivityAccess.Respond, + thread: { + access: ThreadAccess.Respond, }, note: { intents: [ @@ -54,14 +54,14 @@ export default class ChatTwist extends Twist { } async respond(note: Note) { - const activity = note.activity; + const thread = note.thread; - // Get all notes in this activity (conversation history) - const previousNotes = await this.tools.plot.getNotes(activity); + // Get all notes in this thread (conversation history) + const previousNotes = await this.tools.plot.getNotes(thread); // Add Thinking tag to indicate processing has started - await this.tools.plot.updateActivity({ - id: activity.id, + await this.tools.plot.updateThread({ + id: thread.id, twistTags: { [Tag.Twist]: true, }, @@ -75,12 +75,12 @@ You respond helpfully to user requests. You can also create tasks, but should only do so when the user explicitly asks you to. You can provide either or both inline and standalone links. Only use standalone links for key references, such as a website that answers the user's question in detail.`, }, - // Include activity title as context - ...(activity.title + // Include thread title as context + ...(thread.title ? [ { role: "user" as const, - content: activity.title, + content: thread.title, }, ] : []), @@ -132,35 +132,35 @@ You can provide either or both inline and standalone links. Only use standalone outputSchema: schema, }); - // Convert AI links to Link format - const activityLinks: Link[] | null = + // Convert AI links to Action format + const threadActions: Action[] | null = response.output!.links?.map((link) => ({ - type: LinkType.external, + type: ActionType.external, title: link.title, url: link.url, })) || null; - // Create AI response as a note on the existing activity + // Create AI response as a note on the existing thread await Promise.all([ this.tools.plot.createNote({ - activity, + thread, content: response.output!.response, - links: activityLinks, + actions: threadActions, }), ...(response.output!.tasks?.map((task) => this.tools.plot.createNote({ - activity, + thread, content: task, tags: { - [Tag.Now]: [{ id: note.author.id }], + [Tag.Todo]: [{ id: note.author.id }], }, }) ) ?? []), ]); // Remove Thinking tag after response is created - await this.tools.plot.updateActivity({ - id: activity.id, + await this.tools.plot.updateThread({ + id: thread.id, twistTags: { [Tag.Twist]: false, }, diff --git a/twists/code-review/package.json b/twists/code-review/package.json deleted file mode 100644 index 808d286..0000000 --- a/twists/code-review/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@plotday/twist-code-review", - "plotTwistId": "d4c81f01-3f43-5304-bdb1-81c77c1c713c", - "displayName": "Code Review", - "description": "Sync GitHub pull requests and code reviews", - "main": "src/index.ts", - "types": "src/index.ts", - "version": "0.1.0", - "private": true, - "scripts": { - "deploy": "plot deploy", - "lint": "plot lint", - "logs": "plot logs" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-github": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/twists/code-review/src/index.ts b/twists/code-review/src/index.ts deleted file mode 100644 index b7f619b..0000000 --- a/twists/code-review/src/index.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { GitHub } from "@plotday/tool-github"; -import { - type Activity, - type ActivityFilter, - ActorType, - type NewActivityWithNotes, - type Note, - type Priority, - type ToolBuilder, - Twist, -} from "@plotday/twister"; -import type { SourceControlTool } from "@plotday/twister/common/source-control"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; - -type SourceControlProvider = "github"; - -/** - * Code Review Twist - * - * Syncs source control tools (GitHub) with Plot. - * Converts pull requests into Plot activities with notes for comments - * and review summaries. - */ -export default class CodeReview extends Twist { - build(build: ToolBuilder) { - return { - github: build(GitHub, { - onItem: this.onGitHubItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - plot: build(Plot, { - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, - }, - note: { - created: this.onNoteCreated, - }, - }), - }; - } - - /** - * Get the tool for a specific source control provider - */ - private getProviderTool(provider: SourceControlProvider): SourceControlTool { - switch (provider) { - case "github": - return this.tools.github; - default: - throw new Error(`Unknown provider: ${provider}`); - } - } - - async activate(_priority: Pick) { - // Auth and repository selection are handled in the twist edit modal. - } - - async onGitHubItem(item: NewActivityWithNotes) { - return this.onPullRequest(item, "github"); - } - - 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) - */ - private isNoteEmpty(note: { - content?: string | null; - links?: any[] | null; - mentions?: any[] | null; - }): boolean { - return ( - (!note.content || note.content.trim() === "") && - (!note.links || note.links.length === 0) && - (!note.mentions || note.mentions.length === 0) - ); - } - - /** - * Called for each PR synced from any provider. - * Creates or updates Plot activities based on PR state. - */ - async onPullRequest( - pr: NewActivityWithNotes, - provider: SourceControlProvider, - ) { - // Add provider to meta for routing updates back to the correct tool - pr.meta = { ...pr.meta, provider }; - - // Filter out empty notes to avoid warnings in Plot tool - pr.notes = pr.notes?.filter((note) => !this.isNoteEmpty(note)); - - // Create/upsert - database handles everything automatically - await this.tools.plot.createActivity(pr); - } - - /** - * Called when an activity created by this twist is updated. - * Syncs changes back to the external service. - */ - private async onActivityUpdated( - activity: Activity, - _changes: { - tagsAdded: Record; - tagsRemoved: Record; - }, - ): Promise { - const provider = activity.meta?.provider as - | SourceControlProvider - | undefined; - if (!provider) return; - - const tool = this.getProviderTool(provider); - - try { - if (tool.updatePRStatus) { - await tool.updatePRStatus(activity); - } - } catch (error) { - console.error( - `Failed to sync activity update to ${provider}:`, - error, - ); - } - } - - /** - * 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 { - const activity = note.activity; - - // Filter out notes created by twists to prevent loops - if (note.author.type === ActorType.Twist) { - return; - } - - // Only sync if note has content - if (!note.content) { - return; - } - - const provider = activity.meta?.provider as - | SourceControlProvider - | undefined; - if (!provider || !activity.meta) { - return; - } - - const tool = this.getProviderTool(provider); - if (!tool.addPRComment) { - console.warn( - `Provider ${provider} does not support adding PR comments`, - ); - return; - } - - try { - const commentKey = await tool.addPRComment( - activity.meta, - note.content, - note.id, - ); - if (commentKey) { - await this.tools.plot.updateNote({ id: note.id, key: commentKey }); - } - } catch (error) { - console.error(`Failed to sync note to ${provider}:`, error); - } - } -} diff --git a/twists/code-review/tsconfig.json b/twists/code-review/tsconfig.json deleted file mode 100644 index 280de0f..0000000 --- a/twists/code-review/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "include": ["src/**/*.ts"] -} diff --git a/twists/document-actions/package.json b/twists/document-actions/package.json deleted file mode 100644 index e36ab8d..0000000 --- a/twists/document-actions/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@plotday/twist-document-actions", - "plotTwistId": "a7e1c4d2-5f38-4b91-9d06-8c2e3a1f7b54", - "displayName": "Document Actions", - "description": "Sync documents, comments, and action items from Google Drive", - "main": "src/index.ts", - "types": "src/index.ts", - "version": "0.1.0", - "private": true, - "scripts": { - "deploy": "plot deploy", - "lint": "plot lint", - "logs": "plot logs" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-google-drive": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/twists/document-actions/src/index.ts b/twists/document-actions/src/index.ts deleted file mode 100644 index 0b17ebe..0000000 --- a/twists/document-actions/src/index.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { GoogleDrive } from "@plotday/tool-google-drive"; -import { - type ActivityFilter, - type NewActivityWithNotes, - type Note, - type Priority, - ActorType, - type ToolBuilder, - Twist, -} from "@plotday/twister"; -import type { DocumentTool } from "@plotday/twister/common/documents"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; - -/** - * Document Actions Twist - * - * Syncs documents, comments, and action items from Google Drive with Plot. - * Converts documents into Plot activities with notes for comments, - * syncs Plot notes back as comments on the documents, - * and tags action items with Tag.Now for assigned users. - */ -export default class DocumentActions extends Twist { - build(build: ToolBuilder) { - return { - googleDrive: build(GoogleDrive, { - onItem: this.onDocument, - onSyncableDisabled: this.onSyncableDisabled, - }), - plot: build(Plot, { - activity: { - access: ActivityAccess.Create, - }, - note: { - created: this.onNoteCreated, - }, - }), - }; - } - - /** - * Get the document tool for a provider. - * Currently only Google Drive is supported. - */ - private getProviderTool(_provider: string): DocumentTool { - return this.tools.googleDrive; - } - - async activate(_priority: Pick) { - // 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. - */ - async onDocument(doc: NewActivityWithNotes) { - // Add provider to meta for routing updates back - doc.meta = { ...doc.meta, provider: "google-drive" }; - - await this.tools.plot.createActivity(doc); - } - - /** - * Called when a note is created on an activity created by this twist. - * Syncs the note as a comment or reply to Google Drive. - */ - private async onNoteCreated(note: Note): Promise { - const activity = note.activity; - - // Filter out twist-authored notes to prevent loops - if (note.author.type === ActorType.Twist) { - return; - } - - // Only sync if note has content - if (!note.content) { - return; - } - - // Get provider from meta - const provider = activity.meta?.provider as string | undefined; - if (!provider || !activity.meta) { - return; - } - - const tool = this.getProviderTool(provider); - - // Determine if this is a reply and find the Google Drive comment ID - let commentId: string | null = null; - if (note.reNote?.id && tool.addDocumentReply) { - commentId = await this.resolveCommentId(note); - } - - 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); - } - } - - /** - * Walks the reNote chain to find the root Google Drive comment ID. - * Returns the commentId extracted from a key like "comment-{commentId}", - * or null if the chain doesn't lead to a synced comment. - */ - private async resolveCommentId(note: Note): Promise { - // Fetch all notes for the activity to build the lookup map - const notes = await this.tools.plot.getNotes(note.activity); - const noteMap = new Map(notes.map((n) => [n.id, n])); - - // Walk up the reNote chain - let currentId = note.reNote?.id; - const visited = new Set(); - - while (currentId) { - if (visited.has(currentId)) break; // Prevent infinite loops - visited.add(currentId); - - const parent = noteMap.get(currentId); - if (!parent) break; - - // Check if this note's key is a comment key - if (parent.key?.startsWith("comment-")) { - return parent.key.slice("comment-".length); - } - - // Check if this note's key is a reply key (extract commentId) - if (parent.key?.startsWith("reply-")) { - const parts = parent.key.split("-"); - // key format: "reply-{commentId}-{replyId}" - if (parts.length >= 3) { - return parts[1]; - } - } - - // Continue up the chain - currentId = parent.reNote?.id; - } - - return null; - } -} diff --git a/twists/document-actions/tsconfig.json b/twists/document-actions/tsconfig.json deleted file mode 100644 index 280de0f..0000000 --- a/twists/document-actions/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "include": ["src/**/*.ts"] -} diff --git a/twists/message-tasks/package.json b/twists/message-tasks/package.json index 3a71112..4223020 100644 --- a/twists/message-tasks/package.json +++ b/twists/message-tasks/package.json @@ -15,8 +15,6 @@ }, "dependencies": { "@plotday/twister": "workspace:^", - "@plotday/tool-slack": "workspace:^", - "@plotday/tool-gmail": "workspace:^", "typebox": "^1.0.35" }, "devDependencies": { diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index 2d5bc82..ac67581 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -1,23 +1,16 @@ import { Type } from "typebox"; -import { Gmail } from "@plotday/tool-gmail"; -import { Slack } from "@plotday/tool-slack"; import { - type ActivityFilter, - ActivityType, - type NewActivityWithNotes, - type NewContact, + type Link, type Note, type Priority, type ToolBuilder, Twist, } from "@plotday/twister"; import { AI, type AIMessage } from "@plotday/twister/tools/ai"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; import { Uuid } from "@plotday/twister/utils/uuid"; -type MessageProvider = "slack" | "gmail"; - type Instruction = { id: string; text: string; @@ -36,18 +29,11 @@ type ThreadTask = { export default class MessageTasksTwist extends Twist { build(build: ToolBuilder) { return { - slack: build(Slack, { - onItem: this.onSlackThread, - onSyncableDisabled: this.onSyncableDisabled, - }), - gmail: build(Gmail, { - onItem: this.onGmailThread, - onSyncableDisabled: this.onSyncableDisabled, - }), ai: build(AI), plot: build(Plot, { - activity: { - access: ActivityAccess.Create, + link: true, + thread: { + access: ThreadAccess.Create, }, note: { intents: [ @@ -91,18 +77,51 @@ 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); - } + // ============================================================================ + // Link Lifecycle + // ============================================================================ - async onGmailThread(thread: NewActivityWithNotes): Promise { - const channelId = thread.meta?.syncableId as string; - return this.onMessageThread(thread, "gmail", channelId); + async onLinkCreated(link: Link, notes: Note[]): Promise { + if (!notes.length) return; + + const threadId = link.source; + if (!threadId) { + console.warn("Link has no source, skipping"); + return; + } + + // Check if we already have a task for this thread + const existingTask = await this.getThreadTask(threadId); + + if (existingTask) { + // Already has a task — check latest note for completion + const lastNote = notes[notes.length - 1]; + if (lastNote) { + await this.checkNoteForCompletion(lastNote, existingTask); + await this.updateThreadTaskCheck(threadId); + } + return; + } + + // Analyze link with AI to see if it needs a task + const analysis = await this.analyzeLink(link, notes); + + if (!analysis.needsTask || analysis.confidence < 0.6) { + return; + } + + await this.createTaskFromLink(link, notes, analysis); } - async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); + async onLinkNoteCreated(note: Note, link: Link): Promise { + const threadId = link.source; + if (!threadId) return; + + const existingTask = await this.getThreadTask(threadId); + if (!existingTask) return; + + await this.checkNoteForCompletion(note, existingTask); + await this.updateThreadTaskCheck(threadId); } // ============================================================================ @@ -157,7 +176,7 @@ export default class MessageTasksTwist extends Twist { const instructions = await this.getInstructions(); if (instructions.length >= 20) { await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: "You've reached the limit of 20 instructions. Remove one first with \"forget instruction\" before adding more.", }); @@ -174,7 +193,7 @@ export default class MessageTasksTwist extends Twist { if (summary === "UNCLEAR") { await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `I didn't understand that as an instruction. Try something like:\n- "Ignore threads from #random"\n- "Always create tasks for messages from Sarah"\n- "Never create tasks for bot messages"`, }); return; @@ -192,7 +211,7 @@ export default class MessageTasksTwist extends Twist { await this.setInstructions(instructions); await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `Saved: "${summary}"`, }); } @@ -202,7 +221,7 @@ export default class MessageTasksTwist extends Twist { if (instructions.length === 0) { await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `No instructions yet. Mention me with an instruction like "Ignore threads from #random" to add one.`, }); return; @@ -213,7 +232,7 @@ export default class MessageTasksTwist extends Twist { .join("\n"); await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `**Instructions:**\n${list}`, }); } @@ -226,7 +245,7 @@ export default class MessageTasksTwist extends Twist { if (instructions.length === 0) { await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: "No instructions to remove.", }); return; @@ -286,7 +305,7 @@ export default class MessageTasksTwist extends Twist { .join("\n"); await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `Couldn't find a matching instruction. Here are the current ones:\n${list}`, }); return; @@ -295,64 +314,28 @@ export default class MessageTasksTwist extends Twist { await this.setInstructions(instructions.filter((i) => i.id !== target.id)); await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `Removed: "${target.summary}"`, }); } // ============================================================================ - // Message Thread Processing + // Link Analysis & Task Creation // ============================================================================ - async onMessageThread( - thread: NewActivityWithNotes, - provider: MessageProvider, - channelId: string - ): Promise { - if (!thread.notes || thread.notes.length === 0) return; - - const threadId = "source" in thread ? thread.source : undefined; - if (!threadId) { - console.warn("Thread has no source, skipping"); - return; - } - - // Check if we already have a task for this thread - const existingTask = await this.getThreadTask(threadId); - - if (existingTask) { - // Thread already has a task - check if it needs updating - await this.checkThreadForCompletion(thread, existingTask); - await this.updateThreadTaskCheck(threadId); - return; - } - - // Analyze thread with AI to see if it needs a task - const analysis = await this.analyzeThread(thread); - - if (!analysis.needsTask || analysis.confidence < 0.6) { - return; - } - - // Create task from thread - await this.createTaskFromThread(thread, analysis, provider, channelId); - } - - private async analyzeThread(thread: NewActivityWithNotes): Promise<{ + private async analyzeLink(link: Link, notes: Note[]): Promise<{ needsTask: boolean; taskTitle: string | null; taskNote: string | null; confidence: number; isCompleted: boolean; }> { - // Load user instructions const instructions = await this.getInstructions(); const instructionBlock = instructions.length > 0 ? `\n\nUser instructions (follow these as rules):\n${instructions.map((i) => `- ${i.summary}`).join("\n")}` : ""; - // Build conversation for AI const messages: AIMessage[] = [ { role: "system", @@ -376,16 +359,12 @@ DO NOT create tasks for: If a task is needed, create a clear, actionable title that describes what the user needs to do.${instructionBlock}`, }, - ...thread.notes.map((note, idx) => { - const author: NewContact | null = - note.author && "email" in note.author ? note.author : null; - return { - role: "user" as const, - content: `[Message ${idx + 1}] From ${ - author?.name || author?.email || "someone" - }: ${note.content || "(empty message)"}`, - }; - }), + ...notes.map((note, idx) => ({ + role: "user" as const, + content: `[Message ${idx + 1}] From ${ + note.author?.name || note.author?.email || "someone" + }: ${note.content || "(empty message)"}`, + })), ]; const schema = Type.Object({ @@ -437,7 +416,7 @@ If a task is needed, create a clear, actionable title that describes what the us isCompleted: output.isCompleted, }; } catch (error) { - console.error("Failed to analyze thread with AI:", error); + console.error("Failed to analyze link with AI:", error); return { needsTask: false, taskTitle: null, @@ -448,52 +427,42 @@ If a task is needed, create a clear, actionable title that describes what the us } } - private formatSourceReference( - thread: NewActivityWithNotes, - provider: MessageProvider, - channelId: string - ): string { - if (provider === "gmail") { - const firstNote = thread.notes?.[0]; - const author: NewContact | null = - firstNote?.author && "email" in firstNote.author - ? firstNote.author - : null; - const senderName = author?.name || author?.email; - const subject = thread.title; + private formatSourceReference(link: Link, notes: Note[]): string { + if (link.type === "email") { + const firstNote = notes[0]; + const senderName = firstNote?.author?.name || firstNote?.author?.email; + const subject = link.title; if (senderName && subject) return `From ${senderName}: ${subject}`; if (senderName) return `From ${senderName}`; if (subject) return `Re: ${subject}`; - return `From Gmail`; + return `From email`; + } + if (link.type === "message") { + return link.channelId ? `From #${link.channelId}` : "From message"; } - return `From #${channelId}`; + return link.title || "From linked source"; } - private async createTaskFromThread( - thread: NewActivityWithNotes, + private async createTaskFromLink( + link: Link, + notes: Note[], analysis: { needsTask: boolean; taskTitle: string | null; taskNote: string | null; confidence: number; - }, - provider: MessageProvider, - channelId: string + } ): Promise { - const threadId = "source" in thread ? thread.source : undefined; + const threadId = link.source; if (!threadId) { - console.warn("Thread has no source, skipping task creation"); + console.warn("Link has no source, skipping task creation"); return; } - const sourceRef = this.formatSourceReference(thread, provider, channelId); + const sourceRef = this.formatSourceReference(link, notes); - // Create task activity - database handles upsert automatically - const taskId = await this.tools.plot.createActivity({ - source: `message-tasks:${threadId}`, - type: ActivityType.Action, - title: analysis.taskTitle || thread.title || "Action needed from message", - start: new Date(), + const taskId = await this.tools.plot.createThread({ + title: analysis.taskTitle || link.title || "Action needed from message", notes: analysis.taskNote ? [ { @@ -508,30 +477,20 @@ If a task is needed, create a clear, actionable title that describes what the us preview: analysis.taskNote ? `${analysis.taskNote}\n\n---\n${sourceRef}` : sourceRef, - meta: { - originalThreadId: threadId, - channelId, - }, - // Use pickPriority for automatic priority matching pickPriority: { content: 50, mentions: 50 }, }); - // Store mapping await this.storeThreadTask(threadId, taskId); } - private async checkThreadForCompletion( - thread: NewActivityWithNotes, + private async checkNoteForCompletion( + note: Note, taskInfo: ThreadTask ): Promise { - // Only check the last few messages for completion signals - const recentMessages = thread.notes.slice(-3); - - // Build a simple prompt to check for completion const messages: AIMessage[] = [ { role: "system", - content: `You are checking if a task appears to be completed based on recent messages in a thread. + content: `You are checking if a task appears to be completed based on a message in a thread. Look for signals like: - "Done", "Completed", "Finished" @@ -542,10 +501,10 @@ Look for signals like: Return true only if there's clear evidence the task is done.`, }, - ...recentMessages.map((note) => ({ - role: "user" as const, + { + role: "user", content: `User: ${note.content || ""}`, - })), + }, ]; const schema = Type.Object({ @@ -572,13 +531,13 @@ Return true only if there's clear evidence the task is done.`, }; if (result.isCompleted && result.confidence >= 0.7) { - await this.tools.plot.updateActivity({ + await this.tools.plot.updateThread({ id: taskInfo.taskId, - done: new Date(), + archived: true, }); } } catch (error) { - console.error("Failed to check thread for completion:", error); + console.error("Failed to check note for completion:", error); } } } diff --git a/twists/project-sync/package.json b/twists/project-sync/package.json deleted file mode 100644 index bedde08..0000000 --- a/twists/project-sync/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@plotday/twist-project-sync", - "plotTwistId": "c3b70e90-2e32-4293-adc0-70b66b0b602b", - "displayName": "Project Sync", - "description": "Sync project management tools like Linear, Jira, Asana, and GitHub Issues", - "main": "src/index.ts", - "types": "src/index.ts", - "version": "0.1.0", - "private": true, - "scripts": { - "deploy": "plot deploy", - "lint": "plot lint", - "logs": "plot logs" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-linear": "workspace:^", - "@plotday/tool-jira": "workspace:^", - "@plotday/tool-asana": "workspace:^", - "@plotday/tool-github-issues": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/twists/project-sync/src/index.ts b/twists/project-sync/src/index.ts deleted file mode 100644 index 004674b..0000000 --- a/twists/project-sync/src/index.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { Asana } from "@plotday/tool-asana"; -import { GitHubIssues } from "@plotday/tool-github-issues"; -import { Jira } from "@plotday/tool-jira"; -import { Linear } from "@plotday/tool-linear"; -import { - type Activity, - type ActivityFilter, - ActorType, - type NewActivityWithNotes, - type Note, - type Priority, - type ToolBuilder, - Twist, -} from "@plotday/twister"; -import type { ProjectTool } from "@plotday/twister/common/projects"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; - -type ProjectProvider = "linear" | "jira" | "asana" | "github-issues"; - -/** - * Project Sync Twist - * - * Syncs project management tools (Linear, Jira, Asana) with Plot. - * Converts issues and tasks into Plot activities with notes for comments. - */ -export default class ProjectSync extends Twist { - build(build: ToolBuilder) { - return { - 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, - }), - githubIssues: build(GitHubIssues, { - onItem: this.onGitHubIssuesItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - plot: build(Plot, { - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, - }, - note: { - created: this.onNoteCreated, - }, - }), - }; - } - - /** - * Get the tool for a specific project provider - */ - private getProviderTool(provider: ProjectProvider): ProjectTool { - switch (provider) { - case "linear": - return this.tools.linear; - case "jira": - return this.tools.jira; - case "asana": - return this.tools.asana; - case "github-issues": - return this.tools.githubIssues; - default: - throw new Error(`Unknown provider: ${provider}`); - } - } - - async activate(_priority: Pick) { - // 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 onGitHubIssuesItem(item: NewActivityWithNotes) { - return this.onIssue(item, "github-issues"); - } - - 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) - */ - private isNoteEmpty(note: { - content?: string | null; - links?: any[] | null; - mentions?: any[] | null; - }): boolean { - return ( - (!note.content || note.content.trim() === "") && - (!note.links || note.links.length === 0) && - (!note.mentions || note.mentions.length === 0) - ); - } - - /** - * Called for each issue synced from any provider. - * Creates or updates Plot activities based on issue state. - */ - async onIssue( - issue: NewActivityWithNotes, - provider: ProjectProvider - ) { - // Add provider to meta for routing updates back to the correct tool - issue.meta = { ...issue.meta, provider }; - - // Filter out empty notes to avoid warnings in Plot tool - issue.notes = issue.notes?.filter((note) => !this.isNoteEmpty(note)); - - // 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(issue); - } - - /** - * Called when an activity created by this twist is updated. - * Syncs changes back to the external service. - */ - private async onActivityUpdated( - activity: Activity, - _changes: { - tagsAdded: Record; - tagsRemoved: Record; - } - ): Promise { - // Get provider from meta (set by this twist when creating the activity) - const provider = activity.meta?.provider as ProjectProvider | undefined; - if (!provider) 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(activity); - } - } catch (error) { - console.error(`Failed to sync activity update to ${provider}:`, error); - } - } - - /** - * 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 { - const activity = note.activity; - - // Filter out notes created by twists to prevent loops - if (note.author.type === ActorType.Twist) { - return; - } - - // Only sync if note has content - if (!note.content) { - return; - } - - // Get provider from meta (set by this twist when creating the activity) - const provider = activity.meta?.provider as ProjectProvider | undefined; - if (!provider || !activity.meta) { - return; - } - - const tool = this.getProviderTool(provider); - if (!tool.addIssueComment) { - console.warn(`Provider ${provider} does not support adding comments`); - return; - } - - 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 }); - } - } catch (error) { - console.error(`Failed to sync note to ${provider}:`, error); - } - } -} diff --git a/twists/project-sync/tsconfig.json b/twists/project-sync/tsconfig.json deleted file mode 100644 index 280de0f..0000000 --- a/twists/project-sync/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "include": ["src/**/*.ts"] -}