Serverless פשוט וחינמי - Google Apps Script

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

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

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

מה הבעיה?

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

להריץ על איזה מחשב ישן זה קצת בעייתי:

  • המחשב מאבד תקשורת לרשת מידי פעם.
  • המחשב זמין רק פיזית (או רק בSSH מהרשת הביתית)
  • כשיש לי אוטומציות שדורשות לשמור State, אני נכנס לתחום מורכב, אני יכול להרים DB, או לשמור הכל בקבצים, אבל כאן כבר כדאי לי להתעסק בגיבויים בצורה כלשהי, וחוצמיזה, מדובר בסקריפט בסה"כ, אין לי back office ועכשיו אני שוב צריך חיבור לDB או לעריכה של הקבצים.

כמובן, פתרון חלקי הוא להרים מכונה או שירות בענן, אבל זה עולה כסף, או שיש איזה תוכנית חינמית, אבל אני מתחיל להסתבך עם קונפיגורציות של DevOps וכל ההרשאות שם בAWS (או בOracle, לא משנה, שם ביקשתי עזרה מהחבר הDevOps שלי רק כדי להתחבר למכונה שהרמתי).

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

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

Google Apps Script

נבחן רגע את הדרישות שהגדרנו.

  • מחיר: חינם (כלומר, יש איזה רף שממנו מתחילים לשלם (freemium) אבל קשה להגיע אליו בסקריפטים אישיים).
  • גישה: רק למשתמש יש גישה, זה מסמך/שירות של גוגל, כמו Gmail או docs (אלא אם פותחים endpoint ספציפי, נראה בהמשך).
  • תקשורת: תמיד (אני מקווה, בטוח יותר ממחשב שמחובר אצלי לרשת הביתית).
  • ממשק חיבור: חיבור דרך הדפדפן, מכל רשת אינטרנט.
  • נתונים: זה קטע שממש אהבתי- אפשר תמיד לשמור את הנתונים בגליון אלקטרוני (Google Sheets), ואז אמנם הקוד רץ ומשתמש בהם, אבל אנחנו יכולים גם תמיד לגעת בהם ידנית בצורה נוחה.

עוד כמה נתונים על פיתוח בGoogle Apps Script (GAS):

  • שפה: JavaScript
  • שימוש בספריות: מנגנון ספריות ייחודי ומוגבל, זה בהחלט חסרון.
  • סביבת פיתוח: סביבה ייחודית של Google Apps Script. היא לא משוכללת יותר מידי, ובתור מתכנת היא מרגישה קצת מוגבלת, אבל במחשבה שניה ייתכן שלאנשים שלא מתכנתים ביום יום שלהם היא דווקא תהיה פשוטה ונוחה יותר.
  • יש דיבאגר, יש לוגים (ברוב המקרים). אפשר אפילו לקבל מייל אם יש שגיאה בזמן ריצה.
  • אפשר להתממשק בקלות להמון שירותים של גוגל (שזה כבר חצי מהשירותים שקיימים בעולם ;-) )

שליחת מידע לGoogle Apps Script

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

בGAS אפשר לפתוח לקוד גישה חיצונית לבקשות GET & POST, מה שעקרונית מאפשר לנו התממשקות עם שירותים אחרים, על ידי שליחת מידע או קבלת מידע.

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

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

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

אז הנה הקוד להתממשקות עם טלגרם:

/**
* Every time you changing something related to the Bot webhook, you have to
* 1. Deploy as Webapp
* 2. Copy the deployment URL
* 3. Paste it here
* 4. Run the setWebhook function
*/
const token = '<<Telegram Token>>'
const url = 'https://script.google.com/macros/s/*********/exec'
const myChatId = <<your chat id>>;
const telegramURL = `https://api.telegram.org/bot${token}`
const telegramFileUrl = `https://api.telegram.org/file/bot${token}`
function setWebhook() {
const res = request('setWebhook', { url })
Logger.log(res)
}
function request(method, data) {
var options = {
'method': 'post',
'contentType': 'application/json',
'payload': JSON.stringify(data)
};
var response = UrlFetchApp.fetch(`${telegramURL}/${method}`, options);
if (response.getResponseCode() == 200) {
return JSON.parse(response.getContentText());
}
return false;
}
/**
* https://core.telegram.org/bots/api#update
*
* @return {{
* chatId: number,
* text: string,
* update: Update
* }}
*/
function getUpdate(doPostE) {
// Make sure to only reply to json requests
if (doPostE.postData.type == "application/json") {
// Parse the update sent from Telegram
const update = JSON.parse(doPostE.postData.contents);
const text = update.message.text
log(`Message: ${text}`)
return {
chatId: update.message.from.id,
text,
update
}
}
}
function replyToSender(update, text) {
sendMessage(update.message.from.id, text)
}
function sendMessage(chatId, text, parse_mode) {
return request('sendMessage', {
'chat_id': chatId,
'text': text,
parse_mode,
});
}
/**
* @param update {Update}
*/
function isDocument(update) {
return !!update.message.document
}
function DownloadFile(fileId) {
const response = UrlFetchApp.fetch(`${telegramURL}/getFile?file_id=${fileId}`);
if (response.getResponseCode() != 200) {
log('getFile', 'response', response.getResponseCode())
throw new Error()
}
const parsed = JSON.parse(response.getContentText())
log('parsed', parsed)
const urlFile = `${telegramFileUrl}/${parsed.result["file_path"]}`;
log('urlFile', urlFile)
const resa = UrlFetchApp.fetch(urlFile);
const blob = resa.getBlob();
return blob;
}
view raw telegram.gs hosted with ❤ by GitHub

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

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

הערה קטנה לגבי Types בקוד.

אמנם הEditor מאוד בסיסי ותומך רק בJavaScript, אבל הוא בכל זאת תומך בJSDoc Types, אז כדי לקבל עזרה עם השלמת מילים, אפשר להגדיר Types בהערות.

אז מה אנחנו רואים בקוד?

בעקרון אין מודולים, אז קשה לשים לב לאיזה פונקציות מיועדות לשימוש בקבצים אחרים (בגלל שאין export). הפונקציות החשובות הן sendMessage וReplyToSender, בהן אנחנו יכולים להשתמש כדי לשלוח הודעות בטלגרם.

עוד דבר שחשוב מאוד לשים לב אליו זאת הפונקציה המיוחדת doPost. לא רואים שהיא מיוחדת, אבל כשמעלים את הפרויקט כWebApplication, אם שולחים בקשת POST לכתובת שקיבלנו, הבקשה תגיע לdoPost (ובהתאם, בקשת GET תגיע לdoGet).
מכיוון שרשמנו את האפליקצייה שלנו כWebhook בטלגרם, בכל פעם שיקרה אירוע בבוט שלנו בטלגרם, אנחנו נקבל את האירוע כפרמטר של doPost על פי התיעוד של טלגרם.

function doPost(e) {
log("Received doPost")
try {
const { chatId, text, update } = getUpdate(e)
if (myChatId !== chatId)
return sendMessage(chatId, `You are not authorized`)
if (!isDocument(update))
return sendMessage(chatId, `message is without document`)
const fileUrl = HandleTelegrmFile(update.message.document, new Date(update.message.date * 1000))
replyToSender(update, fileUrl)
} catch (e) {
log('error', JSON.stringify(e, Object.getOwnPropertyNames(e)))
sendMessage(myChatId, JSON.stringify(e, Object.getOwnPropertyNames(e)))
throw e
}
}
view raw main.gs hosted with ❤ by GitHub

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

שימוש בספריות

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

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

כדי להוסיף את ספריית twitter-lib יש ללחוץ על הוספת ספריה בעורך הקוד:

הוספת ספריה

בחלון ההוספה יש להכניס את מזהה הספריה (נכון לזמן כתיבת הקוד, הספריה זמינה בגרסה 25 עם המזהה 11dB74uW9VLpgvy1Ax3eBZ8J7as0ZrGtx4BPw7RKK-JQXyAJHBx98pY-7)

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

  • OAuth1 גרסה 18: 1CXDCY5sqT9ph64fFwSzVtXnbjpSfWdRymafDrtIZ7Z_hwysTY7IIhi7s
  • OAuth2 גרסה 41: 1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

טוויטר

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

הנה הקוד:

/*
callback url:
Click on File > Project properties and copy the value from 'Project key'
your callback url will be:
https://script.google.com/macros/d/[Project key]/usercallback
copy this url and paste it in your app on the Twitter developer dashboard:
https://developer.twitter.com/
you should also paste it in the code here, line 18;
*/
var consumer_key = "****"
var consumer_secret = "****"
var project_key = "****" // File > Project properties > Project key
function onOpen() {
var ui = SpreadsheetApp.getUi();
ui.createMenu('Twitter')
.addItem('Tweet test text', 'sendTestTweet')
.addItem('revoke', 'authorizationRevoke')
.addItem('show my callBack url', 'getCallBackUrl')
.addToUi();
msgPopUp('<p>Click on Tools > Script Editor and follow instructions</p>');
};
function sendTestTweet() {
doTweet("hello world");
}
function authorizationRevoke() {
var scriptProperties = PropertiesService.getScriptProperties();
scriptProperties.deleteProperty('oauth1.twitter');
msgPopUp('<p>Your Twitter authorization credentials have been deleted. You\'ll need to re-run "Send a Test Tweet" to reauthorize before you can start posting again.');
}
function getTwitterService() {
var service = OAuth1.createService('twitter');
service.setAccessTokenUrl('https://api.twitter.com/oauth/access_token');
service.setRequestTokenUrl('https://api.twitter.com/oauth/request_token');
service.setAuthorizationUrl('https://api.twitter.com/oauth/authorize');
service.setConsumerKey(consumer_key);
service.setConsumerSecret(consumer_secret);
service.setCallbackFunction('authCallback');
service.setPropertyStore(PropertiesService.getScriptProperties());
const x = PropertiesService.getScriptProperties();
return service;
}
function authCallback(request) {
var service = getTwitterService();
var isAuthorized = service.handleCallback(request);
if (isAuthorized) {
return HtmlService.createHtmlOutput('Success! You can close this page.');
} else {
return HtmlService.createHtmlOutput('Denied. You can close this page');
}
}
function msgPopUp(msg) {
var content = '<div style="font-family: Verdana;font-size: 22px; text-align:left; width: 95%; margin: 0 auto;">' + msg + '</div>';
var htmlOutput = HtmlService
.createHtmlOutput(content)
.setSandboxMode(HtmlService.SandboxMode.IFRAME)
.setWidth(600)
.setHeight(500);
SpreadsheetApp.getUi().showModalDialog(htmlOutput, ' ');
}
function twitterAPIRequest(url, parameters) {
var service = getTwitterService();
if (!service.hasAccess()) {
var authorizationUrl = service.authorize();
msgPopUp('<p>Please visit the following URL and then re-run "Send a Test Tweet": <br/> <a target="_blank" href="' + authorizationUrl + '">' + authorizationUrl + '</a></p>');
return;
}
var result = service.fetch("https://api.twitter.com" + url, parameters);
return JSON.parse(result.getContentText());
}
/**
* @param tweet {string}
* @return {string}
*/
function doTweet(tweet) {
var id_str = "";
var url = "/1.1/statuses/update.json";
var payload = "status=" + fixedEncodeURIComponent(tweet);
var parameters = {
"method": "POST",
"escaping": false,
"payload": payload
};
try {
var result = twitterAPIRequest(url, parameters);
id_str = result.id_str;
}
catch (e) {
Logger.log(e.toString());
throw e
}
return id_str;
}
/**
* @param tweet {string}
* @param status_id {string|number}
* @return {string}
*/
function doReply(tweet, status_id) {
var url = '/1.1/statuses/update.json';
var payload = {
"in_reply_to_status_id": status_id,
"auto_populate_reply_metadata": true,
"status": tweet
}
var parameters = {
"method": "POST",
"escaping": false,
"payload": payload
};
const result = twitterAPIRequest(url, parameters);
Logger.log(result);
return result.id_str;
}
function fixedEncodeURIComponent(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, function (c) {
return '%' + c.charCodeAt(0).toString(16);
});
}
view raw twitter.gs hosted with ❤ by GitHub

טריגרים

טריגרים

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

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

הוספת טריגר

סיכום

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

מה בפועל אפשר לעשות עם סקריפטים קטנים כאלה? מתברר שהרבה.
אני חושב שעמרי מבסס את זזנו-בוט על GAS.

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

  • סיכום פודקאסטים:
    כשאני שומע פודקאסט בפלאפון, אני עושה share לבוט בטלגרם.
    GAS מקבל את ההודעה ומחלץ מתוך הURL (בעזרת פונקציית IMPORTXML) את כותרת הפרק ושם הפודקאסט, ומוסיף אותם לטבלה בSheets.
    בזמני הפנוי אני נכנס לאפליקצייה שבניתי בעזרת Glideapps וכותב סיכום לפרק ששמעתי. יש מיפוי אוטומטי משם הפודקאסט לתיוגים שאני רוצה להוסיף להודעה, אבל אם אני רוצה לתייג אורח למשל, אני מוסיף את התיוג שלו.
    יש לי checkbox שאני מסמן כשהסיכום מוכן, וGAS מזהה אותו ובונה הודעה לטוויטר ולטלגרם, ומצייץ ושולח.
  • ארגון מסמכים:
    אני משתמש בתוכנת paperwork לתיוק המסמכים שאני מקבל בדואר ובמייל. התוכנה מסדרת אותם במבנה תיקיות וקבצים מסוים, אז יש לי פילטר במייל שמתייג הודעות קבועות שמכילות קובץ שאני רוצה לשמור, וGAS עובר על המייל אחת ל10 דקות ומוצא את ההודעות הללו, לוקח את הattachment, שומר אותו בDrive במבנה תיקיות הנכון ומשנה את התיוג של ההודעה.

בונוס - ממשקים נוספים

קוד כל, אם קישרתם את פרויקט הGAS שלכם לGoogle Sheets, אני ממליץ לשמור את הstate שלכם בגליון (תכל'ס לא ידוע לי על דרך אחרת).

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

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

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

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