יצירת שלבים מותאמים אישית ב-Google Workspace Studio ב-Node.js ב-Cloud Run

‫1. מבוא

Google Workspace Studio מאפשר לכם להפוך תהליכים עסקיים לאוטומטיים באפליקציות של Google Workspace כמו Gmail,‏ Google Chat,‏ Google Drive ו-Google Sheets. אם תהליכי העבודה שלכם צריכים להתחיל מאפליקציית אינטרנט חיצונית או להריץ לוגיקה בהתאמה אישית בסביבת הענן שלכם, אתם יכולים להרחיב את Google Workspace Studio באמצעות תוסף HTTP ל-Google Workspace ב-Node.js ב-Cloud Run:

  1. שלב התחלה בהתאמה אישית (workflowTrigger): הפעלה של תהליך ב-Google Workspace Studio כשמתרחש אירוע חיצוני, כמו לקוח ששולח כרטיס תמיכה בפורטל אינטרנט פנימי, על ידי קריאה ל-notifyUri של הטריגר (או ל-triggers.fire REST API של Google Workspace Studio).
  2. שלב פעולה בהתאמה אישית (workflowAction): מקבל משתנים משלבים קודמים בתהליך, מריץ לוגיקה בהתאמה אישית או הסקת AI בשירות Node.js ומחזיר משתני פלט ל-Google Workspace Studio.

ב-Codelab הזה, תבנו ותפרסו תוסף HTTP מלא של Node.js ל-Google Workspace Studio ב-Cloud Run, תשתמשו ב-Cloud Firestore כדי לשמר מינויים פעילים לטריגרים של תהליכים וכרטיסי תמיכה שנשלחו בין גרסאות קונטיינרים, תשתמשו ב-Cloud Tasks כדי לנסות שוב באופן אוטומטי לשלוח התראות על טריגרים אם notifyUri לא מחזיר תגובת הצלחה, ותסווגו את דחיפות הכרטיסים בשלב הפעולה בהתאמה אישית באמצעות Gemini 3.8 Flash‏ (gemini-3.8-flash) ב-Gemini Enterprise Agent Platform עם חלופה גמישה של התאמה למילות מפתח.

מה תפַתחו

  • אפליקציית אינטרנט של Support Quick Portal ותוסף HTTP ל-Google Workspace שנוצרו באמצעות Node.js,‏ Express,‏ Firestore ו-Cloud Tasks ב-Cloud Run.
  • שלב התחלה מותאם אישית של כרטיס תמיכה חדש (supportTicketTrigger) ששומר מינויים פעילים של תהליכי עבודה באוסף studio_triggers Firestore, מתעד כרטיסים שנשלחו באוסף support_tickets Firestore ומופעל בכל פעם שכרטיס נשלח בפורטל האינטרנט – ומעביר את ticketTitle ו-ticketDescription לתהליך עבודה של Google Workspace Studio.
  • תהליך עבודה לניסיון חוזר ב-Cloud Tasks (תור studio-trigger-retries ו-POST /api/tasks/fire-trigger) שמוסיף לתור ומנסה שוב קריאות לטריגר שנכשלו עם השהיה מעריכית לפני ניסיון חוזר (exponential backoff) באמצעות הגרסה העדכנית ביותר של userOAuthToken שמאוחסנת ב-Firestore, ומבטיח מסירה ל-notifyUri.
  • שלב של פעולה מותאמת אישית Detect Urgency (זיהוי דחיפות) (urgencyDetectorStep) שמסווג את הדחיפות של הכרטיס באמצעות Gemini 3.8 Flash (gemini-3.8-flash) ב-Gemini Enterprise Agent Platform (aiplatform.googleapis.com), עם מעבר אוטומטי לחיפוש התאמה למילות מפתח (urgent, ‏ blocker, ‏ critical).

ארכיטקטורה

בתרשים הבא מוצגות האינטראקציות בין פורטל התמיכה המהיר, תוסף Node.js ב-Cloud Run, ‏ 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

מה תלמדו

  • איך מגדירים מניפסט פריסה של תוסף HTTP ל-Google Workspace (deployment.json) עם רכיבי workflowTrigger ו-workflowAction.
  • איך לשמר מינויים לטריגרים של Google Workspace Studio (triggerId,‏ notifyUri ו-userOAuthToken) באוסף studio_triggers Firestore ולהפעיל תהליכים באמצעות notifyUri או triggers.fire.
  • איך משתמשים ב-Cloud Tasks ‏(@google-cloud/tasks) כדי לנסות שוב לשלוח התראות על טריגרים שנכשלו עם השהיה מעריכית לפני ניסיון חוזר (exponential backoff) עד ש-notifyUri מחזיר תגובת הצלחה 2xx.
  • איך ליצור workflowAction שלב מותאם אישית ב-Node.js באמצעות פלט מובנה של Gemini 3.8 Flash (gemini-3.8-flash) ב-Gemini Enterprise Agent Platform עם חזרה למילת מפתח.

דרישות מוקדמות

  • חשבון Google Workspace עם גישה ל-Google Workspace Studio.
  • פרויקט ב-Google Cloud עם חיוב מופעל, שנוצר באותו ארגון ב-Google Workspace כמו חשבון המשתמש שלכם, כדי שתוכלו להתקין פריסות של תוספים שלא פורסמו לצורך בדיקה.

2. הגדרה של פרויקט Google Cloud,‏ Firestore ו-Cloud Tasks

בשלב הזה מגדירים את מסך ההסכמה ל-OAuth של הפרויקט, מפעילים את ממשקי ה-API הנדרשים של Google Cloud ו-Google Workspace ב-Cloud Shell, מקצים את מסד הנתונים של (default) Firestore, יוצרים את תור הניסיונות החוזרים של Cloud Tasks ומפעילים את חשבון השירות של שירות ההרשאות של התוסף.

  1. פותחים את מסוף Google Cloud, בוחרים את פרויקט בענן ולוחצים על הסמל Activate Cloud Shell (הפעלה של Cloud Shell) בסרגל הניווט העליון.
  2. מגדירים את מסך ההסכמה ל-OAuth כדי ש-Google Workspace יוכל להקצות לקוח OAuth לתוסף:
    1. פותחים את דף המיתוג של פלטפורמת האימות של Google.
    2. אם מוצגת בקשה, לוחצים על שנתחיל?. (אם מסך ההסכמה כבר מוגדר, דלגו לשלב 3).
    3. בקטע App Information, מזינים Support Extension בשדה App name, בוחרים את User support email ולוחצים על Next.
    4. בקטע Audience, לוחצים על Internal ואז על Next.
    5. בקטע פרטים ליצירת קשר, מזינים את כתובת האימייל ולוחצים על הבא.
    6. בקטע Finish, מאשרים את המדיניות של Google בנושא נתוני משתמשים בשירותי API, לוחצים על Continue ואז על Create.
  3. ב-Cloud Shell, מגדירים את הפרויקט הפעיל (מחליפים את YOUR_PROJECT_ID במזהה הפרויקט ב-Google Cloud) ומפעילים את ממשקי ה-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. מקצים את מסד הנתונים של (default) Firestore ואת תור המשימות של studio-trigger-retries Cloud Tasks ב-us-central1 (אם הם עדיין לא קיימים), ומאתחלים את חשבון השירות של התוספים ל-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. פיתוח אפליקציית Node.js באמצעות Firestore,‏ Cloud Tasks ו-Gemini 3.8 Flash

בשלב הזה, אתם בונים את אפליקציית Node.js ב-~/studio-support-addon. כדי שהקוד יהיה מודולרי וקריא, הפרויקט מחולק לשישה קבצים ממוקדים:

  • ‫firestore.js: מאתחל את הלקוח של Cloud Firestore (אוספי studio_triggers ו-support_tickets) ומרענן את הטוקנים של OAuth שנשמרו במטמון.
  • ‫starter.js: הטמעה של ווּבּהוקים (webhook) של שלב התחלה מותאם אישית כרטיס תמיכה חדש (/onConfigTrigger ו-/onManageTrigger).
  • ‫action.js: הטמעה של ה-webhooks של שלב הפעולה בהתאמה אישית Detect Urgency (/onConfigUrgency ו-/onExecuteUrgency) באמצעות Gemini 3.8 Flash (gemini-3.8-flash) עם חלופה של התאמה למילות מפתח.
  • ‫tasks.js: שולח התראות טריגר אל trigger.notifyUri (עם חזרה אל https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire), מוסיף לתור של studio-trigger-retries Cloud Tasks מסירות שנכשלו ומטמיע את נקודת הקצה של ה-worker לניסיונות חוזרים של POST /api/tasks/fire-trigger.
  • <!-- : Serves the Support Quick Portal web UI () and fires active Google Workspace Studio triggers (), with an option to simulate a transient failure and watch Cloud Tasks retry. -->portal.js: מציג את ממשק המשתמש של Support Quick Portal באינטרנט (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. יצירת package.json עם express,‏ @google-cloud/firestore,‏ @google-cloud/tasks ו-@google/genai:
    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 כדי להטמיע את שתי נקודות הקצה של ה-webhook שנדרשות לשלב התחלה בהתאמה אישית ב-Google Workspace Studio:

  • ‫onConfigTrigger (POST /onConfigTrigger): נקראת כשמשתמש בוחר באפשרות כרטיס תמיכה חדש כטריגר לתהליך שלו ב-Google Workspace Studio. הפונקציה מחזירה פעולת ניווט 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 כדי להטמיע את שתי נקודות הקצה של ה-webhook שנדרשות לשלב של פעולה בהתאמה אישית ב-Google Workspace Studio:

  • ‫onConfigUrgency (POST /onConfigUrgency): מחזיר כרטיס הגדרה עם ווידג'ט textInput שמגדיר את hostAppDataSource.workflowDataSource.includeVariables: true, ומאפשר למשתמשים לבחור את המשתנה ticketDescription שמופק משלב ההתחלה.
  • ‫onExecuteUrgency (POST /onExecuteUrgency): קורא את הקלט של ticketDescription מ-req.body.workflow.actionInvocation.inputs, מסווג את ההשפעה העסקית והדחיפות שלו באמצעות Gemini 3.8 Flash‏ (gemini-3.8-flash) ב-Gemini Enterprise Agent Platform עם פלט מובנה (responseSchema enum ["High", "Normal"]), חוזר ל-detectKeywordUrgency() (urgent, blocker, critical) אם הקריאה למודל נכשלת, ומחזיר את משתנה הפלט של urgencyLevel באמצעות hostAppAction.workflowAction.returnOutputVariablesAction.
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): המערכת משתמשת ב-trigger.notifyUri שנשמר מ-triggerCreation.notifyUri אם הוא קיים, או חוזרת ל-https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire.
  • ‫fireTriggerDirect(trigger, payload): שולח את הבקשה POST לכתובת ה-URL של הטריגר עם משתני הפלט ticketTitle ו-ticketDescription ועם requestId אידמפוטנטי.
  • ‫enqueueTriggerRetry(req, payload): הוספה של משימת HTTP לתור studio-trigger-retries שמטרגטת את נקודת הקצה (endpoint) POST /api/tasks/fire-trigger של שירות Cloud Run, בכל פעם שהקריאה הראשונית לטריגר נכשלת או מחזירה סטטוס HTTP שהוא לא 2xx.
  • ‫handleFireTriggerTask (POST /api/tasks/fire-trigger): מופעל על ידי Cloud Tasks בכל ניסיון חוזר. חיפוש המסמך האחרון של trigger ב-studio_triggers מ-Firestore (כדי לוודא שבניסיונות החוזרים תמיד נעשה שימוש ב-userOAuthToken העדכני ביותר), בדיקת הכותרת X-CloudTasks-TaskRetryCount (אם simulateFailure מופעל, ההפעלה הראשונה של Cloud Task נכשלת עם 503 כדי שתוכלו לראות את Cloud Tasks מבצע השהיה לפני ניסיון חוזר ומצליח בניסיון החוזר #1), והחזרת 200 OK רק כש-notifyUri מחזיר תגובת הצלחה של 2xx. כל תגובה מסוג 5xx תגרום ל-Cloud Tasks לנסות שוב בהתאם למדיניות ההשהיה לפני ניסיון חוזר (backoff) של התור.
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 כדי להציג את פורטל האינטרנט ולשלוח את הפניות ל-Google Workspace Studio עם תזמון אוטומטי של ניסיונות חוזרים ב-Cloud Tasks:

  • ‫renderPortal (GET /): שאילתה באוסף studio_triggers Firestore כדי להציג כמה טריגרים פעילים של Google Workspace Studio רשומים, והצגת טופס שליחת הכרטיס HTML (כולל תיבת סימון Simulate transient failure (הדמיית כשל זמני) כדי לבדוק ניסיונות חוזרים של Cloud Tasks).
  • ‫submitTicket (POST /api/submit-ticket): עובר על כל מסמכי הטריגר הפעילים ב-studio_triggers וקורא ל-fireTriggerDirect(). אם כתובת ה-URL של הטריגר מחזירה תשובה שהיא לא 2xx, מקפיצה שגיאה בחיבור לרשת או שהאפשרות simulateFailure מופעלת, היא מוסיפה באופן אוטומטי משימה של ניסיון חוזר לתור ב-studio-trigger-retries באמצעות enqueueTriggerRetry() ומתעדת את הכרטיס באוסף 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 כדי לחבר את נתיבי הפורטל, נקודת הקצה של ה-worker לניסיון חוזר של Cloud Tasks ונקודות הקצה של ה-webhook של Google Workspace Studio לשרת Express HTTP שמאזין ב-PORT (ברירת המחדל היא 8080 ב-Cloud Run):

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, נותנים הרשאות IAM למפעיל של התוספים ל-Google Workspace ולגישה בזמן הריצה של Firestore, של Cloud Tasks ושל Agent Platform, יוצרים את המניפסט של deployment.json ומתקינים את פריסת התוסף.

  1. פורסים את שירות support-extension ב-Cloud Run עם משתני הסביבה של הפרויקט, האוסף ב-Firestore ותור משימות של Cloud Tasks:
    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. נותנים את ההרשאה roles/run.invoker בשירות Cloud Run לחשבון השירות של Google Workspace Add-ons, כדי ש-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. צריך להעניק את ההרשאות roles/datastore.user (לקריאה וכתיבה של האוספים studio_triggers ו-support_tickets ב-Firestore),‏ roles/cloudtasks.enqueuer (להוספת משימות חוזרות לתור ב-studio-trigger-retries) ו-roles/aiplatform.user (להפעלת Gemini בפלטפורמת הסוכנים של Gemini Enterprise) לחשבון השירות של זמן הריצה ב-Cloud Run:
    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. מאחזרים את כתובת ה-URL של 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 בחשבון, ומדפיסים את כתובת ה-URL של 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. לוחצים על בחירת גורם מפעיל > תוסף תמיכה > כרטיס תמיכה חדש.
  3. לוחצים על הוספת שלב (+) > Support Extension (תוסף תמיכה) > Detect Urgency (זיהוי דחיפות). בשדה תיאור המקור, לוחצים על + משתנים ובוחרים באפשרות תיאור הכרטיס.
  4. לוחצים על הוספת שלב (+) ומוסיפים שלב של צ'אט > התראה בצ'אט שכולל את המשתנים שם הכרטיס, רמת הדחיפות ותיאור הכרטיס בהודעה.
  5. בפינה השמאלית העליונה, מעבירים את המתג של התהליך למצב מופעל. ‫Google Workspace Studio קורא מיד לנקודת הקצה (endpoint) של /onManageTrigger ב-Cloud Run, שכותבת את המינוי הפעיל לטריגר (כולל triggerId,‏ notifyUri ו-userOAuthToken) לאוסף studio_triggers ב-Firestore.
  6. פותחים את כתובת ה-URL של Support Quick Portal (https://support-extension-...run.app) בכרטיסייה חדשה, מוודאים שהפורטל מציג טריגר פעיל של 1 ב-Firestore, ושולחים שני כרטיסי תמיכה כדי לבדוק את הסיווג של Gemini 3.8 Flash (gemini-3.8-flash):
    • כרטיס עם השפעה גבוהה (שם הכרטיס: London office lockout, תיאור הבעיה: 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.
    • פנייה שגרתית (שם הפנייה: Conference room adapters, תיאור הבעיה: Could we order two extra HDMI adapters for the third-floor conference room next month?): gemini-3.8-flash מסווג את הפנייה הזו כדחופה ברמה Normal.
  7. כדי לוודא שהתהליך של רצף הפעולות פעל עבור שני הכרטיסים עם Urgency Level: High ו-Urgency Level: Normal, אפשר לבדוק את הכרטיסייה פעילות ב-Google Workspace Studio וב-Google Chat.
  8. כדי לבדוק את ניסיון המסירה הנוסף של Cloud Tasks כשnotifyUri נכשל:
    1. בפורטל התמיכה המהיר, מסמנים את התיבה Simulate transient failure (enqueue in Cloud Tasks and retry notifyUri).
    2. מזינים את שם הפנייה: Checkout service outage ואת תיאור הבעיה: Production payment gateway is timing out for all customers, ולוחצים על שליחת הפנייה.
    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. מעולה!

יצרתם, פרסתם ובדקתם תוסף HTTP ב-Node.js ל-Google Workspace Studio באמצעות Firestore,‏ Cloud Tasks,‏ Cloud Run ו-Gemini 3.8 Flash ב-Gemini Enterprise Agent Platform.

מה למדנו

  • הגדרת מניפסט של תוסף HTTP ל-Google Workspace Studio (deployment.json) עם רכיבי workflowTrigger ו-workflowAction.
  • שמירת מינויים ל-Google Workspace Studio workflowTrigger באוסף studio_triggers Firestore ורישום של שליחות בפורטל ב-support_tickets, כדי שהטריגרים ימשיכו לפעול גם אחרי שינוי קנה מידה ופריסה מחדש של Cloud Run.
  • הבטחת מסירה של notifyUri (triggers.fire) באמצעות Cloud Tasks (studio-trigger-retries), כך שמתבצע ניסיון חוזר באופן אוטומטי במקרה של כשלים זמניים ב-HTTP, עם השהיה מעריכית לפני ניסיון חוזר (exponential backoff) באמצעות ה-userOAuthToken העדכני מ-Firestore.
  • יצירת שלב workflowAction שמסווג כרטיסי תמיכה באמצעות Gemini 3.8 Flash (gemini-3.8-flash) עם פלט מובנה ב-Gemini Enterprise Agent Platform, עם חזרה למצב קודם של התאמת מילות מפתח.

השלבים הבאים