diff --git a/apps/docs/content/docs/en/integrations/sendblue.mdx b/apps/docs/content/docs/en/integrations/sendblue.mdx index ca03c545710..d45590a7b31 100644 --- a/apps/docs/content/docs/en/integrations/sendblue.mdx +++ b/apps/docs/content/docs/en/integrations/sendblue.mdx @@ -55,6 +55,7 @@ Send an iMessage or SMS to a single recipient via Sendblue. | `content` | string | No | Message text content. Either content or media_url must be provided. | | `media_url` | string | No | URL of a media file to send. Either content or media_url must be provided. | | `send_style` | string | No | iMessage expressive style \(e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam\). | +| `seat_id` | string | No | Seat \(user\) the message is attributed to. Accepts the seat UUID or Firebase Auth subject. | | `status_callback` | string | No | Webhook URL that Sendblue will POST message status updates to. | #### Output @@ -85,11 +86,12 @@ Send an iMessage or SMS to a group of recipients via Sendblue. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `numbers` | array | Yes | Recipient phone numbers in E.164 format \(e.g., \["+19998887777", "+13334445555"\]\) | +| `numbers` | array | No | Recipient phone numbers in E.164 format \(e.g., \["+19998887777", "+13334445555"\]\). Optional when sending to an existing group via group_id. | | `from_number` | string | Yes | One of your registered Sendblue phone numbers to send from, in E.164 format \(e.g., +18887776666\) | | `content` | string | No | Message text content. Either content or media_url must be provided. | | `media_url` | string | No | URL of a media file to send. Either content or media_url must be provided. | | `send_style` | string | No | iMessage expressive style \(e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam\). | +| `seat_id` | string | No | Seat \(user\) the message is attributed to. Accepts the seat UUID or Firebase Auth subject. | | `group_id` | string | No | Unique identifier of an existing group to send to. Omit to start a new group. | | `status_callback` | string | No | Webhook URL that Sendblue will POST message status updates to. | @@ -142,6 +144,8 @@ Display a typing indicator to a recipient (not supported in group chats). | --------- | ---- | -------- | ----------- | | `number` | string | Yes | Recipient's phone number in E.164 format \(e.g., +19998887777\) | | `from_number` | string | No | Your Sendblue line number to send from, in E.164 format. | +| `state` | string | No | "start" \(default\) shows the indicator; "stop" ends an active indicator before max_duration_ms expires. | +| `max_duration_ms` | number | No | How long \(ms\) the indicator stays visible before auto-stopping. Defaults to 60000. Must be between 1 and 300000. | #### Output @@ -226,7 +230,7 @@ Trigger when an inbound iMessage or SMS is received in Sendblue | `was_downgraded` | boolean | True if the recipient lacks iMessage support | | `plan` | string | Account plan type | | `message_type` | string | Message category \(e.g., message, group\) | -| `group_id` | string | Group identifier, empty for non-group messages | +| `group_id` | string | Group identifier, null for non-group messages | | `participants` | array | Participant phone numbers for group messages | | `send_style` | string | Expressive style if applied | | `opted_out` | boolean | True if the recipient has opted out | @@ -266,7 +270,7 @@ Trigger when an outbound message status changes (SENT, DELIVERED, ERROR) in Send | `was_downgraded` | boolean | True if the recipient lacks iMessage support | | `plan` | string | Account plan type | | `message_type` | string | Message category \(e.g., message, group\) | -| `group_id` | string | Group identifier, empty for non-group messages | +| `group_id` | string | Group identifier, null for non-group messages | | `participants` | array | Participant phone numbers for group messages | | `send_style` | string | Expressive style if applied | | `opted_out` | boolean | True if the recipient has opted out | diff --git a/apps/sim/blocks/blocks/sendblue.ts b/apps/sim/blocks/blocks/sendblue.ts index 3e558cae9dd..832659bc8ae 100644 --- a/apps/sim/blocks/blocks/sendblue.ts +++ b/apps/sim/blocks/blocks/sendblue.ts @@ -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,25 +231,32 @@ 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': @@ -226,6 +264,13 @@ export const SendblueBlock: BlockConfig = { ...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, } 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' }, }, diff --git a/apps/sim/lib/webhooks/providers/sendblue.test.ts b/apps/sim/lib/webhooks/providers/sendblue.test.ts new file mode 100644 index 00000000000..ca82c47917d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/sendblue.test.ts @@ -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([]) + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/sendblue.ts b/apps/sim/lib/webhooks/providers/sendblue.ts index 0384f27fcd3..ec9d700078c 100644 --- a/apps/sim/lib/webhooks/providers/sendblue.ts +++ b/apps/sim/lib/webhooks/providers/sendblue.ts @@ -22,6 +22,18 @@ const SENDBLUE_TRIGGER_IS_OUTBOUND: Record = { sendblue_message_status_updated: true, } +/** + * Sendblue webhook handler. + * + * No `verifyAuth` is implemented: Sendblue supports an optional per-webhook + * `secret`/`globalSecret` that it "includes in the webhook request headers," + * but the official docs never name the header or specify whether the value is + * a plain token echo or an HMAC signature. Implementing verification today + * would require guessing the header name, so it is deferred. When Sendblue + * documents the scheme, wire `verifyTokenAuth` (plain token) or + * `createHmacVerifier` (HMAC) from `@/lib/webhooks/providers/utils` and add a + * secret sub-block to the block definition. + */ export const sendblueHandler: WebhookProviderHandler = { matchEvent({ body, webhook, requestId }: EventMatchContext): boolean { const providerConfig = getProviderConfig(webhook) @@ -60,7 +72,7 @@ export const sendblueHandler: WebhookProviderHandler = { input: { account_email: b.accountEmail ?? b.account_email ?? null, content: b.content ?? null, - media_url: b.media_url ?? null, + media_url: (typeof b.media_url === 'string' && b.media_url) || null, is_outbound: b.is_outbound ?? null, status: b.status ?? null, error_code: b.error_code ?? null, @@ -76,7 +88,7 @@ export const sendblueHandler: WebhookProviderHandler = { was_downgraded: b.was_downgraded ?? null, plan: b.plan ?? null, message_type: b.message_type ?? null, - group_id: b.group_id ?? null, + group_id: (typeof b.group_id === 'string' && b.group_id) || null, participants: b.participants ?? [], send_style: b.send_style ?? null, opted_out: b.opted_out ?? null, diff --git a/apps/sim/tools/sendblue/send_group_message.ts b/apps/sim/tools/sendblue/send_group_message.ts index ebc5582af93..ee58c6f8c62 100644 --- a/apps/sim/tools/sendblue/send_group_message.ts +++ b/apps/sim/tools/sendblue/send_group_message.ts @@ -23,10 +23,10 @@ export const sendblueSendGroupMessageTool: ToolConfig< ...sendblueBaseParamFields, numbers: { type: 'array', - required: true, + required: false, 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({ + 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) => { diff --git a/apps/sim/tools/sendblue/send_message.ts b/apps/sim/tools/sendblue/send_message.ts index 325382da019..353db97a0ae 100644 --- a/apps/sim/tools/sendblue/send_message.ts +++ b/apps/sim/tools/sendblue/send_message.ts @@ -50,6 +50,13 @@ export const sendblueSendMessageTool: 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.', + }, status_callback: { type: 'string', required: false, @@ -69,6 +76,7 @@ export const sendblueSendMessageTool: ToolConfig< content: params.content, media_url: params.media_url, send_style: params.send_style, + seat_id: params.seat_id, status_callback: params.status_callback, }), }, diff --git a/apps/sim/tools/sendblue/send_typing_indicator.ts b/apps/sim/tools/sendblue/send_typing_indicator.ts index 7200c2f0151..ec45b242a20 100644 --- a/apps/sim/tools/sendblue/send_typing_indicator.ts +++ b/apps/sim/tools/sendblue/send_typing_indicator.ts @@ -33,17 +33,44 @@ export const sendblueSendTypingIndicatorTool: ToolConfig< visibility: 'user-or-llm', description: 'Your Sendblue line number to send from, in E.164 format.', }, + state: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + '"start" (default) shows the indicator; "stop" ends an active indicator before max_duration_ms expires.', + }, + max_duration_ms: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'How long (ms) the indicator stays visible before auto-stopping. Defaults to 60000. Must be between 1 and 300000.', + }, }, request: { url: `${SENDBLUE_API_BASE_URL}/api/send-typing-indicator`, method: 'POST', headers: (params) => sendblueHeaders(params), - body: (params) => - filterUndefined({ + body: (params) => { + if (params.state !== undefined && params.state !== 'start' && params.state !== 'stop') { + throw new Error('"state" must be either "start" or "stop".') + } + let maxDurationMs: number | undefined + if (params.max_duration_ms !== undefined) { + maxDurationMs = Number(params.max_duration_ms) + if (!Number.isInteger(maxDurationMs) || maxDurationMs < 1 || maxDurationMs > 300000) { + throw new Error('"max_duration_ms" must be an integer between 1 and 300000.') + } + } + return filterUndefined({ number: params.number, from_number: params.from_number, - }), + state: params.state, + max_duration_ms: maxDurationMs, + }) + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/sendblue/types.ts b/apps/sim/tools/sendblue/types.ts index e025c200b5e..d7c1fe41f5b 100644 --- a/apps/sim/tools/sendblue/types.ts +++ b/apps/sim/tools/sendblue/types.ts @@ -29,15 +29,17 @@ export interface SendblueSendMessageParams extends SendblueBaseParams { content?: string media_url?: string send_style?: SendblueSendStyle + seat_id?: string status_callback?: string } export interface SendblueSendGroupMessageParams extends SendblueBaseParams { - numbers: string[] + numbers?: string[] from_number: string content?: string media_url?: string send_style?: SendblueSendStyle + seat_id?: string group_id?: string status_callback?: string } @@ -49,6 +51,8 @@ export interface SendblueEvaluateServiceParams extends SendblueBaseParams { export interface SendblueTypingIndicatorParams extends SendblueBaseParams { number: string from_number?: string + state?: 'start' | 'stop' + max_duration_ms?: number } export interface SendblueGetMessageParams extends SendblueBaseParams { diff --git a/apps/sim/triggers/sendblue/utils.ts b/apps/sim/triggers/sendblue/utils.ts index 3b5849cadc2..a8f8819be4d 100644 --- a/apps/sim/triggers/sendblue/utils.ts +++ b/apps/sim/triggers/sendblue/utils.ts @@ -56,7 +56,7 @@ export function buildSendblueOutputs(): Record { }, plan: { type: 'string', description: 'Account plan type' }, message_type: { type: 'string', description: 'Message category (e.g., message, group)' }, - group_id: { type: 'string', description: 'Group identifier, empty for non-group messages' }, + group_id: { type: 'string', description: 'Group identifier, null for non-group messages' }, participants: { type: 'array', description: 'Participant phone numbers for group messages' }, send_style: { type: 'string', description: 'Expressive style if applied' }, opted_out: { type: 'boolean', description: 'True if the recipient has opted out' },