Skip to content

Commit 068351a

Browse files
TobinBraunKrisBraunclaudeTobinBraun
authored
Fellow connector public (#353)
* feat(google): parse attendee responses from METHOD:REPLY calendar parts * fix(google): dedupe ICS line unfolding and correct backslash-escape ordering Factor icsProp's and icsPropLine's shared RFC 5545 line-unfolding and regex into one matchIcsLine helper. Fix unescapeIcsText's two-pass replacement, which misread an escaped backslash followed by a literal 'n' (\\n) as a newline escape; a single-pass scan now handles all four escapes correctly. Adds tests locking the quoted-parameter colon-scoping invariant and the backslash-escape ordering fix. * feat(google): compose succinct notes for attendee responses * docs(twister): describe NewNote.unread's three read-state behaviours * feat(google): fold attendee responses onto the event thread Google emails the meeting organizer a separate notification for every guest response ("Declined: Team sync @ Tue Aug 4"), each importing as its own, mostly-boilerplate email thread. The Gmail sync now folds each response onto the calendar event's own thread as a short note instead, and only falls back to creating a standalone email thread when the event hasn't synced yet or when the conversation also carries real correspondence. * fix(google): keep preview and facets on the surviving message after folding RSVPs A mixed conversation (an RSVP notification plus a genuine human reply) kept computing its preview and classifier facets from the notification's headers and snippet even after that message's note was folded onto the event thread and dropped. Restrict both to a message whose note survived the fold, falling back to the previous behavior untouched when nothing was folded. * docs(twister): clarify NewNote.unread interaction with scoped notes * fix: keep Granola backfill from touching calendar-event read state Granola summaries attach as a note to the calendar event's own thread rather than creating a separate thread. During the initial backfill, the note carried an unread:false hint intended for its own thread — but since the note attaches to a thread Granola doesn't own, that hint would mark the calendar event's thread read instead. Drop it so backfilled summaries leave the event thread's read state untouched, as they always have. Also neutralizes a test fixture subject line in the Gmail connector's RSVP-fold tests to a generic placeholder, consistent with the other synthetic fixtures in that file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JQ7oQtGrvpDYqeEMVt1DRa * Fellow AI Meeting Notes * Google Connector Supports iCalUID no matter suffix * fix(fellow): signal initial sync completion on every exit path The platform re-stamps a connection's initial-sync-started marker (and clears any completed marker) on every channel enable, so the "Syncing…" indicator turns back on each time. Any path out of onChannelEnabled or syncBatch that returned without reaching channelSyncCompleted left the connection spinning until the stuck-sync watchdog gave up and mislabelled a healthy connection as needing to be reconnected. Four such paths are closed: - onChannelEnabled's "already synced a wider range" early return now signals completion. There is nothing to back-fill, so this IS the enable's sync completing, trivially. - The covered-history marker is now written only once the backfill actually reaches the last page, instead of before the sync runs. Writing it up front armed the early return above even for a sync that then died mid-chain, permanently short-circuiting every later re-enable. - onChannelDisabled clears that marker. Disabling archives every link, so leaving it set made a re-enable take the early return and come back with no notes at all. - syncBatch's missing-state guard (an at-least-once redelivery of the batch task) signals completion instead of returning silently. Also carry syncHistoryMin forward into the continuation state. Dropping it made every batch after the first re-query with no updated_at_start filter — a wider result set than the cursor was issued against — so pagination walked a different list than it started on and re-saved notes the first batch had already written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01No4H37BP9u8bYedFKhavoq --------- Co-authored-by: Kris Braun <kris.braun@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: TobinBraun <your.email@example.com>
1 parent 396323b commit 068351a

8 files changed

Lines changed: 1304 additions & 57 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Changed: `NewNote.unread` now controls read state on the `saveNotes` path. Omitting it leaves the thread's read state untouched, `true` marks the thread unread for everyone but the note's author, and `false` marks it read for the connection owner so a two-way read sync converges. Previously the field had no effect on notes saved through `saveNotes`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `TextDef.required` on the `Options` tool. When set, the Flutter connect/edit form blocks submission while the field is empty (secure fields were already implicitly required). Text fields with no sensible default — like a workspace subdomain — can now opt in to this instead of silently accepting a blank value that only fails once the connector tries to use it.

‎connectors/fellow/src/fellow-api.ts‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,61 @@ export type ListActionItemsParams = {
5656
archived?: boolean;
5757
};
5858

59+
/**
60+
* Payload Fellow POSTs to the webhook URL for `ai_note.generated` /
61+
* `ai_note.shared_to_channel` events. Carries the AI-generated notes content
62+
* directly via `ai_notes` — this is the ONLY place that content is available.
63+
* The `/notes` list endpoint's `content_markdown` field renders the note's
64+
* agenda/manual content but never includes the AI-generated summary, so
65+
* consumers must read `ai_notes` from this payload rather than re-fetching
66+
* the note via the API. `id` is the note id (matches `FellowNote.id`);
67+
* `event_id` is the calendar event identifier (matches `FellowNote.event_guid`).
68+
*/
69+
export type FellowAiNoteWebhook = {
70+
event_type: "ai_note.generated" | "ai_note.shared_to_channel";
71+
id: string;
72+
event_id: string | null;
73+
event_title: string | null;
74+
event_start: string | null;
75+
recap_url: string | null;
76+
ai_notes: string | null;
77+
};
78+
79+
/**
80+
* Payload Fellow POSTs to the webhook URL for an `action_item.assigned`
81+
* event. Unlike `ai_note.*` events, this carries the full action item —
82+
* including `note_id`, which is `null` for a standalone action item created
83+
* directly in Fellow (not attached to a meeting note).
84+
*/
85+
export type FellowActionItemAssignedWebhook = {
86+
event_type: "action_item.assigned";
87+
id: string;
88+
text: string;
89+
assignees: Array<{ id: string; full_name: string; email: string }>;
90+
completion_type: string | null;
91+
status: FellowActionItem["status"];
92+
due_date: string | null;
93+
note_id: string | null;
94+
ai_generated: boolean;
95+
created_at: string;
96+
updated_at: string;
97+
};
98+
99+
/** Payload Fellow POSTs to the webhook URL for an `action_item.completed` event. */
100+
export type FellowActionItemCompletedWebhook = {
101+
event_type: "action_item.completed";
102+
id: string;
103+
text: string;
104+
assignee_id: string | null;
105+
assignee_name: string | null;
106+
assignee_email: string | null;
107+
note_id: string | null;
108+
due_date: string | null;
109+
done: boolean;
110+
wont_do: boolean;
111+
ai_generated: boolean;
112+
};
113+
59114
export class FellowAPI {
60115
private baseUrl: string;
61116

@@ -83,7 +138,11 @@ export class FellowAPI {
83138
);
84139
}
85140

86-
return response.json() as Promise<T>;
141+
// Some endpoints (e.g. complete/archive) return no body on success;
142+
// JSON.parse("") would throw, so treat an empty body as `undefined`
143+
// rather than a malformed response.
144+
const text = await response.text();
145+
return (text ? JSON.parse(text) : undefined) as T;
87146
}
88147

89148
async listNotes(params?: ListNotesParams): Promise<{
@@ -147,6 +206,16 @@ export class FellowAPI {
147206
};
148207
}
149208

209+
/** Marks an action item done (`completed: true`) or reopens it (`false`). */
210+
async completeActionItem(id: string, completed: boolean): Promise<void> {
211+
await this.request<void>(`/action_item/${id}/complete`, { completed });
212+
}
213+
214+
/** Archives an action item (marks it "won't do"). Not reversible via the API. */
215+
async archiveActionItem(id: string): Promise<void> {
216+
await this.request<void>(`/action_item/${id}/archive`);
217+
}
218+
150219
async createWebhook(
151220
url: string,
152221
events: string[],

0 commit comments

Comments
 (0)