Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
improvement(sendblue): audit fixes — optional group numbers, seat_id, typing state/duration#5300
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
534b74e17bbe06116a0bb89d3885bb4ccaeFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -107,9 +107,9 @@ export const SendblueBlock: BlockConfig = { | ||
| id: 'numbers', | ||
| title: 'Recipient Numbers', | ||
| type: 'long-input', | ||
| placeholder: 'One phone number per line, e.g.\n+19998887777\n+13334445555', | ||
| placeholder: | ||
| 'One phone number per line, e.g.\n+19998887777\n+13334445555\n(optional when sending to an existing Group ID)', | ||
| condition: { field: 'operation', value: 'sendblue_send_group_message' }, | ||
| required: { field: 'operation', value: 'sendblue_send_group_message' }, | ||
| }, | ||
| { | ||
| id: 'content', | ||
| @@ -152,6 +152,17 @@ export const SendblueBlock: BlockConfig = { | ||
| mode: 'advanced', | ||
| condition: { field: 'operation', value: 'sendblue_send_group_message' }, | ||
| }, | ||
| { | ||
| id: 'seat_id', | ||
| title: 'Seat ID', | ||
| type: 'short-input', | ||
| placeholder: 'Seat UUID or Firebase Auth subject to attribute the message to', | ||
| mode: 'advanced', | ||
| condition: { | ||
| field: 'operation', | ||
| value: ['sendblue_send_message', 'sendblue_send_group_message'], | ||
| }, | ||
| }, | ||
| { | ||
| id: 'status_callback', | ||
| title: 'Status Callback URL', | ||
| @@ -163,6 +174,26 @@ export const SendblueBlock: BlockConfig = { | ||
| value: ['sendblue_send_message', 'sendblue_send_group_message'], | ||
| }, | ||
| }, | ||
| { | ||
| id: 'typing_state', | ||
| title: 'Typing State', | ||
| type: 'dropdown', | ||
| options: [ | ||
| { label: 'Start', id: 'start' }, | ||
| { label: 'Stop', id: 'stop' }, | ||
| ], | ||
| value: () => 'start', | ||
| mode: 'advanced', | ||
| condition: { field: 'operation', value: 'sendblue_send_typing_indicator' }, | ||
| }, | ||
| { | ||
| id: 'max_duration_ms', | ||
| title: 'Max Duration (ms)', | ||
| type: 'short-input', | ||
| placeholder: '60000 (1–300000)', | ||
| mode: 'advanced', | ||
| condition: { field: 'operation', value: 'sendblue_send_typing_indicator' }, | ||
| }, | ||
| { | ||
| id: 'message_id', | ||
| title: 'Message Handle / ID', | ||
| @@ -200,32 +231,46 @@ export const SendblueBlock: BlockConfig = { | ||
| content: params.content || undefined, | ||
| media_url: params.media_url || undefined, | ||
| send_style: params.send_style || undefined, | ||
| seat_id: params.seat_id || undefined, | ||
| status_callback: params.status_callback || undefined, | ||
| } | ||
| case 'sendblue_send_group_message': | ||
| case 'sendblue_send_group_message': { | ||
| const parsedNumbers = | ||
| typeof params.numbers === 'string' | ||
| ? params.numbers | ||
| .split('\n') | ||
| .map((n: string) => n.trim()) | ||
| .filter(Boolean) | ||
| : params.numbers | ||
| return { | ||
| ...base, | ||
| numbers: | ||
| typeof params.numbers === 'string' | ||
| ? params.numbers | ||
| .split('\n') | ||
| .map((n: string) => n.trim()) | ||
| .filter(Boolean) | ||
| : params.numbers, | ||
| Array.isArray(parsedNumbers) && parsedNumbers.length === 0 | ||
| ? undefined | ||
| : parsedNumbers, | ||
| from_number: params.from_number, | ||
| content: params.content || undefined, | ||
| media_url: params.media_url || undefined, | ||
| send_style: params.send_style || undefined, | ||
| seat_id: params.seat_id || undefined, | ||
| group_id: params.group_id || undefined, | ||
| status_callback: params.status_callback || undefined, | ||
| } | ||
| } | ||
| case 'sendblue_evaluate_service': | ||
| return { ...base, number: params.number } | ||
| case 'sendblue_send_typing_indicator': | ||
| return { | ||
| ...base, | ||
| number: params.number, | ||
| from_number: params.from_number || undefined, | ||
| state: params.typing_state || undefined, | ||
| max_duration_ms: | ||
| params.max_duration_ms !== undefined && | ||
| params.max_duration_ms !== '' && | ||
| Number.isFinite(Number(params.max_duration_ms)) | ||
| ? Number(params.max_duration_ms) | ||
| : undefined, | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| case 'sendblue_get_message': | ||
| return { ...base, message_id: params.message_id } | ||
| @@ -247,7 +292,13 @@ export const SendblueBlock: BlockConfig = { | ||
| media_url: { type: 'string', description: 'URL of media to send' }, | ||
| send_style: { type: 'string', description: 'iMessage expressive style' }, | ||
| group_id: { type: 'string', description: 'Existing group ID' }, | ||
| seat_id: { type: 'string', description: 'Seat (user) the message is attributed to' }, | ||
| status_callback: { type: 'string', description: 'Status callback webhook URL' }, | ||
| typing_state: { type: 'string', description: 'Typing indicator state (start or stop)' }, | ||
| max_duration_ms: { | ||
| type: 'number', | ||
| description: 'Typing indicator max visible duration in milliseconds', | ||
| }, | ||
| message_id: { type: 'string', description: 'Message handle/ID to retrieve' }, | ||
| }, | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| /** | ||
| * @vitest-environment node | ||
| */ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { sendblueHandler } from '@/lib/webhooks/providers/sendblue' | ||
| const inboundBody = { | ||
| accountEmail: 'me@example.com', | ||
| content: 'hello', | ||
| media_url: '', | ||
| is_outbound: false, | ||
| status: 'RECEIVED', | ||
| message_handle: 'handle-123', | ||
| from_number: '+19998887777', | ||
| number: '+18887776666', | ||
| group_id: '', | ||
| } | ||
| const outboundBody = { | ||
| ...inboundBody, | ||
| is_outbound: true, | ||
| status: 'SENT', | ||
| } | ||
| describe('sendblueHandler', () => { | ||
| describe('matchEvent', () => { | ||
| it('matches an inbound message for the message_received trigger', () => { | ||
| expect( | ||
| sendblueHandler.matchEvent!({ | ||
| body: inboundBody, | ||
| webhook: { providerConfig: { triggerId: 'sendblue_message_received' } }, | ||
| requestId: 'r1', | ||
| } as any) | ||
| ).toBe(true) | ||
| }) | ||
| it('rejects an outbound event for the message_received trigger', () => { | ||
| expect( | ||
| sendblueHandler.matchEvent!({ | ||
| body: outboundBody, | ||
| webhook: { providerConfig: { triggerId: 'sendblue_message_received' } }, | ||
| requestId: 'r1', | ||
| } as any) | ||
| ).toBe(false) | ||
| }) | ||
| it('matches an outbound status update for the message_status_updated trigger', () => { | ||
| expect( | ||
| sendblueHandler.matchEvent!({ | ||
| body: outboundBody, | ||
| webhook: { providerConfig: { triggerId: 'sendblue_message_status_updated' } }, | ||
| requestId: 'r1', | ||
| } as any) | ||
| ).toBe(true) | ||
| }) | ||
| it('passes through when the triggerId is unknown or unset', () => { | ||
| expect( | ||
| sendblueHandler.matchEvent!({ | ||
| body: inboundBody, | ||
| webhook: {}, | ||
| requestId: 'r1', | ||
| } as any) | ||
| ).toBe(true) | ||
| }) | ||
| it('rejects a non-object payload for a known trigger', () => { | ||
| expect( | ||
| sendblueHandler.matchEvent!({ | ||
| body: 'not-an-object', | ||
| webhook: { providerConfig: { triggerId: 'sendblue_message_received' } }, | ||
| requestId: 'r1', | ||
| } as any) | ||
| ).toBe(false) | ||
| }) | ||
| }) | ||
| describe('extractIdempotencyId', () => { | ||
| it('uses the message handle alone when no status is present', () => { | ||
| expect(sendblueHandler.extractIdempotencyId!({ message_handle: 'handle-123' })).toBe( | ||
| 'handle-123' | ||
| ) | ||
| }) | ||
| it('suffixes the status so SENT and DELIVERED on one handle stay distinct', () => { | ||
| expect( | ||
| sendblueHandler.extractIdempotencyId!({ message_handle: 'handle-123', status: 'DELIVERED' }) | ||
| ).toBe('handle-123:DELIVERED') | ||
| }) | ||
| it('returns null when no message handle is present', () => { | ||
| expect(sendblueHandler.extractIdempotencyId!({})).toBeNull() | ||
| expect(sendblueHandler.extractIdempotencyId!('nope')).toBeNull() | ||
| }) | ||
| }) | ||
| describe('formatInput', () => { | ||
| it('returns the payload under input with empty strings normalized to null', async () => { | ||
| const result = await sendblueHandler.formatInput!({ body: inboundBody } as any) | ||
| expect(result.input.account_email).toBe('me@example.com') | ||
| expect(result.input.media_url).toBeNull() | ||
| expect(result.input.group_id).toBeNull() | ||
| expect(result.input.is_outbound).toBe(false) | ||
| expect(result.input.participants).toEqual([]) | ||
| expect(result.input.raw).toBe(JSON.stringify(inboundBody)) | ||
| }) | ||
| it('defaults missing fields to null and tolerates a non-object body', async () => { | ||
| const result = await sendblueHandler.formatInput!({ body: undefined } as any) | ||
| expect(result.input.message_handle).toBeNull() | ||
| expect(result.input.content).toBeNull() | ||
| expect(result.input.participants).toEqual([]) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -23,10 +23,10 @@ export const sendblueSendGroupMessageTool: ToolConfig< | ||
| ...sendblueBaseParamFields, | ||
| numbers: { | ||
| type: 'array', | ||
| required: true, | ||
| required: false, | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| visibility: 'user-or-llm', | ||
| description: | ||
| 'Recipient phone numbers in E.164 format (e.g., ["+19998887777", "+13334445555"])', | ||
| 'Recipient phone numbers in E.164 format (e.g., ["+19998887777", "+13334445555"]). Optional when sending to an existing group via group_id.', | ||
| items: { type: 'string', description: 'Phone number in E.164 format' }, | ||
| }, | ||
| from_number: { | ||
| @@ -55,6 +55,13 @@ export const sendblueSendGroupMessageTool: ToolConfig< | ||
| description: | ||
| 'iMessage expressive style (e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam).', | ||
| }, | ||
| seat_id: { | ||
| type: 'string', | ||
| required: false, | ||
| visibility: 'user-or-llm', | ||
| description: | ||
| 'Seat (user) the message is attributed to. Accepts the seat UUID or Firebase Auth subject.', | ||
| }, | ||
| group_id: { | ||
| type: 'string', | ||
| required: false, | ||
| @@ -73,16 +80,28 @@ export const sendblueSendGroupMessageTool: ToolConfig< | ||
| url: `${SENDBLUE_API_BASE_URL}/api/send-group-message`, | ||
| method: 'POST', | ||
| headers: (params) => sendblueHeaders(params), | ||
| body: (params) => | ||
| filterUndefined({ | ||
| numbers: params.numbers, | ||
| body: (params) => { | ||
| const numbers = Array.isArray(params.numbers) | ||
| ? params.numbers.map((n) => n.trim()).filter(Boolean) | ||
| : undefined | ||
| const hasNumbers = numbers !== undefined && numbers.length > 0 | ||
| const hasGroupId = typeof params.group_id === 'string' && params.group_id.trim().length > 0 | ||
| if (!hasNumbers && !hasGroupId) { | ||
| throw new Error( | ||
| 'Provide either "numbers" to start a new group or "group_id" to message an existing group.' | ||
| ) | ||
| } | ||
| return filterUndefined({ | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| numbers: hasNumbers ? numbers : undefined, | ||
| from_number: params.from_number, | ||
| content: params.content, | ||
| media_url: params.media_url, | ||
| send_style: params.send_style, | ||
| group_id: params.group_id, | ||
| seat_id: params.seat_id, | ||
| group_id: hasGroupId ? params.group_id?.trim() : undefined, | ||
| status_callback: params.status_callback, | ||
| }), | ||
| }) | ||
| }, | ||
| }, | ||
| transformResponse: async (response) => { | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.