fix(line): send quick-reply-only payloads

This commit is contained in:
Vincent Koc
2026-05-01 04:26:23 -07:00
parent 6776129315
commit 5230b09ca9
6 changed files with 115 additions and 9 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Discord/native commands: send component-only interaction replies from slash command and status handlers instead of treating renderable Discord components as an empty response. Thanks @vincentkoc.
- Slack/slash commands: send block-only slash command replies instead of dropping Slack block payloads with no plain-text fallback. Thanks @vincentkoc.
- Telegram/messages: derive fallback text from interactive button/select labels before sending button-only payloads, so Telegram replies are not rejected as empty messages. Thanks @vincentkoc.
- LINE/messages: send quick-reply-only payloads with fallback option text instead of accepting the payload and returning an empty delivery. Thanks @vincentkoc.
- Gateway/agent: reject strict `openclaw agent --deliver` requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc.
- Setup/import: honor non-interactive `--import-from` onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc.
- Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram.

View File

@@ -137,6 +137,42 @@ describe("deliverLineAutoReply", () => {
expect(createQuickReplyItems).toHaveBeenCalledWith(["A"]);
});
it("uses fallback text for quick-reply-only payloads", async () => {
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,
text,
quickReply: { items: ["A", "B"] },
}));
const lineData = {
quickReplies: ["A", "B"],
};
const { deps, replyMessageLine, pushMessagesLine } = createDeps({
createTextMessageWithQuickReplies:
createTextMessageWithQuickReplies as LineAutoReplyDeps["createTextMessageWithQuickReplies"],
});
const result = await deliverLineAutoReply({
...baseDeliveryParams,
payload: { text: "", channelData: { line: lineData } },
lineData,
deps,
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{
type: "text",
text: "Options:\n- A\n- B",
quickReply: { items: ["A", "B"] },
},
],
{ cfg: LINE_TEST_CFG, accountId: "acc" },
);
expect(pushMessagesLine).not.toHaveBeenCalled();
});
it("sends rich messages before quick-reply text so quick replies remain visible", async () => {
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,

View File

@@ -4,6 +4,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { FlexContainer } from "./flex-templates.js";
import type { ProcessedLineMessage } from "./markdown-to-line.js";
import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js";
import type { SendLineReplyChunksParams } from "./reply-chunks.js";
import type { LineChannelData, LineTemplateMessagePayload } from "./types.js";
@@ -165,16 +166,34 @@ export async function deliverLineAutoReply(params: {
}
} else {
const combined = [...richMessages, ...mediaMessages];
if (hasQuickReplies && combined.length > 0) {
const quickReply = deps.createQuickReplyItems(lineData.quickReplies!);
const targetIndex =
replyToken && !replyTokenUsed ? Math.min(4, combined.length - 1) : combined.length - 1;
const target = combined[targetIndex] as messagingApi.Message & {
quickReply?: messagingApi.QuickReply;
};
combined[targetIndex] = { ...target, quickReply };
if (hasQuickReplies && combined.length === 0) {
const { replyTokenUsed: nextReplyTokenUsed } = await deps.sendLineReplyChunks({
to,
chunks: [buildLineQuickReplyFallbackText(lineData.quickReplies)],
quickReplies: lineData.quickReplies,
replyToken,
replyTokenUsed,
cfg: params.cfg,
accountId,
replyMessageLine: deps.replyMessageLine,
pushMessageLine: deps.pushMessageLine,
pushTextMessageWithQuickReplies: deps.pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies,
onReplyError: deps.onReplyError,
});
replyTokenUsed = nextReplyTokenUsed;
} else {
if (hasQuickReplies && combined.length > 0) {
const quickReply = deps.createQuickReplyItems(lineData.quickReplies!);
const targetIndex =
replyToken && !replyTokenUsed ? Math.min(4, combined.length - 1) : combined.length - 1;
const target = combined[targetIndex] as messagingApi.Message & {
quickReply?: messagingApi.QuickReply;
};
combined[targetIndex] = { ...target, quickReply };
}
await sendLineMessages(combined, true);
}
await sendLineMessages(combined, true);
}
return { replyTokenUsed };

View File

@@ -202,6 +202,34 @@ describe("line outbound sendPayload", () => {
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
});
it("sends quick-reply-only payloads with fallback text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
const result = await lineOutboundAdapter.sendPayload!({
to: "line:user:quick",
text: "",
payload: {
channelData: {
line: {
quickReplies: ["One", "Two"],
},
},
},
accountId: "default",
cfg,
});
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
"line:user:quick",
"Options:\n- One\n- Two",
["One", "Two"],
{ verbose: false, accountId: "default", cfg },
);
expect(result).toEqual({ channel: "line", messageId: "m-quick", chatId: "c1" });
});
it("sends media before quick-reply text so buttons stay visible", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);

View File

@@ -6,6 +6,7 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js";
import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js";
import { getLineRuntime } from "./runtime.js";
import type { LineChannelData } from "./types.js";
@@ -292,6 +293,17 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
quickReply,
};
await sendMessageBatch(quickReplyMessages);
} else if (quickReply) {
lastResult = await sendQuickReplies(
to,
buildLineQuickReplyFallbackText(quickReplies),
quickReplies,
{
verbose: false,
cfg,
accountId: accountId ?? undefined,
},
);
}
}

View File

@@ -0,0 +1,10 @@
export function buildLineQuickReplyFallbackText(labels: readonly string[] | undefined): string {
const normalized = (labels ?? [])
.map((label) => label.trim())
.filter(Boolean)
.slice(0, 13);
if (normalized.length === 0) {
return "Choose an option.";
}
return `Options:\n${normalized.map((label) => `- ${label}`).join("\n")}`;
}