refactor: migrate bundled plugins to message lifecycle

This commit is contained in:
Peter Steinberger
2026-05-06 01:40:53 +01:00
parent 2ead1502c9
commit 05eda57b3c
223 changed files with 8568 additions and 1354 deletions

View File

@@ -21,7 +21,7 @@ export {
runPassiveAccountLifecycle,
} from "openclaw/plugin-sdk/channel-lifecycle";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
export {
evaluateGroupRouteAccessForPolicy,
resolveDmGroupAccessWithLists,

View File

@@ -1,4 +1,9 @@
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
import {
composeAccountWarningCollectors,
createAllowlistProviderOpenWarningCollector,
@@ -36,6 +41,28 @@ const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
"googleChatChannelRuntime",
);
function createGoogleChatSendReceipt(params: {
messageId?: string;
chatId: string;
kind: MessageReceiptPartKind;
}) {
const messageId = params.messageId?.trim();
return createMessageReceiptFromOutboundResults({
results: messageId
? [
{
channel: "googlechat",
messageId,
chatId: params.chatId,
conversationId: params.chatId,
},
]
: [],
threadId: params.chatId,
kind: params.kind,
});
}
export const formatAllowFromEntry = (entry: string) =>
normalizeLowercaseStringOrEmpty(
entry
@@ -200,9 +227,11 @@ export const googlechatOutboundAdapter = {
text,
thread,
});
const messageId = result?.messageName ?? "";
return {
messageId: result?.messageName ?? "",
messageId,
chatId: space,
receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "text" }),
};
},
sendMedia: async ({
@@ -284,10 +313,28 @@ export const googlechatOutboundAdapter = {
]
: undefined,
});
const messageId = result?.messageName ?? "";
return {
messageId: result?.messageName ?? "",
messageId,
chatId: space,
receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "media" }),
};
},
},
};
export const googlechatMessageAdapter = defineChannelMessageAdapter({
id: "googlechat",
durableFinal: {
capabilities: {
text: true,
media: true,
thread: true,
messageSendingHooks: true,
},
},
send: {
text: googlechatOutboundAdapter.attachedResults.sendText,
media: googlechatOutboundAdapter.attachedResults.sendMedia,
},
});

View File

@@ -1,3 +1,4 @@
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message";
import {
createDirectoryTestRuntime,
expectDirectorySurface,
@@ -6,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
googlechatDirectoryAdapter,
googlechatMessageAdapter,
googlechatOutboundAdapter,
googlechatPairingTextAdapter,
googlechatSecurityAdapter,
@@ -206,6 +208,70 @@ function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: strin
}
describe("googlechatPlugin outbound sendMedia", () => {
it("declares message adapter durable text, media, and thread with receipt proofs", async () => {
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-1",
});
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-1",
});
const cfg = createGoogleChatCfg();
await expect(
verifyChannelMessageAdapterCapabilityProofs({
adapterName: "googlechat",
adapter: googlechatMessageAdapter,
proofs: {
text: async () => {
const result = await googlechatMessageAdapter.send?.text?.({
cfg,
to: "spaces/AAA",
text: "hello",
});
expect(result?.receipt.parts[0]?.kind).toBe("text");
expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]);
},
media: async () => {
const result = await googlechatMessageAdapter.send?.media?.({
cfg,
to: "spaces/AAA",
text: "image",
mediaUrl: "https://example.com/img.png",
});
expect(result?.receipt.parts[0]?.kind).toBe("media");
expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]);
},
thread: async () => {
sendGoogleChatMessageMock.mockClear();
await googlechatMessageAdapter.send?.text?.({
cfg,
to: "spaces/AAA",
text: "threaded",
threadId: "thread-1",
});
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/AAA",
thread: "thread-1",
}),
);
},
messageSendingHooks: () => {
expect(googlechatMessageAdapter.send?.text).toBeTypeOf("function");
},
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "thread", status: "verified" },
{ capability: "messageSendingHooks", status: "verified" },
]),
);
});
it("chunks outbound text without requiring Google Chat runtime initialization", () => {
const chunker = googlechatOutboundAdapter.base.chunker;
@@ -256,10 +322,11 @@ describe("googlechatPlugin outbound sendMedia", () => {
text: "caption",
}),
);
expect(result).toEqual({
expect(result).toMatchObject({
messageId: "spaces/AAA/messages/msg-1",
chatId: "spaces/AAA",
});
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-1");
});
it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => {
@@ -305,10 +372,11 @@ describe("googlechatPlugin outbound sendMedia", () => {
text: "caption",
}),
);
expect(result).toEqual({
expect(result).toMatchObject({
messageId: "spaces/AAA/messages/msg-2",
chatId: "spaces/AAA",
});
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-2");
});
});
@@ -572,7 +640,7 @@ describe("googlechatPlugin outbound cfg threading", () => {
mediaLocalRoots: ["/tmp/workspace"],
accountId: "default",
}),
).resolves.toEqual({
).resolves.toMatchObject({
messageId: "spaces/AAA/messages/msg-cold",
chatId: "spaces/AAA",
});

View File

@@ -17,6 +17,7 @@ import {
formatAllowFromEntry,
googlechatDirectoryAdapter,
googlechatGroupsAdapter,
googlechatMessageAdapter,
googlechatOutboundAdapter,
googlechatPairingTextAdapter,
googlechatSecurityAdapter,
@@ -155,6 +156,7 @@ export const googlechatPlugin = createChatChannelPlugin({
},
},
directory: googlechatDirectoryAdapter,
message: googlechatMessageAdapter,
resolver: {
resolveTargets: async ({ inputs, kind }) => {
const resolved = inputs.map((input) => {

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js";
describe("resolveGoogleChatDurableReplyOptions", () => {
it("enables durable final delivery when no typing preview is active", () => {
expect(
resolveGoogleChatDurableReplyOptions({
payload: { text: "hello", replyToId: "thread-1" },
infoKind: "final",
spaceId: "spaces/AAA",
}),
).toEqual({
to: "spaces/AAA",
replyToId: "thread-1",
threadId: "thread-1",
});
});
it("keeps typing preview delivery on the legacy edit path", () => {
expect(
resolveGoogleChatDurableReplyOptions({
payload: { text: "hello" },
infoKind: "final",
spaceId: "spaces/AAA",
typingMessageName: "spaces/AAA/messages/typing",
}),
).toBe(false);
});
it("does not durable-deliver non-final chunks", () => {
expect(
resolveGoogleChatDurableReplyOptions({
payload: { text: "hello" },
infoKind: "block",
spaceId: "spaces/AAA",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,23 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type GoogleChatDurableReplyOptions = {
to: string;
replyToId?: string;
threadId?: string;
};
export function resolveGoogleChatDurableReplyOptions(params: {
payload: ReplyPayload;
infoKind: string;
spaceId: string;
typingMessageName?: string;
}): GoogleChatDurableReplyOptions | false {
if (params.infoKind !== "final" || params.typingMessageName) {
return false;
}
const threadId = params.payload.replyToId?.trim() || undefined;
return {
to: params.spaceId,
...(threadId ? { replyToId: threadId, threadId } : {}),
};
}

View File

@@ -1,7 +1,6 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import type { OpenClawConfig } from "../runtime-api.js";
import {
createChannelReplyPipeline,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveWebhookPath,
} from "../runtime-api.js";
@@ -9,6 +8,7 @@ import { type ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
import { type GoogleChatAudienceType } from "./auth.js";
import { applyGoogleChatInboundAccessPolicy } from "./monitor-access.js";
import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js";
import { deliverGoogleChatReply } from "./monitor-reply-delivery.js";
import {
registerGoogleChatWebhookTarget,
@@ -281,13 +281,6 @@ async function processMessageWithPipeline(params: {
}
}
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: config,
agentId: route.agentId,
channel: "googlechat",
accountId: route.accountId,
});
await core.channel.turn.run({
channel: "googlechat",
accountId: route.accountId,
@@ -313,6 +306,13 @@ async function processMessageWithPipeline(params: {
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
durable: (payload, info) =>
resolveGoogleChatDurableReplyOptions({
payload,
infoKind: info.kind,
spaceId,
typingMessageName,
}),
deliver: async (payload) => {
await deliverGoogleChatReply({
payload,
@@ -327,16 +327,16 @@ async function processMessageWithPipeline(params: {
// Only use typing message for first delivery
typingMessageName = undefined;
},
onDelivered: () => {
statusSink?.({ lastOutboundAt: Date.now() });
},
onError: (err, info) => {
runtime.error?.(
`[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`,
);
},
},
dispatcherOptions: replyPipeline,
replyOptions: {
onModelSelected,
},
replyPipeline: {},
record: {
onRecordError: (err) => {
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);