From dcd0fc23f12df6d121c448749579b052a712276d Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Fri, 3 Jul 2026 15:05:58 +0700 Subject: [PATCH] feat(chatwoot-adapter): relay locations and stickers as first-class types (#609) A shared WhatsApp location previously relayed as an empty bubble; it now posts a Chatwoot text message with a pin line (description/address when present) and an openable link (the message's own url, else a maps query), threaded like any other message. A sticker is uploaded as an image/webp attachment named sticker.webp so Chatwoot renders it, and an omitted sticker falls back to a short placeholder. Adds `location` to the vendored IncomingMessage type. Folded into the unreleased v0.2.0. --- chatwoot-adapter/CHANGELOG.md | 3 +++ chatwoot-adapter/inbound.test.ts | 20 +++++++++++++++++++ chatwoot-adapter/inbound.ts | 34 +++++++++++++++++++++++++------- types/openwa.d.ts | 2 ++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index 3bcf207..603698c 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -25,6 +25,9 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form - **Self-hosted Chatwoot guidance** in the README: `baseUrl` must be a public `https` URL (LAN/`localhost` are rejected by the SSRF guard), how to expose a self-hosted instance, and how to avoid 502/530 on large media uploads through a tunnel. (#609) +- **Locations and stickers relay as first-class types.** A shared location posts as a Chatwoot text bubble + with its coordinates and an openable maps link (previously an empty message); a sticker is uploaded as a + `image/webp` attachment named `sticker.webp` so it renders. (#609) ## [0.1.1] — 2026-07-02 diff --git a/chatwoot-adapter/inbound.test.ts b/chatwoot-adapter/inbound.test.ts index 6a82b1b..6e59a35 100644 --- a/chatwoot-adapter/inbound.test.ts +++ b/chatwoot-adapter/inbound.test.ts @@ -92,6 +92,26 @@ test('a voice note with an omitted blob posts a placeholder, not an empty bubble assert.deepEqual(posted, [{ id: 55, c: '🎤 Voice message' }]); }); +test('relays a shared location as a text bubble with a maps link (#609 P2)', async () => { + const { deps: d, posted } = deps(); + const loc = { ...msg, id: 'loc1', body: '', type: 'location', location: { latitude: -6.2, longitude: 106.8, description: 'Office' } } as IncomingMessage; + await handleInbound(d, 'sess', 'Engine', loc); + assert.equal(posted.length, 1); + assert.match(posted[0].c, /📍 Office/); + assert.match(posted[0].c, /maps\.google\.com\/\?q=-6\.2,106\.8/); +}); + +test('relays a sticker as a webp image attachment (#609 P2)', async () => { + let file: { filename: string; contentType: string } | undefined; + const { deps: d } = deps({ + client: { postMedia: async (_id: number, _c: string, f: never) => { file = f; return { id: 2 }; } }, + }); + const sticker = { ...msg, id: 's1', body: '', type: 'sticker', media: { mimetype: 'image/webp', data: 'AAA' } } as IncomingMessage; + await handleInbound(d, 'sess', 'Engine', sticker); + assert.equal(file!.filename, 'sticker.webp'); + assert.equal(file!.contentType, 'image/webp'); +}); + test('refreshes an @lid contact name once a real pushName arrives (#609)', async () => { const updates: Array<[number, string]> = []; const patches: Array<{ name?: string }> = []; diff --git a/chatwoot-adapter/inbound.ts b/chatwoot-adapter/inbound.ts index 68c1dbc..d8a8bc0 100644 --- a/chatwoot-adapter/inbound.ts +++ b/chatwoot-adapter/inbound.ts @@ -31,13 +31,19 @@ export async function handleInbound(deps: InboundDeps, sessionId: string, source // quote context (#606) so a short reply like ".." keeps the bubble it answered. const post = { sourceId: msg.id, inReplyToExternalId: msg.quotedMessage?.id }; const isVoice = msg.type === 'voice'; - if (deps.relayMedia && msg.media?.data && !msg.media.omitted) { + const isSticker = msg.type === 'sticker'; + if (msg.type === 'location' && msg.location) { + // A location carries no media blob; relay it as a text bubble with coordinates + a maps link the + // agent can open, threaded like any other message. + await deps.client.postText(conversationId, locationText(msg), post); + } else if (deps.relayMedia && msg.media?.data && !msg.media.omitted) { await deps.client.postMedia( conversationId, content, { - filename: isVoice ? 'voice.ogg' : msg.media.filename ?? 'file', - contentType: msg.media.mimetype || (isVoice ? 'audio/ogg' : 'application/octet-stream'), + filename: isVoice ? 'voice.ogg' : isSticker ? 'sticker.webp' : msg.media.filename ?? 'file', + contentType: + msg.media.mimetype || (isVoice ? 'audio/ogg' : isSticker ? 'image/webp' : 'application/octet-stream'), data: Buffer.from(msg.media.data, 'base64'), }, { ...post, isVoiceMessage: isVoice }, @@ -53,16 +59,30 @@ export async function handleInbound(deps: InboundDeps, sessionId: string, source }); } +function senderLabel(msg: IncomingMessage): string { + return msg.contact?.pushName || msg.senderPhone || msg.author || 'unknown'; +} + function prefixSender(msg: IncomingMessage): string { if (!msg.isGroup) return msg.body; - const who = msg.contact?.pushName || msg.senderPhone || msg.author || 'unknown'; - return `*${who}:* ${msg.body}`; + return `*${senderLabel(msg)}:* ${msg.body}`; +} + +// A shared location rendered for Chatwoot: a pin line (description/address when present) plus a link the +// agent can open (the message's own url, else a maps query). Group messages keep the sender prefix. +function locationText(msg: IncomingMessage): string { + const loc = msg.location!; + const link = loc.url || `https://maps.google.com/?q=${loc.latitude},${loc.longitude}`; + const label = [loc.description, loc.address].filter(Boolean).join(' — '); + const body = label ? `📍 ${label}\n${link}` : `📍 ${link}`; + return msg.isGroup ? `*${senderLabel(msg)}:* ${body}` : body; } -// A short stand-in for a bodyless message we couldn't relay as media (e.g. a voice note whose blob was -// omitted for size), so Chatwoot shows a meaningful line instead of an empty bubble. +// A short stand-in for a bodyless message we couldn't relay as media (e.g. a voice note or sticker whose +// blob was omitted for size), so Chatwoot shows a meaningful line instead of an empty bubble. function placeholderFor(msg: IncomingMessage): string { if (msg.type === 'voice') return '🎤 Voice message'; + if (msg.type === 'sticker') return '🎨 Sticker'; if (msg.media) return `📎 ${msg.media.filename ?? 'Attachment'}`; return msg.body; } diff --git a/types/openwa.d.ts b/types/openwa.d.ts index 5cbd3a2..192a2a6 100644 --- a/types/openwa.d.ts +++ b/types/openwa.d.ts @@ -266,4 +266,6 @@ export interface IncomingMessage { // The message this one replies to (swipe-to-reply / quote), when present. `id` is the quoted WhatsApp // message id; `body` is its text. Carried on the inbound hook payload for reply-threading relays. quotedMessage?: { id: string; body: string }; + // Shared location (`type: 'location'`), when present. + location?: { latitude: number; longitude: number; description?: string; address?: string; url?: string }; }