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

@@ -0,0 +1,178 @@
import {
createMessageReceiptFromOutboundResults,
verifyChannelMessageAdapterCapabilityProofs,
} from "openclaw/plugin-sdk/channel-message";
import { describe, expect, it, vi } from "vitest";
import { bluebubblesPlugin } from "./channel.js";
const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn());
const sendBlueBubblesMediaMock = vi.hoisted(() => vi.fn());
const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn());
vi.mock("./channel.runtime.js", () => ({
blueBubblesChannelRuntime: {
sendMessageBlueBubbles: sendMessageBlueBubblesMock,
sendBlueBubblesMedia: sendBlueBubblesMediaMock,
resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock,
},
}));
describe("bluebubbles message adapter", () => {
it("declares durable text, media, and reply target capabilities with receipt proofs", async () => {
sendMessageBlueBubblesMock.mockImplementation(
async (_to: string, _text: string, opts: { replyToMessageGuid?: string } = {}) => ({
messageId: opts.replyToMessageGuid ? "bb-reply-1" : "bb-text-1",
receipt: createMessageReceiptFromOutboundResults({
results: [
{
channel: "bluebubbles",
messageId: opts.replyToMessageGuid ? "bb-reply-1" : "bb-text-1",
},
],
kind: "text",
...(opts.replyToMessageGuid ? { replyToId: opts.replyToMessageGuid } : {}),
}),
}),
);
sendBlueBubblesMediaMock.mockResolvedValue({
messageId: "bb-media-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "bluebubbles", messageId: "bb-media-1" }],
kind: "media",
}),
});
resolveBlueBubblesMessageIdMock.mockReturnValue("guid-reply-1");
await expect(
verifyChannelMessageAdapterCapabilityProofs({
adapterName: "bluebubbles",
adapter: bluebubblesPlugin.message!,
proofs: {
text: async () => {
const result = await bluebubblesPlugin.message?.send?.text?.({
cfg: {},
to: "+15551234567",
text: "hello",
});
expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith("+15551234567", "hello", {
cfg: {},
accountId: undefined,
replyToMessageGuid: undefined,
});
expect(result?.receipt.platformMessageIds).toEqual(["bb-text-1"]);
},
media: async () => {
const result = await bluebubblesPlugin.message?.send?.media?.({
cfg: {},
to: "+15551234567",
text: "image",
mediaUrl: "https://example.com/image.png",
});
expect(sendBlueBubblesMediaMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "+15551234567",
mediaUrl: "https://example.com/image.png",
caption: "image",
}),
);
expect(result?.receipt.platformMessageIds).toEqual(["bb-media-1"]);
},
replyTo: async () => {
const result = await bluebubblesPlugin.message?.send?.text?.({
cfg: {},
to: "chat_guid:chat-1",
text: "reply",
replyToId: "short-1",
});
expect(resolveBlueBubblesMessageIdMock).toHaveBeenCalledWith(
"short-1",
expect.objectContaining({ requireKnownShortId: true }),
);
expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith("chat_guid:chat-1", "reply", {
cfg: {},
accountId: undefined,
replyToMessageGuid: "guid-reply-1",
});
expect(result?.receipt.replyToId).toBe("guid-reply-1");
},
messageSendingHooks: async () => {
const beforeSendAttempt = vi.fn(() => "pending-1");
const afterSendFailure = vi.fn();
const ctx = {
cfg: {},
kind: "text" as const,
to: "+15551234567",
text: "hello",
deps: {
bluebubblesMessageLifecycle: {
beforeSendAttempt,
afterSendFailure,
},
},
};
const attemptToken =
await bluebubblesPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx);
await bluebubblesPlugin.message?.send?.lifecycle?.afterSendFailure?.({
...ctx,
error: new Error("send failed"),
attemptToken,
});
expect(beforeSendAttempt).toHaveBeenCalledWith(ctx);
expect(afterSendFailure).toHaveBeenCalledWith(
expect.objectContaining({
kind: "text",
attemptToken: "pending-1",
error: expect.any(Error),
}),
);
},
afterSendSuccess: async () => {
const beforeSendAttempt = vi.fn(() => "pending-1");
const afterSendSuccess = vi.fn();
const ctx = {
cfg: {},
kind: "text" as const,
to: "+15551234567",
text: "hello",
deps: {
bluebubblesMessageLifecycle: {
beforeSendAttempt,
afterSendSuccess,
},
},
};
const attemptToken =
await bluebubblesPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx);
await bluebubblesPlugin.message?.send?.lifecycle?.afterSendSuccess?.({
...ctx,
result: {
messageId: "bb-text-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "bluebubbles", messageId: "bb-text-1" }],
kind: "text",
}),
},
attemptToken,
});
expect(beforeSendAttempt).toHaveBeenCalledWith(ctx);
expect(afterSendSuccess).toHaveBeenCalledWith(
expect.objectContaining({
kind: "text",
attemptToken: "pending-1",
result: expect.objectContaining({ messageId: "bb-text-1" }),
}),
);
},
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "replyTo", status: "verified" },
{ capability: "messageSendingHooks", status: "verified" },
{ capability: "afterSendSuccess", status: "verified" },
]),
);
});
});

View File

@@ -2,11 +2,21 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
type ChannelMessageSendAttemptContext,
type ChannelMessageSendFailureContext,
type ChannelMessageSendSuccessContext,
type ChannelMessageSendResult,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
import {
createOpenGroupPolicyRestrictSendersWarningCollector,
projectAccountWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
createComputedAccountStatusAdapter,
@@ -61,6 +71,169 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
"blueBubblesChannelRuntime",
);
type BlueBubblesRuntime = Awaited<ReturnType<typeof loadBlueBubblesChannelRuntime>>;
type BlueBubblesMediaExtras = {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
type BlueBubblesMessageLifecycleDeps = {
beforeSendAttempt?: (ctx: ChannelMessageSendAttemptContext) => unknown;
afterSendSuccess?: (ctx: ChannelMessageSendSuccessContext) => Promise<void> | void;
afterSendFailure?: (ctx: ChannelMessageSendFailureContext) => Promise<void> | void;
};
function resolveBlueBubblesMessageLifecycleDeps(
ctx:
| ChannelMessageSendAttemptContext
| ChannelMessageSendSuccessContext
| ChannelMessageSendFailureContext,
): BlueBubblesMessageLifecycleDeps | undefined {
const candidate = ctx.deps?.bluebubblesMessageLifecycle;
if (!candidate || typeof candidate !== "object") {
return undefined;
}
return candidate as BlueBubblesMessageLifecycleDeps;
}
function resolveBlueBubblesReplyToMessageGuid(params: {
runtime: BlueBubblesRuntime;
to: string;
replyToId?: string | null;
}): string | undefined {
const rawReplyToId = normalizeOptionalString(params.replyToId) ?? "";
if (!rawReplyToId) {
return undefined;
}
return (
params.runtime.resolveBlueBubblesMessageId(rawReplyToId, {
requireKnownShortId: true,
chatContext: buildBlueBubblesChatContextFromTarget(params.to),
}) || undefined
);
}
async function sendBlueBubblesTextWithRuntime(params: {
cfg: OpenClawConfig;
to: string;
text: string;
accountId?: string;
replyToId?: string | null;
}) {
const runtime = await loadBlueBubblesChannelRuntime();
return await runtime.sendMessageBlueBubbles(params.to, params.text, {
cfg: params.cfg,
accountId: params.accountId,
replyToMessageGuid: resolveBlueBubblesReplyToMessageGuid({
runtime,
to: params.to,
replyToId: params.replyToId,
}),
});
}
async function sendBlueBubblesMediaWithRuntime(params: {
cfg: OpenClawConfig;
to: string;
text?: string;
mediaUrl: string;
accountId?: string;
replyToId?: string | null;
audioAsVoice?: boolean;
extras?: BlueBubblesMediaExtras;
}) {
const runtime = await loadBlueBubblesChannelRuntime();
return await runtime.sendBlueBubblesMedia({
cfg: params.cfg,
to: params.to,
mediaUrl: params.mediaUrl,
mediaPath: params.extras?.mediaPath,
mediaBuffer: params.extras?.mediaBuffer,
contentType: params.extras?.contentType,
filename: params.extras?.filename,
caption: params.extras?.caption ?? params.text ?? undefined,
replyToId:
resolveBlueBubblesReplyToMessageGuid({
runtime,
to: params.to,
replyToId: params.replyToId,
}) ?? null,
accountId: params.accountId,
asVoice: params.audioAsVoice === true,
});
}
function toBlueBubblesMessageSendResult(
result: { messageId?: string; receipt?: ChannelMessageSendResult["receipt"] },
kind: MessageReceiptPartKind,
replyToId?: string | null,
): ChannelMessageSendResult {
const receipt =
result.receipt ??
createMessageReceiptFromOutboundResults({
results: result.messageId ? [{ channel: "bluebubbles", messageId: result.messageId }] : [],
kind,
...(replyToId ? { replyToId } : {}),
});
return {
messageId: result.messageId || receipt.primaryPlatformMessageId,
receipt,
};
}
const bluebubblesMessageAdapter = defineChannelMessageAdapter({
id: "bluebubbles",
durableFinal: {
capabilities: {
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
afterSendSuccess: true,
},
},
send: {
lifecycle: {
beforeSendAttempt: async (ctx) =>
await resolveBlueBubblesMessageLifecycleDeps(ctx)?.beforeSendAttempt?.(ctx),
afterSendSuccess: async (ctx) => {
await resolveBlueBubblesMessageLifecycleDeps(ctx)?.afterSendSuccess?.(ctx);
},
afterSendFailure: async (ctx) => {
await resolveBlueBubblesMessageLifecycleDeps(ctx)?.afterSendFailure?.(ctx);
},
},
text: async (ctx) =>
toBlueBubblesMessageSendResult(
await sendBlueBubblesTextWithRuntime({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
accountId: ctx.accountId ?? undefined,
replyToId: ctx.replyToId,
}),
"text",
ctx.replyToId,
),
media: async (ctx) =>
toBlueBubblesMessageSendResult(
await sendBlueBubblesMediaWithRuntime({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
mediaUrl: ctx.mediaUrl,
accountId: ctx.accountId ?? undefined,
replyToId: ctx.replyToId,
audioAsVoice: ctx.audioAsVoice,
}),
"media",
ctx.replyToId,
),
},
});
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
channelKey: "bluebubbles",
resolvePolicy: (account) => account.config.dmPolicy,
@@ -281,6 +454,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
}
},
},
message: bluebubblesMessageAdapter,
},
security: {
resolveDmPolicy: resolveBlueBubblesDmPolicy,
@@ -318,24 +492,19 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
},
attachedResults: {
channel: "bluebubbles",
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = normalizeOptionalString(replyToId) ?? "";
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, {
requireKnownShortId: true,
chatContext: buildBlueBubblesChatContextFromTarget(to),
})
: "";
return await runtime.sendMessageBlueBubbles(to, text, {
cfg: cfg,
sendText: async ({ cfg, to, text, accountId, replyToId }) =>
await sendBlueBubblesTextWithRuntime({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
},
replyToId,
}),
sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId, audioAsVoice } = ctx;
if (!mediaUrl) {
throw new Error("BlueBubbles media send requires mediaUrl");
}
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
@@ -343,18 +512,15 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
filename?: string;
caption?: string;
};
return await runtime.sendBlueBubblesMedia({
cfg: cfg,
return await sendBlueBubblesMediaWithRuntime({
cfg,
to,
text,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: caption ?? text ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
asVoice: audioAsVoice === true,
replyToId,
audioAsVoice,
extras: { mediaPath, mediaBuffer, contentType, filename, caption },
});
},
},

View File

@@ -3,7 +3,7 @@ export { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-fee
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-message";
export {
DM_GROUP_ACCESS_REASON,
readStoreAllowFromForDmPolicy,

View File

@@ -2,6 +2,7 @@ import {
resolveOutboundMediaUrls,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
type ReplyPayload,
} from "openclaw/plugin-sdk/reply-payload";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
@@ -38,7 +39,7 @@ import {
import {
DM_GROUP_ACCESS_REASON,
createChannelPairingController,
createChannelReplyPipeline,
deriveDurableFinalDeliveryRequirements,
evictOldHistoryKeys,
evaluateSupplementalContextVisibility,
logAckFailure,
@@ -1541,6 +1542,45 @@ async function processMessageAfterDedupe(
.replace(/[ \t]+/g, " ")
.trim();
};
const resolveReplyToMessageGuidForPayload = (payload: { replyToId?: string | null }): string => {
const rawReplyToId =
privateApiEnabled && typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
if (!rawReplyToId) {
return "";
}
return (
resolveBlueBubblesMessageId(rawReplyToId, {
requireKnownShortId: true,
chatContext: {
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
},
}) || ""
);
};
const prepareBlueBubblesReplyPayload = (payload: ReplyPayload): ReplyPayload => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "bluebubbles",
accountId: account.accountId,
});
const text = sanitizeReplyDirectiveText(
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
);
return {
...payload,
text,
...(typeof payload.replyToId === "string" && !privateApiEnabled ? { replyToId: "" } : {}),
};
};
const canUseDurableBlueBubblesFinalDelivery = (payload: { text?: string }): boolean => {
const textLimit =
account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit
: DEFAULT_TEXT_LIMIT;
return (payload.text ?? "").length <= textLimit;
};
// History: in-memory rolling map with bounded API backfill retries
const historyLimit = isGroup
@@ -1728,42 +1768,36 @@ async function processMessageAfterDedupe(
}, typingRestartDelayMs);
};
try {
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
cfg: config,
agentId: route.agentId,
channel: "bluebubbles",
accountId: account.accountId,
typingCallbacks: {
onReplyStart: async () => {
if (!chatGuidForActions) {
return;
}
if (!baseUrl || !password) {
return;
}
streamingActive = true;
clearTypingRestartTimer();
try {
await sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
accountId: account.accountId,
});
} catch (err) {
runtime.error?.(`[bluebubbles] typing start failed: ${sanitizeForLog(err)}`);
}
},
onIdle: () => {
if (!chatGuidForActions) {
return;
}
if (!baseUrl || !password) {
return;
}
// Intentionally no-op for block streaming. We stop typing in finally
// after the run completes to avoid flicker between paragraph blocks.
},
const typingCallbacks = {
onReplyStart: async () => {
if (!chatGuidForActions) {
return;
}
if (!baseUrl || !password) {
return;
}
streamingActive = true;
clearTypingRestartTimer();
try {
await sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
accountId: account.accountId,
});
} catch (err) {
runtime.error?.(`[bluebubbles] typing start failed: ${sanitizeForLog(err)}`);
}
},
});
onIdle: () => {
if (!chatGuidForActions) {
return;
}
if (!baseUrl || !password) {
return;
}
// Intentionally no-op for block streaming. We stop typing in finally
// after the run completes to avoid flicker between paragraph blocks.
},
};
await core.channel.turn.run({
channel: "bluebubbles",
accountId: account.accountId,
@@ -1789,6 +1823,76 @@ async function processMessageAfterDedupe(
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
preparePayload: (payload) => prepareBlueBubblesReplyPayload(payload),
durable: (payload, info) => {
if (info.kind !== "final" || !canUseDurableBlueBubblesFinalDelivery(payload)) {
return false;
}
const replyToMessageGuid = resolveReplyToMessageGuidForPayload(payload);
return {
to: outboundTarget,
replyToId:
typeof payload.replyToId === "string" ? payload.replyToId.trim() || null : null,
deps: {
bluebubblesMessageLifecycle: {
beforeSendAttempt: (ctx: { kind: string; text?: string }) => {
const snippet =
ctx.kind === "media"
? (ctx.text ?? "").trim() || "<media:attachment>"
: (ctx.text ?? "").trim();
return rememberPendingOutboundMessageId({
accountId: account.accountId,
sessionKey: route.sessionKey,
outboundTarget,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
snippet,
});
},
afterSendSuccess: (ctx: {
kind: string;
text?: string;
result?: { messageId?: string };
attemptToken?: unknown;
}) => {
const snippet =
ctx.kind === "media"
? (ctx.text ?? "").trim() || "<media:attachment>"
: (ctx.text ?? "").trim();
if (
maybeEnqueueOutboundMessageId(ctx.result?.messageId, snippet) &&
typeof ctx.attemptToken === "number"
) {
forgetPendingOutboundMessageId(ctx.attemptToken);
}
},
afterSendFailure: (ctx: { attemptToken?: unknown }) => {
if (typeof ctx.attemptToken === "number") {
forgetPendingOutboundMessageId(ctx.attemptToken);
}
},
},
},
requiredCapabilities: deriveDurableFinalDeliveryRequirements({
payload,
replyToId: replyToMessageGuid || null,
afterSendSuccess: true,
}),
};
},
onDelivered: (_payload, info, result) => {
if (!result?.deliveryIntent) {
return;
}
if (result.visibleReplySent === true) {
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
if (info.kind === "block") {
restartTypingSoon();
}
}
},
deliver: async (payload, info) => {
const rawReplyToId =
privateApiEnabled && typeof payload.replyToId === "string"
@@ -1932,13 +2036,14 @@ async function processMessageAfterDedupe(
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${sanitizeForLog(err)}`);
},
},
replyPipeline: {
typingCallbacks,
},
dispatcherOptions: {
...replyPipeline,
onReplyStart: typingCallbacks?.onReplyStart,
onIdle: typingCallbacks?.onIdle,
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
},
replyOptions: {
onModelSelected,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming

View File

@@ -34,7 +34,16 @@ import { _setFetchGuardForTesting } from "./types.js";
// Mock dependencies
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({
messageId: "msg-123",
receipt: {
primaryPlatformMessageId: "msg-123",
platformMessageIds: ["msg-123"],
parts: [],
sentAt: 0,
raw: [],
},
}),
}));
vi.mock("./chat.js", () => ({
@@ -78,6 +87,20 @@ const DEFAULT_RESOLVED_AGENT_ROUTE: ReturnType<
matchedBy: "default",
};
const mockResolveAgentRoute = vi.fn(() => DEFAULT_RESOLVED_AGENT_ROUTE);
function blueBubblesTestSendResult(messageId: string) {
const hasPlatformId = messageId && messageId !== "ok" && messageId !== "unknown";
return {
messageId,
receipt: {
...(hasPlatformId ? { primaryPlatformMessageId: messageId } : {}),
platformMessageIds: hasPlatformId ? [messageId] : [],
parts: [],
sentAt: 0,
raw: [],
},
};
}
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
regexes.some((r) => r.test(text)),
@@ -2043,7 +2066,7 @@ describe("BlueBubbles webhook monitor", () => {
mockEnqueueSystemEvent.mockClear();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
@@ -2083,7 +2106,7 @@ describe("BlueBubbles webhook monitor", () => {
mockEnqueueSystemEvent.mockClear();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
@@ -2543,7 +2566,9 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" });
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(
blueBubblesTestSendResult("msg-self-1"),
);
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
@@ -2693,7 +2718,7 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" });

View File

@@ -39,7 +39,7 @@ export {
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
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 { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
export { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";

View File

@@ -717,6 +717,21 @@ describe("send", () => {
});
expect(result.messageId).toBe("msg-uuid-123");
expect(result.receipt).toMatchObject({
primaryPlatformMessageId: "msg-uuid-123",
platformMessageIds: ["msg-uuid-123"],
parts: [
{
platformMessageId: "msg-uuid-123",
kind: "text",
raw: {
channel: "bluebubbles",
conversationId: "iMessage;-;+15551234567",
messageId: "msg-uuid-123",
},
},
],
});
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
@@ -812,6 +827,16 @@ describe("send", () => {
});
expect(result.messageId).toBe("new-msg-guid");
expect(result.receipt).toMatchObject({
primaryPlatformMessageId: "new-msg-guid",
platformMessageIds: ["new-msg-guid"],
parts: [
{
platformMessageId: "new-msg-guid",
kind: "text",
},
],
});
expect(mockFetch).toHaveBeenCalledTimes(2);
const createCall = mockFetch.mock.calls[1];
@@ -857,6 +882,18 @@ describe("send", () => {
});
expect(result.messageId).toBe("msg-uuid-124");
expect(result.receipt).toMatchObject({
primaryPlatformMessageId: "msg-uuid-124",
platformMessageIds: ["msg-uuid-124"],
replyToId: "reply-guid-123",
parts: [
{
platformMessageId: "msg-uuid-124",
kind: "text",
replyToId: "reply-guid-123",
},
],
});
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
@@ -1053,6 +1090,8 @@ describe("send", () => {
});
expect(result.messageId).toBe("ok");
expect(result.receipt.platformMessageIds).toEqual([]);
expect(result.receipt.parts).toEqual([]);
});
it("handles invalid JSON response body", async () => {
@@ -1068,6 +1107,8 @@ describe("send", () => {
});
expect(result.messageId).toBe("ok");
expect(result.receipt.platformMessageIds).toEqual([]);
expect(result.receipt.parts).toEqual([]);
});
it("extracts messageId from various response formats", async () => {

View File

@@ -1,4 +1,9 @@
import crypto from "node:crypto";
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptSourceResult,
} from "openclaw/plugin-sdk/channel-message";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
@@ -35,6 +40,7 @@ export type BlueBubblesSendOpts = {
export type BlueBubblesSendResult = {
messageId: string;
receipt: MessageReceipt;
};
/** Maps short effect names to full Apple effect IDs */
@@ -118,17 +124,61 @@ function resolvePrivateApiDecision(params: {
};
}
async function parseBlueBubblesMessageResponse(res: Response): Promise<BlueBubblesSendResult> {
function createBlueBubblesSendReceipt(params: {
messageId: string;
chatGuid?: string | null;
replyToMessageGuid?: string;
}): MessageReceipt {
const messageId = params.messageId.trim();
const results: MessageReceiptSourceResult[] =
messageId && messageId !== "unknown" && messageId !== "ok"
? [
{
channel: "bluebubbles",
messageId,
},
]
: [];
if (results[0] && params.chatGuid) {
results[0].conversationId = params.chatGuid;
}
return createMessageReceiptFromOutboundResults({
results,
kind: "text",
...(params.replyToMessageGuid ? { replyToId: params.replyToMessageGuid } : {}),
});
}
async function parseBlueBubblesMessageResponse(
res: Response,
params: { chatGuid?: string | null; replyToMessageGuid?: string } = {},
): Promise<BlueBubblesSendResult> {
const body = await res.text();
let messageId = "ok";
if (!body) {
return { messageId: "ok" };
return {
messageId,
receipt: createBlueBubblesSendReceipt({
messageId,
...(params.chatGuid ? { chatGuid: params.chatGuid } : {}),
...(params.replyToMessageGuid ? { replyToMessageGuid: params.replyToMessageGuid } : {}),
}),
};
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
messageId = extractBlueBubblesMessageId(parsed);
} catch {
return { messageId: "ok" };
messageId = "ok";
}
return {
messageId,
receipt: createBlueBubblesSendReceipt({
messageId,
...(params.chatGuid ? { chatGuid: params.chatGuid } : {}),
...(params.replyToMessageGuid ? { replyToMessageGuid: params.replyToMessageGuid } : {}),
}),
};
}
type BlueBubblesChatRecord = Record<string, unknown>;
@@ -479,7 +529,13 @@ async function createNewChatWithMessage(params: {
timeoutMs: params.timeoutMs,
allowPrivateNetwork: params.allowPrivateNetwork,
});
return { messageId: result.messageId };
return {
messageId: result.messageId,
receipt: createBlueBubblesSendReceipt({
messageId: result.messageId,
chatGuid: result.chatGuid,
}),
};
}
export async function sendMessageBlueBubbles(
@@ -614,5 +670,10 @@ export async function sendMessageBlueBubbles(
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
return parseBlueBubblesMessageResponse(res);
return parseBlueBubblesMessageResponse(res, {
chatGuid,
...(wantsReplyThread && opts.replyToMessageGuid
? { replyToMessageGuid: opts.replyToMessageGuid }
: {}),
});
}

View File

@@ -91,6 +91,10 @@ type TelemetryExporterDiagnosticEvent = Extract<
DiagnosticEventPayload,
{ type: "telemetry.exporter" }
>;
type SessionRecoveryDiagnosticEvent = Extract<
DiagnosticEventPayload,
{ type: "session.recovery.requested" | "session.recovery.completed" }
>;
const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = {
inputMessages: false,
@@ -819,6 +823,27 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
unit: "ms",
description: "Age of stuck sessions",
});
const sessionRecoveryRequestedCounter = meter.createCounter(
"openclaw.session.recovery.requested",
{
unit: "1",
description: "Session recovery attempts requested",
},
);
const sessionRecoveryCompletedCounter = meter.createCounter(
"openclaw.session.recovery.completed",
{
unit: "1",
description: "Session recovery attempts completed",
},
);
const sessionRecoveryAgeHistogram = meter.createHistogram(
"openclaw.session.recovery.age_ms",
{
unit: "ms",
description: "Age of sessions selected for recovery",
},
);
const runAttemptCounter = meter.createCounter("openclaw.run.attempt", {
unit: "1",
description: "Run attempts",
@@ -1468,6 +1493,39 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
span.end();
};
const sessionRecoveryAttrs = (evt: SessionRecoveryDiagnosticEvent) => {
const attrs: Record<string, string> = { "openclaw.state": evt.state };
if (evt.reason) {
attrs["openclaw.reason"] = redactSensitiveText(evt.reason);
}
if (evt.activeWorkKind) {
attrs["openclaw.active_work_kind"] = evt.activeWorkKind;
}
return attrs;
};
const recordSessionRecoveryRequested = (
evt: Extract<DiagnosticEventPayload, { type: "session.recovery.requested" }>,
) => {
const attrs = sessionRecoveryAttrs(evt);
attrs["openclaw.action"] = evt.allowActiveAbort ? "abort" : "recover";
sessionRecoveryRequestedCounter.add(1, attrs);
sessionRecoveryAgeHistogram.record(evt.ageMs, attrs);
};
const recordSessionRecoveryCompleted = (
evt: Extract<DiagnosticEventPayload, { type: "session.recovery.completed" }>,
) => {
const attrs = sessionRecoveryAttrs(evt);
attrs["openclaw.status"] = evt.status;
attrs["openclaw.action"] = lowCardinalityAttr(evt.action, "unknown");
if (evt.outcomeReason) {
attrs["openclaw.reason"] = redactSensitiveText(evt.outcomeReason);
}
sessionRecoveryCompletedCounter.add(1, attrs);
sessionRecoveryAgeHistogram.record(evt.ageMs, attrs);
};
const recordRunAttempt = (evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>) => {
runAttemptCounter.add(1, { "openclaw.attempt": evt.attempt });
};
@@ -2236,12 +2294,16 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return;
case "session.long_running":
case "session.stalled":
case "session.recovery.completed":
case "session.recovery.requested":
return;
case "session.stuck":
recordSessionStuck(evt);
return;
case "session.recovery.requested":
recordSessionRecoveryRequested(evt);
return;
case "session.recovery.completed":
recordSessionRecoveryCompleted(evt);
return;
case "run.attempt":
recordRunAttempt(evt);
return;

View File

@@ -229,6 +229,59 @@ describe("discordMessageActions", () => {
).toBeNull();
});
it("prepares Discord send payload channel data for durable core delivery", async () => {
const prepared = await discordMessageActions.prepareSendPayload?.({
ctx: {
channel: "discord",
action: "send",
cfg: {} as OpenClawConfig,
params: {
components: {
text: "Choose",
blocks: [
{
type: "actions",
buttons: [{ label: "Yes", callbackData: "yes" }],
},
],
},
embeds: undefined,
filename: "photo.png",
},
},
to: "channel:123",
payload: { text: "hello", mediaUrl: "/tmp/photo.png" },
});
expect(prepared).toMatchObject({
text: "hello",
mediaUrl: "/tmp/photo.png",
channelData: {
discord: {
components: expect.objectContaining({ text: "Choose" }),
filename: "photo.png",
},
},
});
});
it("keeps non-serializable Discord component sends on the legacy action path", async () => {
const prepared = await discordMessageActions.prepareSendPayload?.({
ctx: {
channel: "discord",
action: "send",
cfg: {} as OpenClawConfig,
params: {
components: () => [],
},
},
to: "channel:123",
payload: { text: "hello" },
});
expect(prepared).toBeNull();
});
it("delegates action handling to the Discord action handler", async () => {
const cfg = {
channels: {

View File

@@ -9,6 +9,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { inspectDiscordAccount } from "./account-inspect.js";
import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js";
import { readDiscordComponentSpec } from "./components.js";
let discordChannelActionsRuntimePromise:
| Promise<typeof import("./channel-actions.runtime.js")>
@@ -175,6 +176,47 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
}
return null;
},
prepareSendPayload: ({ ctx, payload }) => {
if (ctx.action !== "send") {
return null;
}
const rawComponents = ctx.params.components;
if (typeof rawComponents === "function") {
return null;
}
const componentSpec =
rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents)
? readDiscordComponentSpec(rawComponents)
: undefined;
const nativeComponents = Array.isArray(rawComponents) ? rawComponents : undefined;
const embeds = Array.isArray(ctx.params.embeds) ? ctx.params.embeds : undefined;
if ((componentSpec || nativeComponents) && embeds?.length) {
return null;
}
const filename = normalizeOptionalString(ctx.params.filename);
if (!componentSpec && !nativeComponents && !embeds?.length && !filename) {
return payload;
}
const discordData =
payload.channelData?.discord &&
typeof payload.channelData.discord === "object" &&
!Array.isArray(payload.channelData.discord)
? (payload.channelData.discord as Record<string, unknown>)
: {};
return {
...payload,
channelData: {
...payload.channelData,
discord: {
...discordData,
...(componentSpec ? { components: componentSpec } : {}),
...(nativeComponents ? { components: nativeComponents } : {}),
...(embeds?.length ? { embeds } : {}),
...(filename ? { filename } : {}),
},
},
};
},
handleAction: async ({
action,
params,

View File

@@ -0,0 +1,161 @@
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-message";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
createDiscordOutboundHoisted,
installDiscordOutboundModuleSpies,
resetDiscordOutboundMocks,
} from "./outbound-adapter.test-harness.js";
const hoisted = createDiscordOutboundHoisted();
await installDiscordOutboundModuleSpies(hoisted);
let discordPlugin: typeof import("./channel.js").discordPlugin;
beforeAll(async () => {
({ discordPlugin } = await import("./channel.js"));
});
describe("discord channel message adapter", () => {
beforeEach(() => {
resetDiscordOutboundMocks(hoisted);
});
it("backs declared durable-final capabilities with outbound send proofs", async () => {
const adapter = discordPlugin.message;
expect(adapter).toBeDefined();
const proveText = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await adapter!.send!.text!({
cfg: {},
to: "channel:123456",
text: "hello",
accountId: "default",
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
"channel:123456",
"hello",
expect.objectContaining({ accountId: "default" }),
);
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await adapter!.send!.media!({
cfg: {},
to: "channel:123456",
text: "caption",
mediaUrl: "https://example.com/a.png",
accountId: "default",
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
"channel:123456",
"caption",
expect.objectContaining({
accountId: "default",
mediaUrl: "https://example.com/a.png",
}),
);
expect(result.receipt.parts[0]?.kind).toBe("media");
};
const provePayload = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await adapter!.send!.payload!({
cfg: {},
to: "channel:123456",
text: "payload",
payload: { text: "payload" },
accountId: "default",
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
"channel:123456",
"payload",
expect.objectContaining({ accountId: "default" }),
);
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
};
const proveReplyThreadSilent = async () => {
resetDiscordOutboundMocks(hoisted);
const result = await adapter!.send!.text!({
cfg: {},
to: "channel:parent-1",
text: "threaded",
accountId: "default",
replyToId: "reply-1",
threadId: "thread-1",
silent: true,
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
"channel:thread-1",
"threaded",
expect.objectContaining({
accountId: "default",
replyTo: "reply-1",
silent: true,
}),
);
expect(result.receipt.threadId).toBe("thread-1");
expect(result.receipt.replyToId).toBe("reply-1");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "discordMessageAdapter",
adapter: adapter!,
proofs: {
text: proveText,
media: proveMedia,
payload: provePayload,
silent: proveReplyThreadSilent,
replyTo: proveReplyThreadSilent,
thread: proveReplyThreadSilent,
messageSendingHooks: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
},
});
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = discordPlugin.message;
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "discordMessageAdapter",
adapter: adapter!,
proofs: {
draftPreview: () => {
expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "discordMessageAdapter",
adapter: adapter!,
proofs: {
finalEdit: () => {
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
},
});
});
});

View File

@@ -6,6 +6,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
import type { ResolvedDiscordAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
import * as sendModule from "./send.js";
import { createDiscordSendReceipt } from "./send.receipt.js";
import { EMPTY_DISCORD_TEST_CONFIG } from "./test-support/config.js";
let discordPlugin: typeof import("./channel.js").discordPlugin;
let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime;
@@ -18,6 +19,14 @@ const collectDiscordAuditChannelIdsMock = vi.hoisted(() =>
);
const sleepWithAbortMock = vi.hoisted(() => vi.fn(async () => undefined));
function discordTestSendResult(messageId: string, channelId = "channel:thread-123") {
return {
messageId,
channelId,
receipt: createDiscordSendReceipt({ platformMessageIds: [messageId], channelId, kind: "text" }),
};
}
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
"openclaw/plugin-sdk/runtime-env",
@@ -250,8 +259,8 @@ describe("discordPlugin outbound", () => {
it("splits text and video into separate sends for attached outbound delivery", async () => {
const sendMessageDiscord = vi
.fn()
.mockResolvedValueOnce({ messageId: "text-1" })
.mockResolvedValueOnce({ messageId: "video-1" });
.mockResolvedValueOnce(discordTestSendResult("text-1"))
.mockResolvedValueOnce(discordTestSendResult("video-1"));
const result = await discordPlugin.outbound!.sendMedia!({
cfg: EMPTY_DISCORD_TEST_CONFIG,
@@ -287,10 +296,7 @@ describe("discordPlugin outbound", () => {
});
it("threads poll sends through the thread target", async () => {
const sendPollDiscord = vi.fn(async () => ({
channelId: "channel:thread-123",
messageId: "poll-1",
}));
const sendPollDiscord = vi.fn(async () => discordTestSendResult("poll-1"));
const sendPollSpy = vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(sendPollDiscord);
try {
const result = await discordPlugin.outbound!.sendPoll!({

View File

@@ -8,6 +8,7 @@ import type {
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
createChannelDirectoryAdapter,
@@ -81,6 +82,24 @@ import { parseDiscordTarget } from "./target-parsing.js";
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
const discordMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "discord",
outbound: discordOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
},
},
},
});
function startDiscordStartupProbe(params: {
accountId: string;
@@ -180,6 +199,12 @@ const discordMessageActions = {
resolveRuntimeDiscordMessageActions()?.extractToolSend?.(ctx) ??
discordMessageActionsImpl.extractToolSend?.(ctx) ??
null,
prepareSendPayload: (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["prepareSendPayload"]>>[0],
) =>
resolveRuntimeDiscordMessageActions()?.prepareSendPayload?.(ctx) ??
discordMessageActionsImpl.prepareSendPayload?.(ctx) ??
null,
handleAction: async (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["handleAction"]>>[0],
) => {
@@ -315,6 +340,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
listGroupsLive: (runtime) => runtime.listDiscordDirectoryGroupsLive,
}),
}),
message: discordMessageAdapter,
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
const account = resolveDiscordAccount({ cfg, accountId });

View File

@@ -37,7 +37,7 @@ import { deliverDiscordReply } from "./reply-delivery.js";
let conversationRuntimePromise: Promise<typeof import("./agent-components.runtime.js")> | undefined;
let replyPipelineRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/channel-reply-pipeline")>
| Promise<typeof import("openclaw/plugin-sdk/channel-message")>
| undefined;
let typingRuntimePromise: Promise<typeof import("./typing.js")> | undefined;
@@ -47,7 +47,7 @@ async function loadConversationRuntime() {
}
async function loadReplyPipelineRuntime() {
replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-reply-pipeline");
replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-message");
return await replyPipelineRuntimePromise;
}
@@ -241,8 +241,8 @@ export async function dispatchDiscordComponentEvent(params: {
const deliverTarget = `channel:${interactionCtx.channelId}`;
const typingChannelId = interactionCtx.channelId;
const { createChannelReplyPipeline } = await loadReplyPipelineRuntime();
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
const { createChannelMessageReplyPipeline } = await loadReplyPipelineRuntime();
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg: ctx.cfg,
agentId,
channel: "discord",

View File

@@ -6,11 +6,12 @@ import {
logTypingFailure,
shouldAckReaction as shouldAckReactionGate,
} from "openclaw/plugin-sdk/channel-feedback";
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
import {
createChannelReplyPipeline,
resolveChannelSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-reply-pipeline";
createChannelMessageReplyPipeline,
defineFinalizableLivePreviewAdapter,
deliverWithFinalizableLivePreviewAdapter,
resolveChannelMessageSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-message";
import {
formatChannelProgressDraftLine,
formatChannelProgressDraftLineForEntry,
@@ -173,7 +174,7 @@ export async function processDiscordMessage(
}
const { createReplyDispatcherWithTyping, dispatchInboundMessage, settleReplyDispatcher } =
await loadReplyRuntime();
const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
cfg,
ctx: { ChatType: isGuildMessage ? "channel" : undefined },
});
@@ -364,7 +365,7 @@ export async function processDiscordMessage(
? deliverTarget.slice("channel:".length)
: messageChannelId;
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg,
agentId: route.agentId,
channel: "discord",
@@ -455,39 +456,51 @@ export async function processDiscordMessage(
Boolean(payload.replyToTag || payload.replyToCurrent) ||
(typeof finalText === "string" && /\[\[\s*reply_to(?:_current|\s*:)/i.test(finalText));
const result = await deliverFinalizableDraftPreview({
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: info.kind,
payload,
draft: {
flush: () => draftPreview.flush(),
clear: () => draftStream.clear(),
discardPending: () => draftStream.discardPending(),
seal: () => draftStream.seal(),
id: draftStream.messageId,
},
buildFinalEdit: () => {
if (
draftPreview.finalizedViaPreviewMessage ||
hasMedia ||
typeof previewFinalText !== "string" ||
hasExplicitReplyDirective ||
payload.isError
) {
return undefined;
}
return { content: previewFinalText };
},
editFinal: async (previewMessageId, edit) => {
if (isProcessAborted(abortSignal)) {
throw new Error("process aborted");
}
notifyFinalReplyStart();
await editMessageDiscord(deliverChannelId, previewMessageId, edit, {
cfg,
accountId,
rest: deliveryRest,
});
},
adapter: defineFinalizableLivePreviewAdapter({
draft: {
flush: () => draftPreview.flush(),
clear: () => draftStream.clear(),
discardPending: () => draftStream.discardPending(),
seal: () => draftStream.seal(),
id: draftStream.messageId,
},
buildFinalEdit: () => {
if (
draftPreview.finalizedViaPreviewMessage ||
hasMedia ||
typeof previewFinalText !== "string" ||
hasExplicitReplyDirective ||
payload.isError
) {
return undefined;
}
return { content: previewFinalText };
},
editFinal: async (previewMessageId, edit) => {
if (isProcessAborted(abortSignal)) {
throw new Error("process aborted");
}
notifyFinalReplyStart();
await editMessageDiscord(deliverChannelId, previewMessageId, edit, {
cfg,
accountId,
rest: deliveryRest,
});
},
onPreviewFinalized: () => {
draftPreview.markPreviewFinalized();
replyReference.markSent();
observer?.onFinalReplyDelivered?.();
},
logPreviewEditFailure: (err) => {
logVerbose(
`discord: preview final edit failed; falling back to standard send (${String(err)})`,
);
},
}),
deliverNormally: async () => {
if (isProcessAborted(abortSignal)) {
return false;
@@ -516,18 +529,8 @@ export async function processDiscordMessage(
observer?.onFinalReplyDelivered?.();
return true;
},
onPreviewFinalized: () => {
draftPreview.markPreviewFinalized();
replyReference.markSent();
observer?.onFinalReplyDelivered?.();
},
logPreviewEditFailure: (err) => {
logVerbose(
`discord: preview final edit failed; falling back to standard send (${String(err)})`,
);
},
});
if (result !== "normal-skipped") {
if (result.kind !== "normal-skipped") {
return;
}
}

View File

@@ -9,6 +9,7 @@ import type {
ModalInteraction,
StringSelectMenuInteraction,
} from "../internal/discord.js";
import { createDiscordSendReceipt } from "../send.receipt.js";
import {
dispatchPluginInteractiveHandlerMock,
dispatchReplyMock,
@@ -50,6 +51,14 @@ function getLastRecordedCtx(): Record<string, unknown> | undefined {
return params?.ctx;
}
function discordTestSendResult(messageId: string, channelId = "dm-channel") {
return {
messageId,
channelId,
receipt: createDiscordSendReceipt({ platformMessageIds: [messageId], channelId, kind: "card" }),
};
}
describe("discord component interactions", () => {
let editDiscordComponentMessageMock: ReturnType<typeof vi.spyOn>;
const createCfg = (): OpenClawConfig =>
@@ -254,10 +263,7 @@ describe("discord component interactions", () => {
beforeEach(() => {
editDiscordComponentMessageMock = vi
.spyOn(sendComponents, "editDiscordComponentMessage")
.mockResolvedValue({
messageId: "msg-1",
channelId: "dm-channel",
});
.mockResolvedValue(discordTestSendResult("msg-1"));
clearDiscordComponentEntries();
resetDiscordComponentRuntimeMocks();
lastDispatchCtx = undefined;

View File

@@ -1,5 +1,5 @@
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
@@ -42,7 +42,7 @@ export async function dispatchDiscordNativeAgentReply(params: {
suppressReplies?: boolean;
log: ReturnType<typeof createSubsystemLogger>;
}): Promise<void> {
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg: params.cfg,
agentId: params.effectiveRoute.agentId,
channel: "discord",

View File

@@ -3,12 +3,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as discordClientModule from "../client.js";
import * as discordSendModule from "../send.js";
import { createDiscordSendReceipt } from "../send.receipt.js";
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
import type { ThreadBindingRecord } from "./thread-bindings.types.js";
const DEFAULT_SEND_RESULT = {
messageId: "msg-1",
channelId: "thread-1",
receipt: createDiscordSendReceipt({
platformMessageIds: ["msg-1"],
channelId: "thread-1",
kind: "text",
}),
};
const restGet = vi.fn<(...args: unknown[]) => Promise<unknown>>();

View File

@@ -541,6 +541,38 @@ describe("discordOutbound", () => {
).toBe("reply-1");
});
it("sends prepared native Discord payload data through outbound delivery", async () => {
await discordOutbound.sendPayload?.({
cfg: {},
to: "channel:123456",
text: "",
payload: {
text: "hello",
mediaUrl: "https://example.com/photo.png",
channelData: {
discord: {
components: [{ type: 1, components: [] }],
filename: "photo.png",
},
},
},
accountId: "default",
replyToId: "reply-1",
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:123456",
"hello",
expect.objectContaining({
mediaUrl: "https://example.com/photo.png",
components: [{ type: 1, components: [] }],
filename: "photo.png",
accountId: "default",
replyTo: "reply-1",
}),
);
});
it("preserves explicit component payload replies when replyToMode is off", async () => {
const payload = await discordOutbound.renderPresentation?.({
payload: {

View File

@@ -120,6 +120,17 @@ export const discordOutbound: ChannelOutboundAdapter = {
context: true,
divider: true,
},
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
payload: true,
silent: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
renderPresentation: async ({ payload, presentation }) => {
return await buildDiscordPresentationPayload({
payload,

View File

@@ -67,7 +67,12 @@ export async function resolveDiscordComponentSpec(
| { components?: unknown; presentationComponents?: DiscordComponentMessageSpec }
| undefined;
const rawComponentSpec =
discordData?.presentationComponents ?? readDiscordComponentSpec(discordData?.components);
discordData?.presentationComponents ??
(discordData?.components &&
typeof discordData.components === "object" &&
!Array.isArray(discordData.components)
? readDiscordComponentSpec(discordData.components)
: null);
if (rawComponentSpec) {
return addPayloadTextFallback(rawComponentSpec, payload);
}

View File

@@ -7,12 +7,15 @@ import {
sendPayloadMediaSequenceOrFallback,
sendTextMediaPayload,
} from "openclaw/plugin-sdk/reply-payload";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeDiscordApprovalPayload } from "./outbound-approval.js";
import {
resolveDiscordComponentSpec,
sendDiscordComponentMessageLazy,
} from "./outbound-components.js";
import { createDiscordPayloadSendContext } from "./outbound-send-context.js";
import { createDiscordSendReceipt } from "./send.receipt.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "./send.shared.js";
export async function sendDiscordOutboundPayload(params: {
ctx: Parameters<NonNullable<ChannelOutboundAdapter["sendPayload"]>>[0];
@@ -71,6 +74,69 @@ export async function sendDiscordOutboundPayload(params: {
const componentSpec = await resolveDiscordComponentSpec(payload);
if (!componentSpec) {
const discordData =
payload.channelData?.discord &&
typeof payload.channelData.discord === "object" &&
!Array.isArray(payload.channelData.discord)
? (payload.channelData.discord as Record<string, unknown>)
: {};
const nativeComponents = Array.isArray(discordData.components)
? (discordData.components as DiscordSendComponents)
: undefined;
const embeds = Array.isArray(discordData.embeds)
? (discordData.embeds as DiscordSendEmbeds)
: undefined;
const filename = normalizeOptionalString(discordData.filename);
if (nativeComponents || embeds?.length || filename) {
const result = await sendPayloadMediaSequenceOrFallback({
text: payload.text ?? "",
mediaUrls,
fallbackResult: {
messageId: "",
channelId: sendContext.target,
receipt: createDiscordSendReceipt({
platformMessageIds: [],
channelId: sendContext.target,
kind: "unknown",
}),
},
sendNoMedia: async () =>
await sendContext.withRetry(
async () =>
await sendContext.send(sendContext.target, payload.text ?? "", {
verbose: false,
components: nativeComponents,
embeds,
filename,
replyTo: sendContext.resolveReplyTo(),
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
...sendContext.formatting,
}),
),
send: async ({ text, mediaUrl, isFirst }) =>
await sendContext.withRetry(
async () =>
await sendContext.send(sendContext.target, text, {
verbose: false,
mediaUrl,
mediaAccess: ctx.mediaAccess,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
components: isFirst ? nativeComponents : undefined,
embeds: isFirst ? embeds : undefined,
filename: isFirst ? filename : undefined,
replyTo: sendContext.resolveReplyTo(),
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
...sendContext.formatting,
}),
),
});
return attachChannelToResult("discord", result);
}
return await sendTextMediaPayload({
channel: "discord",
ctx: {
@@ -84,7 +150,15 @@ export async function sendDiscordOutboundPayload(params: {
const result = await sendPayloadMediaSequenceOrFallback({
text: payload.text ?? "",
mediaUrls,
fallbackResult: { messageId: "", channelId: sendContext.target },
fallbackResult: {
messageId: "",
channelId: sendContext.target,
receipt: createDiscordSendReceipt({
platformMessageIds: [],
channelId: sendContext.target,
kind: "unknown",
}),
},
sendNoMedia: async () =>
await sendContext.withRetry(
async () =>

View File

@@ -24,6 +24,7 @@ import {
import { parseAndResolveRecipient } from "./recipient-resolution.js";
import { loadOutboundMediaFromUrl } from "./runtime-api.js";
import { sendMessageDiscord } from "./send.outbound.js";
import { createDiscordSendResult } from "./send.receipt.js";
import {
buildDiscordSendError,
createDiscordClient,
@@ -321,10 +322,12 @@ export async function sendDiscordComponentMessage(
direction: "outbound",
});
return {
messageId: result.id ?? "unknown",
channelId: result.channel_id ?? channelId,
};
return createDiscordSendResult({
result,
fallbackChannelId: channelId,
kind: "card",
...(opts.replyTo ? { replyToId: opts.replyTo } : {}),
});
}
export async function editDiscordComponentMessage(
@@ -374,8 +377,13 @@ export async function editDiscordComponentMessage(
direction: "outbound",
});
return {
messageId: result.id ?? messageId,
channelId: result.channel_id ?? channelId,
};
return createDiscordSendResult({
result: {
id: result.id ?? messageId,
channel_id: result.channel_id,
},
fallbackChannelId: channelId,
kind: "card",
...(opts.replyTo ? { replyToId: opts.replyTo } : {}),
});
}

View File

@@ -434,7 +434,10 @@ describe("sendStickerDiscord", () => {
token: "t",
content: "hiya",
});
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(res).toMatchObject({ messageId: "msg1", channelId: "789" });
expect(res.receipt.parts[0]).toEqual(
expect.objectContaining({ platformMessageId: "msg1", kind: "card" }),
);
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
@@ -467,7 +470,10 @@ describe("sendPollDiscord", () => {
token: "t",
},
);
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(res).toMatchObject({ messageId: "msg1", channelId: "789" });
expect(res.receipt.parts[0]).toEqual(
expect.objectContaining({ platformMessageId: "msg1", kind: "card" }),
);
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
@@ -548,9 +554,13 @@ describe("retry rate limits", () => {
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
});
await expect(promise).resolves.toEqual({
await expect(promise).resolves.toMatchObject({
messageId: "msg1",
channelId: "789",
receipt: expect.objectContaining({
primaryPlatformMessageId: "msg1",
platformMessageIds: ["msg1"],
}),
});
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(1);
} finally {
@@ -598,7 +608,8 @@ describe("retry rate limits", () => {
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
});
expect(result).toEqual({ messageId: "msg1", channelId: "789" });
expect(result).toMatchObject({ messageId: "msg1", channelId: "789" });
expect(result.receipt.platformMessageIds).toEqual(["msg1"]);
expect(postMock).toHaveBeenCalledTimes(2);
});

View File

@@ -11,6 +11,7 @@ import { resolveDiscordAccount } from "./accounts.js";
import { createChannelMessage, createThread, type RequestClient } from "./internal/discord.js";
import { rewriteDiscordKnownMentions } from "./mentions.js";
import { parseAndResolveRecipient } from "./recipient-resolution.js";
import { createDiscordSendResult, type DiscordReceiptResultSource } from "./send.receipt.js";
import {
buildDiscordMessageRequest,
buildDiscordSendError,
@@ -55,10 +56,7 @@ type DiscordClientRequest = ReturnType<typeof createDiscordClient>["request"];
const DEFAULT_DISCORD_MEDIA_MAX_MB = 100;
type DiscordChannelMessageResult = {
id?: string | null;
channel_id?: string | null;
};
type DiscordChannelMessageResult = DiscordReceiptResultSource;
async function sendDiscordThreadTextChunks(params: {
rest: RequestClient;
@@ -105,11 +103,24 @@ function isForumLikeType(channelType?: number): boolean {
function toDiscordSendResult(
result: DiscordChannelMessageResult,
fallbackChannelId: string,
params: {
kind?: Parameters<typeof createDiscordSendResult>[0]["kind"];
threadId?: string | number;
replyToId?: string;
} = {},
): DiscordSendResult {
return {
messageId: result.id || "unknown",
channelId: result.channel_id ?? fallbackChannelId,
const resultParams: Parameters<typeof createDiscordSendResult>[0] = {
result,
fallbackChannelId,
kind: params.kind ?? "text",
};
if (params.threadId != null) {
resultParams.threadId = params.threadId;
}
if (params.replyToId) {
resultParams.replyToId = params.replyToId;
}
return createDiscordSendResult(resultParams);
}
async function resolveDiscordSendTarget(
@@ -278,10 +289,11 @@ export async function sendMessageDiscord(
channel_id: resultChannelId,
},
channelId,
{ kind: opts.mediaUrl ? "media" : "text", threadId },
);
}
let result: { id: string; channel_id: string } | { id: string | null; channel_id: string };
let result: DiscordChannelMessageResult;
try {
if (opts.mediaUrl) {
result = await sendDiscordMedia(
@@ -333,7 +345,10 @@ export async function sendMessageDiscord(
accountId: accountInfo.accountId,
direction: "outbound",
});
return toDiscordSendResult(result, channelId);
return toDiscordSendResult(result, channelId, {
kind: opts.mediaUrl ? "media" : opts.components || opts.embeds ? "card" : "text",
replyToId: opts.replyTo,
});
}
export async function sendStickerDiscord(
@@ -356,7 +371,7 @@ export async function sendStickerDiscord(
}),
"sticker",
)) as { id: string; channel_id: string };
return toDiscordSendResult(res, channelId);
return toDiscordSendResult(res, channelId, { kind: "card" });
}
export async function sendPollDiscord(
@@ -384,7 +399,7 @@ export async function sendPollDiscord(
}),
"poll",
)) as { id: string; channel_id: string };
return toDiscordSendResult(res, channelId);
return toDiscordSendResult(res, channelId, { kind: "card" });
}
async function resolveDiscordStructuredSendContext(

View File

@@ -0,0 +1,69 @@
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
type MessageReceiptSourceResult,
} from "openclaw/plugin-sdk/channel-message";
import type { DiscordSendResult } from "./send.types.js";
export type DiscordReceiptResultSource = {
id?: string | null;
channel_id?: string | null;
platformMessageIds?: readonly string[];
};
export function createDiscordSendReceipt(params: {
platformMessageIds: readonly string[];
channelId?: string;
kind: MessageReceiptPartKind;
threadId?: string;
replyToId?: string;
}): MessageReceipt {
const platformMessageIds = params.platformMessageIds
.map((messageId) => messageId.trim())
.filter((messageId) => messageId && messageId !== "unknown");
return createMessageReceiptFromOutboundResults({
results: platformMessageIds.map((messageId) => {
const result: MessageReceiptSourceResult = {
channel: "discord",
messageId,
};
if (params.channelId) {
result.channelId = params.channelId;
}
return result;
}),
kind: params.kind,
threadId: params.threadId,
replyToId: params.replyToId,
});
}
export function createDiscordSendResult(params: {
result: DiscordReceiptResultSource;
fallbackChannelId: string;
kind: MessageReceiptPartKind;
threadId?: string | number;
replyToId?: string;
}): DiscordSendResult {
const messageId = params.result.id || "unknown";
const channelId = params.result.channel_id ?? params.fallbackChannelId;
const receiptParams: Parameters<typeof createDiscordSendReceipt>[0] = {
platformMessageIds: params.result.platformMessageIds?.length
? params.result.platformMessageIds
: [messageId],
channelId,
kind: params.kind,
};
if (params.threadId != null) {
receiptParams.threadId = String(params.threadId);
}
if (params.replyToId) {
receiptParams.replyToId = params.replyToId;
}
return {
messageId,
channelId,
receipt: createDiscordSendReceipt(receiptParams),
};
}

View File

@@ -139,7 +139,12 @@ describe("sendMessageDiscord", () => {
token: "t",
cfg: DISCORD_TEST_CFG,
});
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(res).toMatchObject({ messageId: "msg1", channelId: "789" });
expect(res.receipt).toMatchObject({
primaryPlatformMessageId: "msg1",
platformMessageIds: ["msg1"],
parts: [expect.objectContaining({ platformMessageId: "msg1", kind: "text" })],
});
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({ body: { content: "hello world" } }),
@@ -245,7 +250,12 @@ describe("sendMessageDiscord", () => {
token: "t",
cfg: DISCORD_TEST_CFG,
});
expect(res).toEqual({ messageId: "starter1", channelId: "thread1" });
expect(res).toMatchObject({ messageId: "starter1", channelId: "thread1" });
expect(res.receipt).toMatchObject({
threadId: "thread1",
platformMessageIds: ["starter1"],
parts: [expect.objectContaining({ platformMessageId: "starter1", kind: "text" })],
});
// Should POST to threads route, not channelMessages.
expect(postMock).toHaveBeenCalledWith(
Routes.threads("forum1"),
@@ -266,7 +276,12 @@ describe("sendMessageDiscord", () => {
cfg: DISCORD_TEST_CFG,
mediaUrl: "file:///tmp/photo.jpg",
});
expect(res).toEqual({ messageId: "starter1", channelId: "thread1" });
expect(res).toMatchObject({ messageId: "starter1", channelId: "thread1" });
expect(res.receipt).toMatchObject({
threadId: "thread1",
platformMessageIds: ["starter1"],
parts: [expect.objectContaining({ platformMessageId: "starter1", kind: "media" })],
});
expect(postMock).toHaveBeenNthCalledWith(
1,
Routes.threads("forum1"),

View File

@@ -328,16 +328,21 @@ async function sendDiscordText(
)) as { id: string; channel_id: string };
};
if (chunks.length === 1) {
return await sendChunk(chunks[0], true);
const result = await sendChunk(chunks[0], true);
return { ...result, platformMessageIds: result.id ? [result.id] : [] };
}
const platformMessageIds: string[] = [];
let last: { id: string; channel_id: string } | null = null;
for (const [index, chunk] of chunks.entries()) {
last = await sendChunk(chunk, index === 0);
if (last.id) {
platformMessageIds.push(last.id);
}
}
if (!last) {
throw new Error("Discord send failed (empty chunk result)");
}
return last;
return { ...last, platformMessageIds };
}
async function sendDiscordMedia(
@@ -398,11 +403,12 @@ async function sendDiscordMedia(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"media",
)) as { id: string; channel_id: string };
const platformMessageIds = res.id ? [res.id] : [];
for (const chunk of chunks.slice(1)) {
if (!chunk.trim()) {
continue;
}
await sendDiscordText(
const followup = await sendDiscordText(
rest,
channelId,
chunk,
@@ -415,8 +421,13 @@ async function sendDiscordMedia(
silent,
maxChars,
);
for (const id of followup.platformMessageIds) {
if (id) {
platformMessageIds.push(id);
}
}
}
return res;
return { ...res, platformMessageIds };
}
function buildReactionIdentifier(emoji: { id?: string | null; name?: string | null }) {

View File

@@ -1,3 +1,4 @@
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { RetryConfig } from "openclaw/plugin-sdk/retry-runtime";
import type { RequestClient } from "./internal/discord.js";
@@ -29,6 +30,7 @@ export const DISCORD_MAX_EVENT_COVER_BYTES = 8 * 1024 * 1024;
export type DiscordSendResult = {
messageId: string;
channelId: string;
receipt: MessageReceipt;
};
export type DiscordRuntimeAccountContext = {

View File

@@ -15,6 +15,7 @@ import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import { resolveDiscordAccount } from "./accounts.js";
import type { RequestClient } from "./internal/discord.js";
import { parseAndResolveRecipient } from "./recipient-resolution.js";
import { createDiscordSendResult } from "./send.receipt.js";
import { buildDiscordSendError, createDiscordClient, resolveChannelId } from "./send.shared.js";
import type { DiscordSendResult } from "./send.types.js";
import {
@@ -38,10 +39,11 @@ function toDiscordSendResult(
result: { id?: string | null; channel_id?: string | null },
fallbackChannelId: string,
): DiscordSendResult {
return {
messageId: result.id || "unknown",
channelId: result.channel_id ?? fallbackChannelId,
};
return createDiscordSendResult({
result,
fallbackChannelId,
kind: "voice",
});
}
async function materializeVoiceMessageInput(mediaUrl: string): Promise<{ filePath: string }> {

View File

@@ -64,9 +64,13 @@ describe("sendWebhookMessageDiscord activity", () => {
threadId: "thread-1",
});
expect(result).toEqual({
expect(result).toMatchObject({
messageId: "msg-1",
channelId: "thread-1",
receipt: expect.objectContaining({
threadId: "thread-1",
platformMessageIds: ["msg-1"],
}),
});
expect(recordChannelActivityMock).toHaveBeenCalledWith({
channel: "discord",

View File

@@ -10,6 +10,7 @@ import {
readRetryAfter,
} from "./internal/rest-errors.js";
import { rewriteDiscordKnownMentions } from "./mentions.js";
import { createDiscordSendResult } from "./send.receipt.js";
import type { DiscordSendResult } from "./send.types.js";
type DiscordWebhookSendOpts = {
@@ -126,8 +127,11 @@ export async function sendWebhookMessageDiscord(
} catch {
// Best-effort telemetry only.
}
return {
messageId: payload.id || "unknown",
channelId: payload.channel_id ? payload.channel_id : opts.threadId ? String(opts.threadId) : "",
};
return createDiscordSendResult({
result: payload,
fallbackChannelId: opts.threadId ? String(opts.threadId) : "",
kind: "text",
...(opts.threadId != null ? { threadId: opts.threadId } : {}),
...(replyTo ? { replyToId: replyTo } : {}),
});
}

View File

@@ -33,7 +33,7 @@ export {
} from "openclaw/plugin-sdk/channel-status";
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-message";
export {
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,

View File

@@ -9,6 +9,11 @@ import type {
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
defineChannelMessageAdapter,
type ChannelMessageSendResult,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
createAllowlistProviderGroupPolicyWarningCollector,
@@ -66,6 +71,7 @@ import { messageActionTargetAliases } from "./message-action-contract.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { collectFeishuSecurityAuditFindings } from "./security-audit.js";
import { createFeishuSendReceipt } from "./send-result.js";
import { resolveFeishuSessionConversation } from "./session-conversation.js";
import { resolveFeishuOutboundSessionRoute } from "./session-route.js";
import { feishuSetupAdapter } from "./setup-core.js";
@@ -131,6 +137,51 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
"feishuChannelRuntime",
);
function toFeishuMessageSendResult(
result: { messageId?: string; chatId?: string; receipt?: ChannelMessageSendResult["receipt"] },
kind: MessageReceiptPartKind,
): ChannelMessageSendResult {
const receipt =
result.receipt ??
createFeishuSendReceipt({
messageId: result.messageId,
chatId: result.chatId ?? "",
kind,
});
return {
messageId: result.messageId || receipt.primaryPlatformMessageId,
receipt,
};
}
const feishuMessageAdapter = defineChannelMessageAdapter({
id: "feishu",
durableFinal: {
capabilities: {
text: true,
media: true,
},
},
send: {
text: async (ctx) => {
const runtime = await loadFeishuChannelRuntime();
const sendText = runtime.feishuOutbound.sendText;
if (!sendText) {
throw new Error("Feishu text sending is not available.");
}
return toFeishuMessageSendResult(await sendText(ctx), "text");
},
media: async (ctx) => {
const runtime = await loadFeishuChannelRuntime();
const sendMedia = runtime.feishuOutbound.sendMedia;
if (!sendMedia) {
throw new Error("Feishu media sending is not available.");
}
return toFeishuMessageSendResult(await sendMedia(ctx), "media");
},
},
});
function buildFeishuPresentationCard(params: {
presentation: NonNullable<ReturnType<typeof normalizeMessagePresentation>>;
fallbackText?: string;
@@ -1255,6 +1306,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
});
},
},
message: feishuMessageAdapter,
},
security: {
collectWarnings: projectConfigAccountIdWarningCollector<{

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
import {
@@ -15,7 +16,11 @@ import { createFeishuClient } from "./client.js";
import { requestFeishuApi } from "./comment-shared.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import {
assertFeishuMessageApiSuccess,
resolveFeishuReceiptKind,
toFeishuSendResult,
} from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.js";
const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
@@ -399,6 +404,7 @@ export type UploadFileResult = {
export type SendMediaResult = {
messageId: string;
chatId: string;
receipt: MessageReceipt;
voiceIntentDegradedToFile?: boolean;
};
@@ -532,7 +538,7 @@ export async function sendImageFeishu(params: {
{ includeNestedErrorLogId: true },
);
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
return toFeishuSendResult(response, receiveId);
return toFeishuSendResult(response, receiveId, "media");
}
const response = await requestFeishuApi(
@@ -549,7 +555,7 @@ export async function sendImageFeishu(params: {
{ includeNestedErrorLogId: true },
);
assertFeishuMessageApiSuccess(response, "Feishu image send failed");
return toFeishuSendResult(response, receiveId);
return toFeishuSendResult(response, receiveId, "media");
}
/**
@@ -589,7 +595,7 @@ export async function sendFileFeishu(params: {
{ includeNestedErrorLogId: true },
);
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
return toFeishuSendResult(response, receiveId);
return toFeishuSendResult(response, receiveId, resolveFeishuReceiptKind(msgType));
}
const response = await requestFeishuApi(
@@ -606,7 +612,7 @@ export async function sendFileFeishu(params: {
{ includeNestedErrorLogId: true },
);
assertFeishuMessageApiSuccess(response, "Feishu file send failed");
return toFeishuSendResult(response, receiveId);
return toFeishuSendResult(response, receiveId, resolveFeishuReceiptKind(msgType));
}
/**

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message";
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
@@ -23,6 +24,8 @@ vi.mock("./media.js", () => ({
}));
vi.mock("./send.js", () => ({
editMessageFeishu: vi.fn(),
getMessageFeishu: vi.fn(),
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
@@ -69,7 +72,9 @@ vi.mock("./comment-reaction.js", () => ({
cleanupAmbientCommentTypingReaction: cleanupAmbientCommentTypingReactionMock,
}));
import { feishuPlugin } from "./channel.js";
import { feishuOutbound } from "./outbound.js";
import { createFeishuSendReceipt } from "./send-result.js";
const sendText = feishuOutbound.sendText!;
const emptyConfig: ClawdbotConfig = {};
const cardRenderConfig: ClawdbotConfig = {
@@ -99,6 +104,74 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
resetOutboundMocks();
});
it("declares message adapter durable text and media with receipt proofs", async () => {
sendMessageFeishuMock.mockResolvedValue({
messageId: "feishu-text-1",
chatId: "chat-1",
receipt: createFeishuSendReceipt({
messageId: "feishu-text-1",
chatId: "chat-1",
kind: "text",
}),
});
sendMediaFeishuMock.mockResolvedValue({
messageId: "feishu-media-1",
chatId: "chat-1",
receipt: createFeishuSendReceipt({
messageId: "feishu-media-1",
chatId: "chat-1",
kind: "media",
}),
});
await expect(
verifyChannelMessageAdapterCapabilityProofs({
adapterName: "feishu",
adapter: feishuPlugin.message!,
proofs: {
text: async () => {
const result = await feishuPlugin.message?.send?.text?.({
cfg: emptyConfig,
to: "chat:chat-1",
text: "hello",
accountId: "default",
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat-1",
text: "hello",
accountId: "default",
}),
);
expect(result?.receipt.platformMessageIds).toEqual(["feishu-text-1"]);
},
media: async () => {
const result = await feishuPlugin.message?.send?.media?.({
cfg: emptyConfig,
to: "chat:chat-1",
text: "",
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat-1",
mediaUrl: "https://example.com/image.png",
accountId: "default",
}),
);
expect(result?.receipt.platformMessageIds).toEqual(["feishu-media-1"]);
},
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
]),
);
});
it("chunks outbound text without requiring Feishu runtime initialization", () => {
const chunker = feishuOutbound.chunker;
if (!chunker) {

View File

@@ -1,5 +1,5 @@
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
import {
formatChannelProgressDraftLineForEntry,
isChannelProgressDraftWorkToolName,
@@ -154,7 +154,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
const prefixContext = createReplyPrefixContext({ cfg, agentId });
let typingState: TypingIndicatorState | null = null;
const { typingCallbacks } = createChannelReplyPipeline({
const { typingCallbacks } = createChannelMessageReplyPipeline({
cfg,
agentId,
channel: "feishu",

View File

@@ -1,3 +1,9 @@
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
type FeishuMessageApiResponse = {
code?: number;
msg?: string;
@@ -6,6 +12,47 @@ type FeishuMessageApiResponse = {
};
};
export function resolveFeishuReceiptKind(msgType?: string): MessageReceiptPartKind {
switch (msgType) {
case "audio":
return "voice";
case "image":
case "media":
case "file":
return "media";
case "interactive":
return "card";
case "post":
case "text":
return "text";
default:
return "unknown";
}
}
export function createFeishuSendReceipt(params: {
messageId?: string;
chatId: string;
kind?: MessageReceiptPartKind;
}): MessageReceipt {
const messageId = params.messageId?.trim();
const chatId = params.chatId.trim();
return createMessageReceiptFromOutboundResults({
results: messageId
? [
{
channel: "feishu",
messageId,
chatId,
conversationId: chatId,
},
]
: [],
...(chatId ? { threadId: chatId } : {}),
kind: params.kind ?? "unknown",
});
}
export function assertFeishuMessageApiSuccess(
response: FeishuMessageApiResponse,
errorPrefix: string,
@@ -18,12 +65,16 @@ export function assertFeishuMessageApiSuccess(
export function toFeishuSendResult(
response: FeishuMessageApiResponse,
chatId: string,
kind?: MessageReceiptPartKind,
): {
messageId: string;
chatId: string;
receipt: MessageReceipt;
} {
const messageId = response.data?.message_id ?? "unknown";
return {
messageId: response.data?.message_id ?? "unknown",
messageId,
chatId,
receipt: createFeishuSendReceipt({ messageId, chatId, kind }),
};
}

View File

@@ -128,7 +128,8 @@ describe("getMessageFeishu", () => {
channel: "feishu",
});
expect(mockConvertMarkdownTables).toHaveBeenCalledWith("hello", "preserve");
expect(result).toEqual({ messageId: "om_send", chatId: "oc_send" });
expect(result).toMatchObject({ messageId: "om_send", chatId: "oc_send" });
expect(result.receipt.primaryPlatformMessageId).toBe("om_send");
});
it("extracts text content from interactive card elements", async () => {

View File

@@ -11,7 +11,11 @@ import { createFeishuApiError, requestFeishuApi } from "./comment-shared.js";
import type { MentionTarget } from "./mention-target.types.js";
import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
import { parsePostContent } from "./post.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import {
assertFeishuMessageApiSuccess,
resolveFeishuReceiptKind,
toFeishuSendResult,
} from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
@@ -132,7 +136,7 @@ async function sendFallbackDirect(
{ includeNestedErrorLogId: true },
);
assertFeishuMessageApiSuccess(response, errorPrefix);
return toFeishuSendResult(response, params.receiveId);
return toFeishuSendResult(response, params.receiveId, resolveFeishuReceiptKind(params.msgType));
}
async function sendReplyOrFallbackDirect(
@@ -188,7 +192,11 @@ async function sendReplyOrFallbackDirect(
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
}
assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
return toFeishuSendResult(response, params.directParams.receiveId);
return toFeishuSendResult(
response,
params.directParams.receiveId,
resolveFeishuReceiptKind(params.msgType),
);
}
function normalizeCardTemplateVariable(value: unknown): string | undefined {

View File

@@ -1,3 +1,4 @@
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
import type { BaseProbeResult } from "openclaw/plugin-sdk/core";
import type { FeishuConfigSchema, FeishuAccountConfigSchema, z } from "./config-schema.js";
import type { MentionTarget } from "./mention-target.types.js";
@@ -53,6 +54,7 @@ export type FeishuMessageContext = {
export type FeishuSendResult = {
messageId: string;
chatId: string;
receipt: MessageReceipt;
};
export type FeishuChatType = "p2p" | "group" | "topic_group" | "private";

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)}`);

View File

@@ -1,5 +1,11 @@
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
type ChannelMessageSendResult,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
@@ -27,6 +33,7 @@ import {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./group-policy.js";
import { sanitizeOutboundText } from "./monitor/sanitize-outbound.js";
import type { IMessageProbe } from "./probe.js";
import { imessageSetupAdapter } from "./setup-core.js";
import {
@@ -44,6 +51,70 @@ import {
const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
type IMessageMessageContextExtras = {
deps?: { [channelId: string]: unknown };
};
function toIMessageMessageSendResult(
result: { messageId?: string; receipt?: ChannelMessageSendResult["receipt"] },
kind: MessageReceiptPartKind,
replyToId?: string | null,
): ChannelMessageSendResult {
const receipt =
result.receipt ??
createMessageReceiptFromOutboundResults({
results: result.messageId ? [{ channel: "imessage", messageId: result.messageId }] : [],
kind,
...(replyToId ? { replyToId } : {}),
});
return {
messageId: result.messageId || receipt.primaryPlatformMessageId,
receipt,
};
}
const imessageMessageAdapter = defineChannelMessageAdapter({
id: "imessage",
durableFinal: {
capabilities: {
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
},
},
send: {
text: async (ctx) => {
const result = await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
accountId: ctx.accountId ?? undefined,
deps: (ctx as typeof ctx & IMessageMessageContextExtras).deps,
replyToId: ctx.replyToId ?? undefined,
});
return toIMessageMessageSendResult(result, "text", ctx.replyToId);
},
media: async (ctx) => {
const result = await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
mediaUrl: ctx.mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
accountId: ctx.accountId ?? undefined,
deps: (ctx as typeof ctx & IMessageMessageContextExtras).deps,
replyToId: ctx.replyToId ?? undefined,
});
return toIMessageMessageSendResult(result, "media", ctx.replyToId);
},
},
});
function buildIMessageBaseSessionKey(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
agentId: string;
@@ -228,6 +299,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
}
},
},
message: imessageMessageAdapter,
},
pairing: {
text: {
@@ -244,7 +316,15 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sanitizeText: ({ text }) => sanitizeForPlainText(sanitizeOutboundText(text)),
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
},
},
},
attachedResults: {
channel: "imessage",

View File

@@ -48,6 +48,14 @@ function normalizeIMessageTestHandle(raw: string): string {
const defaultIMessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
},
},
sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => {
const sendIMessage = resolveOutboundSendDep<
(

View File

@@ -25,6 +25,7 @@ vi.mock("./deliver.runtime.js", () => ({
}));
let deliverReplies: typeof import("./deliver.js").deliverReplies;
let createIMessageEchoCachingSend: typeof import("./deliver.js").createIMessageEchoCachingSend;
describe("deliverReplies", () => {
const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } };
@@ -32,7 +33,7 @@ describe("deliverReplies", () => {
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
beforeAll(async () => {
({ deliverReplies } = await import("./deliver.js"));
({ createIMessageEchoCachingSend, deliverReplies } = await import("./deliver.js"));
});
beforeEach(() => {
@@ -128,6 +129,62 @@ describe("deliverReplies", () => {
);
});
it("records durable outbound sends in the sent-message cache", async () => {
const remember = vi.fn();
const send = createIMessageEchoCachingSend({
client,
accountId: "acct-5",
sentMessageCache: { remember },
});
sendMessageIMessageMock.mockResolvedValueOnce({
messageId: "imsg-durable-1",
sentText: "durable hello",
});
await send("chat_id:50", "durable hello", {
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
});
expect(sendMessageIMessageMock).toHaveBeenCalledWith(
"chat_id:50",
"durable hello",
expect.objectContaining({ client }),
);
expect(remember).toHaveBeenCalledWith("acct-5:chat_id:50", {
text: "durable hello",
messageId: "imsg-durable-1",
});
});
it("sanitizes durable outbound text before sending", async () => {
const remember = vi.fn();
const send = createIMessageEchoCachingSend({
client,
accountId: "acct-6",
sentMessageCache: { remember },
});
sendMessageIMessageMock.mockResolvedValueOnce({
messageId: "imsg-durable-2",
sentText: "Visible reply",
});
await send("chat_id:60", "<thinking>hidden</thinking>\nVisible reply\nassistant:", {
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
});
expect(sendMessageIMessageMock).toHaveBeenCalledWith(
"chat_id:60",
"Visible reply",
expect.objectContaining({ client }),
);
expect(remember).toHaveBeenCalledWith("acct-6:chat_id:60", {
text: "Visible reply",
messageId: "imsg-durable-2",
});
});
it("records outbound text and message ids in sent-message cache (post-send only)", async () => {
// Fix for #47830: remember() is called ONLY after each chunk is sent,
// never with the full un-chunked text before sending begins.

View File

@@ -5,7 +5,7 @@ import {
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { createIMessageRpcClient } from "../client.js";
import type { IMessageRpcClient } from "../client.js";
import { sendMessageIMessage } from "../send.js";
import {
chunkTextWithMode,
@@ -20,7 +20,7 @@ export async function deliverReplies(params: {
cfg: OpenClawConfig;
replies: ReplyPayload[];
target: string;
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
client: IMessageRpcClient;
accountId?: string;
runtime: RuntimeEnv;
maxBytes: number;
@@ -80,3 +80,23 @@ export async function deliverReplies(params: {
}
}
}
export function createIMessageEchoCachingSend(params: {
client: IMessageRpcClient;
accountId?: string;
sentMessageCache?: Pick<SentMessageCache, "remember">;
}): typeof sendMessageIMessage {
return async (target, text, opts) => {
const sanitizedText = sanitizeOutboundText(text);
const sent = await sendMessageIMessage(target, sanitizedText, {
...opts,
client: params.client,
});
const scope = `${params.accountId ?? opts.accountId ?? ""}:${target}`;
params.sentMessageCache?.remember(scope, {
text: sent.sentText || undefined,
messageId: sent.messageId,
});
return sent;
};
}

View File

@@ -4,8 +4,11 @@ import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import {
deliverInboundReplyWithMessageSendContext,
createChannelMessageReplyPipeline,
} from "openclaw/plugin-sdk/channel-message";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
@@ -41,7 +44,7 @@ import { probeIMessage } from "../probe.js";
import { sendMessageIMessage } from "../send.js";
import { normalizeIMessageHandle } from "../targets.js";
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
import { deliverReplies } from "./deliver.js";
import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js";
import { createSentMessageCache } from "./echo-cache.js";
import {
buildIMessageInboundContext,
@@ -402,7 +405,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
);
}
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg,
agentId: decision.route.agentId,
channel: "imessage",
@@ -412,12 +415,35 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const dispatcher = createReplyDispatcher({
...replyPipeline,
humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId),
deliver: async (payload) => {
deliver: async (payload, info) => {
const target = ctxPayload.To;
if (!target) {
runtime.error?.(danger("imessage: missing delivery target"));
return;
}
const durable = await deliverInboundReplyWithMessageSendContext({
cfg,
channel: "imessage",
accountId: accountInfo.accountId,
agentId: decision.route.agentId,
ctxPayload,
payload,
info,
to: target,
deps: {
imessage: createIMessageEchoCachingSend({
client: getActiveClient(),
accountId: accountInfo.accountId,
sentMessageCache,
}),
},
});
if (durable.status === "failed") {
throw durable.error;
}
if (durable.status === "handled_visible" || durable.status === "handled_no_send") {
return;
}
await deliverReplies({
cfg,
replies: [payload],

View File

@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import type { IMessageRpcClient } from "./client.js";
import { sendMessageIMessage } from "./send.js";
const IMESSAGE_TEST_CFG = {
channels: {
imessage: {
accounts: {
default: {},
},
},
},
};
function createClient(result: Record<string, unknown>): IMessageRpcClient {
return {
request: vi.fn(async () => result),
stop: vi.fn(async () => {}),
} as unknown as IMessageRpcClient;
}
describe("sendMessageIMessage receipts", () => {
it("attaches a text receipt for native send ids", async () => {
const client = createClient({ guid: "p:0/imsg-1" });
const result = await sendMessageIMessage("chat_id:42", "hello", {
config: IMESSAGE_TEST_CFG,
client,
replyToId: "reply-1",
});
expect(result).toMatchObject({
messageId: "p:0/imsg-1",
sentText: "hello",
receipt: {
primaryPlatformMessageId: "p:0/imsg-1",
platformMessageIds: ["p:0/imsg-1"],
replyToId: "reply-1",
parts: [
expect.objectContaining({
platformMessageId: "p:0/imsg-1",
kind: "text",
replyToId: "reply-1",
raw: expect.objectContaining({
channel: "imessage",
chatId: "42",
}),
}),
],
},
});
});
it("attaches a media receipt after attachment resolution", async () => {
const client = createClient({ message_id: 12345 });
const result = await sendMessageIMessage("chat_guid:chat-1", "", {
config: IMESSAGE_TEST_CFG,
client,
mediaUrl: "/tmp/image.png",
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
});
expect(result).toMatchObject({
messageId: "12345",
sentText: "<media:image>",
receipt: {
primaryPlatformMessageId: "12345",
platformMessageIds: ["12345"],
parts: [
expect.objectContaining({
platformMessageId: "12345",
kind: "media",
raw: expect.objectContaining({
conversationId: "chat-1",
}),
}),
],
},
});
});
it("does not treat compatibility ok responses as visible platform ids", async () => {
const client = createClient({ ok: "true" });
const result = await sendMessageIMessage("+15551234567", "hello", {
config: IMESSAGE_TEST_CFG,
client,
});
expect(result.messageId).toBe("ok");
expect(result.receipt.platformMessageIds).toEqual([]);
});
});

View File

@@ -1,3 +1,9 @@
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
type MessageReceiptSourceResult,
} from "openclaw/plugin-sdk/channel-message";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
@@ -39,6 +45,7 @@ type IMessageSendOpts = {
type IMessageSendResult = {
messageId: string;
sentText: string;
receipt: MessageReceipt;
};
const MAX_REPLY_TO_ID_LENGTH = 256;
@@ -95,6 +102,44 @@ function resolveDeliveredIMessageText(text: string, mediaContentType?: string):
return kind === "image" ? "<media:image>" : `<media:${kind}>`;
}
function createIMessageSendReceipt(params: {
messageId: string;
target: ReturnType<typeof parseIMessageTarget>;
kind: MessageReceiptPartKind;
replyToId?: string;
}): MessageReceipt {
const messageId = params.messageId.trim();
const results: MessageReceiptSourceResult[] =
messageId && messageId !== "unknown" && messageId !== "ok"
? [
{
channel: "imessage",
messageId,
meta: {
targetKind: params.target.kind,
},
},
]
: [];
if (results[0]) {
if (params.target.kind === "chat_id") {
results[0].chatId = String(params.target.chatId);
} else if (params.target.kind === "chat_guid") {
results[0].conversationId = params.target.chatGuid;
} else if (params.target.kind === "chat_identifier") {
results[0].conversationId = params.target.chatIdentifier;
}
}
const receiptParams: Parameters<typeof createMessageReceiptFromOutboundResults>[0] = {
results,
kind: params.kind,
};
if (params.replyToId) {
receiptParams.replyToId = params.replyToId;
}
return createMessageReceiptFromOutboundResults(receiptParams);
}
export async function sendMessageIMessage(
to: string,
text: string,
@@ -183,9 +228,16 @@ export async function sendMessageIMessage(
timeoutMs: opts.timeoutMs,
});
const resolvedId = resolveMessageId(result);
const messageId = resolvedId ?? (result?.ok ? "ok" : "unknown");
return {
messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"),
messageId,
sentText: message,
receipt: createIMessageSendReceipt({
messageId,
target,
kind: filePath ? "media" : "text",
...(resolvedReplyToId ? { replyToId: resolvedReplyToId } : {}),
}),
};
} finally {
if (shouldClose) {

View File

@@ -1,8 +1,14 @@
import {
createMessageReceiptFromOutboundResults,
verifyChannelMessageAdapterCapabilityProofs,
verifyDurableFinalCapabilityProofs,
} from "openclaw/plugin-sdk/channel-message";
import {
listImportedBundledPluginFacadeIds,
resetFacadeRuntimeStateForTest,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { imessagePlugin } from "./channel.js";
import { createIMessageTestPlugin } from "./imessage.test-plugin.js";
beforeEach(() => {
@@ -28,4 +34,136 @@ describe("createIMessageTestPlugin", () => {
expect(plugin.messaging?.normalizeTarget?.(prefixedHandle)).toBe("+442079460958");
});
it("declares durable final delivery capabilities", () => {
expect(imessagePlugin.outbound?.deliveryCapabilities?.durableFinal).toEqual(
expect.objectContaining({
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
}),
);
expect(createIMessageTestPlugin().outbound?.deliveryCapabilities?.durableFinal).toEqual(
expect.objectContaining({
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
}),
);
});
it("backs declared durable final capabilities with delivery proofs", async () => {
const outbound = createIMessageTestPlugin().outbound!;
const sendIMessage = async () => ({ messageId: "imsg-1" });
await verifyDurableFinalCapabilityProofs({
adapterName: "imessageOutbound",
capabilities: outbound.deliveryCapabilities?.durableFinal,
proofs: {
text: async () => {
await expect(
outbound.sendText?.({
cfg: {} as never,
to: "+15551234567",
text: "hello",
deps: { imessage: sendIMessage },
}),
).resolves.toEqual({ channel: "imessage", messageId: "imsg-1" });
},
media: async () => {
await expect(
outbound.sendMedia?.({
cfg: {} as never,
to: "+15551234567",
text: "caption",
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp"],
deps: { imessage: sendIMessage },
}),
).resolves.toEqual({ channel: "imessage", messageId: "imsg-1" });
},
replyTo: async () => {
await expect(
outbound.sendText?.({
cfg: {} as never,
to: "+15551234567",
text: "reply",
replyToId: "reply-1",
deps: { imessage: sendIMessage },
}),
).resolves.toEqual({ channel: "imessage", messageId: "imsg-1" });
},
messageSendingHooks: () => {
expect(outbound.sendText).toBeTypeOf("function");
},
},
});
});
it("backs declared message adapter capabilities with delivery proofs", async () => {
const sendIMessage = async (
_to: string,
_text: string,
opts?: { mediaUrl?: string; replyToId?: string },
) => {
const messageId = opts?.mediaUrl ? "imsg-media-1" : "imsg-text-1";
return {
messageId,
sentText: opts?.mediaUrl ? "<media:image>" : "hello",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "imessage", messageId }],
kind: opts?.mediaUrl ? "media" : "text",
...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
}),
};
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "imessageMessage",
adapter: imessagePlugin.message!,
proofs: {
text: async () => {
const result = await imessagePlugin.message?.send?.text?.({
cfg: {} as never,
to: "+15551234567",
text: "hello",
deps: { imessage: sendIMessage },
} as Parameters<NonNullable<typeof imessagePlugin.message.send.text>>[0] & {
deps: { imessage: typeof sendIMessage };
});
expect(result?.receipt.platformMessageIds).toEqual(["imsg-text-1"]);
},
media: async () => {
const result = await imessagePlugin.message?.send?.media?.({
cfg: {} as never,
to: "+15551234567",
text: "caption",
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp"],
deps: { imessage: sendIMessage },
} as Parameters<NonNullable<typeof imessagePlugin.message.send.media>>[0] & {
deps: { imessage: typeof sendIMessage };
});
expect(result?.receipt.platformMessageIds).toEqual(["imsg-media-1"]);
},
replyTo: async () => {
const result = await imessagePlugin.message?.send?.text?.({
cfg: {} as never,
to: "+15551234567",
text: "reply",
replyToId: "reply-1",
deps: { imessage: sendIMessage },
} as Parameters<NonNullable<typeof imessagePlugin.message.send.text>>[0] & {
deps: { imessage: typeof sendIMessage };
});
expect(result?.receipt.replyToId).toBe("reply-1");
},
messageSendingHooks: () => {
expect(imessagePlugin.message?.send?.text).toBeTypeOf("function");
},
},
});
});
});

View File

@@ -33,6 +33,7 @@ import {
import { IrcChannelConfigSchema } from "./config-schema.js";
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
import { startIrcGatewayAccount } from "./gateway.js";
import { ircMessageAdapter } from "./message-adapter.js";
import {
isChannelTarget,
looksLikeIrcTargetId,
@@ -240,6 +241,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
hint: "<#channel|nick>",
},
},
message: ircMessageAdapter,
resolver: {
resolveTargets: async ({ inputs, kind }) => {
return inputs.map((input) => {

View File

@@ -333,9 +333,9 @@ export async function handleIrcInbound(params: {
CommandAuthorized: commandAuthorized,
});
const { dispatchInboundReplyWithBase } =
await import("openclaw/plugin-sdk/inbound-reply-dispatch");
await dispatchInboundReplyWithBase({
const { dispatchChannelMessageReplyWithBase } =
await import("openclaw/plugin-sdk/channel-message");
await dispatchChannelMessageReplyWithBase({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,

View File

@@ -0,0 +1,28 @@
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message";
import { sendMessageIrc } from "./send.js";
import type { CoreConfig } from "./types.js";
export const ircMessageAdapter = defineChannelMessageAdapter({
id: "irc",
durableFinal: {
capabilities: {
text: true,
media: true,
replyTo: true,
},
},
send: {
text: async ({ cfg, to, text, accountId, replyToId }) =>
await sendMessageIrc(to, text, {
cfg: cfg as CoreConfig,
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
}),
media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
cfg: cfg as CoreConfig,
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
}),
},
});

View File

@@ -29,7 +29,7 @@ export {
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/channel-policy";
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";
export { dispatchChannelMessageReplyWithBase } from "openclaw/plugin-sdk/channel-message";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
deliverFormattedTextWithAttachments,

View File

@@ -1,3 +1,4 @@
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message";
import { createSendCfgThreadingRuntime } from "openclaw/plugin-sdk/channel-test-helpers";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IrcClient } from "./client.js";
@@ -62,6 +63,7 @@ vi.mock("openclaw/plugin-sdk/text-runtime", async () => {
};
});
import { ircMessageAdapter } from "./message-adapter.js";
import { sendMessageIrc } from "./send.js";
describe("sendMessageIrc cfg threading", () => {
@@ -106,6 +108,21 @@ describe("sendMessageIrc cfg threading", () => {
expect(result.target).toBe("#room");
expect(result.messageId).toEqual(expect.any(String));
expect(result.messageId.length).toBeGreaterThan(0);
expect(result.receipt).toMatchObject({
primaryPlatformMessageId: "irc-msg-1",
platformMessageIds: ["irc-msg-1"],
parts: [
{
platformMessageId: "irc-msg-1",
kind: "text",
raw: {
channel: "irc",
conversationId: "#room",
messageId: "irc-msg-1",
},
},
],
});
});
it("fails hard when cfg is omitted", async () => {
@@ -151,4 +168,103 @@ describe("sendMessageIrc cfg threading", () => {
expect(result.messageId).toEqual(expect.any(String));
expect(result.messageId.length).toBeGreaterThan(0);
});
it("preserves reply ids in receipts", async () => {
const providedCfg = {
channels: {
irc: {
host: "irc.example.com",
nick: "openclaw",
},
},
} as unknown as CoreConfig;
const client = {
isReady: vi.fn(() => true),
sendPrivmsg: vi.fn(),
} as unknown as IrcClient;
const result = await sendMessageIrc("#room", "hello", {
cfg: providedCfg,
client,
replyTo: "irc-parent-1",
});
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello\n\n[reply:irc-parent-1]");
expect(result.receipt).toMatchObject({
replyToId: "irc-parent-1",
parts: [
{
platformMessageId: "irc-msg-1",
replyToId: "irc-parent-1",
},
],
});
});
it("declares message adapter durable text, media, and reply with receipt proofs", async () => {
const providedCfg = {
channels: {
irc: {
host: "irc.example.com",
nick: "openclaw",
},
},
} as unknown as CoreConfig;
const client = {
isReady: vi.fn(() => true),
sendPrivmsg: vi.fn(),
quit: vi.fn(),
} as unknown as IrcClient & { quit: ReturnType<typeof vi.fn> };
hoisted.connectIrcClient.mockResolvedValue(client);
await expect(
verifyChannelMessageAdapterCapabilityProofs({
adapterName: "irc",
adapter: ircMessageAdapter,
proofs: {
text: async () => {
const result = await ircMessageAdapter.send?.text?.({
cfg: providedCfg,
to: "#room",
text: "hello",
});
expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]);
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello");
},
media: async () => {
const result = await ircMessageAdapter.send?.media?.({
cfg: providedCfg,
to: "#room",
text: "image",
mediaUrl: "https://example.com/image.png",
});
expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]);
expect(client.sendPrivmsg).toHaveBeenCalledWith(
"#room",
"image\n\nAttachment: https://example.com/image.png",
);
},
replyTo: async () => {
const result = await ircMessageAdapter.send?.text?.({
cfg: providedCfg,
to: "#room",
text: "threaded",
replyToId: "parent-1",
});
expect(result?.receipt.replyToId).toBe("parent-1");
expect(client.sendPrivmsg).toHaveBeenCalledWith(
"#room",
"threaded\n\n[reply:parent-1]",
);
},
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "replyTo", status: "verified" },
]),
);
});
});

View File

@@ -1,3 +1,7 @@
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-message";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
@@ -21,6 +25,7 @@ type SendIrcOptions = {
type SendIrcResult = {
messageId: string;
target: string;
receipt: MessageReceipt;
};
function recordIrcOutboundActivity(accountId: string): void {
@@ -94,8 +99,20 @@ export async function sendMessageIrc(
recordIrcOutboundActivity(account.accountId);
const messageId = makeIrcMessageId();
return {
messageId: makeIrcMessageId(),
messageId,
target,
receipt: createMessageReceiptFromOutboundResults({
results: [
{
channel: "irc",
messageId,
conversationId: target,
},
],
kind: "text",
...(opts.replyTo ? { replyToId: opts.replyTo } : {}),
}),
};
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { LineAutoReplyDeps } from "./auto-reply-delivery.js";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
import { sendLineReplyChunks } from "./reply-chunks.js";
import { createLineSendReceipt } from "./send-receipt.js";
const createFlexMessage = (altText: string, contents: unknown) => ({
type: "flex" as const,
@@ -45,7 +46,11 @@ describe("deliverLineAutoReply", () => {
text,
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1" }));
const pushMessagesLine = vi.fn(async () => ({
messageId: "push",
chatId: "u1",
receipt: createLineSendReceipt({ messageId: "push", chatId: "u1", kind: "text" }),
}));
const deps: LineAutoReplyDeps = {
buildTemplateMessageFromPayload: () => null,

View File

@@ -1,9 +1,15 @@
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageReceiveAckPolicyAdapterProofs,
} from "openclaw/plugin-sdk/channel-message";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../api.js";
import { linePlugin } from "./channel.js";
import { lineConfigAdapter } from "./config-adapter.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
import { lineOutboundAdapter } from "./outbound.js";
import { setLineRuntime } from "./runtime.js";
import { createLineSendReceipt } from "./send-receipt.js";
type LineRuntimeMocks = {
pushMessageLine: ReturnType<typeof vi.fn>;
@@ -20,19 +26,24 @@ type LineRuntimeMocks = {
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
};
function lineResult(messageId: string, chatId = "c1") {
return {
messageId,
chatId,
receipt: createLineSendReceipt({ messageId, chatId, kind: "text" }),
};
}
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
messageId: "m-quick",
chatId: "c1",
}));
const pushMessageLine = vi.fn(async () => lineResult("m-text"));
const pushMessagesLine = vi.fn(async () => lineResult("m-batch"));
const pushFlexMessage = vi.fn(async () => lineResult("m-flex"));
const pushTemplateMessage = vi.fn(async () => lineResult("m-template"));
const pushLocationMessage = vi.fn(async () => lineResult("m-loc"));
const pushTextMessageWithQuickReplies = vi.fn(async () => lineResult("m-quick"));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
const sendMessageLine = vi.fn(async () => lineResult("m-media"));
const chunkMarkdownText = vi.fn((text: string) => [text]);
const resolveTextChunkLimit = vi.fn(() => 123);
const resolveLineAccount = vi.fn(
@@ -227,7 +238,8 @@ describe("line outbound sendPayload", () => {
["One", "Two"],
{ verbose: false, accountId: "default", cfg },
);
expect(result).toEqual({ channel: "line", messageId: "m-quick", chatId: "c1" });
expect(result).toMatchObject({ channel: "line", messageId: "m-quick", chatId: "c1" });
expect(result.receipt?.primaryPlatformMessageId).toBe("m-quick");
});
it("sends media before quick-reply text so buttons stay visible", async () => {
@@ -469,6 +481,84 @@ describe("line outbound sendPayload", () => {
}),
).rejects.toThrow(/require previewimageurl/i);
});
it("declares message adapter durable text and media with receipt proofs", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
await expect(
verifyChannelMessageAdapterCapabilityProofs({
adapterName: "line",
adapter: linePlugin.message!,
proofs: {
text: async () => {
const result = await linePlugin.message?.send?.text?.({
cfg,
to: "line:user:U123",
text: "hello",
accountId: "primary",
});
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:U123", "hello", {
verbose: false,
accountId: "primary",
cfg,
});
expect(result?.receipt.platformMessageIds).toEqual(["m-text"]);
},
media: async () => {
const result = await linePlugin.message?.send?.media?.({
cfg,
to: "line:user:U123",
text: "image",
mediaUrl: "https://example.com/image.jpg",
accountId: "primary",
});
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:U123", "", {
verbose: false,
mediaUrl: "https://example.com/image.jpg",
accountId: "primary",
cfg,
});
expect(result?.receipt.platformMessageIds).toEqual(["m-media"]);
},
messageSendingHooks: () => {
expect(linePlugin.message?.send?.text).toBeTypeOf("function");
},
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "messageSendingHooks", status: "verified" },
]),
);
});
it("declares receive ack policies for deferred LINE webhook acknowledgement", async () => {
await expect(
verifyChannelMessageReceiveAckPolicyAdapterProofs({
adapterName: "line",
adapter: linePlugin.message!,
proofs: {
after_receive_record: () => {
expect(linePlugin.message?.receive?.supportedAckPolicies).toContain(
"after_receive_record",
);
},
after_agent_dispatch: () => {
expect(linePlugin.message?.receive?.defaultAckPolicy).toBe("after_agent_dispatch");
},
},
}),
).resolves.toEqual(
expect.arrayContaining([
{ policy: "after_receive_record", status: "verified" },
{ policy: "after_agent_dispatch", status: "verified" },
]),
);
});
});
describe("linePlugin config.formatAllowFrom", () => {

View File

@@ -9,7 +9,7 @@ import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
import { lineChannelPluginCommon } from "./channel-shared.js";
import { lineGatewayAdapter } from "./gateway.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
import { lineOutboundAdapter } from "./outbound.js";
import { lineMessageAdapter, lineOutboundAdapter } from "./outbound.js";
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
import { getLineRuntime } from "./runtime.js";
import { lineSetupAdapter } from "./setup-core.js";
@@ -72,6 +72,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
setup: lineSetupAdapter,
status: lineStatusAdapter,
gateway: lineGatewayAdapter,
message: lineMessageAdapter,
bindings: lineBindingsAdapter,
conversationBindings: {
defaultTopLevelPlacement: "current",

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { resolveLineDurableReplyOptions } from "./monitor-durable.js";
describe("resolveLineDurableReplyOptions", () => {
it("enables durable final delivery for push-only text replies", () => {
expect(
resolveLineDurableReplyOptions({
payload: { text: "hello" },
infoKind: "final",
to: "U123",
replyToken: "reply-token",
replyTokenUsed: true,
}),
).toEqual({
to: "U123",
});
});
it("keeps unused reply-token delivery on the legacy path", () => {
expect(
resolveLineDurableReplyOptions({
payload: { text: "hello" },
infoKind: "final",
to: "U123",
replyToken: "reply-token",
replyTokenUsed: false,
}),
).toBe(false);
});
it("keeps rich, media, and non-final replies on the legacy path", () => {
expect(
resolveLineDurableReplyOptions({
payload: { text: "hello", channelData: { line: { quickReplies: ["One"] } } },
infoKind: "final",
to: "U123",
replyTokenUsed: true,
}),
).toBe(false);
expect(
resolveLineDurableReplyOptions({
payload: { text: "photo", mediaUrl: "https://example.com/image.png" },
infoKind: "final",
to: "U123",
replyTokenUsed: true,
}),
).toBe(false);
expect(
resolveLineDurableReplyOptions({
payload: { text: "hello" },
infoKind: "block",
to: "U123",
replyTokenUsed: true,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,37 @@
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { LineChannelData } from "./types.js";
export type LineDurableReplyOptions = {
to: string;
};
function hasLineChannelData(payload: ReplyPayload): boolean {
const lineData = payload.channelData?.line as LineChannelData | undefined;
return Boolean(lineData && Object.keys(lineData).length > 0);
}
export function resolveLineDurableReplyOptions(params: {
payload: ReplyPayload;
infoKind: string;
to: string;
replyToken?: string | null;
replyTokenUsed: boolean;
}): LineDurableReplyOptions | false {
if (params.infoKind !== "final") {
return false;
}
if (params.replyToken && !params.replyTokenUsed) {
return false;
}
if (hasLineChannelData(params.payload)) {
return false;
}
const reply = resolveSendableOutboundReplyParts(params.payload);
if (reply.hasMedia || !reply.hasText) {
return false;
}
return {
to: params.to,
};
}

View File

@@ -3,9 +3,9 @@ import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
@@ -52,8 +52,9 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
};
});
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
createChannelReplyPipeline: vi.fn(() => ({})),
vi.mock("openclaw/plugin-sdk/channel-message", () => ({
createChannelMessageReplyPipeline: vi.fn(() => ({})),
hasFinalChannelMessageReplyDispatch: vi.fn(() => false),
}));
vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => {

View File

@@ -1,7 +1,6 @@
import type { webhook } from "@line/bot-sdk";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { hasFinalChannelMessageReplyDispatch } from "openclaw/plugin-sdk/channel-message";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
import {
danger,
@@ -24,6 +23,7 @@ import { resolveDefaultLineAccountId } from "./accounts.js";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
import { createLineBot } from "./bot.js";
import { processLineMessage } from "./markdown-to-line.js";
import { resolveLineDurableReplyOptions } from "./monitor-durable.js";
import { sendLineReplyChunks } from "./reply-chunks.js";
import { getLineRuntime } from "./runtime.js";
import {
@@ -223,13 +223,6 @@ export async function monitorLineProvider(
try {
const textLimit = 5000;
let replyTokenUsed = false;
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: config,
agentId: route.agentId,
channel: "line",
accountId: route.accountId,
});
const core = getLineRuntime();
const turnResult = await core.channel.turn.run({
channel: "line",
@@ -252,13 +245,16 @@ export async function monitorLineProvider(
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
record: ctx.turn.record,
dispatcherOptions: {
...replyPipeline,
},
replyOptions: {
onModelSelected,
},
replyPipeline: {},
delivery: {
durable: (payload, info) =>
resolveLineDurableReplyOptions({
payload,
infoKind: info.kind,
to: ctxPayload.From,
replyToken,
replyTokenUsed,
}),
deliver: async (payload) => {
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
@@ -317,7 +313,7 @@ export async function monitorLineProvider(
},
});
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
if (!hasFinalInboundReplyDispatch(dispatchResult)) {
if (!hasFinalChannelMessageReplyDispatch(dispatchResult)) {
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
}
} catch (err) {

View File

@@ -1,3 +1,8 @@
import {
defineChannelMessageAdapter,
type ChannelMessageSendResult,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
import {
createAttachedChannelResultAdapter,
createEmptyChannelResult,
@@ -8,7 +13,8 @@ import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js";
import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js";
import { getLineRuntime } from "./runtime.js";
import type { LineChannelData } from "./types.js";
import { createLineSendReceipt } from "./send-receipt.js";
import type { LineChannelData, LineSendResult } from "./types.js";
const loadLineOutboundRuntime = createLazyRuntimeModule(() => import("./outbound.runtime.js"));
@@ -91,7 +97,7 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
lineRuntime?.buildTemplateMessageFromPayload ??
outboundRuntime.buildTemplateMessageFromPayload;
let lastResult: { messageId: string; chatId: string } | null = null;
let lastResult: LineSendResult | null = null;
const quickReplies = lineData.quickReplies ?? [];
const hasQuickReplies = quickReplies.length > 0;
const quickReply = hasQuickReplies
@@ -110,7 +116,7 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
cfg,
accountId: accountId ?? undefined,
});
lastResult = { messageId: result.messageId, chatId: result.chatId };
lastResult = result;
}
};
@@ -323,7 +329,7 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
const sendText = outboundRuntime.pushMessageLine;
const sendFlex = outboundRuntime.pushFlexMessage;
const processed = outboundRuntime.processLineMessage(text);
let result: { messageId: string; chatId: string };
let result: LineSendResult;
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
verbose: false,
@@ -331,7 +337,11 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
accountId: accountId ?? undefined,
});
} else {
result = { messageId: "processed", chatId: to };
result = {
messageId: "processed",
chatId: to,
receipt: createLineSendReceipt({ messageId: "processed", chatId: to, kind: "card" }),
};
}
for (const flexMsg of processed.flexMessages) {
const flexContents = flexMsg.contents;
@@ -354,3 +364,64 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
}),
}),
};
function toLineMessageSendResult(
result: Awaited<ReturnType<NonNullable<typeof lineOutboundAdapter.sendPayload>>>,
kind: MessageReceiptPartKind,
): ChannelMessageSendResult {
const source = result as typeof result & { chatId?: string };
const receipt =
result.receipt ??
(result.messageId
? createLineSendReceipt({
messageId: result.messageId,
chatId: source.chatId ?? "",
kind,
})
: undefined);
if (!receipt) {
throw new Error("LINE message adapter send did not return a receipt");
}
return {
messageId: result.messageId || receipt.primaryPlatformMessageId,
receipt,
};
}
export const lineMessageAdapter = defineChannelMessageAdapter({
id: "line",
durableFinal: {
capabilities: {
text: true,
media: true,
messageSendingHooks: true,
},
},
send: {
text: async ({ cfg, to, text, accountId }) => {
const result = await lineOutboundAdapter.sendPayload!({
cfg,
to,
text,
accountId,
payload: { text },
});
return toLineMessageSendResult(result, "text");
},
media: async ({ cfg, to, text, mediaUrl, accountId }) => {
const result = await lineOutboundAdapter.sendPayload!({
cfg,
to,
text,
mediaUrl,
accountId,
payload: { text, mediaUrl },
});
return toLineMessageSendResult(result, "media");
},
},
receive: {
defaultAckPolicy: "after_agent_dispatch",
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
},
});

View File

@@ -0,0 +1,32 @@
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
export function createLineSendReceipt(params: {
messageId: string;
chatId: string;
kind?: MessageReceiptPartKind;
messageCount?: number;
}): MessageReceipt {
const messageId = params.messageId.trim();
const chatId = params.chatId.trim();
return createMessageReceiptFromOutboundResults({
results: messageId
? [
{
channel: "line",
messageId,
chatId,
conversationId: chatId,
meta: {
messageCount: params.messageCount ?? 1,
},
},
]
: [],
...(chatId ? { threadId: chatId } : {}),
kind: params.kind ?? "unknown",
});
}

View File

@@ -165,7 +165,8 @@ describe("LINE send helpers", () => {
direction: "outbound",
});
expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123");
expect(result).toEqual({ messageId: "push", chatId: "U123" });
expect(result).toMatchObject({ messageId: "push", chatId: "U123" });
expect(result.receipt.primaryPlatformMessageId).toBe("push");
});
it("replies when reply token is provided", async () => {
@@ -193,7 +194,10 @@ describe("LINE send helpers", () => {
],
});
expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1");
expect(result).toEqual({ messageId: "reply", chatId: "C1" });
expect(result).toMatchObject({ messageId: "reply", chatId: "C1" });
expect(result.receipt.primaryPlatformMessageId).toBe("reply");
expect(result.receipt.threadId).toBe("C1");
expect(result.receipt.parts[0]?.kind).toBe("media");
});
it("sends video with explicit image preview URL", async () => {

View File

@@ -6,6 +6,7 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveLineAccount } from "./accounts.js";
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
import { validateLineMediaUrl } from "./outbound-media.js";
import { createLineSendReceipt } from "./send-receipt.js";
import type { LineSendResult } from "./types.js";
type Message = messagingApi.Message;
@@ -177,6 +178,23 @@ function recordLineOutboundActivity(accountId: string): void {
});
}
function resolveLineReceiptKind(messages: readonly Message[]) {
const types = new Set(messages.map((message) => message.type));
if (types.has("audio")) {
return "voice";
}
if (types.has("image") || types.has("video")) {
return "media";
}
if (types.has("flex") || types.has("template") || types.has("location")) {
return "card";
}
if (types.has("text")) {
return "text";
}
return "unknown";
}
async function pushLineMessages(
to: string,
messages: Message[],
@@ -214,6 +232,12 @@ async function pushLineMessages(
return {
messageId: "push",
chatId,
receipt: createLineSendReceipt({
messageId: "push",
chatId,
kind: resolveLineReceiptKind(messages),
messageCount: messages.length,
}),
};
}
@@ -293,6 +317,12 @@ export async function sendMessageLine(
return {
messageId: "reply",
chatId,
receipt: createLineSendReceipt({
messageId: "reply",
chatId,
kind: resolveLineReceiptKind(messages),
messageCount: messages.length,
}),
};
}

View File

@@ -1,4 +1,5 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
export type LineTokenSource = "config" | "env" | "file" | "none";
@@ -60,6 +61,7 @@ export interface ResolvedLineAccount {
export interface LineSendResult {
messageId: string;
chatId: string;
receipt: MessageReceipt;
}
export type LineProbeResult = BaseProbeResult<string> & {

View File

@@ -1,5 +1,9 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { webhook } from "@line/bot-sdk";
import {
createMessageReceiveContext,
type MessageReceiveContext,
} from "openclaw/plugin-sdk/channel-message";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
isRequestBodyLimitError,
@@ -57,6 +61,7 @@ export function createLineNodeWebhookHandler(params: {
return;
}
let receiveContext: MessageReceiveContext<webhook.CallbackRequest> | undefined;
try {
const signatureHeader = req.headers["x-line-signature"];
const signature =
@@ -99,15 +104,29 @@ export function createLineNodeWebhookHandler(params: {
params.onRequestAuthenticated?.();
receiveContext = createMessageReceiveContext({
id: `${Date.now()}:line:webhook`,
channel: "line",
message: body,
ackPolicy: body.events?.length ? "after_agent_dispatch" : "after_receive_record",
onAck: () => {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
},
});
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
await params.bot.handleWebhook(body);
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
const ackStage = body.events?.length ? "agent_dispatch" : "receive_record";
if (receiveContext.shouldAckAfter(ackStage)) {
await receiveContext.ack();
}
} catch (err) {
await receiveContext?.nack(err);
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
res.statusCode = 413;
res.setHeader("Content-Type", "application/json");

View File

@@ -1,5 +1,9 @@
import type { webhook } from "@line/bot-sdk";
import type { NextFunction, Request, Response } from "express";
import {
createMessageReceiveContext,
type MessageReceiveContext,
} from "openclaw/plugin-sdk/channel-message";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
@@ -34,6 +38,7 @@ export function createLineWebhookMiddleware(
const { channelSecret, onEvents, runtime } = options;
return async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
let receiveContext: MessageReceiveContext<webhook.CallbackRequest> | undefined;
try {
const signature = req.headers["x-line-signature"];
@@ -66,13 +71,27 @@ export function createLineWebhookMiddleware(
return;
}
receiveContext = createMessageReceiveContext({
id: `${Date.now()}:line:webhook`,
channel: "line",
message: body,
ackPolicy: body.events?.length ? "after_agent_dispatch" : "after_receive_record",
onAck: () => {
res.status(200).json({ status: "ok" });
},
});
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
await onEvents(body);
}
res.status(200).json({ status: "ok" });
const ackStage = body.events?.length ? "agent_dispatch" : "receive_record";
if (receiveContext.shouldAckAfter(ackStage)) {
await receiveContext.ack();
}
} catch (err) {
await receiveContext?.nack(err);
runtime?.error?.(danger(`line webhook error: ${String(err)}`));
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });

View File

@@ -21,6 +21,29 @@ type MatrixPendingPluginApprovalView = Extract<
const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval";
function buildMatrixReceipt(messageIds: readonly string[], roomId = "!room:example.org") {
return {
primaryPlatformMessageId: messageIds[0],
platformMessageIds: [...messageIds],
parts: messageIds.map((messageId, index) => ({
platformMessageId: messageId,
kind: "text" as const,
index,
raw: {
channel: "matrix",
messageId,
roomId,
},
})),
sentAt: 100,
raw: messageIds.map((messageId) => ({
channel: "matrix",
messageId,
roomId,
})),
};
}
function buildMatrixApprovalRoomTarget(
roomId: string,
): MatrixDeliverPendingParams["plannedTarget"] {
@@ -142,7 +165,7 @@ describe("matrixApprovalNativeRuntime", () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$approval",
primaryMessageId: "$approval",
messageIds: ["$approval"],
receipt: buildMatrixReceipt(["$approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
@@ -195,7 +218,7 @@ describe("matrixApprovalNativeRuntime", () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$plugin-approval",
primaryMessageId: "$plugin-approval",
messageIds: ["$plugin-approval"],
receipt: buildMatrixReceipt(["$plugin-approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
@@ -270,7 +293,7 @@ describe("matrixApprovalNativeRuntime", () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$approval",
primaryMessageId: "$approval",
messageIds: ["$approval"],
receipt: buildMatrixReceipt(["$approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockImplementation(async () => {
@@ -318,8 +341,8 @@ describe("matrixApprovalNativeRuntime", () => {
.mockRejectedValue(new Error("Matrix single-message text exceeds limit (5000 > 4000)"));
const sendMessage = vi.fn().mockResolvedValue({
messageId: "$last",
primaryMessageId: "$primary",
messageIds: ["$primary", "$last"],
primaryMessageId: "$legacy-primary",
receipt: buildMatrixReceipt(["$primary", "$last"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
@@ -375,7 +398,7 @@ describe("matrixApprovalNativeRuntime", () => {
);
expect(entry).toMatchObject({
roomId: "!room:example.org",
messageIds: ["$primary", "$last"],
platformMessageIds: ["$primary", "$last"],
reactionEventId: "$primary",
});
const bindPending = matrixApprovalNativeRuntime.interactions?.bindPending;

View File

@@ -15,6 +15,10 @@ import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import {
listMessageReceiptPlatformIds,
resolveMessageReceiptPrimaryId,
} from "openclaw/plugin-sdk/channel-message";
import {
buildMatrixApprovalReactionHint,
listMatrixApprovalReactionBindings,
@@ -42,7 +46,7 @@ const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval" as const;
type PendingMessage = {
roomId: string;
messageIds: readonly string[];
platformMessageIds: readonly string[];
reactionEventId: string;
};
type PreparedMatrixTarget = {
@@ -147,7 +151,9 @@ function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext):
}
function normalizePendingMessageIds(entry: PendingMessage): string[] {
return Array.from(new Set(entry.messageIds.map((messageId) => messageId.trim()).filter(Boolean)));
return Array.from(
new Set(entry.platformMessageIds.map((messageId) => messageId.trim()).filter(Boolean)),
);
}
function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null {
@@ -438,15 +444,15 @@ export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
extraContent: pendingPayload.extraContent,
});
}
const messageIds = Array.from(
new Set(
(result.messageIds ?? [result.messageId])
.map((messageId) => messageId.trim())
.filter(Boolean),
),
);
const receiptMessageIds = listMessageReceiptPlatformIds(result.receipt);
const platformMessageIds = receiptMessageIds.length
? receiptMessageIds
: [result.messageId.trim()].filter(Boolean);
const reactionEventId =
result.primaryMessageId?.trim() || messageIds[0] || result.messageId.trim();
resolveMessageReceiptPrimaryId(result.receipt) ||
result.primaryMessageId?.trim() ||
platformMessageIds[0] ||
result.messageId.trim();
registerMatrixApprovalReactionTarget({
roomId: result.roomId,
eventId: reactionEventId,
@@ -467,7 +473,7 @@ export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
);
return {
roomId: result.roomId,
messageIds,
platformMessageIds,
reactionEventId,
};
},

View File

@@ -0,0 +1,169 @@
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-message";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
const mocks = vi.hoisted(() => ({
sendMessageMatrix: vi.fn(),
}));
vi.mock("./matrix/send.js", () => ({
sendMessageMatrix: mocks.sendMessageMatrix,
sendPollMatrix: vi.fn(),
sendTypingMatrix: vi.fn(),
}));
vi.mock("./runtime.js", () => ({
getMatrixRuntime: () => ({
channel: {
text: {
chunkMarkdownText: (text: string) => [text],
},
},
}),
}));
import { matrixPlugin } from "./channel.js";
const cfg = {
channels: {
matrix: {
accessToken: "resolved-token",
},
},
} as OpenClawConfig;
describe("matrix channel message adapter", () => {
beforeEach(() => {
mocks.sendMessageMatrix.mockReset();
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" });
});
it("backs declared durable-final capabilities with runtime outbound proofs", async () => {
const adapter = matrixPlugin.message;
expect(adapter).toBeDefined();
const proveText = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await adapter!.send!.text!({
cfg,
to: "room:!room:example",
text: "hello",
accountId: "default",
});
expect(mocks.sendMessageMatrix).toHaveBeenLastCalledWith(
"room:!room:example",
"hello",
expect.objectContaining({ cfg, accountId: "default" }),
);
expect(result.receipt.platformMessageIds).toEqual(["$event-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await adapter!.send!.media!({
cfg,
to: "room:!room:example",
text: "caption",
mediaUrl: "file:///tmp/cat.png",
mediaLocalRoots: ["/tmp/openclaw"],
accountId: "default",
audioAsVoice: true,
});
expect(mocks.sendMessageMatrix).toHaveBeenLastCalledWith(
"room:!room:example",
"caption",
expect.objectContaining({
cfg,
mediaUrl: "file:///tmp/cat.png",
mediaLocalRoots: ["/tmp/openclaw"],
audioAsVoice: true,
}),
);
expect(result.receipt.parts[0]?.kind).toBe("voice");
};
const proveReplyThread = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await adapter!.send!.text!({
cfg,
to: "room:!room:example",
text: "threaded",
accountId: "default",
replyToId: "$reply",
threadId: "$thread",
});
expect(mocks.sendMessageMatrix).toHaveBeenLastCalledWith(
"room:!room:example",
"threaded",
expect.objectContaining({
cfg,
replyToId: "$reply",
threadId: "$thread",
}),
);
expect(result.receipt.replyToId).toBe("$reply");
expect(result.receipt.threadId).toBe("$thread");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "matrixMessageAdapter",
adapter: adapter!,
proofs: {
text: proveText,
media: proveMedia,
replyTo: proveReplyThread,
thread: proveReplyThread,
messageSendingHooks: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
},
});
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = matrixPlugin.message;
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "matrixMessageAdapter",
adapter: adapter!,
proofs: {
draftPreview: () => {
expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
quietFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "matrixMessageAdapter",
adapter: adapter!,
proofs: {
finalEdit: () => {
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
previewReceipt: () => {
expect(adapter!.live?.capabilities?.quietFinalization).toBe(true);
},
},
});
});
});

View File

@@ -5,10 +5,12 @@ import {
} from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message";
import {
createAllowlistProviderOpenWarningCollector,
projectAccountConfigWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import {
createChannelDirectoryAdapter,
@@ -319,6 +321,64 @@ function resolveMatrixDeliveryTarget(params: {
return null;
}
const matrixChannelOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg,
accountId,
payload,
}),
...createRuntimeOutboundDelegates({
getRuntime: loadMatrixChannelRuntime,
sendText: {
resolve: (runtime) => runtime.matrixOutbound.sendText,
unavailableMessage: "Matrix outbound text delivery is unavailable",
},
sendMedia: {
resolve: (runtime) => runtime.matrixOutbound.sendMedia,
unavailableMessage: "Matrix outbound media delivery is unavailable",
},
sendPoll: {
resolve: (runtime) => runtime.matrixOutbound.sendPoll,
unavailableMessage: "Matrix outbound poll delivery is unavailable",
},
}),
};
const matrixMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "matrix",
outbound: matrixChannelOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
quietFinalization: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
previewReceipt: true,
},
},
},
});
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
createChatChannelPlugin<ResolvedMatrixAccount, MatrixProbe>({
base: {
@@ -416,6 +476,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
}),
resolver: matrixResolverAdapter,
actions: matrixMessageActions,
message: matrixMessageAdapter,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
@@ -580,31 +641,5 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
};
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg,
accountId,
payload,
}),
...createRuntimeOutboundDelegates({
getRuntime: loadMatrixChannelRuntime,
sendText: {
resolve: (runtime) => runtime.matrixOutbound.sendText,
unavailableMessage: "Matrix outbound text delivery is unavailable",
},
sendMedia: {
resolve: (runtime) => runtime.matrixOutbound.sendMedia,
unavailableMessage: "Matrix outbound media delivery is unavailable",
},
sendPoll: {
resolve: (runtime) => runtime.matrixOutbound.sendPoll,
unavailableMessage: "Matrix outbound poll delivery is unavailable",
},
}),
},
outbound: matrixChannelOutbound,
});

View File

@@ -64,7 +64,12 @@ const sendModuleMocks = vi.hoisted(() => {
messageId: eventId ?? "unknown",
roomId,
primaryMessageId: eventId ?? "unknown",
messageIds: eventId ? [eventId] : [],
receipt: {
...(eventId ? { primaryPlatformMessageId: eventId } : {}),
platformMessageIds: eventId ? [eventId] : [],
parts: eventId ? [{ platformMessageId: eventId, kind: "text" as const, index: 0 }] : [],
sentAt: 123,
},
};
},
);

View File

@@ -1,3 +1,9 @@
import {
createPreviewMessageReceipt,
defineFinalizableLivePreviewAdapter,
deliverWithFinalizableLivePreviewAdapter,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-message";
import {
createChannelProgressDraftGate,
formatChannelProgressDraftLine,
@@ -894,14 +900,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return undefined;
}
const _messageId = event.event_id ?? "";
const _threadRootId = resolveMatrixThreadRootId({ event, content });
const messageId = event.event_id ?? "";
const threadRootId = resolveMatrixThreadRootId({ event, content });
const thread = resolveMatrixThreadRouting({
isDirectMessage,
threadReplies,
dmThreadReplies,
messageId: _messageId,
threadRootId: _threadRootId,
messageId,
threadRootId,
});
const {
route: _route,
@@ -1001,7 +1007,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
sender: senderId,
body: pendingHistoryBody,
timestamp: eventTs ?? undefined,
messageId: _messageId,
messageId,
};
roomHistoryTracker.recordPending(roomId, pendingEntry);
}
@@ -1116,7 +1122,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
sender: senderName,
body: bodyText,
timestamp: eventTs ?? undefined,
messageId: _messageId,
messageId,
})
: undefined;
const inboundHistory = preparedTrigger?.history;
@@ -1139,9 +1145,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
commandBodyText,
media,
locationPayload,
messageId: _messageId,
messageId,
triggerSnapshot,
threadRootId: _threadRootId,
threadRootId,
thread,
effectiveAllowFrom,
effectiveGroupAllowFrom,
@@ -1194,9 +1200,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
commandBodyText,
media,
locationPayload,
messageId: _messageId,
messageId,
triggerSnapshot,
threadRootId: _threadRootId,
threadRootId,
thread,
effectiveGroupAllowFrom,
effectiveRoomUsers,
@@ -1233,8 +1239,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
kind,
senderAllowed: isRoomContextSenderAllowed(contextSenderId),
}).include;
let threadContext = _threadRootId
? await resolveThreadContext({ roomId, threadRootId: _threadRootId })
let threadContext = threadRootId
? await resolveThreadContext({ roomId, threadRootId })
: undefined;
let threadContextBlockedByPolicy = false;
if (
@@ -1246,7 +1252,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
threadContext = undefined;
}
let replyContext: Awaited<ReturnType<typeof resolveReplyContext>> | undefined;
if (replyToEventId && replyToEventId === _threadRootId && threadContext?.summary) {
if (replyToEventId && replyToEventId === threadRootId && threadContext?.summary) {
replyContext = {
replyToBody: threadContext.summary,
replyToSender: threadContext.senderLabel,
@@ -1254,7 +1260,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
};
} else if (
replyToEventId &&
replyToEventId === _threadRootId &&
replyToEventId === threadRootId &&
threadContextBlockedByPolicy
) {
replyContext = await resolveReplyContext({ roomId, eventId: replyToEventId });
@@ -1273,7 +1279,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const roomInfo = isRoom ? await getRoomInfo(roomId) : undefined;
const roomName = roomInfo?.name;
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${_messageId} room: ${roomId}]`;
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: _route.agentId,
});
@@ -1330,7 +1336,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
Provider: "matrix" as const,
Surface: "matrix" as const,
WasMentioned: isRoom ? wasMentioned : undefined,
MessageSid: _messageId,
MessageSid: messageId,
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
ReplyToBody: replyContext?.replyToBody,
ReplyToSender: replyContext?.replyToSender,
@@ -1377,22 +1383,22 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
shouldBypassMention,
}),
);
if (shouldAckReaction() && _messageId) {
if (shouldAckReaction() && messageId) {
loadMatrixSendModule()
.then(({ reactMatrixMessage }) =>
reactMatrixMessage(roomId, _messageId, ackReaction, client),
reactMatrixMessage(roomId, messageId, ackReaction, client),
)
.catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
});
}
if (_messageId) {
if (messageId) {
loadMatrixSendModule()
.then(({ sendReadReceiptMatrix }) => sendReadReceiptMatrix(roomId, _messageId, client))
.then(({ sendReadReceiptMatrix }) => sendReadReceiptMatrix(roomId, messageId, client))
.catch((err) => {
logVerboseMessage(
`matrix: read receipt failed room=${roomId} id=${_messageId}: ${String(err)}`,
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
);
});
}
@@ -1443,7 +1449,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const draftStreamingEnabled = streaming !== "off";
const quietDraftStreaming = streaming === "quiet" || streaming === "progress";
const progressDraftStreaming = streaming === "progress";
const draftReplyToId = replyToMode !== "off" && !threadTarget ? _messageId : undefined;
const draftReplyToId = replyToMode !== "off" && !threadTarget ? messageId : undefined;
const draftStream: MatrixDraftStreamHandle | undefined = draftStreamingEnabled
? await loadMatrixDraftStream().then(({ createMatrixDraftStream }) =>
createMatrixDraftStream({
@@ -1785,39 +1791,77 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!payloadReplyMismatch &&
!mustDeliverFinalNormally
) {
try {
const requiresFinalEdit =
quietDraftStreaming || !draftStream.matchesPreparedText(payload.text);
if (requiresFinalEdit) {
const { editMessageMatrix } = await loadMatrixSendModule();
await editMessageMatrix(roomId, draftEventId, payload.text, {
client,
const finalPreviewText = payload.text;
await deliverWithFinalizableLivePreviewAdapter<
ReplyPayload,
string,
{
text: string;
finalizeLive: boolean;
extraContent?: Record<string, unknown>;
}
>({
kind: "final",
payload,
adapter: defineFinalizableLivePreviewAdapter({
draft: {
flush: async () => {},
clear: async () => {},
discardPending: async () => {},
id: () => draftEventId,
},
buildFinalEdit: () => ({
text: finalPreviewText,
finalizeLive: !(
quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText)
),
...(quietDraftStreaming
? { extraContent: buildMatrixFinalizedPreviewContent() }
: {}),
}),
editFinal: async (_draftEventId, edit) => {
if (edit.finalizeLive) {
if (!(await draftStream.finalizeLive())) {
throw new Error("Matrix draft live finalize failed");
}
return;
}
const { editMessageMatrix } = await loadMatrixSendModule();
await editMessageMatrix(roomId, _draftEventId, edit.text, {
client,
cfg,
threadId: threadTarget,
accountId: _route.accountId,
extraContent: edit.extraContent,
});
},
createPreviewReceipt: (id): MessageReceipt =>
createPreviewMessageReceipt({
id,
...(threadTarget ? { threadId: threadTarget } : {}),
...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}),
}),
logPreviewEditFailure: (err) => {
logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`);
},
}),
deliverNormally: async () => {
await redactMatrixDraftEvent(client, roomId, draftEventId);
await deliverMatrixReplies({
cfg,
replies: [payload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
accountId: _route.accountId,
extraContent: quietDraftStreaming
? buildMatrixFinalizedPreviewContent()
: undefined,
mediaLocalRoots,
tableMode,
});
} else if (!(await draftStream.finalizeLive())) {
throw new Error("Matrix draft live finalize failed");
}
} catch {
await redactMatrixDraftEvent(client, roomId, draftEventId);
await deliverMatrixReplies({
cfg,
replies: [payload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
}
},
});
draftConsumed = true;
} else if (draftEventId && hasMedia && !payloadReplyMismatch) {
let textEditOk = !mustDeliverFinalNormally;
@@ -1968,7 +2012,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
raw: event,
adapter: {
ingest: () => ({
id: _messageId,
id: messageId,
rawText: bodyText,
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
@@ -2108,13 +2152,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (finalReplyDeliveryFailed) {
if (retryableReplyDeliveryFailed) {
logVerboseMessage(
`matrix: final reply delivery failed room=${roomId} id=${_messageId}; leaving event uncommitted`,
`matrix: final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`,
);
// Explicit retryable failures reopen replay so the same history can be retried.
return;
}
logVerboseMessage(
`matrix: final reply delivery failed room=${roomId} id=${_messageId}; keeping replay committed`,
`matrix: final reply delivery failed room=${roomId} id=${messageId}; keeping replay committed`,
);
await commitInboundEventIfClaimed();
return;
@@ -2122,13 +2166,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (!queuedFinal && nonFinalReplyDeliveryFailed) {
if (retryableReplyDeliveryFailed) {
logVerboseMessage(
`matrix: non-final reply delivery failed room=${roomId} id=${_messageId}; leaving event uncommitted`,
`matrix: non-final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`,
);
// Explicit retryable failures reopen replay.
return;
}
logVerboseMessage(
`matrix: non-final reply delivery failed room=${roomId} id=${_messageId}; keeping replay committed`,
`matrix: non-final reply delivery failed room=${roomId} id=${messageId}; keeping replay committed`,
);
await commitInboundEventIfClaimed();
return;
@@ -2137,7 +2181,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
// Only advance to the snapshot position — messages added during async processing remain
// visible for the next trigger.
if (isRoom && triggerSnapshot) {
roomHistoryTracker.consumeHistory(_route.agentId, roomId, triggerSnapshot, _messageId);
roomHistoryTracker.consumeHistory(_route.agentId, roomId, triggerSnapshot, messageId);
}
if (!hasFinalInboundReplyDispatch({ queuedFinal, counts })) {
await commitInboundEventIfClaimed();

View File

@@ -627,7 +627,15 @@ describe("sendMessageMatrix threads", () => {
roomId: "!room:example",
primaryMessageId: "$m1",
messageId: "$m3",
messageIds: ["$m1", "$m2", "$m3"],
receipt: {
primaryPlatformMessageId: "$m1",
platformMessageIds: ["$m1", "$m2", "$m3"],
parts: [
expect.objectContaining({ platformMessageId: "$m1", kind: "text" }),
expect.objectContaining({ platformMessageId: "$m2", kind: "text" }),
expect.objectContaining({ platformMessageId: "$m3", kind: "text" }),
],
},
});
});
@@ -720,7 +728,7 @@ describe("sendSingleTextMessageMatrix", () => {
it("merges extra content fields into single-event sends", async () => {
const { client, sendMessage } = makeClient();
await sendSingleTextMessageMatrix("room:!room:example", "done", {
const result = await sendSingleTextMessageMatrix("room:!room:example", "done", {
client,
cfg: {} as never,
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
@@ -730,6 +738,11 @@ describe("sendSingleTextMessageMatrix", () => {
body: "done",
[MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true,
});
expect(result.receipt).toMatchObject({
primaryPlatformMessageId: "evt1",
platformMessageIds: ["evt1"],
parts: [expect.objectContaining({ platformMessageId: "evt1", kind: "text" })],
});
});
});

View File

@@ -1,3 +1,7 @@
import {
createMessageReceiptFromOutboundResults,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { PollInput } from "../runtime-api.js";
@@ -66,6 +70,25 @@ type MatrixClientResolveOpts = {
accountId?: string | null;
};
function createMatrixSendReceipt(params: {
roomId: string;
platformMessageIds: readonly string[];
kind: MessageReceiptPartKind;
replyToId?: string;
threadId?: string | null;
}) {
return createMessageReceiptFromOutboundResults({
kind: params.kind,
...(params.replyToId ? { replyToId: params.replyToId } : {}),
...(params.threadId ? { threadId: params.threadId } : {}),
results: params.platformMessageIds.map((messageId) => ({
channel: "matrix",
messageId,
roomId: params.roomId,
})),
});
}
function isMatrixClient(value: MatrixClient | MatrixClientResolveOpts): value is MatrixClient {
return typeof (value as { sendEvent?: unknown }).sendEvent === "function";
}
@@ -219,8 +242,9 @@ export async function sendMessageMatrix(
return eventId;
};
const messageIds: string[] = [];
const platformMessageIds: string[] = [];
let lastMessageId = "";
let receiptKind: MessageReceiptPartKind = "text";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg);
const media = await loadOutboundMediaFromUrl(opts.mediaUrl, {
@@ -246,6 +270,7 @@ export async function sendMessageMatrix(
fileName: media.fileName,
});
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
receiptKind = useVoice ? "voice" : "media";
const isImage = msgtype === MsgType.Image;
const imageInfo = isImage
? await prepareImageInfo({
@@ -278,7 +303,7 @@ export async function sendMessageMatrix(
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
if (eventId) {
messageIds.push(eventId);
platformMessageIds.push(eventId);
}
const textChunks = useVoice ? chunks : rest;
// Voice messages use a generic media body ("Voice message"), so keep any
@@ -298,7 +323,7 @@ export async function sendMessageMatrix(
const followupEventId = await sendContent(followup);
lastMessageId = followupEventId ?? lastMessageId;
if (followupEventId) {
messageIds.push(followupEventId);
platformMessageIds.push(followupEventId);
}
}
} else {
@@ -316,7 +341,7 @@ export async function sendMessageMatrix(
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
if (eventId) {
messageIds.push(eventId);
platformMessageIds.push(eventId);
}
}
}
@@ -324,8 +349,14 @@ export async function sendMessageMatrix(
return {
messageId: lastMessageId || "unknown",
roomId,
primaryMessageId: messageIds[0] ?? (lastMessageId || "unknown"),
messageIds,
primaryMessageId: platformMessageIds[0] ?? (lastMessageId || "unknown"),
receipt: createMatrixSendReceipt({
roomId,
platformMessageIds,
kind: receiptKind,
replyToId: opts.replyToId,
threadId,
}),
};
},
);
@@ -474,11 +505,18 @@ export async function sendSingleTextMessageMatrix(
(content as Record<string, unknown>)[MSC4357_LIVE_KEY] = {};
}
const eventId = await client.sendMessage(resolvedRoom, content);
const platformMessageIds = eventId ? [eventId] : [];
return {
messageId: eventId ?? "unknown",
roomId: resolvedRoom,
primaryMessageId: eventId ?? "unknown",
messageIds: eventId ? [eventId] : [],
receipt: createMatrixSendReceipt({
roomId: resolvedRoom,
platformMessageIds,
kind: "text",
replyToId: opts.replyToId,
threadId: normalizedThreadId,
}),
};
},
);

View File

@@ -1,3 +1,4 @@
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
import type { CoreConfig } from "../../types.js";
import { MATRIX_ANNOTATION_RELATION_TYPE, MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js";
import type {
@@ -79,7 +80,7 @@ export type MatrixSendResult = {
messageId: string;
roomId: string;
primaryMessageId?: string;
messageIds?: string[];
receipt: MessageReceipt;
};
export type MatrixSendOpts = {

View File

@@ -90,7 +90,7 @@ export {
export { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps";
export { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
export { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/poll-runtime";
export { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";

View File

@@ -57,7 +57,7 @@ export {
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/channel-policy";
export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
export { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";

View File

@@ -0,0 +1,151 @@
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-message";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageMattermostMock = vi.hoisted(() => vi.fn());
vi.mock("./mattermost/send.js", () => ({
sendMessageMattermost: sendMessageMattermostMock,
}));
import { mattermostPlugin } from "./channel.js";
describe("mattermost channel message adapter", () => {
beforeEach(() => {
sendMessageMattermostMock.mockReset();
sendMessageMattermostMock.mockResolvedValue({
messageId: "post-1",
channelId: "channel-1",
});
});
it("backs declared durable-final capabilities with outbound send proofs", async () => {
const adapter = mattermostPlugin.message;
expect(adapter).toBeDefined();
const proveText = async () => {
sendMessageMattermostMock.mockClear();
const result = await adapter!.send!.text!({
cfg: {},
to: "channel:team-1",
text: "hello",
accountId: "default",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "hello", {
cfg: {},
accountId: "default",
replyToId: undefined,
});
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
sendMessageMattermostMock.mockClear();
const result = await adapter!.send!.media!({
cfg: {},
to: "channel:team-1",
text: "caption",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "caption", {
cfg: {},
accountId: "default",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
replyToId: undefined,
});
expect(result.receipt.parts[0]?.kind).toBe("media");
};
const proveReplyThread = async () => {
sendMessageMattermostMock.mockClear();
const result = await adapter!.send!.text!({
cfg: {},
to: "channel:parent-1",
text: "threaded",
accountId: "default",
threadId: "thread-1",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "threaded", {
cfg: {},
accountId: "default",
replyToId: "thread-1",
});
expect(result.receipt.threadId).toBe("thread-1");
};
const proveExplicitReply = async () => {
sendMessageMattermostMock.mockClear();
const result = await adapter!.send!.text!({
cfg: {},
to: "channel:parent-1",
text: "reply",
accountId: "default",
replyToId: "post-parent-1",
threadId: "thread-1",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "reply", {
cfg: {},
accountId: "default",
replyToId: "post-parent-1",
});
expect(result.receipt.replyToId).toBe("post-parent-1");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "mattermostMessageAdapter",
adapter: adapter!,
proofs: {
text: proveText,
media: proveMedia,
replyTo: proveExplicitReply,
thread: proveReplyThread,
messageSendingHooks: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
},
});
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = mattermostPlugin.message;
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "mattermostMessageAdapter",
adapter: adapter!,
proofs: {
draftPreview: () => {
expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "mattermostMessageAdapter",
adapter: adapter!,
proofs: {
finalEdit: () => {
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
},
});
});
});

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { createChannelReplyPipeline } from "../runtime-api.js";
import { createChannelMessageReplyPipeline } from "../runtime-api.js";
const { sendMessageMattermostMock, mockFetchGuard } = vi.hoisted(() => ({
sendMessageMattermostMock: vi.fn(),
@@ -582,7 +582,7 @@ describe("mattermostPlugin", () => {
},
};
const prefixContext = createChannelReplyPipeline({
const prefixContext = createChannelMessageReplyPipeline({
cfg,
agentId: "main",
channel: "mattermost",

View File

@@ -4,8 +4,13 @@ import type {
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message";
import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
type ChannelOutboundAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import {
@@ -260,6 +265,83 @@ function parseMattermostReactActionParams(params: Record<string, unknown>): {
};
}
const mattermostOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
...createAttachedChannelResultAdapter({
channel: "mattermost",
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) =>
await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
replyToId,
threadId,
}) =>
await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
mediaUrl,
mediaLocalRoots,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
}),
};
const mattermostMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "mattermost",
outbound: mattermostOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
},
},
},
});
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = createChatChannelPlugin({
base: {
id: "mattermost",
@@ -291,6 +373,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
resolveRequireMention: resolveMattermostGroupRequireMention,
},
actions: mattermostMessageActions,
message: mattermostMessageAdapter,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
@@ -431,54 +514,5 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
}),
},
security: mattermostSecurityAdapter,
outbound: {
base: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
},
attachedResults: {
channel: "mattermost",
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) =>
await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
replyToId,
threadId,
}) =>
await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
mediaUrl,
mediaLocalRoots,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
},
},
outbound: mattermostOutbound,
});

View File

@@ -128,7 +128,7 @@ vi.mock("./runtime-api.js", async () => {
readStoreForDmPolicy: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })),
})),
createChannelReplyPipeline: vi.fn(() => ({
createChannelMessageReplyPipeline: vi.fn(() => ({
onModelSelected: vi.fn(),
typingCallbacks: {},
})),

View File

@@ -1,4 +1,7 @@
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
import {
defineFinalizableLivePreviewAdapter,
deliverWithFinalizableLivePreviewAdapter,
} from "openclaw/plugin-sdk/channel-message";
import { resolveChannelStreamingPreviewToolProgress } from "openclaw/plugin-sdk/channel-streaming";
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import { isReasoningReplyPayload } from "openclaw/plugin-sdk/reply-payload";
@@ -77,7 +80,7 @@ import {
buildModelsProviderData,
buildPendingHistoryContextFromMap,
createChannelPairingController,
createChannelReplyPipeline,
createChannelMessageReplyPipeline,
DEFAULT_GROUP_HISTORY_LIMIT,
DM_GROUP_ACCESS_REASON,
isDangerousNameMatchingEnabled,
@@ -337,49 +340,51 @@ export async function deliverMattermostReplyWithDraftPreview(
return;
}
await deliverFinalizableDraftPreview({
await deliverWithFinalizableLivePreviewAdapter({
kind: params.info.kind,
payload: params.payload,
draft: {
flush: params.draftStream.flush,
clear: params.draftStream.clear,
discardPending: params.draftStream.discardPending,
seal: params.draftStream.seal,
id: params.draftStream.postId,
},
buildFinalEdit: (payload) => {
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const previewFinalText = params.resolvePreviewFinalText(payload.text);
adapter: defineFinalizableLivePreviewAdapter<ReplyPayload, string, { message: string }>({
draft: {
flush: params.draftStream.flush,
clear: params.draftStream.clear,
discardPending: params.draftStream.discardPending,
seal: params.draftStream.seal,
id: params.draftStream.postId,
},
buildFinalEdit: (payload) => {
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const previewFinalText = params.resolvePreviewFinalText(payload.text);
if (
hasMedia ||
typeof previewFinalText !== "string" ||
payload.isError ||
!canFinalizeMattermostPreviewInPlace({
kind: params.kind,
previewRootId: params.effectiveReplyToId,
threadRootId: params.effectiveReplyToId,
replyToId: payload.replyToId,
})
) {
return undefined;
}
return { message: previewFinalText };
},
editFinal: async (previewPostId, edit) => {
await updateMattermostPost(params.client, previewPostId, edit);
},
if (
hasMedia ||
typeof previewFinalText !== "string" ||
payload.isError ||
!canFinalizeMattermostPreviewInPlace({
kind: params.kind,
previewRootId: params.effectiveReplyToId,
threadRootId: params.effectiveReplyToId,
replyToId: payload.replyToId,
})
) {
return undefined;
}
return { message: previewFinalText };
},
editFinal: async (previewPostId, edit) => {
await updateMattermostPost(params.client, previewPostId, edit);
},
onPreviewFinalized: () => {
params.previewState.finalizedViaPreviewPost = true;
},
logPreviewEditFailure: (err) => {
params.logVerboseMessage(
`mattermost preview final edit failed; falling back to normal send (${String(err)})`,
);
},
}),
deliverNormally: async () => {
await params.deliverFinal();
},
onPreviewFinalized: () => {
params.previewState.finalizedViaPreviewPost = true;
},
logPreviewEditFailure: (err) => {
params.logVerboseMessage(
`mattermost preview final edit failed; falling back to normal send (${String(err)})`,
);
},
});
}
@@ -721,23 +726,24 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
channel: "mattermost",
accountId: account.accountId,
});
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
cfg,
agentId: route.agentId,
channel: "mattermost",
accountId: account.accountId,
typing: {
start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: opts.channelId,
error: err,
});
const { onModelSelected, typingCallbacks, ...replyPipeline } =
createChannelMessageReplyPipeline({
cfg,
agentId: route.agentId,
channel: "mattermost",
accountId: account.accountId,
typing: {
start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: opts.channelId,
error: err,
});
},
},
},
});
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...replyPipeline,
@@ -915,25 +921,26 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
},
);
const shouldDeliverReplies = params.deliverReplies === true;
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
cfg,
agentId: params.route.agentId,
channel: "mattermost",
accountId: account.accountId,
typing: shouldDeliverReplies
? {
start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: params.channelId,
error: err,
});
},
}
: undefined,
});
const { onModelSelected, typingCallbacks, ...replyPipeline } =
createChannelMessageReplyPipeline({
cfg,
agentId: params.route.agentId,
channel: "mattermost",
accountId: account.accountId,
typing: shouldDeliverReplies
? {
start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: params.channelId,
error: err,
});
},
}
: undefined,
});
const capturedTexts: string[] = [];
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
@@ -1635,23 +1642,24 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
});
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
cfg,
agentId: route.agentId,
channel: "mattermost",
accountId: account.accountId,
typing: {
start: () => sendTypingIndicator(channelId, effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: channelId,
error: err,
});
const { onModelSelected, typingCallbacks, ...replyPipeline } =
createChannelMessageReplyPipeline({
cfg,
agentId: route.agentId,
channel: "mattermost",
accountId: account.accountId,
typing: {
start: () => sendTypingIndicator(channelId, effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: channelId,
error: err,
});
},
},
},
});
});
const draftPreviewEnabled = account.streamingMode !== "off";
const draftToolProgressEnabled = shouldUpdateMattermostDraftToolProgress(account);
const suppressDefaultToolProgressMessages =

View File

@@ -19,7 +19,7 @@ export {
resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/channel-policy";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export {
buildModelsProviderData,

View File

@@ -213,14 +213,19 @@ describe("sendMessageMattermost", () => {
throw new Error("Mattermost runtime not initialized");
});
await expect(
sendMessageMattermost("channel:town-square", "hello", {
cfg: providedCfg,
accountId: "work",
}),
).resolves.toEqual({
const result = await sendMessageMattermost("channel:town-square", "hello", {
cfg: providedCfg,
accountId: "work",
});
expect(result).toMatchObject({
messageId: "post-1",
channelId: "town-square",
receipt: {
primaryPlatformMessageId: "post-1",
platformMessageIds: ["post-1"],
parts: [expect.objectContaining({ platformMessageId: "post-1", kind: "text" })],
},
});
expect(mockState.loadConfig).not.toHaveBeenCalled();
});
@@ -487,6 +492,10 @@ describe("sendMessageMattermost user-first resolution", () => {
expect(params.channelId).toBe("dm-channel-id");
expect(res.channelId).toBe("dm-channel-id");
expect(res.messageId).toBe("post-id");
expect(res.receipt).toMatchObject({
primaryPlatformMessageId: "post-id",
platformMessageIds: ["post-id"],
});
});
it("falls back to channel id when user lookup returns 404", async () => {

View File

@@ -1,3 +1,8 @@
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-message";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -49,6 +54,7 @@ export type MattermostSendOpts = {
export type MattermostSendResult = {
messageId: string;
channelId: string;
receipt: MessageReceipt;
};
export type MattermostReplyButtons = Array<
@@ -67,6 +73,39 @@ const dmChannelCache = new Map<string, string>();
const getCore = () => getMattermostRuntime();
function createMattermostSendReceipt(params: {
messageId: string;
channelId: string;
kind: MessageReceiptPartKind;
replyToId?: string;
}): MessageReceipt {
const messageIds =
params.messageId.trim() && params.messageId !== "unknown" ? [params.messageId] : [];
return createMessageReceiptFromOutboundResults({
kind: params.kind,
...(params.replyToId ? { replyToId: params.replyToId } : {}),
results: messageIds.map((messageId) => ({
channel: "mattermost",
messageId,
channelId: params.channelId,
})),
});
}
function resolveMattermostReceiptKind(params: {
fileIds?: readonly string[];
buttons?: readonly unknown[];
props?: Record<string, unknown>;
}): MessageReceiptPartKind {
if (params.fileIds?.length) {
return "media";
}
if (params.buttons?.length || params.props) {
return "card";
}
return "text";
}
function recordMattermostOutboundActivity(accountId: string): void {
try {
getCore().channel.activity.record({
@@ -474,9 +513,20 @@ export async function sendMessageMattermost(
});
recordMattermostOutboundActivity(accountId);
const messageId = post.id ?? "unknown";
return {
messageId: post.id ?? "unknown",
messageId,
channelId,
receipt: createMattermostSendReceipt({
messageId,
channelId,
kind: resolveMattermostReceiptKind({
fileIds,
buttons: opts.buttons,
props,
}),
replyToId: opts.replyToId,
}),
};
}

View File

@@ -53,7 +53,7 @@ const mockState = vi.hoisted(() => ({
vi.mock("./runtime-api.js", () => {
return {
buildModelsProviderData: mockState.buildModelsProviderData,
createChannelReplyPipeline: vi.fn(() => ({
createChannelMessageReplyPipeline: vi.fn(() => ({
onModelSelected: vi.fn(),
typingCallbacks: {},
})),

View File

@@ -30,7 +30,7 @@ import {
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import {
buildModelsProviderData,
createChannelReplyPipeline,
createChannelMessageReplyPipeline,
isRequestBodyLimitError,
logTypingFailure,
readRequestBodyWithLimit,
@@ -837,7 +837,7 @@ async function handleSlashCommandAsync(params: {
accountId: account.accountId,
});
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg,
agentId: route.agentId,
channel: "mattermost",

View File

@@ -18,7 +18,7 @@ export {
clearHistoryEntriesIfEnabled,
createAccountStatusSink,
createChannelPairingController,
createChannelReplyPipeline,
createChannelMessageReplyPipeline,
createDedupeCache,
DEFAULT_ACCOUNT_ID,
DEFAULT_GROUP_HISTORY_LIMIT,

View File

@@ -26,7 +26,7 @@ export {
resolveSenderScopedGroupPolicy,
resolveToolsBySender,
} from "openclaw/plugin-sdk/channel-policy";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
export {
PAIRING_APPROVED_MESSAGE,
buildProbeChannelStatusSummary,

Some files were not shown because too many files have changed in this diff Show More