Compare commits
4 Commits
8af4397803
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e3a3aacf | ||
|
|
c182d34a66 | ||
|
|
176309131f | ||
|
|
c6200f3816 |
@@ -85,9 +85,44 @@ export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = (req.body as Record<string, unknown>)?.status;
|
const data = (req.body ?? {}) as Record<string, unknown>;
|
||||||
const messageUuid = (req.body as Record<string, unknown>)?.message_uuid;
|
const status = data.status;
|
||||||
logger.info({ event: 'sms.status_update', messageUuid, status }, 'Message status update');
|
const messageUuid = data.message_uuid;
|
||||||
|
|
||||||
|
// Vonage Messages API status callbacks include fields like:
|
||||||
|
// message_uuid, to, from, channel, timestamp, status (plus optional fields like workflow).
|
||||||
|
// For rejected statuses, additional reason/code fields may also be present depending on channel/route.
|
||||||
|
const to = typeof data.to === 'string' ? data.to : undefined;
|
||||||
|
const from = typeof data.from === 'string' ? data.from : undefined;
|
||||||
|
const channel = typeof data.channel === 'string' ? data.channel : undefined;
|
||||||
|
const timestamp = typeof data.timestamp === 'string' ? data.timestamp : undefined;
|
||||||
|
|
||||||
|
const rejectionCode =
|
||||||
|
(typeof data.error_code === 'string' || typeof data.error_code === 'number' ? data.error_code : undefined) ??
|
||||||
|
(typeof data.err_code === 'string' || typeof data.err_code === 'number' ? data.err_code : undefined) ??
|
||||||
|
(typeof data.code === 'string' || typeof data.code === 'number' ? data.code : undefined);
|
||||||
|
const rejectionReason =
|
||||||
|
(typeof data.error_title === 'string' ? data.error_title : undefined) ??
|
||||||
|
(typeof data.error === 'string' ? data.error : undefined) ??
|
||||||
|
(typeof data.reason === 'string' ? data.reason : undefined) ??
|
||||||
|
(typeof data.detail === 'string' ? data.detail : undefined) ??
|
||||||
|
(typeof data.description === 'string' ? data.description : undefined);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
event: 'sms.status_update',
|
||||||
|
messageUuid,
|
||||||
|
status,
|
||||||
|
channel,
|
||||||
|
timestamp,
|
||||||
|
to: to ? maskPhone(to) : undefined,
|
||||||
|
from: from ? maskPhone(from) : undefined,
|
||||||
|
rejectionCode,
|
||||||
|
rejectionReason,
|
||||||
|
payload: sanitizeVonageStatusPayload(data),
|
||||||
|
},
|
||||||
|
'Message status update'
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).json({ status: 'received' });
|
res.status(200).json({ status: 'received' });
|
||||||
});
|
});
|
||||||
@@ -95,6 +130,19 @@ export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
|
|||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeVonageStatusPayload(data: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
// Avoid leaking full phone numbers or message contents into logs.
|
||||||
|
const out: Record<string, unknown> = { ...data };
|
||||||
|
if (typeof out.to === 'string') out.to = maskPhone(out.to);
|
||||||
|
if (typeof out.from === 'string') out.from = maskPhone(out.from);
|
||||||
|
|
||||||
|
// Best-effort removal of any accidental content fields if present.
|
||||||
|
for (const key of ['text', 'message', 'content', 'image', 'video', 'file', 'audio']) {
|
||||||
|
if (key in out) delete out[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
|
async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
|
||||||
const parsed = parseVonageInboundMessage(body);
|
const parsed = parseVonageInboundMessage(body);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
|
|||||||
@@ -26,12 +26,35 @@ export function parseVonageInboundMessage(body: unknown): ParsedInboundSms | nul
|
|||||||
try {
|
try {
|
||||||
const data = body as Record<string, unknown>;
|
const data = body as Record<string, unknown>;
|
||||||
|
|
||||||
const from = (data.from as string) || '';
|
// Vonage may send either a flat shape (from/to/text) or a nested one:
|
||||||
const to = (data.to as string) || '';
|
// {
|
||||||
const text = (data.text as string) || '';
|
// from: { type: 'sms', number: '1999...' },
|
||||||
|
// to: { type: 'sms', number: '1999...' },
|
||||||
|
// message: { content: { type: 'text', text: '...' } }
|
||||||
|
// }
|
||||||
|
const from =
|
||||||
|
(typeof data.from === 'string' ? data.from : '') ||
|
||||||
|
(data.from && typeof data.from === 'object'
|
||||||
|
? (((data.from as Record<string, unknown>).number as string) || '')
|
||||||
|
: '');
|
||||||
|
const to =
|
||||||
|
(typeof data.to === 'string' ? data.to : '') ||
|
||||||
|
(data.to && typeof data.to === 'object'
|
||||||
|
? (((data.to as Record<string, unknown>).number as string) || '')
|
||||||
|
: '');
|
||||||
|
|
||||||
|
const nestedText =
|
||||||
|
data.message && typeof data.message === 'object'
|
||||||
|
? (((data.message as Record<string, unknown>).content as Record<string, unknown> | undefined)?.text as string) || ''
|
||||||
|
: '';
|
||||||
|
const text = (data.text as string) || nestedText || '';
|
||||||
const messageId = (data.message_uuid as string) || '';
|
const messageId = (data.message_uuid as string) || '';
|
||||||
const messageType = (data.message_type as string) || '';
|
const nestedMessageType =
|
||||||
const channel = (data.channel as string) || 'sms';
|
data.message && typeof data.message === 'object'
|
||||||
|
? (((data.message as Record<string, unknown>).content as Record<string, unknown> | undefined)?.type as string) || ''
|
||||||
|
: '';
|
||||||
|
const messageType = (data.message_type as string) || nestedMessageType || '';
|
||||||
|
const channel = (data.channel as string) || ((data.to as Record<string, unknown> | undefined)?.type as string) || 'sms';
|
||||||
|
|
||||||
// Collect media URLs from MMS messages
|
// Collect media URLs from MMS messages
|
||||||
const mediaUrls: string[] = [];
|
const mediaUrls: string[] = [];
|
||||||
@@ -47,6 +70,18 @@ export function parseVonageInboundMessage(body: unknown): ParsedInboundSms | nul
|
|||||||
const fileUrl = (data.file as Record<string, unknown>)?.url as string;
|
const fileUrl = (data.file as Record<string, unknown>)?.url as string;
|
||||||
if (fileUrl) mediaUrls.push(fileUrl);
|
if (fileUrl) mediaUrls.push(fileUrl);
|
||||||
}
|
}
|
||||||
|
// Nested media shapes (best-effort)
|
||||||
|
const nestedContent =
|
||||||
|
data.message && typeof data.message === 'object'
|
||||||
|
? ((data.message as Record<string, unknown>).content as Record<string, unknown> | undefined)
|
||||||
|
: undefined;
|
||||||
|
const nestedDirectUrl = (nestedContent?.url as string) || '';
|
||||||
|
const nestedImageUrl = (nestedContent?.image as Record<string, unknown> | undefined)?.url as string;
|
||||||
|
const nestedVideoUrl = (nestedContent?.video as Record<string, unknown> | undefined)?.url as string;
|
||||||
|
const nestedFileUrl = (nestedContent?.file as Record<string, unknown> | undefined)?.url as string;
|
||||||
|
for (const url of [nestedDirectUrl, nestedImageUrl, nestedVideoUrl, nestedFileUrl]) {
|
||||||
|
if (url) mediaUrls.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
const hasMedia = mediaUrls.length > 0 || channel === 'mms';
|
const hasMedia = mediaUrls.length > 0 || channel === 'mms';
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export async function sendSms(to: string, from: string, body: string): Promise<v
|
|||||||
event: 'sms.send_error',
|
event: 'sms.send_error',
|
||||||
error: error.message,
|
error: error.message,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
|
responseData: error.response?.data,
|
||||||
}, 'SMS send error');
|
}, 'SMS send error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user