-
Notifications
You must be signed in to change notification settings - Fork 4.2k
feat(providers): add Maqsam WhatsApp chat provider #9463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { MaqsamWhatsAppProvider } from '@novu/providers'; | ||
| import { ChannelTypeEnum, ChatProviderIdEnum, ICredentials } from '@novu/shared'; | ||
| import { BaseChatHandler } from './base.handler'; | ||
|
|
||
| export class MaqsamWhatsAppHandler extends BaseChatHandler { | ||
| constructor() { | ||
| super(ChatProviderIdEnum.MaqsamWhatsApp, ChannelTypeEnum.CHAT); | ||
| } | ||
|
|
||
| buildProvider(credentials: ICredentials) { | ||
| const config: { accessKey: string, accessSecret: string } = { accessKey: credentials.accessKey, accessSecret: credentials.accessSecret }; | ||
|
|
||
| this.provider = new MaqsamWhatsAppProvider(config); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import { IChatOptions } from '@novu/stateless'; | ||
| import { nanoid } from 'nanoid'; | ||
| import { expect, test } from 'vitest'; | ||
| import { axiosSpy } from '../../../utils/test/spy-axios'; | ||
| import { MaqsamWhatsAppProvider } from './maqsam-whatsapp.provider'; | ||
|
|
||
| const mockProviderConfig = { | ||
| accessKey: 'my-access-key', | ||
| accessSecret: 'my-access-secret', | ||
| }; | ||
|
|
||
| const buildResponse = (messageId: string) => { | ||
| return { | ||
| data: { | ||
| message_id: messageId, | ||
| message_status: 'submitted', | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| test('should trigger maqsam library correctly with template message', async () => { | ||
| const messageId = nanoid(); | ||
|
|
||
| const { mockPost, axiosMockSpy } = axiosSpy(buildResponse(messageId)); | ||
|
|
||
| const provider = new MaqsamWhatsAppProvider(mockProviderConfig); | ||
|
|
||
| const options: IChatOptions = { | ||
| phoneNumber: '+111111111', | ||
| content: 'Template message', | ||
| customData: { | ||
| templateId: '123', | ||
| templateVariables: { | ||
| name: 'John', | ||
| company: 'Acme', | ||
| }, | ||
| }, | ||
| }; | ||
|
Comment on lines
+28
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: Test uses deprecated Lines 29 and 44 use the deprecated 🔎 Corrected test structureAlso applies to: 40-47
<details>
<summary>🤖 Prompt for AI Agents</summary>
In |
||
|
|
||
| const res = await provider.sendMessage(options); | ||
|
|
||
| expect(mockPost).toHaveBeenCalled(); | ||
| expect(mockPost).toHaveBeenCalledWith('https://api.maqsam.com/v2/whatsapp/messages/send_message', { | ||
| RecipientPhone: options.phoneNumber, | ||
| TemplateId: options.customData?.templateId, | ||
| TemplateVariables: options.customData?.templateVariables, | ||
| }); | ||
|
|
||
| const token = Buffer.from(`${mockProviderConfig.accessKey}:${mockProviderConfig.accessSecret}`).toString('base64'); | ||
|
|
||
| expect(axiosMockSpy).toHaveBeenCalledWith({ | ||
| headers: { | ||
| Authorization: `Basic ${token}`, | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| }, | ||
| }); | ||
|
|
||
| expect(res.id).toBe(messageId); | ||
| }); | ||
|
|
||
| test('should trigger maqsam library correctly with template message with _passthrough', async () => { | ||
| const messageId = nanoid(); | ||
|
|
||
| const { mockPost, axiosMockSpy } = axiosSpy(buildResponse(messageId)); | ||
|
|
||
| const provider = new MaqsamWhatsAppProvider(mockProviderConfig); | ||
|
|
||
| const options: IChatOptions = { | ||
| phoneNumber: '+111111111', | ||
| content: 'Template message', | ||
| customData: { | ||
| templateId: '123', | ||
| templateVariables: { | ||
| name: 'John', | ||
| company: 'Acme', | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| const res = await provider.sendMessage(options, { | ||
| _passthrough: { | ||
| query: { | ||
| RecipientPhone: '+111111111', | ||
| TemplateId: '123', | ||
| TemplateVariables: JSON.stringify({ | ||
| name: 'John', | ||
| company: 'Acme', | ||
| }), | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| expect(mockPost).toHaveBeenCalled(); | ||
| expect(mockPost).toHaveBeenCalledWith('https://api.maqsam.com/v2/whatsapp/messages/send_message', { | ||
| RecipientPhone: '+111111111', | ||
| TemplateId: '123', | ||
| TemplateVariables: { | ||
| name: 'John', | ||
| company: 'Acme', | ||
| }, | ||
| }); | ||
|
|
||
| const token = Buffer.from(`${mockProviderConfig.accessKey}:${mockProviderConfig.accessSecret}`).toString('base64'); | ||
|
|
||
| expect(axiosMockSpy).toHaveBeenCalledWith({ | ||
| headers: { | ||
| Authorization: `Basic ${token}`, | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| }, | ||
| }); | ||
|
|
||
| expect(res.id).toBe(messageId); | ||
| }); | ||
|
|
||
| test('should throw error when message status is not submitted', async () => { | ||
| const messageId = nanoid(); | ||
|
|
||
| axiosSpy({ | ||
| data: { | ||
| message_id: messageId, | ||
| message_status: 'failed', | ||
| }, | ||
| }); | ||
|
|
||
| const provider = new MaqsamWhatsAppProvider(mockProviderConfig); | ||
|
|
||
| const options: IChatOptions = { | ||
| phoneNumber: '+111111111', | ||
| content: 'Template message', | ||
| customData: { | ||
| templateId: '123', | ||
| templateVariables: { | ||
| name: 'John', | ||
| company: 'Acme', | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| await expect(provider.sendMessage(options)).rejects.toThrow('Maqsam Chat failed:'); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { | ||
| ChannelTypeEnum, | ||
| IChatOptions, | ||
| IChatProvider, | ||
| ISendMessageSuccessResponse, | ||
| PhoneData, | ||
| } from "@novu/stateless"; | ||
| import Axios, { AxiosInstance } from 'axios'; | ||
| import { BaseProvider, CasingEnum } from "../../../base.provider"; | ||
| import { WithPassthrough } from "../../../utils/types"; | ||
| import { ISendMessageFailureRes, ISendMessageRes } from "./types/maqsam-whatsapp.types"; | ||
|
|
||
| export class MaqsamWhatsAppProvider extends BaseProvider implements IChatProvider { | ||
| id = "maqsam-whatsapp"; | ||
| channelType = ChannelTypeEnum.CHAT as ChannelTypeEnum.CHAT; | ||
| protected casing: CasingEnum = CasingEnum.CAMEL_CASE; | ||
|
|
||
| private readonly axiosClient: AxiosInstance; | ||
| private readonly baseUrl = 'https://api.maqsam.com/v2/whatsapp/messages/send_message'; | ||
|
|
||
| constructor(private config: { | ||
| accessKey: string; | ||
| accessSecret: string; | ||
| }) { | ||
| super(); | ||
|
|
||
| const token = Buffer.from(`${this.config.accessKey}:${this.config.accessSecret}`).toString('base64'); | ||
|
|
||
| this.axiosClient = Axios.create({ | ||
| headers: { | ||
| Authorization: `Basic ${token}`, | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
|
Comment on lines
+30
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's examine the maqsam-whatsapp provider file
cat -n packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.tsRepository: novuhq/novu Length of output: 2572 🏁 Script executed: # Find the BaseProvider class and its transform method
fd -t f "base.provider" | head -20Repository: novuhq/novu Length of output: 148 🏁 Script executed: # Examine the BaseProvider file to find the transform method
cat -n packages/providers/src/base.provider.tsRepository: novuhq/novu Length of output: 5436 🏁 Script executed: # Search for URL encoding logic and axios transformers in the maqsam whatsapp provider
rg -n "URLSearchParams|urlencoded|qs\.|stringify" packages/providers/src/lib/chat/maqsam-whatsapp/ -A 3 -B 3
# Also check if there are test files
find packages/providers -path "*maqsam*" -type f | grep -i testRepository: novuhq/novu Length of output: 3691 🏁 Script executed: # Examine the test file to understand the expected behavior
cat -n packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.spec.tsRepository: novuhq/novu Length of output: 4728 🏁 Script executed: # Find the axiosSpy utility
find packages/providers -path "*test*" -name "*spy*axios*" -type fRepository: novuhq/novu Length of output: 101 🏁 Script executed: # Examine the axiosSpy utility
cat -n packages/providers/src/utils/test/spy-axios.tsRepository: novuhq/novu Length of output: 1380 🏁 Script executed: # Verify if axios has any custom transformers or if there's URL encoding elsewhere
# Check the axios client creation in maqsam-whatsapp.provider.ts more carefully
grep -A 10 "Axios.create" packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.ts
# Check if URLSearchParams or qs is installed
find packages/providers -name "package.json" | xargs grep -l "qs\|query-string"Repository: novuhq/novu Length of output: 433 🏁 Script executed: # Check dependencies in providers package.json
cat packages/providers/package.json | grep -A 50 "dependencies"Repository: novuhq/novu Length of output: 1589 🏁 Script executed: # Verify that qs is not imported or used in maqsam-whatsapp files
rg -n "import.*qs|from.*qs" packages/providers/src/lib/chat/maqsam-whatsapp/Repository: novuhq/novu Length of output: 374 🏁 Script executed: # Final verification: Check actual axios default behavior documentation
# by looking at how other providers in the codebase handle form-urlencoded
rg -n "application/x-www-form-urlencoded" packages/providers/src/lib/ -B 5 -A 5Repository: novuhq/novu Length of output: 7106 Serialize payload to form-urlencoded format before sending. The Content-Type header is set to Use 🤖 Prompt for AI Agents |
||
| }, | ||
| }); | ||
| } | ||
|
|
||
| private defineMessagePayload(options: IChatOptions) { | ||
| return { | ||
| RecipientPhone: (options.channelData as PhoneData).endpoint.phoneNumber, | ||
| TemplateId: options.customData?.templateId, | ||
| TemplateVariables: options.customData?.templateVariables, | ||
| } | ||
| } | ||
|
Comment on lines
+37
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add validation for channelData and required customData properties. Line 39 performs an unsafe type cast without validating that Lines 40-41 use optional chaining for 🔎 Proposed fix with validation private defineMessagePayload(options: IChatOptions) {
+ if (!options.channelData || options.channelData.type !== 'phone') {
+ throw new Error('Phone number is required in channelData for Maqsam WhatsApp');
+ }
+
+ if (!options.customData?.templateId) {
+ throw new Error('templateId is required in customData for Maqsam WhatsApp');
+ }
+
+ const phoneData = options.channelData as PhoneData;
+
return {
- RecipientPhone: (options.channelData as PhoneData).endpoint.phoneNumber,
+ RecipientPhone: phoneData.endpoint.phoneNumber,
TemplateId: options.customData?.templateId,
TemplateVariables: options.customData?.templateVariables,
}
}
🤖 Prompt for AI Agents |
||
|
|
||
| async sendMessage( | ||
| options: IChatOptions, | ||
| bridgeProviderData: WithPassthrough<Record<string, unknown>> = {}, | ||
| ): Promise<ISendMessageSuccessResponse> { | ||
| const payload = this.transform(bridgeProviderData, this.defineMessagePayload(options)); | ||
|
|
||
| const { data } = await this.axiosClient.post<ISendMessageRes | ISendMessageFailureRes>(this.baseUrl, | ||
| payload.body, | ||
| ); | ||
|
|
||
| if ('conversationId' in data && data.conversationId) { | ||
| return { | ||
| id: data.conversationId, | ||
| date: new Date().toISOString(), | ||
| }; | ||
| } | ||
|
|
||
| throw new Error(`Maqsam Chat failed: ${JSON.stringify(data || {})}`); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,10 @@ | ||||||||||||||||||||||
| export interface ISendMessageRes { | ||||||||||||||||||||||
| conversationId: string; | ||||||||||||||||||||||
| messageStatus: string; | ||||||||||||||||||||||
| result: string; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+1
to
+5
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -path "*maqsam*whatsapp*" -name "*.ts" -o -path "*maqsam*whatsapp*" -name "*.json" | head -20Repository: novuhq/novu Length of output: 296 🏁 Script executed: # Get the full path to the types file mentioned in the review
find . -type f -path "*maqsam-whatsapp.types.ts"Repository: novuhq/novu Length of output: 135 🏁 Script executed: # Search for provider file
find . -type f -path "*maqsam-whatsapp.provider.ts"Repository: novuhq/novu Length of output: 132 🏁 Script executed: # Search for test files related to maqsam
find . -type f -path "*maqsam*" -name "*.spec.ts" -o -path "*maqsam*" -name "*.test.ts"Repository: novuhq/novu Length of output: 201 🏁 Script executed: cat -n ./packages/providers/src/lib/chat/maqsam-whatsapp/types/maqsam-whatsapp.types.tsRepository: novuhq/novu Length of output: 309 🏁 Script executed: cat -n ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.tsRepository: novuhq/novu Length of output: 2572 🏁 Script executed: cat -n ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.spec.tsRepository: novuhq/novu Length of output: 4728 🏁 Script executed: find . -type f -name "base.provider.ts" | head -5Repository: novuhq/novu Length of output: 96 🏁 Script executed: # Also search for how CasingEnum.CAMEL_CASE is used
rg "CasingEnum" -A 5 -B 5 --type ts | head -100Repository: novuhq/novu Length of output: 9418 🏁 Script executed: cat -n ./packages/providers/src/base.provider.ts | head -150Repository: novuhq/novu Length of output: 5436 🏁 Script executed: # Search for the transform method in base provider
rg "transform\(" -A 20 ./packages/providers/src/base.provider.ts | head -80Repository: novuhq/novu Length of output: 37 🏁 Script executed: # Check if there's any transformation happening in axiosSpy or if tests are mocking correctly
rg "axiosSpy" -B 10 -A 10 ./packages/providers/src/utils/test/spy-axios.ts 2>/dev/null | head -100Repository: novuhq/novu Length of output: 595 🏁 Script executed: # Verify the actual flow - does the response data get transformed anywhere?
rg "casingTransform\|transform(" ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.tsRepository: novuhq/novu Length of output: 159 🏁 Script executed: # Check if there's any middleware or interceptor that might transform response data
rg "interceptor\|response" ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.tsRepository: novuhq/novu Length of output: 37 🏁 Script executed: # Check what the response flow is - is data transformed after axios call?
rg "casingTransform" ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.tsRepository: novuhq/novu Length of output: 37 🏁 Script executed: # Look at how axios response is handled - does it go through any transformation?
rg "axiosClient.post" -A 5 ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.tsRepository: novuhq/novu Length of output: 262 🏁 Script executed: # Let me check the actual transformation flow more carefully
# Look at line 49 - where transform is called
sed -n '45,65p' ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.tsRepository: novuhq/novu Length of output: 687 🏁 Script executed: # Let me verify my understanding - check if axiosSpy does any transformation or just returns data as-is
cat -n ./packages/providers/src/utils/test/spy-axios.tsRepository: novuhq/novu Length of output: 1380 🏁 Script executed: # Verify the critical path - does the test actually pass?
# Let me check if there are any recent test results or CI logs
# First, let me see what the test framework is and if we can understand test behavior
# Let's trace through the logic one more time:
# 1. Mock returns: { message_id, message_status }
# 2. Provider checks: 'conversationId' in data
# 3. This check should fail
# Let me verify by looking at another provider to see how they handle responses
find . -type f -path "*provider.spec.ts" -type f | head -3 | xargs -I {} bash -c 'echo "=== {} ===" && cat -n {} | head -80'Repository: novuhq/novu Length of output: 7371 🏁 Script executed: # Let me check if maybe the transform is being applied to responses too
# by searching for any response transformation in base provider
rg "response\|data" ./packages/providers/src/base.provider.ts | grep -i "transform\|case"Repository: novuhq/novu Length of output: 37 🏁 Script executed: # Critical check - does the response transformation happen somewhere I missed?
# Let me trace the exact flow: what does casingTransform do to the RESPONSE?
# First, let me check if there are any response interceptors in axios config
grep -r "interceptor" ./packages/providers/src/lib/chat/maqsam-whatsapp/Repository: novuhq/novu Length of output: 37 🏁 Script executed: # Let me also verify if there's any middleware transforming responses
# by searching for any response transformation in similar providers
rg "const { data }" ./packages/providers/src/lib/chat/ -A 3 | head -60Repository: novuhq/novu Length of output: 159 🏁 Script executed: # Most importantly - let me trace the actual test execution path
# The test mocks the response to have message_id, but provider checks for conversationId
# This should fail, but the test expects it to pass. Let me verify this is indeed the case:
sed -n '12,19p' ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.spec.tsRepository: novuhq/novu Length of output: 203 🏁 Script executed: # Show the test assertion that expects this to succeed
sed -n '58,58p' ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.spec.tsRepository: novuhq/novu Length of output: 88 🏁 Script executed: # Let me verify one more critical thing - is the test actually passing or failing?
# Let me check if there are any CI test results or if I can understand what should happen
# The key question: when mock returns { message_id }, does provider.sendMessage() succeed?
# Based on the code, it should FAIL because:
# 1. Mock: { message_id: 'xyz' }
# 2. Provider checks: 'conversationId' in data → FALSE
# 3. Provider throws error
# So test line 58 (expect(res.id).toBe(messageId)) should FAIL
# Let me verify by checking if there's any response transformation I missed
# by looking at the complete flow one more time in the provider
cat -n ./packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.ts | sed -n '45,63p'Repository: novuhq/novu Length of output: 818 🏁 Script executed: # Also check - does BaseProvider have any response transformer?
grep -n "response\|Response" ./packages/providers/src/base.provider.tsRepository: novuhq/novu Length of output: 37 🏁 Script executed: # Final verification - check if axios.post response is transformed somewhere
# by searching for any response interceptors or transformers in the entire provider
rg "transformResponse\|responseTransformer" ./packages/providers/src/Repository: novuhq/novu Length of output: 37 Critical: Response field mismatch causes provider failure. The The interface must be updated to match the actual API response: export interface ISendMessageRes {
- conversationId: string;
+ messageId: string;
messageStatus: string;
result: string;
}And the provider check accordingly (in -if ('conversationId' in data && data.conversationId) {
+if ('messageId' in data && data.messageId) {
return {
- id: data.conversationId,
+ id: data.messageId,
date: new Date().toISOString(),
};
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export interface ISendMessageFailureRes { | ||||||||||||||||||||||
| error: string | ||||||||||||||||||||||
| message: string | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add credential validation to prevent runtime errors.
The
buildProvidermethod doesn't validate thatcredentials.accessKeyandcredentials.accessSecretare defined before passing them to the provider. If either credential is missing, the provider would create an invalid Basic auth token like"dW5kZWZpbmVkOnVuZGVmaW5lZA=="(base64 of "undefined:undefined"), causing API authentication failures.🔎 Recommended validation
buildProvider(credentials: ICredentials) { + if (!credentials.accessKey || !credentials.accessSecret) { + throw new Error('Maqsam WhatsApp provider requires both accessKey and accessSecret'); + } + const config: { accessKey: string, accessSecret: string } = { accessKey: credentials.accessKey, accessSecret: credentials.accessSecret }; this.provider = new MaqsamWhatsAppProvider(config); }Alternatively, use non-null assertions if validation happens upstream:
🤖 Prompt for AI Agents