diff --git a/docs/channels/line.md b/docs/channels/line.md index 7b6016986419..33e741991344 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -404,6 +404,31 @@ Each select keeps its prompt and any overflow options together in that text. Prompts and overflow option names remain complete. Only native quick-reply button labels are shortened to LINE's 20-character limit. +In direct chats, the options an `ask_user` question offers become tappable controls +on the same Flex card, and a tap answers the question directly. LINE carries the option +index the Gateway assigned rather than the label, so a reply whose choices the Gateway no longer +lists falls back to readable text instead of drawing a tap that answers the wrong +option. The eligible shape is one single-select, non-secret question offering two to +four distinct options — the same bound Telegram, Discord and Slack use; anything else +stays readable text that a typed reply still answers. Groups, multi-person chats, and +unrecognized destinations also use this readable fallback. LINE's group and room +postbacks do not include the sender identity needed to admit a question answer; +reply with the option text instead. + +The **Other…** free-text control is not drawn. Tapping it resolves nothing by itself, and LINE +cannot take a control back off a card it already delivered, so the button would add a tap that +changes nothing the question's own text does not already offer. Discord and Slack leave that +route in text for the same reason. In eligible direct chats, each declared option keeps +a native control, and **Other…** stays named in the card's text under `Actions:` whatever the option count. + +LINE cannot edit a message it already delivered, so the controls stay on screen after +the question ends. A tap that arrives then is answered with `That question is no longer +waiting for an answer.` Initial taps follow the channel's normal admission and +pairing rules. If pairing is revoked while the question is being read, the answer +is ignored without an answer notice or a new pairing challenge. The Gateway reports one +terminal state for answered, cancelled and expired questions alike, so the notice does +not claim which one it was. + ```json5 { action: "send", diff --git a/docs/plugins/message-presentation.md b/docs/plugins/message-presentation.md index ae6a01bf7506..0f78647229fa 100644 --- a/docs/plugins/message-presentation.md +++ b/docs/plugins/message-presentation.md @@ -152,21 +152,27 @@ Button semantics: - `action.type: "question"` identifies one choice for a live, runtime-authored `ask_user` question. Like `approval`, this is an OpenClaw runtime action; agents and plugins must not synthesize question IDs. Telegram, Discord, - Slack, and Mattermost map it to transport-private native callbacks and - resolve the choice through the Gateway. When the question becomes answered, + Slack, Mattermost, and LINE direct chats map it to transport-private native + callbacks and resolve the choice through the Gateway. When the question becomes answered, expired, or cancelled, Telegram, Discord, and Slack edit the delivered - message, remove its actions, and append the terminal status; Mattermost + message, remove its actions, and append the terminal status. Mattermost retires its prompt only on the click it accepts, so a question that ends - elsewhere keeps its buttons until one is clicked. WhatsApp, Signal, and - iMessage render up to four single-select choices as `1️⃣` through `4️⃣` - reactions. Other question shapes degrade to label text, and the user can - answer with a plain-text reply. + elsewhere keeps its buttons; a later click gets private feedback. Denied + Mattermost clicks also receive private feedback and leave the prompt unchanged. + LINE group and multi-person chats keep readable choices because their postbacks + do not include the sender identity needed to admit an answer. Unknown LINE + destinations also use text. LINE cannot edit a message it already delivered, so a + tap after the question ends receives a notice. + LINE draws at most four controls on one card, matching its two-to-four option + bound. WhatsApp, Signal, and iMessage render up to four single-select choices + as `1️⃣` through `4️⃣` reactions. Other question shapes degrade to label text, + and the user can answer with a plain-text reply. - `intent: "custom-input"` switches a live question to its free-text answer path without resolving it. Producers must also state the free-text route in visible text. A channel can omit this one native control while keeping declared-choice controls native when it cannot target a text composer safely. - Telegram maps it to **Other…** and Force Reply. Discord, Slack, and - Mattermost keep the visible text route. + Telegram maps it to **Other…** and Force Reply. Discord, Slack, Mattermost, + and LINE keep the visible text route. - `action.type: "url"` opens a normal link. - `action.type: "web-app"` launches a channel-native web app. Set `url` for a URL-backed app or `widgetId` for an OpenClaw-hosted widget whose launch diff --git a/extensions/line/src/bot-handlers.test.ts b/extensions/line/src/bot-handlers.test.ts index 9a77b039d2f9..6d4e20416999 100644 --- a/extensions/line/src/bot-handlers.test.ts +++ b/extensions/line/src/bot-handlers.test.ts @@ -9,10 +9,14 @@ type MessageEvent = webhook.MessageEvent; const pairingDeliveryMocks = vi.hoisted(() => ({ invokePairingReply: false, - pushMessageLine: vi.fn(async () => { + pushMessageLine: vi.fn< + (...args: Parameters) => Promise + >(async () => { throw new Error("pushMessageLine should not be called from bot-handlers tests"); }), - replyMessageLine: vi.fn(async () => { + replyMessageLine: vi.fn< + (...args: Parameters) => Promise + >(async () => { throw new Error("replyMessageLine should not be called from bot-handlers tests"); }), })); @@ -178,7 +182,11 @@ vi.mock("./send.js", () => ({ replyMessageLine: pairingDeliveryMocks.replyMessageLine, })); -const { buildLineMessageContextMock, buildLinePostbackContextMock } = vi.hoisted(() => ({ +const { + buildLineMessageContextMock, + buildLinePostbackContextMock, + resolveLineQuestionPostbackMock, +} = vi.hoisted(() => ({ buildLineMessageContextMock: vi.fn( async (_params: { allMedia?: { path: string }[]; @@ -193,6 +201,16 @@ const { buildLineMessageContextMock, buildLinePostbackContextMock } = vi.hoisted }), ), buildLinePostbackContextMock: vi.fn(async () => null as unknown), + // Typed from the real resolver so a test can drive every outcome it declares. + resolveLineQuestionPostbackMock: vi.fn< + typeof import("./question-postback.js").resolveLineQuestionPostback + >(async () => ({ status: "answered" as const })), +})); + +vi.mock("./question-postback.js", async (importOriginal) => ({ + // Parsing stays real so the routing decision is the one production makes. + ...(await importOriginal()), + resolveLineQuestionPostback: resolveLineQuestionPostbackMock, })); vi.mock("./bot-message-context.js", async (importOriginal) => ({ @@ -369,8 +387,12 @@ describe("handleLineWebhookEvents", () => { beforeEach(() => { pairingDeliveryMocks.invokePairingReply = false; - pairingDeliveryMocks.pushMessageLine.mockClear(); - pairingDeliveryMocks.replyMessageLine.mockClear(); + pairingDeliveryMocks.pushMessageLine.mockReset().mockImplementation(async () => { + throw new Error("pushMessageLine should not be called from bot-handlers tests"); + }); + pairingDeliveryMocks.replyMessageLine.mockReset().mockImplementation(async () => { + throw new Error("replyMessageLine should not be called from bot-handlers tests"); + }); buildLineMessageContextMock.mockReset(); buildLineMessageContextMock.mockImplementation(async () => ({ ctxPayload: { From: "line:group:group-1" }, @@ -974,6 +996,261 @@ describe("handleLineWebhookEvents", () => { ); }); + it("answers a pending question instead of starting a turn when an option is tapped", async () => { + const processMessage = vi.fn(); + const context = createLineWebhookTestContext({ processMessage, dmPolicy: "open" }); + + await handleLineWebhookEvents( + [ + { + type: "postback", + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "user", userId: "user-one" }, + mode: "active", + webhookEventId: "evt-question", + deliveryContext: { isRedelivery: false }, + postback: { + data: "line.question=ask_3d8dbe55be452a9a39add7c909beb119&line.option=1", + }, + } as never, + ], + context, + ); + + expect(resolveLineQuestionPostbackMock).toHaveBeenCalledWith( + expect.objectContaining({ + callback: { questionId: "ask_3d8dbe55be452a9a39add7c909beb119", optionIndex: 1 }, + senderId: "user-one", + }), + ); + // A tap answers the question the agent is already waiting on; it is not a new turn. + expect(buildLinePostbackContextMock).not.toHaveBeenCalled(); + expect(processMessage).not.toHaveBeenCalled(); + }); + + it.each([ + { groupPolicy: "disabled", userId: "user-denied" }, + { groupPolicy: "allowlist", userId: "user-denied" }, + { groupPolicy: "allowlist", userId: undefined }, + ] as const)( + "does not resolve question taps with groupPolicy $groupPolicy and userId $userId", + async ({ groupPolicy, userId }) => { + resolveLineQuestionPostbackMock.mockClear(); + const processMessage = vi.fn(); + await handleLineWebhookEvents( + [ + { + type: "postback", + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-1", ...(userId ? { userId } : {}) }, + mode: "active", + webhookEventId: `evt-question-denied-${groupPolicy}`, + deliveryContext: { isRedelivery: false }, + postback: { + data: "line.question=ask_3d8dbe55be452a9a39add7c909beb119&line.option=1", + }, + } as never, + ], + createLineWebhookTestContext({ + processMessage, + groupPolicy, + groupAllowFrom: ["user-allowed"], + }), + ); + + expect(resolveLineQuestionPostbackMock).not.toHaveBeenCalled(); + expect(buildLinePostbackContextMock).not.toHaveBeenCalled(); + expect(processMessage).not.toHaveBeenCalled(); + }, + ); + + it("rechecks paired access without another pairing challenge or answer notice", async () => { + const processMessage = vi.fn(); + const userId = "U0123456789abcdef0123456789abcdef"; + readAllowFromStoreMock.mockResolvedValue([userId]); + let admittedBeforeWrite = true; + resolveLineQuestionPostbackMock.mockImplementationOnce(async ({ authorize }) => { + // The Gateway can await its question read while this pairing is revoked. + readAllowFromStoreMock.mockResolvedValue([]); + admittedBeforeWrite = await authorize(); + return { status: admittedBeforeWrite ? "answered" : "denied" }; + }); + await handleLineWebhookEvents( + [ + { + type: "postback", + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "user", userId }, + mode: "active", + webhookEventId: "evt-question-revoked", + deliveryContext: { isRedelivery: false }, + postback: { data: "line.question=ask_3d8dbe55be452a9a39add7c909beb119&line.option=1" }, + }, + ], + createLineWebhookTestContext({ processMessage, dmPolicy: "pairing" }), + ); + + expect(readAllowFromStoreMock).toHaveBeenCalledTimes(2); + expect(admittedBeforeWrite).toBe(false); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(pairingDeliveryMocks.replyMessageLine).not.toHaveBeenCalled(); + expect(pairingDeliveryMocks.pushMessageLine).not.toHaveBeenCalled(); + expect(buildLinePostbackContextMock).not.toHaveBeenCalled(); + expect(processMessage).not.toHaveBeenCalled(); + }); + + it.each( + (["already-terminal", "failed"] as const).flatMap((status) => + [false, true].flatMap((revoked) => + (["reply", "push"] as const).map((transport) => ({ status, revoked, transport })), + ), + ), + )( + "rechecks $status notice admission for revoked=$revoked over $transport", + async ({ status, revoked, transport }) => { + const userId = "U0123456789abcdef0123456789abcdef"; + const processMessage = vi.fn(); + readAllowFromStoreMock.mockResolvedValue([userId]); + resolveLineQuestionPostbackMock.mockImplementationOnce(async () => { + if (revoked) { + readAllowFromStoreMock.mockResolvedValue([]); + } + return { status }; + }); + pairingDeliveryMocks.replyMessageLine.mockResolvedValueOnce(undefined); + pairingDeliveryMocks.pushMessageLine.mockResolvedValueOnce(undefined); + await handleLineWebhookEvents( + [ + { + type: "postback", + replyToken: transport === "reply" ? "reply-token" : "", + timestamp: Date.now(), + source: { type: "user", userId }, + mode: "active", + webhookEventId: `notice-${status}-${revoked}-${transport}`, + deliveryContext: { isRedelivery: false }, + postback: { data: "line.question=ask_0123456789abcdef0123456789abcdef&line.option=1" }, + }, + ], + createLineWebhookTestContext({ processMessage, dmPolicy: "pairing" }), + ); + expect(pairingDeliveryMocks.replyMessageLine).toHaveBeenCalledTimes( + !revoked && transport === "reply" ? 1 : 0, + ); + expect(pairingDeliveryMocks.pushMessageLine).toHaveBeenCalledTimes( + !revoked && transport === "push" ? 1 : 0, + ); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(processMessage).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { revoked: false, partial: false, pushed: true }, + { revoked: true, partial: false, pushed: false }, + { revoked: false, partial: true, pushed: false }, + ])( + "rechecks notice fallback after reply failure revoked=$revoked partial=$partial", + async ({ revoked, partial, pushed }) => { + const userId = "U0123456789abcdef0123456789abcdef"; + const processMessage = vi.fn(); + readAllowFromStoreMock.mockResolvedValue([userId]); + resolveLineQuestionPostbackMock.mockResolvedValueOnce({ status: "already-terminal" }); + pairingDeliveryMocks.replyMessageLine.mockImplementationOnce(async () => { + if (revoked) { + readAllowFromStoreMock.mockResolvedValue([]); + } + throw Object.assign( + new Error("reply failed"), + partial ? { code: "CHANNEL_PARTIAL_DELIVERY" } : {}, + ); + }); + pairingDeliveryMocks.pushMessageLine.mockResolvedValueOnce(undefined); + await handleLineWebhookEvents( + [ + { + type: "postback", + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "user", userId }, + mode: "active", + webhookEventId: `notice-fallback-${revoked}-${partial}`, + deliveryContext: { isRedelivery: false }, + postback: { data: "line.question=ask_0123456789abcdef0123456789abcdef&line.option=1" }, + }, + ], + createLineWebhookTestContext({ processMessage, dmPolicy: "pairing" }), + ); + expect(pairingDeliveryMocks.replyMessageLine).toHaveBeenCalledOnce(); + expect(pairingDeliveryMocks.pushMessageLine).toHaveBeenCalledTimes(pushed ? 1 : 0); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(processMessage).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["already-terminal" as const, "That question is no longer waiting for an answer."], + ["failed" as const, "Could not record that answer. Reply with the option text instead."], + ])("tells the tapper what happened when a %s tap did not answer", async (status, notice) => { + resolveLineQuestionPostbackMock.mockResolvedValueOnce({ status }); + pairingDeliveryMocks.replyMessageLine.mockResolvedValueOnce(undefined as never); + const processMessage = vi.fn(); + const context = createLineWebhookTestContext({ processMessage, dmPolicy: "open" }); + + await handleLineWebhookEvents( + [ + { + type: "postback", + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "user", userId: "user-one" }, + mode: "active", + webhookEventId: `evt-question-${status}`, + deliveryContext: { isRedelivery: false }, + postback: { + data: "line.question=ask_3d8dbe55be452a9a39add7c909beb119&line.option=0", + }, + } as never, + ], + context, + ); + + expect(pairingDeliveryMocks.replyMessageLine).toHaveBeenCalledWith( + "reply-token", + [{ type: "text", text: notice }], + expect.anything(), + ); + expect(processMessage).not.toHaveBeenCalled(); + }); + + it("still routes an ordinary postback to the agent", async () => { + resolveLineQuestionPostbackMock.mockClear(); + const processMessage = vi.fn(); + const context = createLineWebhookTestContext({ processMessage, dmPolicy: "open" }); + + await handleLineWebhookEvents( + [ + { + type: "postback", + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "user", userId: "user-one" }, + mode: "active", + webhookEventId: "evt-plain-postback", + deliveryContext: { isRedelivery: false }, + postback: { data: "line.action=play&line.device=tv" }, + } as never, + ], + context, + ); + + expect(resolveLineQuestionPostbackMock).not.toHaveBeenCalled(); + expect(buildLinePostbackContextMock).toHaveBeenCalled(); + }); + it("keeps a group message recorded during a mention turn instead of clearing it", async () => { const groupHistories = new Map(); let releaseTurn: () => void = () => {}; diff --git a/extensions/line/src/bot-handlers.ts b/extensions/line/src/bot-handlers.ts index 63b7c2ce2c8a..c4aee9a1951d 100644 --- a/extensions/line/src/bot-handlers.ts +++ b/extensions/line/src/bot-handlers.ts @@ -57,6 +57,7 @@ import { reserveLineGroupHistory } from "./group-history.js"; import { resolveLineGroupConfigEntry } from "./group-keys.js"; import { hasAnyLineMention, isLineBotMentioned } from "./mentions.js"; import { quotesLineBotMessage } from "./outbound-message-log.js"; +import { parseLineQuestionPostbackData, resolveLineQuestionPostback } from "./question-postback.js"; import { getLineGroupName, getUserDisplayName, pushMessageLine, replyMessageLine } from "./send.js"; import type { ResolvedLineAccount } from "./types.js"; import type { LineWebhookTurnAdoptionLifecycle } from "./webhook-spool.js"; @@ -108,6 +109,50 @@ function normalizeLineIngressEntry(value: string): string | null { return normalizeLineAllowEntry(value) || null; } +/** + * Say one line back to a sender, preferring their reply token so the answer costs no + * push quota, and falling back to a push when no token is usable. A partial-delivery + * failure means the reply was seen, so it never falls back. + */ +async function sendLineHandlerText(params: { + context: LineHandlerContext; + text: string; + replyToken?: string; + pushTarget: string; + logLabel: string; + authorize?: () => boolean | Promise; +}): Promise { + const { context, logLabel, text } = params; + const sendOptions = { + cfg: context.cfg, + accountId: context.account.accountId, + channelAccessToken: context.account.channelAccessToken, + ...(params.authorize ? { authorize: params.authorize } : {}), + }; + if (params.replyToken) { + if (params.authorize && !(await params.authorize())) { + return; + } + try { + await replyMessageLine(params.replyToken, [{ type: "text", text }], sendOptions); + return; + } catch (err) { + logVerbose(`${logLabel}: ${String(err)}`); + if (isChannelPartialDeliveryError(err)) { + return; + } + } + } + if (params.authorize && !(await params.authorize())) { + return; + } + try { + await pushMessageLine(params.pushTarget, text, sendOptions); + } catch (err) { + logVerbose(`${logLabel}: ${String(err)}`); + } +} + async function sendLinePairingReply(params: { senderId: string; replyToken?: string; @@ -137,43 +182,33 @@ async function sendLinePairingReply(params: { onCreated: () => { logVerbose(`line pairing request sender=${senderId}`); }, - sendPairingReply: async (text) => { - if (replyToken) { - try { - await replyMessageLine(replyToken, [{ type: "text", text }], { - cfg: context.cfg, - accountId: context.account.accountId, - channelAccessToken: context.account.channelAccessToken, - }); - return; - } catch (err) { - logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`); - // A visible reply survived failed bookkeeping; a fallback push would duplicate it. - if (isChannelPartialDeliveryError(err)) { - return; - } - } - } - try { - await pushMessageLine(`line:${senderId}`, text, { - cfg: context.cfg, - accountId: context.account.accountId, - channelAccessToken: context.account.channelAccessToken, - }); - } catch (err) { - logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`); - } - }, + sendPairingReply: async (text) => + await sendLineHandlerText({ + context, + text, + replyToken, + pushTarget: `line:${senderId}`, + logLabel: `line pairing reply failed for ${senderId}`, + }), }); } +function isLineEventAdmitted(access: ResolvedChannelMessageIngress): boolean { + return ( + access.senderAccess.decision === "allow" && + (access.ingress.admission === "dispatch" || + access.ingress.admission === "observe" || + access.ingress.admission === "skip") + ); +} + async function resolveLineEventAdmission( event: MessageEvent | PostbackEvent | JoinEvent, context: LineHandlerContext, ): Promise<{ access: ResolvedChannelMessageIngress; resolveBoundAccess: ( - contextBinding: ChannelIngressContextBinding, + contextBinding?: ChannelIngressContextBinding, ) => Promise; mentions?: LineInboundMentionAccess; } | null> { @@ -291,12 +326,7 @@ async function resolveLineEventAdmission( return roomAllowed ? { access, resolveBoundAccess: resolveAccess } : null; } - if ( - access.senderAccess.decision === "allow" && - (access.ingress.admission === "dispatch" || - access.ingress.admission === "observe" || - access.ingress.admission === "skip") - ) { + if (isLineEventAdmitted(access)) { // Quotes and authorized commands can address the bot without a native LINE // mention. Preserve that effective result separately from explicit evidence. const mentions = mentionFacts @@ -593,6 +623,16 @@ async function handleLeaveEvent(event: LeaveEvent, _context: LineHandlerContext) logVerbose(`line: bot left ${groupId ? `group ${groupId}` : `room ${roomId}`}`); } +/** What a tap that did not answer the question has to tell the person who tapped. */ +function lineQuestionOutcomeNotice(status: "already-terminal" | "failed"): string { + if (status === "already-terminal") { + // The Gateway reports one terminal state for answered, cancelled and expired + // questions alike, so the notice claims only what it knows. + return "That question is no longer waiting for an answer."; + } + return "Could not record that answer. Reply with the option text instead."; +} + async function handlePostbackEvent( event: PostbackEvent, context: LineHandlerContext, @@ -605,6 +645,36 @@ async function handlePostbackEvent( return; } + const question = parseLineQuestionPostbackData(data ?? ""); + if (question) { + // An ask_user tap answers the pending question; it is not a new turn. + const { userId, groupId, roomId } = getLineSourceInfo(event.source); + // Re-read admission without issuing another pairing challenge. + const authorize = async () => isLineEventAdmitted(await decision.resolveBoundAccess()); + const outcome = await resolveLineQuestionPostback({ + cfg: context.cfg, + callback: question, + accountId: context.account.accountId, + ...(userId ? { senderId: userId } : {}), + authorize, + }); + // A recorded answer needs no acknowledgement: the agent's next reply is the + // feedback, and LINE already echoed the label through the action's displayText. + const pushTarget = groupId ?? roomId ?? (userId ? `line:${userId}` : undefined); + if (outcome.status === "answered" || outcome.status === "denied" || !pushTarget) { + return; + } + await sendLineHandlerText({ + context, + replyToken: event.replyToken, + pushTarget, + logLabel: "line: question answer notice failed", + text: lineQuestionOutcomeNotice(outcome.status), + authorize, + }); + return; + } + const postbackContext = await buildLinePostbackContext({ event, cfg: context.cfg, diff --git a/extensions/line/src/channel.rich-messages.test.ts b/extensions/line/src/channel.rich-messages.test.ts index bbf033bec0d0..3f20d26e31da 100644 --- a/extensions/line/src/channel.rich-messages.test.ts +++ b/extensions/line/src/channel.rich-messages.test.ts @@ -1,4 +1,6 @@ // Line tests cover typed rich-message boundaries. +import { renderPresentationForDelivery } from "openclaw/plugin-sdk/interactive-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { linePlugin } from "./channel.js"; @@ -12,6 +14,12 @@ import { } from "./rich-messages.js"; import type { LineRichCard } from "./types.js"; +const DIRECT_TARGET = "line:U0123456789abcdef0123456789abcdef"; + +function prepareDirectLineReplyPayload(payload: ReplyPayload) { + return prepareLineReplyPayload(payload, DIRECT_TARGET); +} + function resolveChannelDataSchema() { const discovery = lineMessageActions.describeMessageTool({ cfg: { @@ -80,7 +88,7 @@ describe("LINE rich-message boundaries", () => { }, ], }, - ctx: {} as never, + ctx: { to: "line:group:C0123456789abcdef0123456789abcdef" } as never, }); const line = result?.channelData?.line as { @@ -130,6 +138,352 @@ describe("LINE rich-message boundaries", () => { }); }); + it("turns an ask_user question into tappable LINE options", async () => { + const prepared = await prepareDirectLineReplyPayload({ + text: "Agent needs input:\n\nWhich environment?\n1. Staging\n2. Production", + presentationTextMode: "fallback", + channelData: { + askUser: { + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValues: ["Staging", "Production"], + }, + }, + presentation: { + blocks: [ + { type: "text", text: "Which environment?" }, + { + type: "buttons", + buttons: [ + { + label: "Staging", + action: { + type: "question", + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValue: "Staging", + }, + }, + { + label: "Production", + action: { + type: "question", + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValue: "Production", + }, + }, + ], + }, + ], + }, + }); + + const line = prepared.channelData?.line as { + flexMessage?: { + contents?: { footer?: { contents?: Array<{ action?: { data?: string } }> } }; + }; + }; + // The Gateway owns option order, so the tap carries an index, never the label. + expect( + line.flexMessage?.contents?.footer?.contents?.map((button) => button.action?.data), + ).toEqual([ + "line.question=ask_3d8dbe55be452a9a39add7c909beb119&line.option=0", + "line.question=ask_3d8dbe55be452a9a39add7c909beb119&line.option=1", + ]); + // altText is the whole message in the notification and the chat list, so a + // question that only ever renders as a card still has to say what it asks. + expect((line.flexMessage as { altText?: string } | undefined)?.altText).toBe( + "Which environment?", + ); + }); + + it.each([ + { to: DIRECT_TARGET, native: true }, + { to: "line:group:C0123456789abcdef0123456789abcdef", native: false }, + { to: "line:room:R0123456789abcdef0123456789abcdef", native: false }, + { to: "unknown", native: false }, + { to: undefined, native: false }, + ])("renders question choices for destination $to", async ({ to, native }) => { + const questionId = "ask_3d8dbe55be452a9a39add7c909beb119"; + const payload: ReplyPayload = { + text: "Which environment?\n1. Staging\n2. Production", + presentationTextMode: "fallback", + channelData: { askUser: { questionId, optionValues: ["Staging", "Production"] } }, + presentation: { + blocks: [ + { type: "text", text: "Which environment?" }, + { + type: "buttons", + buttons: ["Staging", "Production"].map((label) => ({ + label, + action: { type: "question", questionId, optionValue: label }, + })), + }, + ], + }, + }; + const outbound = await renderPresentationForDelivery( + { + presentationCapabilities: lineOutboundAdapter.presentationCapabilities, + renderPresentation: (adapted) => + lineOutboundAdapter.renderPresentation!({ + payload: adapted, + presentation: adapted.presentation, + ctx: { to } as never, + }), + }, + payload, + ); + for (const prepared of [await prepareLineReplyPayload(payload, to), outbound]) { + expect(prepared.presentation).toBeUndefined(); + const line = prepared.channelData?.line as + | { + flexMessage?: { contents?: { footer?: { contents?: Array<{ action?: unknown }> } } }; + } + | undefined; + if (native) { + expect(line?.flexMessage?.contents?.footer?.contents).toMatchObject([ + { action: { type: "postback", data: `line.question=${questionId}&line.option=0` } }, + { action: { type: "postback", data: `line.question=${questionId}&line.option=1` } }, + ]); + } else { + expect(line).toBeUndefined(); + expect(prepared.text).toBe(payload.text); + } + } + }); + + it.each([ + ["buttons only", "", "text", "", 2, false, false], + ["omitted Other control", "", "text", "", 2, true, false], + ["overflow Other guidance", "", "text", "", 4, true, false], + ["blank authored content", " ", "text", " ", 2, false, false], + ["title-only prompt", "Which environment?", "text", "", 2, false, true], + ["context prompt", "", "context", "Which environment?", 2, false, true], + ] as const)( + "preserves the question prompt for %s through both render owners", + async (_name, title, promptType, prompt, optionCount, other, native) => { + const questionId = "ask_3d8dbe55be452a9a39add7c909beb119"; + const labels = ["Staging", "Production", "Canary", "Sandbox"].slice(0, optionCount); + const payload: ReplyPayload = { + text: `Which environment?\n${labels.join(" / ")}${other ? " / Other: reply with your own answer." : ""}`, + presentationTextMode: "fallback", + channelData: { askUser: { questionId, optionValues: labels } }, + presentation: { + title, + blocks: [ + ...(prompt ? [{ type: promptType, text: prompt }] : []), + { + type: "buttons", + buttons: [ + ...labels.map((label) => ({ + label, + action: { type: "question" as const, questionId, optionValue: label }, + })), + ...(other + ? [ + { + label: "Other…", + action: { + type: "question" as const, + questionId, + intent: "custom-input" as const, + }, + }, + ] + : []), + ], + }, + ], + }, + }; + const outbound = await renderPresentationForDelivery( + { + presentationCapabilities: lineOutboundAdapter.presentationCapabilities, + renderPresentation: (adapted, sourcePresentation) => + lineOutboundAdapter.renderPresentation!({ + payload: adapted, + presentation: adapted.presentation, + sourcePresentation, + ctx: { cfg: {}, to: DIRECT_TARGET, text: adapted.text ?? "", payload: adapted }, + }), + }, + payload, + ); + for (const prepared of [await prepareDirectLineReplyPayload(payload), outbound]) { + expect(prepared.presentation).toBeUndefined(); + const line = prepared.channelData?.line as { flexMessage?: unknown } | undefined; + if (native) { + expect(line?.flexMessage).toBeDefined(); + expect(JSON.stringify(line?.flexMessage)).toContain("Which environment?"); + } else { + expect(line).toBeUndefined(); + expect(prepared.text).toBe(payload.text); + } + } + }, + ); + + // The free-text route is only ever offered as text on LINE. Above the action + // budget the shared adapter writes it under `Actions:`; below it the control is + // still delivered here, so the renderer has to write the same words itself or a + // two- or three-option card offers no way to answer in your own words. + it("names the omitted Other… control on a card whatever the option count", async () => { + const QUESTION_ID = "ask_3d8dbe55be452a9a39add7c909beb119"; + const readCardBody = async (optionCount: number): Promise => { + const labels = ["Staging", "Production", "Canary", "Sandbox"].slice(0, optionCount); + const prepared = await prepareDirectLineReplyPayload({ + text: "Which environment?", + presentationTextMode: "fallback", + channelData: { askUser: { questionId: QUESTION_ID, optionValues: labels } }, + presentation: { + blocks: [ + { type: "text", text: "Which environment?" }, + { + type: "buttons", + buttons: [ + ...labels.map((label) => ({ + label, + action: { + type: "question" as const, + questionId: QUESTION_ID, + optionValue: label, + }, + })), + { + label: "Other…", + action: { + type: "question" as const, + questionId: QUESTION_ID, + intent: "custom-input" as const, + }, + }, + ], + }, + ], + }, + }); + const flex = (prepared.channelData?.line as { flexMessage?: { contents?: unknown } }) + ?.flexMessage?.contents as { body?: { contents?: Array<{ text?: string }> } } | undefined; + return flex?.body?.contents?.map((entry) => entry.text).find((text) => text?.includes("\n")); + }; + + // Four options push Other… past the action budget, so the shared adapter owns + // this wording. Comparing against it keeps the two shapes from drifting apart. + const overBudget = await readCardBody(4); + expect(overBudget).toBe("Which environment?\nActions:\n- Other…"); + expect(await readCardBody(2)).toBe(overBudget); + expect(await readCardBody(3)).toBe(overBudget); + }); + + it("falls back to text when two options truncate to the same control label", async () => { + const prepared = await prepareDirectLineReplyPayload({ + text: "Which environment? 1. Deploy the release candidate to the shared staging cluster 2. Deploy the release candidate to the shared production cluster", + presentationTextMode: "fallback", + channelData: { + askUser: { + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValues: [ + "Deploy the release candidate to the shared staging cluster", + "Deploy the release candidate to the shared production cluster", + ], + }, + }, + presentation: { + blocks: [ + { type: "text", text: "Which environment?" }, + { + type: "buttons", + buttons: [ + { + label: "Deploy the release candidate to the shared staging cluster", + action: { + type: "question", + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValue: "Deploy the release candidate to the shared staging cluster", + }, + }, + { + label: "Deploy the release candidate to the shared production cluster", + action: { + type: "question", + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValue: "Deploy the release candidate to the shared production cluster", + }, + }, + ], + }, + ], + }, + }); + + // Both labels truncate to the same 40 characters, so two taps would read + // identically; the prose still names them in full. + expect((prepared.channelData?.line as { flexMessage?: unknown } | undefined)?.flexMessage).toBe( + undefined, + ); + expect(prepared.text).toContain("Deploy the release candidate to the shared staging cluster"); + }); + + it("draws the declared options and leaves the free-text route to the card text", async () => { + const prepared = await prepareDirectLineReplyPayload({ + text: "Agent needs input: Which environment?", + presentationTextMode: "fallback", + channelData: { + askUser: { + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValues: ["Staging", "Production"], + }, + }, + presentation: { + blocks: [ + { type: "text", text: "Which environment?" }, + { + type: "buttons", + buttons: [ + { + label: "Staging", + action: { + type: "question", + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValue: "Staging", + }, + }, + { + label: "Production", + action: { + type: "question", + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + optionValue: "Production", + }, + }, + { + label: "Other…", + action: { + type: "question", + questionId: "ask_3d8dbe55be452a9a39add7c909beb119", + intent: "custom-input", + }, + }, + ], + }, + ], + }, + }); + + const line = prepared.channelData?.line as { + flexMessage?: { + contents?: { footer?: { contents?: Array<{ action?: { data?: string } }> } }; + }; + }; + const footer = line.flexMessage?.contents?.footer?.contents; + // A tap on the free-text control would open the composer and nothing else: + // the answer typed after it is queued as a new turn rather than claimed by + // the question. The card's own words carry that route instead. + expect(footer?.map((button) => button.action?.data)).toEqual([ + "line.question=ask_3d8dbe55be452a9a39add7c909beb119&line.option=0", + "line.question=ask_3d8dbe55be452a9a39add7c909beb119&line.option=1", + ]); + }); + it.each([ { name: "exact byte limit", character: "x", extraBytes: 0, fits: true }, { name: "one byte over", character: "x", extraBytes: 1, fits: false }, @@ -337,6 +691,7 @@ describe("LINE rich-message boundaries", () => { }, ], }, + ctx: { to: DIRECT_TARGET }, } as never); expect(rendered?.text).toBe("Pick a file"); @@ -379,6 +734,7 @@ describe("LINE rich-message boundaries", () => { }, ], }, + ctx: { to: DIRECT_TARGET }, } as never); expect(rendered?.text).toBe("Pick a day"); diff --git a/extensions/line/src/monitor.lifecycle.test.ts b/extensions/line/src/monitor.lifecycle.test.ts index 23815104a978..8fb73175d402 100644 --- a/extensions/line/src/monitor.lifecycle.test.ts +++ b/extensions/line/src/monitor.lifecycle.test.ts @@ -376,67 +376,125 @@ describe("monitorLineProvider lifecycle", () => { expect(statusSink).not.toHaveBeenCalledWith(expect.objectContaining({ lifecycle: "ready" })); }); - it("resolves a reply's presentation into LINE controls before delivering it", async () => { - const { setLineRuntime } = await import("./runtime.js"); - type ResolvedTurn = Pick; - let resolvedTurn: ResolvedTurn | undefined; - const runTurn = async (params: { - adapter: { resolveTurn: () => ResolvedTurn }; - }): Promise<{ dispatched: false }> => { - resolvedTurn = params.adapter.resolveTurn(); - return { dispatched: false }; - }; - setLineRuntime({ - channel: { inbound: { run: runTurn } }, - } as unknown as Parameters[0]); - const monitor = await monitorLineProvider({ - channelAccessToken: "token", - channelSecret: "secret", // pragma: allowlist secret - config: {} as OpenClawConfig, - runtime: {} as RuntimeEnv, - }); - const onMessage = createLineBotMock.mock.calls[0]?.[0]?.onMessage; - if (!onMessage) { - throw new Error("expected the LINE bot to receive an inbound message handler"); - } + it.each([ + { from: "line:U0123456789abcdef0123456789abcdef", question: true, prompt: true, native: true }, + { + from: "line:U0123456789abcdef0123456789abcdef", + question: true, + prompt: false, + native: false, + }, + { + from: "line:group:C0123456789abcdef0123456789abcdef", + question: true, + prompt: true, + native: false, + }, + { + from: "line:room:R0123456789abcdef0123456789abcdef", + question: true, + prompt: true, + native: false, + }, + { from: "unknown", question: true, prompt: true, native: false }, + { + from: "line:group:C0123456789abcdef0123456789abcdef", + question: false, + prompt: false, + native: true, + }, + ])( + "prepares native=$native question=$question prompt=$prompt replies for $from", + async ({ from, question, prompt, native }) => { + const { setLineRuntime } = await import("./runtime.js"); + type ResolvedTurn = Pick; + let resolvedTurn: ResolvedTurn | undefined; + const runTurn = async (params: { + adapter: { resolveTurn: () => ResolvedTurn }; + }): Promise<{ dispatched: false }> => { + resolvedTurn = params.adapter.resolveTurn(); + return { dispatched: false }; + }; + setLineRuntime({ + channel: { inbound: { run: runTurn } }, + } as unknown as Parameters[0]); + const monitor = await monitorLineProvider({ + channelAccessToken: "token", + channelSecret: "secret", // pragma: allowlist secret + config: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + }); + const onMessage = createLineBotMock.mock.calls[0]?.[0]?.onMessage; + if (!onMessage) { + throw new Error("expected the LINE bot to receive an inbound message handler"); + } - try { - await onMessage( - { - ctxPayload: { From: "line:group:C1", MessageSid: "m1", RawBody: "approve?" }, - replyToken: "reply-token", - route: { accountId: "default", agentId: "main", sessionKey: "line:C1" }, - isGroup: true, - accountId: "default", - turn: { record: {} }, - } as unknown as Parameters[0], - // Admission always hands the turn its live config; an empty one is unreachable. - { cfg: {} } as Parameters[1], - ); + try { + await onMessage( + { + ctxPayload: { From: from, MessageSid: "m1", RawBody: "approve?" }, + replyToken: "reply-token", + route: { accountId: "default", agentId: "main", sessionKey: "line:C1" }, + isGroup: !from.startsWith("line:U"), + accountId: "default", + turn: { record: {} }, + } as unknown as Parameters[0], + // Admission always hands the turn its live config; an empty one is unreachable. + { cfg: {} } as Parameters[1], + ); - const prepared = await resolvedTurn?.delivery.preparePayload?.( - { - text: "Approve this run?", - presentation: { - blocks: [ - { - type: "buttons", - buttons: [{ label: "Approve", action: { type: "callback", value: "approve" } }], - }, - ], + const questionId = "ask_3d8dbe55be452a9a39add7c909beb119"; + const prepared = await resolvedTurn?.delivery.preparePayload?.( + { + text: "Approve this run? Approve / Deny", + presentationTextMode: "fallback", + ...(question + ? { channelData: { askUser: { questionId, optionValues: ["Approve", "Deny"] } } } + : {}), + presentation: { + blocks: [ + ...(prompt ? [{ type: "text" as const, text: "Approve this run?" }] : []), + { + type: "buttons", + buttons: question + ? ["Approve", "Deny"].map((label) => ({ + label, + action: { type: "question" as const, questionId, optionValue: label }, + })) + : [{ label: "Approve", action: { type: "callback", value: "approve" } }], + }, + ], + }, }, - }, - { kind: "final" }, - ); - const line = prepared?.channelData?.line as { flexMessage?: unknown } | undefined; + { kind: "final" }, + ); + const line = prepared?.channelData?.line as { flexMessage?: unknown } | undefined; - expect(prepared?.presentation).toBeUndefined(); - expect(line?.flexMessage).toBeDefined(); - } finally { - // A leaked registration makes later shared-path signature tests ambiguous. - await monitor.stop(); - } - }); + expect(prepared?.presentation).toBeUndefined(); + if (native) { + expect(line?.flexMessage).toBeDefined(); + if (question) { + expect(line?.flexMessage).toMatchObject({ + contents: { + footer: { + contents: [ + { action: { data: `line.question=${questionId}&line.option=0` } }, + { action: { data: `line.question=${questionId}&line.option=1` } }, + ], + }, + }, + }); + } + } else { + expect(line).toBeUndefined(); + expect(prepared?.text).toBe("Approve this run? Approve / Deny"); + } + } finally { + // A leaked registration makes later shared-path signature tests ambiguous. + await monitor.stop(); + } + }, + ); it("paces block replies with the humanDelay the turn's own config carries", async () => { // humanDelay lives on the agent, but only the dispatcher can act on it, so a diff --git a/extensions/line/src/monitor.ts b/extensions/line/src/monitor.ts index 789eac7fff38..774f49c5b637 100644 --- a/extensions/line/src/monitor.ts +++ b/extensions/line/src/monitor.ts @@ -254,7 +254,7 @@ export async function monitorLineProvider( delivery: { // Core renders presentations inside the outbound send pipeline only, // so this path resolves them before either branch reads channelData. - preparePayload: prepareLineReplyPayload, + preparePayload: (payload) => prepareLineReplyPayload(payload, ctxPayload.From), durable: (payload, info) => resolveLineDurableReplyOptions({ payload, diff --git a/extensions/line/src/outbound.ts b/extensions/line/src/outbound.ts index c3bf33900fa1..6a3fac597d4c 100644 --- a/extensions/line/src/outbound.ts +++ b/extensions/line/src/outbound.ts @@ -49,7 +49,8 @@ export const lineOutboundAdapter: NonNullable textChunkLimit: 5000, sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text), presentationCapabilities: LINE_PRESENTATION_CAPABILITIES, - renderPresentation: ({ payload, presentation }) => renderLinePresentation(payload, presentation), + renderPresentation: ({ payload, presentation, sourcePresentation, ctx }) => + renderLinePresentation(payload, presentation, ctx.to, sourcePresentation), sendPayload: async ({ to, payload, accountId, cfg, replyToId, onDeliveryResult }) => { const runtime = getLineRuntime(); const outboundRuntime = await loadLineOutboundRuntime(); diff --git a/extensions/line/src/question-postback.lazy.test.ts b/extensions/line/src/question-postback.lazy.test.ts new file mode 100644 index 000000000000..5a2e713e1d9b --- /dev/null +++ b/extensions/line/src/question-postback.lazy.test.ts @@ -0,0 +1,66 @@ +import { expect, it, vi } from "vitest"; + +const gateway = vi.hoisted(() => ({ + loaded: vi.fn(), + resolveOption: vi.fn< + (params: { authorize: () => boolean | Promise }) => Promise<{ + status: "answered" | "denied"; + }> + >(async () => ({ status: "answered" })), +})); + +vi.mock("openclaw/plugin-sdk/question-gateway-runtime", () => { + gateway.loaded(); + return { questionGatewayRuntime: { resolveOption: gateway.resolveOption } }; +}); + +it("loads the question Gateway only when resolving a tap", async () => { + const { + buildLineQuestionPostbackData, + parseLineQuestionPostbackData, + resolveLineQuestionPostback, + } = await import("./question-postback.js"); + const callback = { questionId: "ask_0123456789abcdef0123456789abcdef", optionIndex: 1 }; + const data = buildLineQuestionPostbackData(callback); + expect(parseLineQuestionPostbackData(data ?? "")).toEqual(callback); + expect(gateway.loaded).not.toHaveBeenCalled(); + + const cfg = {}; + const authorize = vi.fn(() => true); + await expect( + resolveLineQuestionPostback({ + cfg, + callback, + senderId: "user-one", + accountId: "default", + authorize, + }), + ).resolves.toEqual({ status: "answered" }); + expect(gateway.loaded).toHaveBeenCalledTimes(1); + expect(gateway.resolveOption).toHaveBeenCalledWith({ + cfg, + questionId: callback.questionId, + optionIndex: callback.optionIndex, + senderId: "user-one", + clientDisplayName: "LINE question (default)", + authorize, + }); +}); + +it("preserves a denied answer without turning it into a transport failure", async () => { + const { resolveLineQuestionPostback } = await import("./question-postback.js"); + const authorize = vi.fn(() => false); + gateway.resolveOption.mockImplementationOnce(async (params) => ({ + status: (await params.authorize()) ? "answered" : "denied", + })); + await expect( + resolveLineQuestionPostback({ + cfg: {}, + callback: { questionId: "ask_0123456789abcdef0123456789abcdef", optionIndex: 0 }, + senderId: "user-one", + accountId: "default", + authorize, + }), + ).resolves.toEqual({ status: "denied" }); + expect(authorize).toHaveBeenCalledOnce(); +}); diff --git a/extensions/line/src/question-postback.test.ts b/extensions/line/src/question-postback.test.ts new file mode 100644 index 000000000000..6045c29c12ee --- /dev/null +++ b/extensions/line/src/question-postback.test.ts @@ -0,0 +1,45 @@ +// Line tests cover the postback encoding for ask_user question controls. +import { describe, expect, it } from "vitest"; +import { + buildLineQuestionPostbackData, + parseLineQuestionPostbackData, +} from "./question-postback.js"; + +const QUESTION_ID = "ask_3d8dbe55be452a9a39add7c909beb119"; + +describe("LINE question postback data", () => { + it("round-trips a chosen option", () => { + const data = buildLineQuestionPostbackData({ questionId: QUESTION_ID, optionIndex: 2 }); + expect(data).toBe(`line.question=${QUESTION_ID}&line.option=2`); + expect(parseLineQuestionPostbackData(data!)).toEqual({ + questionId: QUESTION_ID, + optionIndex: 2, + }); + }); + + it("renders no control when the option index is unresolved", () => { + // -1 is what an unresolved Gateway option index looks like; a button that cannot + // name its option must not be offered at all. + expect( + buildLineQuestionPostbackData({ questionId: QUESTION_ID, optionIndex: -1 }), + ).toBeUndefined(); + }); + + it("renders no control when the reference would exceed LINE's postback limit", () => { + expect( + buildLineQuestionPostbackData({ questionId: "a".repeat(400), optionIndex: 0 }), + ).toBeUndefined(); + }); + + it("ignores postback data that carries no question", () => { + expect(parseLineQuestionPostbackData("line.action=play&line.device=tv")).toBeUndefined(); + expect(parseLineQuestionPostbackData("")).toBeUndefined(); + }); + + it("ignores a question reference with no usable choice", () => { + expect(parseLineQuestionPostbackData(`line.question=${QUESTION_ID}`)).toBeUndefined(); + expect( + parseLineQuestionPostbackData(`line.question=${QUESTION_ID}&line.option=abc`), + ).toBeUndefined(); + }); +}); diff --git a/extensions/line/src/question-postback.ts b/extensions/line/src/question-postback.ts new file mode 100644 index 000000000000..cfedbe1d136e --- /dev/null +++ b/extensions/line/src/question-postback.ts @@ -0,0 +1,87 @@ +// Line plugin module owns the postback encoding for ask_user question controls. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; + +const QUESTION_ID_PARAM = "line.question"; +const OPTION_INDEX_PARAM = "line.option"; +// LINE rejects a postback whose data exceeds 300 bytes, and it is the only place a +// question reference can ride back, so an oversized reference renders no button. +const POSTBACK_DATA_MAX_BYTES = 300; + +export type LineQuestionPostback = { questionId: string; optionIndex: number }; + +function withinPostbackLimit(data: string): string | undefined { + return Buffer.byteLength(data, "utf8") <= POSTBACK_DATA_MAX_BYTES ? data : undefined; +} + +/** Encodes one ask_user choice into LINE postback data, or nothing when it cannot fit. */ +export function buildLineQuestionPostbackData(callback: LineQuestionPostback): string | undefined { + const questionId = normalizeOptionalString(callback.questionId); + if (!questionId) { + return undefined; + } + const params = new URLSearchParams({ [QUESTION_ID_PARAM]: questionId }); + if (!Number.isInteger(callback.optionIndex) || callback.optionIndex < 0) { + return undefined; + } + params.set(OPTION_INDEX_PARAM, String(callback.optionIndex)); + return withinPostbackLimit(params.toString()); +} + +/** Reads a question choice back out of inbound postback data, if it carries one. */ +export function parseLineQuestionPostbackData(data: string): LineQuestionPostback | undefined { + if (!data.includes(QUESTION_ID_PARAM)) { + return undefined; + } + const params = new URLSearchParams(data); + const questionId = normalizeOptionalString(params.get(QUESTION_ID_PARAM)); + if (!questionId) { + return undefined; + } + const rawIndex = normalizeOptionalString(params.get(OPTION_INDEX_PARAM)); + if (!rawIndex || !/^\d+$/.test(rawIndex)) { + return undefined; + } + return { questionId, optionIndex: Number(rawIndex) }; +} + +/** + * Submit an ask_user choice a LINE tap carried, and report only what the user needs. + * + * A successful answer needs no acknowledgement: the agent's own next reply is the + * feedback, and LINE already echoes the chosen label through the action's + * `displayText`. Only an answer that cannot land says so. + */ +export async function resolveLineQuestionPostback(params: { + cfg: OpenClawConfig; + callback: LineQuestionPostback; + senderId?: string; + accountId: string; + authorize: () => boolean | Promise; +}): Promise<{ status: "answered" | "already-terminal" | "denied" | "failed" }> { + try { + const { questionGatewayRuntime } = await import("openclaw/plugin-sdk/question-gateway-runtime"); + const result = await questionGatewayRuntime.resolveOption({ + cfg: params.cfg, + questionId: params.callback.questionId, + senderId: params.senderId, + clientDisplayName: `LINE question (${params.accountId})`, + optionIndex: params.callback.optionIndex, + authorize: params.authorize, + }); + if ( + result.status === "answered" || + result.status === "already-terminal" || + result.status === "denied" + ) { + return { status: result.status }; + } + // The resolver reports custom-input only to a request that asked for one, and + // this path submits an option index, so anything else could not be recorded. + return { status: "failed" }; + } catch { + // The tap is the user's only signal that anything happened; a swallowed failure + // would leave the question looking answered while the agent still waits. + return { status: "failed" }; + } +} diff --git a/extensions/line/src/rich-messages.ts b/extensions/line/src/rich-messages.ts index 78a8823b7362..591c2cf62ec1 100644 --- a/extensions/line/src/rich-messages.ts +++ b/extensions/line/src/rich-messages.ts @@ -9,9 +9,15 @@ import { resolveMessagePresentationButtonAction, resolveMessagePresentationOptionAction, type MessagePresentation, + type MessagePresentationAction, type MessagePresentationBlock, type MessagePresentationButton, } from "openclaw/plugin-sdk/interactive-runtime"; +import { + resolveAskUserQuestionOptionIndex, + resolveAskUserQuestionOptionIndices, + type AskUserQuestionOptionIndices, +} from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { isRecord, @@ -29,6 +35,8 @@ import { } from "./flex-templates/media-control-cards.js"; import { fitsLineFlexBubble } from "./flex-templates/message.js"; import { createAgendaCard, createEventCard } from "./flex-templates/schedule-cards.js"; +import { inferLineTargetChatType } from "./messaging-target.js"; +import { buildLineQuestionPostbackData, type LineQuestionPostback } from "./question-postback.js"; import type { LineQuickReplyItem, LineRichCard } from "./types.js"; const nonempty = () => Type.String({ minLength: 1 }); @@ -136,9 +144,62 @@ export const LINE_PRESENTATION_CAPABILITIES = { }, } satisfies NonNullable; -function toLineAction(button: MessagePresentationButton): Action | undefined { +/** + * Reads the choice one question button carries. The Gateway owns option order, so a + * tap sends the index it published, never the rendered label; a choice it no longer + * lists renders no button at all rather than a tap that answers the wrong option. + */ +function toLineQuestionChoice( + action: Extract, + questionOptionIndices: AskUserQuestionOptionIndices | undefined, +): LineQuestionPostback | undefined { + if ("intent" in action) { + // The free-text control is dropped before the card is built, so only a + // declared choice ever reaches here. + return undefined; + } + const optionIndex = resolveAskUserQuestionOptionIndex({ + questionOptionIndices, + questionId: action.questionId, + optionValue: action.optionValue, + }); + return optionIndex === undefined ? undefined : { questionId: action.questionId, optionIndex }; +} + +/** + * The free-text control is not drawn. LINE can open the composer on a tap + * (`inputOption: "openKeyboard"`), so the platform is not the reason: an answer + * is claimed only on the plain-text inbound path, which no postback reaches, so + * the button cannot change whether what follows it counts as the answer. It + * would add a tap that changes nothing the card's own words already offer, which + * is why Discord and Slack leave that route in text too. + */ +function isLineTextFallbackButton(button: MessagePresentationButton): boolean { + const action = resolveMessagePresentationButtonAction(button); + return action?.type === "question" && "intent" in action && action.intent === "custom-input"; +} + +/** A control the Gateway owns, whose label the operator cannot disambiguate. */ +function isLineQuestionButton(button: MessagePresentationButton): boolean { + return resolveMessagePresentationButtonAction(button)?.type === "question"; +} + +function toLineAction( + button: MessagePresentationButton, + questionOptionIndices?: AskUserQuestionOptionIndices, +): Action | undefined { const normalized = resolveMessagePresentationButtonAction(button); const { label } = button; + if (normalized?.type === "question") { + const choice = toLineQuestionChoice(normalized, questionOptionIndices); + const data = choice && buildLineQuestionPostbackData(choice); + if (!data) { + return undefined; + } + // The postback carries the Gateway's canonical option index; LINE echoes + // the chosen label in the chat through displayText. + return { type: "postback", label, data, displayText: label }; + } if (normalized?.type === "command") { return { type: "message", label, text: normalized.command }; } @@ -154,7 +215,30 @@ function toLineAction(button: MessagePresentationButton): Action | undefined { return undefined; } -export function renderLinePresentation(payload: ReplyPayload, presentation: MessagePresentation) { +export function renderLinePresentation( + payload: ReplyPayload, + presentation: MessagePresentation, + to?: string, + sourcePresentation: MessagePresentation = presentation, +) { + const hasQuestion = sourcePresentation.blocks.some( + (block) => block.type === "buttons" && block.buttons.some(isLineQuestionButton), + ); + const hasAuthoredPrompt = + Boolean(sourcePresentation.title?.trim()) || + sourcePresentation.blocks.some( + (block) => (block.type === "text" || block.type === "context") && block.text.trim(), + ); + // Adaptation may add Actions/Other guidance, which cannot replace the prompt. + // Declining native rendering preserves the producer's complete text fallback. + if (hasQuestion && !hasAuthoredPrompt) { + return null; + } + // Group and room postbacks do not carry the sender identity required by + // question admission. Keep their choices readable through the shared fallback. + if (inferLineTargetChatType(to ?? "") !== "direct" && hasQuestion) { + return null; + } const hasCard = presentation.blocks.some( (block) => block.type === "buttons" && block.buttons.length > 0, ); @@ -162,13 +246,35 @@ export function renderLinePresentation(payload: ReplyPayload, presentation: Mess const quickReplyItems: LineQuickReplyItem[] = []; const carriedBlocks: MessagePresentationBlock[] = []; const cardBody: string[] = []; + // Controls this renderer declines to draw. The shared adapter already names a + // control it drops for budget under `Actions:`, so naming these the same way + // keeps one wording for "offered, but not tappable here" no matter which layer + // dropped it; without it a two- or three-option question loses the free-text + // route from the card entirely. + const omittedControlLabels: string[] = []; + const questionLabels = new Set(); + const questionOptionIndices = resolveAskUserQuestionOptionIndices(payload); for (const block of presentation.blocks) { if (block.type === "buttons") { for (const button of block.buttons) { - const action = toLineAction(button); + if (isLineTextFallbackButton(button)) { + omittedControlLabels.push(button.label); + continue; + } + const action = toLineAction(button, questionOptionIndices); if (!action) { return null; } + // Two Gateway options are distinct by contract, but a label is truncated + // to fit the control. Options that collide after that would be two + // identical taps, so the whole reply falls back to text that still + // distinguishes them. + if (isLineQuestionButton(button)) { + if (questionLabels.has(button.label)) { + return null; + } + questionLabels.add(button.label); + } buttons.push({ label: button.label, action }); } } else if (block.type === "select") { @@ -200,12 +306,18 @@ export function renderLinePresentation(payload: ReplyPayload, presentation: Mess if (buttons.length === 0 && quickReplyItems.length === 0) { return null; } + if (hasCard && omittedControlLabels.length > 0) { + cardBody.push(`Actions:\n${omittedControlLabels.map((label) => `- ${label}`).join("\n")}`); + } const lineData = isRecord(payload.channelData?.line) ? payload.channelData.line : {}; const title = presentation.title || "Choose an option"; + // The card's own heading can be generic, but altText is the whole message in + // the notification and the chat list, so it carries the words being asked. + const altText = presentation.title || cardBody[0] || title; const flexMessage = hasCard ? { - altText: title, + altText, contents: createActionCard(title, cardBody.join("\n") || "Choose an option.", buttons), } : undefined; @@ -237,7 +349,10 @@ export function renderLinePresentation(payload: ReplyPayload, presentation: Mess * replies the plugin delivers itself reach delivery with the controls still * portable. Preparing them here keeps both LINE delivery paths on one rendering. */ -export async function prepareLineReplyPayload(payload: ReplyPayload): Promise { +export async function prepareLineReplyPayload( + payload: ReplyPayload, + to?: string, +): Promise { if (!normalizeMessagePresentation(payload.presentation)) { return payload; } @@ -246,8 +361,13 @@ export async function prepareLineReplyPayload(payload: ReplyPayload): Promise { - const rendered = renderLinePresentation(adapted, adapted.presentation); + renderPresentation: (adapted, sourcePresentation) => { + const rendered = renderLinePresentation( + adapted, + adapted.presentation, + to, + sourcePresentation, + ); // Quick replies have no Flex body to replace the author's fallback prose. return rendered && usesFallbackText && rendered.channelData.line.flexMessage === undefined ? { ...rendered, text: payload.text } diff --git a/extensions/line/src/send.test.ts b/extensions/line/src/send.test.ts index 59ea91ff55a5..711039f2ae2b 100644 --- a/extensions/line/src/send.test.ts +++ b/extensions/line/src/send.test.ts @@ -731,6 +731,60 @@ describe("LINE send helpers", () => { expect(bodies[1]?.messages).toEqual([{ type: "text", text: "answering you" }]); }); + it.each( + (["push", "reply"] as const).flatMap((operation) => [ + { operation, stage: "initial denial", allowed: false, allowFallback: false, attempts: 0 }, + { + operation, + stage: "revoked quote fallback", + allowed: true, + allowFallback: false, + attempts: 1, + }, + { + operation, + stage: "allowed quote fallback", + allowed: true, + allowFallback: true, + attempts: 2, + }, + ]), + )("authorizes $operation at each attempt: $stage", async (testCase) => { + let allowed = testCase.allowed; + const authorize = vi.fn(async () => allowed); + lineFetchMock.mockImplementationOnce(async () => { + allowed = testCase.allowFallback; + return new Response("invalid quote", { status: 400, statusText: "Bad Request" }); + }); + const options = { cfg: LINE_TEST_CFG, quoteToken: "stale-token", authorize }; + const messages = [{ type: "text" as const, text: "answering you" }]; + const sending = + testCase.operation === "push" + ? sendModule.pushMessagesLine("U0123456789abcdef0123456789abcdef", messages, options) + : sendModule.replyMessageLine("reply-token", messages, options); + + if (testCase.allowFallback) { + await sending; + } else { + await expect(sending).rejects.toThrow("LINE send authorization denied"); + } + expect(lineFetchMock).toHaveBeenCalledTimes(testCase.attempts); + expect(authorize).toHaveBeenCalledTimes(testCase.allowed ? 2 : 1); + if (testCase.allowFallback) { + const messagesSent = lineFetchMock.mock.calls.map(([, init]) => { + const body = (init as RequestInit).body; + if (typeof body !== "string") { + throw new Error("Expected LINE request JSON"); + } + return (JSON.parse(body) as { messages: unknown[] }).messages; + }); + expect(messagesSent).toEqual([ + [{ type: "text", text: "answering you", quoteToken: "stale-token" }], + [{ type: "text", text: "answering you" }], + ]); + } + }); + it("does not resend a rejected send that carried no quote", async () => { lineFetchMock.mockResolvedValueOnce( new Response("invalid payload", { status: 400, statusText: "Bad Request" }), diff --git a/extensions/line/src/send.ts b/extensions/line/src/send.ts index 917be3d22df5..7711bf12b384 100644 --- a/extensions/line/src/send.ts +++ b/extensions/line/src/send.ts @@ -111,12 +111,14 @@ interface LineSendOpts { trackingId?: string; replyToken?: string; quoteToken?: string; + /** Revalidate immediately before every provider attempt, including retries. */ + authorize?: () => boolean | Promise; } type LineClientOpts = Pick; type LinePushOpts = Pick< LineSendOpts, - "cfg" | "channelAccessToken" | "accountId" | "verbose" | "quoteToken" + "cfg" | "channelAccessToken" | "accountId" | "verbose" | "quoteToken" | "authorize" >; interface LinePushBehavior { @@ -221,9 +223,10 @@ async function sendLineProviderMessages( token: string, request: LineProviderRequest, retryKey?: string, + authorize?: LineSendOpts["authorize"], ): Promise { try { - return await postLineProviderMessages(operation, token, request, retryKey); + return await postLineProviderMessages(operation, token, request, retryKey, authorize); } catch (error) { // LINE refuses the whole request for a quote token it no longer accepts and // names no field in the answer, so a quoted reply would simply disappear. @@ -241,6 +244,7 @@ async function sendLineProviderMessages( token, { ...request, messages: unquoted }, retryKey, + authorize, ); } } @@ -250,7 +254,11 @@ async function postLineProviderMessages( token: string, request: LineProviderRequest, retryKey?: string, + authorize?: LineSendOpts["authorize"], ): Promise { + if (authorize && !(await authorize())) { + throw new Error("LINE send authorization denied"); + } const response = await fetchWithRuntimeDispatcherOrMockedGlobal( `https://api.line.me/v2/bot/message/${operation}`, { @@ -412,6 +420,7 @@ async function pushLineMessages( token, { to: chatId, messages: normalizedMessages }, retryKey, + opts.authorize, ); } catch (err) { if (behavior.errorContext) { @@ -455,10 +464,13 @@ async function replyLineMessages( normalizeLineMessage, ); - const response = await sendLineProviderMessages("reply", token, { - replyToken, - messages: normalizedMessages, - }); + const response = await sendLineProviderMessages( + "reply", + token, + { replyToken, messages: normalizedMessages }, + undefined, + opts.authorize, + ); const result = resolveLineProviderMessageIds(response, "reply"); return { ...result, accountId: account.accountId }; } diff --git a/test/line-question-gateway.test.ts b/test/line-question-gateway.test.ts new file mode 100644 index 000000000000..0da292728839 --- /dev/null +++ b/test/line-question-gateway.test.ts @@ -0,0 +1,469 @@ +// Public LINE entry proof keeps webhook admission and both question resolvers real. +import { createHmac } from "node:crypto"; +import { createJiti } from "jiti"; +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import { createMockIncomingRequest, createMockServerResponse } from "openclaw/plugin-sdk/test-env"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as linePublicApi from "../extensions/line/api.js"; +import lineEntry from "../extensions/line/index.js"; +import { + closeOpenClawStateDatabaseForTest, + createChannelIngressQueueForTests, +} from "../src/plugin-sdk/channel-ingress-test-runtime.js"; +import { createPluginRuntimeMock } from "../src/plugin-sdk/test-helpers/plugin-runtime-mock.js"; +import { createStartAccountContext } from "../src/plugin-sdk/test-helpers/start-account-context.js"; +import { createDeferred } from "./helpers/promise.js"; +import { createTempDirTracker } from "./helpers/temp-dir.js"; + +type GatewayCall = { method: string; params?: Record }; +type HttpRoute = Parameters< + typeof import("../src/plugins/http-registry.js").registerPluginHttpRoute +>[0]; +const boundary = vi.hoisted(() => ({ + paired: [] as string[], + trace: [] as string[], + onRoute: undefined as ((route: HttpRoute) => void) | undefined, + callGateway: vi.fn<(request: GatewayCall) => Promise>(), + upsertPairing: vi.fn(async () => ({ code: "CODE", created: true })), +})); + +vi.mock("../src/gateway/call.js", () => ({ callGateway: boundary.callGateway })); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => ({ + resolvePairingIdLabel: (await import("../src/pairing/pairing-labels.js")).resolvePairingIdLabel, + readChannelAllowFromStore: async ( + ...args: Parameters + ) => { + expect(args[0]).toBe("line"); + expect(args[2]).toBe("default"); + boundary.trace.push(`store:${boundary.paired.length ? "allowed" : "denied"}`); + return [...boundary.paired]; + }, + upsertChannelPairingRequest: boundary.upsertPairing, +})); +vi.mock("../src/plugins/http-registry.js", async (importOriginal) => ({ + ...(await importOriginal()), + registerPluginHttpRoute: (route: HttpRoute) => { + if (!boundary.onRoute) { + throw new Error("Unexpected LINE webhook registration"); + } + boundary.onRoute(route); + return () => { + boundary.onRoute = undefined; + }; + }, +})); + +// The existing entry test loader keeps the public plugin module in Vitest's graph, +// so Gateway transport and store IO are intercepted. Seed the actual public runtime +// store below; the unchanged broad bootstrap sidecar loader is outside this fixture. +// No private plugin imports or production exports are added for this test. +const createEntryLoader: typeof createJiti = (...loaderArgs) => + new Proxy(createJiti(...loaderArgs), { + apply(target, thisArg, args) { + if (typeof args[0] === "string" && /[/\\]channel-plugin-api\.[cm]?[jt]s$/.test(args[0])) { + return linePublicApi; + } + return Reflect.apply(target, thisArg, args); + }, + }); + +const userId = "U0123456789abcdef0123456789abcdef"; +const questionId = "ask_0123456789abcdef0123456789abcdef"; +const secret = "line-question-test-secret"; // pragma: allowlist secret +const tempDirs = createTempDirTracker(); +const runtimeStore = createPluginRuntimeStore({ + pluginId: "line", + errorMessage: "LINE fixture runtime not initialized", +}); + +beforeEach(() => { + boundary.paired = []; + boundary.trace = []; + boundary.onRoute = undefined; + boundary.callGateway.mockReset(); + boundary.upsertPairing.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + runtimeStore.clearRuntime(); + closeOpenClawStateDatabaseForTest(); + tempDirs.cleanup(); +}); + +type Scenario = { + name: string; + paired: boolean; + policy: "pairing" | "allowlist"; + lookup: "pending" | "terminal" | "failed"; + revoke: "none" | "read" | "reply" | "push"; + push: "success" | "server-error" | "transport-error"; + reply: "success" | "reject" | "partial"; + replyToken: string; + writes: number; + replies: number; + pushes: number; + pairings: number; +}; +function scenario(name: string, overrides: Partial = {}): Scenario { + return { + name, + paired: true, + policy: "pairing", + lookup: "pending", + revoke: "none", + push: "success", + reply: "success", + replyToken: "reply-token", + writes: 1, + replies: 0, + pushes: 0, + pairings: 0, + ...overrides, + }; +} + +const scenarios = [ + scenario("allowed pending answer"), + scenario("initially forbidden", { paired: false, policy: "allowlist", writes: 0 }), + scenario("initially unpaired follows pairing policy", { + paired: false, + writes: 0, + replies: 1, + pairings: 1, + }), + scenario("revoked during pending lookup", { revoke: "read", writes: 0 }), + scenario("revoked during terminal lookup", { lookup: "terminal", revoke: "read", writes: 0 }), + scenario("revoked during failed lookup", { lookup: "failed", revoke: "read", writes: 0 }), + scenario("allowed terminal notice", { lookup: "terminal", writes: 0, replies: 1 }), + scenario("allowed failed lookup notice", { lookup: "failed", writes: 0, replies: 1 }), + scenario("revoked during failed reply prevents push", { + lookup: "terminal", + revoke: "reply", + reply: "reject", + writes: 0, + replies: 1, + }), + scenario("allowed failed reply falls back to push", { + lookup: "terminal", + reply: "reject", + writes: 0, + replies: 1, + pushes: 1, + }), + scenario("partial reply never duplicates as push", { + lookup: "terminal", + reply: "partial", + writes: 0, + replies: 1, + }), + scenario("revoked terminal notice without reply token", { + lookup: "terminal", + revoke: "read", + replyToken: "", + writes: 0, + }), + ...(["server-error", "transport-error"] as const).flatMap((push) => [ + scenario(`revoked after ${push} prevents push retry`, { + lookup: "terminal", + replyToken: "", + revoke: "push", + push, + writes: 0, + pushes: 1, + }), + scenario(`allowed ${push} retries under the same key`, { + lookup: "terminal", + replyToken: "", + push, + writes: 0, + pushes: 2, + }), + ]), + scenario("allowed terminal notice without reply token", { + lookup: "terminal", + replyToken: "", + writes: 0, + pushes: 1, + }), +]; + +describe("LINE public webhook question Gateway boundary", () => { + it.each(scenarios)("$name", async (testCase) => { + boundary.paired = testCase.paired ? [userId] : []; + let questionStatus = testCase.lookup === "terminal" ? "answered" : "pending"; + boundary.callGateway.mockImplementation(async ({ method }) => { + boundary.trace.push(method); + if (method === "question.get") { + if (testCase.revoke === "read") { + boundary.paired = []; + boundary.trace.push("pairing:revoked"); + } + if (testCase.lookup === "failed") { + throw new Error("Fixture Gateway lookup failed"); + } + return { + question: { + id: questionId, + status: questionStatus, + questions: [ + { + questionId: "deploy_target", + header: "Target", + question: "Where should this deploy?", + options: [{ label: "Staging" }, { label: "Production" }], + }, + ], + }, + }; + } + if (method === "question.resolve") { + questionStatus = "answered"; + return { status: questionStatus }; + } + throw new Error(`Unexpected Gateway method: ${method}`); + }); + const providerCalls: Array<{ operation: string; body: unknown; retryKey: string | null }> = []; + const unexpectedProviderIo: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const rejectUnexpectedIo = (reason: string): never => { + unexpectedProviderIo.push(`${method} ${url.origin}${url.pathname}: ${reason}`); + throw new Error(reason); + }; + if (url.origin !== "https://api.line.me") { + return rejectUnexpectedIo("Unexpected provider origin"); + } + if (method === "GET" && url.pathname === "/v2/bot/info") { + return Response.json({ userId: "bot", displayName: "Fixture LINE" }); + } + if (method === "GET" && url.pathname === "/v2/bot/message/quota") { + return Response.json({ type: "none" }); + } + if (method === "GET" && url.pathname === "/v2/bot/channel/webhook/endpoint") { + return Response.json({ endpoint: "https://example.test/line/webhook", active: true }); + } + if ( + method !== "POST" || + (url.pathname !== "/v2/bot/message/reply" && url.pathname !== "/v2/bot/message/push") + ) { + return rejectUnexpectedIo("Unexpected LINE operation"); + } + const operation = url.pathname === "/v2/bot/message/reply" ? "reply" : "push"; + if (typeof init?.body !== "string") { + return rejectUnexpectedIo("Expected JSON string request body"); + } + let requestBody: unknown; + try { + requestBody = JSON.parse(init.body); + } catch { + return rejectUnexpectedIo("Invalid JSON request body"); + } + providerCalls.push({ + operation, + body: requestBody, + retryKey: new Headers(init?.headers).get("X-Line-Retry-Key"), + }); + boundary.trace.push(`line:${operation}`); + if ( + operation === "push" && + testCase.push !== "success" && + providerCalls.filter((call) => call.operation === "push").length === 1 + ) { + if (testCase.revoke === "push") { + boundary.paired = []; + boundary.trace.push("pairing:revoked"); + } + if (testCase.push === "transport-error") { + throw Object.assign(new Error("Fixture socket reset"), { code: "ECONNRESET" }); + } + return Response.json({ message: "Fixture server error" }, { status: 500 }); + } + if (operation === "reply" && testCase.revoke === "reply") { + boundary.paired = []; + boundary.trace.push("pairing:revoked"); + } + if (operation === "reply" && testCase.reply === "reject") { + return Response.json({ message: "Invalid reply token" }, { status: 400 }); + } + if (operation === "reply" && testCase.reply === "partial") { + return new Response("not-json", { status: 200 }); + } + return Response.json({ sentMessages: [{ id: `message-${providerCalls.length}` }] }); + }), + ); + + const stateDir = tempDirs.make("openclaw-line-question-"); + const queue = createChannelIngressQueueForTests({ + channelId: "line", + accountId: "default", + stateDir, + }); + const runtime = createPluginRuntimeMock(); + runtime.state.openChannelIngressQueue = < + TPayload, + TMetadata = unknown, + TCompletedMetadata = unknown, + >( + options?: Parameters[0], + ) => + createChannelIngressQueueForTests({ + ...options, + channelId: "line", + stateDir, + }); + const runTurn = vi.fn(async () => { + throw new Error("Question taps must not start an agent turn"); + }); + runtime.channel.inbound.run = runTurn; + const plugin = lineEntry.loadChannelPlugin({ createLoaderForTest: createEntryLoader }); + expect(plugin).toBe(linePublicApi.linePlugin); + runtimeStore.setRuntime(runtime); + const cfg: OpenClawConfig = { + channels: { + line: { + channelAccessToken: "fixture-token", + channelSecret: secret, + dmPolicy: testCase.policy, + allowFrom: [], + }, + }, + }; + const account = linePublicApi.linePlugin.config.resolveAccount(cfg, "default"); + const abort = new AbortController(); + const runtimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: () => { + throw new Error("Unexpected runtime exit"); + }, + }; + if (!plugin.gateway?.startAccount) { + throw new Error("Expected the public LINE gateway adapter"); + } + const registered = createDeferred(); + boundary.onRoute = registered.resolve; + const monitor = plugin.gateway.startAccount( + createStartAccountContext({ account, cfg, runtime: runtimeEnv, abortSignal: abort.signal }), + ); + try { + const route = await Promise.race([ + registered.promise, + monitor.then(() => { + throw new Error("LINE monitor stopped before webhook registration"); + }), + ]); + const body = JSON.stringify({ + destination: "bot", + events: [ + { + type: "postback", + replyToken: testCase.replyToken, + timestamp: Date.now(), + source: { type: "user", userId }, + mode: "active", + webhookEventId: `question-${testCase.name}`, + deliveryContext: { isRedelivery: false }, + postback: { data: `line.question=${questionId}&line.option=1` }, + }, + ], + }); + const req = createMockIncomingRequest([body]); + req.method = "POST"; + req.url = "/line/webhook"; + req.headers = { + "content-type": "application/json", + "x-line-signature": createHmac("sha256", secret).update(body).digest("base64"), + }; + const res = createMockServerResponse(); + await route.handler(req, res); + expect(res.statusCode).toBe(200); + expect(res.getHeader("x-openclaw-delivery-accepted")).toBe("durable"); + await vi.waitFor( + async () => { + if (testCase.policy === "pairing") { + expect(boundary.trace.some((entry) => entry.startsWith("store:"))).toBe(true); + } + expect(await queue.listPending()).toHaveLength(0); + expect(await queue.listClaims()).toHaveLength(0); + }, + { timeout: 5_000 }, + ); + expect(await queue.listFailed?.()).toEqual([]); + const calls = boundary.callGateway.mock.calls.map(([request]) => request); + console.info( + "LINE_QUESTION_GATEWAY_PROOF", + JSON.stringify({ + scenario: testCase.name, + trace: boundary.trace, + writes: calls.filter((request) => request.method === "question.resolve").length, + replies: providerCalls.filter((request) => request.operation === "reply").length, + pushes: providerCalls.filter((request) => request.operation === "push").length, + pairings: boundary.upsertPairing.mock.calls.length, + }), + ); + expect(unexpectedProviderIo).toEqual([]); + expect(calls.map((request) => request.method)).toEqual( + testCase.paired + ? testCase.writes + ? ["question.get", "question.resolve"] + : ["question.get"] + : [], + ); + for (const request of providerCalls) { + expect(request.body).toMatchObject({ + ...(request.operation === "reply" ? { replyToken: testCase.replyToken } : { to: userId }), + messages: [{ type: "text", text: expect.any(String) }], + }); + } + expect(calls.filter((request) => request.method === "question.resolve")).toHaveLength( + testCase.writes, + ); + expect(providerCalls.filter((request) => request.operation === "reply")).toHaveLength( + testCase.replies, + ); + expect(providerCalls.filter((request) => request.operation === "push")).toHaveLength( + testCase.pushes, + ); + const pushKeys = providerCalls + .filter((request) => request.operation === "push") + .map((request) => request.retryKey); + if (pushKeys.length) { + expect(pushKeys[0]).toEqual(expect.any(String)); + expect(new Set(pushKeys).size).toBe(1); + } + expect(boundary.upsertPairing).toHaveBeenCalledTimes(testCase.pairings); + expect(runTurn).not.toHaveBeenCalled(); + if (testCase.writes) { + expect(questionStatus).toBe("answered"); + expect(calls.at(-1)?.params).toEqual({ + id: questionId, + answers: { answers: { deploy_target: ["Production"] } }, + resolvedBy: userId, + }); + expect(boundary.trace).toEqual([ + "store:allowed", + "question.get", + "store:allowed", + "question.resolve", + ]); + } else if (testCase.revoke === "read") { + expect(boundary.trace).toEqual([ + "store:allowed", + "question.get", + "pairing:revoked", + "store:denied", + ]); + } else if (!testCase.paired) { + expect(calls).toEqual([]); + } + } finally { + abort.abort(); + await monitor; + } + }); +});