refactor(outbound): dedupe poll threading + tighten duration semantics

This commit is contained in:
Peter Steinberger
2026-02-14 18:53:23 +01:00
parent f47584fec8
commit 4b9cb46c6e
7 changed files with 85 additions and 33 deletions

View File

@@ -21,6 +21,7 @@ import {
resolveChannelId,
sendDiscordMedia,
sendDiscordText,
SUPPRESS_NOTIFICATIONS_FLAG,
} from "./send.shared.js";
import {
ensureOggOpus,
@@ -273,9 +274,11 @@ export async function sendPollDiscord(
const recipient = await parseAndResolveRecipient(to, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
const content = opts.content?.trim();
if (poll.durationSeconds !== undefined) {
throw new Error("Discord polls do not support durationSeconds; use durationHours");
}
const payload = normalizeDiscordPollInput(poll);
// Discord message flag for silent/suppress notifications (matches send.shared.ts)
const flags = opts.silent ? 1 << 12 : undefined;
const flags = opts.silent ? SUPPRESS_NOTIFICATIONS_FLAG : undefined;
const res = (await request(
() =>
rest.post(Routes.channelMessages(channelId), {

View File

@@ -232,7 +232,7 @@ async function resolveChannelId(
}
// Discord message flag for silent/suppress notifications
const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12;
export const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12;
export function buildDiscordTextChunks(
text: string,

View File

@@ -36,7 +36,7 @@ export const PollParamsSchema = Type.Object(
options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }),
maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
/** Poll duration in seconds (channel-specific limits may apply). */
durationSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 600 })),
durationSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 604_800 })),
durationHours: Type.Optional(Type.Integer({ minimum: 1 })),
/** Send silently (no notification) where supported. */
silent: Type.Optional(Type.Boolean()),

View File

@@ -303,6 +303,25 @@ export const sendHandlers: GatewayRequestHandlers = {
return;
}
const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL;
if (typeof request.durationSeconds === "number" && channel !== "telegram") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"durationSeconds is only supported for Telegram polls",
),
);
return;
}
if (typeof request.isAnonymous === "boolean" && channel !== "telegram") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "isAnonymous is only supported for Telegram polls"),
);
return;
}
const poll = {
question: request.question,
options: request.options,

View File

@@ -59,6 +59,33 @@ export type MessageActionRunnerGateway = {
mode: GatewayClientMode;
};
function resolveAndApplyOutboundThreadId(
params: Record<string, unknown>,
ctx: {
channel: ChannelId;
to: string;
toolContext?: ChannelThreadingToolContext;
allowSlackAutoThread: boolean;
},
): string | undefined {
const threadId = readStringParam(params, "threadId");
const slackAutoThreadId =
ctx.allowSlackAutoThread && ctx.channel === "slack" && !threadId
? resolveSlackAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
: undefined;
const telegramAutoThreadId =
ctx.channel === "telegram" && !threadId
? resolveTelegramAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
: undefined;
const resolved = threadId ?? slackAutoThreadId ?? telegramAutoThreadId;
// Write auto-resolved threadId back into params so downstream dispatch
// (plugin `readStringParam(params, "threadId")`) picks it up.
if (resolved && !params.threadId) {
params.threadId = resolved;
}
return resolved ?? undefined;
}
export type RunMessageActionParams = {
cfg: OpenClawConfig;
action: ChannelMessageActionName;
@@ -469,23 +496,12 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const silent = readBooleanParam(params, "silent");
const replyToId = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
// Slack auto-threading can inject threadTs without explicit params; mirror to that session key.
const slackAutoThreadId =
channel === "slack" && !replyToId && !threadId
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
: undefined;
// Telegram forum topic auto-threading: inject threadId so media/buttons land in the correct topic.
const telegramAutoThreadId =
channel === "telegram" && !threadId
? resolveTelegramAutoThreadId({ to, toolContext: input.toolContext })
: undefined;
const resolvedThreadId = threadId ?? slackAutoThreadId ?? telegramAutoThreadId;
// Write auto-resolved threadId back into params so downstream dispatch
// (plugin `readStringParam(params, "threadId")`) picks it up.
if (resolvedThreadId && !params.threadId) {
params.threadId = resolvedThreadId;
}
const resolvedThreadId = resolveAndApplyOutboundThreadId(params, {
channel,
to,
toolContext: input.toolContext,
allowSlackAutoThread: channel === "slack" && !replyToId,
});
const outboundRoute =
agentId && !dryRun
? await resolveOutboundSessionRoute({
@@ -584,19 +600,19 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
});
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
const threadId = readStringParam(params, "threadId");
const slackAutoThreadId =
channel === "slack" && !threadId
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
: undefined;
const telegramAutoThreadId =
channel === "telegram" && !threadId
? resolveTelegramAutoThreadId({ to, toolContext: input.toolContext })
: undefined;
const resolvedThreadId = threadId ?? slackAutoThreadId ?? telegramAutoThreadId;
if (resolvedThreadId && !params.threadId) {
params.threadId = resolvedThreadId;
if (durationSeconds !== undefined && channel !== "telegram") {
throw new Error("pollDurationSeconds is only supported for Telegram polls");
}
if (isAnonymous !== undefined && channel !== "telegram") {
throw new Error("pollAnonymous/pollPublic are only supported for Telegram polls");
}
const resolvedThreadId = resolveAndApplyOutboundThreadId(params, {
channel,
to,
toolContext: input.toolContext,
allowSlackAutoThread: channel === "slack",
});
const base = typeof params.message === "string" ? params.message : "";
await maybeApplyCrossContextMarker({

View File

@@ -29,4 +29,15 @@ describe("polls", () => {
expect(normalizePollDurationHours(999, { defaultHours: 24, maxHours: 48 })).toBe(48);
expect(normalizePollDurationHours(1, { defaultHours: 24, maxHours: 48 })).toBe(1);
});
it("rejects both durationSeconds and durationHours", () => {
expect(() =>
normalizePollInput({
question: "Q",
options: ["A", "B"],
durationSeconds: 60,
durationHours: 1,
}),
).toThrow(/mutually exclusive/);
});
});

View File

@@ -71,6 +71,9 @@ export function normalizePollInput(
if (durationHours !== undefined && durationHours < 1) {
throw new Error("durationHours must be at least 1");
}
if (durationSeconds !== undefined && durationHours !== undefined) {
throw new Error("durationSeconds and durationHours are mutually exclusive");
}
return {
question,
options: cleaned,