From 21680c33da4e5d6cd1fa9d9f1e1ff5be281ad848 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:34:40 -0500 Subject: [PATCH] fix: canonicalize gateway message dedupe routes --- src/gateway/server-methods/send.test.ts | 222 +++++++++++++++- src/gateway/server-methods/send.ts | 336 +++++++++++++----------- 2 files changed, 407 insertions(+), 151 deletions(-) diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 7eea7c4a7749..2ef791fd34e0 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -9,6 +9,7 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../../packages/gateway-protocol/src/client-info.js"; +import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; import type { SessionTranscriptAppendResult } from "../../config/sessions/transcript.js"; @@ -778,6 +779,7 @@ describe("gateway send mirroring", () => { const response = firstRespondCall(respond); expect(response[0]).toBe(false); + expect(response[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); expect(JSON.stringify(response[2])).toContain(testCase.expectedError); expect(testCase.providerCall).not.toHaveBeenCalled(); }); @@ -1059,7 +1061,9 @@ describe("gateway send mirroring", () => { await invalidRequest; expect(firstRespondCall(invalidRespond)?.[0]).toBe(false); expect(JSON.stringify(firstRespondCall(invalidRespond)?.[2])).toContain("Unknown account"); - expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1); + }); actionDeferred.resolve({ details: { action: "handled" } }); await validRequest; @@ -1118,6 +1122,58 @@ describe("gateway send mirroring", () => { expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1); }); + it("dedupes omitted and explicit default message.action accounts", async () => { + const context = makeContext(); + const omittedRespond = vi.fn(); + const explicitRespond = vi.fn(); + const actionDeferred = createDeferred<{ details: { action: string } }>(); + mocks.dispatchChannelMessageAction.mockReturnValueOnce(actionDeferred.promise); + + const omittedRequest = expectDefined( + sendHandlers["message.action"], + 'sendHandlers["message.action"] test invariant', + )({ + params: { + channel: "slack", + action: "send", + params: { target: "channel:current", message: "hi" }, + idempotencyKey: "idem-action-effective-default", + } as never, + respond: omittedRespond, + context, + req: { type: "req", id: "omitted", method: "message.action" }, + client: null as never, + isWebchatConnect: () => false, + }); + const explicitRequest = expectDefined( + sendHandlers["message.action"], + 'sendHandlers["message.action"] test invariant', + )({ + params: { + channel: "slack", + action: "send", + params: { target: "channel:current", message: "hi" }, + accountId: "default", + idempotencyKey: "idem-action-effective-default", + } as never, + respond: explicitRespond, + context, + req: { type: "req", id: "explicit", method: "message.action" }, + client: null as never, + isWebchatConnect: () => false, + }); + + await vi.waitFor(() => { + expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1); + }); + actionDeferred.resolve({ details: { action: "handled" } }); + await Promise.all([omittedRequest, explicitRequest]); + + expect(firstRespondCall(omittedRespond)?.[0]).toBe(true); + expect(firstRespondCall(explicitRespond)?.[0]).toBe(true); + expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1); + }); + it("keeps an agent runtime delegated even with a direct-operator marker", async () => { const sessionKey = "agent:main:slack:channel:C1"; mocks.dispatchChannelMessageAction.mockResolvedValueOnce({ @@ -1150,6 +1206,142 @@ describe("gateway send mirroring", () => { expect(lastDispatchChannelMessageActionCall()?.conversationReadOrigin).toBe("delegated"); }); + it("dedupes omitted and explicit default send routes", async () => { + const context = makeContext(); + const firstRespond = vi.fn(); + const secondRespond = vi.fn(); + const deliveryDeferred = createDeferred>(); + mocks.deliverOutboundPayloads.mockReturnValueOnce(deliveryDeferred.promise); + + const firstRequest = expectDefined( + sendHandlers.send, + "sendHandlers.send test invariant", + )({ + params: { + to: "channel:C1", + message: "hi", + idempotencyKey: "idem-send-concurrent", + } as never, + respond: firstRespond, + context, + req: { type: "req", id: "1", method: "send" }, + client: null as never, + isWebchatConnect: () => false, + }); + + const secondRequest = expectDefined( + sendHandlers.send, + "sendHandlers.send test invariant", + )({ + params: { + to: "channel:C1", + message: "hi", + channel: "slack", + accountId: "default", + idempotencyKey: "idem-send-concurrent", + } as never, + respond: secondRespond, + context, + req: { type: "req", id: "2", method: "send" }, + client: null as never, + isWebchatConnect: () => false, + }); + + await vi.waitFor(() => { + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1); + }); + + deliveryDeferred.resolve([{ messageId: "m-concurrent", channel: "slack" }]); + await Promise.all([firstRequest, secondRequest]); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expect(firstRespond).toHaveBeenCalledTimes(1); + expect(secondRespond).toHaveBeenCalledTimes(1); + const firstCall = firstRespondCall(firstRespond); + expect(firstCall?.[0]).toBe(true); + expect(firstCall?.[1]?.messageId).toBe("m-concurrent"); + expect(firstCall?.[1]?.runId).toBe("idem-send-concurrent"); + expect(firstCall?.[2]).toBeUndefined(); + expect(firstCall?.[3]?.channel).toBe("slack"); + const secondCall = firstRespondCall(secondRespond); + expect(secondCall?.[0]).toBe(true); + expect(secondCall?.[1]?.messageId).toBe("m-concurrent"); + expect(secondCall?.[1]?.runId).toBe("idem-send-concurrent"); + expect(secondCall?.[2]).toBeUndefined(); + expect(secondCall?.[3]?.channel).toBe("slack"); + expect([firstCall?.[3]?.cached, secondCall?.[3]?.cached].filter(Boolean)).toHaveLength(1); + }); + + it("dedupes omitted and explicit default poll routes", async () => { + const context = makeContext(); + const firstRespond = vi.fn(); + const secondRespond = vi.fn(); + const pollDeferred = createDeferred<{ messageId: string; pollId: string }>(); + mocks.sendPoll.mockReturnValueOnce(pollDeferred.promise); + + const firstRequest = expectDefined( + sendHandlers.poll, + "sendHandlers.poll test invariant", + )({ + params: { + to: "channel:C1", + question: "Q?", + options: ["A", "B"], + idempotencyKey: "idem-poll-concurrent", + } as never, + respond: firstRespond, + context, + req: { type: "req", id: "1", method: "poll" }, + client: null as never, + isWebchatConnect: () => false, + }); + + const secondRequest = expectDefined( + sendHandlers.poll, + "sendHandlers.poll test invariant", + )({ + params: { + to: "channel:C1", + question: "Q?", + options: ["A", "B"], + channel: "slack", + accountId: "default", + idempotencyKey: "idem-poll-concurrent", + } as never, + respond: secondRespond, + context, + req: { type: "req", id: "2", method: "poll" }, + client: null as never, + isWebchatConnect: () => false, + }); + + await vi.waitFor(() => { + expect(mocks.sendPoll).toHaveBeenCalledTimes(1); + }); + + pollDeferred.resolve({ messageId: "poll-concurrent", pollId: "poll-1" }); + await Promise.all([firstRequest, secondRequest]); + + expect(mocks.sendPoll).toHaveBeenCalledTimes(1); + expect(firstRespond).toHaveBeenCalledTimes(1); + expect(secondRespond).toHaveBeenCalledTimes(1); + const firstCall = firstRespondCall(firstRespond); + expect(firstCall?.[0]).toBe(true); + expect(firstCall?.[1]?.messageId).toBe("poll-concurrent"); + expect(firstCall?.[1]?.pollId).toBe("poll-1"); + expect(firstCall?.[1]?.runId).toBe("idem-poll-concurrent"); + expect(firstCall?.[2]).toBeUndefined(); + expect(firstCall?.[3]?.channel).toBe("slack"); + const secondCall = firstRespondCall(secondRespond); + expect(secondCall?.[0]).toBe(true); + expect(secondCall?.[1]?.messageId).toBe("poll-concurrent"); + expect(secondCall?.[1]?.pollId).toBe("poll-1"); + expect(secondCall?.[1]?.runId).toBe("idem-poll-concurrent"); + expect(secondCall?.[2]).toBeUndefined(); + expect(secondCall?.[3]?.channel).toBe("slack"); + expect([firstCall?.[3]?.cached, secondCall?.[3]?.cached].filter(Boolean)).toHaveLength(1); + }); + it("accepts media-only sends without message", async () => { mockDeliverySuccess("m-media"); @@ -1900,7 +2092,14 @@ describe("gateway send mirroring", () => { mocks.deliverOutboundPayloads.mockResolvedValue([ { messageId: "m-threaded", channel: "slack" }, ]); - const outboundPlugin = { outbound: { sendPoll: mocks.sendPoll } }; + const outboundPlugin = { + id: "slack", + outbound: { sendPoll: mocks.sendPoll }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + }, + }; mocks.getChannelPlugin .mockReturnValueOnce(undefined) .mockReturnValueOnce(outboundPlugin) @@ -1927,7 +2126,14 @@ describe("gateway send mirroring", () => { it("forwards replyToId on gateway sends", async () => { mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" }); mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-reply", channel: "slack" }]); - const outboundPlugin = { outbound: { sendPoll: mocks.sendPoll } }; + const outboundPlugin = { + id: "slack", + outbound: { sendPoll: mocks.sendPoll }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + }, + }; mocks.getChannelPlugin.mockReturnValue(outboundPlugin); const { respond } = await runSend({ @@ -2103,9 +2309,14 @@ describe("gateway send mirroring", () => { it("strips current-turn context from unauthenticated message action callers", async () => { mocks.getChannelPlugin.mockReturnValue({ + id: "whatsapp", actions: { handleAction: vi.fn(), }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + }, }); mocks.dispatchChannelMessageAction.mockResolvedValueOnce(jsonResult({ ok: true })); @@ -2132,9 +2343,14 @@ describe("gateway send mirroring", () => { it("strips forged current-turn context from agent runs without an ingress capability", async () => { mocks.getChannelPlugin.mockReturnValue({ + id: "whatsapp", actions: { handleAction: vi.fn(), }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + }, }); mocks.dispatchChannelMessageAction.mockResolvedValueOnce(jsonResult({ ok: true })); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index a91ad70fb862..e4bdad8161a3 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -15,8 +15,12 @@ import { import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { sendDurableMessageBatch } from "../../channels/message/runtime.js"; import type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; +import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; -import type { ChannelThreadingToolContext } from "../../channels/plugins/types.public.js"; +import type { + ChannelPlugin, + ChannelThreadingToolContext, +} from "../../channels/plugins/types.public.js"; import { resolveChannelThreadAddressing } from "../../channels/thread-addressing.js"; import type { InternalChannelThreadingToolContext } from "../../channels/threading-tool-context-internal.js"; import { createOutboundSendDeps } from "../../cli/deps.js"; @@ -54,7 +58,7 @@ import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; import { extractToolPayload } from "../../plugin-sdk/tool-payload.js"; import { normalizePollInput } from "../../polls.js"; -import { normalizeAgentId, normalizeOptionalAccountId } from "../../routing/session-key.js"; +import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js"; import { isAgentHarnessSessionKey, resolveMissingAgentHarnessSessionError, @@ -185,26 +189,48 @@ function resolveGatewayInflightRequest(params: { }); } -function resolveMessageOperationDedupeScope(params: { - channel?: unknown; +function resolveMessageOperationAccountRoute(params: { + cfg: OpenClawConfig; + channel: string; + plugin: ChannelPlugin; accountIds: readonly unknown[]; -}): string { - const normalizeAccountScope = (value: unknown): string | null => { - const raw = normalizeOptionalString(value); - if (!raw) { - return null; - } - return normalizeOptionalAccountId(raw) ?? `invalid:${raw}`; + conflictMessage: string; +}): { accountId: string | undefined; requestScope: string } { + const accountIds = params.accountIds + .map((accountId) => + validateExplicitMessageAccountSelection({ + cfg: params.cfg, + channel: params.channel, + accountId, + plugin: params.plugin, + }), + ) + .filter((accountId): accountId is string => accountId !== undefined); + const distinctAccountIds = [...new Set(accountIds)]; + if (distinctAccountIds.length > 1) { + throw new Error(params.conflictMessage); + } + const accountId = distinctAccountIds[0]; + // Missing input remains host-derived authority; this value only canonicalizes + // idempotency and is not forwarded as a caller-supplied explicit selection. + const effectiveAccountId = + accountId ?? + normalizeAccountId(resolveChannelDefaultAccountId({ plugin: params.plugin, cfg: params.cfg })); + return { + accountId, + requestScope: JSON.stringify([params.channel, effectiveAccountId]), }; - const accountScopes = params.accountIds - .map(normalizeAccountScope) - .filter((value): value is string => value !== null); - const distinctAccountScopes = [...new Set(accountScopes)]; - const accountScope = - distinctAccountScopes.length <= 1 - ? (distinctAccountScopes[0] ?? null) - : { conflict: distinctAccountScopes.toSorted() }; - return JSON.stringify([normalizeOptionalLowercaseString(params.channel) ?? null, accountScope]); +} + +function respondGatewayInvalidRequest(params: { + respond: RespondFn; + channel: string; + error: unknown; +}): void { + params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(params.error)), { + channel: params.channel, + error: formatForLog(params.error), + }); } async function resolveRequestedChannel(params: { @@ -517,16 +543,50 @@ export const sendHandlers: GatewayRequestHandlers = { client, requestedOrigin: request.conversationReadOrigin, }); + const resolvedChannel = await resolveRequestedChannel({ + requestChannel: request.channel, + unsupportedMessage: (input) => `unsupported channel: ${input}`, + context, + rejectWebchatAsInternalOnly: true, + }); + if ("error" in resolvedChannel) { + respond(false, undefined, resolvedChannel.error); + return; + } + const { cfg: selectedCfg, sourceCfg, channel } = resolvedChannel; + const cfg = resolveMessageActionRuntimeConfig({ cfg: selectedCfg, sourceCfg }); + const plugin = resolveOutboundChannelPlugin({ channel, cfg }); + if (!plugin?.actions?.handleAction) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Channel ${channel} does not support action ${request.action}.`, + ), + ); + return; + } + let accountRoute: ReturnType; + try { + accountRoute = resolveMessageOperationAccountRoute({ + cfg, + channel, + plugin, + accountIds: [request.accountId, request.params.accountId], + conflictMessage: "message.action accountId does not match params.accountId", + }); + } catch (error) { + respondGatewayInvalidRequest({ respond, channel, error }); + return; + } const inflight = resolveGatewayInflightRequest({ context, prefix: "message.action", idempotencyKey: request.idempotencyKey, respond, conversationReadOrigin, - requestScope: resolveMessageOperationDedupeScope({ - channel: request.channel, - accountIds: [request.accountId, request.params.accountId], - }), + requestScope: accountRoute.requestScope, }); if (inflight.kind === "handled") { await inflight.done; @@ -534,49 +594,12 @@ export const sendHandlers: GatewayRequestHandlers = { } const { dedupeKey, inflightMap } = inflight; const work = (async (): Promise => { - const resolvedChannel = await resolveRequestedChannel({ - requestChannel: request.channel, - unsupportedMessage: (input) => `unsupported channel: ${input}`, - context, - rejectWebchatAsInternalOnly: true, - }); - if ("error" in resolvedChannel) { - return { ok: false, error: resolvedChannel.error }; - } - const { cfg: selectedCfg, sourceCfg, channel } = resolvedChannel; - const cfg = resolveMessageActionRuntimeConfig({ cfg: selectedCfg, sourceCfg }); - const plugin = resolveOutboundChannelPlugin({ channel, cfg }); - if (!plugin?.actions?.handleAction) { - return { - ok: false, - error: errorShape( - ErrorCodes.INVALID_REQUEST, - `Channel ${channel} does not support action ${request.action}.`, - ), - }; - } - try { const sessionKey = normalizeOptionalString(request.sessionKey) ?? undefined; const agentId = normalizeOptionalString(request.agentId) ?? (sessionKey ? resolveSessionAgentId({ sessionKey, config: cfg }) : undefined); - const envelopeAccountId = validateExplicitMessageAccountSelection({ - cfg, - channel, - accountId: request.accountId, - plugin, - }); - const nestedAccountId = validateExplicitMessageAccountSelection({ - cfg, - channel, - accountId: request.params.accountId, - plugin, - }); - if (envelopeAccountId && nestedAccountId && envelopeAccountId !== nestedAccountId) { - throw new Error("message.action accountId does not match params.accountId"); - } - const accountId = envelopeAccountId ?? nestedAccountId; + const accountId = accountRoute.accountId; if (accountId) { request.params.accountId = accountId; } @@ -701,21 +724,6 @@ export const sendHandlers: GatewayRequestHandlers = { sessionKey?: string; idempotencyKey: string; }; - const inflight = resolveGatewayInflightRequest({ - context, - prefix: "send", - idempotencyKey: request.idempotencyKey, - respond, - requestScope: resolveMessageOperationDedupeScope({ - channel: request.channel, - accountIds: [request.accountId], - }), - }); - if (inflight.kind === "handled") { - await inflight.done; - return; - } - const { idem, dedupeKey, inflightMap } = inflight; const to = normalizeOptionalString(request.to) ?? ""; const message = normalizeOptionalString(request.message) ?? ""; const mediaUrl = normalizeOptionalString(request.mediaUrl); @@ -736,29 +744,52 @@ export const sendHandlers: GatewayRequestHandlers = { const requestedAccountId = normalizeOptionalString(request.accountId); const replyToId = normalizeOptionalString(request.replyToId); const threadId = normalizeOptionalString(request.threadId); + const resolvedChannel = await resolveInternalDeliveryChannel(request.channel, context); + if (resolvedChannel.kind !== "ready") { + const result = resolvedChannel.result; + respond(result.ok, result.payload, result.error, result.meta); + return; + } + const { cfg, channel } = resolvedChannel; + const outboundChannel = channel; + const plugin = resolveOutboundChannelPlugin({ channel, cfg }); + if (!plugin) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channel}`), + ); + return; + } + let accountRoute: ReturnType; + try { + accountRoute = resolveMessageOperationAccountRoute({ + cfg, + channel, + plugin, + accountIds: [requestedAccountId], + conflictMessage: "send account selections do not match", + }); + } catch (error) { + respondGatewayInvalidRequest({ respond, channel, error }); + return; + } + const accountId = accountRoute.accountId; + const inflight = resolveGatewayInflightRequest({ + context, + prefix: "send", + idempotencyKey: request.idempotencyKey, + respond, + requestScope: accountRoute.requestScope, + }); + if (inflight.kind === "handled") { + await inflight.done; + return; + } + const { idem, dedupeKey, inflightMap } = inflight; const work = (async (): Promise => { - const resolvedChannel = await resolveInternalDeliveryChannel(request.channel, context); - if (resolvedChannel.kind !== "ready") { - return resolvedChannel.result; - } - const { cfg, channel } = resolvedChannel; - const outboundChannel = channel; - const plugin = resolveOutboundChannelPlugin({ channel, cfg }); - if (!plugin) { - return { - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channel}`), - }; - } - try { - const accountId = validateExplicitMessageAccountSelection({ - cfg, - channel, - accountId: requestedAccountId, - plugin, - }); const resolvedTarget = resolveGatewayOutboundTarget({ channel: outboundChannel, to, @@ -957,15 +988,70 @@ export const sendHandlers: GatewayRequestHandlers = { accountId?: string; idempotencyKey: string; }; + const resolvedChannel = await resolveRequestedChannel({ + requestChannel: request.channel, + unsupportedMessage: (input) => `unsupported poll channel: ${input}`, + context, + }); + if ("error" in resolvedChannel) { + respond(false, undefined, resolvedChannel.error); + return; + } + const { cfg, channel } = resolvedChannel; + const plugin = resolveOutboundChannelPlugin({ channel, cfg }); + const outbound = plugin?.outbound; + if ( + typeof request.durationSeconds === "number" && + outbound?.supportsPollDurationSeconds !== true + ) { + // Duration support is channel-specific; reject before normalizing to avoid silent truncation. + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `durationSeconds is not supported for ${channel} polls`, + ), + ); + return; + } + if (typeof request.isAnonymous === "boolean" && outbound?.supportsAnonymousPolls !== true) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `isAnonymous is not supported for ${channel} polls`), + ); + return; + } + if (!plugin || !outbound?.sendPoll) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unsupported poll channel: ${channel}`), + ); + return; + } + const sendPoll = outbound.sendPoll; + let accountRoute: ReturnType; + try { + accountRoute = resolveMessageOperationAccountRoute({ + cfg, + channel, + plugin, + accountIds: [request.accountId], + conflictMessage: "poll account selections do not match", + }); + } catch (error) { + respondGatewayInvalidRequest({ respond, channel, error }); + return; + } + const accountId = accountRoute.accountId; const inflight = resolveGatewayInflightRequest({ context, prefix: "poll", idempotencyKey: request.idempotencyKey, respond, - requestScope: resolveMessageOperationDedupeScope({ - channel: request.channel, - accountIds: [request.accountId], - }), + requestScope: accountRoute.requestScope, }); if (inflight.kind === "handled") { await inflight.done; @@ -973,39 +1059,6 @@ export const sendHandlers: GatewayRequestHandlers = { } const { idem, dedupeKey, inflightMap } = inflight; const work = (async (): Promise => { - const resolvedChannel = await resolveRequestedChannel({ - requestChannel: request.channel, - unsupportedMessage: (input) => `unsupported poll channel: ${input}`, - context, - }); - if ("error" in resolvedChannel) { - return { ok: false, error: resolvedChannel.error }; - } - const { cfg, channel } = resolvedChannel; - const plugin = resolveOutboundChannelPlugin({ channel, cfg }); - const outbound = plugin?.outbound; - if ( - typeof request.durationSeconds === "number" && - outbound?.supportsPollDurationSeconds !== true - ) { - // Duration support is channel-specific; reject before normalizing to avoid silent truncation. - return { - ok: false, - error: errorShape( - ErrorCodes.INVALID_REQUEST, - `durationSeconds is not supported for ${channel} polls`, - ), - }; - } - if (typeof request.isAnonymous === "boolean" && outbound?.supportsAnonymousPolls !== true) { - return { - ok: false, - error: errorShape( - ErrorCodes.INVALID_REQUEST, - `isAnonymous is not supported for ${channel} polls`, - ), - }; - } const poll = { question: request.question, options: request.options, @@ -1015,19 +1068,6 @@ export const sendHandlers: GatewayRequestHandlers = { }; const threadId = normalizeOptionalString(request.threadId); try { - if (!outbound?.sendPoll) { - const error = errorShape( - ErrorCodes.INVALID_REQUEST, - `unsupported poll channel: ${channel}`, - ); - return { ok: false, error }; - } - const accountId = validateExplicitMessageAccountSelection({ - cfg, - channel, - accountId: request.accountId, - plugin, - }); const resolvedTarget = resolveGatewayOutboundTarget({ channel, to: request.to.trim(), @@ -1040,7 +1080,7 @@ export const sendHandlers: GatewayRequestHandlers = { const normalized = outbound.pollMaxOptions ? normalizePollInput(poll, { maxOptions: outbound.pollMaxOptions }) : normalizePollInput(poll); - const result = await outbound.sendPoll({ + const result = await sendPoll({ cfg, to: resolvedTarget.to, poll: normalized,