diff --git a/extensions/imessage/src/approval-handler.runtime.test.ts b/extensions/imessage/src/approval-handler.runtime.test.ts index ea44174397fc..c24a8273500e 100644 --- a/extensions/imessage/src/approval-handler.runtime.test.ts +++ b/extensions/imessage/src/approval-handler.runtime.test.ts @@ -361,6 +361,48 @@ describe("imessageApprovalNativeRuntime", () => { expect(payload.text).toContain("👍 Allow Once"); }); + it("carries the same bold headers and labels in tapback and poll mode", async () => { + // #85954: poll mode used to fall back to the unstyled legacy prompt, so + // every label reached Messages as flat text on any poll-capable bridge. + const payload = await imessageApprovalNativeRuntime.presentation.buildPendingPayload({ + cfg: {} as never, + accountId: "default", + context: { accountId: "default" }, + request: { + id: "exec-bold", + request: { command: "echo hi" }, + createdAtMs: 0, + expiresAtMs: 60_000, + }, + approvalKind: "exec", + nowMs: 0, + view: { + approvalKind: "exec", + approvalId: "exec-bold", + commandText: "echo hi", + host: "gateway", + cwd: "/tmp/work", + expiresAtMs: 60_000, + actions: [ + { decision: "allow-once", label: "Allow Once", command: "/approve exec-bold allow-once" }, + { decision: "deny", label: "Deny", command: "/approve exec-bold deny" }, + ], + } as never, + }); + + for (const text of [payload.text, payload.pollText]) { + expect(text).toContain("**Exec approval required**"); + expect(text).toContain("**ID:** exec-bold"); + expect(text).toContain("**Host:** gateway"); + expect(text).toContain("**CWD:**"); + expect(text).toContain("**Expires in:**"); + expect(text).toContain("**Full id:**"); + } + // The poll owns the controls, so the tapback hint stays out of poll mode. + expect(payload.text).toContain("React with:"); + expect(payload.pollText).not.toContain("React with:"); + }); + describe("native poll controls", () => { const pollDeliverArgs = { cfg: { @@ -491,6 +533,27 @@ describe("imessageApprovalNativeRuntime", () => { ]); }); + it("keeps markdown markers out of the poll question", async () => { + // The details message is styled through attributedBody ranges, but + // `imsg poll send --question` has no formatting channel, so the balloon + // would otherwise show literal asterisks. + const pollText = ["**Exec approval required**", "**ID:** exec-poll"].join("\n"); + await imessageApprovalNativeRuntime.transport.deliverPending({ + ...pollDeliverArgs, + pendingPayload: { ...pollDeliverArgs.pendingPayload, pollText }, + }); + + // The send path converts the markers into typed ranges itself. + expect(sendMock.sendMessageIMessage).toHaveBeenCalledWith( + "+15551230000", + pollText, + expect.objectContaining({ conversationReadOrigin: "direct-operator" }), + ); + const question = actionsMock.sendPoll.mock.calls[0]?.[0]?.question; + expect(question).toBe("Exec approval required\nID: exec-poll"); + expect(question).not.toContain("**"); + }); + it("binds the poll before deliverPending returns", async () => { await imessageApprovalNativeRuntime.transport.deliverPending(pollDeliverArgs); diff --git a/extensions/imessage/src/approval-handler.runtime.ts b/extensions/imessage/src/approval-handler.runtime.ts index 28ea686c52f8..bee3317547df 100644 --- a/extensions/imessage/src/approval-handler.runtime.ts +++ b/extensions/imessage/src/approval-handler.runtime.ts @@ -8,7 +8,10 @@ import { resolvePreparedApprovalAccountId, } from "openclaw/plugin-sdk/approval-handler-runtime"; import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime"; -import { buildApprovalReactionPendingContent } from "openclaw/plugin-sdk/approval-reaction-runtime"; +import { + buildApprovalNativeControlsPromptText, + buildApprovalReactionPendingContent, +} from "openclaw/plugin-sdk/approval-reaction-runtime"; import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime"; import type { ExecApprovalRequest, @@ -32,6 +35,7 @@ import { unregisterIMessageApprovalReactionTarget, type IMessageApprovalConversationKey, } from "./approval-reactions.js"; +import { extractMarkdownFormatRuns } from "./markdown-format.js"; import { normalizeIMessageMessagingTarget } from "./normalize.js"; import { getCachedIMessagePrivateApiStatus } from "./probe.js"; import { sendMessageIMessage } from "./send.js"; @@ -97,7 +101,9 @@ function buildPendingPayload(params: { text: pendingContent.reactionPayload.text ?? "", // The native poll owns the primary controls. Manual commands stay in the // details message because bridge capability cannot prove recipient support. - pollText: pendingContent.manualFallbackPayload.text ?? "", + // Same bold headers and labels as the tapback prompt (#85954): both are + // delivered through the attributed-body send path. + pollText: buildApprovalNativeControlsPromptText({ view: params.view, nowMs: params.nowMs }), allowedDecisions: pendingContent.reactionPayload.allowedDecisions, }; } @@ -242,7 +248,10 @@ async function deliverIMessageApprovalPoll(params: { const runtime = await loadIMessageActionsRuntime(); const sent = await runtime.sendPoll({ chatGuid, - question: params.question, + // `imsg poll send --question` has no attributed-body channel, so the + // question keeps the marker-free rendering of the same prompt copy the + // details message delivers with typed formatting ranges. + question: extractMarkdownFormatRuns(params.question).text, choices: options.map((option) => option.text), suppressComment: true, options: { ...cliOptions, chatGuid }, diff --git a/src/plugin-sdk/approval-reaction-runtime.ts b/src/plugin-sdk/approval-reaction-runtime.ts index 1231bc6055aa..a2e34e92b508 100644 --- a/src/plugin-sdk/approval-reaction-runtime.ts +++ b/src/plugin-sdk/approval-reaction-runtime.ts @@ -499,6 +499,23 @@ export function buildApprovalReactionPendingContent(params: { return { reactionPayload, manualFallbackPayload }; } +/** + * Prompt copy for channels whose native controls (Apple Messages polls, inline + * buttons) own the decision surface. Same bold headers and labels as the + * reaction prompt (#85954) minus the tapback hint, which would advertise a + * second, redundant control path next to the native one. + */ +export function buildApprovalNativeControlsPromptText(params: { + view: PendingApprovalView; + nowMs: number; +}): string { + return buildApprovalReactionPromptText({ + view: params.view, + nowMs: params.nowMs, + reactionHint: null, + }); +} + /** Build reaction and manual-fallback pending approval content directly from a request. */ export function buildApprovalReactionPendingContentForRequest(params: { request: ApprovalRequest;