fix: canonicalize gateway message dedupe routes

This commit is contained in:
joshavant
2026-07-24 22:34:40 -05:00
committed by Josh Avant
parent bd8fc28ae8
commit 21680c33da
2 changed files with 407 additions and 151 deletions
+219 -3
View File
@@ -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<Array<{ messageId: string; channel: string }>>();
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 }));
+188 -148
View File
@@ -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<typeof resolveMessageOperationAccountRoute>;
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<InflightResult> => {
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<typeof resolveMessageOperationAccountRoute>;
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<InflightResult> => {
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<typeof resolveMessageOperationAccountRoute>;
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<InflightResult> => {
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,