diff --git a/lerna.json b/lerna.json index c2e45f307..e653b59a0 100644 --- a/lerna.json +++ b/lerna.json @@ -23,6 +23,7 @@ "packages/provider-venom", "packages/provider-gohighlevel", "packages/provider-email", + "packages/provider-gmail", "packages/contexts-dialogflow", "packages/contexts-dialogflow-cx" ], diff --git a/package.json b/package.json index 3cf927a5c..3abef8d39 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "packages/provider-venom", "packages/provider-gohighlevel", "packages/provider-email", + "packages/provider-gmail", "packages/contexts-dialogflow", "packages/contexts-dialogflow-cx" ], diff --git a/packages/cli/src/configuration/index.ts b/packages/cli/src/configuration/index.ts index 318387f4d..9792bd223 100644 --- a/packages/cli/src/configuration/index.ts +++ b/packages/cli/src/configuration/index.ts @@ -29,6 +29,7 @@ export const PROVIDER_LIST: Provider[] = [ { value: 'instagram', label: 'Instagram' }, { value: 'gohighlevel', label: 'GoHighLevel' }, { value: 'email', label: 'Email', hint: 'IMAP/SMTP' }, + { value: 'gmail', label: 'Gmail', hint: 'OAuth2' }, ] export const PROVIDER_DATA: ValueLabel[] = [ diff --git a/packages/provider-gmail/LICENSE.md b/packages/provider-gmail/LICENSE.md new file mode 100644 index 000000000..959d8ec5a --- /dev/null +++ b/packages/provider-gmail/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Leifer Mendez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/provider-gmail/README.md b/packages/provider-gmail/README.md new file mode 100644 index 000000000..c5330ea25 --- /dev/null +++ b/packages/provider-gmail/README.md @@ -0,0 +1,225 @@ +# @builderbot/provider-gmail + +Gmail provider for BuilderBot using the Gmail API with OAuth2 authentication. Receive emails in real-time via polling and send emails through the Gmail API. + +## Installation + +```bash +npm install @builderbot/provider-gmail +# or +pnpm add @builderbot/provider-gmail +``` + +## Features + +- Gmail API with OAuth2 authentication (no app passwords needed) +- Real-time email reception via polling with history tracking +- Send emails via Gmail API +- Native Gmail thread support +- Attachment support (send and receive via Gmail API) +- Automatic mark-as-read support + +## Prerequisites + +### Setting up Google Cloud OAuth2 + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the **Gmail API** in the API Library +4. Go to **Credentials** > **Create Credentials** > **OAuth 2.0 Client ID** +5. Set the application type to **Web application** +6. Add `http://localhost:3000` to Authorized redirect URIs +7. Copy the **Client ID** and **Client Secret** + +### Obtaining a Refresh Token + +Use the [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/) or implement your own authorization flow: + +1. Go to [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/) +2. Click the gear icon and check "Use your own OAuth credentials" +3. Enter your Client ID and Client Secret +4. In Step 1, select `https://mail.google.com/` scope +5. Click "Authorize APIs" and sign in with your Gmail account +6. In Step 2, click "Exchange authorization code for tokens" +7. Copy the **Refresh Token** + +## Quick Start + +```typescript +import { createBot, createProvider, createFlow, addKeyword } from '@builderbot/bot' +import { GmailProvider } from '@builderbot/provider-gmail' + +const gmailProvider = createProvider(GmailProvider, { + email: 'your-email@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + +const welcomeFlow = addKeyword(['hello', 'hi']) + .addAnswer('Hello! I received your email.') + .addAction(async (ctx, { provider }) => { + console.log('Email from:', ctx.from) + console.log('Subject:', ctx.subject) + console.log('Body:', ctx.body) + console.log('Thread ID:', ctx.threadId) + console.log('Is reply:', ctx.isReply) + }) + +const main = async () => { + await createBot({ + flow: createFlow([welcomeFlow]), + provider: gmailProvider, + database: new MemoryDB() + }) +} + +main() +``` + +## Configuration + +### IGmailProviderArgs + +| Property | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `email` | `string` | Yes | - | Gmail account email address | +| `oauth2` | `GmailOAuth2Config` | Yes | - | OAuth2 credentials | +| `label` | `string` | No | `'INBOX'` | Gmail label to monitor | +| `markAsRead` | `boolean` | No | `true` | Mark emails as read after processing | +| `fromName` | `string` | No | - | Display name for outgoing emails | +| `messageSource` | `string` | No | `'body'` | Source for message: 'body', 'subject', or 'both' | +| `pollingInterval` | `number` | No | `10000` | Polling interval in ms | + +### GmailOAuth2Config + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `clientId` | `string` | Yes | OAuth2 Client ID | +| `clientSecret` | `string` | Yes | OAuth2 Client Secret | +| `refreshToken` | `string` | Yes | OAuth2 Refresh Token | + +## Email Context (ctx) + +When an email is received, the context object includes: + +```typescript +interface GmailBotContext { + from: string // Sender's email address + name: string // Sender's display name + body: string // Email body (plain text) + subject: string // Email subject + messageId: string // RFC Message-ID header + threadId?: string // Gmail Thread ID + inReplyTo?: string // ID of email being replied to + isReply: boolean // Whether this is a reply + attachments?: Array<{ + filename: string + contentType: string + size: number + attachmentId?: string + }> + html?: string // HTML content (if available) + to?: string[] // Recipients + cc?: string[] // CC recipients + date?: Date // Email date + gmailId?: string // Gmail internal message ID +} +``` + +## API Methods + +### sendMessage(to, message, options?) + +Send an email to a recipient. + +```typescript +await provider.sendMessage('recipient@example.com', 'Hello!', { + subject: 'Greeting', + html: '

Hello!

' +}) +``` + +### sendMedia(to, message, mediaPath, options?) + +Send an email with an attachment. + +```typescript +await provider.sendMedia( + 'recipient@example.com', + 'Please find the document attached.', + '/path/to/document.pdf', + { subject: 'Document' } +) +``` + +### reply(ctx, message, options?) + +Reply to an existing email thread. + +```typescript +.addAction(async (ctx, { provider }) => { + await provider.reply(ctx, 'Thank you for your message!') +}) +``` + +### saveFile(ctx, options?) + +Save an email attachment to disk. + +```typescript +.addAction(async (ctx, { provider }) => { + if (ctx.attachments?.length) { + const filePath = await provider.saveFile(ctx, { + path: './downloads', + attachmentIndex: 0 + }) + console.log('Saved to:', filePath) + } +}) +``` + +### getAttachments(ctx) + +Get all attachments from an email. + +```typescript +const attachments = provider.getAttachments(ctx) +``` + +### isReply(ctx) + +Check if the email is a reply. + +```typescript +if (provider.isReply(ctx)) { + console.log('This is a reply to:', ctx.inReplyTo) +} +``` + +### getThreadId(ctx) + +Get the Gmail thread ID for conversation tracking. + +```typescript +const threadId = provider.getThreadId(ctx) +``` + +## Gmail vs Email Provider + +| Feature | Gmail Provider | Email Provider | +|---------|---------------|----------------| +| Authentication | OAuth2 | IMAP/SMTP credentials | +| Receiving | Gmail API polling | IMAP IDLE | +| Sending | Gmail API | SMTP | +| Thread tracking | Native Gmail threads | Message-ID references | +| Attachment download | Gmail API | IMAP fetch | +| Server compatibility | Gmail only | Any IMAP/SMTP server | + +Use the **Gmail Provider** when you specifically need Gmail integration with OAuth2. Use the **Email Provider** when you need generic IMAP/SMTP support for any email server. + +## License + +MIT diff --git a/packages/provider-gmail/__tests__/core.test.ts b/packages/provider-gmail/__tests__/core.test.ts new file mode 100644 index 000000000..ffdd649e5 --- /dev/null +++ b/packages/provider-gmail/__tests__/core.test.ts @@ -0,0 +1,498 @@ +import { describe, expect, test, jest, beforeEach } from '@jest/globals' +import type { gmail_v1 } from 'googleapis' + +// Mock @builderbot/bot before importing GmailCoreVendor +jest.mock('@builderbot/bot', () => ({ + ProviderClass: class MockProviderClass {}, + utils: { + generateRefProvider: jest.fn((event: string) => `REF:${event}`), + }, +})) + +import { GmailCoreVendor } from '../src/gmail/core' +import type { IGmailProviderArgs, GmailBotContext } from '../src/types' + +const mockConfig: IGmailProviderArgs = { + email: 'test@gmail.com', + oauth2: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + }, +} + +/** + * Helper to create a mock Gmail API message + */ +const createMockGmailMessage = (options: { + text?: string + html?: string + attachments?: Array<{ + filename: string + mimeType: string + size?: number + attachmentId?: string + }> + from?: string + to?: string + subject?: string + messageId?: string + threadId?: string + inReplyTo?: string + references?: string + labelIds?: string[] +}): gmail_v1.Schema$Message => { + const headers: gmail_v1.Schema$MessagePartHeader[] = [ + { name: 'From', value: options.from || 'Test Sender ' }, + { name: 'To', value: options.to || 'recipient@example.com' }, + { name: 'Subject', value: options.subject !== undefined ? options.subject : 'Test Subject' }, + { name: 'Message-ID', value: options.messageId || '' }, + { name: 'Date', value: new Date().toISOString() }, + ] + + if (options.inReplyTo) { + headers.push({ name: 'In-Reply-To', value: options.inReplyTo }) + } + if (options.references) { + headers.push({ name: 'References', value: options.references }) + } + + const parts: gmail_v1.Schema$MessagePart[] = [] + + if (options.text) { + parts.push({ + mimeType: 'text/plain', + body: { + data: Buffer.from(options.text).toString('base64url'), + }, + }) + } + + if (options.html) { + parts.push({ + mimeType: 'text/html', + body: { + data: Buffer.from(options.html).toString('base64url'), + }, + }) + } + + if (options.attachments) { + for (const att of options.attachments) { + parts.push({ + filename: att.filename, + mimeType: att.mimeType, + body: { + attachmentId: att.attachmentId || 'att-id-123', + size: att.size || 100, + }, + }) + } + } + + return { + id: 'gmail-msg-id', + threadId: options.threadId || 'thread-id-123', + labelIds: options.labelIds || ['INBOX'], + payload: { + headers: headers, + mimeType: parts.length > 1 ? 'multipart/mixed' : 'text/plain', + parts: parts.length > 0 ? parts : undefined, + body: parts.length === 0 && options.text ? { + data: Buffer.from(options.text || '').toString('base64url'), + } : undefined, + }, + } +} + +describe('GmailCoreVendor', () => { + let vendor: GmailCoreVendor + + beforeEach(() => { + jest.clearAllMocks() + vendor = new GmailCoreVendor(mockConfig) + }) + + describe('parseMessageToContext - event detection', () => { + test('should generate _event_media_ for image attachments', () => { + const message = createMockGmailMessage({ + text: 'Hello', + attachments: [{ filename: 'photo.png', mimeType: 'image/png' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_media_') + }) + + test('should generate _event_media_ for video attachments', () => { + const message = createMockGmailMessage({ + text: 'Check this video', + attachments: [{ filename: 'video.mp4', mimeType: 'video/mp4' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_media_') + }) + + test('should generate _event_voice_note_ for audio attachments', () => { + const message = createMockGmailMessage({ + text: '', + attachments: [{ filename: 'voice.mp3', mimeType: 'audio/mp3' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_voice_note_') + }) + + test('should generate _event_document_ for application/pdf', () => { + const message = createMockGmailMessage({ + text: '', + attachments: [{ filename: 'document.pdf', mimeType: 'application/pdf' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_document_') + }) + + test('should generate _event_document_ for text/csv', () => { + const message = createMockGmailMessage({ + text: '', + attachments: [{ filename: 'data.csv', mimeType: 'text/csv' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_document_') + }) + + test('should NOT generate _event_document_ for text/plain attachments', () => { + const message = createMockGmailMessage({ + text: '', + attachments: [{ filename: 'note.txt', mimeType: 'text/plain' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('') + }) + + test('should NOT generate _event_document_ for text/html attachments', () => { + const message = createMockGmailMessage({ + text: '', + attachments: [{ filename: 'page.html', mimeType: 'text/html' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('') + }) + + test('should keep text body when no special attachments', () => { + const message = createMockGmailMessage({ + text: 'Hello world', + attachments: [], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('Hello world') + }) + }) + + describe('parseMessageToContext - event priority', () => { + test('MEDIA should have priority over VOICE_NOTE', () => { + const message = createMockGmailMessage({ + text: '', + attachments: [ + { filename: 'photo.jpg', mimeType: 'image/jpeg' }, + { filename: 'audio.mp3', mimeType: 'audio/mp3' }, + ], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_media_') + }) + + test('MEDIA should have priority over DOCUMENT', () => { + const message = createMockGmailMessage({ + text: '', + attachments: [ + { filename: 'video.mp4', mimeType: 'video/mp4' }, + { filename: 'doc.pdf', mimeType: 'application/pdf' }, + ], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_media_') + }) + + test('VOICE_NOTE should have priority over DOCUMENT', () => { + const message = createMockGmailMessage({ + text: '', + attachments: [ + { filename: 'voice.ogg', mimeType: 'audio/ogg' }, + { filename: 'doc.doc', mimeType: 'application/msword' }, + ], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_voice_note_') + }) + + test('DOCUMENT only triggers when no text body', () => { + const message = createMockGmailMessage({ + text: 'Please see attached document', + attachments: [{ filename: 'report.pdf', mimeType: 'application/pdf' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('Please see attached document') + }) + + test('MEDIA triggers even with text body', () => { + const message = createMockGmailMessage({ + text: 'Check out this photo!', + attachments: [{ filename: 'photo.png', mimeType: 'image/png' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_media_') + }) + + test('VOICE_NOTE triggers even with text body', () => { + const message = createMockGmailMessage({ + text: 'Listen to this', + attachments: [{ filename: 'recording.wav', mimeType: 'audio/wav' }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_voice_note_') + }) + }) + + describe('parseMessageToContext - email parsing', () => { + test('should return null for email without From header', () => { + const message: gmail_v1.Schema$Message = { + id: 'gmail-id', + threadId: 'thread-id', + payload: { + headers: [ + { name: 'To', value: 'recipient@example.com' }, + { name: 'Subject', value: 'Test' }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Hello').toString('base64url') }, + }, + } + + const result = vendor.parseMessageToContext(message, 'gmail-id') + + expect(result).toBeNull() + }) + + test('should detect reply from In-Reply-To header', () => { + const message = createMockGmailMessage({ + text: 'Thanks for your email', + inReplyTo: '', + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.isReply).toBe(true) + expect(result.inReplyTo).toBe('') + }) + + test('should detect reply from References header', () => { + const message = createMockGmailMessage({ + text: 'Following up', + references: ' ', + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.isReply).toBe(true) + }) + + test('should include threadId from Gmail API', () => { + const message = createMockGmailMessage({ + text: 'Reply', + threadId: 'gmail-thread-123', + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.threadId).toBe('gmail-thread-123') + }) + + test('should include attachments in context', () => { + const message = createMockGmailMessage({ + text: 'See attached', + attachments: [{ filename: 'notes.txt', mimeType: 'text/plain', size: 500 }], + }) + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.attachments).toBeDefined() + expect(result.attachments).toHaveLength(1) + expect(result.attachments![0].filename).toBe('notes.txt') + expect(result.attachments![0].contentType).toBe('text/plain') + }) + + test('should use default subject when missing', () => { + const message: gmail_v1.Schema$Message = { + id: 'gmail-id', + threadId: 'thread-id', + payload: { + headers: [ + { name: 'From', value: 'sender@example.com' }, + { name: 'Date', value: new Date().toISOString() }, + ], + mimeType: 'text/plain', + body: { data: Buffer.from('Hello').toString('base64url') }, + }, + } + + const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.subject).toBe('(no subject)') + }) + + test('should include gmailId in context', () => { + const message = createMockGmailMessage({ text: 'Hello' }) + + const result = vendor.parseMessageToContext(message, 'my-gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.gmailId).toBe('my-gmail-id') + }) + }) + + describe('parseMessageToContext - messageSource option', () => { + test('should use body by default', () => { + const vendorDefault = new GmailCoreVendor(mockConfig) + const message = createMockGmailMessage({ + text: 'This is the body', + subject: 'This is the subject', + }) + + const result = vendorDefault.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('This is the body') + }) + + test('should use subject when messageSource is "subject"', () => { + const vendorSubject = new GmailCoreVendor({ + ...mockConfig, + messageSource: 'subject', + }) + const message = createMockGmailMessage({ + text: 'This is the body', + subject: 'This is the subject', + }) + + const result = vendorSubject.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('This is the subject') + }) + + test('should use both when messageSource is "both"', () => { + const vendorBoth = new GmailCoreVendor({ + ...mockConfig, + messageSource: 'both', + }) + const message = createMockGmailMessage({ + text: 'This is the body', + subject: 'This is the subject', + }) + + const result = vendorBoth.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('This is the subject\n\nThis is the body') + }) + + test('should handle empty body with messageSource "both"', () => { + const vendorBoth = new GmailCoreVendor({ + ...mockConfig, + messageSource: 'both', + }) + const message = createMockGmailMessage({ + text: '', + subject: 'Only subject', + }) + + const result = vendorBoth.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('Only subject') + }) + + test('messageSource should not affect MEDIA event detection', () => { + const vendorSubject = new GmailCoreVendor({ + ...mockConfig, + messageSource: 'subject', + }) + const message = createMockGmailMessage({ + text: 'Check this image', + subject: 'Photo for you', + attachments: [{ filename: 'photo.png', mimeType: 'image/png' }], + }) + + const result = vendorSubject.parseMessageToContext(message, 'gmail-id') as GmailBotContext + + expect(result).not.toBeNull() + expect(result.body).toBe('REF:_event_media_') + }) + }) + + describe('parseEmailHeader', () => { + test('should parse "Name " format', () => { + const result = vendor.parseEmailHeader('John Doe ') + expect(result.name).toBe('John Doe') + expect(result.address).toBe('john@example.com') + }) + + test('should parse quoted name format', () => { + const result = vendor.parseEmailHeader('"John Doe" ') + expect(result.name).toBe('John Doe') + expect(result.address).toBe('john@example.com') + }) + + test('should parse plain email address', () => { + const result = vendor.parseEmailHeader('john@example.com') + expect(result.name).toBe('') + expect(result.address).toBe('john@example.com') + }) + }) +}) diff --git a/packages/provider-gmail/__tests__/gmailProvider.test.ts b/packages/provider-gmail/__tests__/gmailProvider.test.ts new file mode 100644 index 000000000..fcdcaea17 --- /dev/null +++ b/packages/provider-gmail/__tests__/gmailProvider.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, test, beforeEach, jest } from '@jest/globals' + +// Mock @builderbot/bot before importing GmailProvider +jest.mock('@builderbot/bot', () => { + const EventEmitter = require('node:events') + return { + ProviderClass: class MockProviderClass extends EventEmitter {}, + utils: { + generateRefProvider: jest.fn((event: string) => `REF:${event}`), + }, + } +}) + +import { GmailProvider } from '../src/gmail/provider' +import type { IGmailProviderArgs, GmailBotContext } from '../src/types' + +const mockConfig: IGmailProviderArgs = { + email: 'test@gmail.com', + oauth2: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + }, +} + +describe('GmailProvider', () => { + describe('constructor', () => { + test('should create instance with valid config', () => { + const provider = new GmailProvider(mockConfig) + expect(provider).toBeInstanceOf(GmailProvider) + expect(provider.globalVendorArgs.email).toBe('test@gmail.com') + expect(provider.globalVendorArgs.oauth2).toBeDefined() + }) + + test('should throw error without email', () => { + expect(() => { + new GmailProvider({ + oauth2: mockConfig.oauth2, + } as IGmailProviderArgs) + }).toThrow('Gmail email address is required') + }) + + test('should throw error without OAuth2 config', () => { + expect(() => { + new GmailProvider({ + email: 'test@gmail.com', + } as IGmailProviderArgs) + }).toThrow('OAuth2 configuration is required') + }) + + test('should throw error without OAuth2 clientId', () => { + expect(() => { + new GmailProvider({ + email: 'test@gmail.com', + oauth2: { + clientSecret: 'secret', + refreshToken: 'token', + } as any, + }) + }).toThrow('OAuth2 clientId, clientSecret, and refreshToken are required') + }) + + test('should throw error without OAuth2 clientSecret', () => { + expect(() => { + new GmailProvider({ + email: 'test@gmail.com', + oauth2: { + clientId: 'id', + refreshToken: 'token', + } as any, + }) + }).toThrow('OAuth2 clientId, clientSecret, and refreshToken are required') + }) + + test('should throw error without OAuth2 refreshToken', () => { + expect(() => { + new GmailProvider({ + email: 'test@gmail.com', + oauth2: { + clientId: 'id', + clientSecret: 'secret', + } as any, + }) + }).toThrow('OAuth2 clientId, clientSecret, and refreshToken are required') + }) + + test('should set default values', () => { + const provider = new GmailProvider(mockConfig) + expect(provider.globalVendorArgs.name).toBe('gmail-bot') + expect(provider.globalVendorArgs.port).toBe(3000) + expect(provider.globalVendorArgs.label).toBe('INBOX') + expect(provider.globalVendorArgs.markAsRead).toBe(true) + expect(provider.globalVendorArgs.pollingInterval).toBe(10000) + }) + + test('should allow overriding default values', () => { + const provider = new GmailProvider({ + ...mockConfig, + name: 'custom-bot', + port: 4000, + label: 'Custom', + markAsRead: false, + pollingInterval: 5000, + }) + expect(provider.globalVendorArgs.name).toBe('custom-bot') + expect(provider.globalVendorArgs.port).toBe(4000) + expect(provider.globalVendorArgs.label).toBe('Custom') + expect(provider.globalVendorArgs.markAsRead).toBe(false) + expect(provider.globalVendorArgs.pollingInterval).toBe(5000) + }) + }) + + describe('helper methods', () => { + let provider: GmailProvider + + beforeEach(() => { + provider = new GmailProvider(mockConfig) + }) + + test('isReply should return correct value', () => { + expect(provider.isReply({ isReply: true } as any)).toBe(true) + expect(provider.isReply({ isReply: false } as any)).toBe(false) + }) + + test('getThreadId should return threadId', () => { + expect(provider.getThreadId({ threadId: 'test-thread' } as any)).toBe('test-thread') + expect(provider.getThreadId({} as any)).toBeUndefined() + }) + + test('getAttachments should return attachments array', () => { + const attachments = [{ filename: 'test.txt', contentType: 'text/plain', size: 100 }] + expect(provider.getAttachments({ attachments } as any)).toEqual(attachments) + expect(provider.getAttachments({} as any)).toEqual([]) + }) + }) + + describe('thread replies', () => { + let provider: GmailProvider + + // Helper to create a mock sendEmail function + const createMockSendEmail = () => { + const fn = jest.fn() + fn.mockImplementation(() => Promise.resolve({ messageId: 'reply-id', threadId: 'thread-id' })) + return fn + } + + beforeEach(() => { + provider = new GmailProvider(mockConfig) + }) + + test('busEvents should store context in conversationContexts Map', () => { + const busEvents = provider['busEvents']() + const messageHandler = busEvents.find((e) => e.event === 'message') + + expect(messageHandler).toBeDefined() + + const mockPayload: GmailBotContext = { + from: 'user@example.com', + name: 'Test User', + body: 'Hello', + subject: 'Test Subject', + messageId: '', + threadId: 'thread123', + isReply: false, + gmailId: 'gmail123', + } + + // Call the message handler + messageHandler!.func(mockPayload) + + // Check that context was stored + const storedContext = (provider as any).conversationContexts.get('user@example.com') + expect(storedContext).toBeDefined() + expect(storedContext.messageId).toBe('') + expect(storedContext.subject).toBe('Test Subject') + expect(storedContext.threadId).toBe('thread123') + }) + + test('sendMessage should use stored context for inReplyTo', async () => { + // Setup: store a context + const mockContext: GmailBotContext = { + from: 'user@example.com', + name: 'Test User', + body: 'Hello', + subject: 'Original Subject', + messageId: '', + threadId: 'thread123', + isReply: false, + gmailId: 'gmail123', + } + ;(provider as any).conversationContexts.set('user@example.com', mockContext) + + // Mock vendor.sendEmail + const mockSendEmail = createMockSendEmail() + ;(provider as any).vendor = { sendEmail: mockSendEmail } + + // Call sendMessage + await provider.sendMessage('user@example.com', 'Reply message') + + // Verify sendEmail was called with correct inReplyTo + expect(mockSendEmail).toHaveBeenCalledWith( + 'user@example.com', + 'Re: Original Subject', + 'Reply message', + expect.objectContaining({ + inReplyTo: '', + threadId: 'thread123', + }) + ) + }) + + test('sendMessage should add Re: prefix to subject', async () => { + const mockContext: GmailBotContext = { + from: 'user@example.com', + name: 'Test User', + body: 'Hello', + subject: 'Question about product', + messageId: '', + threadId: 'thread123', + isReply: false, + gmailId: 'gmail123', + } + ;(provider as any).conversationContexts.set('user@example.com', mockContext) + + const mockSendEmail = createMockSendEmail() + ;(provider as any).vendor = { sendEmail: mockSendEmail } + + await provider.sendMessage('user@example.com', 'Here is the answer') + + expect(mockSendEmail).toHaveBeenCalledWith( + 'user@example.com', + 'Re: Question about product', + 'Here is the answer', + expect.any(Object) + ) + }) + + test('sendMessage should include references and threadId', async () => { + const mockContext: GmailBotContext = { + from: 'user@example.com', + name: 'Test User', + body: 'Hello', + subject: 'Thread test', + messageId: '', + threadId: 'thread-start-id', + isReply: false, + gmailId: 'gmail123', + } + ;(provider as any).conversationContexts.set('user@example.com', mockContext) + + const mockSendEmail = createMockSendEmail() + ;(provider as any).vendor = { sendEmail: mockSendEmail } + + await provider.sendMessage('user@example.com', 'Following up') + + expect(mockSendEmail).toHaveBeenCalledWith( + 'user@example.com', + expect.any(String), + 'Following up', + expect.objectContaining({ + references: ['thread-start-id'], + threadId: 'thread-start-id', + }) + ) + }) + + test('sendMessage should not add Re: if already present', async () => { + const mockContext: GmailBotContext = { + from: 'user@example.com', + name: 'Test User', + body: 'Hello', + subject: 'Re: Already a reply', + messageId: '', + threadId: 'thread123', + isReply: true, + gmailId: 'gmail123', + } + ;(provider as any).conversationContexts.set('user@example.com', mockContext) + + const mockSendEmail = createMockSendEmail() + ;(provider as any).vendor = { sendEmail: mockSendEmail } + + await provider.sendMessage('user@example.com', 'Continuing the thread') + + // Should NOT have "Re: Re:" + expect(mockSendEmail).toHaveBeenCalledWith( + 'user@example.com', + 'Re: Already a reply', // Not "Re: Re: Already a reply" + 'Continuing the thread', + expect.any(Object) + ) + }) + + test('sendMessage without context should use default subject', async () => { + // No context stored for this user + const mockSendEmail = createMockSendEmail() + ;(provider as any).vendor = { sendEmail: mockSendEmail } + + await provider.sendMessage('newuser@example.com', 'Hello!') + + expect(mockSendEmail).toHaveBeenCalledWith( + 'newuser@example.com', + 'Message from Bot', + 'Hello!', + expect.objectContaining({ + inReplyTo: undefined, + }) + ) + }) + + test('sendMessage should allow custom subject override', async () => { + const mockContext: GmailBotContext = { + from: 'user@example.com', + name: 'Test User', + body: 'Hello', + subject: 'Original', + messageId: '', + threadId: 'thread123', + isReply: false, + gmailId: 'gmail123', + } + ;(provider as any).conversationContexts.set('user@example.com', mockContext) + + const mockSendEmail = createMockSendEmail() + ;(provider as any).vendor = { sendEmail: mockSendEmail } + + await provider.sendMessage('user@example.com', 'Custom message', { + subject: 'Custom Subject', + }) + + // Custom subject should be used with Re: prefix since there's context + expect(mockSendEmail).toHaveBeenCalledWith( + 'user@example.com', + 'Re: Custom Subject', + 'Custom message', + expect.any(Object) + ) + }) + }) +}) diff --git a/packages/provider-gmail/__tests__/utils.test.ts b/packages/provider-gmail/__tests__/utils.test.ts new file mode 100644 index 000000000..0dbac4512 --- /dev/null +++ b/packages/provider-gmail/__tests__/utils.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from '@jest/globals' + +import { + extractEmailAddress, + extractEmailName, + isValidEmail, + cleanEmail, + parseEmailList, + formatEmailAddress, + htmlToText, + isHtml, + extractThreadId, + isReplySubject, + stripReplyPrefix, + addReplyPrefix, + parseMimeType, + mimeToExtension, +} from '../src/utils' + +describe('#extractEmailAddress', () => { + test('should extract email from "Name " format', () => { + const input = 'John Doe ' + const result = extractEmailAddress(input) + expect(result).toBe('john@example.com') + }) + + test('should handle plain email address', () => { + const input = 'john@example.com' + const result = extractEmailAddress(input) + expect(result).toBe('john@example.com') + }) + + test('should handle email with quotes in name', () => { + const input = '"John Doe" ' + const result = extractEmailAddress(input) + expect(result).toBe('john@example.com') + }) + + test('should return empty string for empty input', () => { + const result = extractEmailAddress('') + expect(result).toBe('') + }) +}) + +describe('#extractEmailName', () => { + test('should extract name from "Name " format', () => { + const input = 'John Doe ' + const result = extractEmailName(input) + expect(result).toBe('John Doe') + }) + + test('should handle quoted name', () => { + const input = '"John Doe" ' + const result = extractEmailName(input) + expect(result).toBe('John Doe') + }) + + test('should return empty string for plain email', () => { + const input = 'john@example.com' + const result = extractEmailName(input) + expect(result).toBe('') + }) +}) + +describe('#isValidEmail', () => { + test('should return true for valid email', () => { + expect(isValidEmail('john@example.com')).toBe(true) + expect(isValidEmail('test.user@domain.org')).toBe(true) + }) + + test('should return false for invalid email', () => { + expect(isValidEmail('invalid')).toBe(false) + expect(isValidEmail('invalid@')).toBe(false) + expect(isValidEmail('@domain.com')).toBe(false) + expect(isValidEmail('')).toBe(false) + }) +}) + +describe('#cleanEmail', () => { + test('should clean and normalize email', () => { + const result = cleanEmail(' John@EXAMPLE.COM ') + expect(result).toBe('john@example.com') + }) + + test('should extract and clean email from full format', () => { + const result = cleanEmail('John Doe ') + expect(result).toBe('john@example.com') + }) +}) + +describe('#parseEmailList', () => { + test('should parse comma-separated emails', () => { + const result = parseEmailList('john@example.com, jane@example.com') + expect(result).toEqual(['john@example.com', 'jane@example.com']) + }) + + test('should parse semicolon-separated emails', () => { + const result = parseEmailList('john@example.com; jane@example.com') + expect(result).toEqual(['john@example.com', 'jane@example.com']) + }) + + test('should filter out invalid emails', () => { + const result = parseEmailList('john@example.com, invalid, jane@example.com') + expect(result).toEqual(['john@example.com', 'jane@example.com']) + }) +}) + +describe('#formatEmailAddress', () => { + test('should format with name', () => { + const result = formatEmailAddress('john@example.com', 'John Doe') + expect(result).toBe('"John Doe" ') + }) + + test('should return plain email without name', () => { + const result = formatEmailAddress('john@example.com') + expect(result).toBe('john@example.com') + }) +}) + +describe('#htmlToText', () => { + test('should strip HTML tags', () => { + const html = '

Hello World

' + const result = htmlToText(html) + expect(result).toContain('Hello') + expect(result).toContain('World') + expect(result).not.toContain('<') + }) + + test('should decode HTML entities', () => { + const html = '& < > "' + const result = htmlToText(html) + expect(result).toBe('& < > "') + }) + + test('should handle empty input', () => { + expect(htmlToText('')).toBe('') + }) +}) + +describe('#isHtml', () => { + test('should detect HTML content', () => { + expect(isHtml('

Hello

')).toBe(true) + expect(isHtml('
Content
')).toBe(true) + }) + + test('should return false for plain text', () => { + expect(isHtml('Hello World')).toBe(false) + expect(isHtml('')).toBe(false) + }) +}) + +describe('#extractThreadId', () => { + test('should extract from references array', () => { + const references = ['', ''] + const result = extractThreadId(references) + expect(result).toBe('') + }) + + test('should extract from references string', () => { + const references = ' ' + const result = extractThreadId(references) + expect(result).toBe('') + }) + + test('should fall back to inReplyTo', () => { + const result = extractThreadId(undefined, '') + expect(result).toBe('') + }) + + test('should return undefined when no data', () => { + const result = extractThreadId(undefined, undefined) + expect(result).toBeUndefined() + }) +}) + +describe('#isReplySubject', () => { + test('should detect reply subjects', () => { + expect(isReplySubject('Re: Hello')).toBe(true) + expect(isReplySubject('RE: Hello')).toBe(true) + expect(isReplySubject('re: Hello')).toBe(true) + expect(isReplySubject('Aw: Hello')).toBe(true) // German + expect(isReplySubject('Sv: Hello')).toBe(true) // Swedish + }) + + test('should return false for non-reply subjects', () => { + expect(isReplySubject('Hello')).toBe(false) + expect(isReplySubject('Meeting Request')).toBe(false) + expect(isReplySubject('')).toBe(false) + }) +}) + +describe('#stripReplyPrefix', () => { + test('should strip reply prefix', () => { + expect(stripReplyPrefix('Re: Hello')).toBe('Hello') + expect(stripReplyPrefix('RE: Hello')).toBe('Hello') + }) + + test('should not modify non-reply subjects', () => { + expect(stripReplyPrefix('Hello')).toBe('Hello') + }) +}) + +describe('#addReplyPrefix', () => { + test('should add reply prefix', () => { + expect(addReplyPrefix('Hello')).toBe('Re: Hello') + }) + + test('should not add prefix if already present', () => { + expect(addReplyPrefix('Re: Hello')).toBe('Re: Hello') + }) + + test('should handle empty subject', () => { + expect(addReplyPrefix('')).toBe('Re:') + }) +}) + +describe('#parseMimeType', () => { + test('should parse simple MIME type', () => { + const result = parseMimeType('text/plain') + expect(result.type).toBe('text') + expect(result.subtype).toBe('plain') + }) + + test('should parse MIME type with parameters', () => { + const result = parseMimeType('text/plain; charset=utf-8') + expect(result.type).toBe('text') + expect(result.subtype).toBe('plain') + expect(result.parameters.charset).toBe('utf-8') + }) + + test('should handle empty input', () => { + const result = parseMimeType('') + expect(result.type).toBe('text') + expect(result.subtype).toBe('plain') + }) +}) + +describe('#mimeToExtension', () => { + test('should return correct extension for known types', () => { + expect(mimeToExtension('text/plain')).toBe('txt') + expect(mimeToExtension('text/html')).toBe('html') + expect(mimeToExtension('application/pdf')).toBe('pdf') + expect(mimeToExtension('image/jpeg')).toBe('jpg') + expect(mimeToExtension('image/png')).toBe('png') + }) + + test('should return subtype for unknown types', () => { + expect(mimeToExtension('application/unknown')).toBe('unknown') + }) +}) diff --git a/packages/provider-gmail/jest.config.ts b/packages/provider-gmail/jest.config.ts new file mode 100644 index 000000000..ca2c6a76f --- /dev/null +++ b/packages/provider-gmail/jest.config.ts @@ -0,0 +1,14 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +import type { Config } from 'jest' + +const config: Config = { + preset: 'ts-jest', + verbose: true, + cache: true, +} + +export default config diff --git a/packages/provider-gmail/package.json b/packages/provider-gmail/package.json new file mode 100644 index 000000000..46b324824 --- /dev/null +++ b/packages/provider-gmail/package.json @@ -0,0 +1,48 @@ +{ + "name": "@builderbot/provider-gmail", + "version": "1.3.15-alpha.10", + "description": "Gmail provider for BuilderBot using Gmail API with OAuth2", + "author": "Leifer Mendez ", + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "license": "MIT", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "files": [ + "./dist/" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest", + "test:coverage": "jest --coverage", + "test:watch": "jest --watchAll --coverage" + }, + "directories": { + "lib": "dist", + "test": "__tests__" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "devDependencies": { + "@builderbot/bot": "^1.3.15-alpha.10", + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/node": "^24.10.2", + "@types/polka": "^0.5.7", + "cors": "^2.8.5", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0" + }, + "dependencies": { + "body-parser": "^2.2.1", + "googleapis": "^144.0.0", + "polka": "^0.5.2" + } +} diff --git a/packages/provider-gmail/rollup.config.js b/packages/provider-gmail/rollup.config.js new file mode 100644 index 000000000..45aaf4802 --- /dev/null +++ b/packages/provider-gmail/rollup.config.js @@ -0,0 +1,24 @@ +import typescript from 'rollup-plugin-typescript2' +import json from '@rollup/plugin-json' +import commonjs from '@rollup/plugin-commonjs' +import { nodeResolve } from '@rollup/plugin-node-resolve' + +export default { + input: ['src/index.ts'], + output: [ + { + dir: 'dist', + entryFileNames: '[name].cjs', + format: 'cjs', + exports: 'named', + }, + ], + plugins: [ + json(), + nodeResolve({ + resolveOnly: (module) => !/googleapis|@builderbot\/bot/i.test(module), + }), + commonjs(), + typescript(), + ], +} diff --git a/packages/provider-gmail/src/gmail/core.ts b/packages/provider-gmail/src/gmail/core.ts new file mode 100644 index 000000000..527d86e66 --- /dev/null +++ b/packages/provider-gmail/src/gmail/core.ts @@ -0,0 +1,668 @@ +import { utils } from '@builderbot/bot' +import { google, type gmail_v1 } from 'googleapis' +import EventEmitter from 'node:events' + +import type { IGmailProviderArgs, GmailBotContext, GmailSendOptions, GmailAttachment } from '../types' + +/** + * Class representing GmailCoreVendor, handles Gmail API operations. + * Uses OAuth2 for authentication and Gmail API for sending/receiving. + * @extends EventEmitter + */ +export class GmailCoreVendor extends EventEmitter { + private gmail: gmail_v1.Gmail | null = null + private config: IGmailProviderArgs + private isConnected: boolean = false + private pollingTimer: ReturnType | null = null + private lastHistoryId: string | null = null + private processedMessageIds: Set = new Set() + + constructor(config: IGmailProviderArgs) { + super() + this.config = config + } + + /** + * Initialize OAuth2 client and connect to Gmail API + */ + public async connect(): Promise { + try { + const oauth2Client = new google.auth.OAuth2( + this.config.oauth2.clientId, + this.config.oauth2.clientSecret + ) + + oauth2Client.setCredentials({ + refresh_token: this.config.oauth2.refreshToken, + }) + + // Force token refresh to validate credentials + await oauth2Client.getAccessToken() + + this.gmail = google.gmail({ version: 'v1', auth: oauth2Client }) + this.isConnected = true + + console.log('[GmailProvider] Connected to Gmail API') + + // Get initial historyId to track changes + const profile = await this.gmail.users.getProfile({ userId: 'me' }) + this.lastHistoryId = profile.data.historyId || null + + const host = { + email: this.config.email, + phone: this.config.email, + } + this.emit('host', host) + this.emit('ready') + + // Start polling for new emails + this.startPolling() + } catch (error) { + console.error('[GmailProvider] Failed to connect to Gmail API:', error) + this.emit('auth_failure', error) + throw error + } + } + + /** + * Start polling for new emails using Gmail history API + */ + private startPolling(): void { + if (!this.gmail || !this.isConnected) return + + const interval = this.config.pollingInterval || 10000 + + console.log(`[GmailProvider] Starting polling every ${interval}ms`) + + this.pollingTimer = setInterval(async () => { + try { + await this.checkForNewEmails() + } catch (error) { + console.error('[GmailProvider] Polling error:', error) + this.emit('error', error) + } + }, interval) + } + + /** + * Check for new emails using Gmail history or messages.list + */ + private async checkForNewEmails(): Promise { + if (!this.gmail || !this.isConnected) return + + try { + if (this.lastHistoryId) { + await this.checkHistory() + } else { + await this.checkLatestMessages() + } + } catch (error: any) { + // If history is invalid (404), fall back to listing messages + if (error?.code === 404 || error?.status === 404) { + console.log('[GmailProvider] History expired, falling back to messages.list') + this.lastHistoryId = null + await this.checkLatestMessages() + } else { + throw error + } + } + } + + /** + * Check for new messages using Gmail history API + */ + private async checkHistory(): Promise { + if (!this.gmail || !this.lastHistoryId) return + + const label = this.config.label || 'INBOX' + + const response = await this.gmail.users.history.list({ + userId: 'me', + startHistoryId: this.lastHistoryId, + labelId: label, + historyTypes: ['messageAdded'], + }) + + if (response.data.historyId) { + this.lastHistoryId = response.data.historyId + } + + const history = response.data.history || [] + + for (const record of history) { + const messagesAdded = record.messagesAdded || [] + for (const added of messagesAdded) { + const messageId = added.message?.id + if (messageId && !this.processedMessageIds.has(messageId)) { + this.processedMessageIds.add(messageId) + await this.processMessage(messageId) + } + } + } + + // Limit the size of processedMessageIds to prevent memory leaks + if (this.processedMessageIds.size > 1000) { + const entries = Array.from(this.processedMessageIds) + this.processedMessageIds = new Set(entries.slice(-500)) + } + } + + /** + * Check latest messages when history is not available + */ + private async checkLatestMessages(): Promise { + if (!this.gmail) return + + const label = this.config.label || 'INBOX' + + const response = await this.gmail.users.messages.list({ + userId: 'me', + labelIds: [label], + q: 'is:unread', + maxResults: 10, + }) + + const messages = response.data.messages || [] + + for (const msg of messages) { + if (msg.id && !this.processedMessageIds.has(msg.id)) { + this.processedMessageIds.add(msg.id) + await this.processMessage(msg.id) + } + } + + // Update historyId from profile for future checks + const profile = await this.gmail.users.getProfile({ userId: 'me' }) + if (profile.data.historyId) { + this.lastHistoryId = profile.data.historyId + } + + // Limit the size of processedMessageIds + if (this.processedMessageIds.size > 1000) { + const entries = Array.from(this.processedMessageIds) + this.processedMessageIds = new Set(entries.slice(-500)) + } + } + + /** + * Process a single Gmail message by ID + */ + private async processMessage(gmailId: string): Promise { + if (!this.gmail) return + + try { + const response = await this.gmail.users.messages.get({ + userId: 'me', + id: gmailId, + format: 'full', + }) + + const message = response.data + const context = this.parseMessageToContext(message, gmailId) + + if (context) { + // Mark as read if configured + if (this.config.markAsRead !== false) { + try { + await this.gmail.users.messages.modify({ + userId: 'me', + id: gmailId, + requestBody: { + removeLabelIds: ['UNREAD'], + }, + }) + } catch (markError) { + console.error('[GmailProvider] Failed to mark email as read:', markError) + } + } + + console.log('[GmailProvider] About to emit message event') + this.emit('message', context) + console.log('[GmailProvider] Message event emitted') + } + } catch (error) { + console.error('[GmailProvider] Failed to process message:', error) + } + } + + /** + * Parse a Gmail API message object to GmailBotContext + */ + parseMessageToContext(message: gmail_v1.Schema$Message, gmailId: string): GmailBotContext | null { + const headers = message.payload?.headers || [] + + const getHeader = (name: string): string | undefined => { + const header = headers.find((h) => h.name?.toLowerCase() === name.toLowerCase()) + return header?.value || undefined + } + + const fromRaw = getHeader('From') + if (!fromRaw) { + console.warn('[GmailProvider] Email has no From header, skipping') + return null + } + + const fromParsed = this.parseEmailHeader(fromRaw) + + // Extract recipients + const toRaw = getHeader('To') + const ccRaw = getHeader('Cc') + const toAddresses = toRaw ? this.parseEmailListHeader(toRaw) : [] + const ccAddresses = ccRaw ? this.parseEmailListHeader(ccRaw) : undefined + + // Extract body + const { text, html } = this.extractBody(message.payload) + + // Extract attachments info + const attachments = this.extractAttachmentInfo(message.payload) + + // Check if reply + const inReplyTo = getHeader('In-Reply-To') + const referencesRaw = getHeader('References') + const references = referencesRaw ? referencesRaw.split(/\s+/).filter(Boolean) : undefined + const isReply = !!(inReplyTo || (references && references.length > 0)) + + const subject = getHeader('Subject') || '(no subject)' + const messageId = getHeader('Message-ID') || `${gmailId}@gmail.com` + const dateStr = getHeader('Date') + const date = dateStr ? new Date(dateStr) : new Date() + + // Determine attachment types for event routing + const hasMedia = attachments.some( + (a) => a.contentType.startsWith('image/') || a.contentType.startsWith('video/') + ) + const hasAudio = attachments.some((a) => a.contentType.startsWith('audio/')) + const hasDocument = attachments.some((a) => { + if (a.contentType.startsWith('application/')) return true + if ( + a.contentType.startsWith('text/') && + !a.contentType.includes('plain') && + !a.contentType.includes('html') + ) { + return true + } + return false + }) + + // Determine base body based on messageSource config + let body = '' + const messageSource = this.config.messageSource || 'body' + switch (messageSource) { + case 'subject': + body = subject + break + case 'both': + body = [getHeader('Subject'), text].filter(Boolean).join('\n\n') + break + case 'body': + default: + body = text || '' + break + } + + // Build body - generate special events for attachments + if (hasMedia) { + body = utils.generateRefProvider('_event_media_') + } else if (hasAudio) { + body = utils.generateRefProvider('_event_voice_note_') + } else if (hasDocument && !body.trim()) { + body = utils.generateRefProvider('_event_document_') + } + + const context: GmailBotContext = { + from: fromParsed.address, + name: fromParsed.name || fromParsed.address, + body: body, + subject: subject, + messageId: messageId, + threadId: message.threadId || undefined, + inReplyTo: inReplyTo, + attachments: attachments.length > 0 ? attachments : undefined, + isReply: isReply, + html: html || undefined, + to: toAddresses, + cc: ccAddresses, + date: date, + gmailId: gmailId, + } + + return context + } + + /** + * Extract body (text and html) from message payload + */ + private extractBody(payload: gmail_v1.Schema$MessagePart | undefined): { text: string; html: string } { + let text = '' + let html = '' + + if (!payload) return { text, html } + + const extractFromPart = (part: gmail_v1.Schema$MessagePart): void => { + if (part.mimeType === 'text/plain' && part.body?.data) { + text = Buffer.from(part.body.data, 'base64url').toString('utf-8') + } else if (part.mimeType === 'text/html' && part.body?.data) { + html = Buffer.from(part.body.data, 'base64url').toString('utf-8') + } + + if (part.parts) { + for (const subPart of part.parts) { + extractFromPart(subPart) + } + } + } + + extractFromPart(payload) + + // If no text but has HTML, do basic conversion + if (!text && html) { + text = html + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<\/?(div|p|br|h[1-6]|li|tr)[^>]*>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\n\s*\n/g, '\n\n') + .trim() + } + + return { text, html } + } + + /** + * Extract attachment information from message payload + */ + private extractAttachmentInfo(payload: gmail_v1.Schema$MessagePart | undefined): GmailAttachment[] { + const attachments: GmailAttachment[] = [] + + if (!payload) return attachments + + const extractFromPart = (part: gmail_v1.Schema$MessagePart): void => { + if (part.filename && part.filename.length > 0 && part.body?.attachmentId) { + attachments.push({ + filename: part.filename, + contentType: part.mimeType || 'application/octet-stream', + size: part.body.size || 0, + attachmentId: part.body.attachmentId, + }) + } + + if (part.parts) { + for (const subPart of part.parts) { + extractFromPart(subPart) + } + } + } + + extractFromPart(payload) + + return attachments + } + + /** + * Parse an email header like "Name " + */ + parseEmailHeader(raw: string): { address: string; name: string } { + const match = raw.match(/^(.+?)\s*<([^>]+)>$/) + if (match) { + return { + name: match[1].replace(/^["']|["']$/g, '').trim(), + address: match[2].trim().toLowerCase(), + } + } + return { + name: '', + address: raw.trim().toLowerCase(), + } + } + + /** + * Parse a comma-separated email list header + */ + private parseEmailListHeader(raw: string): string[] { + // Split on commas that are not inside angle brackets + const parts = raw.split(/,(?=(?:[^<]*<[^>]*>)*[^>]*$)/) + return parts + .map((part) => { + const parsed = this.parseEmailHeader(part.trim()) + return parsed.address + }) + .filter(Boolean) + } + + /** + * Send an email via Gmail API + */ + public async sendEmail( + to: string, + subject: string, + text: string, + options?: GmailSendOptions + ): Promise<{ messageId: string; threadId?: string }> { + if (!this.gmail) { + throw new Error('Gmail API client not initialized') + } + + const fromEmail = this.config.email + const fromName = this.config.fromName || fromEmail + + // Build raw MIME message + const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(2)}` + const mimeLines: string[] = [] + + mimeLines.push(`From: "${fromName}" <${fromEmail}>`) + mimeLines.push(`To: ${to}`) + mimeLines.push(`Subject: ${subject}`) + + if (options?.cc) { + const ccList = Array.isArray(options.cc) ? options.cc.join(', ') : options.cc + mimeLines.push(`Cc: ${ccList}`) + } + if (options?.bcc) { + const bccList = Array.isArray(options.bcc) ? options.bcc.join(', ') : options.bcc + mimeLines.push(`Bcc: ${bccList}`) + } + if (options?.replyTo) { + mimeLines.push(`Reply-To: ${options.replyTo}`) + } + if (options?.inReplyTo) { + mimeLines.push(`In-Reply-To: ${options.inReplyTo}`) + } + if (options?.references) { + const refs = Array.isArray(options.references) ? options.references.join(' ') : options.references + mimeLines.push(`References: ${refs}`) + } + + const hasAttachments = options?.attachments && options.attachments.length > 0 + + if (hasAttachments) { + mimeLines.push('MIME-Version: 1.0') + mimeLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`) + mimeLines.push('') + mimeLines.push(`--${boundary}`) + } + + if (options?.html) { + if (!hasAttachments) { + mimeLines.push('MIME-Version: 1.0') + const altBoundary = `alt_${boundary}` + mimeLines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`) + mimeLines.push('') + mimeLines.push(`--${altBoundary}`) + mimeLines.push('Content-Type: text/plain; charset=utf-8') + mimeLines.push('') + mimeLines.push(text) + mimeLines.push(`--${altBoundary}`) + mimeLines.push('Content-Type: text/html; charset=utf-8') + mimeLines.push('') + mimeLines.push(options.html) + mimeLines.push(`--${altBoundary}--`) + } else { + const altBoundary = `alt_${boundary}` + mimeLines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`) + mimeLines.push('') + mimeLines.push(`--${altBoundary}`) + mimeLines.push('Content-Type: text/plain; charset=utf-8') + mimeLines.push('') + mimeLines.push(text) + mimeLines.push(`--${altBoundary}`) + mimeLines.push('Content-Type: text/html; charset=utf-8') + mimeLines.push('') + mimeLines.push(options.html) + mimeLines.push(`--${altBoundary}--`) + } + } else { + if (hasAttachments) { + mimeLines.push('Content-Type: text/plain; charset=utf-8') + mimeLines.push('') + mimeLines.push(text) + } else { + mimeLines.push('MIME-Version: 1.0') + mimeLines.push('Content-Type: text/plain; charset=utf-8') + mimeLines.push('') + mimeLines.push(text) + } + } + + // Add attachments + if (hasAttachments && options?.attachments) { + for (const att of options.attachments) { + mimeLines.push(`--${boundary}`) + const ct = att.contentType || 'application/octet-stream' + mimeLines.push(`Content-Type: ${ct}; name="${att.filename}"`) + mimeLines.push(`Content-Disposition: attachment; filename="${att.filename}"`) + mimeLines.push('Content-Transfer-Encoding: base64') + mimeLines.push('') + + let base64Content: string + if (att.content) { + if (Buffer.isBuffer(att.content)) { + base64Content = att.content.toString('base64') + } else { + base64Content = Buffer.from(att.content).toString('base64') + } + } else if (att.path) { + const fs = await import('fs/promises') + const fileContent = await fs.readFile(att.path) + base64Content = fileContent.toString('base64') + } else { + continue + } + mimeLines.push(base64Content) + } + mimeLines.push(`--${boundary}--`) + } + + const raw = Buffer.from(mimeLines.join('\r\n')).toString('base64url') + + try { + const sendParams: gmail_v1.Params$Resource$Users$Messages$Send = { + userId: 'me', + requestBody: { + raw: raw, + }, + } + + // Include threadId for replies + if (options?.threadId) { + sendParams.requestBody!.threadId = options.threadId + } + + const response = await this.gmail.users.messages.send(sendParams) + + console.log(`[GmailProvider] Email sent: ${response.data.id}`) + + return { + messageId: response.data.id || '', + threadId: response.data.threadId || undefined, + } + } catch (error) { + console.error('[GmailProvider] Failed to send email:', error) + throw error + } + } + + /** + * Reply to an existing email thread + */ + public async replyToEmail( + originalContext: GmailBotContext, + text: string, + options?: Omit + ): Promise<{ messageId: string; threadId?: string }> { + // Build references chain + const references: string[] = [] + if (originalContext.threadId) { + references.push(originalContext.threadId) + } + if (originalContext.messageId && originalContext.messageId !== originalContext.threadId) { + references.push(originalContext.messageId) + } + + // Prepare subject with Re: prefix + let subject = originalContext.subject + if (!subject.toLowerCase().startsWith('re:')) { + subject = `Re: ${subject}` + } + + return this.sendEmail(originalContext.from, subject, text, { + ...options, + inReplyTo: originalContext.messageId, + references: references, + threadId: originalContext.threadId, + }) + } + + /** + * Download attachment content by Gmail attachment ID + */ + public async downloadAttachment(gmailId: string, attachmentId: string): Promise { + if (!this.gmail) return null + + try { + const response = await this.gmail.users.messages.attachments.get({ + userId: 'me', + messageId: gmailId, + id: attachmentId, + }) + + if (response.data.data) { + return Buffer.from(response.data.data, 'base64url') + } + + return null + } catch (error) { + console.error('[GmailProvider] Failed to download attachment:', error) + return null + } + } + + /** + * Disconnect from Gmail API and stop polling + */ + public async disconnect(): Promise { + this.isConnected = false + + if (this.pollingTimer) { + clearInterval(this.pollingTimer) + this.pollingTimer = null + } + + this.gmail = null + this.processedMessageIds.clear() + + console.log('[GmailProvider] Disconnected') + } + + /** + * Check if connected to Gmail API + */ + public isGmailConnected(): boolean { + return this.isConnected && this.gmail !== null + } +} diff --git a/packages/provider-gmail/src/gmail/provider.ts b/packages/provider-gmail/src/gmail/provider.ts new file mode 100644 index 000000000..814be6edd --- /dev/null +++ b/packages/provider-gmail/src/gmail/provider.ts @@ -0,0 +1,291 @@ +import { ProviderClass } from '@builderbot/bot' +import type { BotContext, SendOptions } from '@builderbot/bot/dist/types' +import { writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join, resolve } from 'path' + +import { GmailCoreVendor } from './core' +import type { IGmailProviderArgs, GmailBotContext, GmailSendOptions } from '../types' + +/** + * Gmail Provider for BuilderBot + * Uses Gmail API with OAuth2 for sending and receiving emails + * @extends ProviderClass + */ +class GmailProvider extends ProviderClass { + globalVendorArgs: IGmailProviderArgs + + // Map to store the last context of each conversation for thread replies + private conversationContexts: Map = new Map() + + constructor(args: IGmailProviderArgs) { + super() + + // Validate required configuration + if (!args.email) { + throw new Error('Gmail email address is required') + } + if (!args.oauth2) { + throw new Error('OAuth2 configuration is required') + } + if (!args.oauth2.clientId || !args.oauth2.clientSecret || !args.oauth2.refreshToken) { + throw new Error('OAuth2 clientId, clientSecret, and refreshToken are required') + } + + this.globalVendorArgs = { + name: 'gmail-bot', + port: 3000, + writeMyself: 'none', + label: 'INBOX', + markAsRead: true, + messageSource: 'body', + pollingInterval: 10000, + ...args, + } + } + + /** + * Initialize the Gmail vendor (API connection) + */ + protected async initVendor(): Promise { + console.log('[GmailProvider] initVendor() called') + const vendor = new GmailCoreVendor(this.globalVendorArgs) + this.vendor = vendor + + // Connect to Gmail API + await vendor.connect() + + console.log('[GmailProvider] initVendor() returning vendor') + return vendor + } + + /** + * Called before HTTP server initialization + */ + protected beforeHttpServerInit(): void { + this.server = this.server + .use((req, _, next) => { + req['globalVendorArgs'] = this.globalVendorArgs + return next() + }) + .get('/', this.indexHome) + .post('/webhook', this.webhookHandler) + } + + /** + * Called after HTTP server initialization + */ + protected afterHttpServerInit(): void {} + + /** + * Index home endpoint + */ + private indexHome = (_: any, res: any) => { + res.end('Gmail Provider running') + } + + /** + * Webhook handler for Gmail push notifications (optional) + */ + private webhookHandler = (req: any, res: any) => { + const body = req.body + console.log('[GmailProvider] Webhook received:', body) + res.end(JSON.stringify({ status: 'ok' })) + } + + /** + * Map vendor events to provider events + */ + protected busEvents = () => { + console.log('[GmailProvider] busEvents() called - registering listeners') + return [ + { + event: 'auth_failure', + func: (payload: any) => this.emit('auth_failure', payload), + }, + { + event: 'ready', + func: () => { + console.log('[GmailProvider] busEvents ready handler called') + this.emit('ready', true) + }, + }, + { + event: 'message', + func: (payload: GmailBotContext) => { + console.log('[GmailProvider] busEvents message handler called!') + console.log('[GmailProvider] Payload from:', payload.from, 'body:', payload.body?.substring(0, 50)) + // Store context to enable thread replies + this.conversationContexts.set(payload.from, payload) + this.emit('message', payload) + console.log('[GmailProvider] Provider emitted message to bot') + }, + }, + { + event: 'host', + func: (payload: any) => { + this.emit('host', payload) + }, + }, + { + event: 'error', + func: (payload: any) => { + console.error('[GmailProvider] Error:', payload) + }, + }, + ] + } + + /** + * Send an email message + * @param to - Recipient email address + * @param message - Email body content + * @param options - Send options (subject, attachments, etc.) + */ + async sendMessage(to: string, message: string, options?: SendOptions & GmailSendOptions): Promise { + // Look up existing conversation context for thread replies + const conversationCtx = this.conversationContexts.get(to) + + // Build email options with thread context if available + const baseSubject = + options?.subject || (conversationCtx?.subject ? conversationCtx.subject : 'Message from Bot') + const subject = + conversationCtx && !baseSubject.toLowerCase().startsWith('re:') ? `Re: ${baseSubject}` : baseSubject + + const emailOptions: GmailSendOptions = { + ...options, + subject, + inReplyTo: options?.inReplyTo || conversationCtx?.messageId, + references: + options?.references || + (conversationCtx + ? ([conversationCtx.threadId || conversationCtx.messageId].filter(Boolean) as string[]) + : undefined), + threadId: options?.threadId || conversationCtx?.threadId, + } + + // Check for media/attachments + if (options?.media) { + return this.sendMedia(to, message, options.media, emailOptions) + } + + return this.vendor.sendEmail(to, subject, message, emailOptions) + } + + /** + * Send an email with media attachment + * @param to - Recipient email address + * @param message - Email body content + * @param mediaPath - Path to media file + * @param options - Additional email options + */ + async sendMedia(to: string, message: string, mediaPath: string, options?: GmailSendOptions): Promise { + const subject = options?.subject || 'Message with attachment' + + const attachments = [ + { + filename: mediaPath.split('/').pop() || 'attachment', + path: mediaPath, + }, + ] + + return this.vendor.sendEmail(to, subject, message, { + ...options, + attachments: [...(options?.attachments || []), ...attachments], + }) + } + + /** + * Reply to an existing email thread + * @param ctx - Original email context + * @param message - Reply message content + * @param options - Additional email options + */ + async reply( + ctx: GmailBotContext, + message: string, + options?: Omit + ): Promise { + return this.vendor.replyToEmail(ctx, message, options) + } + + /** + * Save an attachment from an email to disk + * @param ctx - Email context containing attachments + * @param options - Save options (path, attachment index) + */ + async saveFile( + ctx: Partial, + options?: { path?: string; attachmentIndex?: number } + ): Promise { + try { + const gmailCtx = ctx as GmailBotContext + + if (!gmailCtx.attachments || gmailCtx.attachments.length === 0) { + throw new Error('No attachments in email') + } + + const attachmentIndex = options?.attachmentIndex ?? 0 + const attachment = gmailCtx.attachments[attachmentIndex] + + if (!attachment) { + throw new Error(`Attachment at index ${attachmentIndex} not found`) + } + + // Download attachment content from Gmail API if needed + let content = attachment.content + if (!content && attachment.attachmentId && gmailCtx.gmailId) { + content = (await this.vendor.downloadAttachment(gmailCtx.gmailId, attachment.attachmentId)) || undefined + } + + if (!content) { + throw new Error('Attachment content not available') + } + + const savePath = options?.path ?? tmpdir() + const fileName = `${Date.now()}-${attachment.filename}` + const filePath = join(savePath, fileName) + + await writeFile(filePath, content) + return resolve(filePath) + } catch (error) { + console.error('[GmailProvider] Error saving file:', error) + throw error + } + } + + /** + * Get all attachments from an email + * @param ctx - Email context + */ + getAttachments(ctx: GmailBotContext) { + return ctx.attachments || [] + } + + /** + * Check if the email is a reply + * @param ctx - Email context + */ + isReply(ctx: GmailBotContext): boolean { + return ctx.isReply + } + + /** + * Get the thread ID from an email + * @param ctx - Email context + */ + getThreadId(ctx: GmailBotContext): string | undefined { + return ctx.threadId + } + + /** + * Disconnect the Gmail provider + */ + async disconnect(): Promise { + if (this.vendor) { + await this.vendor.disconnect() + } + } +} + +export { GmailProvider } diff --git a/packages/provider-gmail/src/index.ts b/packages/provider-gmail/src/index.ts new file mode 100644 index 000000000..461e752e2 --- /dev/null +++ b/packages/provider-gmail/src/index.ts @@ -0,0 +1,13 @@ +export { GmailProvider } from './gmail/provider' +export { GmailCoreVendor } from './gmail/core' +export type { + IGmailProviderArgs, + GmailOAuth2Config, + GmailBotContext, + GmailSendOptions, + GmailAttachment, + ParsedGmailMessage, + GmailVendorEvents, +} from './types' +export type { GmailInterface } from './interface/gmail' +export * from './utils' diff --git a/packages/provider-gmail/src/interface/gmail.ts b/packages/provider-gmail/src/interface/gmail.ts new file mode 100644 index 000000000..b499bee26 --- /dev/null +++ b/packages/provider-gmail/src/interface/gmail.ts @@ -0,0 +1,70 @@ +import type { SendOptions, BotContext } from '@builderbot/bot/dist/types' + +import type { GmailBotContext, GmailSendOptions } from '../types' + +/** + * Interface for the Gmail Provider + */ +export interface GmailInterface { + /** + * Send an email with optional media attachment + * @param to - Recipient email address + * @param message - Email body content + * @param mediaPath - Path to media file to attach + * @param options - Additional email options + */ + sendMedia: (to: string, message: string, mediaPath: string, options?: GmailSendOptions) => Promise + + /** + * Send an email message + * @param to - Recipient email address + * @param message - Email body content + * @param options - Send options including subject, attachments, etc. + */ + sendMessage: (to: string, message: string, options?: SendOptions & GmailSendOptions) => Promise + + /** + * Save an attachment from an email to disk + * @param ctx - Email context with attachments + * @param options - Save options (path, attachment index) + */ + saveFile: ( + ctx: Partial, + options?: { path?: string; attachmentIndex?: number } + ) => Promise + + /** + * Reply to an existing email thread + * @param ctx - Original email context + * @param message - Reply message content + * @param options - Additional email options + */ + reply: ( + ctx: GmailBotContext, + message: string, + options?: Omit + ) => Promise + + /** + * Get all attachments from an email + * @param ctx - Email context + */ + getAttachments: (ctx: GmailBotContext) => GmailBotContext['attachments'] + + /** + * Check if the email is a reply to another email + * @param ctx - Email context + */ + isReply: (ctx: GmailBotContext) => boolean + + /** + * Get the thread ID from an email + * @param ctx - Email context + */ + getThreadId: (ctx: GmailBotContext) => string | undefined + + /** + * Disconnect the Gmail provider + */ + disconnect: () => Promise +} diff --git a/packages/provider-gmail/src/types.ts b/packages/provider-gmail/src/types.ts new file mode 100644 index 000000000..bc89a7205 --- /dev/null +++ b/packages/provider-gmail/src/types.ts @@ -0,0 +1,159 @@ +import type { BotContext, GlobalVendorArgs } from '@builderbot/bot/dist/types' + +/** + * Gmail OAuth2 configuration + */ +export interface GmailOAuth2Config { + /** OAuth2 Client ID from Google Cloud Console */ + clientId: string + /** OAuth2 Client Secret from Google Cloud Console */ + clientSecret: string + /** OAuth2 Refresh Token (obtained via authorization flow) */ + refreshToken: string +} + +/** + * Source for the message body + * - 'body': Use email body only (default) + * - 'subject': Use email subject only + * - 'both': Use both subject and body (format: "subject\n\nbody") + */ +export type MessageSource = 'body' | 'subject' | 'both' + +/** + * Gmail provider configuration arguments + */ +export interface IGmailProviderArgs extends GlobalVendorArgs { + /** Gmail account email address */ + email: string + /** OAuth2 credentials for Gmail API */ + oauth2: GmailOAuth2Config + /** Label to monitor for new emails (default: 'INBOX') */ + label?: string + /** Mark emails as read after processing (default: true) */ + markAsRead?: boolean + /** From name for outgoing emails */ + fromName?: string + /** Source for message body: 'body', 'subject', or 'both' (default: 'body') */ + messageSource?: MessageSource + /** Polling interval in milliseconds for checking new emails (default: 10000) */ + pollingInterval?: number +} + +/** + * Gmail attachment information + */ +export interface GmailAttachment { + /** Attachment filename */ + filename: string + /** MIME content type */ + contentType: string + /** File size in bytes */ + size: number + /** Gmail attachment ID (for downloading) */ + attachmentId?: string + /** Raw content buffer (available after download) */ + content?: Buffer +} + +/** + * Gmail context extending BotContext + */ +export interface GmailBotContext extends BotContext { + /** Sender's email address */ + from: string + /** Sender's display name */ + name: string + /** Email body content (plain text preferred, fallback to HTML) */ + body: string + /** Email subject line */ + subject: string + /** Gmail Message ID */ + messageId: string + /** Gmail Thread ID */ + threadId?: string + /** In-Reply-To header value (if this is a reply) */ + inReplyTo?: string + /** List of attachments in the email */ + attachments?: GmailAttachment[] + /** Whether this email is a reply to another email */ + isReply: boolean + /** Original HTML content */ + html?: string + /** All recipients (To field) */ + to?: string[] + /** CC recipients */ + cc?: string[] + /** Email date */ + date?: Date + /** Gmail internal message ID */ + gmailId?: string +} + +/** + * Options for sending emails via Gmail + */ +export interface GmailSendOptions { + /** Email subject (required for new threads) */ + subject?: string + /** CC recipients */ + cc?: string | string[] + /** BCC recipients */ + bcc?: string | string[] + /** Reply-To address */ + replyTo?: string + /** Attachments to send */ + attachments?: Array<{ + filename: string + path?: string + content?: Buffer | string + contentType?: string + }> + /** HTML content (alternative to plain text) */ + html?: string + /** In-Reply-To header for replies */ + inReplyTo?: string + /** References header for thread continuity */ + references?: string | string[] + /** Gmail Thread ID to reply to */ + threadId?: string +} + +/** + * Internal Gmail message structure + */ +export interface ParsedGmailMessage { + gmailId: string + threadId: string + messageId: string + from: { + address: string + name: string + } + to: Array<{ + address: string + name: string + }> + cc?: Array<{ + address: string + name: string + }> + subject: string + text?: string + html?: string + date: Date + inReplyTo?: string + references?: string[] + attachments: GmailAttachment[] + labelIds: string[] +} + +/** + * Gmail vendor events + */ +export type GmailVendorEvents = { + message: [payload: GmailBotContext] + ready: [] + auth_failure: [error: Error] + error: [error: Error] +} diff --git a/packages/provider-gmail/src/utils/index.ts b/packages/provider-gmail/src/utils/index.ts new file mode 100644 index 000000000..089e6c613 --- /dev/null +++ b/packages/provider-gmail/src/utils/index.ts @@ -0,0 +1,17 @@ +export { + extractEmailAddress, + extractEmailName, + isValidEmail, + cleanEmail, + parseEmailList, + formatEmailAddress, + htmlToText, + isHtml, + extractThreadId, + isReplySubject, + stripReplyPrefix, + addReplyPrefix, + generateMessageId, + parseMimeType, + mimeToExtension, +} from './parser' diff --git a/packages/provider-gmail/src/utils/parser.ts b/packages/provider-gmail/src/utils/parser.ts new file mode 100644 index 000000000..f04d3d5f9 --- /dev/null +++ b/packages/provider-gmail/src/utils/parser.ts @@ -0,0 +1,247 @@ +/** + * Gmail parsing utilities + */ + +/** + * Extract email address from a string that might contain name and email + * e.g., "John Doe " -> "john@example.com" + */ +export function extractEmailAddress(input: string): string { + if (!input) return '' + + // Check if it contains angle brackets + const match = input.match(/<([^>]+)>/) + if (match) { + return match[1].trim().toLowerCase() + } + + // Return as-is if it looks like an email + const trimmed = input.trim().toLowerCase() + if (isValidEmail(trimmed)) { + return trimmed + } + + return trimmed +} + +/** + * Extract name from email string + * e.g., "John Doe " -> "John Doe" + */ +export function extractEmailName(input: string): string { + if (!input) return '' + + // Check if it contains angle brackets + const bracketIndex = input.indexOf('<') + if (bracketIndex > 0) { + return input + .substring(0, bracketIndex) + .trim() + .replace(/^["']|["']$/g, '') + } + + return '' +} + +/** + * Validate email address format + */ +export function isValidEmail(email: string): boolean { + if (!email) return false + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +/** + * Clean and normalize email address + */ +export function cleanEmail(email: string): string { + return extractEmailAddress(email).toLowerCase().trim() +} + +/** + * Parse email list (comma or semicolon separated) + */ +export function parseEmailList(input: string): string[] { + if (!input) return [] + + return input + .split(/[,;]/) + .map((email) => extractEmailAddress(email)) + .filter((email) => isValidEmail(email)) +} + +/** + * Format email address with optional name + */ +export function formatEmailAddress(email: string, name?: string): string { + if (name) { + return `"${name}" <${email}>` + } + return email +} + +/** + * Extract plain text from HTML email content + * Basic implementation - strips HTML tags + */ +export function htmlToText(html: string): string { + if (!html) return '' + + return ( + html + // Remove script and style tags with content + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + // Replace common block elements with newlines + .replace(/<\/?(div|p|br|h[1-6]|li|tr)[^>]*>/gi, '\n') + // Remove remaining HTML tags + .replace(/<[^>]+>/g, '') + // Decode common HTML entities + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + // Clean up whitespace + .replace(/\n\s*\n/g, '\n\n') + .trim() + ) +} + +/** + * Check if content is likely HTML + */ +export function isHtml(content: string): boolean { + if (!content) return false + return /<[a-z][\s\S]*>/i.test(content) +} + +/** + * Extract thread ID from References or In-Reply-To headers + */ +export function extractThreadId(references?: string | string[], inReplyTo?: string): string | undefined { + // Try references first (get the first/root message) + if (references) { + if (Array.isArray(references) && references.length > 0) { + return references[0] + } + if (typeof references === 'string') { + const refs = references.split(/\s+/).filter(Boolean) + if (refs.length > 0) return refs[0] + } + } + + // Fall back to In-Reply-To + if (inReplyTo) { + return inReplyTo + } + + return undefined +} + +/** + * Check if email subject indicates a reply + */ +export function isReplySubject(subject: string): boolean { + if (!subject) return false + const replyPrefixes = ['re:', 'r:', 'aw:', 'sv:', 'antw:', 'odp:'] + const lowerSubject = subject.toLowerCase().trim() + return replyPrefixes.some((prefix) => lowerSubject.startsWith(prefix)) +} + +/** + * Strip reply prefixes from subject + */ +export function stripReplyPrefix(subject: string): string { + if (!subject) return '' + return subject.replace(/^(re:|r:|aw:|sv:|antw:|odp:)\s*/i, '').trim() +} + +/** + * Add reply prefix to subject if not present + */ +export function addReplyPrefix(subject: string): string { + if (!subject) return 'Re:' + if (isReplySubject(subject)) return subject + return `Re: ${subject}` +} + +/** + * Generate a unique message ID for Gmail domain + */ +export function generateMessageId(domain: string = 'gmail.com'): string { + const timestamp = Date.now().toString(36) + const random = Math.random().toString(36).substring(2, 10) + return `<${timestamp}.${random}@${domain}>` +} + +/** + * Parse MIME content type + */ +export function parseMimeType(contentType: string): { + type: string + subtype: string + parameters: Record +} { + if (!contentType) { + return { type: 'text', subtype: 'plain', parameters: {} } + } + + const parts = contentType.split(';') + const [type, subtype] = (parts[0] || 'text/plain').split('/') + const parameters: Record = {} + + for (let i = 1; i < parts.length; i++) { + const param = parts[i].trim() + const eqIndex = param.indexOf('=') + if (eqIndex > 0) { + const key = param.substring(0, eqIndex).trim().toLowerCase() + let value = param.substring(eqIndex + 1).trim() + // Remove quotes + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1) + } + parameters[key] = value + } + } + + return { + type: type?.toLowerCase() || 'text', + subtype: subtype?.toLowerCase() || 'plain', + parameters, + } +} + +/** + * Get file extension from MIME type + */ +export function mimeToExtension(mimeType: string): string { + const mimeMap: Record = { + 'text/plain': 'txt', + 'text/html': 'html', + 'text/css': 'css', + 'text/javascript': 'js', + 'application/json': 'json', + 'application/pdf': 'pdf', + 'application/zip': 'zip', + 'application/xml': 'xml', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/ogg': 'ogg', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + } + + const { type, subtype } = parseMimeType(mimeType) + const fullType = `${type}/${subtype}` + + return mimeMap[fullType] || subtype || 'bin' +} diff --git a/packages/provider-gmail/tsconfig.json b/packages/provider-gmail/tsconfig.json new file mode 100644 index 000000000..bed0d7489 --- /dev/null +++ b/packages/provider-gmail/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "es2021", + "types": ["node"] + }, + "include": ["src/**/*.js", "src/**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] +} diff --git a/starters/apps/base-js-gmail-json/.dockerignore b/starters/apps/base-js-gmail-json/.dockerignore new file mode 100644 index 000000000..1eaeed3c3 --- /dev/null +++ b/starters/apps/base-js-gmail-json/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-js-gmail-json/Dockerfile b/starters/apps/base-js-gmail-json/Dockerfile new file mode 100644 index 000000000..e2cb3f816 --- /dev/null +++ b/starters/apps/base-js-gmail-json/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-js-gmail-json/README.md b/starters/apps/base-js-gmail-json/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-js-gmail-json/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-js-gmail-json/_gitignore b/starters/apps/base-js-gmail-json/_gitignore new file mode 100644 index 000000000..727ad6c73 --- /dev/null +++ b/starters/apps/base-js-gmail-json/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-js-gmail-json/assets/sample.png b/starters/apps/base-js-gmail-json/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-js-gmail-json/assets/sample.png differ diff --git a/starters/apps/base-js-gmail-json/eslint.config.js b/starters/apps/base-js-gmail-json/eslint.config.js new file mode 100644 index 000000000..86d53d609 --- /dev/null +++ b/starters/apps/base-js-gmail-json/eslint.config.js @@ -0,0 +1,23 @@ +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-js-gmail-json/nodemon.json b/starters/apps/base-js-gmail-json/nodemon.json new file mode 100644 index 000000000..425752ae8 --- /dev/null +++ b/starters/apps/base-js-gmail-json/nodemon.json @@ -0,0 +1,9 @@ +{ + "watch": ["src"], + "ext": "js", + "ignore": [ + "**/*.test.js", + "**/*.spec.js" +], + "delay": "3" +} \ No newline at end of file diff --git a/starters/apps/base-js-gmail-json/package.json b/starters/apps/base-js-gmail-json/package.json new file mode 100644 index 000000000..e0a36d860 --- /dev/null +++ b/starters/apps/base-js-gmail-json/package.json @@ -0,0 +1,25 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "src/app.js", + "type": "module", + "scripts": { + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.js", + "start": "node ./src/app.js" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest", + "@builderbot/database-json": "latest" + }, + "devDependencies": { + "eslint-plugin-builderbot": "latest", + "eslint": "^9.39.1", + "nodemon": "^3.1.11" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-js-gmail-json/src/app.js b/starters/apps/base-js-gmail-json/src/app.js new file mode 100644 index 000000000..f46640501 --- /dev/null +++ b/starters/apps/base-js-gmail-json/src/app.js @@ -0,0 +1,131 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { JsonFileDB as Database } from '@builderbot/database-json' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + + const adapterDB = new Database({ filename: 'db.json' }) + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-js-gmail-memory/.dockerignore b/starters/apps/base-js-gmail-memory/.dockerignore new file mode 100644 index 000000000..1eaeed3c3 --- /dev/null +++ b/starters/apps/base-js-gmail-memory/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-js-gmail-memory/Dockerfile b/starters/apps/base-js-gmail-memory/Dockerfile new file mode 100644 index 000000000..e2cb3f816 --- /dev/null +++ b/starters/apps/base-js-gmail-memory/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-js-gmail-memory/README.md b/starters/apps/base-js-gmail-memory/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-js-gmail-memory/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-js-gmail-memory/_gitignore b/starters/apps/base-js-gmail-memory/_gitignore new file mode 100644 index 000000000..727ad6c73 --- /dev/null +++ b/starters/apps/base-js-gmail-memory/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-js-gmail-memory/assets/sample.png b/starters/apps/base-js-gmail-memory/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-js-gmail-memory/assets/sample.png differ diff --git a/starters/apps/base-js-gmail-memory/eslint.config.js b/starters/apps/base-js-gmail-memory/eslint.config.js new file mode 100644 index 000000000..86d53d609 --- /dev/null +++ b/starters/apps/base-js-gmail-memory/eslint.config.js @@ -0,0 +1,23 @@ +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-js-gmail-memory/nodemon.json b/starters/apps/base-js-gmail-memory/nodemon.json new file mode 100644 index 000000000..425752ae8 --- /dev/null +++ b/starters/apps/base-js-gmail-memory/nodemon.json @@ -0,0 +1,9 @@ +{ + "watch": ["src"], + "ext": "js", + "ignore": [ + "**/*.test.js", + "**/*.spec.js" +], + "delay": "3" +} \ No newline at end of file diff --git a/starters/apps/base-js-gmail-memory/package.json b/starters/apps/base-js-gmail-memory/package.json new file mode 100644 index 000000000..2a1bf2d85 --- /dev/null +++ b/starters/apps/base-js-gmail-memory/package.json @@ -0,0 +1,24 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "src/app.js", + "type": "module", + "scripts": { + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.js", + "start": "node ./src/app.js" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest" + }, + "devDependencies": { + "eslint-plugin-builderbot": "latest", + "eslint": "^9.39.1", + "nodemon": "^3.1.11" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-js-gmail-memory/src/app.js b/starters/apps/base-js-gmail-memory/src/app.js new file mode 100644 index 000000000..5d886011d --- /dev/null +++ b/starters/apps/base-js-gmail-memory/src/app.js @@ -0,0 +1,130 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { MemoryDB as Database } from '@builderbot/bot' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + const adapterDB = new Database() + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-js-gmail-mongo/.dockerignore b/starters/apps/base-js-gmail-mongo/.dockerignore new file mode 100644 index 000000000..1eaeed3c3 --- /dev/null +++ b/starters/apps/base-js-gmail-mongo/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-js-gmail-mongo/Dockerfile b/starters/apps/base-js-gmail-mongo/Dockerfile new file mode 100644 index 000000000..e2cb3f816 --- /dev/null +++ b/starters/apps/base-js-gmail-mongo/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-js-gmail-mongo/README.md b/starters/apps/base-js-gmail-mongo/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-js-gmail-mongo/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-js-gmail-mongo/_gitignore b/starters/apps/base-js-gmail-mongo/_gitignore new file mode 100644 index 000000000..727ad6c73 --- /dev/null +++ b/starters/apps/base-js-gmail-mongo/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-js-gmail-mongo/assets/sample.png b/starters/apps/base-js-gmail-mongo/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-js-gmail-mongo/assets/sample.png differ diff --git a/starters/apps/base-js-gmail-mongo/eslint.config.js b/starters/apps/base-js-gmail-mongo/eslint.config.js new file mode 100644 index 000000000..86d53d609 --- /dev/null +++ b/starters/apps/base-js-gmail-mongo/eslint.config.js @@ -0,0 +1,23 @@ +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-js-gmail-mongo/nodemon.json b/starters/apps/base-js-gmail-mongo/nodemon.json new file mode 100644 index 000000000..425752ae8 --- /dev/null +++ b/starters/apps/base-js-gmail-mongo/nodemon.json @@ -0,0 +1,9 @@ +{ + "watch": ["src"], + "ext": "js", + "ignore": [ + "**/*.test.js", + "**/*.spec.js" +], + "delay": "3" +} \ No newline at end of file diff --git a/starters/apps/base-js-gmail-mongo/package.json b/starters/apps/base-js-gmail-mongo/package.json new file mode 100644 index 000000000..0426bb1b2 --- /dev/null +++ b/starters/apps/base-js-gmail-mongo/package.json @@ -0,0 +1,25 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "src/app.js", + "type": "module", + "scripts": { + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.js", + "start": "node ./src/app.js" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest", + "@builderbot/database-mongo": "latest" + }, + "devDependencies": { + "eslint-plugin-builderbot": "latest", + "eslint": "^9.39.1", + "nodemon": "^3.1.11" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-js-gmail-mongo/src/app.js b/starters/apps/base-js-gmail-mongo/src/app.js new file mode 100644 index 000000000..bb043e740 --- /dev/null +++ b/starters/apps/base-js-gmail-mongo/src/app.js @@ -0,0 +1,133 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { MongoAdapter as Database } from '@builderbot/database-mongo' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + const adapterDB = new Database({ + dbUri: process.env.MONGO_DB_URI, + dbName: process.env.MONGO_DB_NAME, + }) + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-js-gmail-mysql/.dockerignore b/starters/apps/base-js-gmail-mysql/.dockerignore new file mode 100644 index 000000000..1eaeed3c3 --- /dev/null +++ b/starters/apps/base-js-gmail-mysql/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-js-gmail-mysql/Dockerfile b/starters/apps/base-js-gmail-mysql/Dockerfile new file mode 100644 index 000000000..e2cb3f816 --- /dev/null +++ b/starters/apps/base-js-gmail-mysql/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-js-gmail-mysql/README.md b/starters/apps/base-js-gmail-mysql/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-js-gmail-mysql/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-js-gmail-mysql/_gitignore b/starters/apps/base-js-gmail-mysql/_gitignore new file mode 100644 index 000000000..727ad6c73 --- /dev/null +++ b/starters/apps/base-js-gmail-mysql/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-js-gmail-mysql/assets/sample.png b/starters/apps/base-js-gmail-mysql/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-js-gmail-mysql/assets/sample.png differ diff --git a/starters/apps/base-js-gmail-mysql/eslint.config.js b/starters/apps/base-js-gmail-mysql/eslint.config.js new file mode 100644 index 000000000..86d53d609 --- /dev/null +++ b/starters/apps/base-js-gmail-mysql/eslint.config.js @@ -0,0 +1,23 @@ +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-js-gmail-mysql/nodemon.json b/starters/apps/base-js-gmail-mysql/nodemon.json new file mode 100644 index 000000000..425752ae8 --- /dev/null +++ b/starters/apps/base-js-gmail-mysql/nodemon.json @@ -0,0 +1,9 @@ +{ + "watch": ["src"], + "ext": "js", + "ignore": [ + "**/*.test.js", + "**/*.spec.js" +], + "delay": "3" +} \ No newline at end of file diff --git a/starters/apps/base-js-gmail-mysql/package.json b/starters/apps/base-js-gmail-mysql/package.json new file mode 100644 index 000000000..9d53d104b --- /dev/null +++ b/starters/apps/base-js-gmail-mysql/package.json @@ -0,0 +1,25 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "src/app.js", + "type": "module", + "scripts": { + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.js", + "start": "node ./src/app.js" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest", + "@builderbot/database-mysql": "latest" + }, + "devDependencies": { + "eslint-plugin-builderbot": "latest", + "eslint": "^9.39.1", + "nodemon": "^3.1.11" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-js-gmail-mysql/src/app.js b/starters/apps/base-js-gmail-mysql/src/app.js new file mode 100644 index 000000000..ed6cbf2e8 --- /dev/null +++ b/starters/apps/base-js-gmail-mysql/src/app.js @@ -0,0 +1,135 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { MysqlAdapter as Database } from '@builderbot/database-mysql' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + const adapterDB = new Database({ + host: process.env.MYSQL_DB_HOST, + user: process.env.MYSQL_DB_USER, + database: process.env.MYSQL_DB_NAME, + password: process.env.MYSQL_DB_PASSWORD, + }) + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-js-gmail-postgres/.dockerignore b/starters/apps/base-js-gmail-postgres/.dockerignore new file mode 100644 index 000000000..1eaeed3c3 --- /dev/null +++ b/starters/apps/base-js-gmail-postgres/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-js-gmail-postgres/Dockerfile b/starters/apps/base-js-gmail-postgres/Dockerfile new file mode 100644 index 000000000..e2cb3f816 --- /dev/null +++ b/starters/apps/base-js-gmail-postgres/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-js-gmail-postgres/README.md b/starters/apps/base-js-gmail-postgres/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-js-gmail-postgres/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-js-gmail-postgres/_gitignore b/starters/apps/base-js-gmail-postgres/_gitignore new file mode 100644 index 000000000..727ad6c73 --- /dev/null +++ b/starters/apps/base-js-gmail-postgres/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env + +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-js-gmail-postgres/assets/sample.png b/starters/apps/base-js-gmail-postgres/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-js-gmail-postgres/assets/sample.png differ diff --git a/starters/apps/base-js-gmail-postgres/eslint.config.js b/starters/apps/base-js-gmail-postgres/eslint.config.js new file mode 100644 index 000000000..86d53d609 --- /dev/null +++ b/starters/apps/base-js-gmail-postgres/eslint.config.js @@ -0,0 +1,23 @@ +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-js-gmail-postgres/nodemon.json b/starters/apps/base-js-gmail-postgres/nodemon.json new file mode 100644 index 000000000..425752ae8 --- /dev/null +++ b/starters/apps/base-js-gmail-postgres/nodemon.json @@ -0,0 +1,9 @@ +{ + "watch": ["src"], + "ext": "js", + "ignore": [ + "**/*.test.js", + "**/*.spec.js" +], + "delay": "3" +} \ No newline at end of file diff --git a/starters/apps/base-js-gmail-postgres/package.json b/starters/apps/base-js-gmail-postgres/package.json new file mode 100644 index 000000000..a73bcc0c6 --- /dev/null +++ b/starters/apps/base-js-gmail-postgres/package.json @@ -0,0 +1,25 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "src/app.js", + "type": "module", + "scripts": { + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.js", + "start": "node ./src/app.js" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest", + "@builderbot/database-postgres": "latest" + }, + "devDependencies": { + "eslint-plugin-builderbot": "latest", + "eslint": "^9.39.1", + "nodemon": "^3.1.11" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-js-gmail-postgres/src/app.js b/starters/apps/base-js-gmail-postgres/src/app.js new file mode 100644 index 000000000..8de2b7f27 --- /dev/null +++ b/starters/apps/base-js-gmail-postgres/src/app.js @@ -0,0 +1,136 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { PostgreSQLAdapter as Database } from '@builderbot/database-postgres' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + const adapterDB = new Database({ + host: process.env.POSTGRES_DB_HOST, + user: process.env.POSTGRES_DB_USER, + database: process.env.POSTGRES_DB_NAME, + password: process.env.POSTGRES_DB_PASSWORD, + port: +process.env.POSTGRES_DB_PORT + }) + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-ts-gmail-json/.dockerignore b/starters/apps/base-ts-gmail-json/.dockerignore new file mode 100644 index 000000000..3c5abc495 --- /dev/null +++ b/starters/apps/base-ts-gmail-json/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-ts-gmail-json/Dockerfile b/starters/apps/base-ts-gmail-json/Dockerfile new file mode 100644 index 000000000..5cc4e424e --- /dev/null +++ b/starters/apps/base-ts-gmail-json/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install && pnpm run build \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-ts-gmail-json/README.md b/starters/apps/base-ts-gmail-json/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-ts-gmail-json/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-json/_gitignore b/starters/apps/base-ts-gmail-json/_gitignore new file mode 100644 index 000000000..b32acec90 --- /dev/null +++ b/starters/apps/base-ts-gmail-json/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-json/assets/sample.png b/starters/apps/base-ts-gmail-json/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-ts-gmail-json/assets/sample.png differ diff --git a/starters/apps/base-ts-gmail-json/eslint.config.js b/starters/apps/base-ts-gmail-json/eslint.config.js new file mode 100644 index 000000000..59f51f733 --- /dev/null +++ b/starters/apps/base-ts-gmail-json/eslint.config.js @@ -0,0 +1,25 @@ +import tseslint from 'typescript-eslint' +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + ...tseslint.configs.recommended, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-ts-gmail-json/nodemon.json b/starters/apps/base-ts-gmail-json/nodemon.json new file mode 100644 index 000000000..931698cc0 --- /dev/null +++ b/starters/apps/base-ts-gmail-json/nodemon.json @@ -0,0 +1,12 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": [ + "**/*.test.ts", + "**/*.spec.ts" + ], + "delay": "3", + "execMap": { + "ts": "tsx" + } + } \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-json/package.json b/starters/apps/base-ts-gmail-json/package.json new file mode 100644 index 000000000..f93d168a9 --- /dev/null +++ b/starters/apps/base-ts-gmail-json/package.json @@ -0,0 +1,32 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "dist/app.js", + "type": "module", + "scripts": { + "start": "node ./dist/app.js", + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.ts", + "build": "npx rollup -c" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest", + "@builderbot/database-json": "latest" + }, + "devDependencies": { + "@types/node": "^25.0.0", + "typescript-eslint": "^8.0.0", + "eslint": "^9.0.0", + "eslint-plugin-builderbot": "latest", + "rollup": "^4.10.0", + "nodemon": "^3.1.11", + "rollup-plugin-typescript2": "^0.36.0", + "tsx": "^4.7.1", + "typescript": "^5.4.3" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-ts-gmail-json/rollup.config.js b/starters/apps/base-ts-gmail-json/rollup.config.js new file mode 100644 index 000000000..6de1ab0c2 --- /dev/null +++ b/starters/apps/base-ts-gmail-json/rollup.config.js @@ -0,0 +1,13 @@ +import typescript from 'rollup-plugin-typescript2' + +export default { + input: 'src/app.ts', + output: { + file: 'dist/app.js', + format: 'esm', + }, + onwarn: (warning) => { + if (warning.code === 'UNRESOLVED_IMPORT') return + }, + plugins: [typescript()], +} diff --git a/starters/apps/base-ts-gmail-json/src/app.ts b/starters/apps/base-ts-gmail-json/src/app.ts new file mode 100644 index 000000000..5f5405344 --- /dev/null +++ b/starters/apps/base-ts-gmail-json/src/app.ts @@ -0,0 +1,131 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { JsonFileDB as Database } from '@builderbot/database-json' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + + const adapterDB = new Database({ filename: 'db.json' }) + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-ts-gmail-json/tsconfig.json b/starters/apps/base-ts-gmail-json/tsconfig.json new file mode 100644 index 000000000..dfa5d961e --- /dev/null +++ b/starters/apps/base-ts-gmail-json/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "declaration": false, + "declarationMap": false, + "moduleResolution": "node", + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "sourceMap": false, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./", + "incremental": true, + "skipLibCheck": true, + "paths": { + "~/*": ["./src/*"] + } + }, + "include": [ + "**/*.js", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**e2e**", + "**mock**" + ] +} \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-memory/.dockerignore b/starters/apps/base-ts-gmail-memory/.dockerignore new file mode 100644 index 000000000..3c5abc495 --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-ts-gmail-memory/Dockerfile b/starters/apps/base-ts-gmail-memory/Dockerfile new file mode 100644 index 000000000..5cc4e424e --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install && pnpm run build \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-ts-gmail-memory/README.md b/starters/apps/base-ts-gmail-memory/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-memory/_gitignore b/starters/apps/base-ts-gmail-memory/_gitignore new file mode 100644 index 000000000..b32acec90 --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-memory/assets/sample.png b/starters/apps/base-ts-gmail-memory/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-ts-gmail-memory/assets/sample.png differ diff --git a/starters/apps/base-ts-gmail-memory/eslint.config.js b/starters/apps/base-ts-gmail-memory/eslint.config.js new file mode 100644 index 000000000..59f51f733 --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/eslint.config.js @@ -0,0 +1,25 @@ +import tseslint from 'typescript-eslint' +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + ...tseslint.configs.recommended, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-ts-gmail-memory/nodemon.json b/starters/apps/base-ts-gmail-memory/nodemon.json new file mode 100644 index 000000000..931698cc0 --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/nodemon.json @@ -0,0 +1,12 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": [ + "**/*.test.ts", + "**/*.spec.ts" + ], + "delay": "3", + "execMap": { + "ts": "tsx" + } + } \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-memory/package.json b/starters/apps/base-ts-gmail-memory/package.json new file mode 100644 index 000000000..a551d632c --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/package.json @@ -0,0 +1,31 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "dist/app.js", + "type": "module", + "scripts": { + "start": "node ./dist/app.js", + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.ts", + "build": "npx rollup -c" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest" + }, + "devDependencies": { + "@types/node": "^25.0.0", + "typescript-eslint": "^8.0.0", + "eslint": "^9.0.0", + "eslint-plugin-builderbot": "latest", + "rollup": "^4.10.0", + "nodemon": "^3.1.11", + "rollup-plugin-typescript2": "^0.36.0", + "tsx": "^4.7.1", + "typescript": "^5.4.3" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-ts-gmail-memory/rollup.config.js b/starters/apps/base-ts-gmail-memory/rollup.config.js new file mode 100644 index 000000000..6de1ab0c2 --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/rollup.config.js @@ -0,0 +1,13 @@ +import typescript from 'rollup-plugin-typescript2' + +export default { + input: 'src/app.ts', + output: { + file: 'dist/app.js', + format: 'esm', + }, + onwarn: (warning) => { + if (warning.code === 'UNRESOLVED_IMPORT') return + }, + plugins: [typescript()], +} diff --git a/starters/apps/base-ts-gmail-memory/src/app.ts b/starters/apps/base-ts-gmail-memory/src/app.ts new file mode 100644 index 000000000..1f60c6788 --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/src/app.ts @@ -0,0 +1,130 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { MemoryDB as Database } from '@builderbot/bot' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + const adapterDB = new Database() + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-ts-gmail-memory/tsconfig.json b/starters/apps/base-ts-gmail-memory/tsconfig.json new file mode 100644 index 000000000..dfa5d961e --- /dev/null +++ b/starters/apps/base-ts-gmail-memory/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "declaration": false, + "declarationMap": false, + "moduleResolution": "node", + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "sourceMap": false, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./", + "incremental": true, + "skipLibCheck": true, + "paths": { + "~/*": ["./src/*"] + } + }, + "include": [ + "**/*.js", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**e2e**", + "**mock**" + ] +} \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-mongo/.dockerignore b/starters/apps/base-ts-gmail-mongo/.dockerignore new file mode 100644 index 000000000..3c5abc495 --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-ts-gmail-mongo/Dockerfile b/starters/apps/base-ts-gmail-mongo/Dockerfile new file mode 100644 index 000000000..5cc4e424e --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install && pnpm run build \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-ts-gmail-mongo/README.md b/starters/apps/base-ts-gmail-mongo/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-mongo/_gitignore b/starters/apps/base-ts-gmail-mongo/_gitignore new file mode 100644 index 000000000..b32acec90 --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-mongo/assets/sample.png b/starters/apps/base-ts-gmail-mongo/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-ts-gmail-mongo/assets/sample.png differ diff --git a/starters/apps/base-ts-gmail-mongo/eslint.config.js b/starters/apps/base-ts-gmail-mongo/eslint.config.js new file mode 100644 index 000000000..59f51f733 --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/eslint.config.js @@ -0,0 +1,25 @@ +import tseslint from 'typescript-eslint' +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + ...tseslint.configs.recommended, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-ts-gmail-mongo/nodemon.json b/starters/apps/base-ts-gmail-mongo/nodemon.json new file mode 100644 index 000000000..931698cc0 --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/nodemon.json @@ -0,0 +1,12 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": [ + "**/*.test.ts", + "**/*.spec.ts" + ], + "delay": "3", + "execMap": { + "ts": "tsx" + } + } \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-mongo/package.json b/starters/apps/base-ts-gmail-mongo/package.json new file mode 100644 index 000000000..3f1c1e7b4 --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/package.json @@ -0,0 +1,32 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "dist/app.js", + "type": "module", + "scripts": { + "start": "node ./dist/app.js", + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.ts", + "build": "npx rollup -c" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest", + "@builderbot/database-mongo": "latest" + }, + "devDependencies": { + "@types/node": "^25.0.0", + "typescript-eslint": "^8.0.0", + "eslint": "^9.0.0", + "eslint-plugin-builderbot": "latest", + "rollup": "^4.10.0", + "nodemon": "^3.1.11", + "rollup-plugin-typescript2": "^0.36.0", + "tsx": "^4.7.1", + "typescript": "^5.4.3" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-ts-gmail-mongo/rollup.config.js b/starters/apps/base-ts-gmail-mongo/rollup.config.js new file mode 100644 index 000000000..6de1ab0c2 --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/rollup.config.js @@ -0,0 +1,13 @@ +import typescript from 'rollup-plugin-typescript2' + +export default { + input: 'src/app.ts', + output: { + file: 'dist/app.js', + format: 'esm', + }, + onwarn: (warning) => { + if (warning.code === 'UNRESOLVED_IMPORT') return + }, + plugins: [typescript()], +} diff --git a/starters/apps/base-ts-gmail-mongo/src/app.ts b/starters/apps/base-ts-gmail-mongo/src/app.ts new file mode 100644 index 000000000..240c43029 --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/src/app.ts @@ -0,0 +1,133 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { MongoAdapter as Database } from '@builderbot/database-mongo' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + const adapterDB = new Database({ + dbUri: process.env.MONGO_DB_URI, + dbName: process.env.MONGO_DB_NAME, + }) + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-ts-gmail-mongo/tsconfig.json b/starters/apps/base-ts-gmail-mongo/tsconfig.json new file mode 100644 index 000000000..dfa5d961e --- /dev/null +++ b/starters/apps/base-ts-gmail-mongo/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "declaration": false, + "declarationMap": false, + "moduleResolution": "node", + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "sourceMap": false, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./", + "incremental": true, + "skipLibCheck": true, + "paths": { + "~/*": ["./src/*"] + } + }, + "include": [ + "**/*.js", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**e2e**", + "**mock**" + ] +} \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-mysql/.dockerignore b/starters/apps/base-ts-gmail-mysql/.dockerignore new file mode 100644 index 000000000..3c5abc495 --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-ts-gmail-mysql/Dockerfile b/starters/apps/base-ts-gmail-mysql/Dockerfile new file mode 100644 index 000000000..5cc4e424e --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install && pnpm run build \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-ts-gmail-mysql/README.md b/starters/apps/base-ts-gmail-mysql/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-mysql/_gitignore b/starters/apps/base-ts-gmail-mysql/_gitignore new file mode 100644 index 000000000..b32acec90 --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-mysql/assets/sample.png b/starters/apps/base-ts-gmail-mysql/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-ts-gmail-mysql/assets/sample.png differ diff --git a/starters/apps/base-ts-gmail-mysql/eslint.config.js b/starters/apps/base-ts-gmail-mysql/eslint.config.js new file mode 100644 index 000000000..59f51f733 --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/eslint.config.js @@ -0,0 +1,25 @@ +import tseslint from 'typescript-eslint' +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + ...tseslint.configs.recommended, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-ts-gmail-mysql/nodemon.json b/starters/apps/base-ts-gmail-mysql/nodemon.json new file mode 100644 index 000000000..931698cc0 --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/nodemon.json @@ -0,0 +1,12 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": [ + "**/*.test.ts", + "**/*.spec.ts" + ], + "delay": "3", + "execMap": { + "ts": "tsx" + } + } \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-mysql/package.json b/starters/apps/base-ts-gmail-mysql/package.json new file mode 100644 index 000000000..f7a1aa0af --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/package.json @@ -0,0 +1,32 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "dist/app.js", + "type": "module", + "scripts": { + "start": "node ./dist/app.js", + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.ts", + "build": "npx rollup -c" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest", + "@builderbot/database-mysql": "latest" + }, + "devDependencies": { + "@types/node": "^25.0.0", + "typescript-eslint": "^8.0.0", + "eslint": "^9.0.0", + "eslint-plugin-builderbot": "latest", + "rollup": "^4.10.0", + "nodemon": "^3.1.11", + "rollup-plugin-typescript2": "^0.36.0", + "tsx": "^4.7.1", + "typescript": "^5.4.3" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-ts-gmail-mysql/rollup.config.js b/starters/apps/base-ts-gmail-mysql/rollup.config.js new file mode 100644 index 000000000..6de1ab0c2 --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/rollup.config.js @@ -0,0 +1,13 @@ +import typescript from 'rollup-plugin-typescript2' + +export default { + input: 'src/app.ts', + output: { + file: 'dist/app.js', + format: 'esm', + }, + onwarn: (warning) => { + if (warning.code === 'UNRESOLVED_IMPORT') return + }, + plugins: [typescript()], +} diff --git a/starters/apps/base-ts-gmail-mysql/src/app.ts b/starters/apps/base-ts-gmail-mysql/src/app.ts new file mode 100644 index 000000000..6aa5d2e8c --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/src/app.ts @@ -0,0 +1,135 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { MysqlAdapter as Database } from '@builderbot/database-mysql' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + const adapterDB = new Database({ + host: process.env.MYSQL_DB_HOST, + user: process.env.MYSQL_DB_USER, + database: process.env.MYSQL_DB_NAME, + password: process.env.MYSQL_DB_PASSWORD, + }) + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-ts-gmail-mysql/tsconfig.json b/starters/apps/base-ts-gmail-mysql/tsconfig.json new file mode 100644 index 000000000..dfa5d961e --- /dev/null +++ b/starters/apps/base-ts-gmail-mysql/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "declaration": false, + "declarationMap": false, + "moduleResolution": "node", + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "sourceMap": false, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./", + "incremental": true, + "skipLibCheck": true, + "paths": { + "~/*": ["./src/*"] + } + }, + "include": [ + "**/*.js", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**e2e**", + "**mock**" + ] +} \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-postgres/.dockerignore b/starters/apps/base-ts-gmail-postgres/.dockerignore new file mode 100644 index 000000000..3c5abc495 --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/.dockerignore @@ -0,0 +1,16 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +.git +.gitignore +Dockerfile* +npm-debug.log* +pnpm-debug.log* +tests +docs +*.md diff --git a/starters/apps/base-ts-gmail-postgres/Dockerfile b/starters/apps/base-ts-gmail-postgres/Dockerfile new file mode 100644 index 000000000..5cc4e424e --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/Dockerfile @@ -0,0 +1,40 @@ +# Image size ~ 400MB +FROM node:21-alpine3.18 as builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +COPY . . + +COPY package*.json *-lock.yaml ./ + +RUN apk add --no-cache --virtual .gyp \ + python3 \ + make \ + g++ \ + && apk add --no-cache git \ + && pnpm install && pnpm run build \ + && apk del .gyp + +FROM node:21-alpine3.18 as deploy + +WORKDIR /app + +ARG PORT +ENV PORT $PORT +EXPOSE $PORT + +COPY --from=builder /app/assets ./assets +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/*.json /app/*-lock.yaml ./ + +RUN corepack enable && corepack prepare pnpm@latest --activate +ENV PNPM_HOME=/usr/local/bin + +RUN npm cache clean --force && pnpm install --production --ignore-scripts \ + && addgroup -g 1001 -S nodejs && adduser -S -u 1001 nodejs \ + && rm -rf $PNPM_HOME/.npm $PNPM_HOME/.node-gyp + +CMD ["npm", "start"] diff --git a/starters/apps/base-ts-gmail-postgres/README.md b/starters/apps/base-ts-gmail-postgres/README.md new file mode 100644 index 000000000..ef1dd6c06 --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/README.md @@ -0,0 +1,44 @@ +

+ + + + +

BuilderBot

+ +

+ + + +

+ + + + + + +

+ + +## Getting Started + +With this library, you can build automated conversation flows agnostic to the WhatsApp provider, set up automated responses for frequently asked questions, receive and respond to messages automatically, and track interactions with customers. Additionally, you can easily set up triggers to expand functionalities limitlessly. + +``` +npm create builderbot@latest +``` + + +## Documentation + +Visit [builderbot](https://builderbot.app/) to view the full documentation. + + +## Official Course + +If you want to discover all the functions and features offered by the library you can take the course. +[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) + + +## Contact Us +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-postgres/_gitignore b/starters/apps/base-ts-gmail-postgres/_gitignore new file mode 100644 index 000000000..b32acec90 --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/_gitignore @@ -0,0 +1,10 @@ +dist/* +node_modules +.env +.pnpm-store +*_sessions +*tokens +.wwebjs* + +*.log +*qr.png \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-postgres/assets/sample.png b/starters/apps/base-ts-gmail-postgres/assets/sample.png new file mode 100644 index 000000000..de2737204 Binary files /dev/null and b/starters/apps/base-ts-gmail-postgres/assets/sample.png differ diff --git a/starters/apps/base-ts-gmail-postgres/eslint.config.js b/starters/apps/base-ts-gmail-postgres/eslint.config.js new file mode 100644 index 000000000..59f51f733 --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/eslint.config.js @@ -0,0 +1,25 @@ +import tseslint from 'typescript-eslint' +import builderbot from 'eslint-plugin-builderbot' + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'rollup.config.js'], + }, + ...tseslint.configs.recommended, + { + plugins: { + builderbot, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + ...builderbot.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-unsafe-optional-chaining': 'off', + }, + }, +] diff --git a/starters/apps/base-ts-gmail-postgres/nodemon.json b/starters/apps/base-ts-gmail-postgres/nodemon.json new file mode 100644 index 000000000..931698cc0 --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/nodemon.json @@ -0,0 +1,12 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": [ + "**/*.test.ts", + "**/*.spec.ts" + ], + "delay": "3", + "execMap": { + "ts": "tsx" + } + } \ No newline at end of file diff --git a/starters/apps/base-ts-gmail-postgres/package.json b/starters/apps/base-ts-gmail-postgres/package.json new file mode 100644 index 000000000..b2b6ab60a --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/package.json @@ -0,0 +1,32 @@ +{ + "name": "base-bailey-json", + "version": "1.0.0", + "description": "", + "main": "dist/app.js", + "type": "module", + "scripts": { + "start": "node ./dist/app.js", + "lint": "eslint . --no-ignore", + "dev": "npm run lint && nodemon --signal SIGKILL ./src/app.ts", + "build": "npx rollup -c" + }, + "keywords": [], + "dependencies": { + "@builderbot/bot": "latest", + "@builderbot/provider-gmail": "latest", + "@builderbot/database-postgres": "latest" + }, + "devDependencies": { + "@types/node": "^25.0.0", + "typescript-eslint": "^8.0.0", + "eslint": "^9.0.0", + "eslint-plugin-builderbot": "latest", + "rollup": "^4.10.0", + "nodemon": "^3.1.11", + "rollup-plugin-typescript2": "^0.36.0", + "tsx": "^4.7.1", + "typescript": "^5.4.3" + }, + "author": "", + "license": "ISC" +} diff --git a/starters/apps/base-ts-gmail-postgres/rollup.config.js b/starters/apps/base-ts-gmail-postgres/rollup.config.js new file mode 100644 index 000000000..6de1ab0c2 --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/rollup.config.js @@ -0,0 +1,13 @@ +import typescript from 'rollup-plugin-typescript2' + +export default { + input: 'src/app.ts', + output: { + file: 'dist/app.js', + format: 'esm', + }, + onwarn: (warning) => { + if (warning.code === 'UNRESOLVED_IMPORT') return + }, + plugins: [typescript()], +} diff --git a/starters/apps/base-ts-gmail-postgres/src/app.ts b/starters/apps/base-ts-gmail-postgres/src/app.ts new file mode 100644 index 000000000..e93c73b6e --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/src/app.ts @@ -0,0 +1,136 @@ +import { join } from 'path' +import { createBot, createProvider, createFlow, addKeyword, utils } from '@builderbot/bot' +import { PostgreSQLAdapter as Database } from '@builderbot/database-postgres' +import { GmailProvider as Provider } from '@builderbot/provider-gmail' + +const PORT = process.env.PORT ?? 3008 + +const discordFlow = addKeyword('doc').addAnswer( + ['You can see the documentation here', '📄 https://builderbot.app/docs \n', 'Do you want to continue? *yes*'].join( + '\n' + ), + { capture: true }, + async (ctx, { gotoFlow, flowDynamic }) => { + if (ctx.body.toLocaleLowerCase().includes('yes')) { + return gotoFlow(registerFlow) + } + await flowDynamic('Thanks!') + return + } +) + +const welcomeFlow = addKeyword(['hi', 'hello', 'hola']) + .addAnswer(`🙌 Hello welcome to this *Chatbot*`) + .addAnswer( + [ + 'I share with you the following links of interest about the project', + '👉 *doc* to view the documentation', + ].join('\n'), + { delay: 800, capture: true }, + async (ctx, { fallBack }) => { + if (!ctx.body.toLocaleLowerCase().includes('doc')) { + return fallBack('You should type *doc*') + } + return + }, + [discordFlow] + ) + +const registerFlow = addKeyword(utils.setEvent('REGISTER_FLOW')) + .addAnswer(`What is your name?`, { capture: true }, async (ctx, { state }) => { + await state.update({ name: ctx.body }) + }) + .addAnswer('What is your age?', { capture: true }, async (ctx, { state }) => { + await state.update({ age: ctx.body }) + }) + .addAction(async (_, { flowDynamic, state }) => { + await flowDynamic(`${state.get('name')}, thanks for your information!: Your age: ${state.get('age')}`) + }) + +const fullSamplesFlow = addKeyword(['samples', utils.setEvent('SAMPLES')]) + .addAnswer(`💪 I'll send you a lot files...`) + .addAnswer(`Send image from Local`, { media: join(process.cwd(), 'assets', 'sample.png') }) + .addAnswer(`Send video from URL`, { + media: 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYTJ0ZGdjd2syeXAwMjQ4aWdkcW04OWlqcXI3Ynh1ODkwZ25zZWZ1dCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LCohAb657pSdHv0Q5h/giphy.mp4', + }) + .addAnswer(`Send audio from URL`, { media: 'https://cdn.freesound.org/previews/728/728142_11861866-lq.mp3' }) + .addAnswer(`Send file from URL`, { + media: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + }) + +const main = async () => { + const adapterFlow = createFlow([welcomeFlow, registerFlow, fullSamplesFlow]) + const adapterProvider = createProvider(Provider, { + email: 'YOUR_EMAIL@gmail.com', + oauth2: { + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'YOUR_REFRESH_TOKEN' + } +}) + const adapterDB = new Database({ + host: process.env.POSTGRES_DB_HOST, + user: process.env.POSTGRES_DB_USER, + database: process.env.POSTGRES_DB_NAME, + password: process.env.POSTGRES_DB_PASSWORD, + port: +process.env.POSTGRES_DB_PORT + }) + + const { handleCtx, httpServer } = await createBot({ + flow: adapterFlow, + provider: adapterProvider, + database: adapterDB, + }) + + adapterProvider.server.post( + '/v1/messages', + handleCtx(async (bot, req, res) => { + const { number, message, urlMedia } = req.body + await bot.sendMessage(number, message, { media: urlMedia ?? null }) + return res.end('sended') + }) + ) + + adapterProvider.server.post( + '/v1/register', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('REGISTER_FLOW', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/samples', + handleCtx(async (bot, req, res) => { + const { number, name } = req.body + await bot.dispatch('SAMPLES', { from: number, name }) + return res.end('trigger') + }) + ) + + adapterProvider.server.post( + '/v1/blacklist', + handleCtx(async (bot, req, res) => { + const { number, intent } = req.body + if (intent === 'remove') bot.blacklist.remove(number) + if (intent === 'add') bot.blacklist.add(number) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', number, intent })) + }) + ) + + adapterProvider.server.get( + '/v1/blacklist/list', + handleCtx(async (bot, req, res) => { + const blacklist = bot.blacklist.getList() + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ status: 'ok', blacklist })) + }) + ) + + httpServer(+PORT) +} + +main() diff --git a/starters/apps/base-ts-gmail-postgres/tsconfig.json b/starters/apps/base-ts-gmail-postgres/tsconfig.json new file mode 100644 index 000000000..dfa5d961e --- /dev/null +++ b/starters/apps/base-ts-gmail-postgres/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "declaration": false, + "declarationMap": false, + "moduleResolution": "node", + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "sourceMap": false, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./", + "incremental": true, + "skipLibCheck": true, + "paths": { + "~/*": ["./src/*"] + } + }, + "include": [ + "**/*.js", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**e2e**", + "**mock**" + ] +} \ No newline at end of file