mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix: route direct outbound polls through channel adapters (#99950)
* fix: route direct outbound polls through channel adapters * docs: note direct poll delivery * test: isolate direct poll formatter label --------- Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- **Exec approval prompts:** keep background-disabled fallback warnings out of pending gateway/node approvals and show them only after a command actually runs in the foreground. (#78184) Thanks @vincentkoc.
|
||||
- **Direct poll delivery:** route direct and hybrid channel polls through the owning outbound adapter while preserving gateway-mode routing and channel option checks. (#99950) Thanks @NianJiuZst.
|
||||
- **Agent wait hard-timeout snapshots:** preserve canonical hard-timeout phase and timestamps when the outer `agent.wait` timer wins the retry-grace race, while leaving queue, draining, and restart-cancelled waits correctable. (#89367) Thanks @Pick-cat.
|
||||
- **Control UI typed approvals:** send `/approve` commands immediately through the authorized Gateway command path while an agent run is blocked instead of queueing the command behind that run. (#77672) Thanks @vincentkoc.
|
||||
- **Microsoft Teams Graph response bounds:** cap successful file-upload and chat JSON reads so oversized Microsoft Graph responses cannot be buffered without limit. (#97784) Thanks @Alix-007.
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
// Tests for CLI message text formatting helpers (renderMessageList, formatMessageCliText).
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js";
|
||||
import { formatMessageCliText } from "./message-format.js";
|
||||
|
||||
const getChannelPluginMock = vi.hoisted(() =>
|
||||
vi.fn((channel: string) =>
|
||||
channel === "directchat" ? { meta: { label: "Direct Chat" } } : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: getChannelPluginMock,
|
||||
getLoadedChannelPlugin: getChannelPluginMock,
|
||||
}));
|
||||
|
||||
function readResultPayload(payload: unknown) {
|
||||
return {
|
||||
kind: "action" as const,
|
||||
@@ -189,3 +201,37 @@ describe("renderPaginationHint", () => {
|
||||
expect(out).not.toContain("More results available");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMessageCliText poll results", () => {
|
||||
it("formats direct core poll results as direct deliveries", () => {
|
||||
const result = {
|
||||
kind: "poll",
|
||||
action: "poll",
|
||||
channel: "directchat",
|
||||
to: "room-1",
|
||||
handledBy: "core",
|
||||
payload: {},
|
||||
dryRun: false,
|
||||
pollResult: {
|
||||
channel: "directchat",
|
||||
to: "room-1",
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 1,
|
||||
durationSeconds: null,
|
||||
durationHours: null,
|
||||
via: "direct",
|
||||
result: {
|
||||
messageId: "p1",
|
||||
conversationId: "conv-1",
|
||||
pollId: "poll-1",
|
||||
},
|
||||
},
|
||||
} satisfies MessageActionRunResult;
|
||||
|
||||
expect(formatMessageCliText(result)).toEqual([
|
||||
"✅ Poll sent via Direct Chat. Message ID: p1 (conversation conv-1)",
|
||||
"Poll id: poll-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,6 +341,22 @@ export function formatMessageCliText(
|
||||
const poll = result.pollResult;
|
||||
const pollId = (poll.result as { pollId?: string } | undefined)?.pollId;
|
||||
const msgId = poll.result?.messageId ?? null;
|
||||
if (poll.via === "direct") {
|
||||
const directResult = poll.result
|
||||
? ({ ...poll.result, channel: poll.channel } satisfies OutboundDeliveryResult)
|
||||
: undefined;
|
||||
const lines = [
|
||||
ok(
|
||||
formatOutboundDeliverySummary(poll.channel, directResult, {
|
||||
action: "Poll sent",
|
||||
}),
|
||||
),
|
||||
];
|
||||
if (pollId) {
|
||||
lines.push(ok(`Poll id: ${pollId}`));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
const lines = [
|
||||
ok(
|
||||
formatGatewaySummary({
|
||||
|
||||
@@ -43,13 +43,15 @@ const resolveChannelLabel = (channel: string) => {
|
||||
export function formatOutboundDeliverySummary(
|
||||
channel: string,
|
||||
result?: OutboundDeliveryResult,
|
||||
opts?: { action?: string },
|
||||
): string {
|
||||
const action = opts?.action ?? "Sent";
|
||||
if (!result) {
|
||||
return `✅ Sent via ${resolveChannelLabel(channel)}. Message ID: unknown`;
|
||||
return `✅ ${action} via ${resolveChannelLabel(channel)}. Message ID: unknown`;
|
||||
}
|
||||
|
||||
const label = resolveChannelLabel(result.channel);
|
||||
const base = `✅ Sent via ${label}. Message ID: ${result.messageId}`;
|
||||
const base = `✅ ${action} via ${label}. Message ID: ${result.messageId}`;
|
||||
|
||||
if ("chatId" in result) {
|
||||
return `${base} (chat ${result.chatId})`;
|
||||
|
||||
@@ -234,21 +234,27 @@ describe("sendMessage replyToId threading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function setDemoPollRegistry(
|
||||
outboundOptions: Parameters<typeof createDemoAliasOutbound>[0] = {},
|
||||
) {
|
||||
setRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "demo-alias-channel",
|
||||
source: "test",
|
||||
plugin: createDemoAliasPlugin({
|
||||
aliases: ["workspace-chat"],
|
||||
outbound: createDemoAliasOutbound({ includePoll: true, ...outboundOptions }),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("sendPoll channel normalization", () => {
|
||||
it("normalizes plugin aliases for polls", async () => {
|
||||
it("normalizes plugin aliases for gateway polls", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
|
||||
setRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "demo-alias-channel",
|
||||
source: "test",
|
||||
plugin: createDemoAliasPlugin({
|
||||
aliases: ["workspace-chat"],
|
||||
outbound: createDemoAliasOutbound({ includePoll: true }),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
setDemoPollRegistry({ deliveryMode: "gateway" });
|
||||
|
||||
const result = await sendPoll({
|
||||
cfg: {},
|
||||
@@ -260,6 +266,76 @@ describe("sendPoll channel normalization", () => {
|
||||
|
||||
expect(gatewayCall()?.params?.channel).toBe("demo-alias-channel");
|
||||
expect(result.channel).toBe("demo-alias-channel");
|
||||
expect(result.via).toBe("gateway");
|
||||
});
|
||||
|
||||
it("uses direct poll fallback for direct channel plugins", async () => {
|
||||
const cfg = { channels: {} };
|
||||
const sendPollMock = vi.fn(async () => ({ messageId: "p1" }));
|
||||
setDemoPollRegistry({ supportsAnonymousPolls: true, sendPoll: sendPollMock });
|
||||
|
||||
const result = await sendPoll({
|
||||
cfg,
|
||||
to: "conversation:demo-target",
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
channel: "Workspace-Chat",
|
||||
accountId: "acct-1",
|
||||
threadId: "thread-1",
|
||||
silent: true,
|
||||
isAnonymous: false,
|
||||
});
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
channel: "demo-alias-channel",
|
||||
to: "conversation:demo-target",
|
||||
via: "direct",
|
||||
result: { messageId: "p1" },
|
||||
});
|
||||
expect(sendPollMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "conversation:demo-target",
|
||||
poll: {
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 1,
|
||||
},
|
||||
accountId: "acct-1",
|
||||
threadId: "thread-1",
|
||||
silent: true,
|
||||
isAnonymous: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "durationSeconds",
|
||||
params: { durationSeconds: 300 },
|
||||
message: "durationSeconds is not supported for demo-alias-channel polls",
|
||||
},
|
||||
{
|
||||
name: "isAnonymous",
|
||||
params: { isAnonymous: false },
|
||||
message: "isAnonymous is not supported for demo-alias-channel polls",
|
||||
},
|
||||
])("rejects unsupported direct poll option $name", async ({ params, message }) => {
|
||||
const sendPollMock = vi.fn(async () => ({ messageId: "p1" }));
|
||||
setDemoPollRegistry({ sendPoll: sendPollMock });
|
||||
|
||||
await expect(
|
||||
sendPoll({
|
||||
cfg: {},
|
||||
to: "conversation:demo-target",
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
channel: "Workspace-Chat",
|
||||
...params,
|
||||
}),
|
||||
).rejects.toThrow(message);
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
expect(sendPollMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,8 +533,14 @@ const createLocalChatAliasPlugin = (): ChannelPlugin => ({
|
||||
},
|
||||
});
|
||||
|
||||
const createDemoAliasOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({
|
||||
deliveryMode: "direct",
|
||||
const createDemoAliasOutbound = (opts?: {
|
||||
deliveryMode?: ChannelOutboundAdapter["deliveryMode"];
|
||||
includePoll?: boolean;
|
||||
supportsAnonymousPolls?: boolean;
|
||||
supportsPollDurationSeconds?: boolean;
|
||||
sendPoll?: NonNullable<ChannelOutboundAdapter["sendPoll"]>;
|
||||
}): ChannelOutboundAdapter => ({
|
||||
deliveryMode: opts?.deliveryMode ?? "direct",
|
||||
sendText: async ({ deps, to, text }) => {
|
||||
const send = deps?.["demo-alias-channel"] as
|
||||
| ((to: string, text: string, opts?: unknown) => Promise<{ messageId: string }>)
|
||||
@@ -482,7 +564,9 @@ const createDemoAliasOutbound = (opts?: { includePoll?: boolean }): ChannelOutbo
|
||||
...(opts?.includePoll
|
||||
? {
|
||||
pollMaxOptions: 12,
|
||||
sendPoll: async () => ({ channel: "demo-alias-channel", messageId: "p1" }),
|
||||
...(opts.supportsAnonymousPolls ? { supportsAnonymousPolls: true } : {}),
|
||||
...(opts.supportsPollDurationSeconds ? { supportsPollDurationSeconds: true } : {}),
|
||||
sendPoll: opts.sendPoll ?? (async () => ({ messageId: "p1" })),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ export type MessagePollResult = {
|
||||
maxSelections: number;
|
||||
durationSeconds: number | null;
|
||||
durationHours: number | null;
|
||||
via: "gateway";
|
||||
via: "direct" | "gateway";
|
||||
result?: {
|
||||
messageId: string;
|
||||
toJid?: string;
|
||||
@@ -155,6 +155,7 @@ function buildMessagePollResult(params: {
|
||||
durationSeconds?: number | null;
|
||||
durationHours?: number | null;
|
||||
};
|
||||
via: MessagePollResult["via"];
|
||||
result?: MessagePollResult["result"];
|
||||
dryRun?: boolean;
|
||||
}): MessagePollResult {
|
||||
@@ -166,11 +167,28 @@ function buildMessagePollResult(params: {
|
||||
maxSelections: params.normalized.maxSelections,
|
||||
durationSeconds: params.normalized.durationSeconds ?? null,
|
||||
durationHours: params.normalized.durationHours ?? null,
|
||||
via: "gateway",
|
||||
via: params.via,
|
||||
...(params.dryRun ? { dryRun: true } : { result: params.result }),
|
||||
};
|
||||
}
|
||||
|
||||
function assertPollOptionSupport(params: {
|
||||
channel: string;
|
||||
outbound: NonNullable<ReturnType<typeof resolveRequiredPlugin>["outbound"]>;
|
||||
durationSeconds?: number;
|
||||
isAnonymous?: boolean;
|
||||
}): void {
|
||||
if (
|
||||
typeof params.durationSeconds === "number" &&
|
||||
params.outbound.supportsPollDurationSeconds !== true
|
||||
) {
|
||||
throw new Error(`durationSeconds is not supported for ${params.channel} polls`);
|
||||
}
|
||||
if (typeof params.isAnonymous === "boolean" && params.outbound.supportsAnonymousPolls !== true) {
|
||||
throw new Error(`isAnonymous is not supported for ${params.channel} polls`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRequiredChannel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
@@ -484,6 +502,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
||||
if (!outbound?.sendPoll) {
|
||||
throw new Error(`Unsupported poll channel: ${channel}`);
|
||||
}
|
||||
const deliveryMode = outbound.deliveryMode ?? "direct";
|
||||
const normalized = outbound.pollMaxOptions
|
||||
? normalizePollInput(pollInput, { maxOptions: outbound.pollMaxOptions })
|
||||
: normalizePollInput(pollInput);
|
||||
@@ -493,10 +512,49 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
||||
channel,
|
||||
to: params.to,
|
||||
normalized,
|
||||
via: deliveryMode === "gateway" ? "gateway" : "direct",
|
||||
dryRun: true,
|
||||
});
|
||||
}
|
||||
|
||||
assertPollOptionSupport({
|
||||
channel,
|
||||
outbound,
|
||||
durationSeconds: params.durationSeconds,
|
||||
isAnonymous: params.isAnonymous,
|
||||
});
|
||||
|
||||
if (deliveryMode !== "gateway") {
|
||||
const resolvedTarget = resolveOutboundTarget({
|
||||
channel,
|
||||
to: params.to,
|
||||
cfg,
|
||||
accountId: params.accountId,
|
||||
mode: "explicit",
|
||||
});
|
||||
if (!resolvedTarget.ok) {
|
||||
throw resolvedTarget.error;
|
||||
}
|
||||
|
||||
const result = await outbound.sendPoll({
|
||||
cfg,
|
||||
to: resolvedTarget.to,
|
||||
poll: normalized,
|
||||
accountId: params.accountId,
|
||||
threadId: params.threadId,
|
||||
silent: params.silent,
|
||||
isAnonymous: params.isAnonymous,
|
||||
});
|
||||
|
||||
return buildMessagePollResult({
|
||||
channel,
|
||||
to: params.to,
|
||||
normalized,
|
||||
via: "direct",
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await callMessageGateway<{
|
||||
messageId: string;
|
||||
toJid?: string;
|
||||
@@ -526,6 +584,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
||||
channel,
|
||||
to: params.to,
|
||||
normalized,
|
||||
via: "gateway",
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user