在 Cloud Run 上使用 Node.js 构建自定义 Google Workspace Studio 步骤

1. 简介

借助 Google Workspace Studio ,您可以自动执行 Gmail、Google Chat、Google 云端硬盘和 Google 表格等 Google Workspace 应用中的业务流程。如果您的工作流需要从外部 Web 应用启动,或者需要在您自己的云环境中运行自定义逻辑,您可以使用 Cloud Run 上的 Node.js 中的 HTTP Google Workspace 插件 扩展 Google Workspace Studio:

  1. 自定义启动器步骤 (workflowTrigger):当外部事件发生时(例如客户在内部 Web 门户中提交工单),通过调用触发器的 notifyUri(或 Google Workspace Studio triggers.fire REST API)启动 Google Workspace Studio 流程。
  2. 自定义操作步骤 (workflowAction):接收流程中前面步骤的变量,在 Node.js 服务中运行自定义逻辑或 AI 推理,并将输出变量返回给 Google Workspace Studio。

在本 Codelab 中,您将在 Cloud Run 上为 Google Workspace Studio 构建并部署完整的 Node.js HTTP 扩展程序,使用 Cloud Firestore 在容器修订版本之间持久保存有效流程触发器订阅和已提交的工单,使用 Cloud Tasks 在 notifyUri 未返回成功响应时自动重试触发器通知,并在自定义操作步骤中使用 Gemini Enterprise Agent Platform 上的 Gemini 3.8 Flash (gemini-3.8-flash) 对工单紧急程度进行分类,并提供弹性关键字匹配后备。

构建内容

  • 一个 Support Quick Portal Web 应用和 HTTP Google Workspace 插件,使用 Node.js 、Express 、Firestore 和 Cloud Tasks 在 Cloud Run 上构建。
  • 一个 New Support Ticket 自定义启动器步骤 (supportTicketTrigger),用于在 studio_triggers Firestore 集合中保留有效流程订阅,在 support_tickets Firestore 集合中记录已提交的工单,并在 Web 门户中提交工单时触发,将 ticketTitle 和 ticketDescription 传递到 Google Workspace Studio 流程中。
  • 一个 Cloud Tasks 重试工作器 (studio-trigger-retries 队列和 POST /api/tasks/fire-trigger),用于使用存储在 Firestore 中的最新 userOAuthToken 以指数退避算法将失败的触发器调用加入队列并重试,保证将内容发送到 notifyUri。
  • 一个 Detect Urgency 自定义操作步骤 (urgencyDetectorStep),用于使用 Gemini Enterprise Agent Platform (aiplatform.googleapis.com) 上的 Gemini 3.8 Flash (gemini-3.8-flash) 对工单紧急程度进行分类,并自动回退到关键字匹配(urgent、blocker、critical)。

架构

下图说明了 Support Quick Portal、Cloud Run 上的 Node.js 插件、Cloud Firestore、Cloud Tasks (studio-trigger-retries)、Gemini 3.8 Flash 和 Google Workspace Studio 如何从流程注册到弹性触发器传送和操作执行进行交互:

Google Workspace Studio Node.js 插件架构,采用 Firestore、Cloud Tasks 和 Gemini 3.8 Flash

学习内容

  • 如何使用 workflowTrigger 和 workflowAction 元素配置 HTTP Google Workspace 插件部署清单 (deployment.json)。
  • 如何在 studio_triggers Firestore 集合中持久保存 Google Workspace Studio 触发器订阅(triggerId、notifyUri 和 userOAuthToken),以及如何使用 notifyUri / triggers.fire 触发流程。
  • 如何使用 Cloud Tasks (@google-cloud/tasks) 以指数退避算法重试失败的触发器通知,直到 notifyUri 返回 2xx 成功响应。
  • 如何使用 Gemini Enterprise Agent Platform 上的 Gemini 3.8 Flash (gemini-3.8-flash) 结构化输出(并提供关键字回退)在 Node.js 中构建 workflowAction 自定义步骤。

前提条件

  • 一个 Google Workspace 账号,并且可以访问 Google Workspace Studio。
  • 一个已启用结算功能的 Google Cloud 项目 ,该项目是在与您的用户账号相同的 Google Workspace 组织内创建的,因此您可以安装未发布的插件部署以进行测试。

2. 设置 Google Cloud 云项目、Firestore 和 Cloud Tasks

在此步骤中,您将配置项目的 OAuth 权限请求页面,在 Cloud Shell 中启用所需的 Google Cloud 和 Google Workspace API,预配 (default) Firestore 数据库,创建 Cloud Tasks 重试队列,并初始化插件授权服务账号。

  1. 打开 Google Cloud 控制台,选择您的云项目,然后点击顶部导航栏中的 激活 Cloud Shell 图标。
  2. 配置 OAuth 权限请求页面 ,以便 Google Workspace 可以为您的插件预配 OAuth 客户端:
    1. 打开 Google Auth Platform 品牌页面。
    2. 如果系统提示,请点击开始使用 。(如果您的权限请求页面已配置,请跳至第 3 步。)
    3. 在应用信息 下,为应用名称 输入 Support Extension,选择您的用户支持电子邮件地址 ,然后点击下一步 。
    4. 在目标对象 下,选择内部 ,然后点击下一步 。
    5. 在联系信息 下,输入您的电子邮件地址,然后点击下一步 。
    6. 在完成 下,同意 Google API 服务:用户数据政策 ,点击继续 ,然后点击创建 。
  3. 在 Cloud Shell 中,设置您的活跃项目(将 YOUR_PROJECT_ID 替换为您的 Google Cloud 项目 ID),并启用所需的 API:
    gcloud config set project YOUR_PROJECT_ID
    gcloud services enable \
      aiplatform.googleapis.com \
      firestore.googleapis.com \
      cloudtasks.googleapis.com \
      run.googleapis.com \
      cloudbuild.googleapis.com \
      artifactregistry.googleapis.com \
      appsmarket-component.googleapis.com \
      gsuiteaddons.googleapis.com \
      workspacestudio.googleapis.com
    
  4. 在 us-central1 中预配 (default) Firestore 数据库和 studio-trigger-retries Cloud Tasks 队列(如果它们尚不存在),并初始化您的 Google Workspace 插件服务账号:
    gcloud firestore databases describe --database="(default)" >/dev/null 2>&1 || \
      gcloud firestore databases create \
        --database="(default)" \
        --location=us-central1 \
        --type=firestore-native \
        --quiet
    
    gcloud tasks queues describe studio-trigger-retries --location=us-central1 >/dev/null 2>&1 || \
      gcloud tasks queues create studio-trigger-retries \
        --location=us-central1 \
        --max-attempts=5 \
        --min-backoff=10s \
        --max-backoff=300s \
        --max-doublings=4
    
    gcloud workspace-add-ons get-authorization
    

3. 使用 Firestore、Cloud Tasks 和 Gemini 3.8 Flash 构建 Node.js 应用

在此步骤中,您将在 ~/studio-support-addon 中构建 Node.js 应用。为了使代码模块化且易于阅读,您将项目组织为六个重点文件:

  • firestore.js:初始化 Cloud Firestore 客户端(studio_triggers 和 support_tickets 集合),并刷新缓存的 OAuth 令牌。
  • starter.js:实现 New Support Ticket 自定义启动器步骤 Webhook(/onConfigTrigger 和 /onManageTrigger)。
  • action.js:使用 Gemini 3.8 Flash (gemini-3.8-flash) 实现 Detect Urgency 自定义操作步骤 Webhook(/onConfigUrgency 和 /onExecuteUrgency),并提供关键字匹配回退。
  • tasks.js:将触发器通知分派到 trigger.notifyUri(回退到 https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire),将失败的传送加入 studio-trigger-retries Cloud Tasks 队列,并实现 POST /api/tasks/fire-trigger 重试工作器端点。
  • portal.js:提供 Support Quick Portal Web 界面 (GET /) 并触发有效的 Google Workspace Studio 触发器 (POST /api/submit-ticket),并提供模拟暂时性故障和观看 Cloud Tasks 重试的选项。
  • index.js:将所有 Express 路由连接在一起并启动 HTTP 服务器。

初始化项目和 package.json

  1. 创建项目目录并导航至该目录:
    mkdir -p ~/studio-support-addon && cd ~/studio-support-addon
    
  2. 使用 express、@google-cloud/firestore、@google-cloud/tasks 和 @google/genai 创建 package.json:
    cat << 'EOF' > package.json
    {
      "name": "studio-support-addon",
      "version": "1.0.0",
      "type": "module",
      "main": "index.js",
      "scripts": {
        "start": "node index.js"
      },
      "dependencies": {
        "@google-cloud/firestore": "^7.11.0",
        "@google-cloud/tasks": "^5.6.0",
        "@google/genai": "^1.0.0",
        "express": "^4.21.2"
      }
    }
    EOF
    

firestore.js - 共享 Firestore 状态和 OAuth 令牌刷新

创建 firestore.js 以管理您的两个 Firestore 集合,并保持缓存的用户 OAuth 令牌处于最新状态:

  • studio_triggers:存储有效的 Google Workspace Studio 流程订阅(triggerId、notifyUri 和 userOAuthToken),以便它们在 Cloud Run 容器重启和缩减至零事件后仍然存在。
  • support_tickets:存储通过门户提交的每个支持服务工单的记录。
  • refreshTokenInFirestore(req):每当 Google Workspace Studio 调用您的任何 Webhook 端点时,它都会包含一个生命周期较短的 authorizationEventObject.userOAuthToken。此帮助程序会使用最新令牌更新有效的触发器文档,以便后台 triggers.fire 调用保持授权状态。
cat << 'EOF' > firestore.js
import { Firestore } from '@google-cloud/firestore';

export const db = new Firestore({
  projectId: process.env.GOOGLE_CLOUD_PROJECT || undefined,
  databaseId: '(default)',
  ignoreUndefinedProperties: true,
});

export const triggersCollection = db.collection(
  process.env.FIRESTORE_TRIGGERS_COLLECTION || 'studio_triggers'
);

export const ticketsCollection = db.collection(
  process.env.FIRESTORE_TICKETS_COLLECTION || 'support_tickets'
);

// Keep cached userOAuthToken fresh across active trigger documents
export async function refreshTokenInFirestore(req) {
  const freshToken = req.body?.authorizationEventObject?.userOAuthToken;
  if (!freshToken) return;
  try {
    const snapshot = await triggersCollection.get();
    if (snapshot.empty) return;
    const batch = db.batch();
    snapshot.docs.forEach((doc) => {
      batch.set(
        doc.ref,
        { userOAuthToken: freshToken, updatedAt: new Date().toISOString() },
        { merge: true }
      );
    });
    await batch.commit();
  } catch (err) {
    console.error('Error refreshing OAuth token in Firestore:', err.message);
  }
}
EOF

starter.js - 自定义启动器步骤 (workflowTrigger)

创建 starter.js 以实现 Google Workspace Studio 自定义启动器步骤所需的两个 Webhook 端点:

  • onConfigTrigger (POST /onConfigTrigger):当用户在 Google Workspace Studio 中选择 New Support Ticket 作为其流程的启动器时调用。返回描述启动器的 pushCard 导航操作。
  • onManageTrigger (POST /onManageTrigger):当用户将流程开启 (req.body.workflow.triggerCreation) 或将其关闭 / 删除 (req.body.workflow.triggerDeletion) 时调用。在 studio_triggers Firestore 集合中保存或删除订阅文档。
cat << 'EOF' > starter.js
import { triggersCollection, refreshTokenInFirestore } from './firestore.js';

// Custom Starter Configuration Card
export async function onConfigTrigger(req, res) {
  await refreshTokenInFirestore(req);
  res.status(200).json({
    action: {
      navigations: [
        {
          pushCard: {
            sections: [
              {
                header: 'Support Ticket Listener',
                widgets: [
                  {
                    textParagraph: {
                      text: 'This starter listens for new tickets submitted through the Support Quick Portal and stores subscriptions in Firestore.',
                    },
                  },
                ],
              },
            ],
          },
        },
      ],
    },
  });
}

// Custom Starter Subscription Lifecycle (Firestore studio_triggers collection)
export async function onManageTrigger(req, res) {
  try {
    const triggerCreation = req.body?.workflow?.triggerCreation;
    const triggerDeletion = req.body?.workflow?.triggerDeletion;
    const userOAuthToken =
      req.body?.authorizationEventObject?.userOAuthToken || null;

    if (triggerCreation?.triggerId) {
      const triggerId = String(triggerCreation.triggerId);
      const docId = encodeURIComponent(triggerId);
      await triggersCollection.doc(docId).set(
        {
          triggerId,
          notifyUri: triggerCreation.notifyUri || null,
          ...(userOAuthToken ? { userOAuthToken } : {}),
          updatedAt: new Date().toISOString(),
        },
        { merge: true }
      );
    }

    if (triggerDeletion?.triggerId) {
      const triggerId = String(triggerDeletion.triggerId);
      const docId = encodeURIComponent(triggerId);
      await triggersCollection.doc(docId).delete();
    }

    if (!triggerCreation && !triggerDeletion && userOAuthToken) {
      await refreshTokenInFirestore(req);
    }
  } catch (err) {
    console.error('Error in /onManageTrigger:', err.message);
  }
  res.status(200).json({});
}
EOF

action.js - 自定义操作步骤 (workflowAction),使用 Gemini 3.8 Flash

创建 action.js 以实现 Google Workspace Studio 自定义操作步骤所需的两个 Webhook 端点:

  • onConfigUrgency (POST /onConfigUrgency):返回一个配置卡片,其中包含一个 textInput widget,用于设置 hostAppDataSource.workflowDataSource.includeVariables: true,允许用户选择启动器步骤输出的 ticketDescription 变量。
  • onExecuteUrgency (POST /onExecuteUrgency):从 req.body.workflow.actionInvocation.inputs 读取 ticketDescription 输入,使用 Gemini Enterprise Agent Platform 上的 Gemini 3.8 Flash (gemini-3.8-flash) 对其业务影响和紧急程度进行分类,并提供 结构化输出 (responseSchema 枚举 ["High", "Normal"]),如果模型调用失败,则回退到 detectKeywordUrgency() (urgent、blocker、critical),并使用 hostAppAction.workflowAction.returnOutputVariablesAction 返回 urgencyLevel 输出变量。
cat << 'EOF' > action.js
import { GoogleGenAI, Type } from '@google/genai';
import { refreshTokenInFirestore } from './firestore.js';

// Initialize Google GenAI SDK for the Gemini Enterprise Agent Platform
const ai = new GoogleGenAI({
  vertexai: true,
  project: process.env.GOOGLE_CLOUD_PROJECT,
  location: 'global',
});

// Fallback keyword classifier if the Gemini API call is unavailable
export function detectKeywordUrgency(description) {
  const lower = (description || '').toLowerCase();
  return lower.includes('urgent') ||
    lower.includes('blocker') ||
    lower.includes('critical')
    ? 'High'
    : 'Normal';
}

// Custom Action Step Configuration Card
export async function onConfigUrgency(req, res) {
  await refreshTokenInFirestore(req);
  res.status(200).json({
    action: {
      navigations: [
        {
          pushCard: {
            sections: [
              {
                header: 'Urgency Detector Step',
                widgets: [
                  {
                    textInput: {
                      name: 'ticketDescription',
                      label: 'Source Description',
                      hintText:
                        'Select the description variable outputted by the starter',
                      hostAppDataSource: {
                        workflowDataSource: {
                          includeVariables: true,
                        },
                      },
                    },
                  },
                ],
              },
            ],
          },
        },
      ],
    },
  });
}

// Custom Action Step Execution: Gemini 3.8 Flash with keyword fallback
export async function onExecuteUrgency(req, res) {
  await refreshTokenInFirestore(req);
  const inputs = req.body?.workflow?.actionInvocation?.inputs || {};
  const description = inputs.ticketDescription?.stringValues?.[0] || '';

  let urgencyLevel = detectKeywordUrgency(description);
  try {
    const response = await ai.models.generateContent({
      model: 'gemini-3.8-flash',
      contents: `Evaluate the business impact and time sensitivity of this support ticket description and classify its urgency:\n\n${description}`,
      config: {
        responseMimeType: 'text/x.enum',
        responseSchema: {
          type: Type.STRING,
          enum: ['High', 'Normal'],
        },
      },
    });
    const candidate = (response.text || '').trim();
    if (candidate === 'High' || candidate === 'Normal') {
      urgencyLevel = candidate;
    }
  } catch (err) {
    console.error('Gemini fallback to keyword matching:', err.message);
  }

  res.status(200).json({
    hostAppAction: {
      workflowAction: {
        returnOutputVariablesAction: {
          variables: {
            urgencyLevel: {
              stringValues: [urgencyLevel],
            },
          },
        },
      },
    },
  });
}
EOF

tasks.js - Cloud Tasks 重试队列和工作器端点

创建 tasks.js 以将触发器调用分派到 trigger.notifyUri(回退到 https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire),将失败的调用加入 studio-trigger-retries Cloud Tasks 队列,并在 POST /api/tasks/fire-trigger 处理重试:

  • resolveTriggerUrl(trigger):使用 triggerCreation.notifyUri 中保留的 trigger.notifyUri(如果存在),否则回退到 https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire。
  • fireTriggerDirect(trigger, payload):使用 ticketTitle 和 ticketDescription 输出变量以及幂等的 requestId 将 POST 请求发送到触发器网址。
  • enqueueTriggerRetry(req, payload):每当初始触发器调用失败或返回非 2xx HTTP 状态时,在以 Cloud Run 服务的 POST /api/tasks/fire-trigger 端点为目标的 studio-trigger-retries 队列中加入 HTTP 任务。
  • handleFireTriggerTask (POST /api/tasks/fire-trigger):由 Cloud Tasks 在每次重试尝试时调用。在 Firestore 中查找 studio_triggers 中的最新 trigger 文档(确保重试始终使用最新的 userOAuthToken),检查 X-CloudTasks-TaskRetryCount 标头(如果启用了 simulateFailure,则第一次 Cloud Tasks 执行会失败并返回 503,以便您可以观看 Cloud Tasks 退避并成功重试 #1),并且仅当 notifyUri 返回 2xx 成功响应时才返回 200 OK。任何 5xx 响应都会告知 Cloud Tasks 根据队列的退避政策再次重试。
cat << 'EOF' > tasks.js
import { CloudTasksClient } from '@google-cloud/tasks';
import { triggersCollection } from './firestore.js';

const tasksClient = new CloudTasksClient();
const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const LOCATION = process.env.CLOUD_TASKS_LOCATION || 'us-central1';
const QUEUE = process.env.CLOUD_TASKS_QUEUE || 'studio-trigger-retries';

// Prefer trigger.notifyUri from triggerCreation, falling back to triggers.fire
export function resolveTriggerUrl(trigger) {
  return (
    trigger.notifyUri ||
    `https://workspacestudio.googleapis.com/v1/triggers/${encodeURIComponent(trigger.triggerId)}:fire`
  );
}

// Call the Google Workspace Studio trigger URL directly
export async function fireTriggerDirect(
  trigger,
  { title, description, requestId }
) {
  const targetUrl = resolveTriggerUrl(trigger);
  const response = await fetch(targetUrl, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${trigger.userOAuthToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: 'triggers/' + trigger.triggerId,
      outputs: {
        ticketTitle: { stringValues: [title] },
        ticketDescription: { stringValues: [description] },
      },
      requestId,
    }),
  });
  return { ok: response.ok, status: response.status, targetUrl };
}

// Enqueue a retry task in Cloud Tasks targeting POST /api/tasks/fire-trigger
export async function enqueueTriggerRetry(req, payload) {
  const parent = tasksClient.queuePath(PROJECT_ID, LOCATION, QUEUE);
  const baseUrl = process.env.SERVICE_URL || `https://${req.get('host')}`;
  const workerUrl = `${baseUrl}/api/tasks/fire-trigger`;

  const task = {
    httpRequest: {
      httpMethod: 'POST',
      url: workerUrl,
      headers: { 'Content-Type': 'application/json' },
      body: Buffer.from(JSON.stringify(payload)).toString('base64'),
    },
  };

  const [response] = await tasksClient.createTask({ parent, task });
  return response.name;
}

// Cloud Tasks worker endpoint: retries calling notifyUri using fresh Firestore state
export async function handleFireTriggerTask(req, res) {
  const { triggerId, title, description, requestId, simulateFailure } =
    req.body || {};
  const retryCount = Number(req.get('X-CloudTasks-TaskRetryCount') || 0);

  if (!triggerId) {
    return res.status(200).json({ skipped: true, reason: 'Missing triggerId' });
  }

  try {
    const docId = encodeURIComponent(String(triggerId));
    const doc = await triggersCollection.doc(docId).get();
    if (!doc.exists) {
      return res
        .status(200)
        .json({ skipped: true, reason: 'Trigger no longer active' });
    }

    const trigger = doc.data();
    if (!trigger.userOAuthToken) {
      return res
        .status(503)
        .json({ ok: false, error: 'Missing userOAuthToken in Firestore' });
    }

    // When simulateFailure is enabled, fail the 1st Cloud Task attempt (retryCount === 0)
    // so learners can observe Cloud Tasks back off and succeed on retry #1.
    if (simulateFailure && retryCount < 1) {
      console.warn(
        `[Cloud Tasks] Simulated transient failure on initial task attempt (retryCount=${retryCount}) for trigger ${triggerId}`
      );
      return res
        .status(503)
        .json({ ok: false, simulated: true, retryCount, triggerId });
    }

    const outcome = await fireTriggerDirect(trigger, {
      title,
      description,
      requestId,
    });

    if (outcome.ok) {
      console.log(
        `[Cloud Tasks] Successfully delivered trigger ${triggerId} to ${outcome.targetUrl} on retryCount=${retryCount}`
      );
      return res
        .status(200)
        .json({ ok: true, status: outcome.status, retryCount });
    }

    console.error(
      `[Cloud Tasks] Non-2xx response (${outcome.status}) from ${outcome.targetUrl} on retryCount=${retryCount}`
    );
    return res
      .status(502)
      .json({ ok: false, status: outcome.status, retryCount });
  } catch (err) {
    console.error(
      `[Cloud Tasks] Error firing trigger ${triggerId} on retryCount=${retryCount}:`,
      err.message
    );
    return res
      .status(503)
      .json({ ok: false, error: err.message, retryCount });
  }
}
EOF

portal.js - Support Quick Portal 界面和 notifyUri 分派器,提供 Cloud Tasks 回退

创建 portal.js 以提供 Web 门户,并将工单提交分派到 Google Workspace Studio,并自动安排 Cloud Tasks 重试:

  • renderPortal (GET /):查询 studio_triggers Firestore 集合以显示已注册的有效 Google Workspace Studio 触发器的数量,并提供 HTML 工单提交表单(包括用于测试 Cloud Tasks 重试的模拟暂时性故障 复选框)。
  • submitTicket (POST /api/submit-ticket):遍历 studio_triggers 中的所有有效触发器文档,并调用 fireTriggerDirect()。如果触发器网址返回非 2xx 响应、抛出网络连接错误或启用了 simulateFailure,则它会自动使用 enqueueTriggerRetry() 在 studio-trigger-retries 中加入重试任务,并在 support_tickets Firestore 集合中记录工单。
cat << 'EOF' > portal.js
import { triggersCollection, ticketsCollection } from './firestore.js';
import { fireTriggerDirect, enqueueTriggerRetry } from './tasks.js';

// Support Quick Portal UI
export async function renderPortal(req, res) {
  let activeCount = 0;
  try {
    const snapshot = await triggersCollection.get();
    activeCount = snapshot.size;
  } catch (err) {
    console.error('Error reading studio_triggers collection:', err.message);
  }

  res.status(200).send(`<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Support Quick Portal</title>
    <style>
      body { font-family: Arial, sans-serif; padding: 24px; max-width: 540px; margin: auto; color: #202124; }
      .badge { background: #e8f0fe; color: #1967d2; padding: 8px 12px; border-radius: 6px; font-size: 14px; margin-bottom: 16px; }
      label { font-weight: bold; display: block; margin-top: 16px; }
      .checkbox-label { font-weight: normal; margin-top: 14px; display: flex; align-items: center; gap: 8px; cursor: pointer; }
      .checkbox-label input { width: auto; margin: 0; }
      input, textarea { width: 100%; margin-top: 6px; padding: 10px; box-sizing: border-box; border: 1px solid #dadce0; border-radius: 4px; }
      button { background-color: #1a73e8; color: white; border: none; padding: 10px 18px; margin-top: 16px; font-weight: bold; cursor: pointer; border-radius: 4px; }
      button:hover { background-color: #1557b0; }
      #status { margin-top: 16px; font-weight: bold; }
    </style>
  </head>
  <body>
    <h2>Support Quick Portal</h2>
    <div class="badge">
      Active Google Workspace Studio triggers in Firestore (<code>studio_triggers</code>):
      <strong id="trigger-count">${activeCount}</strong>
    </div>
    <p>Submit a support ticket below to fire your Google Workspace Studio flow.</p>

    <label for="title">Ticket Title</label>
    <input type="text" id="title" placeholder="e.g. Database failure">

    <label for="description">Issue Description</label>
    <textarea id="description" rows="4" placeholder="Describe the issue..."></textarea>

    <label class="checkbox-label" for="simulateFailure">
      <input type="checkbox" id="simulateFailure">
      Simulate transient failure (enqueue in Cloud Tasks and retry notifyUri)
    </label>

    <button onclick="submitTicket()">Submit Ticket</button>
    <div id="status"></div>

    <script>
      async function submitTicket() {
        const title = document.getElementById('title').value.trim();
        const description = document.getElementById('description').value.trim();
        const simulateFailure = document.getElementById('simulateFailure').checked;
        const statusEl = document.getElementById('status');

        if (!title || !description) {
          statusEl.style.color = '#d93025';
          statusEl.textContent = 'Please fill out both fields.';
          return;
        }

        statusEl.style.color = '#188038';
        statusEl.textContent = 'Submitting ticket...';

        try {
          const res = await fetch('/api/submit-ticket', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ title, description, simulateFailure }),
          });
          const data = await res.json();
          if (data.ok) {
            if (data.tasksQueued > 0) {
              statusEl.textContent =
                'Ticket saved! Initial notifyUri call did not succeed; queued ' +
                data.tasksQueued +
                ' retry task(s) in Cloud Tasks.';
            } else {
              statusEl.textContent =
                'Ticket saved to Firestore! Fired ' +
                data.triggersFired +
                ' Google Workspace Studio trigger(s).';
            }
          } else {
            statusEl.style.color = '#d93025';
            statusEl.textContent = 'Error: ' + (data.error || 'Unknown error');
          }
        } catch (err) {
          statusEl.style.color = '#d93025';
          statusEl.textContent = 'Request failed: ' + err.message;
        }
      }
    </script>
  </body>
</html>`);
}

// Portal Form Submission -> Call notifyUri directly, or enqueue Cloud Task on failure
export async function submitTicket(req, res) {
  const title = req.body?.title || 'Untitled Ticket';
  const description = req.body?.description || '';
  const simulateFailure = Boolean(req.body?.simulateFailure);
  const results = [];
  let triggersFired = 0;
  let tasksQueued = 0;

  try {
    const snapshot = await triggersCollection.get();
    for (const doc of snapshot.docs) {
      const trigger = doc.data();
      if (!trigger.triggerId || !trigger.userOAuthToken) continue;

      const requestId =
        'req_' + Date.now() + '_' + Math.floor(Math.random() * 1000);

      let outcome = { ok: false, status: 503 };
      if (!simulateFailure) {
        try {
          outcome = await fireTriggerDirect(trigger, {
            title,
            description,
            requestId,
          });
        } catch (fetchErr) {
          console.error(
            `Direct trigger call failed for ${trigger.triggerId}:`,
            fetchErr.message
          );
          outcome = { ok: false, status: 0, error: fetchErr.message };
        }
      }

      if (outcome.ok) {
        triggersFired++;
        results.push({
          triggerId: trigger.triggerId,
          status: outcome.status,
          queuedRetry: false,
        });
      } else {
        const taskName = await enqueueTriggerRetry(req, {
          triggerId: trigger.triggerId,
          title,
          description,
          requestId,
          simulateFailure,
        });
        tasksQueued++;
        results.push({
          triggerId: trigger.triggerId,
          status: outcome.status,
          queuedRetry: true,
          taskName,
        });
      }
    }

    await ticketsCollection.add({
      title,
      description,
      triggersFired,
      tasksQueued,
      createdAt: new Date().toISOString(),
    });

    res.status(200).json({
      ok: true,
      triggersFired,
      tasksQueued,
      results,
    });
  } catch (err) {
    console.error('Error in /api/submit-ticket:', err.message);
    res.status(500).json({ ok: false, error: err.message });
  }
}
EOF

index.js - Express 服务器入口点

创建 index.js 以将门户路由、Cloud Tasks 重试工作器端点和 Google Workspace Studio Webhook 端点连接到在 PORT(Cloud Run 上的默认值为 8080)上监听的 Express HTTP 服务器:

cat << 'EOF' > index.js
import express from 'express';
import { onConfigTrigger, onManageTrigger } from './starter.js';
import { onConfigUrgency, onExecuteUrgency } from './action.js';
import { renderPortal, submitTicket } from './portal.js';
import { handleFireTriggerTask } from './tasks.js';

const app = express();
app.use(express.json());

// Support Quick Portal routes
app.get('/', renderPortal);
app.post('/api/submit-ticket', submitTicket);

// Cloud Tasks retry worker route
app.post('/api/tasks/fire-trigger', handleFireTriggerTask);

// Google Workspace Studio Custom Starter Step webhooks
app.post('/onConfigTrigger', onConfigTrigger);
app.post('/onManageTrigger', onManageTrigger);

// Google Workspace Studio Custom Action Step webhooks
app.post('/onConfigUrgency', onConfigUrgency);
app.post('/onExecuteUrgency', onExecuteUrgency);

const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
  console.log(`Support Extension listening on port ${PORT}`);
});
EOF

4. 部署到 Cloud Run 并安装插件

在此步骤中,您将 Node.js 服务部署到 Cloud Run,授予 Google Workspace 插件调用方和 Firestore、Cloud Tasks 和 Agent Platform 运行时访问权限的 IAM 权限,生成 deployment.json 清单,并安装插件部署。

  1. 使用项目、Firestore 集合和 Cloud Tasks 任务队列环境变量将 support-extension 服务部署到 Cloud Run:
    gcloud run deploy support-extension \
      --source . \
      --region us-central1 \
      --allow-unauthenticated \
      --set-env-vars="GOOGLE_CLOUD_PROJECT=$(gcloud config get-value project),FIRESTORE_TRIGGERS_COLLECTION=studio_triggers,FIRESTORE_TICKETS_COLLECTION=support_tickets,CLOUD_TASKS_QUEUE=studio-trigger-retries,CLOUD_TASKS_LOCATION=us-central1" \
      --quiet
    
  2. 向 Google Workspace 插件服务账号授予 Cloud Run 服务上的 roles/run.invoker,以便 Google Workspace Studio 可以调用您的 Webhook 端点:
    ADDON_SA=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
    gcloud run services add-iam-policy-binding support-extension \
      --region=us-central1 \
      --member="serviceAccount:${ADDON_SA}" \
      --role="roles/run.invoker" \
      --quiet
    
  3. 向 Cloud Run 运行时服务账号授予 roles/datastore.user(用于读取和写入 studio_triggers 和 support_tickets Firestore 集合)、roles/cloudtasks.enqueuer(用于将重试任务加入 studio-trigger-retries)和 roles/aiplatform.user(用于在 Gemini Enterprise Agent Platform 上调用 Gemini):
    PROJECT_ID=$(gcloud config get-value project)
    PROJECT_NUMBER=$(gcloud projects describe "${PROJECT_ID}" --format="value(projectNumber)")
    RUNTIME_SA=$(gcloud run services describe support-extension --region=us-central1 --format="value(spec.template.spec.serviceAccountName)")
    RUNTIME_SA=${RUNTIME_SA:-"${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"}
    
    gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
      --member="serviceAccount:${RUNTIME_SA}" \
      --role="roles/datastore.user" \
      --condition=None \
      --quiet
    
    gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
      --member="serviceAccount:${RUNTIME_SA}" \
      --role="roles/cloudtasks.enqueuer" \
      --condition=None \
      --quiet
    
    gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
      --member="serviceAccount:${RUNTIME_SA}" \
      --role="roles/aiplatform.user" \
      --condition=None \
      --quiet
    
  4. 检索已部署的 Cloud Run 网址,并生成 deployment.json 清单,用于定义 supportTicketTrigger (workflowTrigger) 和 urgencyDetectorStep (workflowAction) 元素:
    SERVICE_URL=$(gcloud run services describe support-extension --region=us-central1 --format="value(status.url)")
    
    cat << EOF > deployment.json
    {
      "oauthScopes": [
        "https://www.googleapis.com/auth/workspace.studio.trigger",
        "https://www.googleapis.com/auth/userinfo.email"
      ],
      "addOns": {
        "common": {
          "name": "Support Extension",
          "logoUrl": "https://www.gstatic.com/images/branding/productlogos/buganizer/v1/192px.svg"
        },
        "studio": {
          "flows": {
            "workflowElements": [
              {
                "id": "supportTicketTrigger",
                "state": "ACTIVE",
                "name": "New Support Ticket",
                "description": "Fires when someone submits a ticket in the Support Quick Portal.",
                "workflowTrigger": {
                  "inputs": [],
                  "outputs": [
                    {
                      "id": "ticketTitle",
                      "description": "Ticket Title",
                      "cardinality": "SINGLE",
                      "dataType": { "basicType": "STRING" }
                    },
                    {
                      "id": "ticketDescription",
                      "description": "Ticket Description",
                      "cardinality": "SINGLE",
                      "dataType": { "basicType": "STRING" }
                    }
                  ],
                  "onConfigFunction": "${SERVICE_URL}/onConfigTrigger",
                  "onManageFunction": "${SERVICE_URL}/onManageTrigger"
                }
              },
              {
                "id": "urgencyDetectorStep",
                "state": "ACTIVE",
                "name": "Detect Urgency",
                "description": "Evaluates the ticket description to detect High or Normal urgency.",
                "workflowAction": {
                  "inputs": [
                    {
                      "id": "ticketDescription",
                      "description": "Description to scan",
                      "cardinality": "SINGLE",
                      "dataType": { "basicType": "STRING" }
                    }
                  ],
                  "outputs": [
                    {
                      "id": "urgencyLevel",
                      "description": "Detected Urgency (High / Normal)",
                      "cardinality": "SINGLE",
                      "dataType": { "basicType": "STRING" }
                    }
                  ],
                  "onConfigFunction": "${SERVICE_URL}/onConfigUrgency",
                  "onExecuteFunction": "${SERVICE_URL}/onExecuteUrgency"
                }
              }
            ]
          }
        },
        "httpOptions": {
          "authorizationHeader": "SYSTEM_ID_TOKEN"
        }
      }
    }
    EOF
    
  5. 为您的账号创建(或替换)并安装 Google Workspace 插件部署,并输出 Support Quick Portal 网址:
    gcloud workspace-add-ons deployments create support-extension --deployment-file=deployment.json || \
      gcloud workspace-add-ons deployments replace support-extension --deployment-file=deployment.json
    gcloud workspace-add-ons deployments install support-extension
    echo "Support Quick Portal URL: ${SERVICE_URL}"
    

5. 测试自定义步骤和 Cloud Tasks 重试

  1. 打开 Google Workspace Studio(如果浏览器标签页已打开,请刷新该标签页),然后点击 新建流程。
  2. 依次点击选择启动器 > Support Extension > New Support Ticket 。
  3. 依次点击添加步骤 (+) > Support Extension > Detect Urgency 。在来源说明 字段中,点击 + 变量 ,然后选择 Ticket Description 。
  4. 点击添加步骤 (+),然后添加一个 Chat > Notify me in Chat 步骤,该步骤在消息中包含 Ticket Title、Urgency Level 和 Ticket Description 变量。
  5. 在右上角将流程切换为开启 。Google Workspace Studio 会立即在 Cloud Run 上调用 /onManageTrigger 端点,该端点会将有效触发器订阅(包括 triggerId、notifyUri 和 userOAuthToken)写入 Firestore 中的 studio_triggers 集合。
  6. 在新标签页中打开 Support Quick Portal 网址 (https://support-extension-...run.app),确认门户在 Firestore 中显示 1 个有效触发器,然后提交两个工单以测试 Gemini 3.8 Flash (gemini-3.8-flash) 分类:
    • 高影响工单(Ticket Title:London office lockout,Issue Description:All 500 employees in the London office are locked out of their workstations 20 minutes before the board presentation.):即使没有 urgent 或 blocker 等硬编码关键字,gemini-3.8-flash 也会将此工单分类为 High 紧急程度。
    • 常规工单 (Ticket Title:Conference room adapters,Issue Description:Could we order two extra HDMI adapters for the third-floor conference room next month?):gemini-3.8-flash 会将此工单分类为 Normal 紧急程度。
  7. 在 Google Chat 和 Google Workspace Studio Activity 标签页中验证,您的流程是否针对这两个工单运行,并且 Urgency Level: High 和 Urgency Level: Normal。
  8. 在 notifyUri 失败时测试 Cloud Tasks 重试传送 :
    1. 在 Support Quick Portal 中,选中 模拟暂时性故障(在 Cloud Tasks 中加入队列并重试 notifyUri)。
    2. 输入 Ticket Title:Checkout service outage 和 Issue Description:Production payment gateway is timing out for all customers,然后点击 Submit Ticket。
    3. 请注意,门户会立即响应:Ticket saved! Initial notifyUri call did not succeed; queued 1 retry task(s) in Cloud Tasks.
    4. 在后台,Cloud Tasks 执行 POST /api/tasks/fire-trigger。第一个任务尝试 (retryCount=0) 模拟 503 失败,导致 Cloud Tasks 退避 10s (--min-backoff=10s) 并重试 (retryCount=1),从而调用 notifyUri 并成功。大约 10-15 秒后,验证您的 Google Chat 通知是否已送达!

6. 清理

为避免持续产生 Google Cloud 费用,请在 Cloud Shell 中移除已部署的资源:

gcloud workspace-add-ons deployments uninstall support-extension
gcloud workspace-add-ons deployments delete support-extension --quiet
gcloud run services delete support-extension --region=us-central1 --quiet
gcloud tasks queues delete studio-trigger-retries --location=us-central1 --quiet
gcloud firestore databases delete --database="(default)" --quiet

7. 恭喜!

您已使用 Firestore 、Cloud Tasks 、Cloud Run 和 Gemini Enterprise Agent Platform 上的 Gemini 3.8 Flash 构建、部署和测试了 Google Workspace Studio 的 Node.js HTTP 扩展程序!

所学内容

  • 使用 workflowTrigger 和 workflowAction 元素配置 Google Workspace Studio HTTP 插件清单 (deployment.json)。
  • 在 studio_triggers Firestore 集合中保留 Google Workspace Studio workflowTrigger 订阅,并在 support_tickets 中记录门户提交,以便触发器在 Cloud Run 扩缩和重新部署后仍然存在。
  • 使用 Cloud Tasks (studio-trigger-retries) 保证 notifyUri (triggers.fire) 传送,以便使用 Firestore 中的最新 userOAuthToken 以指数退避算法自动重试暂时性 HTTP 失败。
  • 构建一个 workflowAction 步骤,该步骤使用 Gemini Enterprise Agent Platform 上的 Gemini 3.8 Flash (gemini-3.8-flash) 结构化输出对支持工单进行分类,并提供弹性关键字匹配回退。

后续步骤