mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:40:43 +00:00
refactor: migrate bundled plugins to message lifecycle
This commit is contained in:
178
extensions/bluebubbles/src/channel.message-adapter.test.ts
Normal file
178
extensions/bluebubbles/src/channel.message-adapter.test.ts
Normal 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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
161
extensions/discord/src/channel.message-adapter.test.ts
Normal file
161
extensions/discord/src/channel.message-adapter.test.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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!({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
69
extensions/discord/src/send.receipt.ts
Normal file
69
extensions/discord/src/send.receipt.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
39
extensions/googlechat/src/monitor-durable.test.ts
Normal file
39
extensions/googlechat/src/monitor-durable.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
extensions/googlechat/src/monitor-durable.ts
Normal file
23
extensions/googlechat/src/monitor-durable.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<
|
||||
(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
94
extensions/imessage/src/send.test.ts
Normal file
94
extensions/imessage/src/send.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
extensions/irc/src/message-adapter.ts
Normal file
28
extensions/irc/src/message-adapter.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
57
extensions/line/src/monitor-durable.test.ts
Normal file
57
extensions/line/src/monitor-durable.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
37
extensions/line/src/monitor-durable.ts
Normal file
37
extensions/line/src/monitor-durable.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
32
extensions/line/src/send-receipt.ts
Normal file
32
extensions/line/src/send-receipt.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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> & {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
169
extensions/matrix/src/channel.message-adapter.test.ts
Normal file
169
extensions/matrix/src/channel.message-adapter.test.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" })],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
151
extensions/mattermost/src/channel.message-adapter.test.ts
Normal file
151
extensions/mattermost/src/channel.message-adapter.test.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
})),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
})),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,7 +18,7 @@ export {
|
||||
clearHistoryEntriesIfEnabled,
|
||||
createAccountStatusSink,
|
||||
createChannelPairingController,
|
||||
createChannelReplyPipeline,
|
||||
createChannelMessageReplyPipeline,
|
||||
createDedupeCache,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user