mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 00:52:57 +00:00
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:
committed by
GitHub
parent
0ddf51cf71
commit
c9a0f03dd7
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -144,6 +144,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
durableFinal: {
|
||||
text: true,
|
||||
media: true,
|
||||
poll: true,
|
||||
payload: true,
|
||||
silent: true,
|
||||
replyTo: true,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -97,6 +97,7 @@ export type {
|
||||
ChannelMessageSendLifecycleAdapter,
|
||||
ChannelMessageSendMediaContext,
|
||||
ChannelMessageSendPayloadContext,
|
||||
ChannelMessageSendPollContext,
|
||||
ChannelMessageSendResult,
|
||||
ChannelMessageSendSuccessContext,
|
||||
ChannelMessageSendTextContext,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export type ChannelDeliveryCapabilities = {
|
||||
durableFinal?: {
|
||||
text?: boolean;
|
||||
media?: boolean;
|
||||
poll?: boolean;
|
||||
payload?: boolean;
|
||||
silent?: boolean;
|
||||
replyTo?: boolean;
|
||||
|
||||
@@ -87,6 +87,7 @@ export type {
|
||||
ChannelMessageSendLifecycleAdapter,
|
||||
ChannelMessageSendMediaContext,
|
||||
ChannelMessageSendPayloadContext,
|
||||
ChannelMessageSendPollContext,
|
||||
ChannelMessageSendResult,
|
||||
ChannelMessageSendSuccessContext,
|
||||
ChannelMessageSendTextContext,
|
||||
|
||||
Reference in New Issue
Block a user