Cal.com Telegram 集成解析:从声明式 App 配置到静态链接会议地点的完整实现
Telegram 是 Cal.com 应用商店中一个典型的“消息/会议地点”型集成,它不依赖 OAuth、不需要服务端回调,而是以一条静态 Telegram 链接(https://t.me/...)作为预约事件的会议地点。本文以 packages/app-store/telegram/DESCRIPTION.md 为骨架,结合其 config.json、安装处理器 api/add.ts 以及应用商店地点注册逻辑 packages/app-store/locations.ts,讲透这一类“静态链接型”集成从声明、安装到校验、预订的全链路实现,读完即可照此模式理解或复刻同类型 App(如 WhatsApp、Campfire 等)。
一、这个集成解决什么问题
官方文档对该应用的定义只有一句话:
Schedule a chat with your guests or have a Telegram Video call.
即:在 Cal.com 的预约事件上,把 Telegram 用户名链接(https://t.me/MyUsername)作为会议地点。访客预约成功后,直接通过该链接发起 Telegram 聊天或视频通话,组织者无需把私人联系方式暴露在页面之外,也不存在“自动生成会议链接”的复杂度——链接由组织者在配置事件时手工填写。
从仓库结构看,该集成目录非常精简,只包含四个有效成员:
- packages/app-store/telegram/DESCRIPTION.md:应用描述(文章主体依据);
- packages/app-store/telegram/config.json:应用声明与地点元数据;
- packages/app-store/telegram/index.ts:仅导出
api命名空间; - packages/app-store/telegram/api/add.ts:声明式安装处理器。
这种“配置驱动、零后端逻辑”的形态,正是 Cal.com App Store 中 linkType: "static" 一类应用的通用设计。
二、应用声明:config.json 逐字段拆解
config.json 是整个集成的元数据中枢,原文如下:
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Telegram",
"slug": "telegram",
"type": "telegram_video",
"logo": "icon.svg",
"url": "https://cal.com/",
"variant": "messaging",
"categories": ["messaging"],
"publisher": "Cal.com, Inc.",
"email": "support@cal.com",
"description": "Schedule a chat with your guests or have a Telegram Video call.",
"__createdUsingCli": true,
"appData": {
"location": {
"type": "integrations:telegram_video",
"label": "Telegram",
"linkType": "static",
"organizerInputPlaceholder": "https://t.me/MyUsername",
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?t.me\\/[a-zA-Z0-9]*"
}
}
}
各字段含义与约束如下:
| 字段 | 值 | 说明 |
|---|---|---|
name |
Telegram |
展示名,也是应用商店列表中的标题 |
slug |
telegram |
全局唯一标识,文件头部注释明确警告:不要手工修改,需通过 CLI edit 命令变更 |
type |
telegram_video |
凭据类型(Credential.type),写入数据库时使用 |
variant |
messaging |
应用形态分类,安装处理器据此路由 |
categories |
["messaging"] |
应用商店分类 |
appData.location.type |
integrations:telegram_video |
地点类型标识,是所有地点逻辑的匹配键 |
appData.location.linkType |
static |
静态链接型:地点值由组织者手工填写 |
appData.location.organizerInputPlaceholder |
https://t.me/MyUsername |
配置界面输入框的占位提示 |
appData.location.urlRegExp |
^http(s)?:\/\/(www\.)?t.me\/[a-zA-Z0-9]* |
链接格式校验正则 |
注意 type 与 appData.location.type 是两个不同的键:前者 telegram_video 用于凭据(Credential)记录,后者 integrations:telegram_video 用于地点(Location)匹配,二者通过安装处理器桥接。
三、安装即声明:零 OAuth 的声明式安装处理器
Telegram 不需要授权码、API Key 或回调地址,因此其安装逻辑完全由声明式处理器承担。api/add.ts 全文如下:
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import { createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
关键点:
appType、variant、slug全部直接取自config.json,保证配置与逻辑不脱节;supportsMultipleInstalls: false——同一用户/团队只允许安装一次,重复安装会被拒绝;handlerType: "add"表明这是“添加应用”处理器;createCredential调用 packages/app-store/_utils/installation.ts 中的createDefaultInstallation,写入一条空 key(key: {})的 Credential 记录,这印证了“无凭据凭据”的设计——Telegram 应用本身不需要任何密钥。
createDefaultInstallation 底层执行:
const installation = await prisma.credential.create({
data: {
type: appType, // "telegram_video"
key, // {} 空对象
...(teamId ? { teamId } : { userId: user.id }),
appId: slug, // "telegram"
subscriptionId,
paymentStatus,
billingCycleStart,
},
});
即:安装动作等价于在 Credential 表中插入一条 type = telegram_video、appId = telegram 的记录,可归属个人(userId)或团队(teamId)。而 api/index.ts 仅做了一件事:export { default as add } from "./add",把该处理器注册到应用商店 API 入口。
四、从 App 元数据到“事件地点”:locations.ts 的动态注册机制
安装之后,Telegram 如何成为事件地点?答案在 packages/app-store/locations.ts 的启动期扫描逻辑:
for (const [appName, meta] of Object.entries(appStoreMetadata)) {
const location = meta.appData?.location;
if (location) {
// 模板变量替换:{SLUG} -> slug, {TITLE} -> name
// 归一化默认值
const newLocation = {
...location,
messageForOrganizer: location.messageForOrganizer || `Set ${location.label} link`,
iconUrl: meta.logo,
variable: location.variable || "locationLink",
defaultValueVariable: location.defaultValueVariable || "link",
};
// 静态链接型:强制要求组织者输入
if (newLocation.linkType === "static") {
newLocation.organizerInputType = location.organizerInputType || "text";
...
}
locationsFromApps.push(newLocation);
}
}
const locations = [...defaultLocations, ...locationsFromApps];
对 Telegram 而言,扫描后的地点对象等价于:
{
type: "integrations:telegram_video",
label: "Telegram",
linkType: "static",
organizerInputPlaceholder: " https://t.me/MyUsername", // 前导空格是为了避免翻译层吞掉 https://
urlRegExp: "^http(s)?:\\/\\/(www\\.)?t.me\\/[a-zA-Z0-9]*",
organizerInputType: "text",
variable: "locationLink",
defaultValueVariable: "link",
iconUrl: "icon.svg",
}
这里有个值得注意的实现细节:linkType: "static" 会强制 organizerInputType = "text",即配置事件时必须由组织者手动填写链接;而 getLocationValueForDB(同文件 locations.ts)在落库时会把地点值替换为 link 字段,存入预订的 location 列。
五、链接校验:urlRegExp 与 zod 双层把关
静态链接型应用在保存事件地点时,会通过 locations.ts 中的 locationsResolver 进行 zod 校验:
.superRefine((val, ctx) => {
if (val?.link) {
const eventLocationType = getLocationByType(val.type);
if (eventLocationType && !eventLocationType.default &&
eventLocationType.linkType === "static" && eventLocationType.urlRegExp) {
const valid = z.string()
.regex(new RegExp(eventLocationType.urlRegExp))
.safeParse(link).success;
if (!valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: t("invalid_url_error_message", {
label: eventLocationType.label,
sampleUrl: sampleUrl ?? "https://cal.com",
}),
});
}
}
}
});
对 Telegram,即校验填写值必须匹配:
^http(s)?:\/\/(www\.)?t.me\/[a-zA-Z0-9]*
这条正则允许 http/https、可选的 www. 前缀,并要求路径为 t.me/<字母数字用户名>。不满足时,界面会提示“无效链接”,并给出示例 https://t.me/MyUsername(即 organizerInputPlaceholder)。除 superRefine 外,guessEventLocationType / getStaticLinkLocationByValue(locations.ts)还支持按值反查地点类型:即使历史数据只存了链接字符串而非类型,也能通过 urlRegExp 匹配回 integrations:telegram_video。
六、预订与展示链路:组织者视角的完整流程
综合以上源码,一次完整的 Telegram 会议预订流程为:
- 安装:用户在应用商店点击安装,
add处理器写入空 key 的 Credential(api/add.ts); - 配置事件:编辑事件类型时选择“Telegram”地点,按占位提示填入
https://t.me/MyUsername,可选勾选“在预订页公开显示”;locationsResolver用urlRegExp校验合法性; - 落库:
getLocationValueForDB将地点类型解析为link值存入预订(locations.ts); - 展示:预订页/确认页通过
getHumanReadableLocationValue、getSuccessPageLocationMessage(locations.ts)将存储值渲染为可点击的 Telegram 链接; - 隐私:若组织者未开启“公开显示”,
privacyFilteredLocations(locations.ts)会在对访客展示时剥掉link字段,仅在确认邮件中给出真实链接。
七、写在最后:如何复刻同类型集成
从 Telegram 这个最小范例可以提炼出“静态链接型”App 的完整实现模板:
- 建立目录
packages/app-store/<slug>/,包含config.json、index.ts、api/add.ts、DESCRIPTION.md与static/素材; - 在
config.json的appData.location中声明linkType: "static"、organizerInputPlaceholder与urlRegExp; api/add.ts复用createDefaultInstallation,无需任何后端鉴权逻辑;- 地点注册、表单校验、落库与展示全部由 packages/app-store/locations.ts 与 packages/app-store/_utils/installation.ts 的统一机制自动完成。
仓库中 linkType: "static" 的同类应用(如 integrations:whatsapp、integrations:campfire_video 等,见 packages/app-store/locations.ts 的注册循环)遵循完全一致的套路。理解 Telegram 这一个例子,就等于掌握了 Cal.com 应用商店中最大的一个集成子类。
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
