') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Require channelId on NewLinkWithNotes so connector write-back can't silently break by KrisBraun · Pull Request #274 · plotday/plot · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/require-link-channelid.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
"@plotday/twister": minor
---

Changed: `NewLinkWithNotes.channelId` (the type `integrations.saveLink()`/`saveLinks()` accept) is now required instead of optional. It was easy to set `channelId` only inside `meta` and forget the top-level field — that compiled fine but silently broke outbound write-back (`onNoteCreated` reads the channel back from the persisted `channelId`, not from `meta`) and bulk operations like `archiveLinks({ channelId })` on disable, with no error anywhere. The type system now catches this at compile time instead.

Added: `CreateLinkResult` — the return type for `Connector.onCreateLink()`. Identical to `NewLinkWithNotes` except `channelId` stays optional, since the platform auto-fills it from the compose draft when omitted. Connectors implementing `onCreateLink` should update their return type from `NewLinkWithNotes | null` to `CreateLinkResult | null`.
14 changes: 9 additions & 5 deletions connectors/AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,13 +301,17 @@ Previews (`preview` fields) always use plain text — `snippet` or truncated tit

## Sync metadata injection

Every synced link must carry provider and channel metadata — bulk operations (e.g. `integrations.archiveLinks({ channelId })` on disable) rely on it:
Every synced link must carry provider and channel metadata:

```typescript
link.channelId = resourceId; // first-class field on NewLink
link.channelId = resourceId; // first-class field on NewLinkWithNotes — REQUIRED
link.meta = { ...link.meta, syncProvider: "myprovider" };
```

`channelId` is required on `NewLinkWithNotes` (the type `saveLink()`/`saveLinks()` accept) precisely because it's easy to set only inside `meta` and forget the top-level field — the type system will reject a link built that way. This isn't just about bulk operations like `integrations.archiveLinks({ channelId })` on disable: the platform persists `channelId` to the link's DB row and reads it back from there — not from `meta` — to populate `thread.meta.channelId` for connector callbacks like `onNoteCreated`. A link with `channelId` only inside `meta` will send replies to nobody: the connector's own `onNoteCreated` reads a channelId that was never actually saved, and typically no-ops silently (no error, nothing captured) because it can't resolve a client for the reply. Set `channelId` at the top level on every value you pass to `saveLink()`/`saveLinks()`.

`onCreateLink` is the one exception: its return type is `CreateLinkResult`, where `channelId` is optional — the platform auto-fills it from `draft.channelId` (the channel the user composed into) if you omit it.

## Classifier facets (optional)

Messaging-style connectors may set `link.facets` (`format` / `automation` / `reach` from `@plotday/twister/facets`) as internal classifier signal. Set a dimension only when a heuristic is confident; leave it `null`/omitted otherwise. See `gmail/src/gmail-facets.ts` and `slack/src/slack-facets.ts`.
Expand DownExpand Up@@ -520,7 +524,7 @@ For closed-roster DM-style compose set `compose.targets: "contacts"`; for open a
Then implement `onCreateLink` — return the link, do NOT call `integrations.saveLink()` (platform wires it to the originating thread):

```typescript
async onCreateLink(draft: CreateLinkDraft): Promise<NewLinkWithNotes | null> {
async onCreateLink(draft: CreateLinkDraft): Promise<CreateLinkResult | null> {
if (draft.type !== "issue") return null;
const client = await this.getClient(draft.channelId);
const payload = await client.createIssue({
Expand DownExpand Up@@ -609,7 +613,7 @@ Add to `pnpm-workspace.yaml` if not already covered by a glob.
- [ ] Batch state writes use `this.setMany()`, never a per-item `set()` loop
- [ ] Canonical, globally-unique `source` using immutable ids; mutable keys in `meta` only
- [ ] `note.key` for note-level upserts
- [ ] Set `link.channelId` and inject `syncProvider` into `link.meta`
- [ ] Set `link.channelId` (top-level field, required) and inject `syncProvider` into `link.meta`
- [ ] `contentType: "html"` for HTML — never strip tags locally
- [ ] `created` on notes = external timestamp, not sync time
- [ ] `initialSync` propagated through every entry point and batch; set `unread: false, archived: false` on initial, omit on incremental
Expand All@@ -622,7 +626,7 @@ Add to `pnpm-workspace.yaml` if not already covered by a glob.
## Common pitfalls

1. Passing functions/`undefined`/RPC stubs to `this.callback()` → use tokens + `null`.
2. Forgetting sync metadata (`link.channelId`, `meta.syncProvider`) → breaks bulk archive on disable.
2. Setting `channelId` only inside `link.meta` instead of at the top level → `NewLinkWithNotes.channelId` is what the platform actually persists and reads back for connector callbacks (`thread.meta.channelId` in `onNoteCreated`, etc.) and for bulk operations like `integrations.archiveLinks({ channelId })` on disable. A `meta`-only channelId compiles fine structurally but leaves outbound replies (and disable-time cleanup) silently broken — no error, nothing captured. The type system now requires the top-level field on `NewLinkWithNotes`, so this fails to compile instead of failing silently in production.
3. Not propagating `initialSync` through the whole pipeline → notification spam.
4. Mutable ids in `source` (e.g. Jira issue key) → use immutable id, store key in `meta`.
5. `source` that's only unique within one user's account → breaks cross-user dedup; add workspace/tenant/mailbox qualifier.
Expand Down
2 changes: 1 addition & 1 deletion connectors/github/src/issue-sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,6 @@ export async function syncIssueBatch(
);

if (link) {
link.channelId = repositoryId;
link.meta = {
...link.meta,
syncProvider: "github",
Expand DownExpand Up@@ -209,6 +208,7 @@ async function convertIssueToLink(
}

const link: NewLinkWithNotes = {
channelId: repositoryId,
source: `github:issue:${owner}/${repo}/${issue.number}`,
type: "issue",
title: issue.title,
Expand Down
2 changes: 1 addition & 1 deletion connectors/github/src/pr-sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,6 @@ export async function syncPRBatch(
);

if (thread) {
thread.channelId = repositoryId;
thread.meta = {
...thread.meta,
syncProvider: "github",
Expand DownExpand Up@@ -219,6 +218,7 @@ async function convertPRToThread(
}

const thread: NewLinkWithNotes = {
channelId: repositoryId,
source: `github:pr:${owner}/${repo}/${pr.number}`,
type: "pull_request",
title: pr.title,
Expand Down
9 changes: 7 additions & 2 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -855,8 +855,11 @@ export function collectAttachments(
*/
export function transformGmailThread(thread: GmailThread): NewLinkWithNotes {
if (!thread.messages || thread.messages.length === 0) {
// Return empty structure for invalid threads
// Return empty structure for invalid threads. channelId is unknown at
// this call site — the caller always sets the real value right after
// (see "Inject channel ID" in sync.ts) before saving.
return {
channelId: null,
type: "email",
title: "",
notes: [],
Expand DownExpand Up@@ -909,8 +912,10 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes {
}
}

// Create link
// Create link. channelId is unknown here — the caller always sets the
// real value right after (see "Inject channel ID" in sync.ts) before saving.
const plotThread: NewLinkWithNotes = {
channelId: null,
source: canonicalUrl,
type: "email",
title: subject || "Email",
Expand Down
4 changes: 2 additions & 2 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { Actor, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, CreateLinkResult, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -774,7 +774,7 @@ export class Gmail extends Connector<Gmail> {
*/
override async onCreateLink(
draft: CreateLinkDraft
): Promise<NewLinkWithNotes | null> {
): Promise<CreateLinkResult | null> {
return onCreateLinkFn(this.makeHost(), draft);
}

Expand Down
5 changes: 3 additions & 2 deletions connectors/gmail/src/sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import { ActionType } from "@plotday/twister/plot";
import type {
Actor,
ActorId,
CreateLinkResult,
NewLinkWithNotes,
Note,
Thread,
Expand DownExpand Up@@ -1968,7 +1969,7 @@ async function onCreateLinkForwardFn(
host: GmailSyncHost,
draft: CreateLinkDraft,
forwardKey: string
): Promise<NewLinkWithNotes | null> {
): Promise<CreateLinkResult | null> {
const api = await getApiAnyFn(host);
if (!api) {
console.error(
Expand DownExpand Up@@ -2154,7 +2155,7 @@ async function onCreateLinkForwardFn(
export async function onCreateLinkFn(
host: GmailSyncHost,
draft: CreateLinkDraft
): Promise<NewLinkWithNotes | null> {
): Promise<CreateLinkResult | null> {
if (draft.type !== "email") return null;

if (draft.forward) {
Expand Down
4 changes: 2 additions & 2 deletions connectors/google-calendar/src/sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -725,6 +725,7 @@ export async function processCalendarEventsFn(
};

const link: NewLinkWithNotes = {
channelId: calendarId,
source: canonicalUrl,
sources: buildEventSources({
iCalUID: event.iCalUID,
Expand DownExpand Up@@ -760,7 +761,6 @@ export async function processCalendarEventsFn(
...(initialSync ? { archived: false } : {}),
};

link.channelId = calendarId;
link.meta = {
...link.meta,
syncProvider: "google",
Expand DownExpand Up@@ -880,6 +880,7 @@ export async function processCalendarEventsFn(
const notes = descriptionNote ? [descriptionNote] : [];

const link: NewLinkWithNotes = {
channelId: calendarId,
source: canonicalUrl,
sources: buildEventSources({
iCalUID: event.iCalUID,
Expand DownExpand Up@@ -913,7 +914,6 @@ export async function processCalendarEventsFn(
...(initialSync ? { archived: false } : {}),
};

link.channelId = calendarId;
link.meta = {
...link.meta,
syncProvider: "google",
Expand Down
1 change: 1 addition & 0 deletions connectors/google-chat/src/google-chat-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -657,6 +657,7 @@ export function transformChatThread(
const preview = firstMessage.text?.substring(0, 200) ?? null;

return {
channelId: spaceId,
source: `google-chat:${spaceId}:thread:${threadKey}`,
type: "thread",
title,
Expand Down
4 changes: 2 additions & 2 deletions connectors/google/src/google.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,7 @@ import type {
Thread,
ToolBuilder,
} from "@plotday/twister";
import type { NewLinkWithNotes, Note } from "@plotday/twister/plot";
import type { CreateLinkResult, Note } from "@plotday/twister/plot";
import type { ScheduleContactStatus } from "@plotday/twister/schedule";
import {
AuthProvider,
Expand DownExpand Up@@ -863,7 +863,7 @@ export class Google extends Connector<Google> {
*/
override async onCreateLink(
draft: CreateLinkDraft
): Promise<NewLinkWithNotes | null> {
): Promise<CreateLinkResult | null> {
if (draft.type === "task") {
return tasksOnCreateLinkFn(this.makeTasksHost(), draft);
}
Expand Down
7 changes: 5 additions & 2 deletions connectors/jira/src/jira.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
type Link,
type Note,
type Thread,
type CreateLinkResult,
type NewLinkWithNotes,
NewContact,
} from "@plotday/twister";
Expand DownExpand Up@@ -579,7 +580,6 @@ export class Jira extends Connector<Jira> {
// Set unread based on sync type (false for initial sync to avoid notification overload)
linkWithNotes.unread = !state.initialSync;
// Inject sync metadata for filtering on disable
linkWithNotes.channelId = projectId;
linkWithNotes.meta = { ...linkWithNotes.meta, syncProvider: "atlassian", syncableId: projectId };
await this.tools.integrations.saveLink(linkWithNotes);
}
Expand DownExpand Up@@ -749,6 +749,7 @@ export class Jira extends Connector<Jira> {
}

return {
channelId: projectId,
...(source ? { source } : {}),
type: "issue",
title: fields.summary || issue.key,
Expand DownExpand Up@@ -903,7 +904,7 @@ export class Jira extends Connector<Jira> {
*/
async onCreateLink(
draft: CreateLinkDraft
): Promise<NewLinkWithNotes | null> {
): Promise<CreateLinkResult | null> {
if (draft.type !== "issue") return null;

const projectId = draft.channelId;
Expand DownExpand Up@@ -1288,6 +1289,7 @@ export class Jira extends Connector<Jira> {

// Create partial link update (empty notes = doesn't touch existing notes)
const link: NewLinkWithNotes = {
channelId: projectId,
...(source ? { source } : {}),
type: "issue",
title: fields.summary || issue.key,
Expand DownExpand Up@@ -1359,6 +1361,7 @@ export class Jira extends Connector<Jira> {

// Create link update with single comment note
const link: NewLinkWithNotes = {
channelId: projectId,
...(source ? { source } : {}),
type: "issue",
title: issue.fields?.summary || issue.key,
Expand Down
1 change: 1 addition & 0 deletions connectors/linear/src/linear-sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -215,6 +215,7 @@ export function buildIssueLink(
}

return {
channelId: projectId,
source: `linear:issue:${issue.id}`,
type: "issue",
title: issue.title,
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
type Note,
type Thread,
ThreadMeta,
type CreateLinkResult,
type NewLinkWithNotes,
} from "@plotday/twister";
import type { NewContact } from "@plotday/twister/plot";
Expand DownExpand Up@@ -490,7 +491,6 @@ export class Linear extends Connector<Linear> {
const link = buildIssueLink(issue, projectId, state.initialSync);

// Inject sync metadata for bulk operations (e.g. disable filtering)
link.channelId = projectId;
link.meta = {
...link.meta,
syncProvider: "linear",
Expand DownExpand Up@@ -544,7 +544,7 @@ export class Linear extends Connector<Linear> {
*/
async onCreateLink(
draft: CreateLinkDraft
): Promise<NewLinkWithNotes | null> {
): Promise<CreateLinkResult | null> {
if (draft.type !== "issue") return null;

const client = await this.getClient(draft.channelId);
Expand Down
6 changes: 6 additions & 0 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -634,6 +634,7 @@ export function transformChannelThread(
const allMessages = [parentMessage, ...replies];

return {
channelId,
source: `ms-teams:channel:${channelId}:message:${parentMessage.id}`,
type: "thread",
title,
Expand DownExpand Up@@ -678,7 +679,11 @@ export function transformDmThread(
);
const firstMessage = messages[0];
if (!firstMessage) {
// channelId is the DM sentinel constant, which lives in ms-teams.ts and
// isn't visible here — the caller always patches the real value in right
// after (see "Inject channel ID" in ms-teams.ts) before saving.
return {
channelId: null,
source: `ms-teams:dm:${chatId}`,
type: "dm",
title: "Empty chat",
Expand All@@ -692,6 +697,7 @@ export function transformDmThread(
stripHtml(firstMessage.body.content).substring(0, 50) || "Teams chat";

return {
channelId: null,
source: `ms-teams:dm:${chatId}`,
type: "dm",
title,
Expand Down
1 change: 0 additions & 1 deletion connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -355,7 +355,6 @@ export class MsTeams extends Connector<MsTeams> {
isInitial
);

link.channelId = channelId;
link.meta = {
...link.meta,
syncProvider: "teams",
Expand Down
2 changes: 2 additions & 0 deletions connectors/outlook-mail/src/enrich.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,7 @@ describe("enrichLinkContactsFromOutlook", () => {
: { value: [] }
);
const link: NewLinkWithNotes = {
channelId: null,
type: "email",
title: "t",
accessContacts: [
Expand DownExpand Up@@ -110,6 +111,7 @@ describe("enrichLinkContactsFromOutlook", () => {
vi.stubGlobal("fetch", fetchSpy);

const link: NewLinkWithNotes = {
channelId: null,
type: "email",
title: "t",
accessContacts: [{ email: "ann@x.com" }],
Expand Down
7 changes: 5 additions & 2 deletions connectors/outlook-mail/src/graph-mail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -601,15 +601,17 @@ export function transformOutlookConversation(opts: {
attachmentsByMessageId: Map<string, GraphAttachmentMeta[]>;
accountEmail: string;
}): NewLinkWithNotes {
// channelId is unknown at this call site — the caller always sets the
// real value right after (see "Inject channel ID" in sync.ts) before saving.
const sorted = sortConversation(opts.messages).filter((m) => !m.isDraft);
if (sorted.length === 0) {
return { type: "email", title: "", notes: [] };
return { channelId: null, type: "email", title: "", notes: [] };
}

const parent = sorted[0];
const conversationId = parent.conversationId;
if (!conversationId) {
return { type: "email", title: "", notes: [] };
return { channelId: null, type: "email", title: "", notes: [] };
}
const source = conversationSource(opts.accountEmail, conversationId);

Expand All@@ -634,6 +636,7 @@ export function transformOutlookConversation(opts: {
}

const plotThread: NewLinkWithNotes = {
channelId: null,
source,
type: "email",
title: parent.subject || "Email",
Expand Down
2 changes: 2 additions & 0 deletions connectors/slack/src/slack-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,7 @@ export function transformSlackThread(
if (!parentMessage) {
// Return empty structure for invalid threads
return {
channelId,
type: "thread",
title: "Empty thread",
notes: [],
Expand All@@ -669,6 +670,7 @@ export function transformSlackThread(

// Create link
const thread: NewLinkWithNotes = {
channelId,
source: canonicalUrl,
type: "thread",
title,
Expand Down
Loading
Loading