feat(line): let a LINE tap answer the question an agent asked (#133421)

* feat(line): let a LINE tap answer the question an agent asked

* refactor(line): share the reply-then-push path both LINE handlers use

* docs(line): describe the native ask_user controls and their four-control bound

* fix(line): let an ask_user notification say what is being asked

* fix(line): answer a question tap that did not resolve the question

* docs(line): correct what the native question controls do

* test(line): type the question resolver double from the resolver it stands in for

* fix(line): open the composer when the ask_user free-text control is tapped

* fix(line): fall back to text when two options share a truncated control label

* docs(line): describe the question controls LINE actually draws

* fix(line): leave the ask_user free-text route to the words the card already carries

* docs(line): say why the free-text control is not drawn

* docs(line): keep the reason for omitting the control inside what LINE decides

* docs(line): give the omitted control a reason that does not depend on a runtime

* fix(line): name the omitted free-text control on every question card

* style(line): reflow the question-card fixture reader

* fix(line): defer question gateway runtime until postback resolution

* fix(line): render question controls only for direct chats

* fix(line): recheck access before recording question answers

* fix(line): preserve question text for button-only presentations

* fix(line): recheck question notice access for every send attempt

* test(line): await webhook registration through the public API

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Eden 2026-09-11 11:31:40 +08:00 • committed by GitHub
parent 9db98f3f75
commit 008a95d805
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1767 additions and 121 deletions

View file

@ -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",

View file

@ -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

View file

@ -9,10 +9,14 @@ type MessageEvent = webhook.MessageEvent;
const pairingDeliveryMocks = vi.hoisted(() => ({
invokePairingReply: false,
pushMessageLine: vi.fn(async () => {
pushMessageLine: vi.fn<
(...args: Parameters<typeof import("./send.js").pushMessageLine>) => Promise<void>
>(async () => {
throw new Error("pushMessageLine should not be called from bot-handlers tests");
}),
replyMessageLine: vi.fn(async () => {
replyMessageLine: vi.fn<
(...args: Parameters<typeof import("./send.js").replyMessageLine>) => Promise<void>
>(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<typeof import("./question-postback.js")>()),
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<string, HistoryEntry[]>();
let releaseTurn: () => void = () => {};

View file

@ -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<boolean>;
}): Promise<void> {
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<ResolvedChannelMessageIngress>;
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,

View file

@ -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<string | undefined> => {
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");

View file

@ -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<ChannelInboundTurnPlan, "delivery">;
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<typeof setLineRuntime>[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<ChannelInboundTurnPlan, "delivery">;
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<typeof setLineRuntime>[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<typeof onMessage>[0],
// Admission always hands the turn its live config; an empty one is unreachable.
{ cfg: {} } as Parameters<typeof onMessage>[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<typeof onMessage>[0],
// Admission always hands the turn its live config; an empty one is unreachable.
{ cfg: {} } as Parameters<typeof onMessage>[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

View file

@ -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,

View file

@ -49,7 +49,8 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
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();

View file

@ -0,0 +1,66 @@
import { expect, it, vi } from "vitest";
const gateway = vi.hoisted(() => ({
loaded: vi.fn(),
resolveOption: vi.fn<
(params: { authorize: () => boolean | Promise<boolean> }) => 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();
});

View file

@ -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();
});
});

View file

@ -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<boolean>;
}): 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" };
}
}

View file

@ -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<ChannelOutboundAdapter["presentationCapabilities"]>;
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<MessagePresentationAction, { type: "question" }>,
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<string>();
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<ReplyPayload> {
export async function prepareLineReplyPayload(
payload: ReplyPayload,
to?: string,
): Promise<ReplyPayload> {
if (!normalizeMessagePresentation(payload.presentation)) {
return payload;
}
@ -246,8 +361,13 @@ export async function prepareLineReplyPayload(payload: ReplyPayload): Promise<Re
return renderPresentationForDelivery(
{
presentationCapabilities: LINE_PRESENTATION_CAPABILITIES,
renderPresentation: (adapted) => {
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 }

View file

@ -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" }),

View file

@ -111,12 +111,14 @@ interface LineSendOpts {
trackingId?: string;
replyToken?: string;
quoteToken?: string;
/** Revalidate immediately before every provider attempt, including retries. */
authorize?: () => boolean | Promise<boolean>;
}
type LineClientOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId">;
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<LineProviderResponse> {
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<LineProviderResponse> {
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 };
}

View file

@ -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<string, unknown> };
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<unknown>>(),
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<typeof import("../src/pairing/pairing-store.js").readChannelAllowFromStore>
) => {
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<typeof import("../src/plugins/http-registry.js")>()),
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<PluginRuntime>({
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> = {}): 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<typeof fetch>(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<PluginRuntime["state"]["openChannelIngressQueue"]>[0],
) =>
createChannelIngressQueueForTests<TPayload, TMetadata, TCompletedMetadata>({
...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<HttpRoute>();
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;
}
});
});