mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
9db98f3f75
commit
008a95d805
15 changed files with 1767 additions and 121 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
66
extensions/line/src/question-postback.lazy.test.ts
Normal file
66
extensions/line/src/question-postback.lazy.test.ts
Normal 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();
|
||||
});
|
||||
45
extensions/line/src/question-postback.test.ts
Normal file
45
extensions/line/src/question-postback.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
87
extensions/line/src/question-postback.ts
Normal file
87
extensions/line/src/question-postback.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
469
test/line-question-gateway.test.ts
Normal file
469
test/line-question-gateway.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue