cal.diy 中的 Salesforce 集成指南:把预订参与者自动写入 Sales Cloud 联系人
本篇技术指南以本仓库(cal.diy,Cal.com 调度基础设施项目)中 Salesforce 集成应用的官方描述文档 DESCRIPTION.md 为核心,结合其 OAuth 授权、CRM 服务、事件类型配置与 Salesforce Apex 扩展包的完整源码实现,系统讲解该集成"把活动参与者(attendees)自动创建为 Salesforce 联系人"的核心能力、可配置项与开发发布流程。读完本文,你将掌握该集成的安装授权链路、事件类型级配置项语义、底层 CRM 服务实现原理,以及基于 scratch org 的本地开发与 SFDC Unlocked Package 发布方法。
集成总览:Salesforce (Sales Cloud) 是什么、集成做什么
按照官方描述文档 DESCRIPTION.md 的定义,Salesforce (Sales Cloud) 是一款云端应用,它通过集中管理客户信息、记录客户与公司的交互、并自动化销售人员每天要完成的许多任务,来帮助销售团队更聪明、更快速地销售。
在该集成中,Cal.com 承担的角色是"调度基础设施":当客户通过你的 Cal.com 链接完成预订后,集成会把本次预订的参与者自动同步到 Salesforce,作为 Sales Cloud 中的联系人(Contact)记录,从而让销售数据与日程数据打通。其特性在文档中被明确概括为一条:
- 将预订事件参与者(event attendees)创建为 Salesforce (Sales Cloud) 中的联系人。
从 config.json 可以看到该应用在 app-store 体系中的完整元信息:
{
"name": "Salesforce",
"slug": "salesforce",
"type": "salesforce_crm",
"logo": "icon.png",
"variant": "crm",
"categories": ["crm"],
"extendsFeature": "EventType",
"publisher": "Cal.com, Inc.",
"isOAuth": true
}
关键点解读:
type: "salesforce_crm"表明它实现了 Cal.com 的 CRM 集成契约,注册在 CRM 类型下;variant: "crm"与categories: ["crm"]决定它在应用市场中归属于 CRM 分类;extendsFeature: "EventType"说明该应用会为每个事件类型(EventType)提供可独立配置的能力卡片(AppCard);isOAuth: true说明其安装走 OAuth 授权流程。
从项目整体结构看,这个集成由两部分组成:一部分是 Cal.com 侧基于 Node/TypeScript 的集成服务(位于 packages/app-store/salesforce),另一部分是部署在 Salesforce 组织内的 Apex 扩展包(sfdc-package),两者通过 OAuth 凭据与 Named Credential 协同工作。
OAuth 授权链路:从安装到令牌落地
Salesforce 集成的安装是一个标准 OAuth 2.0 授权码流程,由两个 Next.js API 端点完成:发起授权(api/add.ts)与回调换令牌(api/callback.ts)。
发起授权
api/add.ts 中,服务端先从应用密钥中读取 consumer_key,缺失时直接返回 400 Salesforce client id missing:
const appKeys = await getAppKeysFromSlug("salesforce");
if (typeof appKeys.consumer_key === "string") consumerKey = appKeys.consumer_key;
if (!consumerKey) return res.status(400).json({ message: "Salesforce client id missing." });
随后基于 jsforce 构造 OAuth2 客户端,并以 refresh_token full 作为授权 scope(前者换取长期可用令牌,后者申请完整 API 权限):
const salesforceClient = new jsforce.Connection({
oauth2: {
clientId: consumerKey,
redirectUri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/salesforce/callback`,
},
});
const url = salesforceClient.oauth2.getAuthorizationUrl({
scope: "refresh_token full",
...(state && { state }),
});
res.status(200).json({ url });
注意 redirectUri 指向 WEBAPP_URL_FOR_OAUTH 下的 callback 端点,同时会把请求的 OAuth state 编码进授权 URL,用于回调时校验会话归属。
回调换令牌与令牌生命周期探测
api/callback.ts 在拿到 code 后,使用 consumer_key 与 consumer_secret 调用 Salesforce 令牌端点换取访问令牌:
const conn = new jsforce.Connection({
oauth2: { clientId: consumerKey, clientSecret: consumerSecret, redirectUri: ... },
});
const salesforceTokenInfo = await conn.oauth2.requestToken(code as string);
值得注意的实现细节是:集成并不会直接保存令牌,而是先调用 Salesforce 的 OIDC Token Introspection 端点(lib/getSalesforceTokenLifetime.ts)计算令牌有效时长,再把 token_lifetime 一并存入凭据:
const response = await fetch(`${instanceUrl}/services/oauth2/introspect`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${consumer_key}:${consumer_secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ token: accessToken, token_type_hint: "access_token" }),
});
const tokenLifetime = data.exp - data.iat; // exp 与 iat 均以秒为单位
最终通过 createOAuthAppCredential 将 { ...salesforceTokenInfo, token_lifetime } 落库,然后重定向回应用安装页(getInstalledAppPath)。凭据对象的结构在 lib/CrmService.ts 中有对应的 zod schema 约束,包含 instance_url、access_token、scope、token_lifetime 等字段。
事件类型级配置项:schema 定义与界面开关
得益于 extendsFeature: "EventType",每个事件类型都可以独立开关并配置 Salesforce 同步行为。配置结构定义在 zod.ts 的 appDataSchema 中(基于 eventTypeAppCardZod 扩展),其配置 UI 实现在 components/EventTypeAppCardInterface.tsx。
核心记录类型与写回枚举
lib/enums.ts 定义了所有配置项可用的枚举值:
- Salesforce 记录类型
SalesforceRecordEnum:Contact、Lead、Account、Event; - 写回时机
WhenToWriteToRecord:every_booking(每次预订都写)、field_empty(仅当字段为空时写); - 字段类型
SalesforceFieldType:date、string、phone、custom、picklist、boolean、datetime、textarea; - 日期数据来源
DateFieldTypeData:booking_start_date、booking_created_date、booking_cancel_date; - 轮询路由判定来源
RoutingReasons:account_lookup_field、lead_owner、contact_owner、account_owner。
完整配置项清单
appDataSchema 中可配置项及语义如下(布尔开关均有对应的 UI 控件,字符串/记录型字段在配置卡片中编辑):
| 配置键 | 类型 | 默认值 | 语义 |
|---|---|---|---|
roundRobinLeadSkip |
boolean | 无 | 轮询路由(Round Robin)分配时跳过已有匹配记录的参与者 |
roundRobinSkipCheckRecordOn |
Contact/Lead/Account | Contact |
轮询跳过时在哪种记录上检查归属 |
rrSkipFieldRules |
{field, value, action}[] |
无 | 字段级跳过规则,action 为 ignore(命中则跳过)或 must_include(必须命中才跳过) |
ifFreeEmailDomainSkipOwnerCheck |
boolean | false |
免费邮箱域名(如 gmail.com)跳过 owner 检查 |
roundRobinSkipFallbackToLeadOwner |
boolean | false |
找不到归属时回退到 Lead Owner |
skipContactCreation |
boolean | 无 | 不自动创建联系人 |
createEventOn |
Contact/Lead/Account | Contact |
在哪种记录类型下创建 Salesforce Event |
createNewContactUnderAccount |
boolean | 无 | 在 Account 下新建 Contact |
createLeadIfAccountNull |
boolean | 无 | Account 为空时创建 Lead |
onBookingWriteToEventObject |
boolean | false |
预订成功后写回 Event 对象 |
onBookingWriteToEventObjectMap |
record | {} |
Event 对象字段映射 |
createEventOnLeadCheckForContact |
boolean | 无 | 以 Lead 创建 Event 前先检查 Contact |
onBookingChangeRecordOwner |
boolean | false |
预订后修改记录 Owner |
onBookingChangeRecordOwnerName |
string | [] |
新 Owner 名称 |
sendNoShowAttendeeData |
boolean | false |
发送 No-Show 参与者数据 |
sendNoShowAttendeeDataField |
string | "" |
No-Show 数据写入的字段 |
onBookingWriteToRecord |
boolean | false |
预订成功后写回记录 |
onBookingWriteToRecordFields |
record | {} |
记录字段映射,值为 writeToBookingEntry |
ignoreGuests |
boolean | false |
忽略参与者(Guests),只处理主预订人 |
onCancelWriteToEventRecord |
boolean | false |
取消预订时写回 |
onCancelWriteToEventRecordFields |
record | {} |
取消时字段映射 |
其中写回条目的结构(writeToBookingEntry)为:
{
value: string | boolean, // 要写入的值
fieldType: SalesforceFieldType, // 对应 SF 字段类型
whenToWrite: WhenToWriteToRecord // every_booking | field_empty
}
UI 侧(components/EventTypeAppCardInterface.tsx)通过 useAppContextWithSchema 读写这些配置,并渲染三个记录类型下拉框:recordOptions(Contact/Lead/Account,决定 createEventOn)与 checkOwnerOptions(Contact/Lead/Account,决定 roundRobinSkipCheckRecordOn),以及 WriteToObjectSettings、FieldRulesSettings 两个子设置组件(见 components/components)。
核心实现:CRM 服务如何把参与者写进 Salesforce
整个集成的主逻辑集中在 lib/CrmService.ts(约 1956 行),它实现了 Cal.com 通用的 CRM 接口,并通过 lib/index.ts 以 BuildCrmService 的名义导出,供 crmManager 统一调度。
SalesforceCRMService 的关键设计(lib/CrmService.ts):
- 持有
appOptions(即上一节的事件类型配置),预订流程按配置逐项执行; - 基于
jsforce维护惰性Connection,用存储的令牌访问 Salesforce REST API; - 通过 graphql/SalesforceGraphQLClient.ts 使用 Salesforce GraphQL 端点(schema 定义见 src/gql)查询记录归属;
- 额外扩展了
SalesforceCRM接口,增加 Salesforce 特有的三个方法:findUserEmailFromLookupField(按查找字段反查用户邮箱与路由来源)、incompleteBookingWriteToRecord(预订信息不完整时的写回兜底)、getAllPossibleAccountWebsiteFromEmailDomain(从邮箱域名推断 Account 官网)。
联系人查找与去重是核心流程之一:代码中定义了 SalesforceDuplicateError 类型(lib/CrmService.ts),用于解析 Salesforce 重复规则(Duplicate Rule)返回的 matchResults,从而在写入前判断目标联系人是否已存在。工具函数 lib/utils/getDominantAccountId.ts 与 lib/utils/getAllPossibleWebsiteValuesFromEmailDomain.ts 负责在多候选 Account 之间决策归属、从免费邮箱域名推导可能官网,这些均有配套单元测试(lib/utils/tests)。
对轮询路由(Round Robin)场景,集成还引入了"Assignment Reason"记录:配置打开 roundRobinLeadSkip 后,服务会基于 rrSkipFieldRules 与 RoutingReasons 判定跳过逻辑,并通过 lib/repositories/PrismaAssignmentReasonRepository.ts 把路由原因持久化,便于审计。
集成测试(lib/tests/CrmService.integration.test.ts)配合 salesforceMock.ts 与 graphql/tests 中的 urqlMock 覆盖了创建联系人、写入 Event、GraphQL 查询等关键链路,是理解预期行为的最佳参考。
Salesforce 侧 Apex 扩展包与用户同步
集成并不止于 Cal.com 单向写数据,还包括部署在 Salesforce 组织内的 Unlocked Package(sfdc-package),实现"Salesforce 变化反向通知 Cal.com"的双向能力。Apex 源码位于 sfdc-package/force-app/main/default:
- CalComCalloutQueueable.cls:以 Queueable 方式异步向 Cal.com 发起 Callout,避免在触发器同步链路中阻塞事务;
- UserUpdateHandler.cls 与 UserUpdateTrigger.trigger:监听 Salesforce 侧 User 记录变更,触发同步请求;
- CalComHttpMock.cls:测试用的 HTTP Mock,配合 UserUpdateHandlerTest.cls 与 CalComCalloutQueueableTest.cls 验证行为;
- Named Credential(CalCom_Development.namedCredential-meta.xml 与
CalCom_Production)用于把目标 Cal.com 实例地址配置化,开发环境指向本地实例,生产环境指向线上。
Cal.com 侧接收这些同步请求的端点是 api/user-sync.ts。该 POST 端点会校验请求体中的 instanceUrl 是否与已存储凭据匹配、orgId 是否与凭据 URL 中的组织 ID 一致、以及邮箱与团队用户是否对应,三重校验全部通过才返回 { success: true },防止跨组织伪造同步请求。
本地开发、测试与包发布流程
官方开发文档 README.md 给出了完整的工程化流程。
创建 Salesforce 测试组织(Scratch Org)
需先安装 Salesforce CLI,然后:
yarn scratch-org:create # 按 project-scratch-def.json 配置创建 scratch org
yarn scratch-org:start # 在浏览器中打开该 org
若要在本地联调,需要把 scratch org 中的 Named Credential 指向本地实例(将 CalCom_Development 指向 localhost)。
GraphQL 类型生成
该集成通过 GraphQL Codegen 从 Salesforce schema 生成查询与类型(文档见 README.md):
- 由于 Salesforce GraphQL 端点 v63 在生成
Setup__JoinInput类型时存在已知报错,schema 需从 Salesforce GraphQL introspection 结果转换而来(使用graphql-introspection-json-to-sdl工具); - 运行
yarn generate:schema生成 SDL 文件; - 开发期间保持
yarn codegen:watch后台运行,自动从 SDL 生成查询与类型; - 相关配置见 codegen.ts、graphql.config.ts 与 graphqlrc.config.ts。
Apex 包部署与发布
Apex 侧开发(需 Salesforce CLI):
yarn sfdc:deploy:preview # 预览将部署到 scratch org 的变更
yarn sfdc:deploy # 实际部署到 scratch org
发布 Unlocked Package 时所有命令需在 sfdc-package 目录下执行。首次创建包(一次性):
sf package create \
--name "calcom-sfdc-package" \
--package-type Unlocked \
--path force-app \
--target-dev-hub team@cal.com
每次发布新版本:
sf package version create \
--package "calcom-sfdc-package" \
--installation-key-bypass \
--wait 20 \
--target-dev-hub team@cal.com
其中 --installation-key-bypass 允许免密码安装,--wait 20 表示最多等待 20 分钟构建完成;准备 promote 时需追加 --code-coverage(要求 Apex 测试覆盖率不低于 75%)。查看已发布版本:
sf package version list --target-dev-hub team@cal.com
安装 URL 格式为 https://login.salesforce.com/packaging/installPackage.apexp?p0=<04t_SUBSCRIBER_PACKAGE_VERSION_ID>。Beta 版只能安装到沙盒/scratch org,允许安装到生产组织的版本需先 promote:
sf package version promote \
--package "calcom-sfdc-package@X.X.X-X" \
--target-dev-hub team@cal.com
运行 Apex 测试与覆盖率统计:
sf project deploy start --target-org <org-alias>
sf apex run test --test-level RunLocalTests --wait 10 --target-org <org-alias>
小结
本文基于官方描述文档 DESCRIPTION.md 展开,完整梳理了 cal.diy 中 Salesforce 集成"将预订参与者创建为 Sales Cloud 联系人"这一核心能力的技术全貌:OAuth 授权与令牌生命周期管理(api/add.ts、api/callback.ts)、二十余项事件类型级配置(zod.ts)、以 lib/CrmService.ts 为核心的写入与去重逻辑、Salesforce 侧 Apex 包的双向同步,以及从 scratch org 到 Unlocked Package 发布的一整套工程实践。如需深入源码,建议从 lib/tests/CrmService.integration.test.ts 的集成测试与 sfdc-package 的 Apex 触发器入手,逐条对照配置项与断言,即可快速掌握端到端的数据流。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00