feat(plugin-sdk): add generic channel poll sender (#85299)

* feat(plugin-sdk): add generic channel poll sender

* test(channels): follow durable capability list

* test(channels): update poll capability expectations

* fix(channels): normalize poll receipt parts
This commit is contained in:
Peter Steinberger
2026-05-22 12:16:07 +01:00
committed by GitHub
parent 0ddf51cf71
commit c9a0f03dd7
15 changed files with 154 additions and 13 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.
- Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.
- Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.

View File

@@ -60,6 +60,16 @@ function requirePayloadSender(
return payload;
}
function requirePollSender(
adapter: DiscordMessageAdapter,
): NonNullable<DiscordMessageSender["poll"]> {
const poll = adapter.send?.poll;
if (!poll) {
throw new Error("Expected discord message adapter poll sender");
}
return poll;
}
describe("discord channel message adapter", () => {
beforeEach(() => {
resetDiscordOutboundMocks(hoisted);
@@ -70,6 +80,7 @@ describe("discord channel message adapter", () => {
const sendText = requireTextSender(adapter);
const sendMedia = requireMediaSender(adapter);
const sendPayload = requirePayloadSender(adapter);
const sendPoll = requirePollSender(adapter);
const proveText = async () => {
resetDiscordOutboundMocks(hoisted);
@@ -144,6 +155,27 @@ describe("discord channel message adapter", () => {
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
};
const provePoll = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await sendPoll({
cfg: {},
to: "channel:123456",
poll: { question: "Ship?", options: ["Yes", "No"] },
accountId: "default",
silent: true,
});
expect(hoisted.sendPollDiscordMock).toHaveBeenLastCalledWith(
"channel:123456",
{ question: "Ship?", options: ["Yes", "No"] },
{
accountId: "default",
silent: true,
cfg: {},
},
);
expect(result.receipt.parts[0]?.kind).toBe("poll");
};
const proveReplyThreadSilent = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await sendText({
@@ -180,6 +212,7 @@ describe("discord channel message adapter", () => {
proofs: {
text: proveText,
media: proveMedia,
poll: provePoll,
payload: provePayload,
silent: proveReplyThreadSilent,
replyTo: proveReplyThreadSilent,

View File

@@ -144,6 +144,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
durableFinal: {
text: true,
media: true,
poll: true,
payload: true,
silent: true,
replyTo: true,

View File

@@ -288,6 +288,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
expect(proofs).toStrictEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "not_declared" },

View File

@@ -278,6 +278,7 @@ describe("signal outbound", () => {
expect(proofResults).toEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "not_declared" },

View File

@@ -130,6 +130,7 @@ describe("tlon channel message adapter", () => {
expect(proofs).toStrictEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "verified" },

View File

@@ -215,6 +215,7 @@ describe("outbound", () => {
expect(proofResults).toEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "not_declared" },

View File

@@ -128,6 +128,7 @@ describe("Zalo outbound payload contract", () => {
expect(proofs).toStrictEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "not_declared" },

View File

@@ -13,6 +13,7 @@ import {
verifyDurableFinalCapabilityProofs,
verifyLivePreviewFinalizerCapabilityProofs,
} from "./contracts.js";
import { durableFinalDeliveryCapabilities } from "./types.js";
function verifiedEntries<T extends { status: string }>(results: readonly T[]): T[] {
return results.filter((result) => result.status === "verified");
@@ -57,7 +58,7 @@ describe("durable final capability contracts", () => {
{ capability: "text", status: "verified" },
{ capability: "silent", status: "verified" },
]);
expect(results).toHaveLength(12);
expect(results).toHaveLength(durableFinalDeliveryCapabilities.length);
expectOnlyVerifiedOrNotDeclared(results);
expect(text).toHaveBeenCalledTimes(1);
expect(silent).toHaveBeenCalledTimes(1);
@@ -103,7 +104,7 @@ describe("durable final capability contracts", () => {
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
]);
expect(results).toHaveLength(12);
expect(results).toHaveLength(durableFinalDeliveryCapabilities.length);
expectOnlyVerifiedOrNotDeclared(results);
expect(text).toHaveBeenCalledTimes(1);
expect(media).toHaveBeenCalledTimes(1);

View File

@@ -97,6 +97,7 @@ export type {
ChannelMessageSendLifecycleAdapter,
ChannelMessageSendMediaContext,
ChannelMessageSendPayloadContext,
ChannelMessageSendPollContext,
ChannelMessageSendResult,
ChannelMessageSendSuccessContext,
ChannelMessageSendTextContext,

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createChannelMessageAdapterFromOutbound } from "./outbound-bridge.js";
import type {
ChannelMessageSendPayloadContext,
ChannelMessageSendPollContext,
ChannelMessageSendTextContext,
MessageReceipt,
} from "./types.js";
@@ -135,6 +136,62 @@ describe("createChannelMessageAdapterFromOutbound", () => {
expect(result?.receipt.parts[0]?.kind).toBe("card");
});
it("wraps outbound poll sends with poll receipts", async () => {
const sendPoll = vi.fn(async (_request: ChannelMessageSendPollContext) => ({
channel: "demo",
pollId: "poll-1",
}));
const adapter = createChannelMessageAdapterFromOutbound({
capabilities: { poll: true },
outbound: { sendPoll },
});
const result = await adapter.send?.poll?.({
cfg,
to: "room-1",
poll: { question: "Ship?", options: ["Yes", "No"] },
threadId: "thread-1",
});
expect(adapter.durableFinal?.capabilities).toEqual({ poll: true });
expect(sendPoll).toHaveBeenCalledTimes(1);
const sendPollRequest = requireFirstCallArg(
sendPoll,
) as unknown as ChannelMessageSendPollContext;
expect(sendPollRequest.poll).toEqual({ question: "Ship?", options: ["Yes", "No"] });
expect(sendPollRequest.threadId).toBe("thread-1");
expect(result?.messageId).toBe("poll-1");
expect(result?.receipt.parts[0]?.platformMessageId).toBe("poll-1");
expect(result?.receipt.parts[0]?.kind).toBe("poll");
});
it("normalizes existing outbound poll receipts", async () => {
const receipt: MessageReceipt = {
primaryPlatformMessageId: "card-1",
platformMessageIds: ["card-1"],
parts: [{ platformMessageId: "card-1", kind: "card", index: 0 }],
sentAt: 123,
};
const adapter = createChannelMessageAdapterFromOutbound({
capabilities: { poll: true },
outbound: {
sendPoll: vi.fn(async () => ({ messageId: "card-1", receipt })),
},
});
const result = await adapter.send?.poll?.({
cfg,
to: "room-1",
poll: { question: "Ship?", options: ["Yes", "No"] },
});
expect(result?.messageId).toBe("card-1");
expect(result?.receipt.parts).toEqual([
{ platformMessageId: "card-1", kind: "poll", index: 0 },
]);
expect(receipt.parts[0]?.kind).toBe("card");
});
it("exposes only send methods backed by outbound handlers", async () => {
const adapter = createChannelMessageAdapterFromOutbound({
outbound: {
@@ -153,6 +210,7 @@ describe("createChannelMessageAdapterFromOutbound", () => {
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
expect(adapter.send?.media).toBeUndefined();
expect(adapter.send?.payload).toBeUndefined();
expect(adapter.send?.poll).toBeUndefined();
});
it("defaults outbound-derived adapters to plugin-owned receive acknowledgements", () => {

View File

@@ -5,6 +5,7 @@ import type {
ChannelMessageReceiveAdapterShape,
ChannelMessageSendMediaContext,
ChannelMessageSendPayloadContext,
ChannelMessageSendPollContext,
ChannelMessageSendResult,
ChannelMessageSendTextContext,
DurableFinalDeliveryRequirementMap,
@@ -36,6 +37,9 @@ export type ChannelMessageOutboundBridgeAdapter<TConfig = unknown> = {
sendPayload?: (
ctx: ChannelMessageSendPayloadContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
sendPoll?: (
ctx: ChannelMessageSendPollContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
};
export type CreateChannelMessageAdapterFromOutboundParams<TConfig = unknown> = {
@@ -64,18 +68,24 @@ function toMessageSendResult(
result: ChannelMessageOutboundBridgeResult,
params: {
kind: MessageReceiptPartKind;
normalizeReceiptKind?: boolean;
threadId?: string | number | null;
replyToId?: string | null;
},
): ChannelMessageSendResult {
const receipt =
result.receipt ??
createMessageReceiptFromOutboundResults({
results: [result],
kind: params.kind,
threadId: params.threadId == null ? undefined : String(params.threadId),
replyToId: params.replyToId ?? undefined,
});
const receipt = result.receipt
? params.normalizeReceiptKind
? {
...result.receipt,
parts: result.receipt.parts.map((part) => ({ ...part, kind: params.kind })),
}
: result.receipt
: createMessageReceiptFromOutboundResults({
results: [result],
kind: params.kind,
threadId: params.threadId == null ? undefined : String(params.threadId),
replyToId: params.replyToId ?? undefined,
});
return {
receipt,
...(resolveResultMessageId({ ...result, receipt })
@@ -135,6 +145,15 @@ export function createChannelMessageAdapterFromOutbound<TConfig = unknown>(
replyToId: ctx.replyToId,
});
}
if (params.outbound.sendPoll) {
send.poll = async (ctx) =>
toMessageSendResult(await params.outbound.sendPoll!(ctx), {
kind: "poll",
normalizeReceiptKind: true,
threadId: ctx.threadId,
replyToId: ctx.replyToId,
});
}
return {
...(params.id ? { id: params.id } : {}),

View File

@@ -3,12 +3,14 @@ import type { ReplyToMode } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { OutboundSendDeps } from "../../infra/outbound/send-deps.js";
import type { OutboundMediaAccess } from "../../media/load-options.js";
import type { PollInput } from "../../polls.js";
export type MessageDurabilityPolicy = "required" | "best_effort" | "disabled";
export const durableFinalDeliveryCapabilities = [
"text",
"media",
"poll",
"payload",
"silent",
"replyTo",
@@ -47,7 +49,14 @@ export type MessageReceiptSourceResult = {
meta?: Record<string, unknown>;
};
export type MessageReceiptPartKind = "text" | "media" | "voice" | "card" | "preview" | "unknown";
export type MessageReceiptPartKind =
| "text"
| "media"
| "voice"
| "poll"
| "card"
| "preview"
| "unknown";
export type MessageReceiptPart = {
platformMessageId: string;
@@ -173,17 +182,27 @@ export type ChannelMessageSendPayloadContext<TConfig = OpenClawConfig> =
forceDocument?: boolean;
};
export type ChannelMessageSendPollContext<TConfig = OpenClawConfig> = Omit<
ChannelMessageSendTextContext<TConfig>,
"text" | "threadId"
> & {
poll: PollInput;
threadId?: string | null;
isAnonymous?: boolean;
};
export type ChannelMessageSendResult = {
receipt: MessageReceipt;
messageId?: string;
};
export type ChannelMessageSendAttemptKind = "text" | "media" | "payload";
export type ChannelMessageSendAttemptKind = "text" | "media" | "payload" | "poll";
export type ChannelMessageSendAttemptContext<TConfig = OpenClawConfig> =
| (ChannelMessageSendTextContext<TConfig> & { kind: "text" })
| (ChannelMessageSendMediaContext<TConfig> & { kind: "media" })
| (ChannelMessageSendPayloadContext<TConfig> & { kind: "payload" });
| (ChannelMessageSendPayloadContext<TConfig> & { kind: "payload" })
| (ChannelMessageSendPollContext<TConfig> & { kind: "poll" });
export type ChannelMessageSendSuccessContext<
TConfig = OpenClawConfig,
@@ -257,6 +276,7 @@ export type ChannelMessageSendAdapter<
text?: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<TSendResult>;
media?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<TSendResult>;
payload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<TSendResult>;
poll?: (ctx: ChannelMessageSendPollContext<TConfig>) => Promise<TSendResult>;
lifecycle?: ChannelMessageSendLifecycleAdapter<TConfig, TSendResult>;
};

View File

@@ -98,6 +98,7 @@ export type ChannelDeliveryCapabilities = {
durableFinal?: {
text?: boolean;
media?: boolean;
poll?: boolean;
payload?: boolean;
silent?: boolean;
replyTo?: boolean;

View File

@@ -87,6 +87,7 @@ export type {
ChannelMessageSendLifecycleAdapter,
ChannelMessageSendMediaContext,
ChannelMessageSendPayloadContext,
ChannelMessageSendPollContext,
ChannelMessageSendResult,
ChannelMessageSendSuccessContext,
ChannelMessageSendTextContext,