You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

在JavaScript中配置Google Calendar监听通知及自定义字段问题

Google Calendar 自定义显示字段与Webhook配置问题

核心需求

作为后端开发者刚接触前端与Webhook,希望实现:在Google Calendar创建事件时,添加一个可在日历界面显示的链接字段(如描述下方新增链接)。

当前遇到的两个问题

  1. 自定义字段无法在日历界面显示:尝试用shared extended properties仅能通过代码添加字段,但Google Calendar界面不会展示该内容。
  2. Webhook配置出现401认证错误:自行开发HTML页面创建会议,调用watch接口时返回401未授权错误,无法监听事件创建动作。

相关代码

尝试添加扩展属性的代码

var updatedEvent = 
{
    'summary': event.summary,
    'description': event.description ,
    'start': {
        'dateTime': event.start.dateTime,
        'timeZone': event.start.timeZone
    },
    'end': {
        'dateTime': event.end.dateTime,
        'timeZone': event.end.timeZone
    }, 
    'extendedProperties':
    {
        'shared': {'link': "http://mylink.com"}
    }
};

var request = gapi.client.calendar.events.patch({
    'calendarId': 'primary',
    'eventId': event['id'],
    'resource': updatedEvent
});

request.execute(function (updatedEvent) {
    console.log(updatedEvent);
});

创建事件的代码

const addEvent = () => {
  const title = document.getElementById("title").value;
  const desc = document.getElementById("desc").value;
  const date = document.getElementById("date").value;
  const start = document.getElementById("st").value;
  const end = document.getElementById("et").value;

  const startTime = new Date(date + "," + start).toISOString();
  const endTime = new Date(date + "," + end).toISOString();

  var event = {
    summary: title,
    location: "Google Meet",
    description: desc,
    start: {
      dateTime: startTime,
      timeZone: "America/Los_Angeles"
    },
    end: {
      dateTime: endTime,
      timeZone: "America/Los_Angeles"
    }
  };

  console.log(event);
  var request = gapi.client.calendar.events.insert({
    calendarId: "primary",
    resource: event
  });
 
  request.execute(function(event) {
    console.log(event.htmlLink);
  });
};

监听事件的代码

async function listenForNewEvents()
{
  ++id;
  var response = gapi.client.calendar.events.watch({
    calendarId: 'primary',
    resource: {
      "id": id.toString(),
      "type": "web_hook",
      "address": "https://www.googleapis.com/calendar/v3/calendars/{myname}/events", //window.location.href,
      //"token": "{myname}",
      "params.ttl": 604800
    }
  }, function(error, response) {
    if (error)
    {
      console.log(error);
      return;
    }
    console.log(response);
  }).execute();
}

认证相关代码

// Set to client ID and API key from the Developer Console
const CLIENT_ID = 'blahblah1.apps.googleusercontent.com'; 
const API_KEY = 'blahblah2';

// Discovery doc URL for APIs used by the quickstart
const DISCOVERY_DOC =
  "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest";

// Authorization scopes required by the API; multiple scopes can be
// included, separated by spaces.
const SCOPES = "https://www.googleapis.com/auth/calendar";

let tokenClient;
let gapiInited = false;
let gisInited = false;
let uuid = 1111;

document.getElementById("authorize_button").style.visibility = "hidden";
document.getElementById("signout_button").style.visibility = "hidden";
document.getElementById("event_form").style.visibility = "hidden";

/**
* Callback after api.js is loaded.
*/
function gapiLoaded() {
  gapi.load("client", initializeGapiClient);
}

/**
* Callback after the API client is loaded. Loads the
* discovery doc to initialize the API.
*/
async function initializeGapiClient() {
  await gapi.client.init({
    apiKey: API_KEY,
    discoveryDocs: [DISCOVERY_DOC]
  });
  gapiInited = true;
  maybeEnableButtons();
}

/**
* Callback after Google Identity Services are loaded.
*/
function gisLoaded() {
  tokenClient = google.accounts.oauth2.initTokenClient({
    client_id: CLIENT_ID,
    scope: SCOPES,
    callback: "" // defined later
  });
  gisInited = true;
  maybeEnableButtons();
}

/**
* Enables user interaction after all libraries are loaded.
*/
function maybeEnableButtons() {
  if (gapiInited && gisInited) {
    document.getElementById("authorize_button").style.visibility = "visible";
  }
}

/**
*  Sign in the user upon button click.
*/
function handleAuthClick() {
  tokenClient.callback = async resp => {
    if (resp.error !== undefined) {
      throw resp;
    }
    document.getElementById("signout_button").style.visibility = "visible";
    document.getElementById("event_form").style.visibility = "visible";
    document.getElementById("authorize_button").innerText = "Refresh";
    await listUpcomingEvents();
    await listenForNewEvents();
  };

  if (gapi.client.getToken() === null) {
    // Prompt the user to select a Google Account and ask for consent to share their data
    // when establishing a new session.
    tokenClient.requestAccessToken({ prompt: "consent" });
  } else {
    // Skip display of account chooser and consent dialog for an existing session.
    tokenClient.requestAccessToken({ prompt: "" });
  }
}

/**
*  Sign out the user upon button click.
*/
function handleSignoutClick() {
  const token = gapi.client.getToken();
  if (token !== null) {
    google.accounts.oauth2.revoke(token.access_token);
    gapi.client.setToken("");
    document.getElementById("content").innerText = "";
    document.getElementById("authorize_button").innerText = "Authorize";
    document.getElementById("signout_button").style.visibility = "hidden";
    document.getElementById("event_form").style.visibility = "hidden";
  }
}

收到的401错误信息

{
  "error": {
    "code": 401,
    "message": "Request is missing required authentication credential. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.",
    "errors": [
      {
        "message": "Login Required.",
        "domain": "global",
        "reason": "required",
        "location": "Authorization",
        "locationType": "header"
      }
    ],
    "status": "UNAUTHENTICATED",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.ErrorInfo",
        "reason": "CREDENTIALS_MISSING",
        "domain": "googleapis.com",
        "metadata": {
          "service": "calendar-json.googleapis.com",
          "method": "calendar.v3.Calendars.Get"
        }
      }
    ]
  }
}

问题解决方法

1. 实现可显示的自定义链接字段

Google Calendar的extendedProperties属于后台数据,不会在界面展示,所以需要换一种方式实现:

  • 直接在事件的description字段末尾添加格式化的链接,Google Calendar支持基础Markdown语法,可生成可点击链接:
    // 创建事件时直接加入链接
    var event = {
      summary: title,
      location: "Google Meet",
      // 在描述后添加换行和可点击链接
      description: desc + "\n\n[跳转链接](http://mylink.com)",
      start: {
        dateTime: startTime,
        timeZone: "America/Los_Angeles"
      },
      end: {
        dateTime: endTime,
        timeZone: "America/Los_Angeles"
      }
    };
    
  • 这样用户在日历界面打开事件详情时,就能看到并点击这个链接。

2. 修复Webhook的401错误与配置问题

错误原因分析

  • watch请求中的address字段错误:你填了Google Calendar的API地址,正确的地址应该是你自己的公网可访问服务器地址,Google会向这个地址发送事件变更通知。
  • id字段不唯一:用++id生成的ID重复概率高,需要用唯一标识符(比如UUID)。
  • 权限与认证:虽然你做了OAuth授权,但要确保watch请求使用的token包含https://www.googleapis.com/auth/calendar权限(你的SCOPES已经设置,这部分没问题)。

修正后的Webhook配置代码

async function listenForNewEvents() {
  // 生成唯一ID,替代简单自增
  const uniqueId = crypto.randomUUID(); 
  try {
    const response = await gapi.client.calendar.events.watch({
      calendarId: 'primary',
      resource: {
        "id": uniqueId,
        "type": "web_hook",
        // 替换成你的公网可访问接口地址,比如用ngrok转发本地服务
        "address": "https://your-public-domain.com/calendar-webhook",
        // 可选:添加token用于验证通知来源
        "token": "your-verification-token",
        "params": {
          "ttl": "604800" // 7天有效期,到期需要重新watch
        }
      }
    });
    console.log("Webhook已创建:", response);
  } catch (error) {
    console.error("Webhook创建失败:", error);
  }
}

额外注意事项

  • 公网访问要求:Google无法访问本地localhost地址,测试时可以用ngrok之类的工具将本地服务转发为公网地址。
  • Webhook验证:Google会向你填写的address发送一个POST请求进行验证,你需要返回200 OK,并在响应中包含请求里的hub.challenge参数值。
  • 权限范围:确保OAuth授权时用户同意了https://www.googleapis.com/auth/calendar权限(你的代码中SCOPES已正确设置)。
  • 重复watch问题:每次调用watch会创建一个新的订阅,建议先检查已有订阅并清理过期的,避免重复通知。

内容的提问来源于stack exchange,提问作者bluetooth16

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.26 22:37:05