如何将 VAPI 连接到 Google Calendar 进行预约安排

发布: (2025年12月13日 GMT+8 08:46)
7 min read
原文: Dev.to

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

  1. 在 Google Cloud Console 中 创建 OAuth 凭证:启用 Calendar API,生成 OAuth 2.0 客户端 ID/secret,并设置重定向 URI。
  2. GOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRETREDIRECT_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

  1. VAPI 助手收到排程请求。
  2. 它调用你的 webhook 并传递函数参数。
  3. 你的服务器验证(或刷新)OAuth 令牌。
  4. 它获取日历可用性并返回时间段。
  5. 助手向用户确认该时间段。
  6. 你的服务器创建事件。

竞争条件警告: 如果用户在可用性检查仍在进行时说“预订下午 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 的生产级预约排程功能。

Back to Blog

相关文章

阅读更多 »

别再买Mac来修复 CSS 了

“黑客”方式在 Windows 与 Linux 上调试 Safari 说实话:Safari 已经成了新的 Internet Explorer。作为网页开发者,我们主要使用 Chromium Chrome……