איך בונים אפליקציית סקר אינטראקטיבית ל-Google Chat באמצעות Node.js

1. מבוא

אפליקציות ל-Google Chat מאפשרות לכם לשלב את השירותים והמשאבים שלכם ישירות ב-Google Chat, כך שהמשתמשים יכולים לקבל מידע ולבצע פעולות במהירות בלי לצאת מהשיחה.

בשיעור הזה תלמדו איך ליצור ולפרוס אפליקציית סקר באמצעות Node.js ו-Cloud Functions.

אפליקציה שמפרסמת סקר ושואלת את המשתתפים במרחב אם אפליקציות הן דבר נהדר, ואוספת את התשובות באמצעות הודעה עם כרטיס אינטראקטיבי.

מה תלמדו

  • שימוש ב-Cloud Shell
  • פריסה ל-Cloud Functions
  • קבלת קלט מהמשתמש באמצעות פקודות ודיאלוגים
  • יצירת כרטיסים אינטראקטיביים

2. הגדרה ודרישות

יוצרים פרויקט ב-Google Cloud, ואז מפעילים את ממשקי ה-API והשירותים שבהם אפליקציית Chat תשתמש.

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

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

הגדרת סביבה בקצב עצמי

  1. פותחים את מסוף Google Cloud ויוצרים פרויקט.

    התפריט לבחירת פרויקטהלחצן החדש 'פרויקט'מזהה הפרויקט

    חשוב לזכור את מזהה הפרויקט, שהוא שם ייחודי בין כל הפרויקטים ב-Google Cloud (השם שלמעלה כבר תפוס ולא יתאים לכם, מצטערים!). בהמשך ה-codelab הזה, נתייחס אליו כאל PROJECT_ID.
  1. בשלב הבא, כדי להשתמש במשאבים של Google Cloud, צריך להפעיל את החיוב במסוף Cloud.

העלות של התרגול הזה לא אמורה להיות גבוהה, ואולי אפילו לא תצטרכו לשלם בכלל. חשוב לפעול לפי ההוראות שבקטע 'ניקוי' בסוף ה-codelab, שמסביר איך להשבית משאבים כדי שלא תחויבו אחרי שתסיימו את המדריך הזה. משתמשים חדשים ב-Google Cloud זכאים לתוכנית תקופת ניסיון בחינם בשווי 300$.

Google Cloud Shell

אפשר להפעיל את Google Cloud מרחוק מהמחשב הנייד, אבל ב-codelab הזה נשתמש ב-Google Cloud Shell, סביבת שורת פקודה שפועלת ב-Google Cloud.

הפעלת Cloud Shell

  1. ב-Cloud Console, לוחצים על Activate Cloud Shell הסמל של Cloud Shell.

    הסמל של Cloud Shell בסרגל התפריטים

    בפעם הראשונה שפותחים את Cloud Shell, מוצגת הודעת פתיחה עם תיאור. אם מופיעה הודעת הפתיחה, לוחצים על המשך. הודעת הפתיחה לא תופיע שוב. הנה הודעת הפתיחה:

    הודעת הפתיחה של Cloud Shell

    הקצאת המשאבים וחיבור ל-Cloud Shell אמורים להימשך רק כמה רגעים. אחרי החיבור, מוצג טרמינל Cloud Shell:

    טרמינל Cloud Shell

    במכונה הווירטואלית הזו טעונים כל הכלים הדרושים למפתחים. יש בה ספריית בית בנפח מתמיד של 5GB והיא פועלת ב-Google Cloud, מה שמשפר מאוד את הביצועים והאימות ברשת. אפשר לבצע את כל העבודה ב-codelab הזה באמצעות דפדפן או Chromebook.אחרי שתתחברו ל-Cloud Shell, תראו שכבר עברתם אימות והפרויקט כבר מוגדר לפי מזהה הפרויקט שלכם.
  2. מריצים את הפקודה הבאה ב-Cloud Shell כדי לוודא שהאימות בוצע:
    gcloud auth list
    
    אם מתבקשים לאשר ל-Cloud Shell לבצע קריאה ל-GCP API, לוחצים על Authorize (אישור).

    פלט הפקודה
    Credentialed Accounts
    ACTIVE  ACCOUNT
    *       <my_account>@<my_domain.com>
    
    אם החשבון שלכם לא נבחר כברירת מחדל, מריצים את הפקודה:
    $ gcloud config set account <ACCOUNT>
    
  1. מוודאים שבחרתם בפרויקט הנכון. ב-Cloud Shell, מריצים את הפקודה:
    gcloud config list project
    
    פלט הפקודה
    [core]
    project = <PROJECT_ID>
    
    אם הפרויקט הנכון לא מוחזר, אפשר להגדיר אותו באמצעות הפקודה הבאה:
    gcloud config set project <PROJECT_ID>
    
    פלט הפקודה
    Updated property [core/project].
    

במהלך ה-codelab הזה תשתמשו בפעולות של שורת הפקודה ותערכו קבצים. כדי לערוך קבצים, אפשר להשתמש בעורך הקוד המובנה של Cloud Shell, ‏ Cloud Shell Editor. לשם כך, לוחצים על Open Editor (פתיחת העורך) בצד שמאל של סרגל הכלים של Cloud Shell. עורכים פופולריים כמו Vim ו-Emacs זמינים גם ב-Cloud Shell.

3. הפעלת Cloud Functions,‏ Cloud Build ו-Google Chat APIs

ב-Cloud Shell, מפעילים את ממשקי ה-API והשירותים הבאים:

gcloud services enable \
  cloudfunctions \
  cloudbuild.googleapis.com \
  chat.googleapis.com

הפעולה הזו עשויה להימשך כמה רגעים.

אחרי שהפעולה תושלם, תופיע הודעה על הצלחה שדומה להודעה הזו:

Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.

4. יצירת אפליקציית Chat ראשונית

הפעלת הפרויקט

כדי להתחיל, יוצרים ופורסים אפליקציית 'Hello world' פשוטה. אפליקציות ל-Chat הן שירותי אינטרנט שמגיבים לבקשות https ומחזירים מטען ייעודי (payload) בפורמט JSON. באפליקציה הזו תשתמשו ב-Node.js וב-Cloud Functions.

ב-Cloud Shell, יוצרים ספרייה חדשה בשם poll-app ועוברים אליה:

mkdir ~/poll-app
cd ~/poll-app

כל העבודה שנותרה לביצוע ב-codelab והקבצים שתיצרו יהיו בספרייה הזו.

מפעילים את פרויקט Node.js:

npm init

מערכת NPM שואלת כמה שאלות לגבי הגדרת הפרויקט, כמו שם וגרסה. לכל שאלה, לוחצים על ENTER כדי לאשר את ערכי ברירת המחדל. נקודת הכניסה שמוגדרת כברירת מחדל היא קובץ בשם index.js, שאותו ניצור בשלב הבא.

יצירת ה-backend של אפליקציית Chat

הגיע הזמן להתחיל ליצור את האפליקציה. יוצרים קובץ בשם index.js עם התוכן הבא:

/**
 * App entry point.
 */
exports.app = async (req, res) => {
  if (!(req.method === 'POST' && req.body)) {
      res.status(400).send('')
  }
  const event = req.body;
  let reply = {};
  if (event.type === 'MESSAGE') {
    reply = {
        text: `Hello ${event.user.displayName}`
    };
  }
  res.json(reply)
}

האפליקציה עדיין לא עושה הרבה, אבל זה בסדר. אפשר להוסיף עוד פונקציות בהמשך.

פריסת האפליקציה

כדי לפרוס את האפליקציה Hello world, תצטרכו לפרוס את Cloud Function, להגדיר את אפליקציית Chat ב-Google Cloud Console ולשלוח הודעת בדיקה לאפליקציה כדי לוודא שהפריסה בוצעה.

פריסת הפונקציה ב-Cloud Functions

כדי לפרוס את הפונקציה ב-Cloud Functions של אפליקציית Hello world, מזינים את הפקודה הבאה:

gcloud functions deploy app --trigger-http --security-level=secure-always --allow-unauthenticated --runtime nodejs14

בסיום, הפלט אמור להיראות כך:

availableMemoryMb: 256
buildId: 993b2ca9-2719-40af-86e4-42c8e4563a4b
buildName: projects/595241540133/locations/us-central1/builds/993b2ca9-2719-40af-86e4-42c8e4563a4b
entryPoint: app
httpsTrigger:
  securityLevel: SECURE_ALWAYS
  url: https://us-central1-poll-app-codelab.cloudfunctions.net/app
ingressSettings: ALLOW_ALL
labels:
  deployment-tool: cli-gcloud
name: projects/poll-app-codelab/locations/us-central1/functions/app
runtime: nodejs14
serviceAccountEmail: poll-app-codelab@appspot.gserviceaccount.com
sourceUploadUrl: https://storage.googleapis.com/gcf-upload-us-central1-66a01777-67f0-46d7-a941-079c24414822/94057943-2b7c-4b4c-9a21-bb3acffc84c6.zip
status: ACTIVE
timeout: 60s
updateTime: '2021-09-17T19:30:33.694Z'
versionId: '1'

שימו לב לכתובת ה-URL של הפונקציה שפרסתם במאפיין httpsTrigger.url. תשתמשו בזה בשלב הבא.

הגדרת האפליקציה

כדי להגדיר את האפליקציה, עוברים לדף Chat configuration במסוף Cloud.

  1. מבטלים את הסימון של Build this Chat app as a Workspace add-on (בניית אפליקציית Chat כתוסף ל-Workspace) ולוחצים על DISABLE (השבתה) כדי לאשר.
  2. בשדה App name (שם האפליקציה), מזינים PollCodelab.
  3. בקטע כתובת ה-URL של הדמות, מזינים https://raw.githubusercontent.com/google/material-design-icons/master/png/social/poll/materialicons/24dp/2x/baseline_poll_black_24dp.png.
  4. בקטע תיאור, מזינים 'אפליקציית סקר ל-codelab'.
  5. בקטע פונקציונליות, בוחרים באפשרות קבלת הודעות בצ'אטים אישיים ובאפשרות הצטרפות למרחבים ולשיחות קבוצתיות.
  6. בקטע הגדרות חיבור, בוחרים באפשרות כתובת URL של נקודת קצה מסוג HTTP ומדביקים את כתובת ה-URL של Cloud Function (המאפיין httpsTrigger.url מהקטע הקודם).
  7. בקטע הרשאות, בוחרים באפשרות אנשים ספציפיים וקבוצות בדומיין ומזינים את כתובת האימייל שלכם.
  8. לוחצים על שמירה.

האפליקציה מוכנה לשליחת הודעות.

בדיקת האפליקציה

לפני שממשיכים, כדאי להוסיף את האפליקציה למרחב ב-Google Chat כדי לוודא שהיא פועלת.

  1. נכנסים ל-Google Chat.
  2. ליד הכותרת 'צ'אט', לוחצים על + > חיפוש אפליקציות.
  3. מזינים PollCodelab בחיפוש.
  4. לוחצים על צ'אט.
  5. כדי לשלוח הודעה לאפליקציה, כותבים Hello ומקישים על Enter.

האפליקציה אמורה להשיב בהודעת שלום קצרה.

עכשיו, כשיש לנו מבנה בסיסי, הגיע הזמן להפוך אותו למשהו שימושי יותר.

5. פיתוח תכונות הסקר

סקירה כללית מהירה של אופן הפעולה של האפליקציה

האפליקציה מורכבת משני חלקים עיקריים:

  1. פקודה דרך שורת הפקודות שפותחת תיבת דו-שיח להגדרת הסקר.
  2. כרטיס אינטראקטיבי להצבעה ולצפייה בתוצאות.

האפליקציה צריכה גם לשמור את ההגדרות והתוצאות של הסקר. אפשר לעשות את זה באמצעות Firestore או כל מסד נתונים אחר, או לאחסן את המצב בהודעות של האפליקציה עצמה. מכיוון שהאפליקציה הזו מיועדת לסקרים קצרים ובלתי רשמיים של צוות, שמירת הסטטוס בהודעות באפליקציה מתאימה מאוד לתרחיש השימוש הזה.

מודל הנתונים של האפליקציה (ב-Typescript):

interface Poll {
  /* Question/topic of poll */
  topic: string;
  /** User that submitted the poll */
  author: {
    /** Unique resource name of user */
    name: string;
    /** Display name */
    displayName: string;
  };
  /** Available choices to present to users */
  choices: string[];
  /** Map of user ids to the index of their selected choice */
  votes: { [key: string]: number };
}

בנוסף לנושא או לשאלה ולרשימת האפשרויות, המצב כולל את המזהה והשם של המחבר וגם את ההצבעות שתועדו. כדי למנוע ממשתמשים להצביע כמה פעמים, ההצבעות מאוחסנות כמפה של מזהי משתמשים לאינדקס של הבחירה שלהם.

כמובן שיש הרבה גישות שונות, אבל זו נקודת התחלה טובה להפעלת סקרים מהירים במרחב.

הטמעה של פקודת ההגדרה של הסקר

כדי לאפשר למשתמשים להתחיל סקרים ולהגדיר אותם, צריך להגדיר פקודת לוכסן שפותחת תיבת דו-שיח. זהו תהליך רב-שלבי:

  1. רושמים את הפקודה עם הקו הנטוי שמתחילה סקר.
  2. יוצרים את תיבת הדו-שיח להגדרת הסקר.
  3. לאפשר לאפליקציה לזהות את הפקודה עם הסלאש ולטפל בה.
  4. ליצור כרטיסים אינטראקטיביים שמאפשרים להצביע בסקר.
  5. מטמיעים את הקוד שמאפשר לאפליקציה להריץ סקרים.
  6. פורסים מחדש את הפונקציה של Cloud Functions.

רישום הפקודה דרך שורת הפקודות

כדי לרשום פקודת לוכסן, חוזרים לדף Chat configuration במסוף (APIs & Services > Dashboard > Hangouts Chat API > Configuration).

  1. בקטע Slash commands (פקודות דרך שורת הפקודות), לוחצים על Add a new slash command (הוספת פקודה חדשה דרך שורת הפקודות).
  2. בשדה שם מזינים '/poll'.
  3. במזהה הפקודה, מזינים '1'.
  4. בתיאור, מזינים 'התחלת סקר'.
  5. בוחרים באפשרות הקישור פותח תיבת דו-שיח.
  6. לוחצים על סיום.
  7. לוחצים על שמירה.

האפליקציה מזהה עכשיו את הפקודה /poll ונפתח חלון דו-שיח. בשלב הבא, נגדיר את תיבת הדו-שיח.

יצירת טופס ההגדרה כתיבת דו-שיח

פקודת הסלאש פותחת תיבת דו-שיח להגדרת נושא הסקר והאפשרויות האפשריות. יוצרים קובץ חדש בשם config-form.js עם התוכן הבא:

/** Upper bounds on number of choices to present. */
const MAX_NUM_OF_OPTIONS = 5;

/**
 * Build widget with instructions on how to use form.
 *
 * @returns {object} card widget
 */
function helpText() {
  return {
    textParagraph: {
      text: 'Enter the poll topic and up to 5 choices in the poll. Blank options will be omitted.',
    },
  };
}

/**
 * Build the text input for a choice.
 *
 * @param {number} index - Index to identify the choice
 * @param {string|undefined} value - Initial value to render (optional)
 * @returns {object} card widget
 */
function optionInput(index, value) {
  return {
    textInput: {
      label: `Option ${index + 1}`,
      type: 'SINGLE_LINE',
      name: `option${index}`,
      value: value || '',
    },
  };
}

/**
 * Build the text input for the poll topic.
 *
 * @param {string|undefined} topic - Initial value to render (optional)
 * @returns {object} card widget
 */
function topicInput(topic) {
  return {
    textInput: {
      label: 'Topic',
      type: 'MULTIPLE_LINE',
      name: 'topic',
      value: topic || '',
    },
  };
}

/**
 * Build the buttons/actions for the form.
 *
 * @returns {object} card widget
 */
function buttons() {
  return {
    buttonList: {
      buttons: [
        {
          text: 'Submit',
          onClick: {
            action: {
              function: 'start_poll',
            },
          },
        },
      ],
    },
  };
}

/**
 * Build the configuration form.
 *
 * @param {object} options - Initial state to render with form
 * @param {string|undefined} options.topic - Topic of poll (optional)
 * @param {string[]|undefined} options.choices - Text of choices to display to users (optional)
 * @returns {object} card
 */
function buildConfigurationForm(options) {
  const widgets = [];
  widgets.push(helpText());
  widgets.push(topicInput(options.topic));
  for (let i = 0; i < MAX_NUM_OF_OPTIONS; ++i) {
    const choice = options?.choices?.[i];
    widgets.push(optionInput(i, choice));
  }
  widgets.push(buttons());

  // Assemble the card
  return {
    sections: [
      {
        widgets,
      },
    ],
  };
}

exports.MAX_NUM_OF_OPTIONS = MAX_NUM_OF_OPTIONS;
exports.buildConfigurationForm = buildConfigurationForm;

הקוד הזה יוצר את טופס הדו-שיח שמאפשר למשתמש להגדיר את הסקר. הוא גם מייצא קבוע למספר המקסימלי של אפשרויות ששאלה יכולה לכלול. מומלץ לבודד את בניית תגי העיצוב של ממשק המשתמש לפונקציות בלי שמירת מצב, ולהעביר כל מצב כפרמטר. היא מאפשרת שימוש חוזר, ובהמשך הכרטיס הזה יוצג בהקשרים שונים.

ביישום הזה הכרטיס מפורק ליחידות או לרכיבים קטנים יותר. השימוש בטכניקה הזו לא חובה, אבל הוא מומלץ כי בדרך כלל קל יותר לקרוא את הקוד ולתחזק אותו כשיוצרים ממשקים מורכבים.

כדי לראות דוגמה של ה-JSON המלא שנוצר, אפשר להציג אותו בכלי ליצירת כרטיסים.

טיפול בפקודה דרך שורת הפקודות

פקודות סלאש מופיעות כאירועי MESSAGE כששולחים אותן לאפליקציה. צריך לעדכן את index.js כדי לבדוק אם יש פקודת סלאש דרך אירוע MESSAGE ולהגיב באמצעות תיבת דו-שיח. מחליפים את index.js בערכים הבאים:

const { buildConfigurationForm, MAX_NUM_OF_OPTIONS } = require('./config-form');

/**
 * App entry point.
 */
exports.app = async (req, res) => {
  if (!(req.method === 'POST' && req.body)) {
      res.status(400).send('')
  }
  const event = req.body;
  let reply = {};
  // Dispatch slash and action events
  if (event.type === 'MESSAGE') {
    const message = event.message;
    if (message.slashCommand?.commandId === '1') {
      reply = showConfigurationForm(event);
    }
  } else if (event.type === 'CARD_CLICKED') {
    if (event.action?.actionMethodName === 'start_poll') {
      reply = await startPoll(event);
    }
  }
  res.json(reply);
}

/**
 * Handles the slash command to display the config form.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function showConfigurationForm(event) {
  // Seed the topic with any text after the slash command
  const topic = event.message?.argumentText?.trim();
  const dialog = buildConfigurationForm({
    topic,
    choices: [],
  });
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        dialog: {
          body: dialog,
        },
      },
    },
  };
}

/**
 * Handle the custom start_poll action.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function startPoll(event) {
  // Not fully implemented yet -- just close the dialog
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        actionStatus: {
          statusCode: 'OK',
          userFacingMessage: 'Poll started.',
        },
      },
    },
  }
}

עכשיו תוצג באפליקציה תיבת דו-שיח כשמפעילים את הפקודה /poll. כדי לבדוק את האינטראקציה, פורסים מחדש את הפונקציה של Cloud Functions מ-Cloud Shell.

gcloud functions deploy app --trigger-http --security-level=secure-always

אחרי שפורסים את Cloud Function, שולחים לאפליקציה הודעה עם הפקודה /poll כדי לבדוק את פקודת הסלאש ואת תיבת הדו-שיח. בתיבת הדו-שיח נשלח אירוע CARD_CLICKED עם הפעולה המותאמת אישית start_poll. האירוע מטופל בנקודת הכניסה המעודכנת שבה מבוצעת קריאה לשיטה startPoll. בשלב הזה, השיטה startPoll היא stubbed out ורק סוגרת את תיבת הדו-שיח. בקטע הבא תטמיעו את פונקציית ההצבעה ותקשרו בין כל החלקים.

הטמעה של כרטיס ההצבעה

כדי להטמיע את החלק של ההצבעה באפליקציה, צריך להתחיל בהגדרת הכרטיס האינטראקטיבי שמספק לאנשים ממשק להצבעה.

הטמעה של ממשק ההצבעה

יוצרים קובץ בשם vote-card.js עם התוכן הבא:

/**
 * Creates a small progress bar to show percent of votes for an option. Since
 * width is limited, the percentage is scaled to 20 steps (5% increments).
 *
 * @param {number} voteCount - Number of votes for this option
 * @param {number} totalVotes - Total votes cast in the poll
 * @returns {string} Text snippet with bar and vote totals
 */
function progressBarText(voteCount, totalVotes) {
  if (voteCount === 0 || totalVotes === 0) {
    return '';
  }

  // For progress bar, calculate share of votes and scale it
  const percentage = (voteCount * 100) / totalVotes;
  const progress = Math.round((percentage / 100) * 20);
  return '▀'.repeat(progress);
}

/**
 * Builds a line in the card for a single choice, including
 * the current totals and voting action.
 *
 * @param {number} index - Index to identify the choice
 * @param {string|undefined} value - Text of the choice
 * @param {number} voteCount - Current number of votes cast for this item
 * @param {number} totalVotes - Total votes cast in poll
 * @param {string} state - Serialized state to send in events
 * @returns {object} card widget
 */
function choice(index, text, voteCount, totalVotes, state) {
  const progressBar = progressBarText(voteCount, totalVotes);
  return {
    keyValue: {
      bottomLabel: `${progressBar} ${voteCount}`,
      content: text,
      button: {
        textButton: {
          text: 'vote',
          onClick: {
            action: {
              actionMethodName: 'vote',
              parameters: [
                {
                  key: 'state',
                  value: state,
                },
                {
                  key: 'index',
                  value: index.toString(10),
                },
              ],
            },
          },
        },
      },
    },
  };
}

/**
 * Builds the card header including the question and author details.
 *
 * @param {string} topic - Topic of the poll
 * @param {string} author - Display name of user that created the poll
 * @returns {object} card widget
 */
function header(topic, author) {
  return {
    title: topic,
    subtitle: `Posted by ${author}`,
    imageUrl:
      'https://raw.githubusercontent.com/google/material-design-icons/master/png/social/poll/materialicons/24dp/2x/baseline_poll_black_24dp.png',
    imageStyle: 'AVATAR',
  };
}

/**
 * Builds the configuration form.
 *
 * @param {object} poll - Current state of poll
 * @param {object} poll.author - User that submitted the poll
 * @param {string} poll.topic - Topic of poll
 * @param {string[]} poll.choices - Text of choices to display to users
 * @param {object} poll.votes - Map of cast votes keyed by user ids
 * @returns {object} card
 */
function buildVoteCard(poll) {
  const widgets = [];
  const state = JSON.stringify(poll);
  const totalVotes = Object.keys(poll.votes).length;

  for (let i = 0; i < poll.choices.length; ++i) {
    // Count votes for this choice
    const votes = Object.values(poll.votes).reduce((sum, vote) => {
      if (vote === i) {
        return sum + 1;
      }
      return sum;
    }, 0);
    widgets.push(choice(i, poll.choices[i], votes, totalVotes, state));
  }

  return {
    header: header(poll.topic, poll.author.displayName),
    sections: [
      {
        widgets,
      },
    ],
  };
}

exports.buildVoteCard = buildVoteCard;

ההטמעה דומה לגישה שבה נעשה שימוש בתיבת הדו-שיח, אבל תגי העיצוב של כרטיסים אינטראקטיביים שונים מעט מאלה של תיבות דו-שיח. כמו קודם, אפשר לראות דוגמה של קובץ ה-JSON שנוצר בכלי ליצירת כרטיסים.

הטמעה של פעולת ההצבעה

כרטיס ההצבעה כולל לחצן לכל אפשרות. האינדקס של הבחירה הזו, יחד עם המצב הסדרתי של הסקר, מצורף ללחצן. האפליקציה מקבלת CARD_CLICKED עם הפעולה vote וכל הנתונים שמצורפים ללחצן כפרמטרים.

עדכון של index.js באמצעות:

const { buildConfigurationForm, MAX_NUM_OF_OPTIONS } = require('./config-form');
const { buildVoteCard } = require('./vote-card');

/**
 * App entry point.
 */
exports.app = async (req, res) => {
  if (!(req.method === 'POST' && req.body)) {
      res.status(400).send('')
  }
  const event = req.body;
  let reply = {};
  // Dispatch slash and action events
  if (event.type === 'MESSAGE') {
    const message = event.message;
    if (message.slashCommand?.commandId === '1') {
      reply = showConfigurationForm(event);
    }
  } else if (event.type === 'CARD_CLICKED') {
    if (event.action?.actionMethodName === 'start_poll') {
      reply = await startPoll(event);
    } else if (event.action?.actionMethodName === 'vote') {
        reply = recordVote(event);
    }
  }
  res.json(reply);
}

/**
 * Handles the slash command to display the config form.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function showConfigurationForm(event) {
  // Seed the topic with any text after the slash command
  const topic = event.message?.argumentText?.trim();
  const dialog = buildConfigurationForm({
    topic,
    choices: [],
  });
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        dialog: {
          body: dialog,
        },
      },
    },
  };
}

/**
 * Handle the custom start_poll action.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function startPoll(event) {
  // Not fully implemented yet -- just close the dialog
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        actionStatus: {
          statusCode: 'OK',
          userFacingMessage: 'Poll started.',
        },
      },
    },
  }
}

/**
 * Handle the custom vote action. Updates the state to record
 * the user's vote then rerenders the card.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function recordVote(event) {
  const parameters = event.common?.parameters;

  const choice = parseInt(parameters['index']);
  const userId = event.user.name;
  const state = JSON.parse(parameters['state']);

  // Add or update the user's selected option
  state.votes[userId] = choice;

  const card = buildVoteCard(state);
  return {
    thread: event.message.thread,
    actionResponse: {
      type: 'UPDATE_MESSAGE',
    },
    cards: [card],
  }
}

השיטה recordVote מנתחת את המצב המאוחסן ומעדכנת אותו בהצבעה של המשתמש, ואז מעבדת מחדש את הכרטיס. תוצאות הסקר עוברות סריאליזציה ונשמרות בכרטיס בכל פעם שהוא מתעדכן.

חיבור החלקים

האפליקציה כמעט מוכנה. אחרי שמטמיעים את פקודת הלוכסן יחד עם ההצבעה, נשאר רק לסיים את השיטה startPoll.

אבל יש מלכוד.

כששולחים את הגדרת הסקר, האפליקציה צריכה לבצע שתי פעולות:

  1. סוגרים את תיבת הדו-שיח.
  2. מפרסמים הודעה חדשה במרחב עם כרטיס ההצבעה.

לצערנו, התשובה הישירה לבקשת ה-HTTP יכולה להיות רק אחת, וחובה שהיא תהיה הראשונה. כדי לפרסם את כרטיס ההצבעה, האפליקציה צריכה להשתמש ב-Chat API כדי ליצור הודעה חדשה באופן אסינכרוני.

הוספה של ספריית הלקוח

מריצים את הפקודה הבאה כדי לעדכן את התלויות של האפליקציה כך שיכללו את לקוח Google API ל-Node.js.

npm install --save googleapis

התחלת הסקר

צריך לעדכן את index.js לגרסה הסופית שמופיעה בהמשך:

const { buildConfigurationForm, MAX_NUM_OF_OPTIONS } = require('./config-form');
const { buildVoteCard } = require('./vote-card');
const {google} = require('googleapis');

/**
 * App entry point.
 */
exports.app = async (req, res) => {
  if (!(req.method === 'POST' && req.body)) {
      res.status(400).send('')
  }
  const event = req.body;
  let reply = {};
  // Dispatch slash and action events
  if (event.type === 'MESSAGE') {
    const message = event.message;
    if (message.slashCommand?.commandId === '1') {
      reply = showConfigurationForm(event);
    }
  } else if (event.type === 'CARD_CLICKED') {
    if (event.action?.actionMethodName === 'start_poll') {
      reply = await startPoll(event);
    } else if (event.action?.actionMethodName === 'vote') {
        reply = recordVote(event);
    }
  }
  res.json(reply);
}

/**
 * Handles the slash command to display the config form.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function showConfigurationForm(event) {
  // Seed the topic with any text after the slash command
  const topic = event.message?.argumentText?.trim();
  const dialog = buildConfigurationForm({
    topic,
    choices: [],
  });
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        dialog: {
          body: dialog,
        },
      },
    },
  };
}

/**
 * Handle the custom start_poll action.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
async function startPoll(event) {
  // Get the form values
  const formValues = event.common?.formInputs;
  const topic = formValues?.['topic']?.stringInputs.value[0]?.trim();
  const choices = [];
  for (let i = 0; i < MAX_NUM_OF_OPTIONS; ++i) {
    const choice = formValues?.[`option${i}`]?.stringInputs.value[0]?.trim();
    if (choice) {
      choices.push(choice);
    }
  }

  if (!topic || choices.length === 0) {
    // Incomplete form submitted, rerender
    const dialog = buildConfigurationForm({
      topic,
      choices,
    });
    return {
      actionResponse: {
        type: 'DIALOG',
        dialogAction: {
          dialog: {
            body: dialog,
          },
        },
      },
    };
  }

  // Valid configuration, build the voting card to display
  // in the space
  const pollCard = buildVoteCard({
    topic: topic,
    author: event.user,
    choices: choices,
    votes: {},
  });
  const message = {
    cards: [pollCard],
  };
  const request = {
    parent: event.space.name,
    requestBody: message,
  };
  // Use default credentials (service account)
  const credentials = new google.auth.GoogleAuth({
    scopes: ['https://www.googleapis.com/auth/chat.bot'],
  });
  const chatApi = google.chat({
    version: 'v1',
    auth: credentials,
  });
  await chatApi.spaces.messages.create(request);

  // Close dialog
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        actionStatus: {
          statusCode: 'OK',
          userFacingMessage: 'Poll started.',
        },
      },
    },
  };
}

/**
 * Handle the custom vote action. Updates the state to record
 * the user's vote then rerenders the card.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function recordVote(event) {
  const parameters = event.common?.parameters;

  const choice = parseInt(parameters['index']);
  const userId = event.user.name;
  const state = JSON.parse(parameters['state']);

  // Add or update the user's selected option
  state.votes[userId] = choice;

  const card = buildVoteCard(state);
  return {
    thread: event.message.thread,
    actionResponse: {
      type: 'UPDATE_MESSAGE',
    },
    cards: [card],
  }
}

פורסים מחדש את הפונקציה:

gcloud functions deploy app --trigger-http --security-level=secure-always

עכשיו תוכלו להשתמש באפליקציה באופן מלא. נסו להפעיל את הפקודה /poll, להזין שאלה וכמה אפשרויות. אחרי השליחה, כרטיס הסקר יופיע.

מה תהיה הצבעתך?

כמובן שאין הרבה תועלת ביצירת סקר לעצמכם, אז כדאי להזמין חברים או קולגות לנסות אותו.

6. מזל טוב

מעולה! יצרתם ופרסתם בהצלחה אפליקציה ל-Google Chat באמצעות Cloud Functions. במהלך ה-codelab נגענו בהרבה מושגי ליבה שקשורים ליצירת אפליקציה, אבל יש עוד הרבה מה ללמוד. כדאי לעיין במשאבים שבהמשך ולזכור לנקות את הפרויקט כדי להימנע מחיובים נוספים.

פעילויות נוספות

אם אתם רוצים לקבל מידע נוסף על פלטפורמת Chat ועל האפליקציה הזו, הנה כמה דברים שאתם יכולים לנסות בעצמכם:

  • מה קורה כשמתייגים את האפליקציה באמצעות @? כדאי לנסות לעדכן את האפליקציה כדי לשפר את ההתנהגות שלה.
  • אפשר להשתמש בסדרת נתונים של מצב הסקר בכרטיס במרחבים קטנים, אבל יש לזה מגבלות. כדאי לנסות לעבור לאפשרות טובה יותר.
  • מה קורה אם המחבר רוצה לערוך את הסקר או להפסיק לקבל הצבעות חדשות? איך היית מטמיע את התכונות האלה?
  • נקודת הקצה של האפליקציה עדיין לא מאובטחת. כדאי להוסיף אימות כדי לוודא שהבקשות מגיעות מ-Google Chat.

אלה רק כמה דרכים לשפר את האפליקציה. תהנו ותפעילו את הדמיון!

הסרת המשאבים

כדי להימנע מחיובים בחשבון Google Cloud Platform בגלל השימוש במשאבים שנעשה במסגרת המדריך הזה:

  • במסוף Cloud, נכנסים לדף Manage resources. בפינה הימנית העליונה, לוחצים על תפריט סמל התפריט > IAM וניהול > ניהול משאבים.
  1. ברשימת הפרויקטים, בוחרים את הפרויקט ולוחצים על Delete.
  2. כדי למחוק את הפרויקט, כותבים את מזהה הפרויקט בתיבת הדו-שיח ולוחצים על Shut down.

מידע נוסף

מידע נוסף על פיתוח אפליקציות ל-Chat:

מידע נוסף על פיתוח ב-Google Cloud Console: