mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(line): send quick-reply-only payloads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
extensions/line/src/quick-reply-fallback.ts
Normal file
10
extensions/line/src/quick-reply-fallback.ts
Normal 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")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user