-
Notifications
You must be signed in to change notification settings - Fork 8.9k
Sync Fathom recordings from webhooks #25208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ehconitin
wants to merge
4
commits into
main
Choose a base branch
from
ehco/fathom-3-live-sync
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
d36fcfb
Sync Fathom recordings from webhooks
ehconitin f3ad17c
Skip cancelled calendar events and split the Fathom registration hook
ehconitin a2746e8
Use isDefined for the call recording existence check
ehconitin 08cc01c
Treat a missing webhook as cleaned up when rolling back a failed regi…
ehconitin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # Fathom live sync | ||
|
|
||
| Each Twenty user connects Fathom through OAuth. Fathom remains the recording | ||
| permission boundary, so the two login email addresses do not need to match. | ||
|
|
||
| The connection hook registers a signed webhook at `TWENTY_FUNCTIONS_URL` and | ||
| stores its identifier and signing secret in workspace-scoped application KV. | ||
| The webhook subscribes to recordings owned by the connected user and recordings | ||
| available through their Fathom team. | ||
|
|
||
| Deliveries are verified before parsing. A deterministic Call Recording ID makes | ||
| replayed deliveries idempotent. Calendar events are linked only when normalized | ||
| meeting URL and scheduled time produce one unambiguous match. | ||
|
|
||
| Twenty currently invokes the disconnect hook after deleting the connection | ||
| token. The hook therefore marks the registration inactive so signed deliveries | ||
| in flight are acknowledged, but cannot delete the remote Fathom webhook. Fathom | ||
| publishes no token revocation endpoint either, so the webhook keeps delivering | ||
| until the user removes the app from their Fathom settings; every such delivery | ||
| is verified, then skipped. The uninstall hook still holds every connection | ||
| token, so it deletes the Fathom webhook of each connected account before the | ||
| app goes away. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,25 @@ | ||
| # Fathom | ||
|
|
||
| Connect a Fathom account to Twenty through OAuth. Twenty stores and refreshes | ||
| the connection token for the person who connected it. | ||
| **Bring new Fathom meeting recordings, transcripts, and insights into Twenty.** | ||
|
|
||
| ## What you get | ||
|
|
||
| - Fathom recordings saved as Call Recordings | ||
| - Transcripts, summaries, and action items | ||
| - Links to matching calendar events when the match is unambiguous | ||
| - Automatic sync for new recordings | ||
|
|
||
| ## Requirements | ||
|
|
||
| The centrally managed Twenty OAuth app needs a Fathom client ID and client | ||
| secret. Development setup lives in [SETUP.md](SETUP.md). | ||
| Each person connects their Fathom account to Twenty. The connected Fathom | ||
| account decides which recordings Twenty can import; the Fathom and Twenty login | ||
| emails do not need to match. | ||
|
|
||
| ## Heads up | ||
|
|
||
| - Twenty only links a recording to a calendar event when its meeting URL and | ||
| scheduled time identify one clear match. | ||
| - Disconnect and reconnect Fathom after changing the webhook URL or webhook | ||
| scopes so Fathom receives the new registration. | ||
|
|
||
| Development setup lives in [SETUP.md](SETUP.md). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
8 changes: 8 additions & 0 deletions
8
packages/twenty-apps/public/fathom/src/__tests__/utils/build-fathom-not-found-error.util.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { FathomError } from 'fathom-typescript/sdk/models/errors'; | ||
|
|
||
| export const buildFathomNotFoundError = (): FathomError => | ||
| new FathomError('Not found', { | ||
| response: new Response(null, { status: 404 }), | ||
| request: new Request('https://api.fathom.ai/external/v1/webhooks/1'), | ||
| body: '', | ||
| }); |
15 changes: 13 additions & 2 deletions
15
packages/twenty-apps/public/fathom/src/connection-providers/fathom-connection.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
5 changes: 5 additions & 0 deletions
5
packages/twenty-apps/public/fathom/src/constants/fathom.constant.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| export const FATHOM_PROVIDER_NAME = 'fathom'; | ||
| export const FATHOM_WEBHOOK_ROUTE_PATH = '/webhook/fathom'; | ||
| export const FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER = 'connectionId'; | ||
| export const CALENDAR_EVENT_PAGE_SIZE = 200; | ||
| export const MAX_CALENDAR_EVENT_PAGES = 10; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 144 additions & 0 deletions
144
...wenty-apps/public/fathom/src/logic-functions/__tests__/fathom-register-connection.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { buildFathomNotFoundError } from 'src/__tests__/utils/build-fathom-not-found-error.util'; | ||
|
|
||
| const sdkMocks = vi.hoisted(() => ({ | ||
| createWebhook: vi.fn(), | ||
| deleteWebhook: vi.fn(), | ||
| getConnection: vi.fn(), | ||
| kvGet: vi.fn(), | ||
| kvSet: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock('twenty-sdk/define', () => ({ | ||
| defineLogicFunction: (config: unknown) => config, | ||
| })); | ||
|
|
||
| vi.mock('twenty-sdk/logic-function', () => ({ | ||
| kv: { get: sdkMocks.kvGet, set: sdkMocks.kvSet }, | ||
| getConnection: sdkMocks.getConnection, | ||
| })); | ||
|
|
||
| vi.mock('fathom-typescript', () => ({ | ||
| Fathom: class Fathom { | ||
| createWebhook = sdkMocks.createWebhook; | ||
| deleteWebhook = sdkMocks.deleteWebhook; | ||
| }, | ||
| })); | ||
|
|
||
| const { fathomRegisterConnectionHandler } = | ||
| await import('src/logic-functions/fathom-register-connection'); | ||
|
|
||
| const HOOK_PAYLOAD = { | ||
| connectionProviderId: 'provider-1', | ||
| connectionProviderName: 'fathom', | ||
| connectedAccountId: 'connection-1', | ||
| }; | ||
|
|
||
| const INACTIVE_REGISTRATION = { | ||
| webhookId: 'stale-webhook', | ||
| secret: 'secret', | ||
| isActive: false, | ||
| }; | ||
|
|
||
| describe('fathomRegisterConnectionHandler', () => { | ||
| beforeEach(() => { | ||
| vi.resetAllMocks(); | ||
| vi.spyOn(console, 'error').mockImplementation(() => undefined); | ||
| process.env.TWENTY_FUNCTIONS_URL = 'https://example.dev/s'; | ||
| sdkMocks.getConnection.mockResolvedValue({ accessToken: 'token' }); | ||
| sdkMocks.kvGet.mockResolvedValue(null); | ||
| sdkMocks.createWebhook.mockResolvedValue({ | ||
| id: 'webhook-1', | ||
| secret: 'secret-1', | ||
| }); | ||
| }); | ||
|
|
||
| it('registers a webhook pointing at the connection-scoped route', async () => { | ||
| expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({ | ||
| success: true, | ||
| webhookId: 'webhook-1', | ||
| }); | ||
| expect(sdkMocks.createWebhook).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| destinationUrl: | ||
| 'https://example.dev/s/webhook/fathom?connectionId=connection-1', | ||
| }), | ||
| ); | ||
| expect(sdkMocks.kvSet).toHaveBeenCalledWith('fathom-webhook:connection-1', { | ||
| webhookId: 'webhook-1', | ||
| secret: 'secret-1', | ||
| isActive: true, | ||
| }); | ||
| }); | ||
|
|
||
| it('deletes the webhook it just created when the registration cannot be stored', async () => { | ||
| sdkMocks.kvSet.mockRejectedValue(new Error('Key value store unavailable')); | ||
|
|
||
| await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow( | ||
| 'Key value store unavailable', | ||
| ); | ||
| expect(sdkMocks.deleteWebhook).toHaveBeenCalledWith({ id: 'webhook-1' }); | ||
| }); | ||
|
|
||
| it('keeps the webhook when the failed write turns out to have committed', async () => { | ||
| sdkMocks.kvSet.mockRejectedValue(new Error('Response lost')); | ||
| sdkMocks.kvGet.mockResolvedValueOnce(null).mockResolvedValueOnce({ | ||
| webhookId: 'webhook-1', | ||
| secret: 'secret-1', | ||
| isActive: true, | ||
| }); | ||
|
|
||
| expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({ | ||
| success: true, | ||
| webhookId: 'webhook-1', | ||
| }); | ||
| expect(sdkMocks.deleteWebhook).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('reports the storage failure even when the created webhook cannot be deleted', async () => { | ||
| sdkMocks.kvSet.mockRejectedValue(new Error('Key value store unavailable')); | ||
| sdkMocks.deleteWebhook.mockRejectedValue(new Error('Fathom unavailable')); | ||
|
|
||
| await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow( | ||
| 'Key value store unavailable', | ||
| ); | ||
| }); | ||
|
|
||
| it('reuses the webhook of an already active registration', async () => { | ||
| sdkMocks.kvGet.mockResolvedValue({ | ||
| ...INACTIVE_REGISTRATION, | ||
| webhookId: 'existing-webhook', | ||
| isActive: true, | ||
| }); | ||
|
|
||
| expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({ | ||
| success: true, | ||
| webhookId: 'existing-webhook', | ||
| }); | ||
| expect(sdkMocks.createWebhook).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('replaces an inactive registration whose webhook Fathom already dropped', async () => { | ||
| sdkMocks.kvGet.mockResolvedValue(INACTIVE_REGISTRATION); | ||
| sdkMocks.deleteWebhook.mockRejectedValue(buildFathomNotFoundError()); | ||
|
|
||
| expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({ | ||
| success: true, | ||
| webhookId: 'webhook-1', | ||
| }); | ||
| expect(sdkMocks.deleteWebhook).toHaveBeenCalledWith({ | ||
| id: 'stale-webhook', | ||
| }); | ||
| }); | ||
|
|
||
| it('does not replace an inactive registration when its webhook cannot be deleted', async () => { | ||
| sdkMocks.kvGet.mockResolvedValue(INACTIVE_REGISTRATION); | ||
| sdkMocks.deleteWebhook.mockRejectedValue(new Error('Fathom unavailable')); | ||
|
|
||
| await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow( | ||
| 'Fathom unavailable', | ||
| ); | ||
| expect(sdkMocks.createWebhook).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.