mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 22:51:34 +00:00
189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
import {
|
|
createMessageReceiptFromOutboundResults,
|
|
defineChannelMessageAdapter,
|
|
} from "openclaw/plugin-sdk/channel-outbound";
|
|
import type {
|
|
ChannelOutboundAdapter,
|
|
OutboundDeliveryResult,
|
|
} from "openclaw/plugin-sdk/channel-send-result";
|
|
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
|
|
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
|
import { canonicalBytes, REEF_MAX_PLAINTEXT_BYTES } from "../protocol/index.js";
|
|
import { normalizeReefTarget } from "./config-schema.js";
|
|
import { isPermanentReefOutboundRejection, prepareReefMessageId } from "./flow.js";
|
|
import { getActiveReef } from "./runtime.js";
|
|
|
|
const MAX_REEF_BODY_ID = "0".repeat(26);
|
|
|
|
function assertAtomicReefMessageFits(params: {
|
|
text: string;
|
|
threadId?: string | number | null;
|
|
replyToId?: string | null;
|
|
}): void {
|
|
if (
|
|
canonicalBytes({
|
|
text: params.text,
|
|
...(params.threadId != null ? { thread: String(params.threadId) } : {}),
|
|
...(params.replyToId ? { replyTo: params.replyToId } : {}),
|
|
}).length > REEF_MAX_PLAINTEXT_BYTES
|
|
) {
|
|
const cause = new Error("Reef conversation turn exceeds the 32 KiB atomic message limit");
|
|
throw new PlatformMessageNotDispatchedError(cause.message, { cause, retryable: false });
|
|
}
|
|
}
|
|
|
|
function reefChunkFits(text: string, maxBytes: number): boolean {
|
|
// Reserve both optional ids so the same chunk is valid with replies and threads.
|
|
return (
|
|
canonicalBytes({ text, replyTo: MAX_REEF_BODY_ID, thread: MAX_REEF_BODY_ID }).length <= maxBytes
|
|
);
|
|
}
|
|
|
|
function chunkReefText(text: string, limit: number): string[] {
|
|
if (!text) {
|
|
return [];
|
|
}
|
|
const maxBytes = Math.min(Math.max(1, limit), REEF_MAX_PLAINTEXT_BYTES);
|
|
const boundaries = [0];
|
|
for (let offset = 0; offset < text.length;) {
|
|
const codePoint = text.codePointAt(offset);
|
|
offset += codePoint !== undefined && codePoint > 0xffff ? 2 : 1;
|
|
boundaries.push(offset);
|
|
}
|
|
const chunks: string[] = [];
|
|
let startIndex = 0;
|
|
while (startIndex < boundaries.length - 1) {
|
|
const start = boundaries[startIndex] ?? 0;
|
|
if (text.length - start <= maxBytes && reefChunkFits(text.slice(start), maxBytes)) {
|
|
chunks.push(text.slice(start));
|
|
break;
|
|
}
|
|
let low = startIndex + 1;
|
|
// Every complete code point costs at least one canonical byte, so no fitting
|
|
// chunk can extend past this bounded search window.
|
|
let high = Math.min(boundaries.length - 1, startIndex + maxBytes);
|
|
let bestIndex = startIndex;
|
|
while (low <= high) {
|
|
const candidateIndex = Math.floor((low + high) / 2);
|
|
const candidate = boundaries[candidateIndex] ?? start;
|
|
if (reefChunkFits(text.slice(start, candidate), maxBytes)) {
|
|
bestIndex = candidateIndex;
|
|
low = candidateIndex + 1;
|
|
} else {
|
|
high = candidateIndex - 1;
|
|
}
|
|
}
|
|
if (bestIndex === startIndex) {
|
|
throw new Error("Reef message contains an unsplittable plaintext unit");
|
|
}
|
|
const end = boundaries[bestIndex] ?? start;
|
|
chunks.push(text.slice(start, end));
|
|
startIndex = bestIndex;
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
async function send(
|
|
to: string,
|
|
text: string,
|
|
threadId?: string | number | null,
|
|
replyToId?: string | null,
|
|
preparedMessageId?: string,
|
|
onPlatformSendDispatch?: () => Promise<void>,
|
|
): Promise<OutboundDeliveryResult> {
|
|
const peer = normalizeReefTarget(to);
|
|
if (!peer) {
|
|
throw new Error("Reef target must be a handle");
|
|
}
|
|
let platformDispatchMarked = false;
|
|
let id: string;
|
|
try {
|
|
if (preparedMessageId) {
|
|
// Correlated turns cannot be split after reserving one transport id. Check
|
|
// here so response prefixes and other send-time transforms are included.
|
|
assertAtomicReefMessageFits({ text, threadId, replyToId });
|
|
}
|
|
const flow = getActiveReef().flow;
|
|
id = await flow.send(peer, text, {
|
|
...(threadId != null ? { thread: String(threadId) } : {}),
|
|
...(replyToId ? { replyTo: replyToId } : {}),
|
|
...(preparedMessageId ? { messageId: preparedMessageId } : {}),
|
|
onPlatformSendDispatch: async () => {
|
|
await onPlatformSendDispatch?.();
|
|
platformDispatchMarked = true;
|
|
},
|
|
});
|
|
} catch (cause) {
|
|
if (cause instanceof PlatformMessageNotDispatchedError) {
|
|
throw cause;
|
|
}
|
|
if (isPermanentReefOutboundRejection(cause)) {
|
|
throw new PlatformMessageNotDispatchedError(
|
|
cause instanceof Error ? cause.message : String(cause),
|
|
{
|
|
cause,
|
|
retryable: false,
|
|
},
|
|
);
|
|
}
|
|
if (!platformDispatchMarked) {
|
|
throw new PlatformMessageNotDispatchedError(
|
|
cause instanceof Error ? cause.message : String(cause),
|
|
{ cause },
|
|
);
|
|
}
|
|
throw cause;
|
|
}
|
|
return { channel: "reef", messageId: id, chatId: peer, toJid: `reef:${peer}` };
|
|
}
|
|
|
|
export const reefOutboundAdapter: ChannelOutboundAdapter = {
|
|
// The encrypted flow belongs to the Gateway account lifecycle; other processes must delegate.
|
|
deliveryMode: "gateway",
|
|
textChunkLimit: REEF_MAX_PLAINTEXT_BYTES,
|
|
chunker: chunkReefText,
|
|
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),
|
|
prepareConversationTurnMessageId: ({ text, threadId }) => {
|
|
// Runs in Gateway correlation setup before the operation and queue row exist.
|
|
assertAtomicReefMessageFits({ text, threadId });
|
|
return prepareReefMessageId();
|
|
},
|
|
deliveryCapabilities: { durableFinal: { text: true, replyTo: true, thread: true } },
|
|
resolveTarget: ({ to }) => {
|
|
const peer = normalizeReefTarget(to ?? "");
|
|
return peer
|
|
? { ok: true, to: peer }
|
|
: { ok: false, error: new Error("Reef target must be a handle") };
|
|
},
|
|
sendText: async ({ to, text, threadId, replyToId, preparedMessageId, onPlatformSendDispatch }) =>
|
|
await send(to, text, threadId, replyToId, preparedMessageId, onPlatformSendDispatch),
|
|
};
|
|
|
|
export const reefMessageAdapter = defineChannelMessageAdapter({
|
|
id: "reef",
|
|
durableFinal: { capabilities: { text: true, replyTo: true, thread: true } },
|
|
send: {
|
|
text: async (ctx) => {
|
|
const result = await send(
|
|
ctx.to,
|
|
ctx.text,
|
|
ctx.threadId,
|
|
ctx.replyToId,
|
|
ctx.preparedMessageId,
|
|
ctx.onPlatformSendDispatch,
|
|
);
|
|
const receipt = createMessageReceiptFromOutboundResults({
|
|
results: [result],
|
|
kind: "text",
|
|
...(ctx.threadId != null ? { threadId: String(ctx.threadId) } : {}),
|
|
...(ctx.replyToId ? { replyToId: ctx.replyToId } : {}),
|
|
});
|
|
return { receipt, messageId: result.messageId };
|
|
},
|
|
},
|
|
receive: {
|
|
defaultAckPolicy: "after_receive_record",
|
|
supportedAckPolicies: ["after_receive_record"],
|
|
},
|
|
});
|