Google App Script (GAS) là ngôn ngữ lập trình dựa trên JavaScript với việc biên tập, biên dịch cũng như lưu trữ đều được đảm nhiệm bởi Gooogle. Về tài liệu liên quan tới GAS các bạn có thể tìm thấy rất nhiều tại đây.
Việc hiểu và biết cách sử dụng GAS sẽ giúp cho cả tôi và bạn có thể tận dụng hệ sinh thái của Google hiệu quả cũng như tiết kiệm rất nhiều thời gian khi làm việc với chúng.
Trong bài viết này tôi sẽ không đi quá sâu vào ngôn ngữ GAS mà chỉ nêu ra một trong số các bài toán tôi đã và đang giải quyết nó nhanh gọn bằng GAS trong nhiều năm trở lại đây.
Bài toán
Tôi có một spreadsheet quản lý các câu hỏi (từ team Offshore) và câu trả lời (từ khách hàng) & nghiễm nhiên các câu hỏi và câu trả lời đều được dịch song ngữ (JP-VN). Spreadsheet này tạm gọi tắt là
Spreadsheets Q&A
.Tuy nhiên trong quá trình phát triển thường xảy ra vấn đề khách hàng trả lời câu hỏi trong
Spreadsheets Q&A
nhưng lại không thông báo lại cho phía VN (Qua Brse/Comtor/Team Leader, …etc) dẫn tới tình trạng ứ đọng việc trao đổi giữa hai bên, tốn thời gian confirm qua lại.
Về nội dung Spreadsheets Q&A
thì tương tự như ảnh bên dưới 👇
Giải pháp A
Ban đầu tôi thường xuyên nhắc nhở khách hàng trên nhóm chat rằng: phải thông báo cho phía VN khi họ trả lời xong một vài Q&A nào đó → Họ cũng ghi nhận, nhưng kết quả là họ chỉ thực hiện tốt việc đó một vài lần đầu 🤣
Giải pháp B
Sau khi giải pháp A thất bại, tôi nghĩ tới cách “tay to” này, đó là tôi dùng 👀 gửi polling request vào Spreadsheets Q&A
😂. Mỗi khi thấy thanh niên khách hàng update là tôi lại hỏi lại nó: “Mày trả lời câu #xyz rồi hả?”, thế để tao nhắn lại cho đội VN nhé 😎 Cách này khá OK, nhưng tôi mệt vãi nồi các bạn ạ ! Không thể giải thoát cái đầu mình khỏi việc tracking các thay đổi trên Spreadsheets Q&A
Giải pháp C
Nhờ một thành viên trong team, tracking sự thay đổi trong Spreadsheets Q&A
, thực sự khó vì méo ai muốn làm công việc nè 👎
Giải pháp D
Sử dụng Bot, giúp tôi watching các thay đổi trên Spreadsheets
. Nếu như người thay đổi là khách hàng hoặc ô (cell
) thay đổi nội dung là ô (cell
) dành cho khách (Các cell
thuộc column G
như Spreadsheets Q&A
tôi có đính kèm ở trên) thì Bot sẽ gửi tin nhắn tới Box chat của dự án, hoặc Box chat private giữa tôi và nó, kèm theo message mô tả nội dung thay đổi.
Như bạn thấy, ở ảnh trên, con Bot không chỉ làm nhiệm vụ tracking các thay đổi trong Spreadsheets Q&A
mà còn làm thêm nhiệm vụ tracking các thay đổi trong Spreadsheets Feedback
(một tài liệu khác). Giảm tải được rất nhiều việc cho tôi ahihi. 💯
Sử dụng Google Apps Script, tất nhiên bạn cần biết lập trình một chút, không cần quá giỏi, chỉ cần đọc hiểu được tài liệu của Google cung cấp sẵn là 👌
- Google Apps Script + Google Sheets
- Tạo Trigger để run Apps Script khi 1 hoạt động nào đó xảy ra
- Tài liệu tổng hợp các Spreadsheet Service
- URL Fetch - HTTP/HTTPS request trong Apps Script
- Utilities - Các tiện ích như encoding/decoding, formatting,… trong Apps Script
Sau khi hiểu một vài khái niệm cũng như các function của Apps Script, trước hết các bạn thực hiện từng bước theo hướng dẫn sau để làm quen trước nhé:
Cách chạy Apps Script trong Spreadsheet
- Đầu tiên các bạn mở
Spreadsheet
ra, vào mụcTools > Script editor
Đây chính là nơi để paste đoạn code cần thực hiện vào. - Để chạy đoạn code bạn hãy chọn Function muốn thực hiện và bấm vào icon
- Để xem Log của script thì chúng ta các 3 kiểu Log sau:
-
Excution transcript
:Every time you run a script, Google Apps Script records an execution transcript, which is a record of each call to a Google Apps Script service that is made while the script runs.
Để truy cập các bạn vào menu
View > Execution transcript
-
Apps Script Logger
:Lightweight but persists only for a short time. These logs are intended for simple checks during development and debugging, and do not persist very long.
Sử dụng
Logger
tương đối dễ, ví dụ đơn giản:// Log the number of Google Groups you belong to. var groups = GroupsApp.getGroups(); Logger.log('You are a member of %s Google Groups.', groups.length);
Để truy cập các bạn vào menu
View > Logs
-
Stackdriver Logging
:Google Cloud Platform (GCP) Stackdriver Logging service (formerly known as “Cloud Logging”). When you require logging that persists for several days, or need a more complex logging solution for a multi-user production environment, Stackdriver Logging is the preferred choice.
Sử dụng
Stackdriver Logging
tương đối dễ, ví dụ đơn giản:/** * Logs the time taken to execute 'myFunction'. */ function measuringExecutionTime() { // A simple INFO log message, using sprintf() formatting. console.info('Timing the %s function (%d arguments)', 'myFunction', 1); ... }
Để truy cập các bạn vào menu
View > Logs > Apps Script Dashboard
Tuy nhiên có một hạn chế đó là trênApps Script Dashboard
chỉ có thể xem được message log sinh ra khi script được thực hiện bởi chính người đó, còn nếu bạn muốn xem được toàn bộ log thì bạn buộc phải attachApps script
vào mộtGCP Project
. Xem hướng dẫn tại đây hoặc như ảnh bên dưới.Setting
OAuth consent screen
Attach to standardGCP project
-
- Trong Apps Script có file
manifest: appsscript.json
theo formatJSON
chứa các cấu hình cơ bản để có thể chạy được code. Ví dụ nhưtimeZone
,oauthScopes
, … để hiển thị nội dung file này, các bạn vàoView > Show manifest file
Implement Giải pháp D
-
[1.] Trong file
appsscript.json
các bạn thêm nội dung sau vào:{ "timeZone": "Asia/Bangkok", "dependencies": { }, "exceptionLogging": "STACKDRIVER", "oauthScopes": [ "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/userinfo.email" ] }
-
[2.] Trong file
Code.gs
các bạn sử dụng code sau và chỉnh sửa lại các params cho hợp lý// Please edit following constant before apply this script var CHATWORK_TOKEN = 'your_chatwork_token'; // Chatwork API token var CHATWORK_ROOM_ID = '12345678'; // Chatwork notice room ID var CHATWORK_SLEEP = 30; // Time sleep before notice / Unit: second var CHATWORK_MESSAGE = '[To:1] Nguyen Van A\n(F) The answer for question [#%s] has been updated !\nPlease check it out: %s'; // Nội dung message sẽ gửi vào room trên var CUSTOMER_INFO = [{name: 'Customer A', email: 'customer.a@example.jp'}, {name: 'Customer B', email: 'customer.b@example.jp'}]; // Thông tin khách hàng thao tác với sheet Q&A var OFFSHORE_INFO = [{name: 'ANV', email: 'nguyen.van.a@example.com'}]; // Thông tin VN team thao tác với sheet Q&A var SPREADSHEET_SHEET_LOGS = 'Logs'; // Tên sheet dùng để log dữ liệu notice nhằm tránh gửi lại 2 lần một thông báo giống nhau var SPREADSHEET_SHEET_WATCHING = [{name: 'Sprint4~QA一覧', subjectColumn: 'A', watchColumn: 'G'}, {name: 'QA一覧', subjectColumn: 'A', watchColumn: 'G'}]; // Thông tin các sheet sẽ tracking sự thay đổi gồm có: Tên sheet/Số thứ tự Q&A/Cột tracking nội dung - Có thể để null nếu muốn tracking toàn bộ nội dung sheet // Trigger when user edit sheet function onEditTrigger(e) { var spreadsheet = e.source; var spreadsheetId = spreadsheet.getId(); var spreadsheetTz = spreadsheet.getSpreadsheetTimeZone(); var spreadsheetLogs = SPREADSHEET_SHEET_LOGS; var sheet = e.range.getSheet(); var sheetName = spreadsheet.getActiveSheet().getName(); var url = spreadsheet.getUrl(); var targetSheet = getItemFromArrayObject(SPREADSHEET_SHEET_WATCHING, 'name', sheetName); // We dont care if someone insert/clear logs manually // Or do not send message if recently notice if ((targetSheet == false) || !isShouldSend(spreadsheetId, spreadsheetTz, spreadsheetLogs, sheet, targetSheet)) { return; } var msg = buildMessage(url, sheet, targetSheet); Utilities.sleep(CHATWORK_SLEEP*1000); sendMessage(CHATWORK_ROOM_ID, msg); } // Set header for Logs sheet/ Run once time function settingLogsSpreadsheet(spreadsheetId, spreadsheetLogs) { if (typeof(spreadsheetId) == 'undefined') { spreadsheetId = SpreadsheetApp.getActive().getId(); } if (typeof(spreadsheetLogs) == 'undefined') { spreadsheetLogs = SPREADSHEET_SHEET_LOGS; } var ssLogs = SpreadsheetApp.openById(spreadsheetId).getSheetByName(spreadsheetLogs); if (ssLogs == null) { ssLogs = SpreadsheetApp.openById(spreadsheetId).insertSheet(spreadsheetLogs); } else { return '✗ Spreadsheet is already existing.'; } ssLogs.setFrozenRows(1); ssLogs.getRange(1, 1, 1, 3) .setBackground('lightGray') .setFontWeight('bold') .setValues([['Subject', 'Editor', 'Datetime']]); return '✔ Spreadsheet header was set successfully.'; } // Check condition to decide execute sending notice message or not function isShouldSend(spreadsheetId, spreadsheetTz, spreadsheetLogs, sheet, targetSheet) { var email = getEditorEmail(); var editor = getCustomer(email); var subject = getSubject(sheet, targetSheet); var now = new Date(); var nowFormatted = Utilities.formatDate(new Date(), spreadsheetTz, "yyyy-MM-dd HH:mm:ss"); var ssLogs = SpreadsheetApp.openById(spreadsheetId).getSheetByName(spreadsheetLogs); var vlookups = ssLogs.getRange(2, 1, ssLogs.getLastRow(), ssLogs.getLastColumn()).getValues(); var columnLetter = getColumnLetter(sheet.getActiveCell().getColumn()); // Return if editor is not a customer if (editor === false || (targetSheet.watchColumn !== null && targetSheet.watchColumn !== columnLetter)) { console.info('This editting is should not send'); console.info('Email: %s', email); return false; } // Match item have same subject in logs for (var i = 0; i < vlookups.length; i++) { var rows = vlookups[i]; // Logs sheet is not empty if (rows.length == 3) { if (rows[0] == subject) { var lastNoticedAt = new Date(rows[2]); var diffTime = Math.floor((now.valueOf() - lastNoticedAt.valueOf())/1000/60); // Unit: minute // No notice if we recently notice 2 minutes ago if (diffTime < 2) return false; break; } continue; } } // Insert log at first ssLogs.insertRowBefore(2); ssLogs.getRange(2, 1, 1, 3).setValues([[subject, editor.email, nowFormatted]]); return true; } // Get column letter from column index number: A, B, C, ... function getColumnLetter(column) { var temp, letter = ''; while (column > 0) { temp = (column - 1) % 26; letter = String.fromCharCode(temp + 65) + letter; column = (column - temp - 1) / 26; } return letter; } // Get active sheet header function getHeader(sheet) { var data = sheet.getRange("A1:1").getValues(); var header = data[0]; return header; } // Get link to cell in spreadsheet function getCellUrl(url, sheetId, row, column) { var cell = getColumnLetter(column) + row; var rows = row + ':' + row; return url + '#gid=' + sheetId + '&range=' + rows; } // Determine edit subject function getSubject(sheet, targetSheet) { var row = sheet.getActiveCell().getRow(); var cellName = Utilities.formatString('%s%d', targetSheet.subjectColumn, row); var subject = ''; var cell = sheet.getRange(cellName); if (!cell.isPartOfMerge()) { subject = cell.getValue(); } else if (cell.getValue() != "") { subject = cell.getValue(); } else { var range = cell.getMergedRanges()[0]; return range.getDisplayValue(); } return subject; } // Get editor email and return email if same G-suite domain function getEditorEmail() { var email = Session.getActiveUser().getEmail(); return email; } // Get customer info from email function getCustomer(email) { if (email == '') { // Because we dont retrieve user email now > we temporary hard fix it T.T email = 'customer.a@example.jp'; } return getItemFromArrayObject(CUSTOMER_INFO, 'email', email); } // Get offshore info from email function getOffshore(email) { return getItemFromArrayObject(OFFSHORE_INFO, 'email', email); } // Build meaning message body from edit info function buildMessage(url, sheet, targetSheet, editor, row, column) { var sheetId = sheet.getSheetId(); var row = sheet.getActiveCell().getRow(); var column = sheet.getActiveCell().getColumn(); var cellUrl = getCellUrl(url, sheetId, row, column); var subject = getSubject(sheet, targetSheet); var email = getEditorEmail(); var editor = getCustomer(email); var message = Utilities.formatString(CHATWORK_MESSAGE, subject, cellUrl); return message; } // Send message to Chatwork room via Chatwork API v2 function sendMessage(roomId, body) { UrlFetchApp.fetch('https://api.chatwork.com/v2/rooms/' + roomId + '/messages', { 'method' : 'post', 'payload' : { "body" : body }, 'headers' : { 'X-ChatWorkToken': CHATWORK_TOKEN } }); } // Get element has property equal value in array object list function getItemFromArrayObject(arr, key, value) { for (var i = 0; i < arr.length; i++) { var item = arr[i]; if (typeof(item[key]) == 'undefined') { continue; } if (item[key] == value) { return item; } } return false; }
Nhớ
execute
functionsettingLogsSpreadsheet
để tạo sheetLogs
nhé. Khi chạy function này, script sẽ yêu cầu một số permission từ gsuite account, hay cho phép [allow]. -
[3.] Thiết lập
onEdit
installable trigger theo hướng dẫn sau:
Sau khi hoàn tất việc thiết lập trigger, mỗi khi có một hành động chỉnh sửa file Spreadsheet
, hệ thống sẽ thông báo tới bạn qua Chatwork nếu người chỉnh sửa đó là khách hàng.
Vấn đề tồn tại
Trong đoạn mã ở file Code.gs
có method:
// Get editor email and return email if same G-suite domain
function getEditorEmail() {
var email = Session.getActiveUser().getEmail();
return email;
}
sử dụng hàm Session.getActiveUser().getEmail()
để lấy email người chỉnh sửa, nhằm mục đích phân biệt giữa khách hàng và team offshore. Vì lý do bảo mật thông tin cá nhân, nên hàm này sẽ trả về một blank string
nếu script được chạy mà không được sự đồng ý của người thao tác trên Spreadsheet
, đơn giản là trong trường hợp onOpen(e)
cũng như onEdit(e)
ta đều không thể lấy được email người dùng. Tuy nhiên trong trường hợp người dùng cùng Gsuite domain với người phát triển script, thì hàm này lại hoạt động rất ok.
- Điều đó dẫn tới với những người cùng Gsuite domain (Offshore team) → Lấy được email
- Điều đó dẫn tới với những người khác Gsuite domain (Customer team) → Không lấy được email
Ở script trên tôi mặc định khi không lấy được email của Editor thì sẽ mặc định người đó là Customer