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,195 @@
---
title: 后台作业
description: 通过将另一次逻辑函数运行入队,而不是在当前执行中完成所有工作,把耗时较长或受速率限制的工作交给 Twenty 工作进程处理。
icon: layer-group
---
一次逻辑函数运行会受到其 `timeoutSeconds`(最大 900 秒)的限制。 任何无法在该时间窗口内完成的工作——例如完整的重新同步、逐记录的扇出、对你进行速率限制的第三方 API——都必须拆分为更小的运行。
`enqueueJob` 正是用于此:它会请求 Twenty 工作进程稍后在其自己的进程中运行你应用中的某个逻辑函数,并使用其独立的超时预算。 调用方会立即返回。
```text
┌─────────────────┐ enqueueJob(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## 将一次运行加入队列
从 `twenty-sdk/logic-function` 导入 `enqueueJob`,并将其指向你想要运行的逻辑函数的 `universalIdentifier`。
```ts src/logic-functions/sync-all-contacts.ts
import { enqueueJob } from 'twenty-sdk/logic-function';
await enqueueJob({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payload: { page: 1 },
});
```
目标函数会像其他任何触发器一样,将 `payload` 作为其处理程序参数接收。 它必须属于与调用方**相同的应用程序**——将其他应用的函数入队会被拒绝,并返回 `Logic function not found`。
<Note>
`enqueueJob` 在作业被接受后立即返回,而不是在它运行完成后返回。 它不会返回目标的结果——如果你需要再次读取结果,让目标将其输出写入[key-value store](/l/zh/developers/extend/apps/logic/key-value-store)或工作区记录。
</Note>
## 作业选项
| 选项 | 默认 | 范围 | 作用 |
| ------------ | --- | -------------------- | ---------------------------------------------------- |
| `retryLimit` | `0` | `0`–`10` | 总体额外队列尝试次数。 应用程序请求的重试次数上限为 `3`。 仅对可以安全运行两次的处理程序提高该值。 |
| `delayMs` | `0` | `0`–`604800000`(7 天) | 在运行符合条件之前等待这么长时间。 |
```ts
await enqueueJob({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payload: { 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 { enqueueJob } 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 enqueueJob({
logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE,
payload: { 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,
});
```
## 按记录扇出(fan-out)
当工作天然按条目划分时,为每个条目入队一个作业,并让工作进程并行处理它们,而不是在内联循环中处理。
```ts
const companies = await listCompaniesToEnrich();
await Promise.all(
companies.map((company) =>
enqueueJob({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
payload: { companyId: company.id },
retryLimit: 2,
}),
),
);
```
## 长时间运行工作的最佳实践
两条规则涵盖几乎所有长作业:**用递归代替循环**,以及**每次运行处理一块有界的数据块(chunk)**。
试图一次完成所有事情的运行就是失败模式——它会触发超时,并在重试时从头开始重新执行整个过程。 相反,应调整单个数据块的大小,使其能够从容地在 `timeoutSeconds` 内完成,持久化当前位置,然后入队下一次运行。
```ts src/logic-functions/enrich-companies-batch.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJob, 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 enqueueJob({
logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH,
payload: { 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 × worst-case item time` 必须在留有余量的情况下适配到 `timeoutSeconds` 中,否则在运行被切断时,一个数据块的尾部会丢失。
* **让终止条件显式化。** 仅在返回的是完整数据块时继续递归。 仅在“无结果”时停止的链,如果源在中途返回一个较短的分页,将会永远继续下去。
* **在入队下一次运行之前持久化进度,** 这样失败的链路会从上一个完成的数据块而不是从头开始重新启动。
* **保持每个数据块幂等。** 在重试后重新处理一个数据块时,不能产生重复写入——应基于你处理的记录或外部 id 进行关键写入。
* **在工作会访问受限频率的第三方服务时,优先使用分块链而不是一次巨大的 fan-out。** 使用 `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` 行。
一个可用的配置需要**两个文件**——连接提供程序,以及在 `defineApplication` 上与之匹配、用于保存 OAuth 客户端凭据的 `serverVariables` 声明。
```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` 会显示在每个应用的设置选项卡以及 AI 工具列表中。
* `clientIdVariable` / `clientSecretVariable` 是**名称**,而不是值——它们必须与 `defineApplication.serverVariables` 中声明的键匹配。 实际的 `client_id` 和 `client_secret` 由服务器管理员通过应用注册 UI 输入,绝不会提交到你的仓库。
* 请使用 `serverVariables`(而非 `applicationVariables`)——OAuth 凭据是服务器范围的,并且每个 Twenty 服务器只配置一个 OAuth 应用。
* 在两个 `serverVariables` 都填写之前,每个应用的设置选项卡会显示"需要服务器管理员"的提示,并且"添加连接"按钮将被禁用。
* `type: 'oauth'` 是目前唯一受支持的取值。 该判别器具备前向兼容性:未来的类型(`'pat'`、`'api-key'` 等) 将会与 `oauth` 并列新增子配置块。
你的提供方需要加入白名单的 OAuth 回调 URL 为:
```
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 回调——请确保该钩子是幂等的,并能自行处理重试。 处理程序会接收到:
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
接下来,使用 `getConnection(connectedAccountId)` 读取最新的访问令牌,并调用提供商的 API(例如 Slack `auth.test`),或者使用[key-value store](/l/zh/developers/extend/apps/logic/key-value-store) 持久化映射。
</Accordion>
<Accordion title="在断开连接时运行逻辑函数" description="在移除连接时进行清理">
应用在连接时声明的任何内容,都必须在连接断开时被释放。 例如,一个在连接时声明了 `team_id` 的 Slack 集成,必须在连接结束时释放该声明,以便其他工作区可以连接同一个 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',
},
});
```
与 on-connect 钩子类似,它在**正在断开连接的工作区中以异步方式运行**,并且不会阻塞断开过程。 处理程序接收的负载结构相同:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
当钩子运行时,`ConnectedAccount` 已经不存在了,因此 `getConnection(connectedAccountId)` 将不再返回结果。 清理所需的任何内容(例如 `team_id`、外部订阅 ID)必须在连接时写入到按 `connectedAccountId` 作为键的[key-value store](/l/zh/developers/extend/apps/logic/key-value-store) 中。
当连接自身被移除时,该钩子会被触发。 卸载应用会通过数据库级联来删除其连接,因此该钩子在这种情况下不会运行。 在 `defineApplication` 上声明一个 `uninstallLogicFunction` 用于该路径:它在应用的元数据被删除之前运行,因此仍然可以调用 `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` | 唯一的行 id;传给 `getConnection(id)` 以重新获取单个连接 |
| `visibility` | `'user'`(仅对单个工作区成员私有)或 `'workspace'`(与所有成员共享) |
| `scopes` | 上游提供方授予的 OAuth 权限(不同于 `visibility`——两者不相关) |
| `userWorkspaceId` | 所有者的 userWorkspace id——在 HTTP 路由触发器中用于选择"请求用户的连接"很有用 |
| `accessToken` | 最新的 OAuth 访问令牌(若已过期会自动刷新) |
| `name` | 连接的显示名称(在 OAuth 回调时自动生成,用户可重命名) |
| `handle` | 已连接的上游帐户电子邮件,每次重新连接时刷新; 从 OIDC `id_token` 返回的 token\` 解决方案在代币交换中返回,回退到连接20个用户的电子邮件,当提供商不返回一个 |
| `authFailedAt` | 当最近一次刷新失败时会设置;用户必须重新连接 |
关键点:
* 传入 `{ providerName }` 以按提供方筛选;省略它则可获取此应用在所有提供方上的全部连接。
* 服务器会在返回前透明地刷新访问令牌。 你的处理器始终会拿到可用的令牌(或已设置 `authFailedAt`)。
* `getConnection(id)` 是获取单行记录的对应方法。
</Accordion>
<Accordion title="按用户与工作区共享的可见性" description="用户如何在私有与共享凭据之间进行选择">
当用户点击"添加连接"时,系统会提示其选择可见性:
* **仅限我**——该凭据仅对连接的用户私有。 代表其调用的任何逻辑函数(带有 `isAuthRequired: true` 的 HTTP 路由触发器)都可以看到它;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');
```
每个(用户、提供方)允许有多个连接,因此同一用户可以同时拥有"个人 Linear"和"工作 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) └──────────────────────────┘
```
## 获取、设置、删除
从 `twenty-sdk/logic-function` 导入 `kv`。 值可以是任何可序列化为 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');
```
## 范围
每个条目都有一个作用域,在每次调用时作为选项传入。 默认值为 `WORKSPACE`。
* **`WORKSPACE`**(默认)— 条目对当前工作区中安装的应用是私有的。 每个安装该应用的工作区都会获得自己独立的一组键。 这正是缓存、游标和按工作区存储状态时所需要的。
* **`SERVER`** — 条目在服务器上应用的**每一次安装**之间共享。 服务器条目的行为类似**声明(claim)**:存储的值始终是声明该键的 workspaceId(在 `set` 时省略 `value` 即可为当前工作区声明该键),并且只有该工作区可以覆盖或删除它。 任何一次安装都可以读取该条目。
服务器声明用于跨工作区路由。 [服务器路由解析器](/l/zh/developers/extend/apps/logic/logic-functions#server-route-trigger)在应用注册所有者的工作区中运行,但入站 Webhook 通常只携带外部账户 ID——而不是 Twenty 的 workspaceId。 让每个工作区在连接时声明其外部 id,然后在路由中解析它:
```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)。** 该存储本身不带有过期机制。 在值中存储时间戳(如缓存示例中所示)并在读取时检查,或者通过[定时任务触发的函数](/l/zh/developers/extend/apps/logic/logic-functions)清理陈旧的键。
* **存什么。** 任何可序列化为 JSON 的值——数字、字符串、数组、对象。 保持条目足够小;此存储用于协调和缓存,而不是用于存放大型二进制对象或文件。 对于文件,请使用 `FILES` 字段和 [`uploadFile`](/l/zh/developers/extend/apps/logic/logic-functions#uploading-files)。
* **可见性。** 条目存在于实例数据库中,而不是作为工作区记录存在——它们不会出现在工作区 UI 中,不属于你应用的数据模型,也不需要角色或对象权限。
## 可选方案:可查询的存储对象
内置存储是刻意设计为不透明的:条目不是记录,因此你无法在 UI 中浏览它们、将它们与其他对象关联,或通过记录查询对它们进行筛选。 当你需要这些能力时——比如可见的同步日志或逐条记录的状态——请定义一个小型的**技术对象**,其中包含唯一的 `key` 字段和一个 `RAW_JSON` 类型的 `value` 字段,并通过[类型化 API 客户端](/l/zh/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk)对其进行查询。 `defineObject` 的参考请参见 [Objects](/l/zh/developers/extend/apps/data/objects),关于强制键唯一性请参见 [Data → Unique indexes](/l/zh/developers/extend/apps/data/overview#unique-indexes)。
* **将作用域限定到记录。** 从存储对象到目标对象添加一个[关系](/l/zh/developers/extend/apps/data/relations),而不是把 id 编码进键中。
* **可见性与权限。** 这些行像任何其他记录一样存放在工作区数据库中,因此可以通过 API 查询,并遵循你的应用[角色](/l/zh/developers/extend/apps/config/roles)设置。 要将存储从主 UI 中隐藏,只需不要把它加入到你的[导航菜单](/l/zh/developers/extend/apps/layout/navigation-menu-items)中。
<Note>
与内置存储不同,自定义对象始终作用于单个工作区——它无法像 `SERVER` 键那样在安装之间共享条目。
</Note>

View File

@@ -0,0 +1,909 @@
---
title: 逻辑函数
description: 定义具有 HTTP、cron 和数据库事件触发器的服务端 TypeScript 函数。
icon: bolt
---
逻辑函数是在 Twenty 平台上运行的服务端 TypeScript 函数。 它们可以由 HTTP 请求、cron 调度或数据库事件触发——也可以作为工具暴露给 AI 智能体。
<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 路径和方法公开你的函数。 在应用代码中,使用 `RestApiClient` 时,在路由路径前加上 `/s/` 前缀;已部署的 URL 使用注入的 `TWENTY_FUNCTIONS_URL` 基础地址(如果未设置,则使用 `\<server-url>/s`)。
<Note>
要从(无头)前端组件调用由路由触发的逻辑函数,请参见[调用逻辑函数](/l/zh/developers/extend/apps/layout/front-components#calling-a-logic-function)。
</Note>
* **cron**:使用 CRON 表达式按计划运行你的函数。
* **databaseEvent**:在工作区对象生命周期事件上运行。 当事件操作为 `updated` 时,可以在 `updatedFields` 数组中指定要监听的特定字段。 如果未定义或为空,任何更新都会触发该函数。
> 例如 `person.updated`、`*.created`、`company.*`
* **serverRoute**:公开一个注册作用域的单一 HTTP 路由。 一个在所有者工作区中运行的 **resolver** 函数(使用 `serverRouteTriggerSettings` 声明)要么返回一个同步的 `Response`,要么返回目标工作区以及要入队的逻辑函数;在入队路径下,平台会发送 `202` 确认并在工作队列上运行该**目标**。 参见 [服务端路由触发器](#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>
#### 路由触发器负载
当路由触发器调用你的逻辑函数时,它会接收一个遵循
[AWS HTTP API v2 格式](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)的 `RoutePayload` 对象。
从 `twenty-sdk/logic-function` 导入 `RoutePayload` 类型:
```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` | 在 JSON 解析之前的原始 UTF-8 请求体。 用于验证 HMAC 风格的 Webhook 签名(例如 GitHub 的 `X-Hub-Signature-256`、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`)。 要控制状态码和响应头,请从 `twenty-sdk/logic-function` 返回一个 `Response`:
```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`,以及当应用的生产环境 `dependencies` 过大而无法安装时返回 `422`——请参见[依赖项大小限制](/l/zh/developers/extend/apps/getting-started/project-structure#dependencies)。
#### 服务端路由触发器
`httpRouteTriggerSettings` 在 `/s/` 下暴露一个函数,并根据请求主机解析 workspace——这在每个 workspace 都有自己域名时有效。 然而,第三方服务商会将每个租户的事件发送到**同一个** URL。 在这种情况下,请使用 `serverRouteTriggerSettings`。
触发器由两部分组成:
1. 一个在 **所有者工作区**(拥有应用注册的工作区)中运行的 **resolver** 逻辑函数——使用 `serverRouteTriggerSettings` 声明。 它会检查传入请求并返回以下两者之一:
* `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — 平台会在解析得到的工作区中将该目标入队,并返回 `202 { queued: true }` 确认,或者
* 来自 `twenty-sdk/logic-function` 的 `Response` — 平台会**同步**回显该 HTTP 响应,并且**不会**入队目标(将其用于诸如 Slack `url_verification` 之类的挑战握手)。
resolver 是唯一的授权点——URL 只携带 resolver 的标识符。 **这里是验证请求签名的首选位置**:resolver 在任何副作用之前运行,可以访问原始的 `rawBody` 和转发的请求头,并且可以在不触及目标的情况下直接拒绝请求。
2. 一个 **target** 逻辑函数——一个常规的、按工作区划分的逻辑函数——随后在解析得到的工作区中运行,并使用 resolver 返回的负载(如果 resolver 未对其进行转换,则使用原始请求负载)。 当 resolver 选择入队(enqueue)路径时,其返回值 **不会** 被 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
```
该标识符是清单(manifest)中 resolver 的 `universalIdentifier`。 在服务商处注册该 URL。
**响应验证 GET 请求。** 某些提供商会在向端点传递内容之前对其进行验证,方法是向其稍后将 `POST` 事件到的同一 URL 发送携带质询的 `GET` 请求——Meta 的 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>
**应用程序必须在其所属工作区中被认领并安装。** 由于 resolver 在**所属工作区**中运行(即拥有应用程序注册的工作区),服务器路由触发器只有在应用程序已被*认领*——也就是说它已有一个所属工作区——**并且**该应用程序**已安装在所属工作区**之后才会生效。 在这两个条件都满足之前,resolver 无处可运行,因此无法分发该路由。 因此,暴露 `serverRouteTriggerSettings` 逻辑函数的应用程序在被认领并安装到其所属工作区之前,不能在 marketplace 中列出。
</Note>
**Resolver 合约。** SDK 的 `LogicFunctionConfig` 类型在编译时强制执行这一点:一旦你设置了 `serverRouteTriggerSettings`,你的处理程序就被限制为返回 `Response`,或 `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }`(或它们之一的 `Promise`)。 在分发(dispatch)路径上,`workspaceId` 必须是已安装目标函数的工作区,否则请求会以 `404` 被拒绝。 如果结果不符合这两种结构中的任何一种——包括标识符不是 UUID 的情况——将会以 `502` 被拒绝。
| 字段 | 类型 | 备注 |
| ---------------------------------------- | ------------ | -------------------------------------- |
| `workspaceId` | `string` | 目标将运行所在的工作区 UUID。 |
| `targetLogicFunctionUniversalIdentifier` | `string` | 要在该工作区中调用的逻辑函数的 `universalIdentifier`。 |
| `payload` | `object`(可选) | 如果设置,将会替换发送到目标的请求正文。 |
<Warning>
**签名验证由你负责——请在 resolver 中进行验证。** 平台不会验证请求签名。 resolver 是执行验证的推荐位置:它最先运行,可以访问 `event.rawBody` 以及你在 `forwardedRequestHeaders` 中列出的请求头,并且只要抛出错误(或返回任意不匹配的 `workspaceId`),就会在调用目标之前停止分发。 如果你反而将验证下推到 target 中,那么 target 必须小心不要丢失 `rawBody` 和请求头——也就是说,resolver 不应返回 `payload`。 始终在产生任何副作用**之前**进行验证,并使用常量时间比较。
</Warning>
对于请求签名,大多数服务商使用 HMAC-SHA256 进行签名;不同之处在于请求头名称、摘要编码方式以及被签名的负载字符串。 例如:
| 提供商 | 要转发的请求头 | 签名字符串 | 摘要 |
| ------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------- |
| Svix(Recall、Resend、Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64(密钥在去掉 `whsec_` 前缀后为 base64) |
| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex |
| GitHub | `x-hub-signature-256` | `{rawBody}` | hex(前缀为 `sha256=`) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex(前缀为 `v0=`) |
上面的 resolver 示例已经展示了 GitHub 的 HMAC-SHA256 流程——请根据你要集成的服务商,调整请求头名称、摘要编码方式以及被签名的负载字符串。
<Note>
当 resolver 返回一个分发对象时,该路由会响应 `202 { queued: true }`,并在工作队列上运行目标函数——调用方不会感知目标函数的延迟、结果或失败(这些都会记录在执行日志中)。 这可以防止发送方的重新投递放大处理变慢的问题,而这正是你在处理 webhook 摄取时所需要的。
当调用方必须在同一个请求中读取响应体(挑战握手、交互式确认)时,请改为从 **resolver** 返回一个 `Response`。 平台会同步回显该响应并跳过队列;其响应头会通过与 HTTP 路由响应相同的允许列表进行过滤。 保持 resolver 足够快速——某些服务商(例如 Slack)会在几秒内超时。 由于 resolver 可以作为公共端点访问,请在边缘(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` | 已更改记录的 ID。 |
| `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/zh/developers/extend/apps/logic/background-jobs)。 |
| `maxRetries` | `number` | 应用程序请求重试次数的上限。 |
当没有人触发运行时,`userWorkspaceId` 和 `workspaceMemberId` 为 `null`:cron 计划、安装钩子和未经身份验证的 Webhook 背后没有人员。 当该人员没有工作区成员记录,或者其记录已被删除时,它们也为 `null`。
<Note>
**上下文会告诉你是谁触发了运行。** 运行可以*执行*什么操作则是下面单独的声明。
</Note>
#### 调用使用谁的访问权限
每个客户端 — `CoreApiClient`、`MetadataApiClient` 和 `RestApiClient` — 都会以触发运行的人员身份执行操作:其角色与你的应用程序角色相交,因此调用所能执行的操作绝不会超过你们任一方的权限。 这是默认行为,意味着人员绝不能使用你的应用超出其自身权限。
当没有人触发运行时,没有可代为执行操作的人员,因此同一客户端会回退到使用你的应用程序自身访问权限:cron 计划、安装钩子和未经身份验证的 Webhook 无需特殊处理。
即使运行背后*有*人员,某些调用也确实需要应用程序自身的访问权限 — 例如读取应用的配置记录,或执行该人员自己无法完成的工作。 为这些情况构建第二个客户端:
```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 计划、安装钩子和未经身份验证的 Webhook 背后没有人员,因此默认客户端会回退到使用你的应用程序自身访问权限,并继续正常工作。 仅当你希望在由人员*触发*的运行中使用该访问权限时,才需要 `runAs: 'application'`。
当函数的行为取决于其背后是否有人员时,请检查 `context.workspaceMemberId`,例如用于为记录归属。
</Note>
用于访问你的应用程序自身资源的 SDK 辅助工具始终使用其访问权限,并忽略 `runAs`:包括[键值存储](/l/zh/developers/extend/apps/logic/key-value-store)、[连接](/l/zh/developers/extend/apps/logic/connections)、`runAgent`、`getPublicAssetUrl` 和积分扣费。
#### 将函数公开为 AI 工具或工作流操作
逻辑函数可以在两个入口对外公开,每个入口都有各自的触发器:
* **`toolTriggerSettings`** — 使该函数可被 Twenty 的 AI 功能(chat、MCP、function calling)发现。 使用标准 JSON Schema,LLM 能够原生理解的格式。
* **`workflowActionTriggerSettings`** — 使该函数在可视化工作流构建器中显示为一个步骤。 使用 Twenty 丰富的 `InputSchema`,以便构建器可以呈现合适的字段编辑器、变量选择器和标签。
函数可以选择加入其中一个、另一个,或两者都加入。 它们与 `cronTriggerSettings`、`databaseEventTriggerSettings` 和 `httpRouteTriggerSettings` 并列 — 相同的模式、相同的结构。
<Note>
**与工作流 Code 动作的关系。** 工作流构建器中的内置 **Code** 动作本身就是一个逻辑函数 —— Twenty 会为每个 Code 步骤创建一个逻辑函数,并在行内展示其编辑器。 `workflowActionTriggerSettings` 是将一次性行内代码转换为**可复用**动作的方式:在你的应用中定义一次该函数,它就可以在任意工作流中被选择,而不需要在每个 Code 步骤中复制粘贴。 请参阅用户指南中的[Code 动作](/l/zh/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`,即可在 chat 和工作流构建器中同时公开它。
* `toolTriggerSettings.inputSchema` 和 `workflowActionTriggerSettings.inputSchema` 均为可选。 如果省略,清单构建器会根据处理器源代码进行推断(AI 工具使用 JSON Schema,工作流操作使用 Twenty 的 `InputSchema`)。 当你需要更丰富的类型时,可显式提供一个 — 例如,在工作流构建器中使用对 `FieldMetadataType` 友好的字段(如 `CURRENCY` 或 `RELATION`),或提供 AI 智能体可读取的 `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`),并使用来自 `twenty-sdk/logic-function` 的 `jsonSchemaToInputSchema` 将其转换为工作流操作所用。 `toolTriggerSettings.inputSchema` 直接接受 JSON Schema,而 `workflowActionTriggerSettings.inputSchema` 需要的是 Twenty 的 `InputSchema`:
```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` | Twenty 提供的功能丰富的 `InputSchema` —— 构建器会将其渲染为可配置字段(带变量选择器)。 可选;若省略,则会从处理函数中推断。 |
| `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`。** AI 智能体会依赖该函数的 `description` 字段来决定何时使用该工具。 明确说明该工具的作用以及应在何时调用。
</Note>
</Accordion>
</AccordionGroup>
<Note>
**运行时辅助工具。** `twenty-sdk/utils` 会重新导出一些小型运行时辅助工具,这样处理程序就不需要直接从 `twenty-shared` 导入。 例如,`isDefined(value)` 对 `null` 和 `undefined` 都会返回 `false` —— 使用它可以安全地收窄可选处理程序输入的类型,因为即使类型标注为 `T | undefined`,在运行时它们仍可能以 `null` 的形式传入:
```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>
**安装 hooks**——预安装、后安装和卸载处理程序——共享此运行时,但使用它们自己的 define 函数进行声明,并且不接受触发器设置。 有关 `definePreInstallLogicFunction`、`definePostInstallLogicFunction` 和 `defineUninstallLogicFunction`,请参阅 [Install Hooks](/l/zh/developers/extend/apps/config/install-hooks)。
</Note>
## 创建时间线活动
使用 `createTimelineActivity()` 从逻辑函数发布显式领域事件。 先将事件定义为一种[时间线活动类型](/l/zh/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 会将通用标识符解析为特定于安装的元数据 ID,验证时间线活动类型属于调用应用,并在新活动上对其展示元数据创建快照。
必需的输入为 `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 客户端,供你的逻辑函数和前端组件与 Twenty API 交互。
| 客户端 | 导入 | 端点 | 是否生成? |
| ------------------- | ---------------------------- | ------------------------ | --------- |
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql`——工作区数据(记录、对象) | 是,在开发/构建时 |
| `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,
},
});
```
该客户端使用选择集语法:传入 `true` 以包含某字段,使用 `__args` 传递参数,并通过嵌套对象表示关系。 你将基于工作区架构获得完整的自动补全和类型检查。
<Note>
**CoreApiClient 在开发/构建时生成。** 如果在未先运行 `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` 端点以获取工作区配置、应用和文件上传。 它接受与 `CoreApiClient` 相同的 `runAs` 选项 — 请参阅[调用使用谁的访问权限](#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`(而不是其工作区特定的 ID),因此你的上传代码可在安装了你的应用的任何工作区中运行。
* 返回的 `url` 是一个签名 URL,你可以用它来访问已上传的文件。
</Accordion>
</AccordionGroup>
<Note>
当你的代码在 Twenty 上运行(逻辑函数或前端组件)时,平台会以环境变量的形式注入凭据:
* `TWENTY_API_URL`——Twenty API 的基础 URL
* `TWENTY_APP_ACCESS_TOKEN` — 用于默认访问权限的短期密钥:当运行背后有人员时,使用该人员角色与你的应用程序角色的交集;当没有人员时,使用你的应用程序自身角色。 在逻辑函数中,该人员是触发运行的人;在前端组件中,则是正在查看页面的人。
* `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — 仅限定于你的应用程序自身角色的短期密钥。 仅用于逻辑函数,始终会在其中注入,并由 `runAs: 'application'` 使用。
你**不**需要将这些传递给客户端 — 它们会自动从 `process.env` 读取,而[调用使用谁的访问权限](#whose-access-a-call-uses)介绍了如何在它们之间进行选择。 你的应用程序自身权限由通过 `defineApplicationRole()` 声明的角色(或在 `application-config.ts` 中通过 `defaultRoleUniversalIdentifier` 引用的角色)决定;以人员身份执行的运行绝不能超出该角色或该人员角色的权限。
</Note>

View File

@@ -0,0 +1,61 @@
---
title: 概览
description: 在 Twenty 内运行的服务端 TypeScript,可由 HTTP 路由、Cron 调度、数据库事件、AI 工具或工作流动作触发。
icon: bolt
---
Twenty 应用的 **逻辑层** 是实际*运行*的代码——用于响应 HTTP 请求、cron 调度和记录变更的服务端 TypeScript 处理程序;存在于工作区内的 AI 技能和智能体;以及让你的函数能够代表用户在第三方服务中执行操作的 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/zh/developers/extend/apps/logic/logic-functions">
核心构建块——触发器类型、载荷以及类型化的 API 客户端。
</Card>
<Card title="技能与智能体" icon="robot" href="/l/zh/developers/extend/apps/logic/skills-and-agents">
可复用的 AI 智能体指令和助手,支持自定义系统提示词。
</Card>
<Card title="连接" icon="plug" href="/l/zh/developers/extend/apps/logic/connections">
你的应用为第三方服务(如 Linear、GitHub、Slack 等)持有的 OAuth 凭证。
</Card>
<Card title="键值存储" icon="database" href="/l/zh/developers/extend/apps/logic/key-value-store">
在逻辑函数的多次运行之间持久化状态——缓存、游标和跨工作区声明。
</Card>
<Card title="后台作业" icon="layer-group" href="/l/zh/developers/extend/apps/logic/background-jobs">
将逻辑函数排入队列,由工作进程运行,以避开单次运行超时限制。
</Card>
</CardGroup>
## 触发器类型一览
逻辑函数选择一个或多个触发器——下面的每一项都是 `defineLogicFunction()` 上的一个独立字段:
| 触发器 | 触发时机 | 设置 |
| ----------- | --------------------------------------- | ------------------------------- |
| **HTTP 路由** | 请求命中你的 `/s/\<path>` 端点 | `httpRouteTriggerSettings` |
| **Cron** | 匹配到一个 CRON 表达式时 | `cronTriggerSettings` |
| **数据库事件** | 当工作区记录被创建、更新或删除时 | `databaseEventTriggerSettings` |
| **AI 工具** | 某个 Twenty AI 功能决定调用你的函数时 | `toolTriggerSettings` |
| **工作流动作** | 当工作流步骤调用你的函数时 | `workflowActionTriggerSettings` |
函数在隔离的 Node.js 进程沙箱中运行,并通过限定在 [`defineApplication()`](/l/zh/developers/extend/apps/config/application) 上声明角色范围内的类型化 API 客户端访问工作区。
<Note>
**安装时钩子(Install-time hooks)**——在安装前或安装后运行的代码——与上述共享同一运行时,但使用它们自己的 define 函数,并位于 [Config → Install Hooks](/l/zh/developers/extend/apps/config/install-hooks) 下。
</Note>

View File

@@ -0,0 +1,179 @@
---
title: 技能与智能体
description: 为你的应用定义 AI 技能和智能体。
icon: robot
---
<Warning>
技能和智能体目前处于 Alpha 阶段。 该功能可用,但仍在演进中。
</Warning>
应用可以定义存在于工作区内的 AI 能力——可复用的技能指令以及具有自定义系统提示词的智能体。
<AccordionGroup>
<Accordion title="defineSkill" description="定义 AI 智能体技能">
技能定义了可复用的指令和能力,AI 智能体可在你的工作区中使用。 使用 `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` 包含技能指令——这是 AI 智能体使用的文本。
* `icon`(可选)设置在 UI 中显示的图标。
* `description`(可选)提供有关技能用途的更多上下文。
</Accordion>
<Accordion title="defineAgent" description="使用自定义提示词定义 AI 智能体">
智能体是在你的工作区内驻留的 AI 助手。 使用 `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`(可选)会覆盖该智能体使用的默认 AI 模型。
* `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()` 允许逻辑函数运行你的应用的某个代理(及其技能和工具)。 通过你传递给 `defineAgent()` 的 `universalIdentifier` 来标识该代理。 只传入 `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()` 会在运行完成后才 resolve。
* 一个应用只能运行它自己的代理。
* 应用的[默认角色](/l/zh/developers/extend/apps/config/roles)必须授予 `AI` 权限标记 —— 在其 `permissionFlagUniversalIdentifiers` 中添加 `SystemPermissionFlag.AI`(或设置 `canAccessAllTools: true`)。
否则,`runAgent()` 会因权限错误而失败。
* 在逻辑函数上设置较大的 `timeoutSeconds` 值 —— 代理运行可能需要数秒时间。
* 当运行完成时,`success` 为 `true` 且 `result` 为非空;失败时,`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>
\*\*避免循环:\*\*如果你从 `*.updated` 数据库事件触发器中调用 `runAgent()`,而代理又会更新同一条记录,请将触发器的 `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>