Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions chatwoot-adapter/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];
Expand Down
34 changes: 27 additions & 7 deletions chatwoot-adapter/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions types/openwa.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Loading