/** * SendSMSforFree pour Google Sheets — envoi de SMS depuis votre téléphone Android. * * Installation (une seule fois par classeur) : * 1. Extensions → Apps Script → remplacer le contenu de Code.gs par ce fichier → Enregistrer. * 2. Recharger la feuille : un menu « 📱 SendSMSforFree » apparaît. * 3. 📱 SendSMSforFree → Configurer : adresse du relais (https://….vercel.app) et clé API * (copiée depuis l'interface web SendSMSforFree, page « API »). * 4. 📱 SendSMSforFree → Créer le modèle de tableau (feuille « SMS » avec un bouton « Envoyer à tous »). * * Colonnes reconnues (ligne 1) : Numéro, Nom, Texte + toute autre colonne comme variable ({date}…). * Les colonnes « Statut » et « ID SMS » sont remplies par le script. */ const SENDSMS_PHONE_HEADERS = ['numero', 'telephone', 'tel', 'phone', 'mobile', 'portable', 'to', 'destinataire']; const SENDSMS_NAME_HEADERS = ['nom', 'name', 'client', 'contact']; const SENDSMS_TEXT_HEADERS = ['texte', 'text', 'message', 'contenu', 'sms']; const SENDSMS_STATUS_HEADER = 'Statut'; const SENDSMS_ID_HEADER = 'ID SMS'; const SENDSMS_STATUS_LABELS = { pending: 'En attente', sending: 'Envoi…', sent: 'Envoyé', delivered: 'Reçu ✅', failed: 'Échec', canceled: 'Annulé', }; const SENDSMS_FINAL = ['delivered', 'failed', 'canceled']; // ───────────── Menu ───────────── function onOpen() { SpreadsheetApp.getUi() .createMenu('📱 SendSMSforFree') .addItem('Envoyer à tous (lignes non envoyées)', 'envoyerATous') .addItem('Envoyer les lignes sélectionnées', 'envoyerSelection') .addItem('Mettre à jour les statuts', 'mettreAJourStatuts') .addItem('Suivi automatique des statuts (15 min)', 'activerSuivi') .addSeparator() .addItem('Créer le modèle de tableau', 'creerModele') .addItem('Configurer (adresse et clé API)', 'configurer') .addItem('Tester la connexion', 'testerConnexion') .addToUi(); } // ───────────── Configuration ───────────── function configurer() { const ui = SpreadsheetApp.getUi(); const props = PropertiesService.getDocumentProperties(); const url = ui.prompt( 'SendSMSforFree — adresse', 'Adresse du relais (ex. https://sendsmsforfree.vercel.app)\nActuelle : ' + (props.getProperty('SENDSMS_URL') || '(aucune)'), ui.ButtonSet.OK_CANCEL, ); if (url.getSelectedButton() !== ui.Button.OK) return; const key = ui.prompt( 'SendSMSforFree — clé API', 'Collez la clé API (interface web SendSMSforFree → page API).\nLaisser vide pour garder la clé actuelle.', ui.ButtonSet.OK_CANCEL, ); if (key.getSelectedButton() !== ui.Button.OK) return; if (url.getResponseText().trim()) props.setProperty('SENDSMS_URL', url.getResponseText().trim().replace(/\/+$/, '')); if (key.getResponseText().trim()) props.setProperty('SENDSMS_KEY', key.getResponseText().trim()); testerConnexion(); } function testerConnexion() { const status = sendsmsApi_('GET', '/status'); const sims = status.sims.map(function (s) { return 'SIM ' + (s.slot + 1) + ' (' + (s.carrier || '?') + ')'; }); SpreadsheetApp.getUi().alert( 'SendSMSforFree connecté ✅\n\nTéléphone : ' + status.device.manufacturer + ' ' + status.device.model + '\n' + sims.join(', ') + '\nEn file : ' + (status.queue.pending + status.queue.sending), ); } // ───────────── Appels à l'API ───────────── /** Appelle l'API SendSMSforFree ; lève une erreur lisible en cas d'échec. */ function sendsmsApi_(method, path, body) { const props = PropertiesService.getDocumentProperties(); const url = props.getProperty('SENDSMS_URL'); const key = props.getProperty('SENDSMS_KEY'); if (!url || !key) throw new Error('SendSMSforFree non configuré : menu 📱 SendSMSforFree → Configurer.'); const res = UrlFetchApp.fetch(url + '/api/v1' + path, { method: method.toLowerCase(), contentType: 'application/json', headers: { Authorization: 'Bearer ' + key }, payload: body === undefined ? undefined : JSON.stringify(body), muteHttpExceptions: true, }); const text = res.getContentText(); const data = text ? JSON.parse(text) : null; if (res.getResponseCode() >= 400) { throw new Error('SendSMSforFree : ' + ((data && data.error && data.error.message) || 'HTTP ' + res.getResponseCode())); } return data; } /** * Envoi d'un SMS depuis vos propres scripts : * envoyerSMS('+687123456', 'Bonjour !'); * envoyerSMS('+687123456', 'Bonjour {nom}', { name: 'Marie', simSlot: 0 }); */ function envoyerSMS(numero, texte, options) { const body = Object.assign({ to: String(numero), text: texte }, options || {}); return sendsmsApi_('POST', '/messages', body); } // ───────────── Lecture du tableau ───────────── function sendsmsKey_(h) { return String(h) .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .trim() .replace(/[\s\-]+/g, '_') .replace(/\.$/, ''); } function sendsmsFind_(headers, candidates) { const keys = headers.map(sendsmsKey_); // D'abord un nom exact, puis un début de nom (« Numéro de téléphone ») pour les mots d'au moins 4 lettres. for (let i = 0; i < keys.length; i++) if (candidates.indexOf(keys[i]) >= 0) return i; for (let i = 0; i < keys.length; i++) { if (candidates.some(function (c) { return c.length >= 4 && keys[i].indexOf(c) === 0; })) return i; } return -1; } /** Colonnes du tableau ; ajoute « Statut » et « ID SMS » si besoin. */ function sendsmsColumns_(sheet) { let lastCol = Math.max(sheet.getLastColumn(), 1); let headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0]; const cols = { phone: sendsmsFind_(headers, SENDSMS_PHONE_HEADERS), name: sendsmsFind_(headers, SENDSMS_NAME_HEADERS), text: sendsmsFind_(headers, SENDSMS_TEXT_HEADERS), status: headers.indexOf(SENDSMS_STATUS_HEADER), id: headers.indexOf(SENDSMS_ID_HEADER), }; if (cols.phone < 0) throw new Error('Colonne « Numéro » introuvable en ligne 1 (ou utilisez « Créer le modèle de tableau »).'); if (cols.status < 0) { sheet.getRange(1, ++lastCol).setValue(SENDSMS_STATUS_HEADER).setFontWeight('bold'); cols.status = lastCol - 1; } if (cols.id < 0) { sheet.getRange(1, ++lastCol).setValue(SENDSMS_ID_HEADER).setFontWeight('bold').setFontColor('#999999'); cols.id = lastCol - 1; } cols.headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0]; cols.count = lastCol; return cols; } function sendsmsCell_(v) { if (v instanceof Date) return Utilities.formatDate(v, Session.getScriptTimeZone(), v.getHours() || v.getMinutes() ? 'dd/MM/yyyy HH:mm' : 'dd/MM/yyyy'); return v === null || v === undefined ? '' : String(v).trim(); } // ───────────── Envoi ───────────── function envoyerATous() { const sheet = SpreadsheetApp.getActiveSheet(); const cols = sendsmsColumns_(sheet); const last = sheet.getLastRow(); if (last < 2) return SpreadsheetApp.getUi().alert('Aucune ligne à envoyer.'); const values = sheet.getRange(2, 1, last - 1, cols.count).getValues(); const rows = []; values.forEach(function (row, i) { if (sendsmsCell_(row[cols.phone]) && !sendsmsCell_(row[cols.status])) rows.push(i + 2); }); sendsmsSendRows_(sheet, cols, rows, 'Envoyer ' + rows.length + ' SMS (lignes sans statut) ?'); } function envoyerSelection() { const sheet = SpreadsheetApp.getActiveSheet(); const cols = sendsmsColumns_(sheet); const range = sheet.getActiveRange(); const rows = []; for (let r = Math.max(2, range.getRow()); r < range.getRow() + range.getNumRows(); r++) rows.push(r); sendsmsSendRows_(sheet, cols, rows, 'Envoyer ' + rows.length + ' SMS (lignes sélectionnées, même déjà envoyées) ?'); } function sendsmsSendRows_(sheet, cols, rowNumbers, question) { const ui = SpreadsheetApp.getUi(); if (!rowNumbers.length) return ui.alert('Aucune ligne à envoyer.'); if (ui.alert('SendSMSforFree', question, ui.ButtonSet.YES_NO) !== ui.Button.YES) return; const recipients = []; rowNumbers.forEach(function (r) { const row = sheet.getRange(r, 1, 1, cols.count).getValues()[0]; const variables = {}; cols.headers.forEach(function (h, i) { if ([cols.phone, cols.name, cols.text, cols.status, cols.id].indexOf(i) < 0 && h) variables[h] = sendsmsCell_(row[i]); }); recipients.push({ to: sendsmsCell_(row[cols.phone]), name: cols.name >= 0 ? sendsmsCell_(row[cols.name]) || undefined : undefined, text: cols.text >= 0 ? sendsmsCell_(row[cols.text]) || undefined : undefined, variables: variables, }); }); const texte = recipients.some(function (r) { return !r.text; }) ? ui.prompt('Texte pour les lignes sans texte', 'Variables possibles : {nom} et les noms de colonnes, ex. {date}', ui.ButtonSet.OK_CANCEL) : null; if (texte && texte.getSelectedButton() !== ui.Button.OK) return; const result = sendsmsApi_('POST', '/batches', { name: sheet.getName() + ' — ' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'dd/MM/yyyy HH:mm'), text: texte ? texte.getResponseText() : undefined, recipients: recipients, }); result.accepted.forEach(function (a) { const r = rowNumbers[a.index]; sheet.getRange(r, cols.status + 1).setValue(SENDSMS_STATUS_LABELS.pending).setBackground(null); sheet.getRange(r, cols.id + 1).setValue(a.id); }); result.rejected.forEach(function (x) { sheet.getRange(rowNumbers[x.index], cols.status + 1).setValue('Refusé : ' + x.reason).setBackground('#fdecea'); }); SpreadsheetApp.getActive().toast(result.accepted.length + ' SMS en file, ' + result.rejected.length + ' refusé(s).', 'SendSMSforFree', 8); Utilities.sleep(6000); mettreAJourStatuts(); } // ───────────── Statuts ───────────── function mettreAJourStatuts() { const sheet = SpreadsheetApp.getActiveSheet(); return sendsmsRefresh_(sheet); } /** Met à jour la colonne Statut ; renvoie le nombre de messages pas encore terminés. */ function sendsmsRefresh_(sheet) { const cols = sendsmsColumns_(sheet); const last = sheet.getLastRow(); if (last < 2) return 0; const ids = sheet.getRange(2, cols.id + 1, last - 1, 1).getValues().map(function (r) { return String(r[0]); }); const wanted = {}; ids.forEach(function (id) { if (id) wanted[id] = true; }); let remaining = Object.keys(wanted).length; if (!remaining) return 0; // Parcourt l'historique récent (500 par page) jusqu'à retrouver tous les messages du tableau. const found = {}; for (let offset = 0; offset < 5000 && remaining > 0; offset += 500) { const page = sendsmsApi_('GET', '/messages?limit=500&offset=' + offset); page.items.forEach(function (m) { if (wanted[m.id] && !found[m.id]) { found[m.id] = m; remaining--; } }); if (offset + 500 >= page.total) break; } let open = 0; ids.forEach(function (id, i) { const m = found[id]; if (!m) return; const label = SENDSMS_STATUS_LABELS[m.status] + (m.error ? ' : ' + m.error : ''); const cell = sheet.getRange(i + 2, cols.status + 1); cell.setValue(label).setBackground(m.status === 'failed' ? '#fdecea' : m.status === 'delivered' ? '#e7f6ee' : null); if (SENDSMS_FINAL.indexOf(m.status) < 0) open++; }); return open; } /** Rafraîchit les statuts chaque minute pendant 15 minutes (ou jusqu'à ce que tout soit terminé). */ function activerSuivi() { sendsmsStopSuivi_(); const props = PropertiesService.getDocumentProperties(); props.setProperty('SENDSMS_SUIVI_FIN', String(Date.now() + 15 * 60 * 1000)); props.setProperty('SENDSMS_SUIVI_FEUILLE', SpreadsheetApp.getActiveSheet().getName()); ScriptApp.newTrigger('suiviAutomatique').timeBased().everyMinutes(1).create(); SpreadsheetApp.getActive().toast('Statuts mis à jour chaque minute pendant 15 min.', 'SendSMSforFree', 5); } function suiviAutomatique() { const props = PropertiesService.getDocumentProperties(); const sheet = SpreadsheetApp.getActive().getSheetByName(props.getProperty('SENDSMS_SUIVI_FEUILLE') || ''); const open = sheet ? sendsmsRefresh_(sheet) : 0; if (!sheet || open === 0 || Date.now() > Number(props.getProperty('SENDSMS_SUIVI_FIN') || 0)) sendsmsStopSuivi_(); } function sendsmsStopSuivi_() { ScriptApp.getProjectTriggers().forEach(function (t) { if (t.getHandlerFunction() === 'suiviAutomatique') ScriptApp.deleteTrigger(t); }); } // ───────────── Modèle de tableau ───────────── function creerModele() { const ss = SpreadsheetApp.getActive(); let sheet = ss.getSheetByName('SMS'); if (!sheet) sheet = ss.insertSheet('SMS'); sheet.clear(); sheet.getRange(1, 1, 1, 6).setValues([['Numéro', 'Nom', 'Texte', 'Date', SENDSMS_STATUS_HEADER, SENDSMS_ID_HEADER]]).setFontWeight('bold').setBackground('#e0f2ef'); sheet.getRange(2, 1, 2, 4).setValues([ ['+687 12 34 56', 'Marie', 'Bonjour {nom}, votre rendez-vous est le {date}.', '06/10'], ['+687 65 43 21', 'Paul', '', '07/10'], ]); sheet.getRange('A:A').setNumberFormat('@'); // numéros en texte : garde le « + » et les zéros sheet.setFrozenRows(1); sheet.setColumnWidth(1, 130); sheet.setColumnWidth(3, 380); sheet.setColumnWidth(5, 200); sheet.setColumnWidth(6, 120); sheet.getRange('C1').setNote('Texte du SMS. Vide = texte demandé au moment de l’envoi. Variables : {nom}, {date} (toute colonne).'); // Bouton « Envoyer à tous » (image cliquable reliée au script). const url = PropertiesService.getDocumentProperties().getProperty('SENDSMS_URL'); if (url) { try { const image = sheet.insertImage(url + '/sheets/bouton-envoyer.png', 8, 2); image.assignScript('envoyerATous'); } catch (e) { ss.toast('Bouton non ajouté (' + e.message + ') : utilisez le menu 📱 SendSMSforFree.', 'SendSMSforFree', 8); } } ss.setActiveSheet(sheet); ss.toast('Modèle créé : remplissez les lignes puis cliquez sur « Envoyer à tous ».', 'SendSMSforFree', 8); }