/** * ChatKomodo ⇄ Google Sheets (two-way). Paste into Extensions → Apps Script of a spreadsheet, save, reload the sheet. * * Sheet → WhatsApp "Send": one row per message (Phone, Message, optional Media URL) → menu ChatKomodo → Send * pending rows (or every 5 minutes). Status, time and message id are written back. * "Contacts": Phone, Name, Tags → menu → Sync contacts to ChatKomodo. * WhatsApp → Sheet Deploy → New deployment → Web app (execute as: me, access: anyone), then menu → Connect * webhook: incoming messages land in "Inbox", delivery status updates the "Send" row, lead and * label changes land in "Leads". * * The API key is kept in this script's properties: create a separate key for the sheet (API keys page) so it can be * revoked on its own. Nothing is sent anywhere except the ChatKomodo API. */ var API_BASE = 'https://api.chatkomodo.com'; var SHEETS = { Send: ['Phone', 'Message', 'Media URL', 'Status', 'Sent at', 'Message ID', 'Error'], Inbox: ['Received at', 'Phone', 'Name', 'Message', 'Type', 'Device'], Contacts: ['Phone', 'Name', 'Tags', 'Synced at'], Leads: ['At', 'Event', 'Phone', 'Name', 'Detail'], }; var MAX_PER_RUN = 60; function onOpen() { SpreadsheetApp.getUi().createMenu('ChatKomodo') .addItem('Set up…', 'setup') .addItem('Send pending rows', 'sendPending') .addItem('Sync contacts to ChatKomodo', 'syncContacts') .addSeparator() .addItem('Connect webhook (after deploying as web app)', 'connectWebhook') .addItem('Send automatically every 5 minutes', 'enableAutoSend') .addItem('Stop sending automatically', 'disableAutoSend') .addToUi(); } function props() { return PropertiesService.getScriptProperties(); } function setup() { var ui = SpreadsheetApp.getUi(); var key = ui.prompt('ChatKomodo API key', 'Create a key for this sheet on the API keys page and paste it here.', ui.ButtonSet.OK_CANCEL); if (key.getSelectedButton() !== ui.Button.OK || !key.getResponseText().trim()) return; var device = ui.prompt('Device ID', 'The device that sends the messages (Devices → the device → ID).', ui.ButtonSet.OK_CANCEL); if (device.getSelectedButton() !== ui.Button.OK || !device.getResponseText().trim()) return; props().setProperties({ apiKey: key.getResponseText().trim(), device: device.getResponseText().trim() }); if (!props().getProperty('token')) props().setProperty('token', Utilities.getUuid().replace(/-/g, '')); ensureSheets(); var me = api('GET', '/v1/me'); ui.alert('Connected to ' + (me && me.name ? me.name : 'ChatKomodo') + '. Fill the "Send" sheet and choose ChatKomodo → Send pending rows.'); } function ensureSheets() { var ss = SpreadsheetApp.getActive(); Object.keys(SHEETS).forEach(function (name) { var sh = ss.getSheetByName(name) || ss.insertSheet(name); if (sh.getLastRow() === 0) { sh.appendRow(SHEETS[name]); sh.setFrozenRows(1); sh.getRange(1, 1, 1, SHEETS[name].length).setFontWeight('bold'); } }); } function api(method, path, body) { var key = props().getProperty('apiKey'); if (!key) throw new Error('Run ChatKomodo → Set up first.'); var res = UrlFetchApp.fetch(API_BASE + path, { method: method, contentType: 'application/json', muteHttpExceptions: true, headers: { Authorization: 'Bearer ' + key }, payload: body === undefined ? undefined : JSON.stringify(body), }); var text = res.getContentText(); var data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = { error: text }; } if (res.getResponseCode() >= 400) throw new Error((data && data.error) || ('HTTP ' + res.getResponseCode())); return data; } function col(sh, name) { return sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0].indexOf(name) + 1; } /** Rows of "Send" with a phone and a message and no status yet. */ function sendPending() { var lock = LockService.getScriptLock(); if (!lock.tryLock(1000)) return; try { ensureSheets(); var sh = SpreadsheetApp.getActive().getSheetByName('Send'); var device = props().getProperty('device'); var last = sh.getLastRow(); if (last < 2) return; var c = { phone: col(sh, 'Phone'), msg: col(sh, 'Message'), media: col(sh, 'Media URL'), status: col(sh, 'Status'), at: col(sh, 'Sent at'), id: col(sh, 'Message ID'), err: col(sh, 'Error') }; var rows = sh.getRange(2, 1, last - 1, sh.getLastColumn()).getValues(); var sent = 0; for (var i = 0; i < rows.length && sent < MAX_PER_RUN; i++) { var r = rows[i]; var phone = String(r[c.phone - 1] || '').trim(), text = String(r[c.msg - 1] || ''), media = c.media ? String(r[c.media - 1] || '').trim() : ''; if (!phone || (!text && !media) || String(r[c.status - 1] || '')) continue; var row = i + 2; try { var body = media ? { chat_id: phone, type: /\.(jpe?g|png|webp|gif)(\?|$)/i.test(media) ? 'image' : 'document', url: media, caption: text || undefined } : { chat_id: phone, type: 'text', body: text }; var res = api('POST', '/v1/sessions/' + encodeURIComponent(device) + '/messages', body); sh.getRange(row, c.status).setValue(res.status || 'queued'); sh.getRange(row, c.id).setValue(res.id || ''); sh.getRange(row, c.err).setValue(''); } catch (e) { sh.getRange(row, c.status).setValue('failed'); sh.getRange(row, c.err).setValue(String(e.message || e)); } sh.getRange(row, c.at).setValue(new Date()); sent++; Utilities.sleep(300); } if (sent) SpreadsheetApp.getActive().toast(sent + ' message(s) handed to ChatKomodo', 'ChatKomodo'); } finally { lock.releaseLock(); } } function syncContacts() { ensureSheets(); var sh = SpreadsheetApp.getActive().getSheetByName('Contacts'); var last = sh.getLastRow(); if (last < 2) return; var rows = sh.getRange(2, 1, last - 1, 4).getValues(); var list = [], idx = []; rows.forEach(function (r, i) { var phone = String(r[0] || '').trim(); if (!phone) return; var tags = String(r[2] || '').split(',').map(function (t) { return t.trim(); }).filter(String); list.push({ phone: phone, name: String(r[1] || '') || undefined, tags: tags.length ? tags : undefined }); idx.push(i + 2); }); for (var s = 0; s < list.length; s += 1000) { var res = api('POST', '/v1/contacts', { contacts: list.slice(s, s + 1000) }); var bad = {}; (res.rejected || []).forEach(function (x) { bad[x.index] = x.error; }); idx.slice(s, s + 1000).forEach(function (row, k) { sh.getRange(row, 4).setValue(bad[k] ? 'Rejected: ' + bad[k] : new Date()); }); } SpreadsheetApp.getActive().toast(list.length + ' contact(s) synced', 'ChatKomodo'); } /** Registers this web app as a ChatKomodo webhook (needs Deploy → New deployment → Web app first). */ function connectWebhook() { var url = ScriptApp.getService().getUrl(); var ui = SpreadsheetApp.getUi(); if (!url) { ui.alert('Deploy first: Deploy → New deployment → Web app (execute as me, access: anyone), then try again.'); return; } url = url.replace(/\/dev$/, '/exec') + '?token=' + props().getProperty('token'); var wh = api('POST', '/v1/webhooks', { url: url, events: ['message.received', 'message.sent', 'message.failed', 'lead.stage_changed', 'label.added', 'chat.unanswered', 'invoice.paid'] }); props().setProperty('webhookId', wh.id); ui.alert('Connected. New WhatsApp messages now appear in the "Inbox" sheet.'); } /** ChatKomodo → this sheet. The token in the URL proves the call comes from your webhook. */ function doPost(e) { if (!e || !e.parameter || e.parameter.token !== props().getProperty('token')) return ContentService.createTextOutput('forbidden'); var ev; try { ev = JSON.parse(e.postData.contents); } catch (err) { return ContentService.createTextOutput('bad json'); } var d = ev.data || {}; var ss = SpreadsheetApp.getActive(); ensureSheets(); if (ev.type === 'message.received') { ss.getSheetByName('Inbox').appendRow([new Date(ev.occurred_at), d.phone || String(d.chat_id || '').split('@')[0], d.name || '', d.body || d.caption || '', d.message_type || '', ev.session_id || '']); } else if (ev.type === 'message.sent' || ev.type === 'message.failed') { var sh = ss.getSheetByName('Send'); var idCol = col(sh, 'Message ID'), stCol = col(sh, 'Status'), errCol = col(sh, 'Error'); if (sh.getLastRow() > 1 && idCol > 0) { var ids = sh.getRange(2, idCol, sh.getLastRow() - 1, 1).getValues(); for (var i = ids.length - 1; i >= 0; i--) { if (String(ids[i][0]) === String(d.message_id)) { sh.getRange(i + 2, stCol).setValue(d.status || (ev.type === 'message.failed' ? 'failed' : 'sent')); if (d.error) sh.getRange(i + 2, errCol).setValue(d.error); break; } } } } else { ss.getSheetByName('Leads').appendRow([new Date(ev.occurred_at), ev.type, d.phone || String(d.chat_id || '').split('@')[0], d.name || '', JSON.stringify(d)]); } return ContentService.createTextOutput('ok'); } function enableAutoSend() { disableAutoSend(); ScriptApp.newTrigger('sendPending').timeBased().everyMinutes(5).create(); SpreadsheetApp.getActive().toast('Pending rows are sent every 5 minutes', 'ChatKomodo'); } function disableAutoSend() { ScriptApp.getProjectTriggers().forEach(function (t) { if (t.getHandlerFunction() === 'sendPending') ScriptApp.deleteTrigger(t); }); }