如何将 VAPI 连接到 Google Calendar 进行预约安排
Source: Dev.to
TL;DR
大多数日历集成在 OAuth 令牌在通话中途过期或时区不匹配导致可用性检查出错时会失效。本文档展示了如何构建一个 VAPI 助手,处理 Google Calendar OAuth 流程、将令牌映射到会话状态,并在没有竞争条件的情况下查询可用时间。你需要在助手的 tools 数组中正确指定 calendarId,实现令牌刷新逻辑,并处理预订冲突。结果:生产级的排程系统,既不会双重预订,也不会因令牌过期而崩溃。
Prerequisites
API Access
- VAPI API key(从 dashboard.vapi.ai 获取)
- 已启用 Calendar API 的 Google Cloud 项目
- OAuth 2.0 凭证(Client ID + Secret),来自 Google Cloud Console
- Service‑account JSON 密钥 或 具备
calendar.events范围的用户 OAuth 令牌
Development Environment
- Node.js 18+(用于 webhook 服务器)
- ngrok 或类似的隧道工具(VAPI 需要公开的 HTTPS 端点)
- 环境变量管理器(例如
dotenv)
Google Calendar Setup
- 目标日历 ID(在日历设置 → “Integrate calendar” 中可找到)
- 若使用服务账号,需要验证域名所有权
- 已配置的日历共享权限(读/写访问)
Technical Knowledge
- OAuth 2.0 令牌刷新流程(令牌每 3600 秒过期一次)
- Webhook 签名校验(VAPI 为所有请求签名)
- VAPI 助手
tools数组中的函数调用语法 - 时区处理(Calendar API 使用 RFC 3339 格式)
VAPI: Get started → Get VAPI
Step‑By‑Step Tutorial
Configuration & Setup
- 在 Google Cloud Console 中 创建 OAuth 凭证:启用 Calendar API,生成 OAuth 2.0 客户端 ID/secret,并设置重定向 URI。
- 将
GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET、REDIRECT_URI存入环境变量——不要硬编码。
// OAuth token exchange – handles user authorization callback
const exchangeCodeForToken = async (authCode) => {
try {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code: authCode,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: process.env.REDIRECT_URI,
grant_type: 'authorization_code',
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`OAuth failed: ${error.error_description}`);
}
const tokens = await response.json();
// Store tokens.access_token and tokens.refresh_token per user
return tokens;
} catch (error) {
console.error('Token exchange error:', error);
throw error;
}
};
关键点: 刷新令牌也会过期。务必在任何 Calendar API 调用之前实现令牌刷新逻辑。大多数生产环境的失败都是因为对话中途访问令牌失效(401 错误)导致的。
Architecture & Flow
- VAPI 助手收到排程请求。
- 它调用你的 webhook 并传递函数参数。
- 你的服务器验证(或刷新)OAuth 令牌。
- 它获取日历可用性并返回时间段。
- 助手向用户确认该时间段。
- 你的服务器创建事件。
竞争条件警告: 如果用户在可用性检查仍在进行时说“预订下午 2 点”,请将预订请求排队。不要并发调用 Calendar API——Google 对每位用户的速率限制为 1000 请求/100 秒。
Step‑By‑Step Implementation
1. Configure VAPI assistant with function‑calling tools
const assistantConfig = {
model: {
provider: "openai",
model: "gpt-4",
messages: [
{
role: "system",
content:
"You schedule appointments. Ask for date, time, duration. Confirm before booking.",
},
],
},
voice: {
provider: "11labs",
voiceId: "21m00Tcm4TlvDq8ikWAM",
},
functions: [
{
name: "check_availability",
description: "Check calendar availability for given date range",
parameters: {
type: "object",
properties: {
calendarId: { type: "string", description: "User's calendar ID (email)" },
timeMin: { type: "string", description: "Start time ISO 8601" },
timeMax: { type: "string", description: "End time ISO 8601" },
},
required: ["calendarId", "timeMin", "timeMax"],
},
},
{
name: "create_event",
description: "Create calendar event after user confirms",
parameters: {
type: "object",
properties: {
calendarId: { type: "string" },
summary: { type: "string", description: "Event title" },
start: { type: "string", description: "Start time ISO 8601" },
end: { type: "string", description: "End time ISO 8601" },
attendees: {
type: "array",
items: { type: "string" },
description: "Attendee emails",
},
},
required: ["calendarId", "summary", "start", "end"],
},
},
],
};
2. Build webhook handler for function execution
app.post("/webhook/vapi", async (req, res) => {
const { message } = req.body;
if (message.type === "function-call") {
const { functionCall } = message;
const userId = message.call.metadata?.userId; // Pass during call creation
try {
// ---------- Check Availability ----------
if (functionCall.name === "check_availability") {
const { calendarId, timeMin, timeMax } = functionCall.parameters;
const accessToken = await getValidToken(userId); // Handles refresh
const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(
calendarId
)}/events?timeMin=${encodeURIComponent(
timeMin
)}&timeMax=${encodeURIComponent(
timeMax
)}&singleEvents=true`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
if (!response.ok) throw new Error(`Calendar API error: ${response.status}`);
const data = await response.json();
const busySlots = data.items.map((event) => ({
start: event.start.dateTime,
end: event.end.dateTime,
}));
return res.json({ result: { busySlots } });
}
// ---------- Create Event ----------
if (functionCall.name === "create_event") {
const { calendarId, summary, start, end, attendees } = functionCall.parameters;
const accessToken = await getValidToken(userId);
const eventBody = {
summary,
start: { dateTime: start, timeZone: "America/New_York" },
end: { dateTime: end, timeZone: "America/New_York" },
attendees: attendees?.map((email) => ({ email })) || [],
};
const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(
calendarId
)}/events`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(eventBody),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Event creation failed: ${error.error.message}`);
}
const event = await response.json();
return res.json({ result: { eventId: event.id, link: event.htmlLink } });
}
} catch (error) {
console.error("Function execution error:", error);
return res.json({ error: { message: error.message } });
}
}
// Fallback for non‑function calls
res.json({ received: true });
});
3. Implement token refresh to prevent mid‑call failures
const getValidToken = async (userId) => {
const stored = await db.getTokens(userId); // Your DB lookup
const expiresAt = stored.expires_at; // Unix timestamp (seconds)
// Refresh 5 minutes before expiry
if (Date.now() / 1000 > expiresAt - 300) {
const refreshResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
refresh_token: stored.refresh_token,
grant_type: "refresh_token",
}),
});
if (!refreshResponse.ok) {
const err = await refreshResponse.json();
throw new Error(`Refresh failed: ${err.error_description}`);
}
const newTokens = await refreshResponse.json();
// Update DB with new access token and expiry
await db.updateTokens(userId, {
access_token: newTokens.access_token,
expires_at: Math.floor(Date.now() / 1000) + newTokens.expires_in,
});
return newTokens.access_token;
}
return stored.access_token;
};
有了上述组件,你的 VAPI 助手就可以可靠地:
- 通过 Google OAuth 对用户进行身份验证
- 自动刷新令牌
- 在没有竞争条件的情况下查询日历可用性
- 安全创建事件,妥善处理冲突和错误
部署 webhook(例如使用 ngrok 进行本地测试),在 VAPI 控制台中注册公开的 URL,即可提供基于 Google Calendar 的生产级预约排程功能。