Source app About description from README and improve internal app READMEs (#22012)
## What - The SDK manifest build now sources an app's `aboutDescription` (the long-form "About" tab content) from its `README.md`. An explicit `aboutDescription` in the config still wins, matching the existing marketplace CDN fallback. - Removed the now-duplicated `aboutDescription` from internal app configs and deleted the standalone `ABOUT_DESCRIPTION` constant files. - Rewrote internal app READMEs to read as user-facing About content: stripped developer/build/source-path noise, and expanded the thin ones. `call-recording` and `self-hosting` (one-liners over substantial apps) and `people-data-labs` were rewritten from a close reading of the code; `twenty-exa` was verified for accuracy. - Added a unit test (and a fixture README) covering README → `aboutDescription` in the build. ## Why The README and the About description were maintained separately and drifted. Making the README the single source keeps the About tab accurate and removes duplicated copy. ## Notes for reviewers - Internal apps depend on the published `twenty-sdk`, so the build change takes effect for them after an SDK release + dependency bump. Until then, published apps still get README → `aboutDescription` via the marketplace CDN sync. - Standard/Custom app descriptions are unchanged (they are resolved in the frontend, not via the manifest). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22012?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Function Execute Test App
|
||||
|
||||
Fixture app used to exercise logic-function execution in SDK tests.
|
||||
@@ -1,210 +1,77 @@
|
||||
# People Data Labs enrichment app
|
||||
# People Data Labs enrichment
|
||||
|
||||
Enriches **Person** and **Company** records with [People Data Labs](https://www.peopledatalabs.com/) (PDL) data.
|
||||
Enriches **Person** and **Company** records with data from [People Data Labs](https://www.peopledatalabs.com/) (PDL): job and role details, location, company firmographics, funding, social profiles, and more.
|
||||
|
||||
> **Status: data model + enrichment mapper.** This package defines the fields, relation,
|
||||
> views, role, and manifest, and implements the enrichment **logic functions** that call the
|
||||
> PDL REST API and map the response onto the standard + `pdl*` fields. The manual "Enrich"
|
||||
> record-action workflows are currently **created by hand** — automatic post-install seeding is
|
||||
> implemented but not wired up (see [Seeded workflows](#seeded-workflows-post-install)).
|
||||
## What it does
|
||||
|
||||
---
|
||||
For each record you enrich, the app matches it against People Data Labs and writes the result back to Twenty. It fills a handful of Twenty standard fields, adds a rich set of dedicated PDL fields, and records the outcome of the last attempt.
|
||||
|
||||
## Enrichment logic functions
|
||||
### Standard fields it can fill
|
||||
|
||||
`enrich-companies` / `enrich-people` (bulk workflow actions, for the manual record action) and
|
||||
`enrich-company` / `enrich-person` (single-record functions exposed **both** as a workflow action
|
||||
and as an AI tool) all delegate to a shared, trigger-agnostic core in `src/logic-functions/handlers/`:
|
||||
- **Person:** name, emails, phone numbers, job title, and LinkedIn link. If the person has no company yet, the app also looks up (or creates) their current company and links it.
|
||||
- **Company:** name, domain name, LinkedIn link, and address.
|
||||
|
||||
- The bulk workflow-action functions accept a **list of records** (`{ records, updateFields? }`),
|
||||
call the PDL **bulk** Enrichment endpoints (`/person/bulk`, `/company/enrich/bulk`), and loop the
|
||||
single-record core over each, aggregating the outcome (`total` / `matched` / `notFound` / `skipped` /
|
||||
`errored`); a per-record failure is captured as `ERROR` without aborting the batch
|
||||
(`src/logic-functions/utils/run-batch-enrichment.ts`).
|
||||
- The single-record functions accept one record (`{ recordId, updateFields? }`), call the PDL
|
||||
**single-record** Enrichment endpoints (`/person/enrich`, `/company/enrich` —
|
||||
`src/logic-functions/utils/post-pdl-single-enrich.ts`), and return a single `EnrichResult`
|
||||
(`src/logic-functions/utils/run-single-enrichment.ts`). They declare both a
|
||||
`workflowActionTriggerSettings` and a `toolTriggerSettings`, so one function is usable as a workflow
|
||||
step and as an AI tool.
|
||||
- The **`updateFields`** select controls persistence: `Yes and overwrite` writes every enriched
|
||||
standard field (replacing existing values), `Yes and don't overwrite` (the default) fills
|
||||
standard fields only when empty, and `No` writes nothing to the record. In every mode each
|
||||
matched result carries the enriched **mapped fields** under `data` (standard + `pdl*` values),
|
||||
so `No` returns the data for downstream steps without modifying the record.
|
||||
Standard fields are only used to complete your existing data. By default they are filled **only when empty**, so your own values are never overwritten unless you explicitly choose to overwrite (see overwrite modes below). The existing person-to-company link is never overwritten.
|
||||
|
||||
- Read the record, guard against re-enriching within a TTL (`pdlLastEnrichedAt`), pick a
|
||||
match identifier (person: `pdlId` → LinkedIn → email → name; company: `pdlId` → domain →
|
||||
name), and call the PDL Person/Company Enrichment API (`src/logic-functions/utils/`).
|
||||
- On a match: fill **standard fields** per `updateFields` (default: only when empty, never
|
||||
clobbering user data); when `updateFields` is not `No`, (re)write `pdl*` fields and set
|
||||
`pdlEnrichmentStatus = MATCHED`, `pdlLastEnrichedAt`,
|
||||
`pdlRawPayload` (+ `pdlLikelihood` for Person). PDL `404` → `NOT_FOUND`; other errors →
|
||||
`ERROR`. No identifier / fresh TTL → skipped with no writes.
|
||||
- SELECT/MULTI_SELECT values are normalized and dropped if not in the field's option set
|
||||
(`src/logic-functions/utils/`); the option sets are the same `src/constants/*-options.ts`
|
||||
the field definitions use.
|
||||
### PDL fields it adds
|
||||
|
||||
Run locally: `yarn twenty dev:function:exec -n enrich-people -p '{"records":[{"id":"<id>"}]}'` (bulk)
|
||||
or `yarn twenty dev:function:exec -n enrich-person -p '{"recordId":"<id>"}'` (single record).
|
||||
The app adds around 30 dedicated PDL data fields on Person and 28 on Company (plus the bookkeeping fields listed below). These are always written on a match. Highlights:
|
||||
|
||||
### Billing
|
||||
- **Person:** seniority, job role, job title class and sub-role, industry, inferred salary, headline and summaries, years of experience, LinkedIn connections, birth date/year, skills, interests, education, work experience, certifications, languages, social profiles (GitHub, Twitter/X, Facebook), and a detailed PDL location.
|
||||
- **Company:** industry, company type, size range and employee count, founded year, funding stages and total funding (in USD), headline and summary, legal name, ticker and exchange, tags, alternative names and domains, NAICS/SIC classifications, employee counts by country, and social profiles.
|
||||
|
||||
Each **successful match** is billed to the workspace in Twenty credits via
|
||||
`chargeCredits` (`twenty-sdk/billing`), mirroring PDL's own model — PDL only consumes a
|
||||
credit on a `200` match, so `not_found`, errors, and skipped records are free:
|
||||
Each object also gets bookkeeping fields: a PDL id, the raw PDL payload, the time of the last enrichment, the match likelihood (Person), and an enrichment status.
|
||||
|
||||
- Person match: **336,000 micro-credits** ($0.336 — PDL list price $0.28 + 20% margin)
|
||||
- Company match: **120,000 micro-credits** ($0.12 — PDL list price $0.10 + 20% margin)
|
||||
Two pre-built table views named **Enriched (PDL)** are added — one on People and one on Companies — to surface the enrichment fields.
|
||||
|
||||
The charge is emitted once per PDL batch call (`src/logic-functions/utils/enrich-chunk.ts`)
|
||||
with `quantity` = number of matches and `resourceContext` `pdl/person` / `pdl/company`,
|
||||
at the moment PDL returns — a record whose subsequent write fails is still billed, since the
|
||||
PDL cost was already incurred. Prices live in `src/constants/*-match-cost-dollars.ts` and
|
||||
the margin in `src/constants/billing-margin-multiplier.ts`. Billing is non-fatal: a failed
|
||||
charge never fails the enrichment.
|
||||
## How matching works
|
||||
|
||||
### Seeded workflows (post-install)
|
||||
The app matches records using the identifiers already on them, in priority order:
|
||||
|
||||
> **Not currently wired up.** `post-install.function.ts` is a no-op
|
||||
> (`return { seededWorkflows: [] }`); the seeding implementation in
|
||||
> `src/logic-functions/handlers/post-install.ts` (`postInstallCore`) is **not invoked**. An
|
||||
> app's `CoreApiClient` only exposes per-object CRUD over the workspace `/graphql` schema, and the
|
||||
> workflow-builder mutations needed to seed a workflow (`createWorkflowVersionStep` /
|
||||
> `activateWorkflowVersion`) are core resolvers the app surface does not yet expose. Until the SDK
|
||||
> exposes them, **create the two "Enrich" workflows by hand**.
|
||||
- **Person:** PDL id, LinkedIn URL, primary email, or full name paired with a company name. LinkedIn URL and email are treated as strong identifiers; a name on its own is not used.
|
||||
- **Company:** PDL id, website domain, LinkedIn URL, or company name. Website and LinkedIn are treated as strong identifiers.
|
||||
|
||||
When re-enabled, each workflow is a `MANUAL` / `BULK_RECORDS` trigger wired to a single
|
||||
`LOGIC_FUNCTION` step whose `records` input is bound to the selected records
|
||||
(`{{trigger.companies}}` / `{{trigger.people}}`):
|
||||
When a record has only weak signals (for example a person's name plus company, or a company name alone), the app applies a stricter confidence threshold to reduce false positives.
|
||||
|
||||
- **Enrich companies** — runs `enrich-companies` over the selected Companies.
|
||||
- **Enrich people** — runs `enrich-people` over the selected People.
|
||||
If a record has no usable identifier, it is **skipped** (and never billed).
|
||||
|
||||
The intended seeding (`postInstallCore`) resolves each function's runtime id from its
|
||||
`universalIdentifier` via the metadata API, publishes the version
|
||||
(`activateWorkflowVersion`), and is **idempotent** (skips a workflow whose name already exists).
|
||||
## Overwrite modes
|
||||
|
||||
**Deferred to a later PR:** auto-enrichment triggers (on-create event + cron backfill).
|
||||
When you trigger enrichment you can choose how matched data is written back:
|
||||
|
||||
---
|
||||
- **Yes and don't overwrite** (default): writes PDL fields and fills standard fields only where they are currently empty.
|
||||
- **Yes and overwrite**: writes PDL fields and replaces existing standard field values.
|
||||
- **No**: returns the enriched data without modifying the record.
|
||||
|
||||
## Data-model decisions
|
||||
In all cases the dedicated PDL fields are written on a match.
|
||||
|
||||
### Bundle scope
|
||||
## Enrichment status
|
||||
|
||||
Only the core PDL company fields are defined. Premium / Comprehensive / specialized fields
|
||||
(`inferred_revenue`, `linkedin_follower_count`, employee growth/churn/tenure, parent /
|
||||
subsidiary, exec movement, top employers, `funding_details`, …) are **out of scope** for this app.
|
||||
The enrichment status field written to each record records the outcome of the last attempt, and can hold one of three values:
|
||||
|
||||
### Enums → SELECT / MULTI_SELECT
|
||||
- **Matched** — a confident match was found and data was written.
|
||||
- **No Match** — PDL returned no confident match.
|
||||
- **Error** — the enrichment attempt failed.
|
||||
|
||||
Every PDL enum that has a canonical file is a SELECT, **validated 0-missing/0-extra against
|
||||
PDL schema v34.1**:
|
||||
Records that are skipped because they have no usable identifier are reported as skipped in the action's returned result and bulk summary, but no status is written back to the record (nothing is changed or billed for them).
|
||||
|
||||
| Field | Type | Options |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------ |
|
||||
| `pdlSeniority` (`job_title_levels`, array) | MULTI_SELECT | 10 |
|
||||
| `pdlFundingStages` (`funding_stages`, array) | MULTI_SELECT | 29 |
|
||||
| `pdlIndustry` (`industry`) | SELECT | 147 |
|
||||
| `pdlJobTitleSubRole` (`job_title_sub_role`) | SELECT | 106 |
|
||||
| `pdlJobTitleClass`, `pdlInferredSalary`, `pdlSex`, `pdlCompanyType`, `pdlSizeRange`, `pdlLatestFundingStage`, `pdlLocationContinent`, `pdlLocationMetro`, `pdlMicExchange` | SELECT | 5 / 11 / 2 / 6 / 8 / 29 / 7 / 384 / 70 |
|
||||
## How to trigger it
|
||||
|
||||
- Option `value`s are normalized to **GraphQL enum names** (`united states` → `UNITED_STATES`):
|
||||
uppercase, accents stripped, non-alphanumeric → `_`, digit-leading prefixed.
|
||||
- Option `universalIdentifier`s are **unique per field** (shared enums like industry, metro, and
|
||||
funding stage get a separate id-set per field).
|
||||
- The large option sets (`metro-options.ts`, `industry-options.ts`, …) and the UUID registry
|
||||
(`universal-identifiers.ts`) are generated from the PDL taxonomy and checked in. When
|
||||
regenerating for a newer PDL schema, **never change an existing option or field UUID** — that
|
||||
orphans stored data; only append ids for new options. `select-option-constants.spec.ts` guards
|
||||
global UUID uniqueness, value normalization, and per-field id integrity.
|
||||
- **Stays `TEXT`** (no canonical PDL enum file exists): `pdlIndustryDetail` (`industry_v2`),
|
||||
`pdlJobOnetCode`. PDL `location_region` has no dedicated field — it fills the `state` slot of
|
||||
the person `pdlLocation` ADDRESS composite.
|
||||
- **Manual action:** run enrichment on a single record or on a selection of records from a People or Companies view.
|
||||
- **Workflow action:** add **Enrich Person**, **Enrich Company**, **Enrich People**, or **Enrich Companies** as a step in a workflow.
|
||||
- **AI tool:** the single-record **Enrich Person** and **Enrich Company** actions are also available to AI agents.
|
||||
|
||||
### Standard-field mapping
|
||||
Bulk enrichment processes records in batches and reports how many were matched, not found, skipped, or errored.
|
||||
|
||||
`pdl*` shadows are **removed** where an equivalent standard field exists; the mapper writes
|
||||
the standard field instead:
|
||||
## Billing
|
||||
|
||||
| Object | Removed shadow → standard target |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Person | `pdlLinkedinUrl`→`linkedinLink`, `pdlJobTitle`→`jobTitle`, `pdlFullName`→`name`, `pdlWorkEmail`/`pdlPersonalEmails`→`emails`, `pdlMobilePhone`/`pdlPhoneNumbers`→`phones` |
|
||||
| Company | `pdlLinkedinUrl`→`linkedinLink`, `pdlWebsite`→`domainName`, `pdlDisplayName`→`name` |
|
||||
Only successful matches are billed, in Twenty credits — records that are not found, skipped, or that error are free.
|
||||
|
||||
Shadows are **kept** where no reliable standard field is available: `pdlEmployeeCount`,
|
||||
`pdlTwitterUrl`.
|
||||
_Trade-off:_ PDL's work/personal-email and mobile/other-phone distinction is dropped (folded
|
||||
into the standard bags).
|
||||
- **Person match:** $0.336
|
||||
- **Company match:** $0.12
|
||||
|
||||
### Location → ADDRESS composite
|
||||
A match is billed based on PDL's response. In the rare case where a record matches but the write back to Twenty fails afterward, the match is still counted.
|
||||
|
||||
- **Company** location → the **standard `address`** composite (street/city/state/postcode/country/geo).
|
||||
- **Person** has no standard address field → dedicated **`pdlLocation` (ADDRESS)**.
|
||||
- `pdlLocationMetro` (both) and `pdlLocationContinent` (company) stay SELECT — ADDRESS has no slot.
|
||||
_Trade-off:_ ADDRESS `country` is free text, so the country SELECT was dropped.
|
||||
## Setup
|
||||
|
||||
### Current company → standard `company`
|
||||
|
||||
PDL's detected current employer (`job_company_*`) is resolved to a Company record
|
||||
(**find-or-create**, matched by `pdlId` → domain → LinkedIn → name; created with
|
||||
`name` / `domainName` / `linkedinLink` + `pdlId` / `pdlIndustry` / `pdlSizeRange` when none
|
||||
matches) and linked via the **standard `company`** relation, **fill-only-if-empty** — it never
|
||||
overwrites a company the user already set, and the lookup is skipped entirely when the person
|
||||
already has one (no orphan companies).
|
||||
|
||||
Company attributes live on the **Company** record, not denormalized on the Person. The earlier
|
||||
`pdlCurrentCompany` / `pdlCurrentEmployees` relation and the six `pdlJobCompany*` scalar fields
|
||||
were **removed** as duplicates of the standard `company` relation and the linked Company's own
|
||||
fields.
|
||||
|
||||
### Enrichment metadata
|
||||
|
||||
- `pdlId` — PDL record id (re-enrich by id: more precise than by email).
|
||||
- `pdlLikelihood` (Person, NUMBER) — PDL match confidence 1–10.
|
||||
- `pdlEnrichmentStatus` (SELECT: `MATCHED` / `NOT_FOUND` / `ERROR`) — distinguishes
|
||||
"no match" from "never tried" (drives re-enrichment scheduling).
|
||||
- `pdlLastEnrichedAt` (DATE_TIME), `pdlRawPayload` (RAW_JSON, full response).
|
||||
|
||||
### Other
|
||||
|
||||
- `pdlTotalFunding` is `CURRENCY` (mapper must convert the bare USD float → micros).
|
||||
- **Views**: a curated "People Data Labs" TABLE view per object.
|
||||
- **Role**: read/update on Person & Company (object-level; tighten to field-scoped later).
|
||||
|
||||
---
|
||||
|
||||
## What the mapper does
|
||||
|
||||
**Orchestration** (`src/logic-functions/`)
|
||||
|
||||
1. Runs from the manual "Enrich" record action (`BULK_RECORDS`) or the single-record
|
||||
`enrich-company` / `enrich-person` functions (as a workflow step or an AI tool).
|
||||
2. Calls the PDL Person / Company Enrichment API with `PDL_API_KEY`, passing a `min_likelihood`
|
||||
chosen by identifier strength (2 with a strong identifier, 6 for a weaker name-based match;
|
||||
overridable per call).
|
||||
3. A match → `pdlEnrichmentStatus = MATCHED`; PDL `404` / no match → `NOT_FOUND`; other errors →
|
||||
`ERROR`. Errored and not-found records are also stamped with `pdlLastEnrichedAt` so the TTL
|
||||
guard backs off instead of re-submitting them on every run.
|
||||
4. **TTL guard**: skips re-enrichment when `pdlLastEnrichedAt` is within 7 days (bypass with
|
||||
`force`), and prefers re-enriching by `pdlId`.
|
||||
|
||||
**Field writing**
|
||||
|
||||
5. **Standard fields** are filled **only when empty** (never clobber user data): Person `name`,
|
||||
`emails`, `phones`, `linkedinLink`, `jobTitle`; Company `name`, `domainName`, `linkedinLink`,
|
||||
`address`. All `pdl*` fields are (re)written on every match.
|
||||
6. **SELECT guard**: a SELECT/MULTI_SELECT value is written only if its normalized form is in the
|
||||
field's option set; otherwise it is skipped and preserved in `pdlRawPayload` (handles PDL
|
||||
schema versions newer than the bundled one). `job_title_levels` → `pdlSeniority`,
|
||||
`funding_stages` → `pdlFundingStages`.
|
||||
7. **CURRENCY**: `total_funding_raised` (USD) → `{ amountMicros: value × 1_000_000, currencyCode: 'USD' }`.
|
||||
8. **ADDRESS**: PDL `location.*` is split into the composite — Company → standard `address`,
|
||||
Person → `pdlLocation`.
|
||||
9. **Current company**: `job_company_*` is resolved to a Company record (find-or-create, matched by
|
||||
`pdlId` → domain → LinkedIn → name) and linked via the standard `company` relation
|
||||
(fill-only-if-empty); resolutions are cached within a batch run.
|
||||
10. **Dates**: partial PDL dates (`YYYY`, `YYYY-MM`) for `job_start_date`, `last_funding_date`,
|
||||
`birth_date` are expanded and range-validated.
|
||||
11. Always sets `pdlId`, `pdlLastEnrichedAt`, `pdlRawPayload` (+ `pdlLikelihood` for Person).
|
||||
Set the **`PDL_API_KEY`** server variable to your People Data Labs API key. This is required for the app to function.
|
||||
|
||||
@@ -1,11 +1,62 @@
|
||||
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
# Self Hosting
|
||||
|
||||
## Getting Started
|
||||
The Self Hosting app collects sign-up telemetry from self-hosted Twenty instances and turns it into structured CRM records. Each time a user signs up on a self-hosted instance, the app records who they are, which instance they belong to, and automatically links them to a matching Person in your workspace.
|
||||
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
## What it does
|
||||
|
||||
## Learn More
|
||||
- Receives sign-up events from self-hosted instances through a public telemetry webhook.
|
||||
- Creates or updates a **Self Hosting User** record for each real sign-up, keyed by email address.
|
||||
- Automatically matches every Self Hosting User to an existing Person by email, creating a new Person when none is found, and keeps that link up to date as the data changes.
|
||||
- Filters out test and example sign-ups so they never enter your CRM.
|
||||
- Carries a rich set of person and company enrichment fields so each sign-up can be augmented with firmographic data.
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
## What it adds to your workspace
|
||||
|
||||
### Self Hosting User object
|
||||
|
||||
A new object, **Self Hosting Users**, stores one record per self-hosted sign-up. Its fields include:
|
||||
|
||||
- **Identity**: Name, Email, Domain, Locale. The webhook only populates Name, Email, and Locale; Domain exists on the object but is not filled in by the sign-up flow.
|
||||
- **Instance**: Server URL, Server ID, User ID, User Workspace ID.
|
||||
- **Relations**: Person, the matched standard Person record for this user.
|
||||
- **Aggregate**: Number of Emails with Same Domain, a count of users sharing the same business domain. This field is part of the data model but is not populated by the app's own ingestion or matching logic.
|
||||
- **Enrichment state**: Is Enriched, Tried To Be Enriched, Is Personal Email, Is Twenty.
|
||||
- **Person enrichment**: City, Country, Job Function, Job Title, LinkedIn, Seniority.
|
||||
- **Company enrichment**: Name, Description, Industry, Industries, Employees, Founded Year, Annual Revenue, Funding Latest Stage, Funding Total Amount, Alexa Rank, LinkedIn, Tags, Tech.
|
||||
|
||||
### Person relation
|
||||
|
||||
The standard **Person** object gains a **Self hosting users** relation, so you can see every self-hosted sign-up tied to a given person directly from their record.
|
||||
|
||||
### View and navigation
|
||||
|
||||
A **Self hosting users** table view exposes all of the fields above as columns and is added to the left sidebar for quick access.
|
||||
|
||||
### Role
|
||||
|
||||
The app ships a default role with read, update, and soft-delete permissions over workspace records. Permanent deletion is not granted.
|
||||
|
||||
## How sign-up data flows in
|
||||
|
||||
The app exposes an unauthenticated HTTP endpoint that self-hosted instances post telemetry to:
|
||||
|
||||
- **Method**: `POST`
|
||||
- **Path**: `/webhook/telemetry`
|
||||
|
||||
A request is processed only when its `action` is `user_signup`; any other event type is acknowledged and ignored. The payload carries the user's email, first and last name, locale, the originating server URL and server ID, and workspace identifiers.
|
||||
|
||||
When a valid sign-up arrives:
|
||||
|
||||
1. Non-signup events are acknowledged and ignored first; then sign-ups with no email are skipped; then sign-ups whose email contains `test` or `example` (case-insensitive) are ignored.
|
||||
2. If a Self Hosting User already exists for that email, it is updated with the latest details; otherwise a new one is created. The app persists Name, Email, Locale, Server URL, Server ID, User ID, and User Workspace ID; Domain and the enrichment fields are left for separate processes.
|
||||
3. Whenever a Self Hosting User is created or its email changes, the app looks for a Person with the same email. If one exists it links them; if not, it creates a Person and links it. Records that already have a matched Person and an unchanged email are left untouched.
|
||||
|
||||
## Configuration
|
||||
|
||||
This app requires no server or application variables. To send data into it, point a self-hosted Twenty instance's telemetry at the webhook path above on the workspace where this app is installed.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The telemetry webhook is intentionally **unauthenticated** and performs no signature or shared-secret verification. Any client that can reach the endpoint can submit sign-up events, so it should only be exposed to trusted self-hosted instances.
|
||||
- Test/example filtering is a simple substring match: any email containing `test` or `example` anywhere is excluded, even if it is a legitimate address.
|
||||
- The enrichment fields and the Domain field are part of the data model but are populated by separate processes; the app itself ingests sign-ups and performs Person matching.
|
||||
|
||||
@@ -64,8 +64,7 @@ What this connector intentionally does **not** support in v1:
|
||||
- **Slash commands / interactions.** The bot doesn't register or respond to
|
||||
`/commands`.
|
||||
- **Per-workspace identity.** All Twenty workspaces in the same Twenty
|
||||
deployment share the same Discord bot — see
|
||||
[Why bot token instead of OAuth?](#why-bot-token-instead-of-oauth) below.
|
||||
deployment share the same Discord bot.
|
||||
- **2000-character message limit.** Discord rejects longer payloads with
|
||||
HTTP 400.
|
||||
|
||||
@@ -120,54 +119,3 @@ this — the bot credentials are already configured.
|
||||
Workspace users can now use the Discord workflow tools immediately — no
|
||||
further per-user configuration needed (which is unique vs Linear / Slack
|
||||
where each user connects their own account).
|
||||
|
||||
---
|
||||
|
||||
## Why bot token instead of OAuth?
|
||||
|
||||
Slack and Linear use OAuth-per-workspace
|
||||
(`defineConnectionProvider({ type: 'oauth' })`) so each Twenty workspace
|
||||
stores its own access token. Discord works differently:
|
||||
|
||||
- Discord's `bot` scope **does** have an OAuth flow, but the `access_token`
|
||||
it returns is a *user* bearer token — useless for bot actions like posting
|
||||
messages. To actually send messages as the bot you need the static
|
||||
**bot token** from the Developer Portal.
|
||||
- That bot token is global to the Discord application (and therefore to
|
||||
the Twenty deployment). Discord deprecated per-install bot tokens years
|
||||
ago.
|
||||
- Webhooks are a separate auth model but only support posting — no edit,
|
||||
delete, or reactions — so they don't cover Slack/Linear parity.
|
||||
|
||||
The result: this connector skips `defineConnectionProvider` entirely and
|
||||
reads `DISCORD_BOT_TOKEN` from an `applicationVariable` set once at
|
||||
deployment scope. See
|
||||
[Discord's OAuth2 docs](https://discord.com/developers/docs/topics/oauth2#bot-users)
|
||||
for the underlying reason bot users authenticate via static tokens.
|
||||
|
||||
---
|
||||
|
||||
## Developers only
|
||||
|
||||
If you're working on this app rather than installing the published version:
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-discord
|
||||
|
||||
# Day-to-day development (publish + install + watch in one):
|
||||
yarn twenty dev
|
||||
|
||||
# Run unit tests:
|
||||
yarn test
|
||||
|
||||
# Lint:
|
||||
yarn lint
|
||||
```
|
||||
|
||||
`twenty dev` is recommended for iteration — it publishes to your local
|
||||
Twenty server, installs the app, and watches for changes in one command.
|
||||
|
||||
The Discord REST API (v10) is called directly via `fetch` — no `discord.js`
|
||||
or other SDK dependency. See
|
||||
`src/logic-functions/utils/discord-api-request.ts` for the auth and
|
||||
error-handling wrapper that all handlers go through.
|
||||
|
||||
@@ -13,8 +13,6 @@ export default defineApplication({
|
||||
logoUrl: 'public/twenty-discord.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Communication',
|
||||
aboutDescription:
|
||||
'Official Discord connector for Twenty CRM. Create a Discord application at https://discord.com/developers/applications, copy its bot token into the DISCORD_BOT_TOKEN application variable, then invite the bot to each server you want workflows to post in. Use workflow actions to post, update, or delete bot messages and add reactions.',
|
||||
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
# twenty-exa
|
||||
# Exa
|
||||
|
||||
Exposes [Exa](https://exa.ai) structured web search to Twenty AI agents
|
||||
(chat + workflow agents + MCP) as the `app_exa_web_search` tool.
|
||||
Structured web search powered by [Exa](https://exa.ai), available to Twenty's AI agents as the `exa_web_search` tool. When an agent needs fresh, entity-aware information from the web (companies, people, research, news, and more), it can call Exa directly from chat and answer with live results.
|
||||
|
||||
## Installation
|
||||
## What it does
|
||||
|
||||
1. Register the app on the Twenty server once (admin API / UI):
|
||||
`twenty-exa` from npm.
|
||||
2. Set `isPreInstalled=true` on the registration so it's installed on
|
||||
every new workspace. Existing workspaces can be backfilled via the
|
||||
`install-pre-installed-apps` CLI command.
|
||||
3. Set the `EXA_API_KEY` server variable on the registration to your Exa
|
||||
API key. The value is injected into every logic function execution —
|
||||
no per-workspace configuration needed.
|
||||
This app adds a single AI tool, `exa_web_search`, to your workspace. Twenty AI agents can invoke it to run a search through Exa and receive structured, entity-aware results. It is designed to surface high-quality matches for companies, people, research papers, news, and similar entities rather than generic page links.
|
||||
|
||||
The tool reads no workspace data. It only uses the search query the agent provides and your configured Exa API key, calling Exa's external API to return results.
|
||||
|
||||
## How agents use it
|
||||
|
||||
The tool accepts the following inputs:
|
||||
|
||||
- **query** (required) — the search query to send to Exa.
|
||||
- **category** (optional) — narrows results to a specific type. Supported values: `company`, `research paper`, `news`, `pdf`, `personal site`, `financial report`, `people`. When omitted, Exa searches across all types.
|
||||
- **numResults** (optional) — how many results to return, between `1` and `30`. Defaults to `10`.
|
||||
|
||||
Agents choose these values automatically based on what they are trying to find, so no manual configuration is needed once the app is set up.
|
||||
|
||||
## Setup
|
||||
|
||||
The app requires an Exa API key, configured once by the server administrator:
|
||||
|
||||
- **EXA_API_KEY** (required, secret) — your Exa API key. It is set on the app after installation and injected into every search. There is no per-workspace configuration; the same key serves all searches.
|
||||
|
||||
If the key is not set, searches fail with an error until an administrator provides it.
|
||||
|
||||
## Billing
|
||||
|
||||
The handler calls Twenty's generic app billing endpoint
|
||||
(`POST /app/billing/charge`) using the application access token injected
|
||||
into the execution env. Pricing mirrors Exa's auto-search tier: $0.007
|
||||
base (10 results) + $0.001 per additional result.
|
||||
Each successful search consumes credits, mirroring Exa's auto-search pricing: a base cost of **$0.007** covers the first 10 results, plus **$0.001** for each additional result. Charges are based on the number of results actually returned, not the number requested.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Returns at most 30 results per search.
|
||||
- Results come live from Exa's external API, so the app depends on Exa's availability and on a valid API key being configured.
|
||||
- The tool only performs web search; it does not read from or write to your workspace data.
|
||||
|
||||
@@ -157,8 +157,7 @@ supported.
|
||||
Fireflies UI. For local development, expose your dev server with a
|
||||
tunnel like `ngrok http 3000` and paste the HTTPS forwarding URL here,
|
||||
or skip the Fireflies UI entirely and POST a signed payload directly to
|
||||
your local endpoint (see [Local webhook testing](#local-webhook-testing)
|
||||
in the developer section below).
|
||||
your local endpoint.
|
||||
3. Set a **Signing Secret** (a long random string — generate one with
|
||||
`openssl rand -hex 32`). Save it; you'll paste it into Twenty next.
|
||||
4. Under **Events**, subscribe to **both**:
|
||||
@@ -180,88 +179,3 @@ After saving, the next time Fireflies finishes processing a recording, the
|
||||
transcript will land on the matching CalendarEvent within a few seconds;
|
||||
the summary follows once Fireflies finishes the AI summarization step
|
||||
(typically a minute or two later — Fireflies sends two separate webhooks).
|
||||
|
||||
---
|
||||
|
||||
## Why `transcript` / `summary` fields on `CalendarEvent` instead of a new object?
|
||||
|
||||
Storing the transcript and AI summary as rich-text fields directly on the
|
||||
existing `CalendarEvent`:
|
||||
|
||||
- Keeps everything about a meeting in one place (no joins)
|
||||
- Avoids inventing a new object that other call-recording apps would each
|
||||
need to coordinate on
|
||||
- Works today without lookup fields
|
||||
|
||||
If later integrations (Gong, Otter, Zoom AI, etc.) make one pair of fields
|
||||
too restrictive — for example, needing to distinguish *which* tool produced
|
||||
the transcript — we'll promote the fields to a platform-level concept rather
|
||||
than keep extending this app.
|
||||
|
||||
---
|
||||
|
||||
## Developers only
|
||||
|
||||
If you're working on this app rather than installing the published version:
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-fireflies
|
||||
|
||||
# Day-to-day development (publish + install + watch in one):
|
||||
yarn twenty dev
|
||||
|
||||
# Run unit tests:
|
||||
yarn test
|
||||
|
||||
# Lint:
|
||||
yarn lint
|
||||
```
|
||||
|
||||
`twenty dev` is recommended for iteration — it publishes to your local Twenty
|
||||
server, installs the app, and watches for changes in one command.
|
||||
|
||||
The Fireflies GraphQL API is called directly via `fetch` — no `fireflies` SDK
|
||||
dependency. See `src/logic-functions/utils/fireflies-api-request.ts` for the
|
||||
auth + error-handling wrapper that all queries go through.
|
||||
|
||||
### Local webhook testing
|
||||
|
||||
Fireflies' Webhooks V2 UI only accepts a publicly reachable HTTPS URL, so
|
||||
pointing it at `http://localhost:*` directly is not possible. Two paths:
|
||||
|
||||
**End-to-end via tunnel.** Run a tunnel that fronts your local server with
|
||||
a public HTTPS URL (`ngrok http 3000`, `cloudflared tunnel`, etc.), paste
|
||||
the HTTPS forwarding URL into the Fireflies webhook UI as the **Webhook
|
||||
URL**, and exercise the integration by ending a real Fireflies meeting.
|
||||
|
||||
**Backend-only via signed `curl`.** Skip the Fireflies UI entirely and POST
|
||||
a signed payload straight to the local endpoint. The signature must be
|
||||
HMAC-SHA256 over the **raw** request body, keyed by your
|
||||
`FIREFLIES_WEBHOOK_SECRET`, prefixed with `sha256=`:
|
||||
|
||||
```bash
|
||||
export FIREFLIES_WEBHOOK_SECRET='<the secret you set in Twenty app settings>'
|
||||
|
||||
BODY='{"event":"meeting.transcribed","meeting_id":"<a-real-fireflies-transcript-id>"}'
|
||||
SIG=$(printf '%s' "$BODY" \
|
||||
| openssl dgst -sha256 -hmac "$FIREFLIES_WEBHOOK_SECRET" \
|
||||
| awk '{print $NF}')
|
||||
|
||||
curl -X POST http://localhost:3000/webhook/fireflies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "x-hub-signature: sha256=$SIG" \
|
||||
--data-binary "$BODY"
|
||||
```
|
||||
|
||||
`--data-binary` (not `--data`) is important: it preserves the bytes
|
||||
verbatim so the HMAC the server computes matches the one `openssl`
|
||||
computed above. Twenty resolves the workspace from the `Host` header, so
|
||||
the default dev workspace (mapped to `localhost:3000` in a standard
|
||||
`yarn start` setup) receives the request.
|
||||
|
||||
To match a real `CalendarEvent`, the transcript ID you pass must belong to
|
||||
a Fireflies call whose `calendar_id` / `cal_id` matches an existing
|
||||
`CalendarEvent.iCalUid` or `CalendarChannelEventAssociation.eventExternalId`
|
||||
in your local Twenty workspace. The easiest local seed is to manually
|
||||
insert a row with one of those identifiers and use a Fireflies transcript
|
||||
whose calendar fields point at it.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import { ABOUT_DESCRIPTION } from 'src/constants/ABOUT_DESCRIPTION.md';
|
||||
import {
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
@@ -15,7 +14,6 @@ export default defineApplication({
|
||||
logoUrl: 'public/twenty-fireflies.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
aboutDescription: ABOUT_DESCRIPTION,
|
||||
screenshots: [
|
||||
'public/gallery/transcript-on-calendar-event.png',
|
||||
'public/gallery/summary-on-calendar-event.png',
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
export const ABOUT_DESCRIPTION = `Bring your Fireflies meeting recordings into Twenty. When Fireflies finishes processing a call, the transcript and AI summary land on the matching CalendarEvent automatically — no copy-pasting, no extra tabs.
|
||||
|
||||
## What gets added to your workspace
|
||||
|
||||
Two new fields appear on the standard **CalendarEvent** object:
|
||||
|
||||
- **Transcript** — speaker-attributed rich text, e.g. *"**Sarah:** Hi there"* followed by *"**John:** Doing well, thanks"*.
|
||||
- **Summary** — Fireflies' AI-generated overview of the meeting (key points, action items, decisions).
|
||||
|
||||
Both update in real time through a Fireflies Webhooks V2 subscription.
|
||||
|
||||
## How it syncs
|
||||
|
||||
The connector subscribes to two Fireflies V2 events:
|
||||
|
||||
- \`meeting.transcribed\` writes the **Transcript** field.
|
||||
- \`meeting.summarized\` writes the **Summary** field.
|
||||
|
||||
Each webhook delivery is HMAC-SHA256 verified against your signing secret before anything touches your data.
|
||||
|
||||
## How calls are matched to CalendarEvents
|
||||
|
||||
The matcher uses provider-native identifiers — never fuzzy URL matching — so transcripts always land on the right event:
|
||||
|
||||
1. **Provider event ID** — Fireflies' \`calendar_id\` / \`calendar_event_uid\` against \`CalendarChannelEventAssociation.eventExternalId\`. Covers events synced from Google Calendar, including individual instances of recurring meetings.
|
||||
2. **iCalUID** — Fireflies' \`calendar_id\` against \`CalendarEvent.iCalUid\`. Covers events synced from Outlook / CalDAV.
|
||||
|
||||
Both identifiers are populated automatically when calendars are synced into Twenty. If a recording can't be matched (orphan recording, no calendar sync configured), the webhook reports a clear skip reason and writes nothing.
|
||||
|
||||
## Tools for workflows and the AI chat
|
||||
|
||||
Beyond the automatic sync, three Fireflies tools become available in **workflows** and the **AI chat**:
|
||||
|
||||
- **Sync Fireflies Call** — Pull a single Fireflies call onto its CalendarEvent on demand. Useful for backfilling history or recovering from a missed webhook. Same matching rules as the webhook.
|
||||
- **Search Fireflies Calls** — Keyword search across **both** meeting titles and the words spoken during meetings. Ask the AI chat *"find any call where we discussed pricing"* and it returns matching calls with titles, dates, participants, and transcript links.
|
||||
- **List Fireflies Calls By Participant** — List every call a given email address attended. Great as the first step of a workflow triggered when a Person record is created, or to answer *"what calls have we had with this contact?"* from the AI chat.
|
||||
|
||||
## Installing
|
||||
|
||||
1. Open **Settings → Applications** in your Twenty workspace.
|
||||
2. Find **Twenty Fireflies** in the available apps and click **Install**.
|
||||
|
||||
Then your admin completes the one-time wiring (see below).
|
||||
|
||||
## One-time setup (admin)
|
||||
|
||||
1. Generate an API key at [Fireflies → Integrations → Fireflies API](https://app.fireflies.ai/settings/developer-settings) and paste it into the **FIREFLIES_API_KEY** application variable.
|
||||
2. Generate a long random string (\`openssl rand -hex 32\`). Paste it into the **FIREFLIES_WEBHOOK_SECRET** application variable.
|
||||
3. Configure a Webhooks V2 endpoint at [Fireflies → Integrations → Webhooks V2](https://app.fireflies.ai/integrations/api/webhook):
|
||||
- **Webhook URL**: \`https://<your-twenty-domain>/webhook/fireflies\`
|
||||
- **Signing Secret**: the same value as \`FIREFLIES_WEBHOOK_SECRET\`
|
||||
- **Events**: subscribe to \`meeting.transcribed\` (required) and \`meeting.summarized\` (optional, for AI summaries)
|
||||
|
||||
That's it — the next call Fireflies processes will start syncing automatically.
|
||||
|
||||
## Limitations
|
||||
|
||||
What this connector intentionally does **not** support in v1:
|
||||
|
||||
- **Orphan calls** (recordings with no matching CalendarEvent in Twenty) are skipped — fuzzy URL matching is avoided so transcripts never land on the wrong event.
|
||||
- **Per-user Fireflies accounts** — all sync goes through one workspace-shared API key set by the admin.
|
||||
- **Editing transcripts in Twenty** — the field is writable in principle, but future Fireflies syncs will overwrite manual edits.
|
||||
- **Speaker analytics, sentiment, action items as structured fields** — only raw transcript and summary text are synced; structured insights stay in the Fireflies dashboard.
|
||||
`;
|
||||
@@ -1,122 +1,57 @@
|
||||
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
# Twenty for Twenty
|
||||
|
||||
The official Twenty internal app, with modules for Resend and more.
|
||||
|
||||
## Overview
|
||||
|
||||
**Twenty for Twenty** is the official internal Twenty app. It is organized into modules, each integrating a third-party service with Twenty.
|
||||
|
||||
### Resend module (`src/modules/resend/`)
|
||||
### Resend module
|
||||
|
||||
Two-way sync between Twenty and the [Resend](https://resend.com) email platform. The module syncs contacts, segments, templates, broadcasts, and emails.
|
||||
|
||||
**Inbound (Resend -> Twenty):**
|
||||
**Inbound (Resend → Twenty):**
|
||||
|
||||
- A cron job runs every 5 minutes to pull all entities from the Resend API
|
||||
- A webhook endpoint receives real-time events for contacts and emails
|
||||
- A cron job runs every 5 minutes to pull all entities from the Resend API.
|
||||
- A webhook endpoint receives real-time events for contacts and emails.
|
||||
|
||||
**Outbound (Twenty -> Resend):**
|
||||
**Outbound (Twenty → Resend):**
|
||||
|
||||
- Database event triggers push contact and segment changes back to Resend when records are created, updated, or deleted in Twenty
|
||||
- Contact and segment changes in Twenty are pushed back to Resend when records are created, updated, or deleted.
|
||||
|
||||
## Getting Started
|
||||
## Configuration
|
||||
|
||||
### 1. Install and run the app
|
||||
In Twenty, go to **Settings → Applications → Twenty for Twenty** and set:
|
||||
|
||||
```bash
|
||||
yarn twenty dev
|
||||
```
|
||||
- **RESEND_API_KEY** — your Resend API key. Create one at https://resend.com/api-keys (full access recommended).
|
||||
- **RESEND_WEBHOOK_SECRET** — the signing secret for verifying inbound webhooks (see "Webhook setup" below).
|
||||
|
||||
This registers the app with your local Twenty instance at `http://localhost:3000/settings/applications`.
|
||||
|
||||
### 2. Configure app variables
|
||||
|
||||
In Twenty, go to **Settings > Applications > Twenty for Twenty** and set:
|
||||
|
||||
- **RESEND_API_KEY** -- Your Resend API key. Create one at https://resend.com/api-keys (full access recommended).
|
||||
- **RESEND_WEBHOOK_SECRET** -- The signing secret for verifying inbound webhooks (see "Webhook setup" below).
|
||||
|
||||
### 3. Webhook setup
|
||||
### Webhook setup
|
||||
|
||||
The app exposes an HTTP endpoint at `/s/webhook/resend` that receives Resend webhook events. To connect it:
|
||||
|
||||
1. Go to https://resend.com/webhooks
|
||||
2. Click **Add webhook**
|
||||
3. Set the **Endpoint URL** to your Twenty server's public URL + `/s/webhook/resend` (e.g. `https://your-domain.com/s/webhook/resend`)
|
||||
4. Set **Events types** to **All Events**
|
||||
4. Set **Event types** to **All Events**
|
||||
5. Click **Add**
|
||||
6. Copy the **signing secret** Resend displays and paste it into the `RESEND_WEBHOOK_SECRET` app variable in Twenty
|
||||
|
||||
The webhook handles:
|
||||
|
||||
- **Contact events** (`contact.created`, `contact.updated`, `contact.deleted`) -- upserts/deletes Resend contact records in Twenty
|
||||
- **Email events** (`email.sent`, `email.delivered`, `email.bounced`, `email.opened`, `email.clicked`, etc.) -- updates delivery status on Resend email records in real-time
|
||||
- **Domain events** -- logged and skipped (no domain object in the app yet)
|
||||
|
||||
### 4. Testing webhooks locally
|
||||
|
||||
Install the [Resend CLI](https://resend.com/docs/resend-cli):
|
||||
|
||||
```bash
|
||||
brew install resend/cli/resend
|
||||
```
|
||||
|
||||
Or via npm if Homebrew has issues:
|
||||
|
||||
```bash
|
||||
npm install -g resend-cli
|
||||
```
|
||||
|
||||
Authenticate:
|
||||
|
||||
```bash
|
||||
resend login
|
||||
```
|
||||
|
||||
Start the webhook listener with forwarding to your local Twenty server:
|
||||
|
||||
```bash
|
||||
resend webhooks listen --forward-to http://localhost:3000/s/webhook/resend
|
||||
```
|
||||
|
||||
The CLI will:
|
||||
|
||||
1. Create a public tunnel automatically
|
||||
2. Register a temporary webhook in Resend pointing to that tunnel
|
||||
3. Forward incoming events (with Svix signature headers) to your local Twenty server
|
||||
4. Display events in the terminal as they arrive
|
||||
5. Clean up the temporary webhook when you press Ctrl+C
|
||||
|
||||
To trigger test events, create or update a contact in the [Resend dashboard](https://resend.com/contacts), or send a test email.
|
||||
|
||||
## Sync behavior
|
||||
|
||||
### Inbound sync
|
||||
### Inbound
|
||||
|
||||
| Source | Mechanism | Entities |
|
||||
|---|---|---|
|
||||
| Cron (every 5 min) | Polls Resend API, upserts into Twenty | Contacts, segments, templates, broadcasts, emails |
|
||||
| Webhook (real-time) | Receives Resend events via HTTP | Contacts, emails |
|
||||
|
||||
### Outbound sync
|
||||
### Outbound
|
||||
|
||||
| Twenty action | Resend API call |
|
||||
| Twenty action | Resend |
|
||||
|---|---|
|
||||
| Create contact | `contacts.create()` -- writes `resendId` back to Twenty |
|
||||
| Update contact (name, email, unsubscribed) | `contacts.update()` |
|
||||
| Delete contact | `contacts.remove()` |
|
||||
| Create segment | `segments.create()` -- writes `resendId` back to Twenty |
|
||||
| Delete segment | `segments.remove()` |
|
||||
|
||||
### Loop prevention
|
||||
|
||||
A `lastSyncedFromResend` field on contact, segment, and email records tracks when data came from Resend. Outbound triggers skip processing when this field is part of the update, preventing infinite echo loops between inbound and outbound sync.
|
||||
|
||||
## Commands
|
||||
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Resend API documentation](https://resend.com/docs)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
| Create contact | Creates the contact in Resend |
|
||||
| Update contact (name, email, unsubscribed) | Updates the Resend contact |
|
||||
| Delete contact | Removes the Resend contact |
|
||||
| Create segment | Creates the segment in Resend |
|
||||
| Delete segment | Removes the Resend segment |
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# Last contacted at
|
||||
|
||||
A [Twenty](https://twenty.com) official application that adds a `lastContactAt` field to the standard Person object and keeps it in sync with email and calendar activity.
|
||||
A [Twenty](https://twenty.com) official application that adds a `lastContactAt` field to the standard Person object and keeps it in sync with email and calendar activity — answering the question every CRM should answer instantly: *when did we last talk to this person?*
|
||||
|
||||
## Why teams install it
|
||||
|
||||
- **Zero manual logging** — every synced email and calendar meeting updates the field automatically, in real time.
|
||||
- **Useful from minute one** — on install, your entire email and meeting history is backfilled. No empty columns, no waiting.
|
||||
- **Spot cold relationships instantly** — sort or filter any People view by Last Contact to build follow-up lists in seconds.
|
||||
- **Meeting-aware** — a meeting counts as contact the moment it starts, not whenever someone remembers to update the CRM.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -9,15 +16,10 @@ A [Twenty](https://twenty.com) official application that adds a `lastContactAt`
|
||||
- Counts a meeting as contact when it starts, via a cron-triggered logic function.
|
||||
- Backfills the field from existing message and calendar history right after install.
|
||||
|
||||
No setup, no configuration — install it, open People, and you immediately know who needs a follow-up.
|
||||
|
||||
### Application variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `CALENDAR_CRON_INTERVAL_MINUTES` | `5` | Interval between runs of `on-calendar-event-started`. The cron scans events that started within the last interval plus a 5-minute safety overlap. |
|
||||
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
CALENDAR_CRON_INTERVAL_MINUTES_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
import { ABOUT_DESCRIPTION } from 'src/constants/about-description.constant';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
@@ -14,7 +13,6 @@ export default defineApplication({
|
||||
screenshots: ['public/gallery/cover.png'],
|
||||
displayName: APP_DISPLAY_NAME,
|
||||
description: APP_DESCRIPTION,
|
||||
aboutDescription: ABOUT_DESCRIPTION,
|
||||
applicationVariables: {
|
||||
CALENDAR_CRON_INTERVAL_MINUTES: {
|
||||
universalIdentifier:
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
export const ABOUT_DESCRIPTION = `# Never let a relationship go cold
|
||||
|
||||
**Last contacted** answers the question every CRM should answer instantly: *when did we last talk to this person?*
|
||||
|
||||
It adds a **Last Contact** field to your People — and keeps it accurate without anyone logging anything, ever.
|
||||
|
||||
## Why teams install it
|
||||
|
||||
- **Zero manual logging** — every synced email and calendar meeting updates the field automatically, in real time.
|
||||
- **Useful from minute one** — on install, your entire email and meeting history is backfilled. No empty columns, no waiting.
|
||||
- **Spot cold relationships instantly** — sort or filter any People view by Last Contact to build follow-up lists in seconds.
|
||||
- **Meeting-aware** — a meeting counts as contact the moment it starts, not whenever someone remembers to update the CRM.
|
||||
|
||||
## How it works
|
||||
|
||||
1. A **Last Contact** date field is added to your people.
|
||||
2. Logic functions listen to the emails and calendar events Twenty already syncs, and keep the field set to the most recent interaction.
|
||||
3. A built-in scheduler marks meetings as contact as soon as they begin.
|
||||
4. A post-install backfill fills the field from your existing history.
|
||||
|
||||
No setup. No configuration. Install it, open People, and you immediately know who needs a follow-up.`;
|
||||
@@ -53,27 +53,3 @@ this — the OAuth credentials are already configured.
|
||||
|
||||
Workspace users will now be able to add Linear connections from the
|
||||
**Connections** tab as described above.
|
||||
|
||||
### 3. (Developers only) Building the app from source
|
||||
|
||||
If you're working on this app rather than installing the published version:
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-linear
|
||||
|
||||
# For day-to-day development (publish + install + watch in one):
|
||||
yarn twenty dev
|
||||
|
||||
# Manual publish flow (publish registers the app, install activates it):
|
||||
yarn twenty app:publish --private
|
||||
yarn twenty app:install
|
||||
```
|
||||
|
||||
`twenty dev` is recommended for iteration — it publishes, installs, and
|
||||
watches for changes in one command. Use `twenty app:publish --private` +
|
||||
`twenty app:install` when you want to control each step separately (e.g.
|
||||
deploying to a production server without auto-installing).
|
||||
|
||||
This serves as the reference implementation for Twenty's
|
||||
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
|
||||
when adding OAuth integrations for other providers.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
import { ABOUT_DESCRIPTION } from './constants/ABOUT_DESCRIPTION.md';
|
||||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
@@ -8,7 +7,6 @@ export default defineApplication({
|
||||
description:
|
||||
'Connect Linear to Twenty. Each workspace member connects their own Linear account; logic functions can then create issues and read team data on their behalf.',
|
||||
logoUrl: 'public/linear-logomark.svg',
|
||||
aboutDescription: ABOUT_DESCRIPTION,
|
||||
applicationVariables: undefined,
|
||||
author: 'Twenty',
|
||||
category: 'Product management',
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export const ABOUT_DESCRIPTION = `Connect your Linear account to Twenty to create issues and look up teams straight from your workflows or the AI chat.
|
||||
|
||||
## What you can do
|
||||
|
||||
Once installed and connected, two tools become available:
|
||||
|
||||
- **Create Linear issue**
|
||||
- From the AI chat, ask something like *"create a Linear issue in the Engineering team titled 'Fix login bug'"* and the AI will file it for you.
|
||||
- From a workflow, add it as a step with \`teamId\` + \`title\` (and optional \`description\`).
|
||||
|
||||
- **List Linear teams** — discovers the teams in your Linear workspace, useful when you need to pick a \`teamId\` for the create-issue step.
|
||||
|
||||
## Installing
|
||||
|
||||
1. Open **Settings → Applications** in your Twenty workspace.
|
||||
2. Find **Linear** in the available apps and click **Install**.
|
||||
3. Open the app, go to the **Connections** tab, and click **Add connection**.
|
||||
4. Choose **Just for me** (your personal Linear account) or **Workspace shared** (a team-managed Linear account anyone in this workspace can act through), then complete the Linear sign-in.
|
||||
|
||||
That's it — you can now use the tools above.`;
|
||||
@@ -1,19 +1,6 @@
|
||||
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
# Twenty Meeting Bot
|
||||
|
||||
## Getting Started
|
||||
|
||||
This app was scaffolded with a local Twenty server running at [http://localhost:2020](http://localhost:2020).
|
||||
|
||||
Login with the default development credentials: `tim@apple.dev` / `tim@apple.dev`.
|
||||
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
- `yarn twenty dev` - Start the development server and sync your app
|
||||
- `yarn twenty docker:status` - Check the local Twenty server status
|
||||
- `yarn twenty docker:start` - Start the local Twenty server
|
||||
- `yarn test` - Run integration tests
|
||||
Capture every customer conversation automatically. A meeting bot joins eligible meetings and records calls for you.
|
||||
|
||||
## Recall.ai configuration
|
||||
|
||||
@@ -49,9 +36,3 @@ The app exposes an unauthenticated route, `POST /webhook/recall`, that verifies
|
||||
3. Set it as the `RECALL_WEBHOOK_SECRET` server variable on the Twenty Meeting Bot application registration.
|
||||
|
||||
The handler ignores out-of-order or duplicate deliveries (it never moves a recording's status backwards) and returns a non-2xx response on signature failures so Recall retries.
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
# twenty-partners
|
||||
# Twenty Partners
|
||||
|
||||
A Twenty app that turns the CRM into the operating system for the Twenty partner program:
|
||||
intake partner-eligible deals, match them to vetted marketplace partners, and track the
|
||||
matching pipeline end-to-end.
|
||||
|
||||
Built on [Twenty](https://twenty.com) with [`twenty-sdk`](https://www.npmjs.com/package/twenty-sdk) v2.5.
|
||||
Turns the CRM into the operating system for the Twenty partner program: intake partner-eligible deals, match them to vetted marketplace partners, and track the matching pipeline end-to-end.
|
||||
|
||||
## What's inside
|
||||
|
||||
- **Custom object: `Partner`** — slug, status, availability, served geos, languages spoken,
|
||||
deployment expertise, Calendly link, last-match timestamp. See `src/objects/partner.object.ts`.
|
||||
- **Opportunity extensions** — `matchStatus`, `designDocStatus`,
|
||||
`introSentAt`, `lastRelanceSentAt`, `tftId`, plus a `partner` relation.
|
||||
- **Logic functions**
|
||||
- `on-opportunity-auto-match` — fires when `matchStatus` is set to `AUTO_MATCH`. Assigns the longest-idle available partner and flips status to `MATCHED`. If no partner is available, hands off to `MANUAL_MATCH` with an audit Note explaining why.
|
||||
- `list-available-partners` — surfaces matchable partners for a given opportunity.
|
||||
- `post-install` — first-run setup.
|
||||
- **Roles** (`src/roles/`)
|
||||
- **Twenty Partner Ops** — internal team role, full CRUD on Partner/Company/Person/Opportunity.
|
||||
- **Partner** — placeholder external-partner role. *Do not assign until Twenty ships
|
||||
row-level permissions* — it currently grants access to every record.
|
||||
- **Views** (`src/views/`)
|
||||
- `Waiting for match` — opportunities awaiting human action (`matchStatus` is `TO_BE_MATCHED` or `MANUAL_MATCH`).
|
||||
- `Matches overview` — full matching funnel grouped by `matchStatus` (configure Kanban
|
||||
grouping manually in the UI).
|
||||
- `Opportunities` — replacement of the native opportunities view with the partner columns.
|
||||
- `Partners` and `All matched deals` — partner-side index and deal log.
|
||||
- **Sidebar nav** — surfaced in workflow order: `Waiting for match`, `All partner deals`,
|
||||
`Matches overview`, `Partners`, `Opportunities`.
|
||||
- **Seed scripts** (`src/scripts/`) — populate a fresh workspace with realistic demo data.
|
||||
- **Partner object** — slug, status, availability, served geographies, languages spoken, deployment expertise, Calendly link, and last-match timestamp.
|
||||
- **Opportunity extensions** — match status, design-doc status, intro and relance timestamps, and a relation to the matched partner.
|
||||
- **Automatic matching** — when an opportunity is set to auto-match, the longest-idle available partner is assigned and the deal is marked matched; if none is available it is handed off for manual matching with an explanatory note.
|
||||
- **Views** — a waiting-for-match queue, a matching-funnel overview grouped by status, the partner index, and a log of matched deals, all surfaced in the sidebar.
|
||||
|
||||
## Match status pipeline
|
||||
|
||||
`matchStatus` is a non-nullable SELECT field with a default of `TO_BE_MATCHED`. The 10 states follow the deal lifecycle:
|
||||
`matchStatus` follows the deal lifecycle:
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
@@ -46,56 +25,3 @@ Built on [Twenty](https://twenty.com) with [`twenty-sdk`](https://www.npmjs.com/
|
||||
| `WON` | Deal closed won |
|
||||
| `RECONNECT_LATER` | Paused — reconnect in future |
|
||||
| `LOST` | Deal closed lost |
|
||||
|
||||
## Getting started
|
||||
|
||||
Requires a local Twenty server at `http://localhost:2020` and Node `^24.5`.
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
Default dev credentials: `tim@apple.dev` / `tim@apple.dev`.
|
||||
|
||||
Run `yarn twenty help` for the full CLI reference.
|
||||
|
||||
## Common commands
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `yarn twenty dev` | Start the dev server and sync the app on file changes |
|
||||
| `yarn twenty server status` | Check the local Twenty server |
|
||||
| `yarn lint` / `yarn lint:fix` | Run oxlint |
|
||||
| `yarn test` | Run integration tests (`vitest.config.ts`) |
|
||||
|
||||
## Seeding demo data
|
||||
|
||||
Two idempotent seed scripts. Both run via the `vitest.seed.config.ts` config that skips
|
||||
the global app uninstall/reinstall.
|
||||
|
||||
```bash
|
||||
# 1. Marketplace partners (run first — pipeline seed wires opportunities to these by slug)
|
||||
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-marketplace-partners.ts
|
||||
|
||||
# 2. Pipeline demo: 3 companies, 3 people, 15 opportunities spread across matchStatus values
|
||||
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-pipeline-demo.ts
|
||||
```
|
||||
|
||||
Both scripts skip records that already exist (by `slug`, `name`, or `firstName+lastName`),
|
||||
so they are safe to re-run.
|
||||
|
||||
## Known limitations
|
||||
|
||||
Current SDK gaps blocking further polish:
|
||||
|
||||
- Custom Partner record page layout (RECORD_TABLE has no relation scoping).
|
||||
- Native Opportunities view column-order override.
|
||||
- Kanban view configuration from app code (`ViewType.KANBAN` is currently ignored).
|
||||
- App and field descriptions.
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
|
||||
- [`twenty-sdk` on npm](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# twenty-slack
|
||||
# Slack for Twenty
|
||||
|
||||
Slack tools for **Twenty workflows** and **agents** (the same logic functions
|
||||
Slack tools for **Twenty workflows** and **agents** — the same logic functions
|
||||
are available as workflow steps and as tools where your deployment exposes
|
||||
them). Uses the official
|
||||
[`@slack/web-api`](https://github.com/slackapi/node-slack-sdk) `WebClient`
|
||||
(Slack retries and error types).
|
||||
them.
|
||||
|
||||
## What you can do
|
||||
|
||||
@@ -63,8 +61,7 @@ Both routes require an authenticated Twenty user and use the same shared Slack c
|
||||
only under **User Token Scopes** — or Slack will refuse install with *“doesn’t
|
||||
have a bot user to install”* until at least one bot scope exists.
|
||||
|
||||
The scopes **requested at connect time** are defined in
|
||||
`src/connection-providers/slack-connection.ts` and must also appear under
|
||||
The scopes **requested at connect time** must also appear under
|
||||
**Bot Token Scopes** on the Slack app (Slack validates the set). Current
|
||||
list:
|
||||
|
||||
@@ -74,7 +71,7 @@ Both routes require an authenticated Twenty user and use the same shared Slack c
|
||||
- `groups:read` — list private channels the bot is in
|
||||
- `reactions:write` — add reactions
|
||||
|
||||
If you **add or remove** scopes in that file or in the Slack app, existing
|
||||
If you **add or remove** scopes for this app or in the Slack app, existing
|
||||
installs must **re-authorize** (disconnect and **Add connection** again, or
|
||||
reinstall the Slack app to the workspace) so the token picks up new scopes.
|
||||
|
||||
@@ -110,17 +107,4 @@ Both routes require an authenticated Twenty user and use the same shared Slack c
|
||||
|
||||
Once connected, workflow steps use the connection access token: a
|
||||
**workspace** connection is preferred when present; otherwise the first
|
||||
connection returned for the Slack provider is used (see
|
||||
`src/logic-functions/utils/get-slack-connection.ts`).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-slack
|
||||
yarn install
|
||||
yarn lint
|
||||
yarn test
|
||||
```
|
||||
|
||||
Use `yarn twenty dev` from this directory to develop against a local Twenty
|
||||
instance (see other internal apps in this monorepo).
|
||||
connection returned for the Slack provider is used.
|
||||
|
||||
@@ -13,8 +13,6 @@ export default defineApplication({
|
||||
logoUrl: 'public/twenty-slack.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Communication',
|
||||
aboutDescription:
|
||||
'Official Slack connector for Twenty CRM. Install a Slack app on api.slack.com, add the OAuth client ID and secret as server variables, then connect Slack per member or as a shared workspace connection. Use workflow actions to post, update, or delete messages, send ephemeral notes, and add reactions using the connected account.',
|
||||
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
FUNCTION_EXECUTE_APP_PATH,
|
||||
MINIMAL_APP_PATH,
|
||||
} from '@/cli/__tests__/apps/fixture-paths';
|
||||
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
|
||||
|
||||
describe('buildManifest aboutDescription from README', () => {
|
||||
it('populates application.aboutDescription from the app README when not set in config', async () => {
|
||||
const { manifest, errors } = await buildManifest(FUNCTION_EXECUTE_APP_PATH);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const readme = await readFile(
|
||||
join(FUNCTION_EXECUTE_APP_PATH, 'README.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(manifest?.application.aboutDescription).toBe(readme);
|
||||
}, 60000);
|
||||
|
||||
it('leaves application.aboutDescription undefined when the app has no README', async () => {
|
||||
const { manifest, errors } = await buildManifest(MINIMAL_APP_PATH);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.application.aboutDescription).toBeUndefined();
|
||||
}, 60000);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-
|
||||
import { type RoleConfig } from '@/sdk/define/roles/role-config';
|
||||
import { type ViewConfig } from '@/sdk/define/views/view-config';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, extname, relative } from 'path';
|
||||
import { basename, extname, join, relative } from 'path';
|
||||
import { glob } from 'tinyglobby';
|
||||
import {
|
||||
type AgentManifest,
|
||||
@@ -72,6 +72,16 @@ const loadAssets = async (appPath: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const loadReadme = async (appPath: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const content = await readFile(join(appPath, 'README.md'), 'utf-8');
|
||||
|
||||
return content.trim().length > 0 ? content : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildManifest = async (
|
||||
appPath: string,
|
||||
): Promise<{
|
||||
@@ -81,6 +91,7 @@ export const buildManifest = async (
|
||||
warnings: string[];
|
||||
}> => {
|
||||
const filePaths = await loadSources(appPath);
|
||||
const readmeContent = await loadReadme(appPath);
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -553,6 +564,7 @@ export const buildManifest = async (
|
||||
...applicationConfig,
|
||||
defaultRoleUniversalIdentifier:
|
||||
resolvedDefaultRoleUniversalIdentifier,
|
||||
aboutDescription: readmeContent,
|
||||
yarnLockChecksum: null,
|
||||
packageJsonChecksum: null,
|
||||
...(postInstallLogicFunctions.length >= 1
|
||||
|
||||
@@ -7,6 +7,7 @@ export type ApplicationConfig = Omit<
|
||||
| 'postInstallLogicFunction'
|
||||
| 'preInstallLogicFunction'
|
||||
| 'defaultRoleUniversalIdentifier'
|
||||
| 'aboutDescription'
|
||||
> & {
|
||||
/**
|
||||
* @deprecated Use `defineApplicationRole()` in your role file instead.
|
||||
|
||||
+2
-19
@@ -42,27 +42,10 @@ export class MarketplaceCatalogSyncService {
|
||||
const universalIdentifier =
|
||||
fetchedManifest.application.universalIdentifier;
|
||||
|
||||
const aboutDescription =
|
||||
fetchedManifest.application.aboutDescription ??
|
||||
(await this.marketplaceService.fetchReadmeFromRegistryCdn(
|
||||
pkg.name,
|
||||
pkg.version,
|
||||
));
|
||||
|
||||
const manifest = aboutDescription
|
||||
? {
|
||||
...fetchedManifest,
|
||||
application: {
|
||||
...fetchedManifest.application,
|
||||
aboutDescription,
|
||||
},
|
||||
}
|
||||
: fetchedManifest;
|
||||
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
fetchedManifest,
|
||||
(filePath) =>
|
||||
buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
@@ -74,7 +57,7 @@ export class MarketplaceCatalogSyncService {
|
||||
|
||||
await this.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier,
|
||||
name: manifest.application.displayName ?? pkg.name,
|
||||
name: fetchedManifest.application.displayName ?? pkg.name,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: pkg.name,
|
||||
latestAvailableVersion: pkg.version ?? null,
|
||||
|
||||
-33
@@ -73,39 +73,6 @@ export class MarketplaceService {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchReadmeFromRegistryCdn(
|
||||
packageName: string,
|
||||
version: string,
|
||||
): Promise<string | null> {
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
const url = buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
packageName,
|
||||
version,
|
||||
filePath: 'README.md',
|
||||
});
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(url, {
|
||||
headers: { 'User-Agent': 'Twenty-Marketplace' },
|
||||
timeout: 5_000,
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
if (!data || data.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
`Could not fetch README from CDN for ${packageName}@${version}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAppsFromRegistry(): Promise<RegistryPackageInfo[]> {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user