fix: process tts in cron announce delivery

This commit is contained in:
Peter Steinberger
2026-05-02 02:10:49 +01:00
parent cac35dbf96
commit 47c020bfc4
4 changed files with 126 additions and 6 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/sessions: move hot transcript reads and mirror appends onto async bounded IO with serialized parent-linked writes, keeping large session histories from stalling Gateway requests and channel replies. Fixes #75656. Thanks @DerFlash.
- Cron/TTS: run cron announce payloads through the normal TTS directive transform before outbound delivery, so scheduled `[[tts]]` replies generate voice payloads instead of leaking raw tags. Fixes #52125. Thanks @kenchen3000.
- Doctor/WhatsApp: warn when Linux crontabs still run the legacy `ensure-whatsapp.sh` health check, which can misreport `Gateway inactive` when cron lacks the systemd user-bus environment. Fixes #60204. Thanks @mySebbe.
- Slack/setup: print the generated app manifest as plain JSON instead of embedding it inside the framed setup note, so it can be copied into Slack without deleting border characters. Fixes #65751. Thanks @theDanielJLewis.
- Channels/WhatsApp: route CLI logout through the live Gateway and stop runtime-backed listeners before channel removal, so removing a WhatsApp account does not leave the old socket replying until restart. Fixes #67746. Thanks @123Mismail.

View File

@@ -15,10 +15,12 @@ import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
// --- Module mocks (must be hoisted before imports) ---
const { countActiveDescendantRunsMock, retireSessionMcpRuntimeMock } = vi.hoisted(() => ({
countActiveDescendantRunsMock: vi.fn().mockReturnValue(0),
retireSessionMcpRuntimeMock: vi.fn().mockResolvedValue(true),
}));
const { countActiveDescendantRunsMock, maybeApplyTtsToPayloadMock, retireSessionMcpRuntimeMock } =
vi.hoisted(() => ({
countActiveDescendantRunsMock: vi.fn().mockReturnValue(0),
maybeApplyTtsToPayloadMock: vi.fn(async (params: { payload: unknown }) => params.payload),
retireSessionMcpRuntimeMock: vi.fn().mockResolvedValue(true),
}));
vi.mock("../../config/sessions/main-session.js", () => ({
resolveAgentMainSessionKey: vi.fn(({ agentId }: { agentId: string }) => `agent:${agentId}:main`),
@@ -66,6 +68,10 @@ vi.mock("../../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
}));
vi.mock("../../tts/tts.runtime.js", () => ({
maybeApplyTtsToPayload: maybeApplyTtsToPayloadMock,
}));
vi.mock("./subagent-followup-hints.js", () => ({
expectsSubagentFollowup: vi.fn().mockReturnValue(false),
isLikelyInterimCronMessage: vi.fn().mockReturnValue(false),
@@ -181,6 +187,7 @@ describe("dispatchCronDelivery — double-announce guard", () => {
vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(undefined);
vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined);
vi.mocked(retireSessionMcpRuntime).mockResolvedValue(true);
maybeApplyTtsToPayloadMock.mockReset().mockImplementation(async (params) => params.payload);
});
afterEach(() => {
@@ -336,6 +343,62 @@ describe("dispatchCronDelivery — double-announce guard", () => {
).toBe(false);
});
it("applies TTS directives before direct cron announce delivery", async () => {
vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false);
maybeApplyTtsToPayloadMock.mockImplementation(async (params: { payload: unknown }) => {
const payload = params.payload as { text?: string };
expect(payload.text).toBe("[[tts]] Morning briefing complete.");
return {
text: "Morning briefing complete.",
mediaUrl: "file:///tmp/cron-tts.mp3",
audioAsVoice: true,
spokenText: "Morning briefing complete.",
};
});
const params = makeBaseParams({
synthesizedText: "[[tts]] Morning briefing complete.",
runStartedAt: 1_000,
});
params.cfgWithAgentDefaults = {
messages: {
tts: {
auto: "tagged",
provider: "microsoft",
},
},
} as never;
const state = await dispatchCronDelivery(params);
expect(state.deliveryAttempted).toBe(true);
expect(state.delivered).toBe(true);
expect(maybeApplyTtsToPayloadMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg: params.cfgWithAgentDefaults,
channel: "telegram",
kind: "final",
agentId: "main",
accountId: undefined,
}),
);
expect(deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "123456",
payloads: [
{
text: "Morning briefing complete.",
mediaUrl: "file:///tmp/cron-tts.mp3",
audioAsVoice: true,
spokenText: "Morning briefing complete.",
},
],
}),
);
});
it("preserves all successful text payloads for direct delivery", async () => {
vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false);

View File

@@ -13,6 +13,7 @@ import {
resolveMainSessionKey,
} from "../../config/sessions/main-session.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import { sleepWithAbort } from "../../infra/backoff.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { OutboundDeliveryResult } from "../../infra/outbound/deliver.js";
@@ -24,6 +25,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { shouldAttemptTtsPayload } from "../../tts/tts-config.js";
import { createCronExecutionId } from "../run-id.js";
import { hasScheduledNextRunAtMs } from "../service/jobs.js";
import type { CronJob, CronRunTelemetry } from "../types.js";
@@ -119,6 +121,7 @@ type DispatchCronDeliveryParams = {
deliveryPayloadHasStructuredContent: boolean;
deliveryPayloads: ReplyPayload[];
synthesizedText?: string;
ttsAuto?: TtsAutoMode;
summary?: string;
outputText?: string;
telemetry?: CronRunTelemetry;
@@ -183,6 +186,7 @@ let deliveryLoggerRuntimePromise:
let subagentFollowupRuntimePromise:
| Promise<typeof import("./subagent-followup.runtime.js")>
| undefined;
let ttsRuntimePromise: Promise<typeof import("../../tts/tts.runtime.js")> | undefined;
const COMPLETED_DIRECT_CRON_DELIVERIES = new Map<string, CompletedDirectCronDelivery>();
@@ -217,6 +221,11 @@ async function loadSubagentFollowupRuntime(): Promise<
return await subagentFollowupRuntimePromise;
}
async function loadTtsRuntime(): Promise<typeof import("../../tts/tts.runtime.js")> {
ttsRuntimePromise ??= import("../../tts/tts.runtime.js");
return await ttsRuntimePromise;
}
async function logCronDeliveryWarn(message: string): Promise<void> {
const { logWarn } = await loadDeliveryLoggerRuntime();
logWarn(message);
@@ -303,6 +312,40 @@ function getCompletedDirectCronDelivery(
return cloneDeliveryResults(cached.results);
}
async function maybeApplyTtsToCronPayloads(params: {
cfg: OpenClawConfig;
payloads: ReplyPayload[];
delivery: SuccessfulDeliveryTarget;
agentId: string;
ttsAuto?: TtsAutoMode;
}): Promise<ReplyPayload[]> {
if (
!shouldAttemptTtsPayload({
cfg: params.cfg,
ttsAuto: params.ttsAuto,
agentId: params.agentId,
channelId: params.delivery.channel,
accountId: params.delivery.accountId,
})
) {
return params.payloads;
}
const { maybeApplyTtsToPayload } = await loadTtsRuntime();
return await Promise.all(
params.payloads.map((payload) =>
maybeApplyTtsToPayload({
payload,
cfg: params.cfg,
channel: params.delivery.channel,
kind: "final",
ttsAuto: params.ttsAuto,
agentId: params.agentId,
accountId: params.delivery.accountId,
}),
),
);
}
function buildDirectCronDeliveryIdempotencyKey(params: {
jobId: string;
runStartedAt: number;
@@ -524,7 +567,7 @@ export async function dispatchCronDelivery(
: synthesizedText
? [{ text: synthesizedText }]
: [];
const payloadsForDelivery = rawPayloads
const normalizedPayloads = rawPayloads
.map((p) => {
if (!p.text) {
return p;
@@ -535,7 +578,7 @@ export async function dispatchCronDelivery(
});
})
.filter((p) => hasReplyPayloadContent(p, { trimText: true }));
if (payloadsForDelivery.length === 0) {
if (normalizedPayloads.length === 0) {
return await finishSilentReplyDelivery();
}
if (params.isAborted()) {
@@ -575,6 +618,18 @@ export async function dispatchCronDelivery(
...params.telemetry,
});
}
const payloadsForDelivery = (
await maybeApplyTtsToCronPayloads({
cfg: params.cfgWithAgentDefaults,
payloads: normalizedPayloads,
delivery,
agentId: params.agentId,
ttsAuto: params.ttsAuto,
})
).filter((p) => hasReplyPayloadContent(p, { trimText: true }));
if (payloadsForDelivery.length === 0) {
return await finishSilentReplyDelivery();
}
deliveryAttempted = true;
const cachedResults = getCompletedDirectCronDelivery(deliveryIdempotencyKey);
if (cachedResults) {

View File

@@ -958,6 +958,7 @@ async function finalizeCronRun(params: {
deliveryPayloadHasStructuredContent,
deliveryPayloads,
synthesizedText,
ttsAuto: prepared.cronSession.sessionEntry.ttsAuto,
summary,
outputText,
telemetry,