diff --git a/CHANGELOG.md b/CHANGELOG.md index f85f84f2560..820342f87a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/line/src/auto-reply-delivery.test.ts b/extensions/line/src/auto-reply-delivery.test.ts index 5f0c1c68013..3e06a7ecbf7 100644 --- a/extensions/line/src/auto-reply-delivery.test.ts +++ b/extensions/line/src/auto-reply-delivery.test.ts @@ -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, diff --git a/extensions/line/src/auto-reply-delivery.ts b/extensions/line/src/auto-reply-delivery.ts index fcced53036a..2f51fed6054 100644 --- a/extensions/line/src/auto-reply-delivery.ts +++ b/extensions/line/src/auto-reply-delivery.ts @@ -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 }; diff --git a/extensions/line/src/channel.sendPayload.test.ts b/extensions/line/src/channel.sendPayload.test.ts index eaadc450302..69b3950374d 100644 --- a/extensions/line/src/channel.sendPayload.test.ts +++ b/extensions/line/src/channel.sendPayload.test.ts @@ -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); diff --git a/extensions/line/src/outbound.ts b/extensions/line/src/outbound.ts index 4f2188377bb..b5703b4515d 100644 --- a/extensions/line/src/outbound.ts +++ b/extensions/line/src/outbound.ts @@ -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 quickReply, }; await sendMessageBatch(quickReplyMessages); + } else if (quickReply) { + lastResult = await sendQuickReplies( + to, + buildLineQuickReplyFallbackText(quickReplies), + quickReplies, + { + verbose: false, + cfg, + accountId: accountId ?? undefined, + }, + ); } } diff --git a/extensions/line/src/quick-reply-fallback.ts b/extensions/line/src/quick-reply-fallback.ts new file mode 100644 index 00000000000..c0c740aaafc --- /dev/null +++ b/extensions/line/src/quick-reply-fallback.ts @@ -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")}`; +}