diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a51d6c43a2..462551703d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/channel.message-adapter.test.ts b/extensions/discord/src/channel.message-adapter.test.ts index 4abe16c4580..2374b1c558b 100644 --- a/extensions/discord/src/channel.message-adapter.test.ts +++ b/extensions/discord/src/channel.message-adapter.test.ts @@ -60,6 +60,16 @@ function requirePayloadSender( return payload; } +function requirePollSender( + adapter: DiscordMessageAdapter, +): NonNullable { + 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, diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 5f8dcd76905..867b8924524 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -144,6 +144,7 @@ export const discordOutbound: ChannelOutboundAdapter = { durableFinal: { text: true, media: true, + poll: true, payload: true, silent: true, replyTo: true, diff --git a/extensions/googlechat/src/channel.test.ts b/extensions/googlechat/src/channel.test.ts index 789c256a913..f66518898e9 100644 --- a/extensions/googlechat/src/channel.test.ts +++ b/extensions/googlechat/src/channel.test.ts @@ -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" }, diff --git a/extensions/signal/src/core.test.ts b/extensions/signal/src/core.test.ts index 302b39237af..11ef8244417 100644 --- a/extensions/signal/src/core.test.ts +++ b/extensions/signal/src/core.test.ts @@ -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" }, diff --git a/extensions/tlon/src/channel.message-adapter.test.ts b/extensions/tlon/src/channel.message-adapter.test.ts index fcd8f9aedfd..dcf56cac020 100644 --- a/extensions/tlon/src/channel.message-adapter.test.ts +++ b/extensions/tlon/src/channel.message-adapter.test.ts @@ -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" }, diff --git a/extensions/twitch/src/outbound.test.ts b/extensions/twitch/src/outbound.test.ts index 4e8b331f7d4..a7731a3b9c1 100644 --- a/extensions/twitch/src/outbound.test.ts +++ b/extensions/twitch/src/outbound.test.ts @@ -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" }, diff --git a/extensions/zalo/src/outbound-payload.contract.test.ts b/extensions/zalo/src/outbound-payload.contract.test.ts index a54efeb3374..c0e083c3c06 100644 --- a/extensions/zalo/src/outbound-payload.contract.test.ts +++ b/extensions/zalo/src/outbound-payload.contract.test.ts @@ -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" }, diff --git a/src/channels/message/contracts.test.ts b/src/channels/message/contracts.test.ts index f14d81a97c5..002b4056e70 100644 --- a/src/channels/message/contracts.test.ts +++ b/src/channels/message/contracts.test.ts @@ -13,6 +13,7 @@ import { verifyDurableFinalCapabilityProofs, verifyLivePreviewFinalizerCapabilityProofs, } from "./contracts.js"; +import { durableFinalDeliveryCapabilities } from "./types.js"; function verifiedEntries(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); diff --git a/src/channels/message/index.ts b/src/channels/message/index.ts index 7144a2ee3ab..a3b7b578b0c 100644 --- a/src/channels/message/index.ts +++ b/src/channels/message/index.ts @@ -97,6 +97,7 @@ export type { ChannelMessageSendLifecycleAdapter, ChannelMessageSendMediaContext, ChannelMessageSendPayloadContext, + ChannelMessageSendPollContext, ChannelMessageSendResult, ChannelMessageSendSuccessContext, ChannelMessageSendTextContext, diff --git a/src/channels/message/outbound-bridge.test.ts b/src/channels/message/outbound-bridge.test.ts index 90d99873a25..e441e733311 100644 --- a/src/channels/message/outbound-bridge.test.ts +++ b/src/channels/message/outbound-bridge.test.ts @@ -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", () => { diff --git a/src/channels/message/outbound-bridge.ts b/src/channels/message/outbound-bridge.ts index 658524646d0..d169adbe516 100644 --- a/src/channels/message/outbound-bridge.ts +++ b/src/channels/message/outbound-bridge.ts @@ -5,6 +5,7 @@ import type { ChannelMessageReceiveAdapterShape, ChannelMessageSendMediaContext, ChannelMessageSendPayloadContext, + ChannelMessageSendPollContext, ChannelMessageSendResult, ChannelMessageSendTextContext, DurableFinalDeliveryRequirementMap, @@ -36,6 +37,9 @@ export type ChannelMessageOutboundBridgeAdapter = { sendPayload?: ( ctx: ChannelMessageSendPayloadContext, ) => Promise; + sendPoll?: ( + ctx: ChannelMessageSendPollContext, + ) => Promise; }; export type CreateChannelMessageAdapterFromOutboundParams = { @@ -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( 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 } : {}), diff --git a/src/channels/message/types.ts b/src/channels/message/types.ts index 32df48cc631..2e7e6176ba3 100644 --- a/src/channels/message/types.ts +++ b/src/channels/message/types.ts @@ -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; }; -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 = forceDocument?: boolean; }; +export type ChannelMessageSendPollContext = Omit< + ChannelMessageSendTextContext, + "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 = | (ChannelMessageSendTextContext & { kind: "text" }) | (ChannelMessageSendMediaContext & { kind: "media" }) - | (ChannelMessageSendPayloadContext & { kind: "payload" }); + | (ChannelMessageSendPayloadContext & { kind: "payload" }) + | (ChannelMessageSendPollContext & { kind: "poll" }); export type ChannelMessageSendSuccessContext< TConfig = OpenClawConfig, @@ -257,6 +276,7 @@ export type ChannelMessageSendAdapter< text?: (ctx: ChannelMessageSendTextContext) => Promise; media?: (ctx: ChannelMessageSendMediaContext) => Promise; payload?: (ctx: ChannelMessageSendPayloadContext) => Promise; + poll?: (ctx: ChannelMessageSendPollContext) => Promise; lifecycle?: ChannelMessageSendLifecycleAdapter; }; diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index de6c50a3fec..9d1f1ebcb2e 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -98,6 +98,7 @@ export type ChannelDeliveryCapabilities = { durableFinal?: { text?: boolean; media?: boolean; + poll?: boolean; payload?: boolean; silent?: boolean; replyTo?: boolean; diff --git a/src/plugin-sdk/channel-message.ts b/src/plugin-sdk/channel-message.ts index e9c91f5195c..a586c43c247 100644 --- a/src/plugin-sdk/channel-message.ts +++ b/src/plugin-sdk/channel-message.ts @@ -87,6 +87,7 @@ export type { ChannelMessageSendLifecycleAdapter, ChannelMessageSendMediaContext, ChannelMessageSendPayloadContext, + ChannelMessageSendPollContext, ChannelMessageSendResult, ChannelMessageSendSuccessContext, ChannelMessageSendTextContext,