Skip to content

Commit 3dee023

Browse files
committed
fix(reply): suppress JSON/channelData NO_REPLY action payloads
1 parent 151f260 commit 3dee023

File tree

2 files changed

+78
-5
lines changed

2 files changed

+78
-5
lines changed

src/auto-reply/reply/normalize-reply.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,37 @@ export type NormalizeReplyOptions = {
2525
onSkip?: (reason: NormalizeReplySkipReason) => void;
2626
};
2727

28+
function isNoReplyActionValue(value: unknown): boolean {
29+
return typeof value === "string" && value.trim().toUpperCase() === SILENT_REPLY_TOKEN;
30+
}
31+
32+
function parseNoReplyActionJsonText(text: string | undefined): boolean {
33+
const trimmed = text?.trim();
34+
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
35+
return false;
36+
}
37+
try {
38+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
39+
return isNoReplyActionValue(parsed.action);
40+
} catch {
41+
return false;
42+
}
43+
}
44+
45+
function containsNoReplyAction(value: unknown): boolean {
46+
if (!value || typeof value !== "object") {
47+
return false;
48+
}
49+
if (Array.isArray(value)) {
50+
return value.some((entry) => containsNoReplyAction(entry));
51+
}
52+
const record = value as Record<string, unknown>;
53+
if (isNoReplyActionValue(record.action)) {
54+
return true;
55+
}
56+
return Object.values(record).some((entry) => containsNoReplyAction(entry));
57+
}
58+
2859
export function normalizeReplyPayload(
2960
payload: ReplyPayload,
3061
opts: NormalizeReplyOptions = {},
@@ -33,16 +64,29 @@ export function normalizeReplyPayload(
3364
const hasChannelData = Boolean(
3465
payload.channelData && Object.keys(payload.channelData).length > 0,
3566
);
67+
const hasNoReplyActionChannelData = containsNoReplyAction(payload.channelData);
68+
const textIsNoReplyActionJson = parseNoReplyActionJsonText(payload.text);
3669
const trimmed = payload.text?.trim() ?? "";
37-
if (!trimmed && !hasMedia && !hasChannelData) {
70+
const hasEffectiveChannelData = hasChannelData && !hasNoReplyActionChannelData;
71+
72+
if ((textIsNoReplyActionJson || hasNoReplyActionChannelData) && !hasMedia && !trimmed) {
73+
opts.onSkip?.("silent");
74+
return null;
75+
}
76+
if (textIsNoReplyActionJson && !hasMedia && !hasEffectiveChannelData) {
77+
opts.onSkip?.("silent");
78+
return null;
79+
}
80+
81+
if (!trimmed && !hasMedia && !hasEffectiveChannelData) {
3882
opts.onSkip?.("empty");
3983
return null;
4084
}
4185

4286
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
4387
let text = payload.text ?? undefined;
4488
if (text && isSilentReplyText(text, silentToken)) {
45-
if (!hasMedia && !hasChannelData) {
89+
if (!hasMedia && !hasEffectiveChannelData) {
4690
opts.onSkip?.("silent");
4791
return null;
4892
}
@@ -53,7 +97,7 @@ export function normalizeReplyPayload(
5397
// silent just like the exact-match path above. (#30916, #30955)
5498
if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) {
5599
text = stripSilentToken(text, silentToken);
56-
if (!text && !hasMedia && !hasChannelData) {
100+
if (!text && !hasMedia && !hasEffectiveChannelData) {
57101
opts.onSkip?.("silent");
58102
return null;
59103
}
@@ -69,7 +113,7 @@ export function normalizeReplyPayload(
69113
if (stripped.didStrip) {
70114
opts.onHeartbeatStrip?.();
71115
}
72-
if (stripped.shouldSkip && !hasMedia && !hasChannelData) {
116+
if (stripped.shouldSkip && !hasMedia && !hasEffectiveChannelData) {
73117
opts.onSkip?.("heartbeat");
74118
return null;
75119
}
@@ -79,7 +123,7 @@ export function normalizeReplyPayload(
79123
if (text) {
80124
text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) });
81125
}
82-
if (!text?.trim() && !hasMedia && !hasChannelData) {
126+
if (!text?.trim() && !hasMedia && !hasEffectiveChannelData) {
83127
opts.onSkip?.("empty");
84128
return null;
85129
}

src/auto-reply/reply/reply-utils.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,35 @@ describe("normalizeReplyPayload", () => {
150150
expect(result!.text).toBe("");
151151
expect(result!.mediaUrl).toBe("https://example.com/img.png");
152152
});
153+
154+
it("suppresses JSON action-only NO_REPLY payload", () => {
155+
const reasons: string[] = [];
156+
const result = normalizeReplyPayload(
157+
{ text: '{"action":"NO_REPLY"}' },
158+
{ onSkip: (reason) => reasons.push(reason) },
159+
);
160+
expect(result).toBeNull();
161+
expect(reasons).toEqual(["silent"]);
162+
});
163+
164+
it("suppresses channelData action-only NO_REPLY payload", () => {
165+
const reasons: string[] = [];
166+
const result = normalizeReplyPayload(
167+
{ text: "", channelData: { action: "NO_REPLY" } },
168+
{ onSkip: (reason) => reasons.push(reason) },
169+
);
170+
expect(result).toBeNull();
171+
expect(reasons).toEqual(["silent"]);
172+
});
173+
174+
it("keeps JSON action payload when media exists", () => {
175+
const result = normalizeReplyPayload({
176+
text: '{"action":"NO_REPLY"}',
177+
mediaUrl: "https://example.com/img.png",
178+
});
179+
expect(result).not.toBeNull();
180+
expect(result!.mediaUrl).toBe("https://example.com/img.png");
181+
});
153182
});
154183

155184
describe("typing controller", () => {

0 commit comments

Comments
 (0)