fix(chat): sync paid voice audio unlock flow

This commit is contained in:
2026-07-14 15:20:56 +08:00
parent 759481b621
commit ca55723e48
12 changed files with 78 additions and 88 deletions
@@ -13,10 +13,6 @@ describe("UnlockPrivateResponse", () => {
creditsCharged: 10, creditsCharged: 10,
requiredCredits: 10, requiredCredits: 10,
shortfallCredits: 0, shortfallCredits: 0,
lockDetail: {
locked: false,
reason: null,
},
}); });
expect(response.unlocked).toBe(true); expect(response.unlocked).toBe(true);
@@ -27,7 +23,6 @@ describe("UnlockPrivateResponse", () => {
expect(response.creditsCharged).toBe(10); expect(response.creditsCharged).toBe(10);
expect(response.requiredCredits).toBe(10); expect(response.requiredCredits).toBe(10);
expect(response.shortfallCredits).toBe(0); expect(response.shortfallCredits).toBe(0);
expect(response.lockDetail.locked).toBe(false);
}); });
it("normalizes backend ids, audio aliases, and unlocked images", () => { it("normalizes backend ids, audio aliases, and unlocked images", () => {
@@ -43,10 +38,6 @@ describe("UnlockPrivateResponse", () => {
type: "promotion", type: "promotion",
url: "https://example.com/image.jpg", url: "https://example.com/image.jpg",
}, },
lockDetail: {
locked: false,
reason: null,
},
}); });
expect(response.messageId).toBe("backend-message-1"); expect(response.messageId).toBe("backend-message-1");
@@ -66,10 +57,6 @@ describe("UnlockPrivateResponse", () => {
creditsCharged: 0, creditsCharged: 0,
requiredCredits: 10, requiredCredits: 10,
shortfallCredits: 5, shortfallCredits: 5,
lockDetail: {
locked: true,
reason: "voice_message",
},
}); });
expect(response.unlocked).toBe(false); expect(response.unlocked).toBe(false);
@@ -78,10 +65,6 @@ describe("UnlockPrivateResponse", () => {
expect(response.creditsCharged).toBe(0); expect(response.creditsCharged).toBe(0);
expect(response.requiredCredits).toBe(10); expect(response.requiredCredits).toBe(10);
expect(response.shortfallCredits).toBe(5); expect(response.shortfallCredits).toBe(5);
expect(response.lockDetail).toEqual({
locked: true,
reason: "voice_message",
});
}); });
it("defaults nullable content to an empty string", () => { it("defaults nullable content to an empty string", () => {
@@ -90,10 +73,6 @@ describe("UnlockPrivateResponse", () => {
content: null, content: null,
audioUrl: null, audioUrl: null,
reason: "insufficient_balance", reason: "insufficient_balance",
lockDetail: {
locked: true,
reason: "private_message",
},
}); });
expect(response.content).toBe(""); expect(response.content).toBe("");
@@ -109,10 +88,6 @@ describe("UnlockPrivateResponse", () => {
creditsCharged: null, creditsCharged: null,
requiredCredits: null, requiredCredits: null,
shortfallCredits: null, shortfallCredits: null,
lockDetail: {
locked: true,
reason: "image_paywall",
},
}); });
expect(response.creditBalance).toBe(0); expect(response.creditBalance).toBe(0);
@@ -121,21 +96,7 @@ describe("UnlockPrivateResponse", () => {
expect(response.shortfallCredits).toBe(0); expect(response.shortfallCredits).toBe(0);
}); });
it("defaults nullable lock detail through schema helpers", () => { it("strips legacy lock detail fields", () => {
const response = UnlockPrivateResponse.from({
unlocked: false,
content: "",
reason: "insufficient_balance",
lockDetail: null,
});
expect(response.lockDetail).toEqual({
locked: false,
reason: null,
});
});
it("strips legacy lock detail fields while preserving lock state and type", () => {
const response = UnlockPrivateResponse.fromJson({ const response = UnlockPrivateResponse.fromJson({
unlocked: false, unlocked: false,
lockDetail: { lockDetail: {
@@ -148,9 +109,7 @@ describe("UnlockPrivateResponse", () => {
}, },
}); });
expect(response.lockDetail).toEqual({ expect(response.toJson()).not.toHaveProperty("lockDetail");
locked: true, expect(response).not.toHaveProperty("lockDetail");
reason: "private_message",
});
}); });
}); });
@@ -4,7 +4,6 @@ import {
type UnlockPrivateResponseData, type UnlockPrivateResponseData,
type UnlockPrivateResponseInput, type UnlockPrivateResponseInput,
} from "@/data/schemas/chat/response/unlock_private_response"; } from "@/data/schemas/chat/response/unlock_private_response";
import type { ChatLockDetailData } from "@/data/schemas/chat";
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat"; import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
/** /**
@@ -23,7 +22,6 @@ export class UnlockPrivateResponse {
declare readonly creditsCharged: number; declare readonly creditsCharged: number;
declare readonly requiredCredits: number; declare readonly requiredCredits: number;
declare readonly shortfallCredits: number; declare readonly shortfallCredits: number;
declare readonly lockDetail: ChatLockDetailData;
private constructor(input: UnlockPrivateResponseInput) { private constructor(input: UnlockPrivateResponseInput) {
const data = UnlockPrivateResponseSchema.parse(input); const data = UnlockPrivateResponseSchema.parse(input);
@@ -34,7 +34,6 @@ function makeResponse(
): ChatSendResponse { ): ChatSendResponse {
return ChatSendResponse.from({ return ChatSendResponse.from({
reply: "", reply: "",
audioUrl: "",
messageId: "msg-1", messageId: "msg-1",
isGuest: false, isGuest: false,
timestamp: 1782356425363, timestamp: 1782356425363,
@@ -92,6 +91,33 @@ describe("chat media cache helpers", () => {
).toEqual([]); ).toEqual([]);
}); });
it("does not cache audio from a locked send response", () => {
expect(
getSendResponseMediaTargets(
makeResponse({
audioUrl: "https://example.com/locked.mp3",
lockDetail: { locked: true, reason: "voice_message" },
}),
),
).toEqual([]);
});
it("caches audio from an unlocked send response", () => {
expect(
getSendResponseMediaTargets(
makeResponse({
audioUrl: "https://example.com/unlocked.mp3",
}),
),
).toEqual([
{
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://example.com/unlocked.mp3",
},
]);
});
it("deduplicates media targets", () => { it("deduplicates media targets", () => {
expect( expect(
uniqueMediaTargets([ uniqueMediaTargets([
@@ -42,11 +42,13 @@ export function getSendResponseMediaTargets(
kind: "image", kind: "image",
remoteUrl: response.image.url, remoteUrl: response.image.url,
}); });
if (!response.lockDetail.locked) {
pushMediaTarget(targets, { pushMediaTarget(targets, {
messageId: response.messageId, messageId: response.messageId,
kind: "audio", kind: "audio",
remoteUrl: response.audioUrl, remoteUrl: response.audioUrl,
}); });
}
return targets; return targets;
} }
@@ -6,7 +6,7 @@ import {
stringOrEmpty, stringOrEmpty,
stringOrNull, stringOrNull,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads"; import { ChatImageSchema } from "../chat_payloads";
import { ChatLockTypeSchema } from "../chat_lock_type"; import { ChatLockTypeSchema } from "../chat_lock_type";
/** /**
@@ -31,7 +31,6 @@ export const UnlockPrivateResponseSchema = z
creditsCharged: numberOrZero, creditsCharged: numberOrZero,
requiredCredits: numberOrZero, requiredCredits: numberOrZero,
shortfallCredits: numberOrZero, shortfallCredits: numberOrZero,
lockDetail: ChatLockDetailSchema,
}) })
.transform(({ reply, message_id, audio_url, ...data }) => ({ .transform(({ reply, message_id, audio_url, ...data }) => ({
...data, ...data,
@@ -14,7 +14,6 @@ function makeResponse(
): ChatSendResponse { ): ChatSendResponse {
return ChatSendResponse.from({ return ChatSendResponse.from({
reply: "AI reply", reply: "AI reply",
audioUrl: "",
messageId: "msg-1", messageId: "msg-1",
isGuest: false, isGuest: false,
timestamp: 1782356425363, timestamp: 1782356425363,
@@ -62,7 +61,6 @@ describe("sendResponseToUiMessage", () => {
it("maps locked voice messages without treating them as private text", () => { it("maps locked voice messages without treating them as private text", () => {
const message = sendResponseToUiMessage( const message = sendResponseToUiMessage(
makeResponse({ makeResponse({
audioUrl: "",
lockDetail: { lockDetail: {
locked: true, locked: true,
reason: "voice_message", reason: "voice_message",
@@ -78,6 +76,34 @@ describe("sendResponseToUiMessage", () => {
expect(message.privateMessageHint).toBeUndefined(); expect(message.privateMessageHint).toBeUndefined();
}); });
it("does not expose an audio URL from a locked send response", () => {
const message = sendResponseToUiMessage(
makeResponse({
audioUrl: "https://example.com/locked-voice.mp3",
lockDetail: {
locked: true,
reason: "voice_message",
},
}),
);
expect(message.audioUrl).toBeUndefined();
expect(message.locked).toBe(true);
});
it("maps an audio URL from an unlocked send response", () => {
const message = sendResponseToUiMessage(
makeResponse({
audioUrl: "https://example.com/unlocked-voice.mp3",
}),
);
expect(message.audioUrl).toBe(
"https://example.com/unlocked-voice.mp3",
);
expect(message.locked).toBe(false);
});
it("maps locked private text messages as private messages", () => { it("maps locked private text messages as private messages", () => {
const message = sendResponseToUiMessage( const message = sendResponseToUiMessage(
makeResponse({ makeResponse({
@@ -227,7 +253,7 @@ describe("localMessagesToUi", () => {
type: "voice", type: "voice",
content: "hidden voice transcript", content: "hidden voice transcript",
createdAt: "2026-06-25T12:00:00.000Z", createdAt: "2026-06-25T12:00:00.000Z",
audioUrl: null, audioUrl: "https://example.com/locked-history-voice.mp3",
image: { type: null, url: null }, image: { type: null, url: null },
lockDetail: { lockDetail: {
locked: true, locked: true,
@@ -57,10 +57,6 @@ function makeUnlockPrivateResponse(
creditsCharged: 10, creditsCharged: 10,
requiredCredits: 10, requiredCredits: 10,
shortfallCredits: 0, shortfallCredits: 0,
lockDetail: {
locked: false,
reason: null,
},
...overrides, ...overrides,
}); });
} }
@@ -822,10 +818,6 @@ describe("chatMachine transitions", () => {
creditsCharged: 0, creditsCharged: 0,
requiredCredits: 10, requiredCredits: 10,
shortfallCredits: 7, shortfallCredits: 7,
lockDetail: {
locked: true,
reason: "voice_message",
},
}), }),
}, },
}), }),
@@ -57,10 +57,6 @@ describe("chat promotion", () => {
type: "promotion", type: "promotion",
url: "https://example.com/unlocked.jpg", url: "https://example.com/unlocked.jpg",
}, },
lockDetail: {
locked: false,
reason: null,
},
}), }),
}); });
+6 -2
View File
@@ -27,7 +27,9 @@ export function localMessagesToUi(
}), }),
isFromAI: m.role === "assistant", isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt), date: messageDateFromCreatedAt(m.createdAt),
...(m.audioUrl ? { audioUrl: m.audioUrl } : {}), ...(m.audioUrl && m.lockDetail?.locked !== true
? { audioUrl: m.audioUrl }
: {}),
...deriveUiLockFields(m.lockDetail, m.image?.url), ...deriveUiLockFields(m.lockDetail, m.image?.url),
})); }));
} }
@@ -46,7 +48,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
}), }),
isFromAI: true, isFromAI: true,
date: todayString(new Date(response.timestamp)), date: todayString(new Date(response.timestamp)),
...(response.audioUrl ? { audioUrl: response.audioUrl } : {}), ...(response.audioUrl && !response.lockDetail.locked
? { audioUrl: response.audioUrl }
: {}),
...deriveUiLockFields(response.lockDetail, response.image.url), ...deriveUiLockFields(response.lockDetail, response.image.url),
}; };
} }
+1 -1
View File
@@ -197,7 +197,7 @@ function hasRenderableSendResponse(response: ChatSendResponse): boolean {
return ( return (
response.reply.trim().length > 0 || response.reply.trim().length > 0 ||
response.audioUrl.trim().length > 0 || (!response.lockDetail.locked && response.audioUrl.trim().length > 0) ||
Boolean(response.image.url) || Boolean(response.image.url) ||
isLockedPaidMessage isLockedPaidMessage
); );
+1 -1
View File
@@ -80,7 +80,7 @@ export const unlockMessageActor = fromPromise<
...(unlockResult.data.image.url ...(unlockResult.data.image.url
? { image: unlockResult.data.image } ? { image: unlockResult.data.image }
: {}), : {}),
lockDetail: unlockResult.data.lockDetail, lockDetail: { locked: false, reason: null },
}, },
cacheIdentityResult.data, cacheIdentityResult.data,
); );
+3 -15
View File
@@ -48,27 +48,15 @@ export function applySingleUnlockOutput(
content: resolvedContent, content: resolvedContent,
audioUrl: getUnlockedAudioUrl(message, output.response), audioUrl: getUnlockedAudioUrl(message, output.response),
imageUrl: resolvedImageUrl, imageUrl: resolvedImageUrl,
locked: output.response.lockDetail.locked, locked: false,
lockReason: output.response.lockDetail.reason, lockReason: null,
imagePaywalled: resolvedImageUrl imagePaywalled: resolvedImageUrl ? false : undefined,
? isLockedImage(output.response.lockDetail)
: undefined,
lockedPrivate: false, lockedPrivate: false,
privateMessageHint: null, privateMessageHint: null,
}; };
}); });
} }
function isLockedImage(lockDetail: {
locked: boolean;
reason: string | null;
}): boolean {
return (
lockDetail.locked &&
(lockDetail.reason === "image_paywall" || lockDetail.reason === "image")
);
}
function getUnlockedAudioUrl( function getUnlockedAudioUrl(
message: UiMessage, message: UiMessage,
response: UnlockPrivateResponse, response: UnlockPrivateResponse,