fix(poll-params): treat zero-valued numeric poll params as unset (#52150)

Merged via squash.

Prepared head SHA: 189e695b7c
Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
Bartok9
2026-03-22 05:39:31 -05:00
committed by GitHub
parent 67e61acac7
commit c70ae1c96e
5 changed files with 89 additions and 4 deletions

View File

@@ -203,6 +203,7 @@ Docs: https://docs.openclaw.ai
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.
- Messages/polls: treat zero-valued poll params on `message.send` as unset defaults while keeping non-zero poll params on the poll validation path. (#52150) Fixes #52118. Thanks @Bartok9.
- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob.
- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob.
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.

View File

@@ -347,6 +347,15 @@ describe("runMessageAction context isolation", () => {
poll_public: "true",
},
},
{
name: "negative poll duration params",
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
pollDurationSeconds: -5,
},
},
])("rejects send actions that include $name", async ({ actionParams }) => {
await expect(
runDrySend({

View File

@@ -217,6 +217,66 @@ describe("runMessageAction plugin dispatch", () => {
}),
);
});
it("does not misclassify send as poll when zero-valued poll params are present", async () => {
const sendMedia = vi.fn().mockResolvedValue({
channel: "testchat",
messageId: "m2",
chatId: "c1",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "testchat",
source: "test",
plugin: createOutboundTestPlugin({
id: "testchat",
outbound: {
deliveryMode: "direct",
sendText: vi.fn().mockResolvedValue({
channel: "testchat",
messageId: "t2",
chatId: "c1",
}),
sendMedia,
},
}),
},
]),
);
const cfg = {
channels: {
testchat: {
enabled: true,
},
},
} as OpenClawConfig;
const result = await runMessageAction({
cfg,
action: "send",
params: {
channel: "testchat",
target: "channel:abc",
media: "https://example.com/file.txt",
message: "hello",
pollDurationHours: 0,
pollDurationSeconds: 0,
pollMulti: false,
pollQuestion: "",
pollOption: [],
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
text: "hello",
mediaUrl: "https://example.com/file.txt",
}),
);
});
});
describe("card-only send behavior", () => {

View File

@@ -23,16 +23,27 @@ describe("poll params", () => {
},
);
it("treats finite numeric poll params as poll creation intent", () => {
expect(hasPollCreationParams({ pollDurationHours: 0 })).toBe(true);
it("treats non-zero finite numeric poll params as poll creation intent", () => {
expect(hasPollCreationParams({ pollDurationSeconds: 60 })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: "60" })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: "1e3" })).toBe(true);
expect(hasPollCreationParams({ pollDurationHours: -1 })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: "-5" })).toBe(true);
expect(hasPollCreationParams({ pollDurationHours: Number.NaN })).toBe(false);
expect(hasPollCreationParams({ pollDurationSeconds: Infinity })).toBe(false);
expect(hasPollCreationParams({ pollDurationSeconds: "60abc" })).toBe(false);
});
it("does not treat zero-valued numeric poll params as poll creation intent", () => {
// Zero values are typically defaults/unset values from tool schemas,
// not intentional poll creation. Fixes #52118.
expect(hasPollCreationParams({ pollDurationHours: 0 })).toBe(false);
expect(hasPollCreationParams({ pollDurationSeconds: 0 })).toBe(false);
expect(hasPollCreationParams({ pollDurationHours: "0" })).toBe(false);
expect(hasPollCreationParams({ poll_duration_seconds: 0 })).toBe(false);
expect(hasPollCreationParams({ poll_duration_hours: "0" })).toBe(false);
});
it("treats string-encoded boolean poll params as poll creation intent when true", () => {
expect(hasPollCreationParams({ pollPublic: "true" })).toBe(true);
expect(hasPollCreationParams({ pollAnonymous: "false" })).toBe(false);

View File

@@ -69,12 +69,16 @@ export function hasPollCreationParams(params: Record<string, unknown>): boolean
}
}
if (def.kind === "number") {
if (typeof value === "number" && Number.isFinite(value)) {
// Treat zero-valued numeric defaults as unset, but preserve any non-zero
// numeric value as explicit poll intent so invalid durations still hit
// the poll-only validation path.
if (typeof value === "number" && Number.isFinite(value) && value !== 0) {
return true;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0 && Number.isFinite(Number(trimmed))) {
const parsed = Number(trimmed);
if (trimmed.length > 0 && Number.isFinite(parsed) && parsed !== 0) {
return true;
}
}