@plotday/twister 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bin/commands/create.js +37 -3
- package/bin/commands/create.js.map +1 -1
- package/bin/commands/deploy.js +4 -0
- package/bin/commands/deploy.js.map +1 -1
- package/bin/index.js +1 -0
- package/bin/index.js.map +1 -1
- package/bin/templates/AGENTS.template.md +101 -189
- package/bin/templates/README.template.md +2 -23
- package/cli/templates/AGENTS.template.md +101 -189
- package/cli/templates/README.template.md +2 -23
- package/dist/docs/assets/hierarchy.js +1 -1
- package/dist/docs/assets/navigation.js +1 -1
- package/dist/docs/assets/search.js +1 -1
- package/dist/docs/classes/index.Options.html +1 -1
- package/dist/docs/classes/index.Source.html +200 -0
- package/dist/docs/classes/tool.ITool.html +1 -1
- package/dist/docs/classes/tool.Tool.html +21 -21
- package/dist/docs/classes/tools_ai.AI.html +1 -1
- package/dist/docs/classes/tools_callbacks.Callbacks.html +2 -2
- package/dist/docs/classes/tools_integrations.Integrations.html +45 -16
- package/dist/docs/classes/tools_network.Network.html +1 -1
- package/dist/docs/classes/tools_plot.Plot.html +93 -60
- package/dist/docs/classes/tools_store.Store.html +1 -1
- package/dist/docs/classes/tools_tasks.Tasks.html +1 -1
- package/dist/docs/classes/tools_twists.Twists.html +1 -1
- package/dist/docs/classes/twist.Twist.html +42 -10
- package/dist/docs/documents/Building_Sources.html +137 -0
- package/dist/docs/documents/Built-in_Tools.html +11 -2
- package/dist/docs/documents/Core_Concepts.html +5 -10
- package/dist/docs/documents/Getting_Started.html +1 -1
- package/dist/docs/enums/{plot.ActivityLinkType.html → plot.ActionType.html} +10 -8
- package/dist/docs/enums/plot.ActorType.html +7 -7
- package/dist/docs/enums/plot.ConferencingProvider.html +6 -6
- package/dist/docs/enums/plot.ThemeColor.html +9 -9
- package/dist/docs/enums/tag.Tag.html +3 -10
- package/dist/docs/enums/tools_integrations.AuthProvider.html +11 -11
- package/dist/docs/enums/tools_plot.ContactAccess.html +3 -3
- package/dist/docs/enums/tools_plot.PriorityAccess.html +3 -3
- package/dist/docs/enums/{tools_plot.ActivityAccess.html → tools_plot.ThreadAccess.html} +6 -6
- package/dist/docs/hierarchy.html +1 -1
- package/dist/docs/index.html +5 -6
- package/dist/docs/media/AGENTS.md +910 -0
- package/dist/docs/media/MULTI_USER_AUTH.md +111 -0
- package/dist/docs/media/SYNC_STRATEGIES.md +7 -7
- package/dist/docs/modules/index.html +1 -1
- package/dist/docs/modules/plot.html +1 -1
- package/dist/docs/modules/tool.html +1 -1
- package/dist/docs/modules/tools_integrations.html +1 -1
- package/dist/docs/modules/tools_plot.html +1 -1
- package/dist/docs/modules.html +1 -1
- package/dist/docs/types/index.NewSchedule.html +33 -0
- package/dist/docs/types/index.NewScheduleContact.html +5 -0
- package/dist/docs/types/index.NewScheduleOccurrence.html +6 -0
- package/dist/docs/types/index.Schedule.html +37 -0
- package/dist/docs/types/index.ScheduleContact.html +5 -0
- package/dist/docs/types/index.ScheduleContactRole.html +1 -0
- package/dist/docs/types/index.ScheduleContactStatus.html +1 -0
- package/dist/docs/types/index.ScheduleOccurrence.html +17 -0
- package/dist/docs/types/index.ScheduleOccurrenceUpdate.html +2 -0
- package/dist/docs/types/plot.Action.html +28 -0
- package/dist/docs/types/plot.Actor.html +6 -6
- package/dist/docs/types/plot.ActorId.html +1 -1
- package/dist/docs/types/plot.ContentType.html +1 -1
- package/dist/docs/types/plot.Link.html +36 -0
- package/dist/docs/types/plot.NewActor.html +1 -1
- package/dist/docs/types/plot.NewContact.html +5 -5
- package/dist/docs/types/plot.NewLink.html +26 -0
- package/dist/docs/types/plot.NewLinkWithNotes.html +7 -0
- package/dist/docs/types/plot.NewNote.html +10 -10
- package/dist/docs/types/plot.NewPriority.html +1 -1
- package/dist/docs/types/plot.NewTags.html +1 -1
- package/dist/docs/types/plot.NewThread.html +27 -0
- package/dist/docs/types/plot.NewThreadWithNotes.html +1 -0
- package/dist/docs/types/plot.Note.html +9 -8
- package/dist/docs/types/plot.NoteUpdate.html +2 -2
- package/dist/docs/types/plot.PickPriorityConfig.html +8 -10
- package/dist/docs/types/plot.Priority.html +6 -6
- package/dist/docs/types/plot.PriorityUpdate.html +1 -1
- package/dist/docs/types/plot.Tags.html +1 -1
- package/dist/docs/types/plot.Thread.html +1 -0
- package/dist/docs/types/plot.ThreadCommon.html +17 -0
- package/dist/docs/types/plot.ThreadFilter.html +2 -0
- package/dist/docs/types/plot.ThreadMeta.html +11 -0
- package/dist/docs/types/plot.ThreadUpdate.html +3 -0
- package/dist/docs/types/plot.ThreadWithNotes.html +1 -0
- package/dist/docs/types/tools_integrations.ArchiveLinkFilter.html +11 -0
- package/dist/docs/types/tools_integrations.AuthToken.html +4 -4
- package/dist/docs/types/tools_integrations.Authorization.html +4 -4
- package/dist/docs/types/tools_integrations.Channel.html +11 -0
- package/dist/docs/types/tools_integrations.LinkTypeConfig.html +17 -0
- package/dist/docs/types/tools_plot.LinkFilter.html +10 -0
- package/dist/docs/types/tools_plot.LinkSearchResult.html +1 -0
- package/dist/docs/types/tools_plot.NoteIntentHandler.html +6 -6
- package/dist/docs/types/tools_plot.NoteSearchResult.html +1 -0
- package/dist/docs/types/tools_plot.SearchOptions.html +7 -0
- package/dist/docs/types/tools_plot.SearchResult.html +1 -0
- package/dist/docs/types/tools_twists.TwistPermissions.html +1 -1
- package/dist/docs/variables/tools_plot.SEARCH_DEFAULT_LIMIT.html +2 -0
- package/dist/docs/variables/tools_plot.SEARCH_MAX_LIMIT.html +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/llm-docs/index.d.ts.map +1 -1
- package/dist/llm-docs/index.js +4 -8
- package/dist/llm-docs/index.js.map +1 -1
- package/dist/llm-docs/plot.d.ts +1 -1
- package/dist/llm-docs/plot.d.ts.map +1 -1
- package/dist/llm-docs/plot.js +1 -1
- package/dist/llm-docs/plot.js.map +1 -1
- package/dist/llm-docs/schedule.d.ts +9 -0
- package/dist/llm-docs/schedule.d.ts.map +1 -0
- package/dist/llm-docs/schedule.js +8 -0
- package/dist/llm-docs/schedule.js.map +1 -0
- package/dist/llm-docs/source.d.ts +9 -0
- package/dist/llm-docs/source.d.ts.map +1 -0
- package/dist/llm-docs/source.js +8 -0
- package/dist/llm-docs/source.js.map +1 -0
- package/dist/llm-docs/tag.d.ts +1 -1
- package/dist/llm-docs/tag.d.ts.map +1 -1
- package/dist/llm-docs/tag.js +1 -1
- package/dist/llm-docs/tag.js.map +1 -1
- package/dist/llm-docs/tool.d.ts +1 -1
- package/dist/llm-docs/tool.d.ts.map +1 -1
- package/dist/llm-docs/tool.js +1 -1
- package/dist/llm-docs/tool.js.map +1 -1
- package/dist/llm-docs/tools/callbacks.d.ts +1 -1
- package/dist/llm-docs/tools/callbacks.d.ts.map +1 -1
- package/dist/llm-docs/tools/callbacks.js +1 -1
- package/dist/llm-docs/tools/callbacks.js.map +1 -1
- package/dist/llm-docs/tools/integrations.d.ts +1 -1
- package/dist/llm-docs/tools/integrations.d.ts.map +1 -1
- package/dist/llm-docs/tools/integrations.js +1 -1
- package/dist/llm-docs/tools/integrations.js.map +1 -1
- package/dist/llm-docs/tools/plot.d.ts +1 -1
- package/dist/llm-docs/tools/plot.d.ts.map +1 -1
- package/dist/llm-docs/tools/plot.js +1 -1
- package/dist/llm-docs/tools/plot.js.map +1 -1
- package/dist/llm-docs/tools/twists.d.ts +1 -1
- package/dist/llm-docs/tools/twists.d.ts.map +1 -1
- package/dist/llm-docs/tools/twists.js +1 -1
- package/dist/llm-docs/tools/twists.js.map +1 -1
- package/dist/llm-docs/twist-guide-template.d.ts +1 -1
- package/dist/llm-docs/twist-guide-template.d.ts.map +1 -1
- package/dist/llm-docs/twist-guide-template.js +1 -1
- package/dist/llm-docs/twist-guide-template.js.map +1 -1
- package/dist/llm-docs/twist.d.ts +1 -1
- package/dist/llm-docs/twist.d.ts.map +1 -1
- package/dist/llm-docs/twist.js +1 -1
- package/dist/llm-docs/twist.js.map +1 -1
- package/dist/plot.d.ts +264 -589
- package/dist/plot.d.ts.map +1 -1
- package/dist/plot.js +18 -49
- package/dist/plot.js.map +1 -1
- package/dist/schedule.d.ts +172 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +2 -0
- package/dist/schedule.js.map +1 -0
- package/dist/source.d.ts +158 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +144 -0
- package/dist/source.js.map +1 -0
- package/dist/tag.d.ts +3 -10
- package/dist/tag.d.ts.map +1 -1
- package/dist/tag.js +2 -11
- package/dist/tag.js.map +1 -1
- package/dist/tool.d.ts +1 -15
- package/dist/tool.d.ts.map +1 -1
- package/dist/tool.js.map +1 -1
- package/dist/tools/callbacks.d.ts +1 -1
- package/dist/tools/callbacks.js +1 -1
- package/dist/tools/integrations.d.ts +119 -52
- package/dist/tools/integrations.d.ts.map +1 -1
- package/dist/tools/integrations.js +23 -22
- package/dist/tools/integrations.js.map +1 -1
- package/dist/tools/plot.d.ts +182 -117
- package/dist/tools/plot.d.ts.map +1 -1
- package/dist/tools/plot.js +23 -21
- package/dist/tools/plot.js.map +1 -1
- package/dist/tools/twists.d.ts +1 -1
- package/dist/twist-guide.d.ts +1 -1
- package/dist/twist-guide.d.ts.map +1 -1
- package/dist/twist.d.ts +66 -11
- package/dist/twist.d.ts.map +1 -1
- package/dist/twist.js +79 -10
- package/dist/twist.js.map +1 -1
- package/package.json +11 -41
- package/dist/common/calendar.d.ts +0 -135
- package/dist/common/calendar.d.ts.map +0 -1
- package/dist/common/calendar.js +0 -2
- package/dist/common/calendar.js.map +0 -1
- package/dist/common/documents.d.ts +0 -122
- package/dist/common/documents.d.ts.map +0 -1
- package/dist/common/documents.js +0 -2
- package/dist/common/documents.js.map +0 -1
- package/dist/common/messaging.d.ts +0 -84
- package/dist/common/messaging.d.ts.map +0 -1
- package/dist/common/messaging.js +0 -2
- package/dist/common/messaging.js.map +0 -1
- package/dist/common/projects.d.ts +0 -112
- package/dist/common/projects.d.ts.map +0 -1
- package/dist/common/projects.js +0 -2
- package/dist/common/projects.js.map +0 -1
- package/dist/docs/documents/Building_Custom_Tools.html +0 -215
- package/dist/docs/enums/plot.ActivityKind.html +0 -14
- package/dist/docs/enums/plot.ActivityType.html +0 -10
- package/dist/docs/modules/common_calendar.html +0 -1
- package/dist/docs/types/common_calendar.Calendar.html +0 -13
- package/dist/docs/types/common_calendar.CalendarTool.html +0 -81
- package/dist/docs/types/common_calendar.SyncOptions.html +0 -24
- package/dist/docs/types/plot.Activity.html +0 -2
- package/dist/docs/types/plot.ActivityCommon.html +0 -20
- package/dist/docs/types/plot.ActivityFilter.html +0 -3
- package/dist/docs/types/plot.ActivityLink.html +0 -26
- package/dist/docs/types/plot.ActivityMeta.html +0 -11
- package/dist/docs/types/plot.ActivityOccurrence.html +0 -22
- package/dist/docs/types/plot.ActivityOccurrenceUpdate.html +0 -3
- package/dist/docs/types/plot.ActivityUpdate.html +0 -3
- package/dist/docs/types/plot.ActivityWithNotes.html +0 -1
- package/dist/docs/types/plot.NewActivity.html +0 -81
- package/dist/docs/types/plot.NewActivityOccurrence.html +0 -24
- package/dist/docs/types/plot.NewActivityWithNotes.html +0 -1
- package/dist/docs/types/tool.SyncToolOptions.html +0 -9
- package/dist/docs/types/tools_integrations.IntegrationOptions.html +0 -4
- package/dist/docs/types/tools_integrations.IntegrationProviderConfig.html +0 -13
- package/dist/docs/types/tools_integrations.Syncable.html +0 -9
- package/dist/llm-docs/common/calendar.d.ts +0 -9
- package/dist/llm-docs/common/calendar.d.ts.map +0 -1
- package/dist/llm-docs/common/calendar.js +0 -8
- package/dist/llm-docs/common/calendar.js.map +0 -1
- package/dist/llm-docs/common/documents.d.ts +0 -9
- package/dist/llm-docs/common/documents.d.ts.map +0 -1
- package/dist/llm-docs/common/documents.js +0 -8
- package/dist/llm-docs/common/documents.js.map +0 -1
- package/dist/llm-docs/common/messaging.d.ts +0 -9
- package/dist/llm-docs/common/messaging.d.ts.map +0 -1
- package/dist/llm-docs/common/messaging.js +0 -8
- package/dist/llm-docs/common/messaging.js.map +0 -1
- package/dist/llm-docs/common/projects.d.ts +0 -9
- package/dist/llm-docs/common/projects.d.ts.map +0 -1
- package/dist/llm-docs/common/projects.js +0 -8
- package/dist/llm-docs/common/projects.js.map +0 -1
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
# Source Development Guide
|
|
2
|
+
|
|
3
|
+
This guide covers everything needed to build a Plot source correctly.
|
|
4
|
+
|
|
5
|
+
**For twist development**: See `../twister/cli/templates/AGENTS.template.md`
|
|
6
|
+
**For general navigation**: See `../AGENTS.md`
|
|
7
|
+
**For type definitions**: See `../twister/src/tools/*.ts` (comprehensive JSDoc)
|
|
8
|
+
|
|
9
|
+
## Quick Start: Complete Source Scaffold
|
|
10
|
+
|
|
11
|
+
Every source follows this structure:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
sources/<name>/
|
|
15
|
+
src/
|
|
16
|
+
index.ts # Re-exports: export { default, ClassName } from "./class-file"
|
|
17
|
+
<class-name>.ts # Main Source class
|
|
18
|
+
<api-name>.ts # (optional) Separate API client + transform functions
|
|
19
|
+
package.json
|
|
20
|
+
tsconfig.json
|
|
21
|
+
README.md
|
|
22
|
+
LICENSE
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### package.json
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"name": "@plotday/source-<name>",
|
|
30
|
+
"displayName": "Human Name",
|
|
31
|
+
"description": "One-line purpose statement",
|
|
32
|
+
"author": "Plot <team@plot.day> (https://plot.day)",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"version": "0.1.0",
|
|
35
|
+
"type": "module",
|
|
36
|
+
"main": "./dist/index.js",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"@plotday/source": "./src/index.ts",
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"default": "./dist/index.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc",
|
|
48
|
+
"clean": "rm -rf dist"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@plotday/twister": "workspace:^"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"typescript": "^5.9.3"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "https://github.com/plotday/plot.git",
|
|
59
|
+
"directory": "sources/<name>"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://plot.day",
|
|
62
|
+
"keywords": ["plot", "source", "<name>"],
|
|
63
|
+
"publishConfig": { "access": "public" }
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Notes:**
|
|
68
|
+
- `"@plotday/source"` export condition resolves to TypeScript source during workspace development
|
|
69
|
+
- Add third-party SDKs to `dependencies` (e.g., `"@linear/sdk": "^72.0.0"`)
|
|
70
|
+
- Add `@plotday/source-google-contacts` as `"workspace:^"` if your source syncs contacts (Google sources only)
|
|
71
|
+
|
|
72
|
+
### tsconfig.json
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
77
|
+
"extends": "@plotday/twister/tsconfig.base.json",
|
|
78
|
+
"compilerOptions": {
|
|
79
|
+
"outDir": "./dist"
|
|
80
|
+
},
|
|
81
|
+
"include": ["src/**/*.ts"]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### src/index.ts
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
export { default, SourceName } from "./source-name";
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Source Class Template
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import {
|
|
95
|
+
ActivityType,
|
|
96
|
+
LinkType,
|
|
97
|
+
type NewActivity,
|
|
98
|
+
type NewActivityWithNotes,
|
|
99
|
+
type NewNote,
|
|
100
|
+
type SyncToolOptions,
|
|
101
|
+
Source,
|
|
102
|
+
type SourceBuilder,
|
|
103
|
+
} from "@plotday/twister";
|
|
104
|
+
import type { NewContact } from "@plotday/twister/plot";
|
|
105
|
+
import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks";
|
|
106
|
+
import {
|
|
107
|
+
AuthProvider,
|
|
108
|
+
type AuthToken,
|
|
109
|
+
type Authorization,
|
|
110
|
+
Integrations,
|
|
111
|
+
type Channel,
|
|
112
|
+
} from "@plotday/twister/tools/integrations";
|
|
113
|
+
import { Network, type WebhookRequest } from "@plotday/twister/tools/network";
|
|
114
|
+
import { ContactAccess, Plot } from "@plotday/twister/tools/plot";
|
|
115
|
+
import { Tasks } from "@plotday/twister/tools/tasks";
|
|
116
|
+
|
|
117
|
+
type SyncState = {
|
|
118
|
+
cursor: string | null;
|
|
119
|
+
batchNumber: number;
|
|
120
|
+
itemsProcessed: number;
|
|
121
|
+
initialSync: boolean;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export class MySource extends Source<MySource> {
|
|
125
|
+
// 1. Static constants
|
|
126
|
+
static readonly PROVIDER = AuthProvider.Linear; // Use appropriate provider
|
|
127
|
+
static readonly SCOPES = ["read", "write"];
|
|
128
|
+
static readonly Options: SyncToolOptions;
|
|
129
|
+
declare readonly Options: SyncToolOptions;
|
|
130
|
+
|
|
131
|
+
// 2. Declare dependencies
|
|
132
|
+
build(build: SourceBuilder) {
|
|
133
|
+
return {
|
|
134
|
+
integrations: build(Integrations, {
|
|
135
|
+
providers: [{
|
|
136
|
+
provider: MySource.PROVIDER,
|
|
137
|
+
scopes: MySource.SCOPES,
|
|
138
|
+
getChannels: this.getChannels,
|
|
139
|
+
onChannelEnabled: this.onChannelEnabled,
|
|
140
|
+
onChannelDisabled: this.onChannelDisabled,
|
|
141
|
+
}],
|
|
142
|
+
}),
|
|
143
|
+
network: build(Network, { urls: ["https://api.example.com/*"] }),
|
|
144
|
+
callbacks: build(Callbacks),
|
|
145
|
+
tasks: build(Tasks),
|
|
146
|
+
plot: build(Plot, { contact: { access: ContactAccess.Write } }),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 3. Create API client using channel-based auth
|
|
151
|
+
private async getClient(channelId: string): Promise<any> {
|
|
152
|
+
const token = await this.tools.integrations.get(MySource.PROVIDER, channelId);
|
|
153
|
+
if (!token) throw new Error("No authentication token available");
|
|
154
|
+
return new SomeApiClient({ accessToken: token.token });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 4. Return available resources for the user to select
|
|
158
|
+
async getChannels(_auth: Authorization, token: AuthToken): Promise<Channel[]> {
|
|
159
|
+
const client = new SomeApiClient({ accessToken: token.token });
|
|
160
|
+
const resources = await client.listResources();
|
|
161
|
+
return resources.map(r => ({ id: r.id, title: r.name }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 5. Called when user enables a resource
|
|
165
|
+
async onChannelEnabled(channel: Channel): Promise<void> {
|
|
166
|
+
await this.set(`sync_enabled_${channel.id}`, true);
|
|
167
|
+
|
|
168
|
+
// Store parent callback tokens
|
|
169
|
+
const itemCallbackToken = await this.tools.callbacks.createFromParent(
|
|
170
|
+
this.options.onItem
|
|
171
|
+
);
|
|
172
|
+
await this.set(`item_callback_${channel.id}`, itemCallbackToken);
|
|
173
|
+
|
|
174
|
+
if (this.options.onChannelDisabled) {
|
|
175
|
+
const disableCallbackToken = await this.tools.callbacks.createFromParent(
|
|
176
|
+
this.options.onChannelDisabled,
|
|
177
|
+
{ meta: { syncProvider: "myprovider", channelId: channel.id } }
|
|
178
|
+
);
|
|
179
|
+
await this.set(`disable_callback_${channel.id}`, disableCallbackToken);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Setup webhook and start initial sync
|
|
183
|
+
await this.setupWebhook(channel.id);
|
|
184
|
+
await this.startBatchSync(channel.id);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 6. Called when user disables a resource
|
|
188
|
+
async onChannelDisabled(channel: Channel): Promise<void> {
|
|
189
|
+
await this.stopSync(channel.id);
|
|
190
|
+
|
|
191
|
+
const disableCallbackToken = await this.get<Callback>(`disable_callback_${channel.id}`);
|
|
192
|
+
if (disableCallbackToken) {
|
|
193
|
+
await this.tools.callbacks.run(disableCallbackToken);
|
|
194
|
+
await this.tools.callbacks.delete(disableCallbackToken);
|
|
195
|
+
await this.clear(`disable_callback_${channel.id}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const itemCallbackToken = await this.get<Callback>(`item_callback_${channel.id}`);
|
|
199
|
+
if (itemCallbackToken) {
|
|
200
|
+
await this.tools.callbacks.delete(itemCallbackToken);
|
|
201
|
+
await this.clear(`item_callback_${channel.id}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await this.clear(`sync_enabled_${channel.id}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 7. Public interface methods (from common interface)
|
|
208
|
+
async getProjects(projectId: string): Promise<any[]> {
|
|
209
|
+
const client = await this.getClient(projectId);
|
|
210
|
+
const projects = await client.listProjects();
|
|
211
|
+
return projects.map(p => ({
|
|
212
|
+
id: p.id,
|
|
213
|
+
name: p.name,
|
|
214
|
+
description: p.description || null,
|
|
215
|
+
key: p.key || null,
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async startSync<TArgs extends any[], TCallback extends Function>(
|
|
220
|
+
options: { projectId: string },
|
|
221
|
+
callback: TCallback,
|
|
222
|
+
...extraArgs: TArgs
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
const callbackToken = await this.tools.callbacks.createFromParent(callback, ...extraArgs);
|
|
225
|
+
await this.set(`item_callback_${options.projectId}`, callbackToken);
|
|
226
|
+
await this.setupWebhook(options.projectId);
|
|
227
|
+
await this.startBatchSync(options.projectId);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async stopSync(projectId: string): Promise<void> {
|
|
231
|
+
// Remove webhook
|
|
232
|
+
const webhookId = await this.get<string>(`webhook_id_${projectId}`);
|
|
233
|
+
if (webhookId) {
|
|
234
|
+
try {
|
|
235
|
+
const client = await this.getClient(projectId);
|
|
236
|
+
await client.deleteWebhook(webhookId);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.warn("Failed to delete webhook:", error);
|
|
239
|
+
}
|
|
240
|
+
await this.clear(`webhook_id_${projectId}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Cleanup callbacks
|
|
244
|
+
const itemCallbackToken = await this.get<Callback>(`item_callback_${projectId}`);
|
|
245
|
+
if (itemCallbackToken) {
|
|
246
|
+
await this.deleteCallback(itemCallbackToken);
|
|
247
|
+
await this.clear(`item_callback_${projectId}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await this.clear(`sync_state_${projectId}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 8. Webhook setup
|
|
254
|
+
private async setupWebhook(resourceId: string): Promise<void> {
|
|
255
|
+
try {
|
|
256
|
+
const webhookUrl = await this.tools.network.createWebhook(
|
|
257
|
+
{},
|
|
258
|
+
this.onWebhook,
|
|
259
|
+
resourceId
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// REQUIRED: Skip webhook registration in development
|
|
263
|
+
if (webhookUrl.includes("localhost") || webhookUrl.includes("127.0.0.1")) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const client = await this.getClient(resourceId);
|
|
268
|
+
const webhook = await client.createWebhook({ url: webhookUrl });
|
|
269
|
+
if (webhook?.id) {
|
|
270
|
+
await this.set(`webhook_id_${resourceId}`, webhook.id);
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error("Failed to set up webhook:", error);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 9. Batch sync
|
|
278
|
+
private async startBatchSync(resourceId: string): Promise<void> {
|
|
279
|
+
await this.set(`sync_state_${resourceId}`, {
|
|
280
|
+
cursor: null,
|
|
281
|
+
batchNumber: 1,
|
|
282
|
+
itemsProcessed: 0,
|
|
283
|
+
initialSync: true,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const batchCallback = await this.callback(this.syncBatch, resourceId);
|
|
287
|
+
await this.tools.tasks.runTask(batchCallback);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async syncBatch(resourceId: string): Promise<void> {
|
|
291
|
+
const state = await this.get<SyncState>(`sync_state_${resourceId}`);
|
|
292
|
+
if (!state) throw new Error(`Sync state not found for ${resourceId}`);
|
|
293
|
+
|
|
294
|
+
const callbackToken = await this.get<Callback>(`item_callback_${resourceId}`);
|
|
295
|
+
if (!callbackToken) throw new Error(`Callback not found for ${resourceId}`);
|
|
296
|
+
|
|
297
|
+
const client = await this.getClient(resourceId);
|
|
298
|
+
const result = await client.listItems({ cursor: state.cursor, limit: 50 });
|
|
299
|
+
|
|
300
|
+
for (const item of result.items) {
|
|
301
|
+
const activity = this.transformItem(item, resourceId, state.initialSync);
|
|
302
|
+
// Inject sync metadata for bulk operations
|
|
303
|
+
activity.meta = {
|
|
304
|
+
...activity.meta,
|
|
305
|
+
syncProvider: "myprovider",
|
|
306
|
+
channelId: resourceId,
|
|
307
|
+
};
|
|
308
|
+
await this.tools.callbacks.run(callbackToken, activity);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (result.nextCursor) {
|
|
312
|
+
await this.set(`sync_state_${resourceId}`, {
|
|
313
|
+
cursor: result.nextCursor,
|
|
314
|
+
batchNumber: state.batchNumber + 1,
|
|
315
|
+
itemsProcessed: state.itemsProcessed + result.items.length,
|
|
316
|
+
initialSync: state.initialSync,
|
|
317
|
+
});
|
|
318
|
+
const nextBatch = await this.callback(this.syncBatch, resourceId);
|
|
319
|
+
await this.tools.tasks.runTask(nextBatch);
|
|
320
|
+
} else {
|
|
321
|
+
await this.clear(`sync_state_${resourceId}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 10. Data transformation
|
|
326
|
+
private transformItem(item: any, resourceId: string, initialSync: boolean): NewActivityWithNotes {
|
|
327
|
+
return {
|
|
328
|
+
source: `myprovider:item:${item.id}`, // Canonical source for upsert
|
|
329
|
+
type: ActivityType.Action,
|
|
330
|
+
title: item.title,
|
|
331
|
+
created: item.createdAt,
|
|
332
|
+
author: item.creator?.email ? {
|
|
333
|
+
email: item.creator.email,
|
|
334
|
+
name: item.creator.name,
|
|
335
|
+
} : undefined,
|
|
336
|
+
meta: {
|
|
337
|
+
externalId: item.id,
|
|
338
|
+
resourceId,
|
|
339
|
+
},
|
|
340
|
+
notes: [{
|
|
341
|
+
key: "description", // Enables note upsert
|
|
342
|
+
content: item.description || null,
|
|
343
|
+
contentType: item.descriptionHtml ? "html" as const : "text" as const,
|
|
344
|
+
links: item.url ? [{
|
|
345
|
+
type: LinkType.external,
|
|
346
|
+
title: "Open in Service",
|
|
347
|
+
url: item.url,
|
|
348
|
+
}] : null,
|
|
349
|
+
}],
|
|
350
|
+
...(initialSync ? { unread: false } : {}),
|
|
351
|
+
...(initialSync ? { archived: false } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 11. Webhook handler
|
|
356
|
+
private async onWebhook(request: WebhookRequest, resourceId: string): Promise<void> {
|
|
357
|
+
// Verify webhook signature (provider-specific)
|
|
358
|
+
// ...
|
|
359
|
+
|
|
360
|
+
const callbackToken = await this.get<Callback>(`item_callback_${resourceId}`);
|
|
361
|
+
if (!callbackToken) return;
|
|
362
|
+
|
|
363
|
+
const payload = JSON.parse(request.rawBody || "{}");
|
|
364
|
+
const activity = this.transformItem(payload.item, resourceId, false);
|
|
365
|
+
activity.meta = {
|
|
366
|
+
...activity.meta,
|
|
367
|
+
syncProvider: "myprovider",
|
|
368
|
+
channelId: resourceId,
|
|
369
|
+
};
|
|
370
|
+
await this.tools.callbacks.run(callbackToken, activity);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export default MySource;
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## The Integrations Pattern (Auth + Channels)
|
|
378
|
+
|
|
379
|
+
**This is how ALL authentication works.** Auth is handled in the Flutter edit modal, not in code. Sources declare their provider config in `build()`.
|
|
380
|
+
|
|
381
|
+
### How It Works
|
|
382
|
+
|
|
383
|
+
1. Source declares providers in `build()` with `getChannels`, `onChannelEnabled`, `onChannelDisabled` callbacks
|
|
384
|
+
2. User clicks "Connect" in the twist edit modal → OAuth flow happens automatically
|
|
385
|
+
3. After auth, the runtime calls your `getChannels()` to list available resources
|
|
386
|
+
4. User enables resources in the modal → `onChannelEnabled()` fires
|
|
387
|
+
5. User disables resources → `onChannelDisabled()` fires
|
|
388
|
+
6. Get tokens via `this.tools.integrations.get(PROVIDER, channelId)`
|
|
389
|
+
|
|
390
|
+
### Available Providers
|
|
391
|
+
|
|
392
|
+
`AuthProvider` enum: `Google`, `Microsoft`, `Notion`, `Slack`, `Atlassian`, `Linear`, `Monday`, `GitHub`, `Asana`, `HubSpot`.
|
|
393
|
+
|
|
394
|
+
### Per-User Auth for Write-Backs
|
|
395
|
+
|
|
396
|
+
For bidirectional sync where actions should be attributed to the acting user:
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
await this.tools.integrations.actAs(
|
|
400
|
+
MySource.PROVIDER,
|
|
401
|
+
actorId, // The user who performed the action
|
|
402
|
+
activityId, // Activity to create auth prompt in (if user hasn't connected)
|
|
403
|
+
this.performWriteBack,
|
|
404
|
+
...extraArgs
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
async performWriteBack(token: AuthToken, ...extraArgs: any[]): Promise<void> {
|
|
408
|
+
// token is the acting user's token
|
|
409
|
+
const client = new ApiClient({ accessToken: token.token });
|
|
410
|
+
await client.doSomething();
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Cross-Source Auth Sharing (Google Sources)
|
|
415
|
+
|
|
416
|
+
When building a Google source that should also sync contacts, merge scopes:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
import GoogleContacts from "@plotday/source-google-contacts";
|
|
420
|
+
|
|
421
|
+
build(build: SourceBuilder) {
|
|
422
|
+
return {
|
|
423
|
+
integrations: build(Integrations, {
|
|
424
|
+
providers: [{
|
|
425
|
+
provider: AuthProvider.Google,
|
|
426
|
+
scopes: Integrations.MergeScopes(
|
|
427
|
+
MyGoogleSource.SCOPES,
|
|
428
|
+
GoogleContacts.SCOPES
|
|
429
|
+
),
|
|
430
|
+
getChannels: this.getChannels,
|
|
431
|
+
onChannelEnabled: this.onChannelEnabled,
|
|
432
|
+
onChannelDisabled: this.onChannelDisabled,
|
|
433
|
+
}],
|
|
434
|
+
}),
|
|
435
|
+
googleContacts: build(GoogleContacts),
|
|
436
|
+
// ...
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## Architecture: Sources Save Directly
|
|
442
|
+
|
|
443
|
+
**Sources save data directly** via `integrations.saveLink()`. Sources build `NewLinkWithNotes` objects and save them, rather than passing them through a parent twist.
|
|
444
|
+
|
|
445
|
+
This means:
|
|
446
|
+
- Sources request `Plot` with `ContactAccess.Write` (for contacts on threads)
|
|
447
|
+
- Sources declare providers via `Integrations` with lifecycle callbacks
|
|
448
|
+
- Sources call save methods directly to persist synced data
|
|
449
|
+
|
|
450
|
+
## Critical: Callback Serialization Pattern
|
|
451
|
+
|
|
452
|
+
**The #1 mistake when building sources is passing function references as callback arguments.** Functions cannot be serialized across worker boundaries.
|
|
453
|
+
|
|
454
|
+
### ❌ WRONG - Passing Function as Callback Argument
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
async startSync(callback: Function, ...extraArgs: any[]): Promise<void> {
|
|
458
|
+
// ❌ WRONG: callback is a function — NOT SERIALIZABLE!
|
|
459
|
+
await this.callback(this.syncBatch, callback, ...extraArgs);
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Error:** `Cannot create callback args: Found function at path "value[0]"`
|
|
464
|
+
|
|
465
|
+
### ✅ CORRECT - Store Token, Pass Primitives
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
async startSync(resourceId: string, callback: Function, ...extraArgs: any[]): Promise<void> {
|
|
469
|
+
// Step 1: Convert function to token and STORE it
|
|
470
|
+
const callbackToken = await this.tools.callbacks.createFromParent(callback, ...extraArgs);
|
|
471
|
+
await this.set(`callback_${resourceId}`, callbackToken);
|
|
472
|
+
|
|
473
|
+
// Step 2: Pass ONLY serializable values to this.callback()
|
|
474
|
+
const batchCallback = await this.callback(this.syncBatch, resourceId);
|
|
475
|
+
await this.tools.tasks.runTask(batchCallback);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async syncBatch(resourceId: string): Promise<void> {
|
|
479
|
+
// Step 3: Retrieve token from storage
|
|
480
|
+
const callbackToken = await this.get<Callback>(`callback_${resourceId}`);
|
|
481
|
+
if (!callbackToken) throw new Error(`Callback not found for ${resourceId}`);
|
|
482
|
+
|
|
483
|
+
// Step 4: Fetch data and execute callback with result
|
|
484
|
+
const result = await this.fetchItems(resourceId);
|
|
485
|
+
for (const item of result.items) {
|
|
486
|
+
await this.tools.callbacks.run(callbackToken, item);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### What's Serializable
|
|
492
|
+
|
|
493
|
+
| ✅ Safe | ❌ NOT Serializable |
|
|
494
|
+
|---------|---------------------|
|
|
495
|
+
| Strings, numbers, booleans, null | Functions, `() => {}`, method refs |
|
|
496
|
+
| Plain objects `{ key: "value" }` | `undefined` (use `null` instead) |
|
|
497
|
+
| Arrays `[1, 2, 3]` | Symbols |
|
|
498
|
+
| Dates (serialized via SuperJSON) | RPC stubs |
|
|
499
|
+
| Callback tokens (branded strings) | Circular references |
|
|
500
|
+
|
|
501
|
+
## Callback Backward Compatibility
|
|
502
|
+
|
|
503
|
+
**All callbacks automatically upgrade to new source versions on deployment.** You MUST maintain backward compatibility.
|
|
504
|
+
|
|
505
|
+
- ❌ Don't change function signatures (remove/reorder params, change types)
|
|
506
|
+
- ✅ Do add optional parameters at the end
|
|
507
|
+
- ✅ Do handle both old and new data formats with version guards
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
// v1.0 - Original
|
|
511
|
+
async syncBatch(batchNumber: number, resourceId: string) { ... }
|
|
512
|
+
|
|
513
|
+
// v1.1 - ✅ GOOD: Optional parameter at end
|
|
514
|
+
async syncBatch(batchNumber: number, resourceId: string, initialSync?: boolean) {
|
|
515
|
+
const isInitial = initialSync ?? true; // Safe default for old callbacks
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// v2.0 - ❌ BAD: Completely changed signature
|
|
519
|
+
async syncBatch(options: SyncOptions) { ... }
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
For breaking changes, implement migration logic in `preUpgrade()`:
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
async preUpgrade(): Promise<void> {
|
|
526
|
+
// Clean up stale locks from previous version
|
|
527
|
+
const keys = await this.list("sync_lock_");
|
|
528
|
+
for (const key of keys) {
|
|
529
|
+
await this.clear(key);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
## Storage Key Conventions
|
|
535
|
+
|
|
536
|
+
All sources use consistent key prefixes:
|
|
537
|
+
|
|
538
|
+
| Key Pattern | Purpose |
|
|
539
|
+
|------------|---------|
|
|
540
|
+
| `item_callback_<id>` | Serialized callback to parent's `onItem` |
|
|
541
|
+
| `disable_callback_<id>` | Serialized callback to parent's `onChannelDisabled` |
|
|
542
|
+
| `sync_state_<id>` | Current batch pagination state |
|
|
543
|
+
| `sync_enabled_<id>` | Boolean tracking enabled state |
|
|
544
|
+
| `webhook_id_<id>` | External webhook registration ID |
|
|
545
|
+
| `webhook_secret_<id>` | Webhook signing secret |
|
|
546
|
+
| `watch_renewal_task_<id>` | Scheduled task token for webhook renewal |
|
|
547
|
+
|
|
548
|
+
## Source URL Conventions
|
|
549
|
+
|
|
550
|
+
The `activity.source` field is the idempotency key for automatic upserts. Use a canonical format:
|
|
551
|
+
|
|
552
|
+
```
|
|
553
|
+
<provider>:<entity>:<id> — Standard pattern
|
|
554
|
+
<provider>:<namespace>:<id> — When provider has multiple entity types
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Examples from existing sources:
|
|
558
|
+
```
|
|
559
|
+
linear:issue:<issueId>
|
|
560
|
+
asana:task:<taskGid>
|
|
561
|
+
jira:<cloudId>:issue:<issueId> — Uses immutable ID, NOT mutable key like "PROJ-123"
|
|
562
|
+
google-calendar:<eventId>
|
|
563
|
+
outlook-calendar:<eventId>
|
|
564
|
+
google-drive:file:<fileId>
|
|
565
|
+
https://mail.google.com/mail/u/0/#inbox/<threadId> — Gmail uses full URL
|
|
566
|
+
https://slack.com/app_redirect?channel=<id>&message_ts=<ts> — Slack uses full URL
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
**Critical:** For services with mutable identifiers (like Jira where issue keys change on project move), use the immutable ID in `source` and store the mutable key in `meta` only.
|
|
570
|
+
|
|
571
|
+
## Note Key Conventions
|
|
572
|
+
|
|
573
|
+
`note.key` enables note-level upserts within an activity:
|
|
574
|
+
|
|
575
|
+
```
|
|
576
|
+
"description" — Main content / description note
|
|
577
|
+
"summary" — Document summary
|
|
578
|
+
"metadata" — Status/priority/assignee metadata
|
|
579
|
+
"cancellation" — Cancelled event note
|
|
580
|
+
"comment-<externalCommentId>" — Individual comment
|
|
581
|
+
"reply-<commentId>-<replyId>" — Reply to a comment
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
## HTML Content Handling
|
|
585
|
+
|
|
586
|
+
**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.
|
|
587
|
+
|
|
588
|
+
### Pattern
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
// ✅ CORRECT: Pass raw HTML with contentType
|
|
592
|
+
const note = {
|
|
593
|
+
key: "description",
|
|
594
|
+
content: item.bodyHtml, // Raw HTML from API
|
|
595
|
+
contentType: "html" as const, // Server converts to markdown
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// ✅ CORRECT: Use plain text when that's what you have
|
|
599
|
+
const note = {
|
|
600
|
+
key: "description",
|
|
601
|
+
content: item.bodyText,
|
|
602
|
+
contentType: "text" as const,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// ❌ WRONG: Stripping HTML locally
|
|
606
|
+
const stripped = html.replace(/<[^>]+>/g, " ").trim();
|
|
607
|
+
const note = { content: stripped }; // Broken encoding, lost links
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### When APIs provide both HTML and plain text
|
|
611
|
+
|
|
612
|
+
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.
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
function extractBody(part: MessagePart): { content: string; contentType: "text" | "html" } {
|
|
616
|
+
// Prefer HTML for server-side conversion
|
|
617
|
+
const htmlPart = findPart(part, "text/html");
|
|
618
|
+
if (htmlPart) return { content: decode(htmlPart), contentType: "html" };
|
|
619
|
+
|
|
620
|
+
const textPart = findPart(part, "text/plain");
|
|
621
|
+
if (textPart) return { content: decode(textPart), contentType: "text" };
|
|
622
|
+
|
|
623
|
+
return { content: "", contentType: "text" };
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
### Previews
|
|
628
|
+
|
|
629
|
+
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.
|
|
630
|
+
|
|
631
|
+
### ContentType values
|
|
632
|
+
|
|
633
|
+
| Value | Meaning |
|
|
634
|
+
|-------|---------|
|
|
635
|
+
| `"text"` | Plain text — auto-links URLs, preserves line breaks |
|
|
636
|
+
| `"markdown"` | Already markdown (default if omitted) |
|
|
637
|
+
| `"html"` | HTML — converted to markdown server-side |
|
|
638
|
+
|
|
639
|
+
## Sync Metadata Injection
|
|
640
|
+
|
|
641
|
+
**Every synced activity MUST include sync metadata** in `activity.meta` for bulk operations (e.g., archiving all activities when a sync is disabled):
|
|
642
|
+
|
|
643
|
+
```typescript
|
|
644
|
+
activity.meta = {
|
|
645
|
+
...activity.meta,
|
|
646
|
+
syncProvider: "myprovider", // Provider identifier
|
|
647
|
+
channelId: resourceId, // Resource being synced
|
|
648
|
+
};
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
This metadata is used by the twist's `onChannelDisabled` callback to match and archive activities:
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
// In the twist:
|
|
655
|
+
async onChannelDisabled(filter: ActivityFilter): Promise<void> {
|
|
656
|
+
await this.tools.plot.updateActivity({ match: filter, archived: true });
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
## Initial vs. Incremental Sync (REQUIRED)
|
|
661
|
+
|
|
662
|
+
**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.
|
|
663
|
+
|
|
664
|
+
| Field | Initial Sync | Incremental Sync | Reason |
|
|
665
|
+
|-------|-------------|------------------|--------|
|
|
666
|
+
| `unread` | `false` | *omit* | Initial: mark all read. Incremental: auto-mark read for author only |
|
|
667
|
+
| `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates |
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
const activity = {
|
|
671
|
+
// ...
|
|
672
|
+
...(initialSync ? { unread: false } : {}),
|
|
673
|
+
...(initialSync ? { archived: false } : {}),
|
|
674
|
+
};
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
### How to propagate the flag
|
|
678
|
+
|
|
679
|
+
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:
|
|
680
|
+
|
|
681
|
+
**Pattern A: Store in SyncState** (used in the scaffold above)
|
|
682
|
+
|
|
683
|
+
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`.
|
|
684
|
+
|
|
685
|
+
**Pattern B: Pass as callback argument** (used by sources like Gmail that don't store `initialSync` in state)
|
|
686
|
+
|
|
687
|
+
Pass `initialSync` as an explicit argument through `this.callback()`:
|
|
688
|
+
|
|
689
|
+
```typescript
|
|
690
|
+
// onChannelEnabled — initial sync
|
|
691
|
+
const syncCallback = await this.callback(this.syncBatch, 1, "full", channel.id, true);
|
|
692
|
+
|
|
693
|
+
// startIncrementalSync — not initial
|
|
694
|
+
const syncCallback = await this.callback(this.syncBatch, 1, "incremental", channelId, false);
|
|
695
|
+
|
|
696
|
+
// syncBatch — accept and propagate the flag
|
|
697
|
+
async syncBatch(
|
|
698
|
+
batchNumber: number,
|
|
699
|
+
mode: "full" | "incremental",
|
|
700
|
+
channelId: string,
|
|
701
|
+
initialSync?: boolean // optional for backward compat with old serialized callbacks
|
|
702
|
+
): Promise<void> {
|
|
703
|
+
const isInitial = initialSync ?? (mode === "full"); // safe default for old callbacks
|
|
704
|
+
// ... pass isInitial to processItems and to next batch callback
|
|
705
|
+
}
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
**Whichever pattern you use, verify that ALL entry points set the flag correctly:**
|
|
709
|
+
- `onChannelEnabled` → `true` (first import)
|
|
710
|
+
- `startSync` → `true` (manual full sync)
|
|
711
|
+
- Webhook / incremental handler → `false`
|
|
712
|
+
- Next batch callback → propagate current value
|
|
713
|
+
|
|
714
|
+
## Webhook Patterns
|
|
715
|
+
|
|
716
|
+
### Localhost Guard (REQUIRED)
|
|
717
|
+
|
|
718
|
+
All sources MUST skip webhook registration in local development:
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
const webhookUrl = await this.tools.network.createWebhook({}, this.onWebhook, resourceId);
|
|
722
|
+
|
|
723
|
+
if (webhookUrl.includes("localhost") || webhookUrl.includes("127.0.0.1")) {
|
|
724
|
+
return; // Skip — webhooks can't reach localhost
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
### Webhook Verification
|
|
729
|
+
|
|
730
|
+
Verify webhook signatures to prevent unauthorized calls. Each provider has its own method:
|
|
731
|
+
|
|
732
|
+
| Provider | Method |
|
|
733
|
+
|----------|--------|
|
|
734
|
+
| Linear | `LinearWebhookClient` from `@linear/sdk/webhooks` |
|
|
735
|
+
| Slack | Challenge response + event type filtering |
|
|
736
|
+
| Google | UUID secret in channel token query |
|
|
737
|
+
| Microsoft | Subscription `clientState` |
|
|
738
|
+
| Asana | HMAC-SHA256 via `crypto.subtle` |
|
|
739
|
+
|
|
740
|
+
### Watch Renewal (Calendar/Drive)
|
|
741
|
+
|
|
742
|
+
For providers that expire watches, schedule proactive renewal:
|
|
743
|
+
|
|
744
|
+
```typescript
|
|
745
|
+
private async scheduleWatchRenewal(resourceId: string): Promise<void> {
|
|
746
|
+
const expiresAt = /* watch expiry from provider */;
|
|
747
|
+
const renewalTime = new Date(expiresAt.getTime() - 24 * 60 * 60 * 1000); // 24h before
|
|
748
|
+
|
|
749
|
+
const renewalCallback = await this.callback(this.renewWatch, resourceId);
|
|
750
|
+
const taskToken = await this.runTask(renewalCallback, { runAt: renewalTime });
|
|
751
|
+
if (taskToken) await this.set(`watch_renewal_task_${resourceId}`, taskToken);
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
## Bidirectional Sync
|
|
756
|
+
|
|
757
|
+
For sources that support write-backs (updating external items from Plot):
|
|
758
|
+
|
|
759
|
+
### Issue/Task Updates (`updateIssue`)
|
|
760
|
+
|
|
761
|
+
```typescript
|
|
762
|
+
async updateIssue(activity: Activity): Promise<void> {
|
|
763
|
+
const externalId = activity.meta?.externalId as string;
|
|
764
|
+
if (!externalId) throw new Error("External ID not found in meta");
|
|
765
|
+
|
|
766
|
+
const client = await this.getClient(activity.meta?.resourceId as string);
|
|
767
|
+
await client.updateItem(externalId, {
|
|
768
|
+
title: activity.title,
|
|
769
|
+
done: activity.type === ActivityType.Action ? activity.done : undefined,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
### Comment Sync (`addIssueComment`)
|
|
775
|
+
|
|
776
|
+
```typescript
|
|
777
|
+
async addIssueComment(meta: ActivityMeta, body: string, noteId?: string): Promise<string | void> {
|
|
778
|
+
const externalId = meta.externalId as string;
|
|
779
|
+
if (!externalId) throw new Error("External ID not found");
|
|
780
|
+
|
|
781
|
+
const client = await this.getClient(meta.resourceId as string);
|
|
782
|
+
const comment = await client.createComment(externalId, { body });
|
|
783
|
+
if (comment?.id) return `comment-${comment.id}`;
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### Loop Prevention
|
|
788
|
+
|
|
789
|
+
The parent twist prevents infinite loops by checking note authorship:
|
|
790
|
+
|
|
791
|
+
```typescript
|
|
792
|
+
// In the twist (not the source):
|
|
793
|
+
async onNoteCreated(note: Note): Promise<void> {
|
|
794
|
+
if (note.author.type === ActorType.Twist) return; // Prevent loops
|
|
795
|
+
// ... sync note to external service
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
## Contacts Pattern
|
|
800
|
+
|
|
801
|
+
Sources that sync user data should create contacts for authors and assignees:
|
|
802
|
+
|
|
803
|
+
```typescript
|
|
804
|
+
import type { NewContact } from "@plotday/twister/plot";
|
|
805
|
+
|
|
806
|
+
const authorContact: NewContact | undefined = creator?.email ? {
|
|
807
|
+
email: creator.email,
|
|
808
|
+
name: creator.name,
|
|
809
|
+
avatar: creator.avatarUrl ?? undefined,
|
|
810
|
+
} : undefined;
|
|
811
|
+
|
|
812
|
+
const activity: NewActivityWithNotes = {
|
|
813
|
+
// ...
|
|
814
|
+
author: authorContact,
|
|
815
|
+
assignee: assigneeContact ?? null,
|
|
816
|
+
notes: [{
|
|
817
|
+
author: authorContact, // Note-level author too
|
|
818
|
+
// ...
|
|
819
|
+
}],
|
|
820
|
+
};
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
Declare `ContactAccess.Write` in build:
|
|
824
|
+
```typescript
|
|
825
|
+
plot: build(Plot, { contact: { access: ContactAccess.Write } }),
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
## Buffer Declaration
|
|
829
|
+
|
|
830
|
+
Cloudflare Workers provides `Buffer` globally, but TypeScript doesn't know about it. Declare it at the top of files that need it:
|
|
831
|
+
|
|
832
|
+
```typescript
|
|
833
|
+
declare const Buffer: {
|
|
834
|
+
from(
|
|
835
|
+
data: string | ArrayBuffer | Uint8Array,
|
|
836
|
+
encoding?: string
|
|
837
|
+
): Uint8Array & { toString(encoding?: string): string };
|
|
838
|
+
};
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
## Building and Testing
|
|
842
|
+
|
|
843
|
+
```bash
|
|
844
|
+
# Build the source
|
|
845
|
+
cd public/sources/<name> && pnpm build
|
|
846
|
+
|
|
847
|
+
# Type-check without building
|
|
848
|
+
cd public/sources/<name> && pnpm exec tsc --noEmit
|
|
849
|
+
|
|
850
|
+
# Install dependencies (from repo root)
|
|
851
|
+
pnpm install
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
After creating a new source, add it to `pnpm-workspace.yaml` if not already covered by the glob pattern.
|
|
855
|
+
|
|
856
|
+
## Source Development Checklist
|
|
857
|
+
|
|
858
|
+
- [ ] Extend `Source<YourSource>`
|
|
859
|
+
- [ ] Declare `static readonly PROVIDER`, `static readonly SCOPES`
|
|
860
|
+
- [ ] Declare `static readonly Options: SyncToolOptions` and `declare readonly Options: SyncToolOptions`
|
|
861
|
+
- [ ] Declare all dependencies in `build()`: Integrations, Network, Callbacks, Tasks, Plot
|
|
862
|
+
- [ ] Implement `getChannels()`, `onChannelEnabled()`, `onChannelDisabled()`
|
|
863
|
+
- [ ] Convert parent callbacks to tokens with `createFromParent()` — **never pass functions to `this.callback()`**
|
|
864
|
+
- [ ] Store callback tokens with `this.set()`, retrieve with `this.get<Callback>()`
|
|
865
|
+
- [ ] Pass only serializable values (no functions, no undefined) to `this.callback()`
|
|
866
|
+
- [ ] Implement batch sync with `this.tools.tasks.runTask()` for fresh request limits
|
|
867
|
+
- [ ] Add localhost guard in webhook setup
|
|
868
|
+
- [ ] Verify webhook signatures
|
|
869
|
+
- [ ] Use canonical `source` URLs for activity upserts (immutable IDs)
|
|
870
|
+
- [ ] Use `note.key` for note-level upserts
|
|
871
|
+
- [ ] Set `contentType: "html"` on notes with HTML content — **never strip HTML locally**
|
|
872
|
+
- [ ] Inject `syncProvider` and `channelId` into `activity.meta`
|
|
873
|
+
- [ ] Set `created` on notes using the external system's timestamp (not sync time)
|
|
874
|
+
- [ ] 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
|
|
875
|
+
- [ ] Create contacts for authors/assignees with `NewContact`
|
|
876
|
+
- [ ] Clean up all stored state and callbacks in `stopSync()` and `onChannelDisabled()`
|
|
877
|
+
- [ ] Add `package.json` with correct structure, `tsconfig.json`, and `src/index.ts` re-export
|
|
878
|
+
- [ ] Verify the source builds: `pnpm build`
|
|
879
|
+
|
|
880
|
+
## Common Pitfalls
|
|
881
|
+
|
|
882
|
+
1. **❌ Passing functions to `this.callback()`** — Convert to tokens first with `createFromParent()`
|
|
883
|
+
2. **❌ Storing functions with `this.set()`** — Convert to tokens first
|
|
884
|
+
3. **❌ Not validating callback token exists** — Always check before `callbacks.run()`
|
|
885
|
+
4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `activity.meta`
|
|
886
|
+
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
|
|
887
|
+
6. **❌ Using mutable IDs in `source`** — Use immutable IDs (Jira issue ID, not issue key)
|
|
888
|
+
7. **❌ Not breaking loops into batches** — Each execution has ~1000 request limit
|
|
889
|
+
8. **❌ Missing localhost guard** — Webhook registration fails silently on localhost
|
|
890
|
+
9. **❌ Calling `plot.createThread()` from a source** — Sources save data directly via `integrations.saveLink()`
|
|
891
|
+
10. **❌ Breaking callback signatures** — Old callbacks auto-upgrade; add optional params at end only
|
|
892
|
+
11. **❌ Passing `undefined` in serializable values** — Use `null` instead
|
|
893
|
+
12. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state
|
|
894
|
+
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)
|
|
895
|
+
14. **❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side conversion. Local regex stripping breaks encoding and loses links
|
|
896
|
+
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"
|
|
897
|
+
|
|
898
|
+
## Study These Examples
|
|
899
|
+
|
|
900
|
+
| Source | Category | Key Patterns |
|
|
901
|
+
|--------|----------|-------------|
|
|
902
|
+
| `linear/` | ProjectSource | Clean reference implementation, webhook handling, bidirectional sync |
|
|
903
|
+
| `google-calendar/` | CalendarSource | Recurring events, RSVP write-back, watch renewal, cross-source auth sharing |
|
|
904
|
+
| `slack/` | MessagingSource | Team-sharded webhooks, thread model, Slack-specific auth |
|
|
905
|
+
| `gmail/` | MessagingSource | PubSub webhooks, email thread transformation, HTML contentType, callback-arg initialSync pattern |
|
|
906
|
+
| `google-drive/` | DocumentSource | Document comments, reply threading, file watching |
|
|
907
|
+
| `jira/` | ProjectSource | Immutable vs mutable IDs, comment metadata for dedup |
|
|
908
|
+
| `asana/` | ProjectSource | HMAC webhook verification, section-based projects |
|
|
909
|
+
| `outlook-calendar/` | CalendarSource | Microsoft Graph API, subscription management |
|
|
910
|
+
| `google-contacts/` | (Supporting) | Contact sync, cross-source `syncWithAuth()` pattern |
|