Skip to content

Commit 5f060dc

Browse files
sung17Deathgiver
andauthored
fix(zalo): stop duplicating inbox-sent messages and handle postback/receipt webhooks (#1012)
* fix(zalo): dedup inbox message echoes and ack delivery receipts Two webhook-driven bugs around inbox-sent messages: - The Send API response was mistyped as flat, so message_id was discarded and sendMessage/sendFlowStep returned messageIds: []. The worker's sourceId backfill no-oped, the oa_send_* echo missed findBySourceId, and every inbox-sent message appeared twice in the conversation. Re-shape the schema to the real { data, error, message } envelope and return the provider ids so the existing dedup path works. - user_received_message delivery receipts carry no content; routing them to incomingMessage made the handler throw "No content found" and the job retry forever. Ack and drop them at the webhook router. * fix(zalo): show tapped flow button label instead of raw postback payload Zalo (and Telegram) deliver a button tap as a plain text message whose body is the encoded payload (postback_<flowId>::<buttonId>) with no button title alongside it, so the inbox displayed the raw payload. Resolve the tapped button's label from the flow version before saving; fall back to the raw text when the flow can no longer be resolved so a stale tap never fails the message save. --------- Co-authored-by: Deathgiver <anonymous@users.noreply.github.com>
1 parent d09ec48 commit 5f060dc

11 files changed

Lines changed: 470 additions & 23 deletions

File tree

apps/worker/__tests__/received-message.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const {
3838
mockChatQueueAdd,
3939
mockSyncScopedIdentity,
4040
mockIsUniqueViolationError,
41+
mockDetectFlowVersion,
4142
} = vi.hoisted(() => {
4243
const mockDbSet = vi.fn()
4344
const updateChain = { set: mockDbSet, where: vi.fn() }
@@ -111,6 +112,7 @@ const {
111112
}),
112113
),
113114
mockIsUniqueViolationError: vi.fn().mockReturnValue(false),
115+
mockDetectFlowVersion: vi.fn(),
114116
}
115117
})
116118

@@ -289,6 +291,10 @@ vi.mock("../src/lib/logger", () => ({
289291
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
290292
}))
291293

294+
vi.mock("../src/lib/db", () => ({
295+
detectFlowVersion: mockDetectFlowVersion,
296+
}))
297+
292298
vi.mock("../src/services/integrations", () => ({
293299
allIntegrations: {
294300
messenger: {
@@ -801,6 +807,110 @@ describe("receiveMessage — message repository branch", () => {
801807
})
802808
})
803809

810+
test("replaces a raw postback payload echo with the flow button label", async () => {
811+
const postbackAction = encodeButtonPayload({ flowId: "42", buttonId: "77" })
812+
mockDetectFlowVersion.mockResolvedValue({
813+
flowVersion: {
814+
id: "fv-1",
815+
nodes: [
816+
{
817+
id: "node-1",
818+
data: {
819+
details: {
820+
steps: [
821+
{
822+
buttons: [
823+
{
824+
id: "77",
825+
label: "Xem sản phẩm",
826+
buttonType: "nextStep",
827+
beforeStep: null,
828+
steps: [],
829+
},
830+
],
831+
},
832+
],
833+
},
834+
},
835+
},
836+
],
837+
},
838+
useLatestFlowVersion: true,
839+
})
840+
mockRunChannelHandler.mockResolvedValue({
841+
message: {
842+
...baseIncomingMessage,
843+
text: `postback_${postbackAction}`,
844+
attachments: [],
845+
},
846+
contact: { sourceId: "psid-123", firstName: "Test" },
847+
postbackAction,
848+
quickReplyAction: null,
849+
ref: null,
850+
})
851+
852+
await receiveMessage({ ...baseProps, integrationType: "zalo" })
853+
854+
expect(mockDetectFlowVersion).toHaveBeenCalledWith({
855+
flowId: "42",
856+
flowVersionId: undefined,
857+
workspaceId: "ws-1",
858+
})
859+
expect(mockCreateOrUpdate).toHaveBeenCalledWith(
860+
expect.objectContaining({ text: "Xem sản phẩm" }),
861+
)
862+
expect(mockUpdateTracking).toHaveBeenCalledWith(
863+
expect.objectContaining({
864+
data: expect.objectContaining({ lastBtnTitle: "Xem sản phẩm" }),
865+
}),
866+
)
867+
expect(mockAutomatedResponseEnqueueFlowAction).toHaveBeenCalledWith({
868+
kind: "postback",
869+
data: expect.objectContaining({ action: postbackAction }),
870+
})
871+
})
872+
873+
test("keeps the raw postback text when the flow can no longer be resolved", async () => {
874+
const postbackAction = encodeButtonPayload({ flowId: "42", buttonId: "77" })
875+
mockDetectFlowVersion.mockRejectedValue(new Error("FlowVersion not found"))
876+
mockRunChannelHandler.mockResolvedValue({
877+
message: {
878+
...baseIncomingMessage,
879+
text: `postback_${postbackAction}`,
880+
attachments: [],
881+
},
882+
contact: { sourceId: "psid-123", firstName: "Test" },
883+
postbackAction,
884+
quickReplyAction: null,
885+
ref: null,
886+
})
887+
888+
await receiveMessage({ ...baseProps, integrationType: "zalo" })
889+
890+
expect(mockCreateOrUpdate).toHaveBeenCalledWith(
891+
expect.objectContaining({ text: `postback_${postbackAction}` }),
892+
)
893+
})
894+
895+
test("does not rewrite text when the channel already supplies a button title", async () => {
896+
const postbackAction = encodeButtonPayload({ flowId: "42", buttonId: "77" })
897+
mockRunChannelHandler.mockResolvedValue({
898+
message: { ...baseIncomingMessage, attachments: [] },
899+
contact: { sourceId: "psid-123", firstName: "Test" },
900+
postbackAction,
901+
quickReplyAction: null,
902+
buttonTitle: "Nút Messenger",
903+
ref: null,
904+
})
905+
906+
await receiveMessage(baseProps)
907+
908+
expect(mockDetectFlowVersion).not.toHaveBeenCalled()
909+
expect(mockCreateOrUpdate).toHaveBeenCalledWith(
910+
expect.objectContaining({ text: "hello" }),
911+
)
912+
})
913+
804914
test("sends Vietnamese feedback instead of going silent when an appointment cancel token is invalid", async () => {
805915
mockParseAppointmentCancelPostback.mockReturnValue("cancel-token")
806916
mockVerifyAppointmentCancelPostback.mockRejectedValue(

apps/worker/src/integration/handlers/flow-action.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { decodeButtonPayload } from "@chatbotx.io/flow-config"
1+
import {
2+
decodeButtonPayload,
3+
type FlowNode,
4+
resolveFlowActionTarget,
5+
ZALO_POSTBACK_TEXT_PREFIX,
6+
} from "@chatbotx.io/flow-config"
7+
import type { IncomingMessage } from "@chatbotx.io/sdk"
8+
import { normalizeError } from "universal-error-normalizer"
9+
import { detectFlowVersion } from "../../lib/db"
210
import { logger } from "../../lib/logger"
311

412
export type FlowActionKind = "postback" | "quickReply"
@@ -36,3 +44,58 @@ export const sanitizeFlowAction = (
3644

3745
return null
3846
}
47+
48+
/**
49+
* Zalo and Telegram deliver a button tap as a plain text message whose body is
50+
* the encoded postback payload, with no button title alongside it — unlike
51+
* Messenger/WhatsApp, which send the title. The echo formats differ: Zalo
52+
* prefixes the payload (`postback_123::456`), Telegram sends it bare
53+
* (`123::456`). Resolve the tapped button's label from the flow so the inbox
54+
* shows what the contact actually pressed instead of the raw payload.
55+
*
56+
* Returns null (leaving the raw text untouched) whenever the channel already
57+
* provided a title, the text is not the bare payload echo, or the flow/button
58+
* can no longer be resolved — a stale tap must never fail the message save.
59+
*/
60+
export const resolvePostbackButtonLabel = async (props: {
61+
postbackAction: string | null
62+
buttonTitle: string | null | undefined
63+
message: IncomingMessage | null
64+
workspaceId: string
65+
}): Promise<string | null> => {
66+
const { postbackAction, buttonTitle, message, workspaceId } = props
67+
if (!postbackAction || buttonTitle || !message?.text) {
68+
return null
69+
}
70+
71+
const isRawPayloadEcho =
72+
message.text === postbackAction ||
73+
message.text === `${ZALO_POSTBACK_TEXT_PREFIX}${postbackAction}`
74+
if (!isRawPayloadEcho) {
75+
return null
76+
}
77+
78+
const parsedAction = decodeButtonPayload(postbackAction)
79+
if (!parsedAction?.buttonId) {
80+
return null
81+
}
82+
83+
try {
84+
const { flowVersion } = await detectFlowVersion({
85+
flowId: parsedAction.flowId,
86+
flowVersionId: parsedAction.flowVersionId,
87+
workspaceId,
88+
})
89+
const target = resolveFlowActionTarget(
90+
flowVersion.nodes as unknown as FlowNode[],
91+
parsedAction.buttonId,
92+
)
93+
return target?.details.label ?? null
94+
} catch (error) {
95+
logger.warn(
96+
{ error: normalizeError(error), postbackAction, workspaceId },
97+
"Could not resolve button label for postback message",
98+
)
99+
return null
100+
}
101+
}

apps/worker/src/integration/handlers/received-message.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ import {
8585
integrationService,
8686
isInstagramViaFacebook,
8787
} from "../../services/integrations"
88-
import { sanitizeFlowAction } from "./flow-action"
88+
import { resolvePostbackButtonLabel, sanitizeFlowAction } from "./flow-action"
8989

9090
type ContactInboxTracking = ContactInboxTrackingData
9191

@@ -249,25 +249,40 @@ export const receiveMessage = async (
249249
integrationIdentifier,
250250
})
251251

252-
const detected = await detectContactAndConversation({
253-
incomingContact,
254-
inbox,
255-
integrationRow,
256-
source:
257-
metaReferralToContactSource(referralSource) ??
258-
contactSources.enum.inboundMessage,
259-
})
252+
// Label resolution only reads the raw text (direction correction never
253+
// changes it) and the workspace, so it can overlap the contact lookup.
254+
const [detected, postbackButtonLabel] = await Promise.all([
255+
detectContactAndConversation({
256+
incomingContact,
257+
inbox,
258+
integrationRow,
259+
source:
260+
metaReferralToContactSource(referralSource) ??
261+
contactSources.enum.inboundMessage,
262+
}),
263+
resolvePostbackButtonLabel({
264+
postbackAction,
265+
buttonTitle: parsedMessage.buttonTitle,
266+
message: rawIncomingMessage,
267+
workspaceId: inbox.workspaceId,
268+
}),
269+
])
260270
if (!detected) {
261271
throw new SdkException("Unable to resolve contact and conversation")
262272
}
263273
const { contactInbox, conversation, contact, isNewContact } = detected
264-
const incomingMessage = correctStoryReplyDirectionForNewContact(
274+
const directedIncomingMessage = correctStoryReplyDirectionForNewContact(
265275
rawIncomingMessage,
266276
isNewContact,
267277
)
278+
const incomingMessage =
279+
postbackButtonLabel && directedIncomingMessage
280+
? { ...directedIncomingMessage, text: postbackButtonLabel }
281+
: directedIncomingMessage
268282
const systemFieldUpdates = getReceivedMessageSystemFieldUpdates({
269-
...parsedMessage,
283+
buttonTitle: parsedMessage.buttonTitle || postbackButtonLabel,
270284
message: incomingMessage,
285+
referral: parsedMessage.referral,
271286
})
272287

273288
// Overwrite Contact.phoneNumber/email from message text — every inbound
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { beforeEach, describe, expect, test, vi } from "vitest"
2+
3+
const { mockSendMessageToZaloOA } = vi.hoisted(() => ({
4+
mockSendMessageToZaloOA: vi.fn(),
5+
}))
6+
7+
vi.mock("../src/api/message", () => ({
8+
sendMessageToZaloOA: mockSendMessageToZaloOA,
9+
uploadAttachment: vi.fn(),
10+
}))
11+
12+
vi.mock("../src/lib/logger", () => ({
13+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
14+
}))
15+
16+
const { sendMessage, sendFlowStep } = await import(
17+
"../src/handlers/message/outgoing-message"
18+
)
19+
20+
const ctx = {
21+
auth: { tokens: { accessToken: "tok" } },
22+
} as never
23+
24+
const contact = {
25+
id: "contact-1",
26+
sourceId: "zalo-uid-1",
27+
} as never
28+
29+
describe("zalo outgoing handlers return provider message ids", () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks()
32+
mockSendMessageToZaloOA.mockResolvedValue({
33+
error: 0,
34+
message: "Success",
35+
data: { message_id: "m_provider-1" },
36+
})
37+
})
38+
39+
test("sendMessage returns the Send API message_id so the worker can persist sourceId", async () => {
40+
const result = await sendMessage({
41+
ctx,
42+
data: {
43+
contact,
44+
message: {
45+
id: "msg-1",
46+
contentType: "text",
47+
messageType: "outgoing",
48+
text: "hello",
49+
},
50+
},
51+
} as never)
52+
53+
expect(mockSendMessageToZaloOA).toHaveBeenCalledTimes(1)
54+
expect(result).toEqual({ messageIds: ["m_provider-1"] })
55+
})
56+
57+
test("sendFlowStep (sendText) returns the Send API message_id", async () => {
58+
const result = await sendFlowStep({
59+
ctx,
60+
data: {
61+
contact,
62+
flowId: "flow-1",
63+
step: {
64+
id: "step-1",
65+
stepType: "sendText",
66+
text: "automated reply",
67+
buttons: [],
68+
},
69+
},
70+
} as never)
71+
72+
expect(mockSendMessageToZaloOA).toHaveBeenCalledTimes(1)
73+
expect(result).toEqual({ messageIds: ["m_provider-1"] })
74+
})
75+
76+
test("sendMessage tolerates a response without data.message_id", async () => {
77+
mockSendMessageToZaloOA.mockResolvedValue({
78+
error: 0,
79+
message: "Success",
80+
})
81+
82+
const result = await sendMessage({
83+
ctx,
84+
data: {
85+
contact,
86+
message: {
87+
id: "msg-1",
88+
contentType: "text",
89+
messageType: "outgoing",
90+
text: "hello",
91+
},
92+
},
93+
} as never)
94+
95+
expect(result).toEqual({ messageIds: [] })
96+
})
97+
})

0 commit comments

Comments
 (0)