snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
---
title: Фоновые задания
description: Передавайте длительные или ограниченные по скорости задачи воркерам Twenty, помещая в очередь ещё один запуск логической функции вместо выполнения всего прямо в текущем процессе.
icon: layer-group
---
Запуск логической функции ограничен значением `timeoutSeconds` (максимум 900 секунд). Любая задача, которая не может завершиться за это время — полная повторная синхронизация, разветвление по каждой записи, сторонний API, который ограничивает вас по скорости, — должна быть разделена на более мелкие запуски.
`enqueueJobs` делает именно это: он просит воркеров Twenty позже запустить одну из логических функций вашего приложения, по одному разу для каждой полезной нагрузки, каждый запуск в отдельном процессе с собственным лимитом времени. Вызов возвращается немедленно.
```text
┌─────────────────┐ enqueueJobs(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## Постановка запусков в очередь
Импортируйте `enqueueJobs` из `twenty-sdk/logic-function`, укажите `universalIdentifier` логической функции для запуска и передайте в очередь по одной полезной нагрузке для каждого запуска.
```ts src/logic-functions/sync-all-contacts.ts
import { enqueueJobs } from 'twenty-sdk/logic-function';
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payloads: [{ page: 1 }],
});
```
Каждый запуск получает свою полезную нагрузку в качестве аргумента обработчика, точно так же, как любой другой триггер. Целевая функция должна принадлежать **тому же приложению**, что и вызывающая сторона — постановка в очередь функции другого приложения будет отклонена с ошибкой `Logic function not found`, и ничего не будет поставлено в очередь. Один вызов принимает до `200` полезных нагрузок.
<Note>
`enqueueJobs` возвращает управление, как только задания приняты, а не когда они выполнены. Он не возвращает результаты целевых функций — если вам нужно потом их прочитать, пусть каждая целевая функция запишет то, что она создаёт, в [key-value store](/l/ru/developers/extend/apps/logic/key-value-store) или в запись рабочего пространства.
</Note>
<Note>
Старый помощник `enqueueJob`, который добавляет в очередь одно задание за вызов, устарел. Вместо этого используйте `enqueueJobs` со списком `payloads`, состоящим из одного элемента.
</Note>
## Параметры задания
Параметры применяются к каждому запуску в пакете.
| Вариант | По умолчанию | Диапазон | Что делает |
| ------------ | ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `retryLimit` | `0` | `0`–`10` | Общее количество дополнительных попыток постановки в очередь. Количество повторных попыток, запрошенных приложением, ограничено значением `3`. Увеличивайте это значение только для обработчиков, которые безопасно запускать дважды. |
| `delayMs` | `0` | `0`–`604800000` (7 дней) | Сколько ждать, прежде чем запуски станут допустимыми к выполнению. |
```ts
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payloads: [{ page: 1 }],
retryLimit: 3,
delayMs: 60_000,
});
```
<Note>
**Приоритет пока нельзя настраивать.** Задания в очереди всегда выполняются с самым низким приоритетом, поэтому работа платформы никогда не откладывается из‑за заданий приложений. Возможность управлять приоритетом появится скоро.
</Note>
Запуск в очереди наследует действующего пользователя функции, которая его поставила в очередь, поэтому он работает с теми же правами.
## Повторная попытка при временном сбое
Twenty не выполняет повторную попытку при каждом исключении из кода приложения. Обычная вызванная ошибка считается постоянным сбоем. При временном сбое логическая функция в очереди может запросить до трёх повторных попыток, вызвав `RetryableLogicFunctionError`.
```ts
import {
type LogicFunctionExecutionContext,
RetryableLogicFunctionError,
} from 'twenty-sdk/logic-function';
export const handler = async (
_payload: unknown,
{ retryCount, maxRetries }: LogicFunctionExecutionContext,
) => {
const response = await fetch('https://api.example.com/contacts');
if (response.status === 429 || response.status >= 500) {
throw new RetryableLogicFunctionError(
`The contacts API is temporarily unavailable (${response.status}); retry ${retryCount} of ${maxRetries}`,
);
}
};
```
По возможности вызывайте `RetryableLogicFunctionError` напрямую. Если вы расширяете его, не заменяйте его `name`: Twenty распознаёт сериализованное имя `RetryableLogicFunctionError` во всех средах выполнения.
`retryCount` равен `0` при первоначальном выполнении и увеличивается, только когда код приложения запрашивает повторную попытку. `maxRetries` не превышает `3` и может быть меньше, если у задания в очереди меньше общий лимит повторных попыток. Сбои платформы не увеличивают `retryCount`, хотя всё равно расходуют общий резерв безопасности очереди.
Очередь задерживает повторные попытки с экспоненциальной задержкой и случайным разбросом. Точная задержка намеренно не гарантируется, поэтому код приложения не должен зависеть от выполнения повторной попытки в точно определённое время. После достижения `maxRetries` ещё один `RetryableLogicFunctionError` фиксируется как окончательный сбой приложения без нового выполнения.
<Warning>
Повторные попытки заново выполняют весь обработчик и могут происходить после успешного выполнения некоторых побочных эффектов. Сделайте обработчик идемпотентным, прежде чем запрашивать повторные попытки.
</Warning>
## Использование: постраничный проход по длинной синхронизации
Классический шаблон — функция, которая ставит *саму себя* в очередь со следующим курсором. Каждый запуск обрабатывает одну страницу работы, уверенно укладываясь в собственный таймаут, а цепочка останавливается, когда больше нечего делать.
```ts src/logic-functions/sync-contacts-page.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs } from 'twenty-sdk/logic-function';
const SYNC_CONTACTS_PAGE = '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33';
const handler = async (params: { cursor?: string }) => {
const { contacts, nextCursor } = await fetchContactsPage(params.cursor);
await importContacts(contacts);
if (nextCursor) {
await enqueueJobs({
logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE,
payloads: [{ cursor: nextCursor }],
delayMs: 2_000,
});
}
return { imported: contacts.length, done: !nextCursor };
};
export default defineLogicFunction({
universalIdentifier: SYNC_CONTACTS_PAGE,
name: 'sync-contacts-page',
timeoutSeconds: 120,
handler,
});
```
## Разветвление по каждой записи
Когда работа естественным образом выполняется по отдельным элементам, добавляйте в очередь по одному заданию на элемент за один вызов и позвольте воркерам обрабатывать их параллельно, вместо того чтобы перебирать элементы в цикле непосредственно в коде.
```ts
const companies = await listCompaniesToEnrich();
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
payloads: companies.map((company) => ({ companyId: company.id })),
retryLimit: 2,
});
```
## Рекомендации для длительной работы
Два правила покрывают почти любое долгое задание: **рекурсивный вызов вместо цикла** и **обработка ограниченного блока за один запуск**.
Запуск, который пытается сделать всё сразу, — это сценарий отказа: он упирается в таймаут и при повторной попытке начинает всё сначала с нуля. Вместо этого выберите размер блока так, чтобы он с запасом завершался в пределах `timeoutSeconds`, сохраните свою позицию и поставьте в очередь следующий запуск.
```ts src/logic-functions/enrich-companies-batch.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs, kv } from 'twenty-sdk/logic-function';
const ENRICH_COMPANIES_BATCH = '3f9d1c02-8a44-4f0e-b1d7-9c2e5a7b4f10';
const CHUNK_SIZE = 50;
const handler = async (params: { offset?: number }) => {
const offset = params.offset ?? 0;
const companies = await listCompaniesToEnrich({
offset,
limit: CHUNK_SIZE,
});
for (const company of companies) {
await enrichCompany(company);
}
await kv.set('enrich:progress', { offset: offset + companies.length });
if (companies.length === CHUNK_SIZE) {
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH,
payloads: [{ offset: offset + CHUNK_SIZE }],
});
}
return { processed: companies.length, done: companies.length < CHUNK_SIZE };
};
export default defineLogicFunction({
universalIdentifier: ENRICH_COMPANIES_BATCH,
name: 'enrich-companies-batch',
timeoutSeconds: 300,
handler,
});
```
Почему это работает:
* **Размер блока выбирайте, исходя из самого медленного элемента, а не среднего.** `CHUNK_SIZE × время обработки в худшем случае` должно помещаться в `timeoutSeconds` с запасом, иначе хвост блока будет потерян, когда запуск обрежет таймаут.
* **Сделайте условие завершения явным.** Вызывайте рекурсивно только пока вернулся полный блок. Цепочка, которая останавливается только на основе «нет результатов», будет продолжаться бесконечно, если источник когда‑либо вернёт укороченную страницу посередине.
* **Сохраняйте прогресс перед постановкой в очередь следующего запуска**, чтобы при сбое звена цепочки она возобновлялась с последнего завершённого блока, а не с начала.
* **Сделайте каждый блок идемпотентным.** Повторная обработка одного блока после повторной попытки не должна приводить к двойной записи — привязывайте операции записи к идентификатору обрабатываемой записи или к внешнему идентификатору.
* **Отдавайте предпочтение цепочке из блоков перед одним гигантским разветвлением**, когда работа обращается к внешнему сервису с ограничением по скорости: цепочка с `delayMs` саморегулирует темп, тогда как тысячи заданий, поставленных в очередь одновременно, сразу становятся допустимыми к выполнению.

View File

@@ -0,0 +1,269 @@
---
title: Подключения
description: Разрешите вашему приложению действовать от имени пользователя в сторонних сервисах с помощью OAuth.
icon: plug
---
Подключения — это учетные данные, которыми пользователь располагает для внешнего сервиса (Linear, GitHub, Slack, ...). Ваше приложение определяет, **как** получают эти учетные данные — через **провайдера подключения** — и использует их во время выполнения для выполнения аутентифицированных вызовов к стороннему API.
На данный момент поддерживается только OAuth 2.0. Будущие типы учетных данных (персональные токены доступа, ключи API, базовая аутентификация) будут подключаться к тому же интерфейсу — приложения, уже использующие `defineConnectionProvider({ type: 'oauth', ... })` не потребуют миграции.
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="Определите, как в вашем приложении получаются подключения">
Провайдер подключения описывает процедуру OAuth-обмена, которая требуется вашему приложению. Пользователь нажимает "Добавить подключение" в настройках вашего приложения, подтверждает разрешения на экране согласия провайдера, и в его рабочем пространстве создается запись `ConnectedAccount`.
Рабочей конфигурации нужны **два файла** — провайдер подключения и соответствующее объявление `serverVariables` в `defineApplication`, которое содержит учетные данные клиента OAuth.
```ts src/connection-providers/linear-connection.ts
import { defineConnectionProvider } from 'twenty-sdk/define';
export default defineConnectionProvider({
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
name: 'linear',
displayName: 'Linear',
icon: 'IconBrandLinear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
// These must match keys in `defineApplication.serverVariables` below.
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
// 'form-urlencoded' for the token request.
tokenRequestContentType: 'form-urlencoded',
// Optional: defaults to true. Disable only if the provider rejects PKCE.
usePkce: false,
// Optional: extra query params on the authorize URL.
// authorizationParams: { prompt: 'consent' },
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
// revokeEndpoint: 'https://example.com/oauth/revoke',
},
// Optional: a logic function in this app to run right after a connection is
// established. See "Run a logic function on connect".
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
// Optional: a logic function in this app to run right after a connection is
// removed. See "Run a logic function on disconnect".
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
});
```
```ts src/application.config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
displayName: 'Linear',
description: 'Connect Linear to Twenty.',
// OAuth client credentials live on the app registration (one OAuth app per
// Twenty server, configured by the admin) — not per-workspace. Declare them
// as serverVariables so the admin can fill them in once for all installs.
serverVariables: {
LINEAR_CLIENT_ID: {
description: 'OAuth client ID from your Linear OAuth application.',
isSecret: false,
isRequired: true,
},
LINEAR_CLIENT_SECRET: {
description: 'OAuth client secret from your Linear OAuth application.',
isSecret: true,
isRequired: true,
},
},
});
```
Основные моменты:
* `name` — это уникальная строка-идентификатор, используемая в `listConnections({ providerName })` (kebab-case, должна соответствовать `^[a-z][a-z0-9-]*$`).
* `displayName` отображается на вкладке настроек приложения и в списке инструментов ИИ.
* `clientIdVariable` / `clientSecretVariable` — это **имена**, а не значения — они должны совпадать с ключами, объявленными в `defineApplication.serverVariables`. Фактические `client_id` и `client_secret` вводятся администратором сервера через интерфейс регистрации приложения и никогда не коммитятся в ваш репозиторий.
* Используйте `serverVariables` (не `applicationVariables`) — учетные данные OAuth являются общими для сервера, и на каждом сервере Twenty используется одно приложение OAuth.
* Пока оба `serverVariables` не заполнены, на вкладке настроек приложения показывается подсказка "нужен администратор сервера", а кнопка "Добавить подключение" отключена.
* `type: 'oauth'` — единственное поддерживаемое сегодня значение. Дискриминатор совместим с будущими версиями: будущие типы (`'pat'`, `'api-key'`, ...) добавят новые блоки подконфигурации рядом с `oauth`.
URL обратного вызова OAuth, который вашему провайдеру нужно добавить в список разрешенных:
```
https://<your-twenty-server>/auth/apps/callback
```
</Accordion>
<Accordion title="Запускайте логическую функцию при подключении" description="Реагируйте в момент установления соединения">
Некоторые провайдеры передают вам данные в момент подключения, которые нужно сохранить до того, как соединение станет пригодным к использованию — классический пример — Slack, где OAuth-ответ указывает `team_id` рабочей области, по которому будут определяться входящие события. Установите `onConnectLogicFunction`, чтобы сослаться на логическую функцию в том же приложении (по её `universalIdentifier`), и она будет выполнена сразу после создания `ConnectedAccount`.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs claimSlackTeam after every successful Slack connection.
onConnectLogicFunction: {
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
},
});
```
Хук выполняется **асинхронно в подключающейся рабочей области** (он ставится в очередь, а не ожидается), поэтому медленный или падающий хук никогда не блокирует и не ломает OAuth-callback — сделайте его идемпотентным и обеспечьте собственную обработку повторных попыток. Обработчик получает:
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
Оттуда используйте `getConnection(connectedAccountId)`, чтобы прочитать новый access token и вызвать API провайдера (например, Slack `auth.test`) или сохранить отображение с помощью [key-value store](/l/ru/developers/extend/apps/logic/key-value-store).
</Accordion>
<Accordion title="Запускайте логическую функцию при отключении" description="Выполняйте очистку при удалении подключения">
Всё, на что приложение заявляет права при подключении, должно быть освобождено, когда подключение прекращается. Интеграция со Slack, которая, например, заявляет `team_id` при подключении, должна освободить это право, чтобы другое рабочее пространство могло подключить ту же Slack-команду. Установите `onDisconnectLogicFunction`, чтобы сослаться на логическую функцию в том же приложении, и она будет выполнена сразу после удаления `ConnectedAccount`.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs releaseSlackTeam after every Slack disconnection.
onDisconnectLogicFunction: {
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
},
});
```
Как и hook on-connect, он запускается **асинхронно в отключаемом рабочем пространстве** и никогда не блокирует отключение. Обработчик получает тот же формат полезной нагрузки:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
`ConnectedAccount` уже удалён к моменту запуска hook, поэтому `getConnection(connectedAccountId)` больше не возвращает результат. Всё, что требуется для очистки (например, `team_id`, внешний идентификатор подписки), должно быть записано в [key-value store](/l/ru/developers/extend/apps/logic/key-value-store) в момент подключения, с ключом `connectedAccountId`.
Hook срабатывает, когда соединение удаляется само по себе. При удалении приложения его соединения удаляются каскадом на уровне базы данных, поэтому hook в этом случае не запускается. Объявите `uninstallLogicFunction` в `defineApplication` для этого сценария: она запускается до удаления метаданных приложения, поэтому всё ещё может вызвать `listConnections` и выполнить оставшуюся очистку.
</Accordion>
<Accordion title="listConnections / getConnection" description="Используйте подключения из логической функции">
Внутри обработчика логической функции `listConnections({ providerName })` возвращает записи `ConnectedAccount` этого приложения для указанного провайдера с обновленными токенами доступа.
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
import { listConnections } from 'twenty-sdk/logic-function';
export const createLinearIssueHandler = async (input: {
teamId?: string;
title?: string;
}) => {
if (!input.teamId || !input.title) {
return { success: false, error: 'teamId and title are required' };
}
const connections = await listConnections({ providerName: 'linear' });
// Workspace-shared credentials win when present; fall back to the first
// user-visibility one. For HTTP-route triggers you typically pick the
// request user's connection via event.userWorkspaceId instead.
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection".',
};
}
// Use connection.accessToken to call the third-party API.
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
}),
});
return { success: response.ok };
};
```
Каждое подключение имеет:
| Поле | Описание |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Уникальный идентификатор записи; передайте его в `getConnection(id)`, чтобы повторно получить одну запись |
| `visibility` | `'user'` (приватно для одного участника рабочего пространства) или `'workspace'` (доступно всем участникам) |
| `scopes` | Разрешения OAuth, предоставленные внешним провайдером (отличаются от `visibility` — это несвязанные вещи) |
| `userWorkspaceId` | Идентификатор userWorkspace владельца — полезно для выбора "подключения пользователя запроса" в триггерах HTTP-маршрутов |
| `accessToken` | Актуальный токен доступа OAuth (обновляется автоматически при истечении срока действия) |
| `name` | Отображаемое имя подключения (автоматически определяется при обратном вызове OAuth, может быть переименовано пользователем) |
| «Обработка» | Подключенная почта учётной записи в потоке, обновляется при каждом переподключении; решил из OIDC `id_token` возвратился при обмене токенов, возвращаясь обратно к подключению электронной почты Twenty пользователей, когда провайдер не возвращает одного |
| `authFailedAt` | Устанавливается, если последняя попытка обновления не удалась; пользователю нужно переподключиться |
Основные моменты:
* Передайте `{ providerName }`, чтобы отфильтровать по провайдеру; опустите, чтобы получить все подключения этого приложения у всех провайдеров.
* Сервер прозрачно обновляет токен доступа перед возвратом. Ваш обработчик всегда получает рабочий токен (или установлено `authFailedAt`).
* `getConnection(id)` — эквивалент для одной записи.
</Accordion>
<Accordion title="Индивидуальная и общая для рабочего пространства видимость" description="Как пользователи выбирают между приватными и общими учетными данными">
Когда пользователь нажимает "Добавить подключение", ему предлагается выбрать видимость:
* **Только для меня** — учетные данные приватны для подключившегося пользователя. Любая логическая функция, вызываемая от его имени (триггер HTTP-маршрута с `isAuthRequired: true`), видит их; триггеры cron и события базы данных — нет.
* **Общее для рабочего пространства** — любой участник рабочего пространства может использовать эти учетные данные. Триггеры cron/базы данных также видят их, поскольку у них нет пользователя запроса.
Используйте подходящий вариант для каждого обработчика:
```ts
// HTTP-route trigger — prefer the request user's own connection.
const conn =
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
connections.find((c) => c.visibility === 'workspace');
// Cron trigger — no request user; only shared credentials are sensible.
const conn = connections.find((c) => c.visibility === 'workspace');
```
Допускается несколько подключений на пару (пользователь, провайдер), поэтому один и тот же пользователь может иметь "Personal Linear" и "Work Linear" одновременно.
</Accordion>
<Accordion title="Единоразовая настройка провайдера" description="Зарегистрируйте свое приложение OAuth у стороннего сервиса">
Для каждого провайдера подключения администратору сервера сначала нужно зарегистрировать у стороннего сервиса приложение OAuth.
1. Перейдите в настройки разработчика провайдера (например, https://linear.app/settings/api/applications/new).
2. Установите **Redirect URI** в значение `\<SERVER_URL>/auth/apps/callback`.
3. Скопируйте сгенерированные **Client ID** и **Client Secret**.
4. Откройте установленное приложение в Twenty под учетной записью администратора сервера → задайте значения в соответствующих `serverVariables`.
5. Затем участники рабочего пространства смогут добавлять подключения в разделе **Подключения** конкретного приложения.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,110 @@
---
title: Хранилище ключ-значение
description: Сохраняйте промежуточные результаты, кэшируйте данные и разделяйте состояние между запусками логических функций с помощью встроенного в приложение хранилища ключ-значение.
icon: database
---
Логические функции выполняются в изолированной среде во временных процессах Node.js — после завершения запуска в памяти ничего не сохраняется. Когда вам нужно **что-то запомнить между запусками** (кэшировать дорогой ответ API, сохранить курсор для инкрементальной синхронизации, «задебаунсить» работу или передать состояние от одной функции к другой), сохраняйте это во встроенном хранилище ключ-значение.
Каждое приложение получает собственное изолированное пространство имён: записи привязаны к аутентифицированному приложению, поэтому ваши ключи никогда не могут конфликтовать с ключами другого приложения или быть прочитаны другим приложением.
```text
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
│ Logic function │ ─────────────────────▶ │ Application KV store │
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
└─────────────────┘ kv.get(key) └──────────────────────────┘
```
## Получение, установка, удаление
Импортируйте `kv` из `twenty-sdk/logic-function`. Значения могут быть любыми JSON-сериализуемыми данными.
```ts src/logic-functions/sync-linear-issues.ts
import { kv } from 'twenty-sdk/logic-function';
// Read a value. Returns null when the key is missing.
const cursor = await kv.get<string>('sync-cursor:linear');
// Write a value. Creates the entry on first write, updates it afterwards.
await kv.set('sync-cursor:linear', newCursor);
// Delete an entry. Returns true when an entry was removed.
await kv.delete('sync-cursor:linear');
```
## Области действия
У каждой записи есть область видимости (scope), которая передаётся как опция при каждом вызове. По умолчанию используется `WORKSPACE`.
* **`WORKSPACE`** (по умолчанию) — запись доступна только для текущей установки вашего приложения в рабочем пространстве. Каждое рабочее пространство, в которое устанавливается приложение, получает собственный независимый набор ключей. Это то, что вам нужно для кэшей, курсоров и состояния на уровне рабочего пространства.
* **`SERVER`** — запись разделяется между **всеми установками** вашего приложения на сервере. Серверные записи ведут себя как механизм резервирования: сохраняемым значением всегда является workspaceId, зарезервировавший ключ (опустите `value` при вызове `set`, чтобы зарезервировать ключ для текущего рабочего пространства), и только это рабочее пространство может перезаписать или удалить запись. Любая установка может прочитать эту запись.
Резервирования на сервере используются для маршрутизации между рабочими пространствами. [Резолвер серверного маршрута](/l/ru/developers/extend/apps/logic/logic-functions#server-route-trigger) выполняется в рабочем пространстве владельца регистрации приложения, но входящий вебхук обычно содержит только внешний идентификатор аккаунта, а не workspaceId в Twenty. Пусть каждое рабочее пространство зарезервирует свой внешний идентификатор в момент подключения, а затем разрешайте его в маршруте:
```ts
// In the connected workspace, when the external account is linked:
await kv.set(`slack:team:${teamId}`, undefined, { scope: 'SERVER' });
// In the server-route resolver (owner workspace), on each webhook:
const workspaceId = await kv.get<string>(`slack:team:${teamId}`, {
scope: 'SERVER',
});
```
Поскольку серверный ключ может быть занят только для собственного рабочего пространства вызывающего кода и никогда не может быть перезаписан другим, рабочее пространство не может перехватить сопоставление, принадлежащее кому-то ещё. `kv.set` выбрасывает ошибку, если ключ уже занят другим рабочим пространством.
## Использование: кэширование дорогого вызова
Типичный сценарий — кэширование медленного или ограниченного по частоте ответа стороннего сервиса, чтобы при повторных запусках переиспользовать его, а не нести затраты каждый раз.
```ts src/logic-functions/getExchangeRate.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
const ONE_HOUR_MS = 60 * 60 * 1000;
type CachedRate = { rate: number; fetchedAt: number };
const handler = async (params: { from: string; to: string }) => {
const cacheKey = `cache:exchange-rate:${params.from}:${params.to}`;
const cached = await kv.get<CachedRate>(cacheKey);
if (cached && Date.now() - cached.fetchedAt < ONE_HOUR_MS) {
return { rate: cached.rate, cached: true };
}
const response = await fetch(
`https://api.example.com/rate?from=${params.from}&to=${params.to}`,
);
const { rate } = (await response.json()) as { rate: number };
await kv.set(cacheKey, { rate, fetchedAt: Date.now() });
return { rate, cached: false };
};
export default defineLogicFunction({
universalIdentifier: 'd9b2f4e6-1c83-4a07-9e52-6b1d3c8a0f47',
name: 'get-exchange-rate',
timeoutSeconds: 10,
handler,
});
```
## Шаблоны и советы
* **Пространства имён.** Добавляйте префиксы к ключам, чтобы разделять разные задачи — `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`.
* **Срок жизни (TTL).** У хранилища нет встроенного механизма истечения срока действия. Сохраняйте метку времени внутри значения (как в примере с кэшем) и проверяйте её при чтении или очищайте устаревшие ключи из [функции, запускаемой по cron](/l/ru/developers/extend/apps/logic/logic-functions).
* **Что хранить.** Любое JSON-сериализуемое значение — числа, строки, массивы, объекты. Держите записи небольшими; это для координации и кэширования, а не для больших блобов или файлов. Для файлов используйте поле `FILES` и [`uploadFile`](/l/ru/developers/extend/apps/logic/logic-functions#uploading-files).
* **Видимость.** Записи хранятся в базе данных экземпляра, а не как записи рабочего пространства — они никогда не отображаются в интерфейсе рабочего пространства, не являются частью модели данных вашего приложения и не требуют ролей или прав доступа к объектам.
## Альтернатива: объект-хранилище с возможностью запросов
Встроенное хранилище намеренно сделано непрозрачным: записи не являются записями объектов, поэтому вы не можете просматривать их в интерфейсе, связывать их с другими объектами или фильтровать их с помощью запросов к записям. Когда вам нужно что-то из этого — например, видимый журнал синхронизаций или состояние на уровне записи, — вместо этого определите небольшой **технический объект** с уникальным полем `key` и полем `value` типа `RAW_JSON`, и выполняйте к нему запросы через [типизированный API-клиент](/l/ru/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk). См. [Objects](/l/ru/developers/extend/apps/data/objects) для справки по `defineObject` и [Data → Unique indexes](/l/ru/developers/extend/apps/data/overview#unique-indexes) по обеспечению уникальности ключей.
* **Привязка к записи.** Добавьте [relation](/l/ru/developers/extend/apps/data/relations) от объекта-хранилища к целевому объекту вместо кодирования идентификатора в ключе.
* **Видимость и разрешения.** Строки живут в базе данных рабочего пространства как любые другие записи, поэтому к ним можно обращаться через API, и они подчиняются [ролям](/l/ru/developers/extend/apps/config/roles) вашего приложения. Чтобы скрыть хранилище из основного пользовательского интерфейса, не добавляйте его в [навигационное меню](/l/ru/developers/extend/apps/layout/navigation-menu-items).
<Note>
В отличие от встроенного хранилища, пользовательский объект всегда ограничен одним рабочим пространством — он не может разделять записи между установками так, как это делают ключи `SERVER`.
</Note>

View File

@@ -0,0 +1,908 @@
---
title: Логические функции
description: Определяйте серверные функции на TypeScript с триггерами HTTP, cron и событиями базы данных.
icon: bolt
---
Функции логики — это серверные функции на TypeScript, которые выполняются на платформе Twenty. Их можно запускать HTTP-запросами, расписаниями cron или событиями базы данных — а также предоставлять как инструменты для ИИ-агентов.
<AccordionGroup>
<Accordion title="defineLogicFunction" description="Определяйте логические функции и их триггеры">
Каждый файл функции использует `defineLogicFunction()` для экспорта конфигурации с обработчиком и необязательными триггерами.
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const body = (params.body ?? {}) as { name?: string };
const name = body.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world';
const result = await client.mutation({
createPostCard: {
__args: { data: { name } },
id: true,
name: true,
},
});
return result;
};
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'POST',
isAuthRequired: true,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
},*/
/*cronTriggerSettings: {
pattern: '0 0 1 1 *',
},*/
});
```
Доступные типы триггеров:
* **httpRoute**: Публикует вашу функцию по HTTP-пути и методу. В коде приложения добавляйте префикс `/s/` к пути маршрута при использовании `RestApiClient`; развернутый URL использует внедренную базовую часть `TWENTY_FUNCTIONS_URL` (или `\<server-url>/s`, если она не задана).
<Note>
Чтобы вызвать логическую функцию, запускаемую маршрутом, из фронтенд-компонента (без интерфейса), см. раздел [Вызов логической функции](/l/ru/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron**: Запускает вашу функцию по расписанию с использованием выражения CRON.
* **databaseEvent**: Запускается при событиях жизненного цикла объектов рабочего пространства. Когда операция события — `updated`, можно указать конкретные поля для отслеживания в массиве `updatedFields`. Если оставить не заданным или пустым, любое обновление будет вызывать функцию.
> например, `person.updated`, `*.created`, `company.*`
* **serverRoute**: открывает один HTTP-маршрут в области регистрации. Функция-резолвер (объявленная с помощью `serverRouteTriggerSettings`) выполняется в рабочем пространстве-владельце и либо возвращает синхронный `Response`, либо целевое рабочее пространство И логическую функцию для постановки в очередь; в случае постановки в очередь платформа отправляет подтверждение с кодом `202` и запускает эту целевую функцию в очереди worker. См. [триггер серверного маршрута](#server-route-trigger).
<Note>
Вы также можете вручную выполнить функцию с помощью CLI:
```bash filename="Terminal"
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
```
```bash filename="Terminal"
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
Вы можете просматривать логи с помощью:
```bash filename="Terminal"
yarn twenty dev:function:logs
```
</Note>
#### Полезная нагрузка триггера маршрута
Когда триггер маршрута вызывает вашу логическую функцию, она получает объект `RoutePayload`, который соответствует [формату AWS HTTP API v2](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
Импортируйте тип `RoutePayload` из `twenty-sdk/logic-function`:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
const { method, path } = event.requestContext.http;
return { message: 'Success' };
};
```
Тип `RoutePayload` имеет следующую структуру:
| Свойство | Тип | Описание | Пример |
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP-заголовки (только перечисленные в `forwardedRequestHeaders`) | см. раздел ниже |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Параметры строки запроса (несколько значений объединяются запятыми) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Параметры пути, извлечённые из шаблона маршрута | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Разобранное тело запроса (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Исходное тело запроса в кодировке UTF-8, до разбора JSON. Полезно для проверки подписей вебхуков в стиле HMAC (например, `X-Hub-Signature-256` от GitHub, Stripe). `undefined`, если среда выполнения не сохранила его. | |
| `isBase64Encoded` | `boolean` | Является ли тело закодированным в base64 | |
| `requestContext.http.method` | `string` | Метод HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Необработанный путь запроса | |
#### forwardedRequestHeaders
По умолчанию HTTP-заголовки из входящих запросов **не** передаются в вашу логическую функцию по соображениям безопасности.
Чтобы получить доступ к определённым заголовкам, перечислите их в массиве `forwardedRequestHeaders`:
```ts
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'webhook-handler',
handler,
httpRouteTriggerSettings: {
path: '/webhook',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
},
});
```
В обработчике обращайтесь к переданным заголовкам следующим образом:
```ts
const handler = async (event: RoutePayload) => {
const signature = event.headers['x-webhook-signature'];
const contentType = event.headers['content-type'];
// Validate webhook signature...
return { received: true };
};
```
<Note>
Имена заголовков приводятся к нижнему регистру. Обращайтесь к ним, используя ключи в нижнем регистре (например, `event.headers['content-type']`).
</Note>
#### Пользовательский HTTP-ответ
По умолчанию возврат простого значения из обработчика отправляет его обратно как ответ `200` (JSON для объектов, `text/plain` для строк). Чтобы управлять статус-кодом и заголовками ответа, верните `Response` из `twenty-sdk/logic-function`:
```ts
import { Response } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
return new Response('<h1>Hello</h1>', {
status: 201,
headers: { 'content-type': 'text/html' },
});
};
```
По соображениям безопасности заголовки ответа ограничены списком разрешенных заголовков. Любой заголовок, которого нет в этом списке (например, `Set-Cookie`, CORS-заголовки, такие как `Access-Control-Allow-Origin`, или пользовательские заголовки `X-*`), молчаливо удаляется перед отправкой ответа. Разрешенные заголовки ответа:
* `content-type`
* `content-language`
* `content-disposition`
* `cache-control`
* `retry-after`
<Note>
Код состояния должен быть допустимым кодом состояния HTTP (в диапазоне от 100 до 599). Имена заголовков ответа сравниваются без учета регистра.
</Note>
#### Ошибочные ответы платформы
Помимо собственных ответов вашего обработчика, платформа в некоторых ситуациях отвечает на запросы к маршрутам напрямую: `404`, когда маршрут или функция не существует, `403`, когда приложение остановлено, `429`, когда достигнут предел частоты выполнения, и `422`, когда `dependencies` приложения для продакшена слишком велики для установки — см. раздел [ограничения на размер зависимостей](/l/ru/developers/extend/apps/getting-started/project-structure#dependencies).
#### Триггер серверного маршрута
`httpRouteTriggerSettings` предоставляет функцию по пути `/s/` и определяет рабочее пространство из хоста запроса — это работает, когда у каждого рабочего пространства свой домен. Поставщики сторонних сервисов, однако, отправляют события всех арендаторов на **один** URL. В этом случае используйте `serverRouteTriggerSettings`.
Триггер состоит из двух частей:
1. Логическая функция-**резолвер** — объявляется с помощью `serverRouteTriggerSettings` — выполняется в вашем **рабочем пространстве-владельце** (рабочем пространстве, которому принадлежит регистрация приложения). Она анализирует входящий запрос и возвращает либо:
* `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — платформа ставит эту целевую функцию в очередь в определённом рабочем пространстве и отправляет подтверждение с кодом `202 { queued: true }`, или
* `Response` из `twenty-sdk/logic-function` — платформа возвращает этот HTTP‑ответ **синхронно** и **не** ставит целевую функцию в очередь (используйте это для challenge‑рукопожатий, таких как Slack `url_verification`).
Резолвер является единой точкой авторизации — URL содержит только идентификатор резолвера. **Это предпочтительное место для проверки подписей запросов**: резолвер выполняется до любых побочных эффектов, имеет доступ к исходным `rawBody` и переадресованным заголовкам и может отклонить запрос, не обращаясь к целевой функции.
2. **Целевая** логическая функция — обычная логическая функция на рабочее пространство — затем выполняется в определенном рабочем пространстве с полезной нагрузкой, возвращенной резолвером (или с исходной полезной нагрузкой запроса, если резолвер ее не преобразовал). Его возвращаемое значение не видит HTTP‑клиент, когда резолвер выбрал путь постановки в очередь.
```ts src/logic-functions/resolve-server-route.logic-function.ts
import { createHmac, timingSafeEqual } from 'crypto';
import { defineLogicFunction } from 'twenty-sdk/define';
import { Response, type RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the owner workspace. Verifies the request signature, picks
// which target function should handle the event, and returns the
// workspace + target the platform should dispatch to.
const handler = async (event: RoutePayload) => {
// Fail closed if the secret isn't configured — never fall back to an
// empty key, which would let any caller forge a matching signature.
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
}
const signature = event.headers['x-hub-signature-256'] ?? '';
const expected =
'sha256=' +
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('invalid signature');
}
const body = (event.body ?? {}) as {
challenge?: string;
metadata?: { twentyWorkspaceId?: string };
type?: string;
};
// Handshakes must be answered on this same response, so reply from the
// resolver instead of returning a dispatch target.
if (body.type === 'url_verification') {
return new Response({ challenge: body.challenge });
}
const workspaceId = body.metadata?.twentyWorkspaceId;
if (!workspaceId) {
throw new Error('event is not linked to a workspace');
}
return {
workspaceId,
// Route different event types to different target functions.
targetLogicFunctionUniversalIdentifier:
body.type === 'invoice.paid'
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
};
};
export default defineLogicFunction({
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'resolve-server-route',
handler,
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
},
});
```
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the resolved workspace. The resolver has already authenticated
// the request, so this handler can focus on the actual work.
const handler = async (event: RoutePayload) => {
// ...handle the verified event
return { received: true };
};
export default defineLogicFunction({
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'handle-invoice-paid',
handler,
});
```
Конечная точка доступна по адресу:
```
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
```
Идентификатор — это `universalIdentifier` резолвера из вашего манифеста. Зарегистрируйте этот URL у поставщика.
**Ответ на проверочный `GET`.** Некоторые поставщики проверяют конечную точку перед отправкой данных на неё: они отправляют `GET` с проверочным запросом на тот же URL, на который позднее будут `POST` события. Meta's WhatsApp Cloud API — один из них. Маршрут сервера отвечает только на `POST`, если не указано иное, поэтому объявите оба метода:
```ts
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
httpMethods: ['GET', 'POST'],
}
```
Проверочный запрос поступает в `event.queryStringParameters`, а возврат `Response` отправляет его обратно поставщику в рамках того же запроса. Строковое тело отправляется как `text/plain`, что и ожидают эти поставщики:
```ts
const handler = async (event: RoutePayload) => {
if (event.requestContext.http.method === 'GET') {
return new Response(event.queryStringParameters['hub.challenge']);
}
// …handle the delivered event
};
```
`httpMethods` заменяет значение по умолчанию, а не дополняет его, поэтому одного `['GET']` достаточно, чтобы маршрут отклонял `POST`. Поддерживаются только `GET` и `POST`. Не задавайте его, если провайдеру не требуется второй метод: резолвер маршрута, объявляющего `GET`, будет выполняться при обращении любого неаутентифицированного вызывающего клиента, включая поисковые роботы и средства предварительного просмотра ссылок, которые отправляют `GET` без запроса. На всё, для чего у платформы нет метода, возвращается `405`, при этом резолвер вообще не запускается.
<Note>
**Приложение должно быть закреплено и установлено в рабочем пространстве владельца.** Поскольку резолвер выполняется в **рабочем пространстве владельца** (рабочем пространстве, которому принадлежит регистрация приложения), триггер серверного маршрута будет работать только после того, как приложение будет *закреплено* — то есть у него появится рабочее пространство владельца — **и** это приложение будет **установлено в рабочем пространстве владельца**. Пока оба этих условия не выполнены, резолверу негде выполняться, поэтому маршрут не может быть отправлен на обработку. Приложение, которое предоставляет логическую функцию `serverRouteTriggerSettings`, соответственно, не может быть размещено в маркетплейсе, пока оно не будет закреплено и установлено в рабочем пространстве владельца.
</Note>
**Контракт резолвера.** Тип `LogicFunctionConfig` в SDK обеспечивает это на этапе компиляции: как только вы задаете `serverRouteTriggerSettings`, ваш обработчик обязан возвращать либо `Response`, либо `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (или `Promise` одного из этих вариантов). На пути диспетчеризации `workspaceId` должен указывать на рабочее пространство, в котором установлена целевая функция, иначе запрос будет отклонен с кодом `404`. Результат, не соответствующий ни одному из этих форматов — включая случай, когда идентификаторы не являются UUID, — отклоняется с кодом `502`.
| Поле | Тип | Заметки |
| ---------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | UUID рабочего пространства, в котором будет выполняться целевая функция. |
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` логической функции, которую нужно вызвать в этом рабочем пространстве. |
| `payload` | `object` (необязательный) | Если задан, заменяет тело запроса, отправляемое целевой функции. |
<Warning>
**Ответственность за проверку подписи лежит на вас — выполняйте проверку в резолвере.** Платформа не проверяет подписи запросов. Резолвер — рекомендуемое место для этого: он выполняется первым, имеет доступ к `event.rawBody` и заголовкам, которые вы указали в `forwardedRequestHeaders`, и выброшенная ошибка (или любой `workspaceId`, не соответствующий ожидаемому) останавливает диспетчеризацию до вызова целевой функции. Если вместо этого вы перенесете проверку в целевую функцию, целевая функция должна позаботиться о том, чтобы не потерять `rawBody` и заголовки — то есть резолвер не должен возвращать `payload`. Всегда выполняйте проверку **до** любых побочных эффектов и используйте сравнение с постоянным временем выполнения.
</Warning>
Для подписей запросов большинство провайдеров используют HMAC-SHA256; различаются имя заголовка, кодировка дайджеста и строка подписываемой полезной нагрузки. Несколько примеров:
| Провайдер | Заголовки для пересылки | Подписываемая строка | Дайджест |
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | ----------------------------------------------------------------- |
| Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (секрет в формате base64 после удаления префикса `whsec_`) |
| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex |
| GitHub | `x-hub-signature-256` | `{rawBody}` | hex (с префиксом `sha256=`) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Слэк | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (с префиксом `v0=`) |
Приведенный выше пример резолвера уже показывает поток GitHub HMAC-SHA256 — адаптируйте имя заголовка, кодировку дайджеста и строку подписываемой полезной нагрузки в соответствии с провайдером, с которым вы интегрируетесь.
<Note>
Когда резолвер возвращает объект диспетчеризации, маршрут отвечает `202 { queued: true }`, а целевая функция запускается в очереди worker — вызывающая сторона никогда не видит задержку, результат или сбои целевой функции (они записываются в журналах выполнения). Это не позволяет повторным отправкам со стороны отправителя усиливать замедление обработки, что и требуется для приёма вебхуков.
Когда вызывающей стороне нужно прочитать тело ответа в рамках того же запроса (challenge‑рукопожатия, интерактивные подтверждения), вместо этого верните `Response` из **резолвера**. Платформа синхронно эхо‑возвращает этот ответ и пропускает очередь; его заголовки проходят через тот же allow‑list, что и заголовки ответов HTTP‑маршрутов. Делайте функцию-резолвер быстрой — некоторые провайдеры (например, Slack) прерывают запрос через несколько секунд. Поскольку резолвер доступен как публичная конечная точка, защитите его с помощью ограничения частоты запросов (rate limiting) на вашем периметре (edge).
</Note>
#### Полезная нагрузка триггера события базы данных
Когда триггер события базы данных вызывает вашу функцию логики, она получает по одному `DatabaseEventPayload` на каждую изменённую запись. Полезная нагрузка объединяет метаданные о рабочем пространстве-источнике и объекте с событием на уровне записи.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
Полезная нагрузка включает:
| Свойство | Описание |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `name` | Имя события, например `person.updated`. |
| `workspaceId` | Рабочее пространство, в котором произошло событие. |
| `objectMetadata` | Метаданные для объекта, который изменился. |
| `recordId` | Идентификатор измененной записи. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Поля инициатора, если событие было вызвано пользователем рабочего пространства. |
| `properties` | Данные записи для события с `before`, `after`, `diff` и `updatedFields` в зависимости от операции. |
| Событие | Данные записи |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
При логическом удалении `.deleted` имеет формат обновления, поскольку изменяется поле `deletedAt` записи.
Для окончательного удаления используйте `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` фильтрует, какие события обновления запускают функцию.
`event.properties.updatedFields` указывает, какие поля фактически изменились в текущем событии.
</Note>
Пример события создания:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Пример события обновления:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
Триггер только при обновлении email:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Пример события уничтожения:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Контекст выполнения
Каждый обработчик получает второй аргумент, описывающий сам запуск независимо от того, что его вызвало. Если форма первого аргумента меняется в зависимости от триггера, то форма этого не меняется:
```ts
import type { LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';
const handler = async (
event: DatabaseEventPayload,
{ workspaceId, workspaceMemberId }: LogicFunctionExecutionContext,
) => {
if (workspaceMemberId === null) {
throw new Error('This function must be triggered by a person');
}
return { reviewedBy: workspaceMemberId, workspaceId };
};
```
| Поле | Тип | Описание |
| ------------------- | ---------------- | --------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Рабочее пространство, к которому относится запуск. |
| `userWorkspaceId` | `string \| null` | Человек, вызвавший запуск, представленный своим участием в этом рабочем пространстве. |
| `workspaceMemberId` | `string \| null` | Тот же человек в виде записи участника рабочего пространства, к которой привязываются записи. |
| `retryCount` | `number` | `0` при первой попытке. См. [Фоновые задачи](/l/ru/developers/extend/apps/logic/background-jobs). |
| `maxRetries` | `number` | Верхняя граница количества повторных попыток, запрошенных приложением. |
`userWorkspaceId` и `workspaceMemberId` имеют значение `null`, когда запуск никто не вызвал: у cron-расписаний, хуков установки и неаутентифицированных вебхуков нет человека, стоящего за ними. Они также имеют значение `null`, когда у человека нет записи участника рабочего пространства или когда она была удалена.
<Note>
**Контекст сообщает, кто вызвал запуск.** То, что запуск может *делать*, определяется отдельно, ниже.
</Note>
#### Чьи права доступа использует вызов
Каждый клиент — `CoreApiClient`, `MetadataApiClient` и `RestApiClient` — действует от имени человека, вызвавшего запуск: его роль пересекается с ролью вашего приложения, поэтому вызов никогда не может сделать больше, чем позволяет любая из этих ролей. Это поведение по умолчанию, и оно означает, что человек никогда не сможет использовать ваше приложение для превышения собственных разрешений.
Когда запуск никто не вызвал, нет человека, от имени которого можно действовать, поэтому тот же клиент использует права доступа самого приложения: cron-расписания, хуки установки и неаутентифицированные вебхуки не требуют специальной обработки.
Для некоторых вызовов правомерно требуются права доступа самого приложения, даже когда за запуском *стоит* человек — например, для чтения записей конфигурации вашего приложения или выполнения работы, которую этот человек не мог бы выполнить сам. Создайте для них второй клиент:
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const asPerson = new CoreApiClient();
const asApplication = new CoreApiClient({ runAs: 'application' });
const handler = async (event: DatabaseEventPayload) => {
// config records only your app may read
const { slackUserLinks } = await asApplication.query({ ... });
// created as the person, so it is attributed and permission-checked
await asPerson.mutation({ createNote: { ... } });
};
```
Создайте оба клиента один раз в области видимости модуля, и тогда каждый сайт вызова будет указывать используемые права доступа через вызываемый клиент. `RestApiClient` принимает тот же параметр:
```ts
import { RestApiClient } from 'twenty-client-sdk/rest';
const asApplication = new RestApiClient({ runAs: 'application' });
```
<Note>
**Запуск, который никто не вызвал, действует как ваше приложение.** У cron-расписаний, хуков установки и неаутентифицированных вебхуков нет человека, стоящего за ними, поэтому клиент по умолчанию использует права доступа самого приложения и продолжает работать. `runAs: 'application'` нужен только тогда, когда вам требуются эти права доступа в запуске, который *вызвал* человек.
Проверяйте `context.workspaceMemberId`, когда функция ведет себя по-разному в зависимости от того, стоит ли за ней кто-то, например чтобы указать автора записи.
</Note>
Помощники SDK, обращающиеся к собственным ресурсам вашего приложения, всегда используют его права доступа и игнорируют `runAs`: [хранилище ключей и значений](/l/ru/developers/extend/apps/logic/key-value-store), [подключения](/l/ru/developers/extend/apps/logic/connections), `runAgent`, `getPublicAssetUrl` и списание кредитов.
#### Предоставление функции в качестве инструмента ИИ или действия рабочего процесса
Функции логики могут быть представлены в двух интерфейсах, у каждого — свой триггер:
* **`toolTriggerSettings`** — делает функцию обнаруживаемой для возможностей ИИ Twenty (чат, MCP, вызов функций). Использует стандартную JSON Schema — формат, который модели LLM изначально понимают.
* **`workflowActionTriggerSettings`** — делает функцию доступной как шаг в визуальном конструкторе рабочих процессов. Использует расширенную `InputSchema` от Twenty, чтобы конструктор мог отрисовывать корректные редакторы полей, селекторы переменных и подписи.
Функция может выбрать один, другой или оба варианта. Они идут рядом с `cronTriggerSettings`, `databaseEventTriggerSettings` и `httpRouteTriggerSettings` — тот же шаблон, та же структура.
<Note>
**Связь с действием Code рабочего процесса.** Встроенное действие **Code** в конструкторе рабочих процессов само по себе является логической функцией — Twenty создаёт по одной на каждый шаг Code и отображает его редактор встроенным образом. `workflowActionTriggerSettings` — это способ превратить разовый встроенный код в **повторно используемое** действие: определите функцию один раз в своём приложении, и она станет доступной для выбора в любом рабочем процессе, вместо копирования и вставки в каждый шаг Code. См. [действие Code](/l/ru/user-guide/workflows/capabilities/workflow-actions#code) в руководстве пользователя, чтобы увидеть, как это выглядит для конечного пользователя.
</Note>
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
return { taskId: result.createTask.id };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
toolTriggerSettings: {},
});
```
Основные моменты:
* Функция может сочетать интерфейсы — объявите и `toolTriggerSettings`, и `workflowActionTriggerSettings`, чтобы сделать её доступной и в чате, и в конструкторе рабочих процессов.
* `toolTriggerSettings.inputSchema` и `workflowActionTriggerSettings.inputSchema` — обе необязательны. Если они опущены, конструктор манифеста выводит их из исходного кода обработчика (JSON Schema — для инструмента ИИ, `InputSchema` от Twenty — для действия рабочего процесса). Укажите её явно, когда вам нужна более богатая типизация — например, с полями, учитывающими `FieldMetadataType`, такими как `CURRENCY` или `RELATION`, для конструктора рабочих процессов, или с полями `description`, которые может прочитать ИИ-агент:
```ts
export default defineLogicFunction({
...,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
},
},
required: ['companyName'],
},
},
});
```
Чтобы объявить параметры **один раз** и использовать их в обоих сценариях, определите одну JSON Schema (`InputJsonSchema`) и преобразуйте её для действия рабочего процесса с помощью `jsonSchemaToInputSchema` из `twenty-sdk/logic-function`. `toolTriggerSettings.inputSchema` принимает JSON Schema напрямую, в то время как `workflowActionTriggerSettings.inputSchema` ожидает `InputSchema` Twenty:
```ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
export default defineLogicFunction({
...,
toolTriggerSettings: { inputSchema },
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
},
});
```
##### Полный пример действия рабочего процесса
`workflowActionTriggerSettings` принимает четыре поля:
| Поле | Назначение |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `label` | Имя, отображаемое для действия в селекторе шагов конструктора рабочих процессов. По умолчанию используется `name` функции. |
| `icon` | Иконка, отображаемая рядом с действием (имя из `tabler-icons`, например, `IconBuilding`). |
| `inputSchema` | Расширенная схема ввода (`InputSchema`) Twenty — то, что конструктор отображает как настраиваемые поля (с выбором переменных). Необязательно; при отсутствии выводится из обработчика. |
| `outputSchema` | Определяет структуру, которую возвращает обработчик, чтобы **последующие шаги могли сопоставлять свои данные с его выходными полями**. Необязательно; без неё вывод представлен одним непрозрачным значением. |
Объединяя всё вместе — функция, представленная как действие рабочего процесса, с объявленным выходом, чтобы последующие шаги могли ссылаться на `taskId`:
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
// The keys returned here should match the `outputSchema` properties below.
return { taskId: result.createTask.id, enriched: true };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
outputSchema: [
{
type: 'object',
properties: {
taskId: { type: 'string' },
enriched: { type: 'boolean' },
},
},
],
},
});
```
После установки приложения **Enrich Company** появляется в селекторе действий конструктора рабочих процессов. Конструктор отображает `companyName` и `domain` как поля ввода (каждое может получать значения из предыдущих шагов), а последующие шаги могут ссылаться на выходные значения шага `taskId` и `enriched`.
<Note>
**Напишите хорошее описание в поле `description`.** Агенты ИИ опираются на поле `description` функции, чтобы решить, когда использовать инструмент. Чётко опишите, что делает инструмент и когда его следует вызывать.
</Note>
</Accordion>
</AccordionGroup>
<Note>
**Вспомогательные функции времени выполнения.** `twenty-sdk/utils` повторно экспортирует небольшие вспомогательные функции времени выполнения, поэтому обработчики никогда не импортируют напрямую из `twenty-shared`. Например, `isDefined(value)` возвращает `false` как для `null`, так и для `undefined` — используйте её, чтобы безопасно сузить необязательные входные данные обработчика, которые могут приходить как `null` во время выполнения, даже если имеют тип `T | undefined`:
```ts
import { isDefined } from 'twenty-sdk/utils';
const handler = async (params: { parentMessageId?: string }) => {
if (isDefined(params.parentMessageId)) {
// params.parentMessageId is narrowed to string here
}
};
```
</Note>
<Note>
**Хуки установки** — обработчики до установки, после установки и при удалении — используют тот же рантайм, но объявляются с помощью собственных функций `define` и не принимают настройки триггеров. См. раздел [Install Hooks](/l/ru/developers/extend/apps/config/install-hooks) для `definePreInstallLogicFunction`, `definePostInstallLogicFunction` и `defineUninstallLogicFunction`.
</Note>
## Создание действия на временной шкале.
Используйте `createTimelineActivity()` для публикации явного события домена из логической функции. Сначала определите событие как [тип действия на временной шкале](/l/ru/developers/extend/apps/data/timeline-activity-types), затем укажите тип и объекты с помощью их стабильных универсальных идентификаторов:
```ts
import { createTimelineActivity } from 'twenty-sdk/logic-function';
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
happensAt: new Date().toISOString(),
properties: {
deliveryProvider: 'postal-service',
},
});
```
Twenty преобразует универсальные идентификаторы в идентификаторы метаданных, специфичные для установки, проверяет, что тип действия на временной шкале принадлежит вызывающему приложению, и сохраняет снимок его метаданных представления в новом действии.
Обязательные входные параметры: `timelineActivityTypeUniversalIdentifier`, `targetObjectUniversalIdentifier` и `targetRecordId`. Также можно указать `happensAt`, `properties` и `workspaceMemberId`. `happensAt` управляет положением события и отображаемым временем на временной шкале; по умолчанию используется время создания.
Чтобы связать с событием другую запись, укажите вместе `linkedRecordId` и `linkedObjectMetadataUniversalIdentifier`. Кроме того, можно указать `linkedRecordCachedName` в качестве резервного варианта отображения исторического имени:
```ts
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
linkedObjectMetadataUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
linkedRecordId: recipientId,
linkedRecordCachedName: recipientName,
});
```
Роли логической функции требуется разрешение на запись в стандартный объект `timelineActivity`. Не связывайте явные типы событий с `action`; тип, связанный с действием, уже получает автоматические события аудита и в противном случае создаст дублирующиеся строки.
## Типизированные клиенты API (twenty-client-sdk)
Пакет `twenty-client-sdk` предоставляет два типизированных клиента GraphQL для взаимодействия с API Twenty из ваших логических функций и фронт-компонентов.
| Клиент | Импорт | Конечная точка | Генерируется? |
| ------------------- | ---------------------------- | ----------------------------------------------------------------- | -------------------------------- |
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — данные рабочего пространства (записи, объекты) | Да, на этапе dev/build |
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — конфигурация рабочего пространства, загрузка файлов | Нет, поставляется в готовом виде |
<AccordionGroup>
<Accordion title="CoreApiClient" description="Запрос и изменение данных рабочего пространства (записи, объекты)">
`CoreApiClient` — основной клиент для запросов и изменений данных рабочего пространства. Он **генерируется из схемы вашего рабочего пространства** во время `yarn twenty dev` или `yarn twenty dev:build`, поэтому полностью типизирован в соответствии с вашими объектами и полями.
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const client = new CoreApiClient();
// Query records
const { companies } = await client.query({
companies: {
edges: {
node: {
id: true,
name: true,
domainName: {
primaryLinkLabel: true,
primaryLinkUrl: true,
},
},
},
},
});
// Create a record
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Acme Corp',
},
},
id: true,
name: true,
},
});
```
Клиент использует синтаксис selection-set: передайте `true`, чтобы включить поле, используйте `__args` для аргументов и вкладывайте объекты для отношений. Вы получаете полное автодополнение и проверку типов на основе схемы вашего рабочего пространства.
<Note>
**CoreApiClient генерируется на этапе dev/build.** Если вы используете его, не запустив сначала `yarn twenty dev` или `yarn twenty dev:build`, он выбросит ошибку. Генерация происходит автоматически — CLI анализирует GraphQL-схему вашего рабочего пространства и создает типизированный клиент с помощью `@genql/cli`.
</Note>
#### Использование CoreSchema для аннотаций типов
`CoreSchema` предоставляет типы TypeScript, соответствующие объектам вашего рабочего пространства — это полезно для типизации состояния компонентов или параметров функций:
```ts
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { useState } from 'react';
const [company, setCompany] = useState<
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
>(undefined);
const client = new CoreApiClient();
const result = await client.query({
company: {
__args: { filter: { position: { eq: 1 } } },
id: true,
name: true,
},
});
setCompany(result.company);
```
</Accordion>
<Accordion title="MetadataApiClient" description="Конфигурация рабочего пространства, приложения и загрузка файлов">
`MetadataApiClient` поставляется в готовом виде вместе с SDK (генерация не требуется). Он выполняет запросы к эндпоинту `/metadata` для получения конфигурации рабочего пространства, приложений и загрузки файлов. Он принимает тот же параметр `runAs`, что и `CoreApiClient` — см. [Чьи права доступа использует вызов](#whose-access-a-call-uses).
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadataClient = new MetadataApiClient();
// List first 10 objects in the workspace
const { objects } = await metadataClient.query({
objects: {
edges: {
node: {
id: true,
nameSingular: true,
namePlural: true,
labelSingular: true,
isCustom: true,
},
},
__args: {
filter: {},
paging: { first: 10 },
},
},
});
```
#### Загрузка файлов
`MetadataApiClient` включает метод `uploadFile` для прикрепления файлов к полям типа файла:
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
);
console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
```
| Параметр | Тип | Описание |
| ---------------------------------- | -------- | ------------------------------------------------------------------ |
| `fileBuffer` | `Buffer` | Необработанное содержимое файла |
| `filename` | `string` | Имя файла (используется для хранения и отображения) |
| `contentType` | `string` | Тип MIME (по умолчанию `application/octet-stream`, если не указан) |
| `fieldMetadataUniversalIdentifier` | `string` | Значение `universalIdentifier` для поля типа файла в вашем объекте |
Основные моменты:
* Он использует `universalIdentifier` поля (а не его идентификатор, специфичный для рабочего пространства), поэтому ваш код загрузки будет работать в любом рабочем пространстве, где установлено ваше приложение.
* Возвращаемый `url` — это подписанный URL, который можно использовать для доступа к загруженному файлу.
</Accordion>
</AccordionGroup>
<Note>
Когда ваш код выполняется на Twenty (логические функции или фронт-компоненты), платформа предоставляет учётные данные в виде переменных окружения:
* `TWENTY_API_URL` — базовый URL API Twenty
* `TWENTY_APP_ACCESS_TOKEN` — краткоживущий ключ для прав доступа по умолчанию: роль человека пересекается с ролью вашего приложения, когда за запуском кто-то стоит; если никто не стоит — используется собственная роль вашего приложения. В логической функции человек — это тот, кто вызвал запуск, а во фронтенд-компоненте — тот, кто просматривает страницу.
* `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — краткоживущий ключ, ограниченный исключительно собственной ролью вашего приложения. Только для логических функций, всегда внедряется в них и используется `runAs: 'application'`.
Вам **не** нужно передавать их клиентам — они автоматически считываются из `process.env`, а в разделе [Чьи права доступа использует вызов](#whose-access-a-call-uses) описан выбор между ними. Собственные разрешения вашего приложения определяются ролью, объявленной с помощью `defineApplicationRole()` (или указанной через `defaultRoleUniversalIdentifier` в `application-config.ts`); запуск, действующий от имени человека, никогда не может превысить ни эту роль, ни его собственную.
</Note>

View File

@@ -0,0 +1,61 @@
---
title: Обзор
description: Серверный TypeScript, который выполняется внутри Twenty — запускается HTTP-маршрутами, расписаниями cron, событиями базы данных, инструментами ИИ или действиями рабочих процессов.
icon: bolt
---
**Логический слой** приложения Twenty — это код, который *выполняется*: серверные обработчики TypeScript, реагирующие на HTTP-запросы, расписания cron и изменения записей; навыки ИИ и агенты, работающие внутри рабочего пространства; а также OAuth-подключения, позволяющие вашим функциям действовать от имени пользователя в сторонних сервисах.
```text
┌─ HTTP route ──┐
│ Cron schedule │
│ Database event │ ┌────────────────────┐
triggers ─┤ AI tool call ├─────▶│ Logic function │
│ Workflow action │ │ (your handler) │
│ Manual exec │ └────────────────────┘
└────────────────────┘ │
▼
┌────────────────────────────┐
│ Twenty API (records) │
│ Third-party API │
│ (via Connection token) │
└────────────────────────────┘
```
## В этом разделе
<CardGroup cols={2}>
<Card title="Логические функции" icon="bolt" href="/l/ru/developers/extend/apps/logic/logic-functions">
Основной строительный блок — типы триггеров, полезные данные (payloads) и типизированный клиент API.
</Card>
<Card title="Навыки и агенты" icon="robot" href="/l/ru/developers/extend/apps/logic/skills-and-agents">
Повторно используемые инструкции агента ИИ и ассистенты с пользовательскими системными подсказками.
</Card>
<Card title="Подключения" icon="plug" href="/l/ru/developers/extend/apps/logic/connections">
OAuth-учетные данные, которые ваше приложение хранит для сторонних сервисов — Linear, GitHub, Slack и других.
</Card>
<Card title="Хранилище ключ-значение" icon="database" href="/l/ru/developers/extend/apps/logic/key-value-store">
Сохраняйте состояние между запусками логических функций — кэши, курсоры и утверждения между рабочими пространствами.
</Card>
<Card title="Фоновые задания" icon="layer-group" href="/l/ru/developers/extend/apps/logic/background-jobs">
Поставьте функцию Logic в очередь на исполнение воркерами, чтобы обойти таймаут на один запуск.
</Card>
</CardGroup>
## Типы триггеров вкратце
Логическая функция выбирает один или несколько триггеров — каждая запись ниже — это отдельное поле в `defineLogicFunction()`:
| Триггер | Когда запускается | Настройка |
| ------------------------------ | -------------------------------------------------------------------- | ------------------------------- |
| **HTTP-маршрут** | Запрос попадает на вашу конечную точку `/s/\<path>` | `httpRouteTriggerSettings` |
| **Cron** | Срабатывает выражение CRON | `cronTriggerSettings` |
| **Событие базы данных** | Запись рабочего пространства создается, обновляется или удаляется | `databaseEventTriggerSettings` |
| **Инструмент ИИ** | Возможность Twenty AI решает вызвать вашу функцию | `toolTriggerSettings` |
| **Действие рабочего процесса** | Шаг рабочего процесса вызывает вашу функцию | `workflowActionTriggerSettings` |
Функции выполняются в изолированных процессах Node.js в песочнице и получают доступ к рабочему пространству через типизированный клиент API с областью действия, ограниченной ролью, объявленной в [`defineApplication()`](/l/ru/developers/extend/apps/config/application).
<Note>
**Хуки времени установки** — код, который выполняется до или после установки, — используют то же окружение выполнения, но свои собственные функции `define*` и находятся в разделе [Config → Install Hooks](/l/ru/developers/extend/apps/config/install-hooks).
</Note>

View File

@@ -0,0 +1,180 @@
---
title: Навыки и агенты
description: Определите навыки и агентов ИИ для вашего приложения.
icon: robot
---
<Warning>
Навыки и агенты сейчас проходят альфа-тестирование. Функция работает, но продолжает развиваться.
</Warning>
Приложения могут определять возможности ИИ, которые находятся внутри рабочего пространства — повторно используемые инструкции для навыков и агенты с настраиваемыми системными подсказками.
<AccordionGroup>
<Accordion title="defineSkill" description="Определяйте навыки ИИ-агентов">
Навыки определяют многократно используемые инструкции и возможности, которые агенты ИИ могут использовать в вашем рабочем пространстве. Используйте `defineSkill()` для определения навыков со встроенной валидацией:
```ts src/skills/example-skill.ts
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-outreach',
label: 'Sales Outreach',
description: 'Guides the AI agent through a structured sales outreach process',
icon: 'IconBrain',
content: `You are a sales outreach assistant. When reaching out to a prospect:
1. Research the company and recent news
2. Identify the prospect's role and likely pain points
3. Draft a personalized message referencing specific details
4. Keep the tone professional but conversational`,
});
```
Основные моменты:
* `name` — уникальная строка-идентификатор навыка (рекомендуется kebab-case).
* `label` — читаемое человеком отображаемое имя, показываемое в UI.
* `content` содержит инструкции навыка — это текст, который использует агент ИИ.
* `icon` (необязательно) задаёт значок, отображаемый в UI.
* `description` (необязательно) предоставляет дополнительный контекст о назначении навыка.
</Accordion>
<Accordion title="defineAgent" description="Определяйте ИИ-агентов с пользовательскими промптами">
Агенты — это ИИ-помощники, работающие в вашем рабочем пространстве. Используйте `defineAgent()` для создания агентов с пользовательским системным промптом:
```ts src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'Helps the sales team draft outreach emails and research prospects',
icon: 'IconRobot',
prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.',
});
```
Основные моменты:
* `name` — уникальная строка-идентификатор агента (рекомендуется kebab-case).
* `label` — отображаемое имя, показываемое в UI.
* `prompt` — это системный промпт, определяющий поведение агента.
* `description` (необязательно) предоставляет контекст о том, что делает агент.
* `icon` (необязательно) задаёт значок, отображаемый в UI.
* `modelId` (необязательно) переопределяет модель ИИ по умолчанию, используемую агентом.
* `responseFormat` (необязательно) определяет форму вывода агента. По умолчанию для свободного текста используется `{ type: 'text' }`. Используйте `{ type: 'json', schema }`, чтобы принудительно получать структурированный JSON-вывод.
По умолчанию агент возвращает свободный текст. Чтобы получить структурированный вывод, установите для `responseFormat` значение `{ type: 'json' }` и укажите `schema`:
```ts src/agents/structured-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'c4d5e6f7-a8b9-0123-cdef-456789012345',
name: 'lead-scorer',
label: 'Lead Scorer',
prompt: 'Score the lead and explain your reasoning.',
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
score: { type: 'number', description: 'Lead score from 0 to 100' },
summary: { type: 'string', description: 'Short reasoning for the score' },
},
required: ['score', 'summary'],
additionalProperties: false,
},
},
});
```
Примечания к схеме:
* Схема — это плоский объект: `type` каждого свойства должен быть примитивом (`string`, `number` или `boolean`). Вложенные объекты и массивы не поддерживаются.
* `description` (необязательно) для каждого свойства подсказывает модели, что туда нужно поместить.
* `required` (необязательно) перечисляет свойства, которые модель всегда должна возвращать.
* `additionalProperties: false` (необязательно) запрещает любые свойства, не объявленные в `properties`.
</Accordion>
<Accordion title="runAgent" description="Запуск агента из логической функции">
`runAgent()` позволяет логической функции запустить одного из агентов вашего приложения (с его навыками и инструментами). Идентифицируйте агента по `universalIdentifier`, который вы передали в `defineAgent()`. Передайте либо строку `prompt`, либо историю диалога `messages` — но не оба варианта одновременно:
```ts src/logic-functions/run-enricher.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'Enrich House Ad <recordId>: fill empty fields from its listing URL.',
});
```
Для многошаговых ботов (Slack, Discord, Teams, …) передавайте историю треда как `messages` вместо одного `prompt`:
```ts src/logic-functions/reply-in-thread.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
messages: [
{ role: 'user', content: 'Who owns Acme?' },
{ role: 'assistant', content: 'Sarah owns the Acme account.' },
{ role: 'user', content: 'What was the last touchpoint?' },
],
});
```
Основные моменты:
* Укажите **ровно один** из `prompt` (строка) или `messages` (от 1 до 100 элементов
вида `{ role: 'user' | 'assistant', content: string }`).
* Агент выполняется **синхронно** и может самостоятельно читать и обновлять записи с помощью собственных инструментов — `runAgent()` возвращает результат после завершения выполнения.
* Приложение может запускать только собственных агентов.
* [Роль по умолчанию](/l/ru/developers/extend/apps/config/roles) приложения должна предоставлять флаг разрешения `AI` — добавьте `SystemPermissionFlag.AI` в `permissionFlagUniversalIdentifiers` (или установите `canAccessAllTools: true`).
Без этого `runAgent()` завершится с ошибкой прав доступа.
* Установите достаточно большое значение `timeoutSeconds` для логической функции — выполнение агента может занимать несколько секунд.
* `success` имеет значение `true`, а `result` — не `null`, когда запуск завершается; при ошибке `success` равно `false`, `result` равно `null`, а в `error` содержится причина (например, если в рабочем пространстве закончились AI-кредиты во время запуска).
```ts src/roles/default-role.ts
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
export default defineApplicationRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
// runAgent() requires the AI permission flag on the app's default role.
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.AI],
});
```
<Warning>
**Избегайте циклов:** если вы вызываете `runAgent()` из триггера события базы данных `*.updated`, и агент обновляет ту же запись, ограничьте триггер, указав в `updatedFields` поле, которое агент никогда не изменяет (например, исходный URL), или добавьте проверку, что хотя бы одно целевое поле всё ещё пусто, прежде чем вызывать `runAgent()`.
</Warning>
### Запуск от имени участника рабочего пространства
Передавайте `runAsWorkspaceMemberId`, когда запуск инициирован человеком — например, чат-ботом, отвечающим на сообщение, — чтобы агент действовал как этот участник, а не как приложение:
```ts src/logic-functions/answer-question.ts
const { result } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'How many open opportunities do we have?',
runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
});
```
Затем запуск выполняется с **собственной ролью участника**: он может делать всё, что может делать этот участник, и не больше; созданные им записи приписываются этому участнику, и к ним применяются его разрешения на уровне строк. Роль агента в этом не участвует — это роль по умолчанию приложения для запусков, за которыми никто не стоит. Опускайте это поле для автономных запусков (плановые задания, триггеры событий базы данных): в таких случаях сохраняется собственная роль агента. Действующее приложение остаётся привязанным к контексту запуска для отслеживания происхождения, не сужая при этом полномочий участника.
Ваше приложение отвечает за сопоставление человека, который инициировал запуск, с участником рабочего пространства. Чтобы указать участника, требуется токен доступа приложения; то, кого можно указать с помощью токена, зависит от того, привязан ли к нему пользователь:
* Токен **без привязанного пользователя** может указать любого участника. Логическая функция выполняется с таким токеном; то же относится к токенам, выпущенным через `client_credentials` или на основе API‑ключа.
* Токен, выданный **от имени пользователя** (как у фронтенд‑компонента), может указывать только на участника самого этого пользователя.
Все остальное — обычная пользовательская сессия, API‑ключ без токена приложения — вообще не может указывать участника.
<Warning>
`runAgent()` генерирует исключение, если не удаётся определить участника рабочего пространства — неизвестного или удалённого участника, либо участника без роли. При этом никогда не используется роль самого агента, так как это предоставило бы больше прав доступа, чем запросил вызывающий код.
</Warning>
</Accordion>
</AccordionGroup>