Skip to content

Commit 7787a23

Browse files
committed
PostHog connector (wip, untested); API-key-based connector support
1 parent 765af7c commit 7787a23

9 files changed

Lines changed: 352 additions & 5 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `secure` property on TextDef for encrypted option values (API keys, secrets)
6+
Changed: Connector `provider` and `scopes` are now optional, enabling API-key-based connectors without OAuth

connectors/posthog/package.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@plotday/connector-posthog",
3+
"displayName": "PostHog",
4+
"description": "Sync PostHog events as person threads",
5+
"author": "Plot <team@plot.day> (https://plot.day)",
6+
"license": "MIT",
7+
"version": "0.1.0",
8+
"type": "module",
9+
"main": "./dist/index.js",
10+
"types": "./dist/index.d.ts",
11+
"exports": {
12+
".": {
13+
"@plotday/connector": "./src/index.ts",
14+
"types": "./dist/index.d.ts",
15+
"default": "./dist/index.js"
16+
}
17+
},
18+
"files": ["dist", "README.md", "LICENSE"],
19+
"scripts": {
20+
"build": "tsc",
21+
"clean": "rm -rf dist"
22+
},
23+
"dependencies": {
24+
"@plotday/twister": "workspace:^"
25+
},
26+
"devDependencies": {
27+
"typescript": "^5.9.3"
28+
},
29+
"repository": {
30+
"type": "git",
31+
"url": "https://github.com/plotday/plot.git",
32+
"directory": "connectors/posthog"
33+
},
34+
"homepage": "https://plot.day",
35+
"keywords": ["plot", "connector", "posthog", "analytics"],
36+
"publishConfig": { "access": "public" }
37+
}

connectors/posthog/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default, PostHog } from "./posthog";
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
export class PostHogAPI {
2+
constructor(
3+
private apiKey: string,
4+
private projectId: string,
5+
private host: string
6+
) {}
7+
8+
private async request<T>(
9+
path: string,
10+
params?: Record<string, string>
11+
): Promise<T> {
12+
const url = new URL(
13+
`/api/projects/${this.projectId}${path}`,
14+
this.host
15+
);
16+
if (params) {
17+
for (const [k, v] of Object.entries(params)) {
18+
url.searchParams.set(k, v);
19+
}
20+
}
21+
const response = await fetch(url.toString(), {
22+
headers: { Authorization: `Bearer ${this.apiKey}` },
23+
});
24+
if (!response.ok) {
25+
throw new Error(
26+
`PostHog API error: ${response.status} ${response.statusText}`
27+
);
28+
}
29+
return response.json() as Promise<T>;
30+
}
31+
32+
async getEventDefinitions(): Promise<
33+
Array<{ name: string; volume_30_day: number | null }>
34+
> {
35+
const data = await this.request<{
36+
results: Array<{ name: string; volume_30_day: number | null }>;
37+
}>("/event_definitions/");
38+
return data.results;
39+
}
40+
41+
async getEvents(
42+
eventName: string,
43+
after?: string
44+
): Promise<{
45+
results: Array<{
46+
uuid: string;
47+
event: string;
48+
distinct_id: string;
49+
properties: Record<string, unknown>;
50+
timestamp: string;
51+
person?: { properties?: Record<string, unknown> };
52+
}>;
53+
next?: string;
54+
}> {
55+
const params: Record<string, string> = {
56+
event: eventName,
57+
limit: "100",
58+
};
59+
if (after) params.after = after;
60+
return this.request("/events/", params);
61+
}
62+
}

connectors/posthog/src/posthog.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { type NewLinkWithNotes } from "@plotday/twister";
2+
import { Connector } from "@plotday/twister/connector";
3+
import { Options } from "@plotday/twister/options";
4+
import type { ToolBuilder } from "@plotday/twister/tool";
5+
import {
6+
type AuthToken,
7+
type Authorization,
8+
Integrations,
9+
type Channel,
10+
} from "@plotday/twister/tools/integrations";
11+
import { Callbacks } from "@plotday/twister/tools/callbacks";
12+
import { Network } from "@plotday/twister/tools/network";
13+
import { Tasks } from "@plotday/twister/tools/tasks";
14+
import { Store } from "@plotday/twister/tools/store";
15+
import { PostHogAPI } from "./posthog-api";
16+
17+
type SyncState = {
18+
after: string | null;
19+
batchNumber: number;
20+
eventsProcessed: number;
21+
initialSync: boolean;
22+
};
23+
24+
/**
25+
* PostHog connector — syncs PostHog events as person threads.
26+
*
27+
* This is a no-provider connector that uses API keys via secure options
28+
* instead of OAuth. Events are grouped by person (distinct_id) and each
29+
* event becomes a note on the person's thread.
30+
*/
31+
export class PostHog extends Connector<PostHog> {
32+
// No provider or scopes — uses API key auth via options
33+
34+
build(build: ToolBuilder) {
35+
return {
36+
integrations: build(Integrations),
37+
options: build(Options, {
38+
apiKey: {
39+
type: "text" as const,
40+
secure: true,
41+
label: "API key",
42+
default: "",
43+
placeholder: "phx_...",
44+
},
45+
projectId: {
46+
type: "text" as const,
47+
label: "Project ID",
48+
default: "",
49+
placeholder: "12345",
50+
},
51+
host: {
52+
type: "text" as const,
53+
label: "Host",
54+
default: "https://us.posthog.com",
55+
},
56+
}),
57+
callbacks: build(Callbacks),
58+
tasks: build(Tasks),
59+
store: build(Store),
60+
network: build(Network, { urls: ["https://*.posthog.com/*"] }),
61+
};
62+
}
63+
64+
private getAPI(): PostHogAPI {
65+
const opts = this.tools.options;
66+
return new PostHogAPI(
67+
opts.apiKey as string,
68+
opts.projectId as string,
69+
(opts.host as string) || "https://us.posthog.com"
70+
);
71+
}
72+
73+
/**
74+
* Returns available event definitions as channels.
75+
* Auth params are null since this connector uses API key options, not OAuth.
76+
*/
77+
async getChannels(
78+
_auth: Authorization | null,
79+
_token: AuthToken | null
80+
): Promise<Channel[]> {
81+
const api = this.getAPI();
82+
const events = await api.getEventDefinitions();
83+
return events.map((e) => ({
84+
id: e.name,
85+
title: e.name,
86+
}));
87+
}
88+
89+
/**
90+
* Start syncing events for an enabled channel (event type).
91+
*/
92+
async onChannelEnabled(channel: Channel): Promise<void> {
93+
await this.set(`sync_enabled_${channel.id}`, true);
94+
await this.startBatchSync(channel.id);
95+
}
96+
97+
/**
98+
* Clean up state when a channel is disabled.
99+
*/
100+
async onChannelDisabled(channel: Channel): Promise<void> {
101+
await this.clear(`sync_enabled_${channel.id}`);
102+
await this.clear(`sync_state_${channel.id}`);
103+
104+
// Archive links for this channel
105+
await this.tools.integrations.archiveLinks({
106+
channelId: channel.id,
107+
meta: { syncProvider: "posthog", channelId: channel.id },
108+
});
109+
}
110+
111+
private async startBatchSync(eventName: string): Promise<void> {
112+
await this.set(`sync_state_${eventName}`, {
113+
after: null,
114+
batchNumber: 1,
115+
eventsProcessed: 0,
116+
initialSync: true,
117+
} satisfies SyncState);
118+
119+
const batchCallback = await this.callback(this.syncBatch, eventName, true);
120+
await this.tools.tasks.runTask(batchCallback);
121+
}
122+
123+
/**
124+
* Fetches a batch of events and saves them as person-grouped threads.
125+
*/
126+
async syncBatch(
127+
eventName: string,
128+
initialSync?: boolean
129+
): Promise<void> {
130+
const state = await this.get<SyncState>(`sync_state_${eventName}`);
131+
if (!state) return;
132+
133+
const isInitial = initialSync ?? state.initialSync;
134+
const api = this.getAPI();
135+
const result = await api.getEvents(eventName, state.after ?? undefined);
136+
137+
// Group events by person (distinct_id)
138+
const byPerson = new Map<
139+
string,
140+
typeof result.results
141+
>();
142+
for (const event of result.results) {
143+
const key = event.distinct_id;
144+
if (!byPerson.has(key)) {
145+
byPerson.set(key, []);
146+
}
147+
byPerson.get(key)!.push(event);
148+
}
149+
150+
// Save a thread per person
151+
for (const [distinctId, events] of byPerson) {
152+
const firstEvent = events[0]!;
153+
const personProps = firstEvent.person?.properties ?? {};
154+
const personName =
155+
(personProps.name as string) ||
156+
(personProps.email as string) ||
157+
distinctId;
158+
159+
const propertiesNotes = events.map((event) => {
160+
const propsMarkdown = formatProperties(event.properties);
161+
return {
162+
key: `event:${event.uuid}`,
163+
content: `**${event.event}** at ${event.timestamp}\n\n${propsMarkdown}`,
164+
contentType: "markdown" as const,
165+
created: new Date(event.timestamp),
166+
};
167+
});
168+
169+
const link: NewLinkWithNotes = {
170+
source: `posthog:person:${distinctId}`,
171+
title: personName,
172+
type: "person",
173+
channelId: eventName,
174+
meta: {
175+
syncProvider: "posthog",
176+
channelId: eventName,
177+
distinctId,
178+
},
179+
notes: propertiesNotes,
180+
...(isInitial ? { unread: false } : {}),
181+
...(isInitial ? { archived: false } : {}),
182+
};
183+
184+
await this.tools.integrations.saveLink(link);
185+
}
186+
187+
// Continue to next batch or finish
188+
if (result.next) {
189+
await this.set(`sync_state_${eventName}`, {
190+
after: result.next,
191+
batchNumber: state.batchNumber + 1,
192+
eventsProcessed: state.eventsProcessed + result.results.length,
193+
initialSync: isInitial,
194+
} satisfies SyncState);
195+
196+
const nextBatch = await this.callback(
197+
this.syncBatch,
198+
eventName,
199+
isInitial
200+
);
201+
await this.tools.tasks.runTask(nextBatch);
202+
} else {
203+
// Sync complete
204+
await this.clear(`sync_state_${eventName}`);
205+
}
206+
}
207+
}
208+
209+
/**
210+
* Format event properties as a Markdown key-value list.
211+
*/
212+
function formatProperties(props: Record<string, unknown>): string {
213+
const entries = Object.entries(props).filter(
214+
([, v]) => v !== null && v !== undefined && v !== ""
215+
);
216+
if (entries.length === 0) return "";
217+
return entries
218+
.map(([k, v]) => `- **${k}**: ${String(v)}`)
219+
.join("\n");
220+
}
221+
222+
export default PostHog;

connectors/posthog/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"$schema": "https://json.schemastore.org/tsconfig",
3+
"extends": "@plotday/twister/tsconfig.base.json",
4+
"compilerOptions": {
5+
"outDir": "./dist"
6+
},
7+
"include": ["src/**/*.ts"]
8+
}

pnpm-lock.yaml

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

twister/src/connector.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
6464
// ---- Identity (abstract — every connector must declare) ----
6565

6666
/** The OAuth provider this connector authenticates with. */
67-
abstract readonly provider: AuthProvider;
67+
readonly provider?: AuthProvider;
6868

6969
/** OAuth scopes to request for this connector. */
70-
abstract readonly scopes: string[];
70+
readonly scopes?: string[];
7171

7272
// ---- Optional metadata ----
7373

@@ -97,8 +97,8 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
9797
* @returns Promise resolving to available channels for the user to select
9898
*/
9999
abstract getChannels(
100-
auth: Authorization,
101-
token: AuthToken
100+
auth: Authorization | null,
101+
token: AuthToken | null
102102
): Promise<Channel[]>;
103103

104104
/**
@@ -205,7 +205,7 @@ export abstract class Connector<TSelf> extends Twist<TSelf> {
205205
* @param context.actor - The actor who activated the connector
206206
*/
207207
// @ts-ignore - Connector.activate() intentionally has a different signature than Twist.activate()
208-
activate(context: { auth: Authorization; actor: Actor }): Promise<void> {
208+
activate(context: { auth?: Authorization; actor?: Actor }): Promise<void> {
209209
return Promise.resolve();
210210
}
211211
}

twister/src/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type TextDef = {
2222
description?: string;
2323
default: string;
2424
placeholder?: string;
25+
secure?: boolean; // Encrypted at rest, masked in UI, never returned to clients
2526
};
2627

2728
/**

0 commit comments

Comments
 (0)