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
11 changes: 11 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form
the duplicate contacts in Chatwoot remains safe, as before. A contact already keyed to a WhatsApp JID is
adopted but never re-keyed, so a chat seen under both JID forms cannot flip the contact back and forth.

- **A failing relay now says why, on the plugin's health check.** The reason a message did not reach
Chatwoot — the API status and response body, a refused private address, a certificate error — was
written to the host's log and nowhere else, while the health check reported only counts. An operator
saw "1 dead-lettered after 5 attempts" with no way to find out what went wrong. The health check now
appends `last error: …` whenever something is actually failing.
- **A `baseUrl` with a path is rejected when the settings are saved, instead of failing on every
message.** The plugin appends `/api/v1/accounts/<id>` to this value, so a URL copied out of the
Chatwoot dashboard's address bar (`https://chat.example.com/app/accounts/2/settings/inboxes/8`)
produced a nonsense request path and 404'd every relay — while the plugin enabled cleanly and looked
healthy until the retries ran out. It now has to be the origin only.

### Verified

- **Reproduced and confirmed against a live OpenWA 0.12.1 host** (Baileys engine) and a self-hosted
Expand Down
1 change: 1 addition & 0 deletions chatwoot-adapter/backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function makeDeps(
log: () => {},
// Both callbacks are required on InboundDeps; the cast would hide an omitted one.
onInboundLost: () => {},
onRelayError: () => {},
onBackfillExhausted: () => {},
} as unknown as InboundDeps;
return { deps, posts, creates, seen };
Expand Down
3 changes: 3 additions & 0 deletions chatwoot-adapter/echo-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ async function wire(sessionId = 'sess') {
relayGroups: true, relayMedia: true, backfillLimit: 0, backfillAllOnce: false, log: () => {},
// Both callbacks are required on InboundDeps; the cast would hide an omitted one.
onInboundLost: () => {},
onRelayError: () => {},
onBackfillExhausted: () => {},
} as unknown as InboundDeps;

Expand Down Expand Up @@ -164,6 +165,7 @@ test("one tenant's mirror marker does not suppress another tenant's reply with t
client: { postText: async () => { posted.push({ id: 60 }); return { id: 60 }; }, postMedia: async () => ({ id: 60 }) },
// Both callbacks are required on InboundDeps; the cast would hide an omitted one.
onInboundLost: () => {},
onRelayError: () => {},
onBackfillExhausted: () => {},
} as unknown as InboundDeps;
await handleSent(inboundA, 'sessA', 'Engine', { ...own, chatId: 'alice@c.us' } as IncomingMessage);
Expand Down Expand Up @@ -207,6 +209,7 @@ test('an echo webhook processed while the adapter post is still in flight is NOT
relayGroups: true, relayMedia: true, backfillLimit: 0, backfillAllOnce: false, log: () => {},
// Both callbacks are required on InboundDeps; the cast would hide an omitted one.
onInboundLost: () => {},
onRelayError: () => {},
onBackfillExhausted: () => {},
} as unknown as InboundDeps;
const sent: Array<{ chatId?: string }> = [];
Expand Down
1 change: 1 addition & 0 deletions chatwoot-adapter/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function makeDeps(
lock: new KeyedAsyncLock(), client, store: mapping, engine, instanceId: 'inst',
relayGroups: true, relayMedia: true, backfillLimit: over.backfillLimit ?? 0, log: over.log ?? (() => {}),
onInboundLost: (msgId: string) => void lost.push(msgId),
onRelayError: () => {},
onBackfillExhausted: over.onBackfillExhausted ?? (() => {}),
} as unknown as InboundDeps;
return { deps: d, counts: () => ({ contacts, convs }), posted, posts, lost };
Expand Down
1 change: 1 addition & 0 deletions chatwoot-adapter/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export async function handleInbound(
await relayInbound(deps, sessionId, msg);
} catch (err) {
deps.log('inbound relay failed; queued for retry', err);
deps.onRelayError(err);
// Strip an oversized media blob before persisting so a huge value can't be rejected by the storage
// layer (which would lose the message — it's already markSeen); the retry then posts a placeholder.
const dropped = await deps.store
Expand Down
33 changes: 33 additions & 0 deletions chatwoot-adapter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,39 @@ test('relayOwnMessages=false gates the message:sent handler off (no Chatwoot API
assert.equal(fetches(), 0);
});

test('onEnable rejects a baseUrl carrying a path (the address-bar copy-paste)', async () => {
// `base()` appends /api/v1/accounts/<id> to this value, so a URL copied out of the Chatwoot dashboard
// produces .../app/accounts/2/settings/inboxes/8/api/v1/... and 404s every single request — while enable
// succeeds and healthCheck stays green until the retries burn out.
for (const bad of ['https://chat.acme.com/app', 'https://chat.acme.com/app/accounts/2/settings/inboxes/8']) {
const { ctx } = fakeCtx({ ...goodConfig, baseUrl: bad });
await assert.rejects(new ChatwootAdapter().onEnable(ctx), /baseUrl must be the Chatwoot origin/);
}
// A bare origin, with or without the trailing slash `base()` already strips, stays valid.
for (const ok of ['https://chat.acme.com', 'https://chat.acme.com/', 'https://chat.acme.com:8443']) {
const { ctx } = fakeCtx({ ...goodConfig, baseUrl: ok });
await new ChatwootAdapter().onEnable(ctx);
}
});

test('healthCheck surfaces the last relay error, so the cause is visible without server logs', async () => {
// The whole point of #63: the error exists and is logged, but the log only reaches container stdout.
// The dashboard shows healthCheck's message, so the reason has to ride along with the counters.
const { ctx, cbs } = fakeCtx(goodConfig);
const adapter = new ChatwootAdapter();
await adapter.onEnable(ctx);
await cbs['message:received']({
sessionId: 'sess',
source: 'Engine',
data: { id: 'm1', fromMe: false, chatId: '628123@c.us', body: 'hi', type: 'chat', isGroup: false },
});
// The hook fires the relay off-thread so a slow Chatwoot never blocks the WA pipeline — let it settle.
for (let i = 0; i < 10; i++) await new Promise(res => setImmediate(res));
const h = await adapter.healthCheck();
assert.match(h.message ?? '', /last error: /);
await adapter.onDisable();
});

test('healthCheck reports the pending retry backlog (healthy — pending is transient)', async () => {
const { ctx, storageMap } = fakeCtx(goodConfig);
storageMap.set('retry:sess:m1', { sessionId: 'sess', chatId: 'c@wa', msg: { id: 'm1' }, attempts: 1, enqueuedAt: 1 });
Expand Down
13 changes: 13 additions & 0 deletions chatwoot-adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ function readConfig(raw: Record<string, unknown>): ChatwootFullConfig {
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
throw new Error('chatwoot-adapter: baseUrl must be an https URL without embedded credentials');
}
// A value copied out of the dashboard's address bar (…/app/accounts/2/settings/inboxes/8) parses and
// passes every check above, then has /api/v1/accounts/<id> appended to it and 404s on every request —
// with the plugin reporting healthy until the retries burn out. Reject it at Save time instead.
if (parsed.pathname !== '/' && parsed.pathname !== '') {
throw new Error('chatwoot-adapter: baseUrl must be the Chatwoot origin only (e.g. https://chat.example.com), with no path');
}
const rawLimit = Number(raw.backfillLimit);
return {
baseUrl,
Expand All @@ -66,6 +72,10 @@ export default class ChatwootAdapter implements IPlugin {
private retryTimer: ReturnType<typeof setInterval> | null = null;
private store: MappingStore | null = null;
private deadLetterCount = 0;
// Message of the most recent failed relay, surfaced on healthCheck. The counters say a relay is failing;
// this says WHY (a Chatwoot status + response body, an SSRF refusal, a TLS error), which is otherwise
// only in the host's stdout — the dashboard renders healthCheck's message and no plugin log.
private lastRelayError: string | null = null;
// Inbound messages that could neither be relayed nor queued — actual data loss, almost always the host
// rejecting a write because the plugin is at its storage quota. Counted separately from dead-lettering:
// a dead letter was at least retried MAX_RETRY_ATTEMPTS times, this one never got a single attempt.
Expand Down Expand Up @@ -108,6 +118,7 @@ export default class ChatwootAdapter implements IPlugin {
ctx.logger.error(`inbound message ${msgId} LOST: could not be relayed and could not be queued`, e);
},
onBackfillExhausted: this.onBackfillExhausted,
onRelayError: (e: unknown) => void (this.lastRelayError = e instanceof Error ? e.message : String(e)),
});

ctx.registerHook(
Expand Down Expand Up @@ -259,6 +270,8 @@ export default class ChatwootAdapter implements IPlugin {
if (this.backfillExhausted.size > 0) {
parts.push(`${this.backfillExhausted.size} chat(s) gave up on history import after ${MAX_BACKFILL_ATTEMPTS} attempts`);
}
// Appended last and only when something is actually wrong: on a green plugin it would be stale noise.
if (parts.length && this.lastRelayError) parts.push(`last error: ${this.lastRelayError.slice(0, 300)}`);
return {
healthy: this.deadLetterCount === 0 && this.lostCount === 0 && !saturated,
message: parts.join('; ') || undefined,
Expand Down
4 changes: 4 additions & 0 deletions chatwoot-adapter/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface InboundDeps {
// lost. The only way this failure reaches an operator: the retry queue can't count an entry it never
// managed to store, so healthCheck would otherwise report green while dropping messages.
onInboundLost: (msgId: string, err: unknown) => void;
// Called on every failed relay, before the message is queued for retry. The error text is the single
// most useful thing an operator can be told (it carries the Chatwoot status and response body), and
// `log` only reaches the host's stdout — healthCheck is the one channel the dashboard renders.
onRelayError: (err: unknown) => void;
// Called once when a chat's history import has burned MAX_BACKFILL_ATTEMPTS. Mirrors onInboundLost:
// the durable per-chat counter stops the retries, this makes the give-up visible on healthCheck.
onBackfillExhausted: (chatId: string) => void;
Expand Down
1 change: 1 addition & 0 deletions chatwoot-adapter/sent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function deps(
log: over.log ?? (() => {}),
// Both callbacks are required on InboundDeps; the cast would hide an omitted one.
onInboundLost: () => {},
onRelayError: () => {},
onBackfillExhausted: () => {},
} as unknown as InboundDeps;
return { deps: d, counts: () => ({ contacts, convs }), posted, seen };
Expand Down
Loading