From e8d0317f827c6dfc055f088f06cc727179ed032b Mon Sep 17 00:00:00 2001 From: Rodrigo Rodriguez Date: Sun, 4 Aug 2024 17:16:04 -0300 Subject: [PATCH] new(whatsapp.gblib): Auto-create WhatsApp templates from articles in .docx. --- .../basic.gblib/services/DialogKeywords.ts | 166 ++++-------- .../services/KeywordsExpressions.ts | 1 + .../basic.gblib/services/SystemKeywords.ts | 66 +++-- .../services/GBConversationalService.ts | 20 +- .../services/GoogleChatDirectLine.ts | 1 - packages/gpt.gblib/services/ChatServices.ts | 2 +- packages/kb.gbapp/services/KBService.ts | 89 ++++--- .../services/WhatsappDirectLine.ts | 243 ++++++++++++++++-- src/app.ts | 4 +- 9 files changed, 383 insertions(+), 209 deletions(-) diff --git a/packages/basic.gblib/services/DialogKeywords.ts b/packages/basic.gblib/services/DialogKeywords.ts index 58eac13a..b92bd9cf 100644 --- a/packages/basic.gblib/services/DialogKeywords.ts +++ b/packages/basic.gblib/services/DialogKeywords.ts @@ -64,8 +64,6 @@ import { GBUtil } from '../../../src/util.js'; import SwaggerClient from 'swagger-client'; import { GBVMService } from './GBVMService.js'; - - /** * Default check interval for user replay */ @@ -212,28 +210,28 @@ export class DialogKeywords { * * @example EXIT */ - public async exit({ }) { } + public async exit({}) {} /** * Get active tasks. * * @example list = ACTIVE TASKS */ - public async getActiveTasks({ pid }) { } + public async getActiveTasks({ pid }) {} /** * Creates a new deal. * * @example CREATE DEAL dealname,contato,empresa,amount */ - public async createDeal({ pid, dealName, contact, company, amount }) { } + public async createDeal({ pid, dealName, contact, company, amount }) {} /** * Finds contacts in XRM. * * @example list = FIND CONTACT "Sandra" */ - public async fndContact({ pid, name }) { } + public async fndContact({ pid, name }) {} public getContentLocaleWithCulture(contentLocale) { switch (contentLocale) { @@ -324,7 +322,6 @@ export class DialogKeywords { // https://weblog.west-wind.com/posts/2008/Mar/18/A-simple-formatDate-function-for-JavaScript public async format({ pid, value, format }) { - const { min, user } = await DialogKeywords.getProcessInfo(pid); const contentLocale = min.core.getParam( min.instance, @@ -337,39 +334,31 @@ export class DialogKeywords { } var date: any = new Date(value); //don't change original date - if (!format) - format = "MM/dd/yyyy"; + if (!format) format = 'MM/dd/yyyy'; var month = date.getMonth() + 1; var year = date.getFullYear(); - format = format.replace("MM", GBUtil.padL(month.toString(), 2, "0")); + format = format.replace('MM', GBUtil.padL(month.toString(), 2, '0')); - if (format.indexOf("yyyy") > -1) - format = format.replace("yyyy", year.toString()); - else if (format.indexOf("yy") > -1) - format = format.replace("yy", year.toString().substr(2, 2)); + if (format.indexOf('yyyy') > -1) format = format.replace('yyyy', year.toString()); + else if (format.indexOf('yy') > -1) format = format.replace('yy', year.toString().substr(2, 2)); - format = format.replace("dd", GBUtil.padL(date.getDate().toString(), 2, "0")); + format = format.replace('dd', GBUtil.padL(date.getDate().toString(), 2, '0')); var hours = date.getHours(); - if (format.indexOf("t") > -1) { - if (hours > 11) - format = format.replace("t", "pm") - else - format = format.replace("t", "am") + if (format.indexOf('t') > -1) { + if (hours > 11) format = format.replace('t', 'pm'); + else format = format.replace('t', 'am'); } - if (format.indexOf("HH") > -1) - format = format.replace("HH", GBUtil.padL(hours.toString(), 2, "0")); - if (format.indexOf("hh") > -1) { + if (format.indexOf('HH') > -1) format = format.replace('HH', GBUtil.padL(hours.toString(), 2, '0')); + if (format.indexOf('hh') > -1) { if (hours > 12) hours - 12; if (hours == 0) hours = 12; - format = format.replace("hh", hours.toString().padL(2, "0")); + format = format.replace('hh', hours.toString().padL(2, '0')); } - if (format.indexOf("mm") > -1) - format = format.replace("mm", GBUtil.padL(date.getMinutes().toString(), 2, "0")); - if (format.indexOf("ss") > -1) - format = format.replace("ss", GBUtil.padL(date.getSeconds().toString(), 2, "0")); + if (format.indexOf('mm') > -1) format = format.replace('mm', GBUtil.padL(date.getMinutes().toString(), 2, '0')); + if (format.indexOf('ss') > -1) format = format.replace('ss', GBUtil.padL(date.getSeconds().toString(), 2, '0')); return format; } @@ -382,7 +371,6 @@ export class DialogKeywords { * https://stackoverflow.com/a/1214753/18511 */ public async dateAdd({ pid, date, mode, units }) { - const { min, user } = await DialogKeywords.getProcessInfo(pid); const contentLocale = min.core.getParam( min.instance, @@ -530,13 +518,13 @@ export class DialogKeywords { public async sendEmail({ pid, to, subject, body }) { const { min, user } = await DialogKeywords.getProcessInfo(pid); - if (!process.env.EMAIL_FROM){ - return; + if (!process.env.EMAIL_FROM) { + return; } - if (!body ) { - body = ""; - }; + if (!body) { + body = ''; + } // tslint:disable-next-line:no-console @@ -550,7 +538,6 @@ export class DialogKeywords { body = result.value; } - if (emailToken) { return new Promise((resolve, reject) => { sgMail.setApiKey(emailToken); @@ -569,39 +556,35 @@ export class DialogKeywords { } }); }); - } - else { + } else { let { client } = await GBDeployer.internalGetDriveClient(min); const data = { - "message": { - "subject": subject, - "body": { - "contentType": "Text", - "content": body + message: { + subject: subject, + body: { + contentType: 'Text', + content: body }, - "toRecipients": [ + toRecipients: [ { - "emailAddress": { - "address": to + emailAddress: { + address: to } } ], - "from": { - "emailAddress": { - "address": process.env.EMAIL_FROM + from: { + emailAddress: { + address: process.env.EMAIL_FROM } } } }; - await client.api('/me/sendMail') - .post(data); - + await client.api('/me/sendMail').post(data); + GBLogEx.info(min, `E-mail ${to} (${subject}) sent.`); - } - } /** @@ -622,7 +605,7 @@ export class DialogKeywords { * @example SEND TEMPLATE TO "+199988887777","image.jpg" * */ - public async sendTemplateTo({ pid, mobile, filename}) { + public async sendTemplateTo({ pid, mobile, filename }) { const { min, user, proc } = await DialogKeywords.getProcessInfo(pid); GBLogEx.info(min, `BASIC: SEND TEMPLATE TO '${mobile}',filename '${filename}'.`); const service = new GBConversationalService(min.core); @@ -630,14 +613,11 @@ export class DialogKeywords { let text; if (filename.endsWith('.docx')) { text = await min.kbService.getAnswerTextByMediaName(min.instance.instanceId, filename); - } - else{ + } else { text = filename; } - - return await service.fillAndBroadcastTemplate(min, mobile, text); - + return await service.fillAndBroadcastTemplate(min, filename, mobile, text); } /** @@ -686,20 +666,17 @@ export class DialogKeywords { // Checks access. - const filters = ["People.xlsx", `${role}=x`, `id=${user.userSystemId}`]; + const filters = ['People.xlsx', `${role}=x`, `id=${user.userSystemId}`]; const people = await sys.find({ pid, handle: null, args: filters }); if (!people) { throw new Error(`Invalid access. Check if People sheet has the role ${role} checked.`); - } - else { + } else { GBLogEx.info(min, `Allowed access for ${user.userSystemId} on ${role}`); return people; } - } - /** * Defines the id generation policy. * @@ -787,7 +764,6 @@ export class DialogKeywords { await DialogKeywords.setOption({ pid, name, value }); } - /** * Returns current if any continuation token for paginated HTTP requests. * @@ -836,7 +812,7 @@ export class DialogKeywords { * Defines page mode for paged GET calls. * * @example SET PAGE MODE "auto" - * data = GET url + * data = GET url * FOR EACH item in data * ... * END FOR @@ -916,7 +892,7 @@ export class DialogKeywords { * @example MENU * */ - public async showMenu({ }) { + public async showMenu({}) { // https://github.com/GeneralBots/BotServer/issues/237 // return await beginDialog('/menu'); } @@ -943,7 +919,6 @@ export class DialogKeywords { * */ public async hear({ pid, kind, args }) { - let { min, user, params } = await DialogKeywords.getProcessInfo(pid); // Handles first arg as an array of args. @@ -1122,7 +1097,6 @@ export class DialogKeywords { result = answer; } else if (kind === 'date') { - const parseDate = str => { function pad(x) { return (('' + x).length == 2 ? '' : '0') + x; @@ -1298,15 +1272,7 @@ export class DialogKeywords { let sec = new SecService(); let user = await sec.getUserFromSystemId(fromOrDialogName); if (!user) { - user = await sec.ensureUser( - min, - fromOrDialogName, - fromOrDialogName, - null, - 'whatsapp', - 'from', - null - ); + user = await sec.ensureUser(min, fromOrDialogName, fromOrDialogName, null, 'whatsapp', 'from', null); } await sec.updateUserHearOnDialog(user.userId, dialogName); } @@ -1341,7 +1307,6 @@ export class DialogKeywords { let count = API_RETRIES; while (count--) { - await GBUtil.sleep(DEFAULT_HEAR_POLL_INTERVAL); try { @@ -1364,33 +1329,23 @@ export class DialogKeywords { } } catch (err) { GBLog.error( - `Error calling printMessages in messageBot API ${err.data === undefined ? err : err.data} ${err.errObj ? err.errObj.message : '' + `Error calling printMessages in messageBot API ${err.data === undefined ? err : err.data} ${ + err.errObj ? err.errObj.message : '' }` ); return err; } - }; + } } - public async start({ botId, botApiKey, userSystemId, text }) { - let min: GBMinInstance = GBServer.globals.minInstances.filter(p => p.instance.botId === botId)[0]; let sec = new SecService(); let user = await sec.getUserFromSystemId(userSystemId); if (!user) { - user = await sec.ensureUser( - min, - userSystemId, - userSystemId, - null, - 'api', - 'API User', - null - ); + user = await sec.ensureUser(min, userSystemId, userSystemId, null, 'api', 'API User', null); } - const pid = GBVMService.createProcessInfo(user, min, 'api', null); const conversation = min['apiConversations'][pid]; @@ -1406,11 +1361,8 @@ export class DialogKeywords { conversation.conversationId = response.obj.conversationId; return await GBVMService.callVM('start', min, null, pid); - } - - public static async getProcessInfo(pid: number) { const proc = GBServer.globals.processes[pid]; const step = proc.step; @@ -1439,15 +1391,13 @@ export class DialogKeywords { text = await min.conversationalService.translate( min, text, - user.locale ? user.locale : min.core.getParam(min.instance, 'Locale', - GBConfigService.get('LOCALE')) + user.locale ? user.locale : min.core.getParam(min.instance, 'Locale', GBConfigService.get('LOCALE')) ); GBLog.verbose(`Translated text(playMarkdown): ${text}.`); if (step) { await min.conversationalService.sendText(min, step, text); - } - else { + } else { await min.conversationalService['sendOnConversation'](min, user, text); } } @@ -1484,7 +1434,6 @@ export class DialogKeywords { } // GBFILE object. - else if (filename.url) { url = filename.url; nameOnly = Path.basename(filename.localName); @@ -1493,9 +1442,7 @@ export class DialogKeywords { } // Handles Markdown. - else if (filename.indexOf('.md') !== -1) { - GBLogEx.info(min, `BASIC: Sending the contents of ${filename} markdown to mobile ${mobile}.`); const md = await min.kbService.getAnswerTextByMediaName(min.instance.instanceId, filename); if (!md) { @@ -1504,14 +1451,10 @@ export class DialogKeywords { await min.conversationalService['playMarkdown'](min, md, DialogKeywords.getChannel(), null, mobile); return; - } // .gbdrive direct sending. - else { - - const ext = Path.extname(filename); const gbaiName = DialogKeywords.getGBAIPath(min.botId); @@ -1529,15 +1472,18 @@ export class DialogKeywords { const driveUrl = template['@microsoft.graph.downloadUrl']; const res = await fetch(driveUrl); let buf: any = Buffer.from(await res.arrayBuffer()); - let localName1 = Path.join('work', gbaiName, 'cache', `${fileOnly.replace(/\s/gi, '')}-${GBAdminService.getNumberIdentifier()}.${ext}`); + let localName1 = Path.join( + 'work', + gbaiName, + 'cache', + `${fileOnly.replace(/\s/gi, '')}-${GBAdminService.getNumberIdentifier()}.${ext}` + ); Fs.writeFileSync(localName1, buf, { encoding: null }); url = urlJoin(GBServer.globals.publicAddress, min.botId, 'cache', Path.basename(localName1)); - } if (!url) { - const ext = Path.extname(filename.localName); // Prepare a cache to be referenced by Bot Framework. diff --git a/packages/basic.gblib/services/KeywordsExpressions.ts b/packages/basic.gblib/services/KeywordsExpressions.ts index 9439470e..5374884d 100644 --- a/packages/basic.gblib/services/KeywordsExpressions.ts +++ b/packages/basic.gblib/services/KeywordsExpressions.ts @@ -128,6 +128,7 @@ export class KeywordsExpressions { keywords[i++] = [/^\s*REM.*/gim, '']; + keywords[i++] = [/^\s*CLOSE.*/gim, '']; // Always autoclose keyword. diff --git a/packages/basic.gblib/services/SystemKeywords.ts b/packages/basic.gblib/services/SystemKeywords.ts index a7495df6..3dbdb6aa 100644 --- a/packages/basic.gblib/services/SystemKeywords.ts +++ b/packages/basic.gblib/services/SystemKeywords.ts @@ -914,11 +914,28 @@ export class SystemKeywords { body.values[0][filter ? index + 1 : index] = value; } - await client - .api( - `${baseUrl}/drive/items/${document.id}/workbook/worksheets('${sheets.value[0].name}')/range(address='${address}')` - ) - .patch(body); + + await retry( + async bail => { + const result = await client + .api( + `${baseUrl}/drive/items/${document.id}/workbook/worksheets('${sheets.value[0].name}')/range(address='${address}')` + ) + .patch(body); + + if (result.status != 200) { + GBLogEx.info(min, `Waiting 5 secs. before retrying HTTP ${result.status} GET: ${result.url}`); + await GBUtil.sleep(5 * 1000); + throw new Error(`BASIC: HTTP:${result.status} retry: ${result.statusText}.`); + } + }, + { + retries: 5, + onRetry: err => { + GBLog.error(`Retrying HTTP GET due to: ${err.message}.`); + } + }); + } /** @@ -1763,7 +1780,7 @@ export class SystemKeywords { result = await fetch(url, options); if (result.status === 401) { - GBLogEx.info(min, `Waiting 5 secs. before retrynig HTTP 401 GET: ${url}`); + GBLogEx.info(min, `Waiting 5 secs. before retrying HTTP 401 GET: ${url}`); await GBUtil.sleep(5 * 1000); throw new Error(`BASIC: HTTP:${result.status} retry: ${result.statusText}.`); } @@ -1773,7 +1790,7 @@ export class SystemKeywords { throw new Error(`BASIC: HTTP:${result.status} retry: ${result.statusText}.`); } if (result.status === 503) { - GBLogEx.info(min, `Waiting 1h before retrynig GET 503: ${url}`); + GBLogEx.info(min, `Waiting 1h before retrying GET 503: ${url}`); await GBUtil.sleep(60 * 60 * 1000); throw new Error(`BASIC: HTTP:${result.status} retry: ${result.statusText}.`); } @@ -2679,7 +2696,7 @@ export class SystemKeywords { let buf: any = Buffer.from(await res.arrayBuffer()); const data = new Uint8Array(buf); const pdf = await getDocument({ data }).promise; - let pages = [] + let pages = []; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); @@ -2688,19 +2705,20 @@ export class SystemKeywords { .map(item => item['str']) .join('') .replace(/\s/g, ''); - pages.push(text) - + pages.push(text); } - return pages.join(""); + return pages.join(''); } - public async setContext({pid, text}){ + public async setContext({ pid, text }) { const { min, user, params } = await DialogKeywords.getProcessInfo(pid); ChatServices.userSystemPrompt[user.userSystemId] = text; + + await this.setMemoryContext({ pid, erase: true }); } - public async setMemoryContext({pid, input, output, erase}){ + public async setMemoryContext({ pid, input = null, output = null, erase }) { const { min, user, params } = await DialogKeywords.getProcessInfo(pid); let memory; if (erase || !ChatServices.memoryMap[user.userSystemId]) { @@ -2716,18 +2734,14 @@ export class SystemKeywords { memory = ChatServices.memoryMap[user.userSystemId]; } - if (memory) - await memory.saveContext( - { - input: input - }, - { - output: output - } - ); - - + if (memory && input) + await memory.saveContext( + { + input: input + }, + { + output: output + } + ); } - - } diff --git a/packages/core.gbapp/services/GBConversationalService.ts b/packages/core.gbapp/services/GBConversationalService.ts index 9f87e945..74c70576 100644 --- a/packages/core.gbapp/services/GBConversationalService.ts +++ b/packages/core.gbapp/services/GBConversationalService.ts @@ -641,7 +641,12 @@ export class GBConversationalService { }); } - public async fillAndBroadcastTemplate(min: GBMinInstance, mobile: string, text) { + + public async fillAndBroadcastTemplate(min: GBMinInstance, template, mobile: string, text) { + + template = template.replace(/\-/gi, '_') + template = template.replace(/\./gi, '_') + let isMedia = text.toLowerCase().endsWith('.jpg') || text.toLowerCase().endsWith('.jpeg') || @@ -659,7 +664,7 @@ export class GBConversationalService { text = text.replace(/\n/g, '\\n'); } - let template = isMedia ? image.replace(/\.[^/.]+$/, '') : 'broadcast1'; + template = isMedia ? image.replace(/\.[^/.]+$/, '') : template; let data: any = { name: template, @@ -678,17 +683,6 @@ export class GBConversationalService { ] }; - if (!isMedia) { - data.components.push({ - type: 'body', - parameters: [ - { - type: 'text', - text: text - } - ] - }); - } await this.sendToMobile(min, mobile, data, null); GBLogEx.info(min, `Sending answer file to mobile: ${mobile}. Header: ${urlImage}`); } diff --git a/packages/google-chat.gblib/services/GoogleChatDirectLine.ts b/packages/google-chat.gblib/services/GoogleChatDirectLine.ts index 86029370..6ab08caa 100644 --- a/packages/google-chat.gblib/services/GoogleChatDirectLine.ts +++ b/packages/google-chat.gblib/services/GoogleChatDirectLine.ts @@ -30,7 +30,6 @@ import Swagger from 'swagger-client'; import { google } from 'googleapis'; -import { promisify } from 'util'; import { PubSub } from '@google-cloud/pubsub'; import Fs from 'fs'; import { GBLog, GBMinInstance, GBService } from 'botlib'; diff --git a/packages/gpt.gblib/services/ChatServices.ts b/packages/gpt.gblib/services/ChatServices.ts index dba6aaf6..56a81649 100644 --- a/packages/gpt.gblib/services/ChatServices.ts +++ b/packages/gpt.gblib/services/ChatServices.ts @@ -255,7 +255,7 @@ export class ChatServices { public static memoryMap = {}; public static userSystemPrompt = {}; - public static async answerByGPT(min: GBMinInstance, user, question: string, mode = null) { + public static async answerByLLM(min: GBMinInstance, user, question: string, mode = null) { const answerMode = min.core.getParam(min.instance, 'Answer Mode', null); if (!answerMode || answerMode === 'nollm') { diff --git a/packages/kb.gbapp/services/KBService.ts b/packages/kb.gbapp/services/KBService.ts index d1c37766..c22e892e 100644 --- a/packages/kb.gbapp/services/KBService.ts +++ b/packages/kb.gbapp/services/KBService.ts @@ -32,7 +32,7 @@ * @fileoverview Knowledge base services and logic. */ -import html2md from 'html-to-md' +import html2md from 'html-to-md'; import Path from 'path'; import Fs from 'fs'; import urlJoin from 'url-join'; @@ -379,7 +379,7 @@ export class KBService implements IGBKBService { returnedScore: ${returnedScore} < required (searchScore): ${searchScore}` ); - return await ChatServices.answerByGPT(min, user, query); + return await ChatServices.answerByLLM(min, user, query); } public async getSubjectItems(instanceId: number, parentId: number): Promise { @@ -703,7 +703,7 @@ export class KBService implements IGBKBService { // Import remaining .md files in articles directory. - await this.importRemainingArticles(localPath, instance, packageStorage.packageId); + await this.importRemainingArticles(min, localPath, instance, packageStorage.packageId); // Import docs files in .docx directory. @@ -713,7 +713,12 @@ export class KBService implements IGBKBService { /** * Import all .md files in articles folder that has not been referenced by tabular files. */ - public async importRemainingArticles(localPath: string, instance: IGBInstance, packageId: number): Promise { + public async importRemainingArticles( + min: GBMinInstance, + localPath: string, + instance: IGBInstance, + packageId: number + ): Promise { const files = await walkPromise(urlJoin(localPath, 'articles')); const data = { questions: [], answers: [] }; @@ -735,14 +740,19 @@ export class KBService implements IGBKBService { }); } } else if (file !== null && file.name.endsWith('.docx')) { - const path = DialogKeywords.getGBAIPath(instance.botId, `gbkb`); + let path = DialogKeywords.getGBAIPath(instance.botId, `gbkb`); const localName = Path.join('work', path, 'articles', file.name); let loader = new DocxLoader(localName); let doc = await loader.load(); + let content = doc[0].pageContent; - const answer = { + if (file.name.endsWith('zap.docx')){ + await min.whatsAppDirectLine.createOrUpdateTemplate(min, file.name, content); + } + + const answer = { instanceId: instance.instanceId, - content: doc[0].pageContent, + content: content, format: '.md', media: file.name, packageId: packageId, @@ -923,7 +933,7 @@ export class KBService implements IGBKBService { // Check if urlToCheck contains any of the ignored URLs var isIgnored = false; - if (websiteIgnoreUrls){ + if (websiteIgnoreUrls) { websiteIgnoreUrls.split(';').some(ignoredUrl => p.href.includes(ignoredUrl)); } @@ -973,22 +983,26 @@ export class KBService implements IGBKBService { } } - async getLogoByPage(page) { + async getLogoByPage(min, page) { const checkPossibilities = async (page, possibilities) => { - for (const possibility of possibilities) { - const { tag, attributes } = possibility; + try { + for (const possibility of possibilities) { + const { tag, attributes } = possibility; - for (const attribute of attributes) { - const selector = `${tag}[${attribute}*="logo"]`; - const elements = await page.$$(selector); + for (const attribute of attributes) { + const selector = `${tag}[${attribute}*="logo"]`; + const elements = await page.$$(selector); - for (const element of elements) { - const src = await page.evaluate(el => el.getAttribute('src'), element); - if (src) { - return src.split('?')[0]; + for (const element of elements) { + const src = await page.evaluate(el => el.getAttribute('src'), element); + if (src) { + return src.split('?')[0]; + } } } } + } catch (error) { + await GBLogEx.info(min, error); } return null; @@ -1030,13 +1044,13 @@ export class KBService implements IGBKBService { let files = []; let website = min.core.getParam(min.instance, 'Website', null); - const maxDepth = min.core.getParam(min.instance, 'Website Depth', 1); + const maxDepth = min.core.getParam(min.instance, 'Website Depth', 1); const websiteIgnoreUrls = min.core.getParam<[]>(min.instance, 'Website Ignore URLs', null); if (website) { // Removes last slash if any. - website.endsWith('/')?website.substring(0, website.length-1):website; + website.endsWith('/') ? website.substring(0, website.length - 1) : website; let path = DialogKeywords.getGBAIPath(min.botId, `gbot`); const directoryPath = Path.join(process.env.PWD, 'work', path, 'Website'); @@ -1045,30 +1059,29 @@ export class KBService implements IGBKBService { let browser = await puppeteer.launch({ headless: false }); const page = await this.getFreshPage(browser, website); - let logo = await this.getLogoByPage(page); + let logo = await this.getLogoByPage(min, page); if (logo) { path = DialogKeywords.getGBAIPath(min.botId); const logoPath = Path.join(process.env.PWD, 'work', path, 'cache'); const baseUrl = page.url().split('/').slice(0, 3).join('/'); logo = logo.startsWith('https') ? logo : urlJoin(baseUrl, logo); - - try { - const logoBinary = await page.goto(logo); - const buffer = await logoBinary.buffer(); - const logoFilename = Path.basename(logo); - sharp(buffer) - .resize({ - width: 48, - height: 48, - fit: 'inside', // Resize the image to fit within the specified dimensions - withoutEnlargement: true // Don't enlarge the image if its dimensions are already smaller - }) - .toFile(Path.join(logoPath, logoFilename)); - await min.core['setConfig'](min, 'Logo', logoFilename); - } catch (error) { - GBLogEx.debug(min, error); - } + try { + const logoBinary = await page.goto(logo); + const buffer = await logoBinary.buffer(); + const logoFilename = Path.basename(logo); + sharp(buffer) + .resize({ + width: 48, + height: 48, + fit: 'inside', // Resize the image to fit within the specified dimensions + withoutEnlargement: true // Don't enlarge the image if its dimensions are already smaller + }) + .toFile(Path.join(logoPath, logoFilename)); + await min.core['setConfig'](min, 'Logo', logoFilename); + } catch (error) { + GBLogEx.debug(min, error); + } } // Extract dominant colors from the screenshot diff --git a/packages/whatsapp.gblib/services/WhatsappDirectLine.ts b/packages/whatsapp.gblib/services/WhatsappDirectLine.ts index be7b7c14..a8b97adc 100644 --- a/packages/whatsapp.gblib/services/WhatsappDirectLine.ts +++ b/packages/whatsapp.gblib/services/WhatsappDirectLine.ts @@ -46,6 +46,7 @@ import qrcode from 'qrcode-terminal'; import express from 'express'; import { GBSSR } from '../../core.gbapp/services/GBSSR.js'; import pkg from 'whatsapp-web.js'; +import fetch, { Response } from 'node-fetch'; import { DialogKeywords } from '../../basic.gblib/services/DialogKeywords.js'; import { ChatServices } from '../../gpt.gblib/services/ChatServices.js'; import { GBAdminService } from '../../admin.gbapp/services/GBAdminService.js'; @@ -55,6 +56,8 @@ import twilio from 'twilio'; import { GBVMService } from '../../basic.gblib/services/GBVMService.js'; import { GBLogEx } from '../../core.gbapp/services/GBLogEx.js'; import { createBot } from 'whatsapp-cloud-api'; +import { promisify } from 'util'; +const stat = promisify(Fs.stat); /** * Support for Whatsapp. @@ -77,7 +80,10 @@ export class WhatsappDirectLine extends GBService { public whatsappServiceKey: string; public whatsappServiceNumber: string; public whatsappServiceUrl: string; + public whatsappBusinessManagerId: string; + public whatsappFBAppId: string; public botId: string; + public botNumber: string; public min: GBMinInstance; private directLineSecret: string; private locale: string = 'pt-BR'; @@ -126,6 +132,26 @@ export class WhatsappDirectLine extends GBService { let options: any; switch (this.provider) { + case 'meta': + this.botNumber = this.min.core.getParam(this.min.instance, 'Bot Number', null); + let whatsappServiceNumber, whatsappServiceKey, url; + if (this.botNumber && this.min.instance.whatsappServiceNumber) { + whatsappServiceNumber = this.min.instance.whatsappServiceNumber; + whatsappServiceKey = this.min.instance.whatsappServiceKey; + url = this.min.instance.whatsappServiceUrl; + } else { + whatsappServiceNumber = GBServer.globals.minBoot.instance.whatsappServiceNumber; + whatsappServiceKey = GBServer.globals.minBoot.instance.whatsappServiceKey; + url = GBServer.globals.minBoot.instance.whatsappServiceUrl; + } + if (url) { + const parts = url.split(';'); + this.whatsappBusinessManagerId = parts[0]; + this.whatsappFBAppId = parts[1]; + } + + this.customClient = createBot(whatsappServiceNumber, whatsappServiceKey); + break; case 'official': const accountSid = process.env.TWILIO_ACCOUNT_SID; const authToken = process.env.TWILIO_AUTH_TOKEN; @@ -381,10 +407,9 @@ export class WhatsappDirectLine extends GBService { // Ignore group messages without the mention to Bot. - let botNumber = this.min.core.getParam(this.min.instance, 'Bot Number', null); - if (botNumber && !answerText && !found) { - botNumber = botNumber.replace('+', ''); - if (!message.body.startsWith('@' + botNumber)) { + if (this.botNumber && !answerText && !found) { + let n = this.botNumber.replace('+', ''); + if (!message.body.startsWith('@' + n)) { return; } } @@ -731,6 +756,119 @@ export class WhatsappDirectLine extends GBService { await this.sendFileToDevice(to, url, 'Audio', msg, chatId); } + // Function to create or update a template using WhatsApp Business API + + public async createOrUpdateTemplate(min: GBMinInstance, template, text) { + + template = template.replace(/\-/gi, '_') + template = template.replace(/\./gi, '_') + + let image = /(.*)\n/gim.exec(text)[0].trim(); + + let path = DialogKeywords.getGBAIPath(min.botId, `gbkb`); + path = Path.join(process.env.PWD, 'work', path, 'images', image); + + text = text.substring(image.length + 1).trim(); + text = text.replace(/\n/g, '\\n'); + + const handleImage = await min.whatsAppDirectLine.uploadLargeFile(min, path); + + let data: any = { + name: template, + components: [ + { + type: 'HEADER', + format: 'IMAGE', + example: { header_handle: [handleImage] } + }, + { + type: 'BODY', + text: text + } + ] + }; + + const name = data.name; + + // Define the API base URL and endpoints + + const baseUrl = 'https://graph.facebook.com/v20.0'; // API version 20.0 + const businessAccountId = this.whatsappBusinessManagerId; + const accessToken = this.whatsappServiceKey; + + // Endpoint for listing templates + + const listTemplatesEndpoint = `${baseUrl}/${businessAccountId}/message_templates?access_token=${accessToken}`; + + // Step 1: Check if the template exists + + const listResponse = await fetch(listTemplatesEndpoint, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + if (!listResponse.ok) { + throw new Error('Failed to list templates'); + } + + const templates = await listResponse.json(); + const templateExists = templates.data.find(template => template.name === name); + + if (templateExists) { + // Step 2: Update the template + const updateTemplateEndpoint = `${baseUrl}/${templateExists.id}`; + + // Removes the first HEADER element. + + data.components.shift(); + + const updateResponse = await fetch(updateTemplateEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + components: data.components + }) + }); + + if (!updateResponse.ok) { + throw new Error(`Failed to update template: ${name} ${await updateResponse.text()}`); + } + + GBLogEx.info(min, `Template updated: ${name}`); + } else { + // Step 3: Create the template + const createTemplateEndpoint = `${baseUrl}/${businessAccountId}/message_templates`; + + const createResponse = await fetch(createTemplateEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: data['name'], + language: 'pt_BR', + category: 'MARKETING', + components: data.components + }) + }); + + if (!createResponse.ok) { + const body = await createResponse.text(); + throw new Error(`Failed to create template: ${name} ${body}`); + } + + GBLogEx.info(min, `Template created: ${name}`); + } + + await GBUtil.sleep(20 * 1000); + } + public async sendToDevice(to: any, msg: string, conversationId) { try { const cmd = '/audio '; @@ -747,26 +885,17 @@ export class WhatsappDirectLine extends GBService { switch (this.provider) { case 'meta': - let whatsappServiceNumber, whatsappServiceKey; - if (botNumber && this.min.instance.whatsappServiceNumber) { - whatsappServiceNumber = this.min.instance.whatsappServiceNumber; - whatsappServiceKey = this.min.instance.whatsappServiceKey; - } else { - whatsappServiceNumber = GBServer.globals.minBoot.instance.whatsappServiceNumber; - whatsappServiceKey = GBServer.globals.minBoot.instance.whatsappServiceKey; - } - - const driver = createBot(whatsappServiceNumber, whatsappServiceKey); - if (msg['name']) { - const res = await driver.sendTemplate(to, msg['name'], 'pt_BR', msg['components']); + await this.customClient.sendTemplate(to, msg['name'], 'pt_BR', msg['components']); } else { messages = msg.match(/(.|[\r\n]){1,4096}/g); await CollectionUtil.asyncForEach(messages, async msg => { - await driver.sendText(to, msg); + await this.customClient.sendText(to, msg); - await GBUtil.sleep(3000); + if (messages.length > 1) { + await GBUtil.sleep(3000); + } }); } @@ -1092,4 +1221,82 @@ export class WhatsappDirectLine extends GBService { GBLog.error(`Error on Whatsapp callback: ${GBUtil.toYAML(error)}`); } } + + public async uploadLargeFile(min, filePath) { + const CHUNK_SIZE = 4 * 1024 * 1024; // 4MB chunks + let uploadSessionId; + const fileSize = (await stat(filePath)).size; + const fileName = filePath.split('/').pop(); + const fileType = mime.lookup(filePath); + const appId = this.whatsappFBAppId; + const userAccessToken = this.whatsappServiceKey; + let h; + + try { + if (!fileType) { + throw new Error('Unsupported file type'); + } + + // Step 1: Start an upload session + const startResponse = await fetch( + `https://graph.facebook.com/v20.0/${appId}/uploads?file_name=${fileName}&file_length=${fileSize}&file_type=${fileType}&access_token=${userAccessToken}`, + { + method: 'POST' + } + ); + + const startData = await startResponse.json(); + if (!startResponse.ok) { + throw new Error(startData.error.message); + } + uploadSessionId = startData.id.split(':')[1]; + + // Step 2: Upload the file in chunks + let startOffset = 0; + + while (startOffset < fileSize) { + const endOffset = Math.min(startOffset + CHUNK_SIZE, fileSize); + const fileStream = Fs.createReadStream(filePath, { start: startOffset, end: endOffset - 1 }); + const chunkSize = endOffset - startOffset; + + const uploadResponse = await fetch(`https://graph.facebook.com/v20.0/upload:${uploadSessionId}`, { + method: 'POST', + headers: { + Authorization: `OAuth ${userAccessToken}`, + file_offset: startOffset.toString(), + 'Content-Length': chunkSize.toString() + }, + body: fileStream + }); + + const uploadData = await uploadResponse.json(); + if (!h) { + h = uploadData.h; + } + if (!uploadResponse.ok) { + throw new Error(uploadData.error.message); + } + + startOffset = endOffset; + } + + // Step 3: Get the file handle + const finalizeResponse = await fetch(`https://graph.facebook.com/v20.0/upload:${uploadSessionId}`, { + method: 'GET', + headers: { + Authorization: `OAuth ${userAccessToken}` + } + }); + + const finalizeData = await finalizeResponse.json(); + if (!finalizeResponse.ok) { + throw new Error(finalizeData.error.message); + } + + console.log('Upload completed successfully with file handle:', finalizeData.h); + return h; + } catch (error) { + console.error('Error during file upload:', error); + } + } } diff --git a/src/app.ts b/src/app.ts index 26ce00fa..aeca5829 100644 --- a/src/app.ts +++ b/src/app.ts @@ -117,11 +117,11 @@ export class GBServer { }); process.on('uncaughtException', (err, p) => { - GBLogEx.error(0, `GBEXCEPTION: ${GBUtil.toYAML(JSON.stringify(err, Object.getOwnPropertyNames(err)))}`); + GBLogEx.error(0, `GBEXCEPTION: ${GBUtil.toYAML(err)}`); }); process.on('unhandledRejection', (err, p) => { - GBLogEx.error(0,`GBREJECTION: ${GBUtil.toYAML(JSON.stringify(err, Object.getOwnPropertyNames(err)))}`); + GBLogEx.error(0,`GBREJECTION: ${GBUtil.toYAML(err)}`); }); // Creates working directory.