refactor(plugins): share channel variants and cache operations

Consolidate Feishu message and document paths, Teams Graph and attachment
handling, iMessage cache and parser operations, and voice-call metadata
and cancellation. Preserve transport-specific payloads, ordering, and
error text.
This commit is contained in:
Peter Steinberger committed 2026-10-08 23:25:13 -07:00
1 parent 50d7eb1b7f
commit b70f975fb1
32 files changed
+692 -1050

No files matched your search

+13 -19
View File
@@ -140,26 +140,21 @@ export function normalizeFeishuCommandProbeBody(text: string): string {
.trim();
}
function parseMediaKeys(
function parseMediaResource(
content: string,
messageType: string,
): { imageKey?: string; fileKey?: string; fileName?: string } {
): { key?: string; fileName?: string } {
try {
const parsed = JSON.parse(content);
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
switch (messageType) {
case "image":
return { imageKey, fileName: parsed.file_name };
case "file":
case "audio":
return { fileKey, fileName: parsed.file_name };
case "video":
case "media":
return { fileKey, imageKey, fileName: parsed.file_name };
default:
return {};
}
const key =
messageType === "image"
? imageKey
: messageType === "video" || messageType === "media"
? fileKey || imageKey
: fileKey;
return { key, fileName: parsed.file_name };
} catch {
return {};
}
@@ -231,15 +226,14 @@ export async function resolveFeishuMediaList(params: {
});
}
} else {
const mediaKeys = parseMediaKeys(content, messageType);
const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
if (!fileKey) {
const resource = parseMediaResource(content, messageType);
if (!resource.key) {
return [{ kind: resolveFeishuMediaKind(messageType) }];
}
resources.push({
key: fileKey,
key: resource.key,
type: messageType === "image" ? "image" : "file",
fileName: mediaKeys.fileName,
fileName: resource.fileName,
kind: resolveFeishuMediaKind(messageType),
label: `${messageType} media`,
});
+69 -77
View File
@@ -4,6 +4,7 @@ import {
recordChannelBotPairLoopAndCheckSuppression,
resolveEnvelopeFormatOptions,
toInboundMediaFactsWithMetadata,
type ChannelInboundEventRunnerParams,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveBotThreadMentionPolicy } from "openclaw/plugin-sdk/channel-mention-gating";
import {
@@ -1471,6 +1472,27 @@ export async function handleFeishuMessage(params: {
messageCreateTimeMs,
});
const runAgentTurn = (
ctxPayload: Awaited<ReturnType<typeof buildCtxPayloadForAgent>>,
resolveTurn: ChannelInboundEventRunnerParams<FeishuMessageContext>["adapter"]["resolveTurn"],
) =>
core.channel.inbound.run({
channel: "feishu",
accountId: route.accountId,
raw: ctx,
adapter: {
ingest: () => ({
id: ctx.messageId,
timestamp: messageCreateTimeMs,
rawText: ctx.content,
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: ctx,
}),
resolveTurn,
},
});
if (broadcastAgents) {
// Cross-account dedup: in multi-account setups, Feishu delivers the same
// event to every bot account in the group. Only one account should handle
@@ -1617,43 +1639,28 @@ export async function handleFeishuMessage(params: {
`feishu[${account.accountId}]: broadcast ${dispatcher ? "active" : "observer"} dispatch agent=${agentId} (session=${agentSessionKey})`,
);
const turnResult = await core.channel.inbound.run({
const turnResult = await runAgentTurn(agentCtx, () => ({
cfg,
channel: "feishu",
accountId: route.accountId,
raw: ctx,
adapter: {
ingest: () => ({
id: ctx.messageId,
timestamp: messageCreateTimeMs,
rawText: ctx.content,
textForAgent: agentCtx.BodyForAgent,
textForCommands: agentCtx.CommandBody,
raw: ctx,
}),
resolveTurn: () => ({
cfg,
channel: "feishu",
accountId: route.accountId,
route: { agentId, sessionKey: agentSessionKey },
ctxPayload: agentCtx,
record: agentRecord,
...(!dispatcher
? {
admission: { kind: "observeOnly" as const, reason: "broadcast-observer" },
delivery: { deliver: async () => ({ visibleReplySent: false }) },
replyOptions: bindIngressLifecycleToReplyOptions(lane.lifecycle),
}
: {
dispatcherOptions: dispatcher.dispatcherOptions,
delivery: dispatcher.delivery,
replyOptions: {
...dispatcher.replyOptions,
...bindIngressLifecycleToReplyOptions(lane.lifecycle),
},
}),
}),
},
});
route: { agentId, sessionKey: agentSessionKey },
ctxPayload: agentCtx,
record: agentRecord,
...(!dispatcher
? {
admission: { kind: "observeOnly" as const, reason: "broadcast-observer" },
delivery: { deliver: async () => ({ visibleReplySent: false }) },
replyOptions: bindIngressLifecycleToReplyOptions(lane.lifecycle),
}
: {
dispatcherOptions: dispatcher.dispatcherOptions,
delivery: dispatcher.delivery,
replyOptions: {
...dispatcher.replyOptions,
...bindIngressLifecycleToReplyOptions(lane.lifecycle),
},
}),
}));
if (
dispatcher &&
turnResult.dispatched &&
@@ -1743,53 +1750,38 @@ export async function handleFeishuMessage(params: {
});
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const turnResult = await core.channel.inbound.run({
const turnResult = await runAgentTurn(ctxPayload, () => ({
cfg: effectiveCfg,
channel: "feishu",
accountId: route.accountId,
raw: ctx,
adapter: {
ingest: () => ({
id: ctx.messageId,
timestamp: messageCreateTimeMs,
rawText: ctx.content,
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: ctx,
}),
resolveTurn: () => ({
cfg: effectiveCfg,
channel: "feishu",
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
record: {
updateLastRoute: buildFeishuInboundLastRouteUpdate({
sessionKey: route.sessionKey,
accountId: route.accountId,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
record: {
updateLastRoute: buildFeishuInboundLastRouteUpdate({
sessionKey: route.sessionKey,
accountId: route.accountId,
}),
onRecordError: (err) => {
log(
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
);
},
},
history: {
isGroup,
historyKey,
historyMap: chatHistories,
limit: historyLimit,
},
dispatcherOptions,
delivery,
replyOptions: {
...replyOptions,
...(turnAdoptionLifecycle
? bindIngressLifecycleToReplyOptions(turnAdoptionLifecycle)
: {}),
},
}),
onRecordError: (err) => {
log(
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
);
},
},
});
history: {
isGroup,
historyKey,
historyMap: chatHistories,
limit: historyLimit,
},
dispatcherOptions,
delivery,
replyOptions: {
...replyOptions,
...(turnAdoptionLifecycle
? bindIngressLifecycleToReplyOptions(turnAdoptionLifecycle)
: {}),
},
}));
if (!turnResult.dispatched) {
return;
}
+29 -54
View File
@@ -16,6 +16,7 @@ import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
defineChannelMessageAdapter,
createRuntimeOutboundDelegates,
type ChannelMessageSendTextContext,
} from "openclaw/plugin-sdk/channel-outbound";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
@@ -273,37 +274,41 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
"feishuChannelRuntime",
);
async function resolveFeishuMessageSender<TSender>(params: {
resolve: (
runtime: Awaited<ReturnType<typeof loadFeishuChannelRuntime>>,
) => TSender | null | undefined;
unavailableMessage: string;
}): Promise<TSender> {
async function resolveFeishuMessageSender(kind: "text" | "media") {
const unavailableMessage = `Feishu ${kind} sending is not available.`;
try {
const sender = params.resolve(await loadFeishuChannelRuntime());
const runtime = await loadFeishuChannelRuntime();
const sender = runtime.feishuOutbound[kind === "text" ? "sendText" : "sendMedia"];
if (sender) {
return sender;
}
throw new Error(params.unavailableMessage);
throw new Error(unavailableMessage);
} catch (error) {
if (error instanceof PlatformMessageNotDispatchedError) {
throw error;
}
throw new PlatformMessageNotDispatchedError(params.unavailableMessage, { cause: error });
throw new PlatformMessageNotDispatchedError(unavailableMessage, { cause: error });
}
}
const resolveFeishuTextSender = () =>
resolveFeishuMessageSender({
resolve: (runtime) => runtime.feishuOutbound.sendText,
unavailableMessage: "Feishu text sending is not available.",
});
const resolveFeishuMediaSender = () =>
resolveFeishuMessageSender({
resolve: (runtime) => runtime.feishuOutbound.sendMedia,
unavailableMessage: "Feishu media sending is not available.",
async function sendFeishuAdapterMessage(
kind: "text" | "media",
ctx: ChannelMessageSendTextContext,
) {
const send = await resolveFeishuMessageSender(kind);
const { onDeliveryResult, ...outboundCtx } = ctx;
const result = await send({
...outboundCtx,
...(onDeliveryResult
? {
onDeliveryResult: async (progress) => {
await onDeliveryResult(toFeishuMessageSendResult(progress, kind));
},
}
: {}),
});
return toFeishuMessageSendResult(result, kind);
}
const feishuMessageAdapter = defineChannelMessageAdapter({
id: "feishu",
@@ -318,43 +323,13 @@ const feishuMessageAdapter = defineChannelMessageAdapter({
// Resolve process-stable runtime methods before core records platform-send start.
// Provider invocation stays below so a lost provider result remains ambiguous.
beforeSendAttempt: async (ctx) => {
if (ctx.kind === "text") {
await resolveFeishuTextSender();
} else if (ctx.kind === "media") {
await resolveFeishuMediaSender();
if (ctx.kind === "text" || ctx.kind === "media") {
await resolveFeishuMessageSender(ctx.kind);
}
},
},
text: async (ctx) => {
const sendText = await resolveFeishuTextSender();
const { onDeliveryResult, ...outboundCtx } = ctx;
const result = await sendText({
...outboundCtx,
...(onDeliveryResult
? {
onDeliveryResult: async (progress) => {
await onDeliveryResult(toFeishuMessageSendResult(progress, "text"));
},
}
: {}),
});
return toFeishuMessageSendResult(result, "text");
},
media: async (ctx) => {
const sendMedia = await resolveFeishuMediaSender();
const { onDeliveryResult, ...outboundCtx } = ctx;
const result = await sendMedia({
...outboundCtx,
...(onDeliveryResult
? {
onDeliveryResult: async (progress) => {
await onDeliveryResult(toFeishuMessageSendResult(progress, "media"));
},
}
: {}),
});
return toFeishuMessageSendResult(result, "media");
},
text: (ctx) => sendFeishuAdapterMessage("text", ctx),
media: (ctx) => sendFeishuAdapterMessage("media", ctx),
},
});
@@ -1290,7 +1265,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
});
} else {
const { target, ...delivery } = await (
await resolveFeishuTextSender()
await resolveFeishuMessageSender("text")
)(outboundContext);
result = { ...delivery, chatId: target?.id };
}
+22 -28
View File
@@ -401,6 +401,21 @@ function createFeishuHttpInstance(
});
}
const createMethod =
(method: "get" | "delete" | "head" | "options"): Lark.HttpInstance["get"] =>
(url, opts) =>
runRequest((authority) =>
dispatch(authority, opts, (prepared) => base[method](resolveRequestUrl(url), prepared)),
);
const createDataMethod =
(method: "post" | "put" | "patch"): Lark.HttpInstance["post"] =>
(url, data, opts) =>
runRequest((authority) =>
dispatch(authority, opts, (prepared) =>
base[method](resolveRequestUrl(url), data, prepared),
),
);
return {
request: (opts) =>
// SDK message requests reach this seam after formatPayload/auth. Token
@@ -412,34 +427,13 @@ function createFeishuHttpInstance(
),
"request",
),
get: (url, opts) =>
runRequest((assert) =>
dispatch(assert, opts, (prepared) => base.get(resolveRequestUrl(url), prepared)),
),
post: (url, data, opts) =>
runRequest((assert) =>
dispatch(assert, opts, (prepared) => base.post(resolveRequestUrl(url), data, prepared)),
),
put: (url, data, opts) =>
runRequest((assert) =>
dispatch(assert, opts, (prepared) => base.put(resolveRequestUrl(url), data, prepared)),
),
patch: (url, data, opts) =>
runRequest((assert) =>
dispatch(assert, opts, (prepared) => base.patch(resolveRequestUrl(url), data, prepared)),
),
delete: (url, opts) =>
runRequest((assert) =>
dispatch(assert, opts, (prepared) => base.delete(resolveRequestUrl(url), prepared)),
),
head: (url, opts) =>
runRequest((assert) =>
dispatch(assert, opts, (prepared) => base.head(resolveRequestUrl(url), prepared)),
),
options: (url, opts) =>
runRequest((assert) =>
dispatch(assert, opts, (prepared) => base.options(resolveRequestUrl(url), prepared)),
),
get: createMethod("get"),
post: createDataMethod("post"),
put: createDataMethod("put"),
patch: createDataMethod("patch"),
delete: createMethod("delete"),
head: createMethod("head"),
options: createMethod("options"),
};
}
+4 -14
View File
@@ -282,27 +282,17 @@ function parseCommentLinkedDocumentPath(pathname: string): {
return { urlKind: kind, token };
}
function hasResolvedLinkedDocumentReference(link: ParsedCommentLinkedDocument): boolean {
return (
link.urlKind !== "unknown" && (Boolean(link.resolvedObjToken) || Boolean(link.wikiNodeToken))
);
}
function resolveCommentLinkedDocumentFromUrl(params: {
rawUrl: string;
currentDocument?: ParsedCommentDocumentRef;
}): ParsedCommentLinkedDocument {
const link: ParsedCommentLinkedDocument = {
rawUrl: params.rawUrl,
urlKind: "unknown",
};
}): ParsedCommentLinkedDocument | undefined {
const parsed = URL.parse(params.rawUrl);
const parsedPath = parsed && parseCommentLinkedDocumentPath(parsed.pathname);
if (!parsedPath) {
return link;
return undefined;
}
const { urlKind, token } = parsedPath;
link.urlKind = urlKind;
const link: ParsedCommentLinkedDocument = { rawUrl: params.rawUrl, urlKind };
if (urlKind === "wiki") {
link.wikiNodeToken = token;
} else {
@@ -366,7 +356,7 @@ export function parseCommentContentElements(params: {
rawUrl,
currentDocument: params.currentDocument,
});
if (hasResolvedLinkedDocumentReference(linkedDocument)) {
if (linkedDocument) {
const key = [
linkedDocument.rawUrl,
linkedDocument.urlKind,
+22 -33
View File
@@ -63,33 +63,20 @@ function splitSourceAtOffsets(source: string, offsets: number[]): string[] {
return chunks;
}
function headingOffsets(source: string, root: FeishuMarkdownNode): number[] {
function blockStartOffsets(
root: FeishuMarkdownNode,
include: (node: FeishuMarkdownNode, offset: number) => boolean = () => true,
): number[] {
const offsets: number[] = [];
for (const node of root.children ?? []) {
if (node.type !== "heading" || node.depth === undefined || node.depth > 2) {
continue;
}
const offset = node.position?.start.offset;
// Preserve the existing ATX-only chunking contract; setext headings were
// never request boundaries and may depend on nearby reference definitions.
if (offset !== undefined && source[offset] === "#") {
if (offset !== undefined && include(node, offset)) {
offsets.push(offset);
}
}
return offsets;
}
function blockBreakOffsets(root: FeishuMarkdownNode): number[] {
const offsets: number[] = [];
for (const block of root.children ?? []) {
const blockStart = block.position?.start.offset;
if (blockStart !== undefined && blockStart > 0) {
offsets.push(blockStart);
}
}
return offsets;
}
function paragraphBreakOffsets(source: string, root: FeishuMarkdownNode): number[] {
const offsets: number[] = [];
for (const block of root.children ?? []) {
@@ -132,20 +119,13 @@ function nearestOffset(offsets: readonly number[], target: number, sourceLength:
.toSorted((left, right) => Math.abs(left - target) - Math.abs(right - target) || left - right);
}
function isStableParagraphBreak(source: string, offset: number): boolean {
const before = parseFeishuMarkdown(source.slice(0, offset)).children?.at(-1);
const after = parseFeishuMarkdown(source.slice(offset)).children?.[0];
return before?.type === "paragraph" && after?.type === "paragraph";
}
function isStableContainerBreak(source: string, offset: number, containerType: string): boolean {
function isStableBreak(source: string, offset: number, blockType: string): boolean {
const before = parseFeishuMarkdown(source.slice(0, offset)).children;
const after = parseFeishuMarkdown(source.slice(offset)).children;
return (
before?.length === 1 &&
after?.length === 1 &&
before[0]?.type === containerType &&
after[0]?.type === containerType
(blockType === "paragraph" || (before?.length === 1 && after?.length === 1)) &&
before?.at(-1)?.type === blockType &&
after?.[0]?.type === blockType
);
}
@@ -195,10 +175,19 @@ export function createDocxMarkdownChunk(markdown: string): DocxMarkdownChunk {
export function createDocxMarkdownPlan(markdown: string): DocxMarkdownPlan {
const root = parseFeishuMarkdown(markdown);
// Only ATX headings are request boundaries; setext headings can depend on nearby definitions.
const headingOffsets = blockStartOffsets(
root,
(node, offset) =>
node.type === "heading" &&
node.depth !== undefined &&
node.depth <= 2 &&
markdown[offset] === "#",
);
return {
// Reparse split chunks so references cannot cross independent request boundaries.
// The unchanged whole input can reuse its already parsed tree.
chunks: splitSourceAtOffsets(markdown, headingOffsets(markdown, root)).map((chunk) =>
chunks: splitSourceAtOffsets(markdown, headingOffsets).map((chunk) =>
chunk === markdown
? { markdown, images: collectMarkdownImages(root) }
: createDocxMarkdownChunk(chunk),
@@ -213,7 +202,7 @@ export function splitDocxMarkdownBySize(markdown: string, maxChars: number): str
const root = parseFeishuMarkdown(markdown);
const target = Math.min(markdown.length - 1, Math.max(1, maxChars));
const splitOffset = nearestOffset(blockBreakOffsets(root), target, markdown.length)[0];
const splitOffset = nearestOffset(blockStartOffsets(root), target, markdown.length)[0];
if (splitOffset !== undefined) {
return [markdown.slice(0, splitOffset), markdown.slice(splitOffset)];
}
@@ -227,7 +216,7 @@ export function splitDocxMarkdownBySize(markdown: string, maxChars: number): str
)
// Keep reparsing bounded for hostile, whitespace-heavy input.
.slice(0, MAX_BREAK_PROBES)
.find((offset) => isStableParagraphBreak(markdown, offset));
.find((offset) => isStableBreak(markdown, offset, "paragraph"));
if (paragraphOffset !== undefined) {
return [markdown.slice(0, paragraphOffset), markdown.slice(paragraphOffset)];
}
@@ -250,7 +239,7 @@ export function splitDocxMarkdownBySize(markdown: string, maxChars: number): str
if (containerType && STABLE_LINE_CONTAINER_TYPES.has(containerType)) {
const containerOffset = nearestOffset(lineBreakOffsets(markdown), target, markdown.length)
.slice(0, MAX_BREAK_PROBES)
.find((offset) => isStableContainerBreak(markdown, offset, containerType));
.find((offset) => isStableBreak(markdown, offset, containerType));
if (containerOffset !== undefined) {
return [markdown.slice(0, containerOffset), markdown.slice(containerOffset)];
}
+82 -101
View File
@@ -208,54 +208,47 @@ async function insertBlocks(
return allInserted;
}
async function convertMarkdownWithFallback(
type ConvertedMarkdown = {
blocks: FeishuDocxBlock[];
firstLevelBlockIds: string[];
images: DocxMarkdownImage[];
};
/** Convert markdown in chunks to avoid document.convert content size limits. */
async function chunkedConvertMarkdown(
client: Lark.Client,
chunk: DocxMarkdownChunk,
chunks: readonly DocxMarkdownChunk[],
depth = 0,
) {
try {
return { ...(await convertMarkdown(client, chunk.markdown)), images: chunk.images };
} catch (error) {
if (depth >= MAX_CONVERT_RETRY_DEPTH || chunk.markdown.length < 2) {
throw error;
}
const splitTarget = Math.max(256, Math.floor(chunk.markdown.length / 2));
const chunks = splitDocxMarkdownBySize(chunk.markdown, splitTarget).map(
createDocxMarkdownChunk,
);
if (chunks.length <= 1) {
throw error;
}
const blocks: FeishuDocxBlock[] = [];
const firstLevelBlockIds: string[] = [];
const images: DocxMarkdownImage[] = [];
for (const fallbackChunk of chunks) {
const converted = await convertMarkdownWithFallback(client, fallbackChunk, depth + 1);
blocks.push(...converted.blocks);
firstLevelBlockIds.push(...converted.firstLevelBlockIds);
images.push(...converted.images);
}
return { blocks, firstLevelBlockIds, images };
}
}
/** Convert markdown in chunks to avoid document.convert content size limits */
async function chunkedConvertMarkdown(client: Lark.Client, chunks: readonly DocxMarkdownChunk[]) {
const allBlocks: FeishuDocxBlock[] = [];
const allRootIds: string[] = [];
const allImages: DocxMarkdownImage[] = [];
): Promise<ConvertedMarkdown> {
const result: ConvertedMarkdown = { blocks: [], firstLevelBlockIds: [], images: [] };
for (const chunk of chunks) {
const { blocks, firstLevelBlockIds, images } = await convertMarkdownWithFallback(client, chunk);
const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
allBlocks.push(...orderedBlocks);
allRootIds.push(...rootIds);
allImages.push(...images);
let converted: ConvertedMarkdown;
try {
converted = { ...(await convertMarkdown(client, chunk.markdown)), images: chunk.images };
} catch (error) {
if (depth >= MAX_CONVERT_RETRY_DEPTH || chunk.markdown.length < 2) {
throw error;
}
const splitTarget = Math.max(256, Math.floor(chunk.markdown.length / 2));
const fallbackChunks = splitDocxMarkdownBySize(chunk.markdown, splitTarget).map(
createDocxMarkdownChunk,
);
if (fallbackChunks.length <= 1) {
throw error;
}
converted = await chunkedConvertMarkdown(client, fallbackChunks, depth + 1);
}
const { blocks, firstLevelBlockIds, images } = converted;
// Normalize once per original chunk, after combining its fallback conversions.
const { orderedBlocks, rootIds } =
depth === 0
? normalizeConvertedBlockTree(blocks, firstLevelBlockIds)
: { orderedBlocks: blocks, rootIds: firstLevelBlockIds };
result.blocks.push(...orderedBlocks);
result.firstLevelBlockIds.push(...rootIds);
result.images.push(...images);
}
return { blocks: allBlocks, firstLevelBlockIds: allRootIds, images: allImages };
return result;
}
type Logger = { info?: (msg: string) => void };
@@ -310,23 +303,25 @@ async function clearDocumentContent(client: Lark.Client, docToken: string) {
return childIds.length;
}
async function uploadImageToDocx(
type DocxUpload = Awaited<ReturnType<typeof resolveDocxUploadInput>>;
async function uploadDocxMedia(
client: Lark.Client,
blockId: string,
imageBuffer: Buffer,
fileName: string,
docToken: string,
kind: "image" | "file",
parentNode: string,
upload: DocxUpload,
docToken?: string,
): Promise<string> {
const res = await client.drive.media.uploadAll({
data: {
file_name: fileName,
parent_type: "docx_image",
parent_node: blockId,
size: imageBuffer.length,
file_name: upload.fileName,
parent_type: kind === "image" ? "docx_image" : "docx_file",
parent_node: parentNode,
size: upload.buffer.length,
// Pass Buffer directly so form-data can calculate Content-Length correctly.
// Readable.from() produces a stream with unknown length, causing Content-Length
// mismatch that silently truncates uploads for images larger than ~1KB.
file: imageBuffer,
file: upload.buffer,
// Required when the document block belongs to a non-default datacenter:
// tells the drive service which document the block belongs to for routing.
// Per API docs: certain upload scenarios require the cloud document token.
@@ -336,8 +331,18 @@ async function uploadImageToDocx(
const fileToken = res?.file_token;
if (!fileToken) {
throw new Error("Image upload failed: no file_token returned");
throw new Error(`${kind === "image" ? "Image" : "File"} upload failed: no file_token returned`);
}
return fileToken;
}
async function uploadImageToDocx(
client: Lark.Client,
blockId: string,
upload: DocxUpload,
docToken: string,
): Promise<string> {
const fileToken = await uploadDocxMedia(client, "image", blockId, upload, docToken);
const patchRes = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { replace_image: { token: fileToken } },
@@ -374,7 +379,7 @@ async function processImages(
maxBytes,
remoteReadTimeoutMs: imageReadTimeoutMs,
});
await uploadImageToDocx(client, blockId, upload.buffer, upload.fileName, docToken);
await uploadImageToDocx(client, blockId, upload, docToken);
processed++;
} catch (err) {
@@ -460,7 +465,7 @@ async function uploadImageBlock(
parent_block_id: parentBlockId,
index,
}: Extract<FeishuDocParams, { action: "upload_image" }>,
upload: Awaited<ReturnType<typeof resolveDocxUploadInput>>,
upload: DocxUpload,
) {
// Create an empty image block (block_type 27).
// Per Feishu FAQ: image token cannot be set at block creation time.
@@ -477,13 +482,7 @@ async function uploadImageBlock(
throw new Error("Failed to create image block");
}
const fileToken = await uploadImageToDocx(
client,
imageBlockId,
upload.buffer,
upload.fileName,
docToken, // drive_route_token for multi-datacenter routing
);
const fileToken = await uploadImageToDocx(client, imageBlockId, upload, docToken);
return {
success: true,
@@ -500,7 +499,7 @@ async function uploadFileBlock(
doc_token: docToken,
parent_block_id: parentBlockId,
}: Extract<FeishuDocParams, { action: "upload_file" }>,
upload: Awaited<ReturnType<typeof resolveDocxUploadInput>>,
upload: DocxUpload,
) {
const blockId = parentBlockId ?? docToken;
@@ -521,20 +520,7 @@ async function uploadFileBlock(
const parentId = placeholderBlock.parent_id ?? blockId;
await deleteChildBlock(client, docToken, parentId, placeholderBlock.block_id);
const fileRes = await client.drive.media.uploadAll({
data: {
file_name: upload.fileName,
parent_type: "docx_file",
parent_node: docToken,
size: upload.buffer.length,
file: upload.buffer,
},
});
const fileToken = fileRes?.file_token;
if (!fileToken) {
throw new Error("File upload failed: no file_token returned");
}
const fileToken = await uploadDocxMedia(client, "file", docToken, upload);
return {
success: true,
@@ -603,28 +589,23 @@ async function createDoc(
const requesterPermType = "edit" as const;
let requesterPermissionAdded = false;
let requesterPermissionSkippedReason: string | undefined;
let requesterPermissionError: string | undefined;
if (shouldGrantToRequester) {
if (!requesterOpenId) {
requesterPermissionSkippedReason = "trusted requester identity unavailable";
} else {
try {
const permissionRes = await client.drive.permissionMember.create({
path: { token: docToken },
params: { type: "docx", need_notification: false },
data: {
member_type: "openid",
member_id: requesterOpenId,
perm: requesterPermType,
},
});
assertFeishuApiSuccess(permissionRes);
requesterPermissionAdded = true;
} catch (err) {
requesterPermissionError = formatErrorMessage(err);
}
if (shouldGrantToRequester && requesterOpenId) {
try {
const permissionRes = await client.drive.permissionMember.create({
path: { token: docToken },
params: { type: "docx", need_notification: false },
data: {
member_type: "openid",
member_id: requesterOpenId,
perm: requesterPermType,
},
});
assertFeishuApiSuccess(permissionRes);
requesterPermissionAdded = true;
} catch (err) {
requesterPermissionError = formatErrorMessage(err);
}
}
@@ -636,8 +617,8 @@ async function createDoc(
requester_permission_added: requesterPermissionAdded,
...(requesterOpenId && { requester_open_id: requesterOpenId }),
requester_perm_type: requesterPermType,
...(requesterPermissionSkippedReason && {
requester_permission_skipped_reason: requesterPermissionSkippedReason,
...(!requesterOpenId && {
requester_permission_skipped_reason: "trusted requester identity unavailable",
}),
...(requesterPermissionError && { requester_permission_error: requesterPermissionError }),
}),
+23 -60
View File
@@ -264,8 +264,7 @@ function registerEventHandlers(
eventDispatcher: Lark.EventDispatcher,
context: RegisterEventHandlersContext,
): void {
const { cfg, accountId, channelRuntime, runtime, chatHistories, fireAndForget, abortSignal } =
context;
const { cfg, accountId, channelRuntime, runtime, chatHistories, fireAndForget } = context;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
@@ -311,24 +310,31 @@ function registerEventHandlers(
},
});
const handleBotMembership = async (data: unknown, action: "added" | "removed") => {
try {
const chatId =
action === "added"
? parseFeishuBotAddedEventPayload(data)?.chat_id
: parseFeishuBotRemovedChatId(data);
if (chatId) {
log(
`feishu[${accountId}]: bot ${action === "added" ? "added to" : "removed from"} chat ${chatId}`,
);
}
} catch (err) {
error(`feishu[${accountId}]: error handling bot ${action} event: ${String(err)}`);
}
};
eventDispatcher.register({
"im.message.receive_v1": createFeishuMessageReceiveHandler({
cfg,
channelRuntime,
accountId,
runtime,
chatHistories,
fireAndForget,
isAccountActive: context.isAccountActive,
trackTask: context.trackTask,
...context,
handleMessage: handleFeishuMessage,
resolveDebounceText: ({ event, botOpenId }) =>
parseFeishuMessageEvent(event, botOpenId).content,
hasProcessedMessage: hasProcessedFeishuMessage,
getBotOpenId: (id) => botOpenIds.get(id),
resolveSequentialKey: getFeishuSequentialKey,
resolveIngressLifecycle: context.resolveIngressLifecycle,
...(context.statusSink ? { statusSink: context.statusSink } : {}),
}),
"im.message.message_read_v1": async () => {
// Ignore read receipts
@@ -336,59 +342,16 @@ function registerEventHandlers(
"im.chat.access_event.bot_p2p_chat_entered_v1": async () => {
// Ignore p2p chat entry notifications — no action needed
},
"im.chat.member.bot.added_v1": async (data) => {
try {
const event = parseFeishuBotAddedEventPayload(data);
if (!event) {
return;
}
log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
} catch (err) {
error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
}
},
"im.chat.member.bot.deleted_v1": async (data) => {
try {
const chatId = parseFeishuBotRemovedChatId(data);
if (!chatId) {
return;
}
log(`feishu[${accountId}]: bot removed from chat ${chatId}`);
} catch (err) {
error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
}
},
"drive.notice.comment_add_v1": createFeishuDriveCommentNoticeHandler({
cfg,
accountId,
runtime,
fireAndForget,
abortSignal,
resolveIngressLifecycle: context.resolveIngressLifecycle,
}),
"im.chat.member.bot.added_v1": (data) => handleBotMembership(data, "added"),
"im.chat.member.bot.deleted_v1": (data) => handleBotMembership(data, "removed"),
"drive.notice.comment_add_v1": createFeishuDriveCommentNoticeHandler(context),
"vc.bot.meeting_invited_v1": createFeishuVcMeetingInvitedHandler({
cfg,
accountId,
runtime,
fireAndForget,
channelRuntime,
...context,
autoJoin: context.vcAutoJoin,
abortSignal: context.abortSignal,
isAccountActive: context.isAccountActive,
trackTask: context.trackTask,
}),
"im.message.reaction.created_v1": (data) => handleReaction(data, "created"),
"im.message.reaction.deleted_v1": (data) => handleReaction(data, "deleted"),
"application.bot.menu_v6": createFeishuBotMenuHandler({
cfg,
accountId,
runtime,
chatHistories,
fireAndForget,
isAccountActive: context.isAccountActive,
trackTask: context.trackTask,
channelRuntime,
}),
"application.bot.menu_v6": createFeishuBotMenuHandler(context),
"card.action.trigger": async (data: unknown) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling card action`,
+45 -59
View File
@@ -287,72 +287,58 @@ type DriveCommentPageRequest = {
accountId: string;
};
function fetchDriveComments(params: DriveCommentPageRequest & { isWholeOnly?: boolean }) {
const errorLabel = `feishu[${params.accountId}]: failed to list drive comments for ${params.fileToken}`;
return fetchDriveCommentPages({
logger: params.logger,
errorLabel,
fetchPage: (pageToken) =>
requestFeishuOpenApi<FeishuDriveCommentPage<FeishuDriveCommentCard>>({
client: params.client,
method: "GET",
url:
`/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments` +
encodeQuery({
file_type: params.fileType,
is_whole: params.isWholeOnly === true ? "true" : undefined,
page_size: String(FEISHU_COMMENT_PAGE_SIZE),
page_token: pageToken,
user_id_type: "open_id",
}),
timeoutMs: params.timeoutMs,
logger: params.logger,
errorLabel,
}),
});
}
type DriveCommentPageTarget = { kind: "whole-comments" } | { kind: "replies"; commentId: string };
function fetchDriveCommentReplies(params: DriveCommentPageRequest & { commentId: string }) {
const errorLabel = `feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}`;
return fetchDriveCommentPages({
logger: params.logger,
errorLabel,
fetchPage: (pageToken) =>
requestFeishuOpenApi<FeishuDriveCommentPage<FeishuDriveCommentReply>>({
client: params.client,
method: "GET",
url:
`/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments/${encodeURIComponent(params.commentId)}/replies` +
encodeQuery({
file_type: params.fileType,
page_token: pageToken,
page_size: String(FEISHU_COMMENT_PAGE_SIZE),
user_id_type: "open_id",
}),
timeoutMs: params.timeoutMs,
logger: params.logger,
errorLabel,
}),
});
}
type DriveCommentPageItem = {
"whole-comments": FeishuDriveCommentCard;
replies: FeishuDriveCommentReply;
};
async function fetchDriveCommentPages<T>(params: {
fetchPage: (pageToken?: string) => Promise<FeishuDriveCommentPage<T> | null>;
logger?: (message: string) => void;
errorLabel: string;
}): Promise<{ items: T[]; logIds: string[] }> {
const items: T[] = [];
async function fetchDriveCommentPages<T extends DriveCommentPageTarget>(
params: DriveCommentPageRequest & T,
): Promise<{ items: DriveCommentPageItem[T["kind"]][]; logIds: string[] }> {
const errorLabel =
params.kind === "whole-comments"
? `feishu[${params.accountId}]: failed to list drive comments for ${params.fileToken}`
: `feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}`;
const items: DriveCommentPageItem[T["kind"]][] = [];
const logIds: string[] = [];
let pageToken: string | undefined;
for (let page = 0; page < FEISHU_COMMENT_PAGE_LIMIT; page += 1) {
const response = await params.fetchPage(pageToken);
const commentsUrl = `/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments`;
const url =
params.kind === "whole-comments"
? commentsUrl
: `${commentsUrl}/${encodeURIComponent(params.commentId)}/replies`;
const query = {
file_type: params.fileType,
...(params.kind === "whole-comments"
? {
is_whole: "true",
page_size: String(FEISHU_COMMENT_PAGE_SIZE),
page_token: pageToken,
}
: {
page_token: pageToken,
page_size: String(FEISHU_COMMENT_PAGE_SIZE),
}),
user_id_type: "open_id",
};
const response = await requestFeishuOpenApi<
FeishuDriveCommentPage<DriveCommentPageItem[T["kind"]]>
>({
...params,
method: "GET",
url: url + encodeQuery(query),
errorLabel,
});
if (response?.log_id?.trim()) {
logIds.push(response.log_id.trim());
}
if (response?.code !== 0) {
if (response) {
params.logger?.(
`${params.errorLabel}: ${response.msg ?? "unknown error"} log_id=${response.log_id?.trim() || "unknown"}`,
`${errorLabel}: ${response.msg ?? "unknown error"} log_id=${response.log_id?.trim() || "unknown"}`,
);
}
break;
@@ -608,7 +594,7 @@ async function fetchDriveCommentContext(
`embedded_hit=${embeddedTargetReply ? "yes" : "no"} ` +
`embedded_has_more=${commentCard?.has_more === true ? "yes" : "no"}`,
);
const fetched = await fetchDriveCommentReplies(params);
const fetched = await fetchDriveCommentPages({ ...params, kind: "replies" });
if (fetched.items.length > 0) {
params.logger?.(
`feishu[${params.accountId}]: fetched extra comment replies comment=${params.commentId} ` +
@@ -638,7 +624,7 @@ async function fetchDriveCommentContext(
if (!delayElapsed) {
break;
}
const retried = await fetchDriveCommentReplies(params);
const retried = await fetchDriveCommentPages({ ...params, kind: "replies" });
if (retried.items.length > 0) {
params.logger?.(
`feishu[${params.accountId}]: fetched retried comment replies comment=${params.commentId} ` +
@@ -720,9 +706,9 @@ async function fetchDriveCommentContext(
let wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[] = [];
if (commentCard?.is_whole === true) {
const allComments = await fetchDriveComments({
const allComments = await fetchDriveCommentPages({
...params,
isWholeOnly: true,
kind: "whole-comments",
});
const wholeComments = allComments.items.filter((comment) => comment.is_whole === true);
wholeCommentTimeline = await Promise.all(
@@ -102,20 +102,16 @@ function dedupeFeishuDebounceEntriesByDedupeKey(
entries: FeishuMessageDebounceEntry[],
): FeishuMessageDebounceEntry[] {
const seen = new Set<string>();
const deduped: FeishuMessageDebounceEntry[] = [];
for (const entry of entries) {
const dedupeKey = entry.messageDedupeKey;
if (!dedupeKey) {
deduped.push(entry);
continue;
return entries.filter(({ messageDedupeKey }) => {
if (!messageDedupeKey) {
return true;
}
if (seen.has(dedupeKey)) {
continue;
if (seen.has(messageDedupeKey)) {
return false;
}
seen.add(dedupeKey);
deduped.push(entry);
}
return deduped;
seen.add(messageDedupeKey);
return true;
});
}
function resolveFeishuDebounceMentions(params: {
@@ -179,9 +175,7 @@ export function createFeishuMessageReceiveHandler({
});
const dispatchFeishuMessage = async (
event: FeishuMessageEvent,
messageDedupeKey?: string,
processingClaim?: FeishuMessageProcessingClaim,
{ event, messageDedupeKey, processingClaim }: FeishuMessageDebounceEntry,
turnAdoptionLifecycle?: FeishuIngressLifecycle,
preparedContent?: string,
) => {
@@ -290,12 +284,7 @@ export function createFeishuMessageReceiveHandler({
}
try {
if (activeEntries.length === 1) {
await dispatchFeishuMessage(
last.event,
last.messageDedupeKey,
last.processingClaim,
admissionLifecycle,
);
await dispatchFeishuMessage(last, admissionLifecycle);
await settle();
return;
}
@@ -325,14 +314,15 @@ export function createFeishuMessageReceiveHandler({
});
await dispatchFeishuMessage(
{
...dispatchEntry.event,
message: {
...dispatchEntry.event.message,
mentions: mergedMentions ?? dispatchEntry.event.message.mentions,
...dispatchEntry,
event: {
...dispatchEntry.event,
message: {
...dispatchEntry.event.message,
mentions: mergedMentions ?? dispatchEntry.event.message.mentions,
},
},
},
dispatchDedupeKey,
dispatchEntry.processingClaim,
admissionLifecycle,
combinedText,
);
+3 -10
View File
@@ -132,10 +132,7 @@ export function isFeishuGroupReadEnabled(
account: ResolvedFeishuAccount,
chatId: string,
): boolean {
if (resolveFeishuReadGroupPolicy(cfg, account) === "disabled") {
return false;
}
return resolveFeishuGroupConfig({ cfg: account.config, groupId: chatId })?.enabled !== false;
return isFeishuGroupReadAllowed(cfg, account, chatId, true);
}
export function canEnumerateAllFeishuPeers(account: ResolvedFeishuAccount): boolean {
@@ -213,20 +210,16 @@ export async function readFeishuChatInfoWithAuthorization<
} catch (error) {
if (params.preliminary.decision === "needs-metadata") {
assertFeishuChatReadAllowed({
cfg: params.cfg,
account: params.account,
...params,
chatId: params.preliminary.chatId,
ctx: params.ctx,
});
}
throw error;
}
assertFeishuChatReadAllowed({
cfg: params.cfg,
account: params.account,
...params,
chatId: params.preliminary.chatId,
chatType: resolveFeishuChatType(chat),
ctx: params.ctx,
});
return chat;
}
+11 -19
View File
@@ -167,25 +167,17 @@ async function runScanToCreate(
tp: SCAN_TO_CREATE_TP,
});
let message: string;
switch (outcome.status) {
case "success":
message = t("wizard.feishu.scanCompleted");
break;
case "access_denied":
message = t("wizard.feishu.scanDenied");
break;
case "expired":
message = t("wizard.feishu.scanExpired");
break;
case "timeout":
message = t("wizard.feishu.scanTimedOut");
break;
case "error":
message = t("wizard.feishu.scanError", { error: outcome.message });
break;
}
progress.stop(message);
const statusMessages = {
success: "wizard.feishu.scanCompleted",
access_denied: "wizard.feishu.scanDenied",
expired: "wizard.feishu.scanExpired",
timeout: "wizard.feishu.scanTimedOut",
};
progress.stop(
outcome.status === "error"
? t("wizard.feishu.scanError", { error: outcome.message })
: t(statusMessages[outcome.status]),
);
return outcome.status === "success" ? outcome.result : null;
}
+13 -21
View File
@@ -116,30 +116,22 @@ function resolveFeishuDeliveryOrigin(params: {
} {
const deliveryTo = params.deliveryTo?.trim();
const deliveryThreadId = params.deliveryThreadId?.trim();
if (deliveryTo) {
return {
channel: "feishu",
accountId: params.accountId,
to: deliveryTo,
...(deliveryThreadId ? { threadId: deliveryThreadId } : {}),
};
}
const parsed = parseFeishuConversationId({
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
if (parsed?.topicId) {
return {
channel: "feishu",
accountId: params.accountId,
to: `chat:${params.parentConversationId?.trim() || parsed.chatId}`,
threadId: parsed.topicId,
};
}
const parsed = deliveryTo
? null
: parseFeishuConversationId({
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
const threadId = deliveryTo ? deliveryThreadId : parsed?.topicId;
return {
channel: "feishu",
accountId: params.accountId,
to: `user:${params.conversationId}`,
to:
deliveryTo ||
(parsed?.topicId
? `chat:${params.parentConversationId?.trim() || parsed.chatId}`
: `user:${params.conversationId}`),
...(threadId ? { threadId } : {}),
};
}
+9 -17
View File
@@ -2,7 +2,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { wrapExternalContent } from "openclaw/plugin-sdk/security-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { jsonResult } from "openclaw/plugin-sdk/tool-results";
import { formatFeishuApiError } from "./comment-shared.js";
import { extractFeishuApiErrorMeta } from "./comment-shared.js";
export function feishuExternalToolResult<TDetails>(details: TDetails) {
// Only model-visible text is fenced; structured callers retain the exact remote payload.
@@ -29,23 +29,15 @@ export function toolExecutionErrorResult(error: unknown) {
const response = isRecord(error) && isRecord(error.response) ? error.response : undefined;
const data = isRecord(response?.data) ? response.data : undefined;
if (data) {
const nestedError = isRecord(data.error) ? data.error : undefined;
const meta = extractFeishuApiErrorMeta({ message, response });
message = formatErrorMessage(
formatFeishuApiError(
{
message,
response: {
status: response?.status,
data: {
code: data.code,
msg: data.msg,
log_id: data.log_id,
error: { log_id: nestedError?.log_id },
},
},
},
{ includeNestedErrorLogId: true },
),
JSON.stringify({
message,
http_status: meta.httpStatus,
feishu_code: meta.feishuCode,
feishu_msg: meta.feishuMsg,
feishu_log_id: meta.feishuLogId || meta.nestedErrorLogId,
}),
);
}
return feishuExternalToolResult({ error: message });
+25 -47
View File
@@ -147,31 +147,24 @@ function resolveIMessageAccountSourceSignature(account: ResolvedIMessageAccount)
return JSON.stringify([cliPath, localDbPath ? path.resolve(localDbPath) : "", ""]);
}
function resolveIMessageAccountSourceOwner(params: {
cfg: OpenClawConfig;
signature: string;
}): string | undefined {
// Prefer an explicit named account over the implicit "default" so that
// bindings tied to the named account keep working (openclaw/openclaw#65141).
let defaultOwner: string | undefined;
for (const candidateAccountId of listIMessageAccountIds(params.cfg)) {
const candidate = resolveIMessageAccount({
cfg: params.cfg,
accountId: candidateAccountId,
});
if (!candidate.enabled || !candidate.configured) {
function groupIMessageAccountsBySource(cfg: OpenClawConfig) {
const groups = new Map<string, ResolvedIMessageAccount[]>();
for (const accountId of listIMessageAccountIds(cfg)) {
const account = resolveIMessageAccount({ cfg, accountId });
if (!account.enabled || !account.configured) {
continue;
}
if (resolveIMessageAccountSourceSignature(candidate) !== params.signature) {
continue;
}
if (candidate.accountId === DEFAULT_ACCOUNT_ID) {
defaultOwner ??= candidate.accountId;
continue;
}
return candidate.accountId;
const signature = resolveIMessageAccountSourceSignature(account);
const accounts = groups.get(signature) ?? [];
accounts.push(account);
groups.set(signature, accounts);
}
return defaultOwner;
return groups;
}
function resolveIMessageAccountSourceOwner(accounts: readonly ResolvedIMessageAccount[]) {
// Named accounts precede the implicit default so their bindings remain usable.
return accounts.find((account) => account.accountId !== DEFAULT_ACCOUNT_ID) ?? accounts[0];
}
function resolveIMessageDatabaseFileIdentity(dbPath: string): string | undefined {
@@ -198,11 +191,11 @@ export function resolveIMessageDuplicateSourceOwner(params: {
if (!params.account.enabled || !params.account.configured) {
return undefined;
}
const owner = resolveIMessageAccountSourceOwner({
cfg: params.cfg,
signature: resolveIMessageAccountSourceSignature(params.account),
});
return owner && owner !== params.account.accountId ? owner : undefined;
const signature = resolveIMessageAccountSourceSignature(params.account);
const owner = resolveIMessageAccountSourceOwner(
groupIMessageAccountsBySource(params.cfg).get(signature) ?? [],
);
return owner && owner.accountId !== params.account.accountId ? owner.accountId : undefined;
}
export function listEnabledIMessageAccounts(cfg: OpenClawConfig): ResolvedIMessageAccount[] {
@@ -264,31 +257,16 @@ export function hasExclusiveIMessageLocalDatabase(params: {
export function collectIMessageDuplicateAccountSourceWarnings(params: {
cfg: OpenClawConfig;
}): string[] {
const groups = new Map<string, ResolvedIMessageAccount[]>();
for (const accountId of listIMessageAccountIds(params.cfg)) {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
if (!account.enabled || !account.configured) {
continue;
}
const signature = resolveIMessageAccountSourceSignature(account);
const existing = groups.get(signature);
if (existing) {
existing.push(account);
} else {
groups.set(signature, [account]);
}
}
const groups = groupIMessageAccountsBySource(params.cfg);
const warnings: string[] = [];
for (const collisions of groups.values()) {
if (collisions.length < 2) {
continue;
}
const firstCollision = expectDefined(collisions[0], "duplicate iMessage account source");
const ownerId = resolveIMessageAccountSourceOwner({
cfg: params.cfg,
signature: resolveIMessageAccountSourceSignature(firstCollision),
});
const owner = collisions.find((a) => a.accountId === ownerId) ?? firstCollision;
const owner = expectDefined(
resolveIMessageAccountSourceOwner(collisions),
"duplicate iMessage account source",
);
const duplicates = collisions.filter((a) => a.accountId !== owner.accountId);
const dupIds = duplicates.map((a) => `"${a.accountId}"`).join(", ");
const cliPath = normalizeIMessageCliPath(owner.config.cliPath);
@@ -33,14 +33,9 @@ function beginIMessageApprovalControlBinding(params: {
}): { close: () => void } {
const keys = bindingKeys(params.accountId, params.conversation);
const { promise, resolve } = createDeferred<void>();
let closed = false;
const window: BindingWindow = {
done: promise,
close: () => {
if (closed) {
return;
}
closed = true;
for (const key of keys) {
const windows = pendingByConversation.get(key);
windows?.delete(window);
+20 -24
View File
@@ -384,21 +384,17 @@ async function lookupPollRecord<T>(
* decision is security-relevant, and diagnosing "the tap did nothing" must not
* require re-running the gateway in debug.
*/
function info(message: string, fields: Record<string, unknown>): void {
function logPollVote(
level: "info" | "warn",
message: string,
fields: Record<string, unknown>,
): void {
try {
getOptionalIMessageRuntime()
?.logging.getChildLogger({ plugin: "imessage", feature: "approval-polls" })
.info(message, fields);
} catch {
// Logger surface is optional in tests; never let logging mask the outcome.
}
}
function warn(message: string, fields: Record<string, unknown>): void {
try {
getOptionalIMessageRuntime()
?.logging.getChildLogger({ plugin: "imessage", feature: "approval-polls" })
.warn(message, fields);
const logger = getOptionalIMessageRuntime()?.logging.getChildLogger({
plugin: "imessage",
feature: "approval-polls",
});
logger?.[level](message, fields);
} catch {
// Logger surface is optional in tests; never let logging mask the outcome.
}
@@ -434,7 +430,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
}
if (event.malformedVotes) {
warn("approval poll vote ignored: malformed complete vote set", {
logPollVote("warn", "approval poll vote ignored: malformed complete vote set", {
approvalId: target.approvalId,
actorHandle: event.actorHandle,
});
@@ -451,7 +447,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
? event.votes
: [];
if (actorVotes.length === 0) {
warn("approval poll vote participants did not identify the transport actor", {
logPollVote("warn", "approval poll vote participants did not identify the transport actor", {
approvalId: target.approvalId,
actorHandle: event.actorHandle,
});
@@ -461,7 +457,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
// An un-vote is owned but never resolves: it must not emit "removed their
// vote" prose while the approval is still pending.
if (selectedVotes.length === 0) {
info("approval poll deselect ignored; first selection decides", {
logPollVote("info", "approval poll deselect ignored; first selection decides", {
approvalId: target.approvalId,
});
return true;
@@ -471,7 +467,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
decision: target.optionDecisions.find(([optionId]) => optionId === vote.optionId)?.[1],
}));
if (selectedDecisions.some((entry) => !entry.decision)) {
warn("approval poll vote ignored: selected option not bound to a decision", {
logPollVote("warn", "approval poll vote ignored: selected option not bound to a decision", {
approvalId: target.approvalId,
optionIds: selectedDecisions.map((entry) => entry.optionId),
});
@@ -479,7 +475,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
}
const decisions = [...new Set(selectedDecisions.map((entry) => entry.decision))];
if (decisions.length !== 1) {
warn("approval poll vote ignored: ambiguous selected decisions", {
logPollVote("warn", "approval poll vote ignored: ambiguous selected decisions", {
approvalId: target.approvalId,
decisions,
});
@@ -490,7 +486,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
return true;
}
if (getIMessageApprovalApprovers({ cfg: params.cfg, accountId: params.accountId }).length === 0) {
info("approval poll vote denied: no explicit approvers configured", {
logPollVote("info", "approval poll vote denied: no explicit approvers configured", {
approvalId: target.approvalId,
});
return true;
@@ -503,7 +499,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
approvalKind: target.approvalKind,
});
if (!auth.authorized) {
info("approval poll vote denied: sender not an approver", {
logPollVote("info", "approval poll vote denied: sender not an approver", {
approvalId: target.approvalId,
actorHandle: event.actorHandle,
});
@@ -528,7 +524,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
optionDecisions: target.optionDecisions,
approvalId: target.approvalId,
});
info(`approval poll vote ${result.applied ? "resolved" : "already resolved"}`, {
logPollVote("info", `approval poll vote ${result.applied ? "resolved" : "already resolved"}`, {
approvalId: target.approvalId,
actorHandle: event.actorHandle,
decision,
@@ -541,14 +537,14 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
optionDecisions: target.optionDecisions,
approvalId: target.approvalId,
});
info("approval poll vote ignored: approval already gone", {
logPollVote("info", "approval poll vote ignored: approval already gone", {
approvalId: target.approvalId,
});
return true;
}
// Keep the binding on a transient gateway/network failure so a retry can
// still land; only terminal and not-found outcomes clear it.
warn("approval poll vote failed", {
logPollVote("warn", "approval poll vote failed", {
approvalId: target.approvalId,
senderId: event.actorHandle,
error: String(error),
+6 -16
View File
@@ -356,20 +356,6 @@ export async function unregisterIMessageApprovalReactionTarget(params: {
]);
}
function resolveTarget(params: {
target: IMessageApprovalReactionTarget | null | undefined;
reactionKey: string;
}): IMessageApprovalReactionResolution | null {
const target = resolveTypedApprovalReactionTarget(params);
return target
? {
approvalId: target.approvalId,
approvalKind: target.approvalKind,
decision: target.decision,
}
: null;
}
function formatCanonicalApprovalTerminalState(approval: ApprovalResolveResult["approval"]): string {
const decision =
approval.status === "allowed" || approval.status === "denied"
@@ -390,12 +376,16 @@ export async function resolveIMessageApprovalReactionTargetWithPersistence(param
// (chat_guid → chat_identifier → chat_id → handle) and accept the first hit.
const keys = enumerateApprovalTargetKeys(params);
for (const key of keys) {
const target = resolveTarget({
const target = resolveTypedApprovalReactionTarget({
target: await imessageApprovalReactionTargets.lookup(key),
reactionKey: params.reactionKey,
});
if (target) {
return target;
return {
approvalId: target.approvalId,
approvalKind: target.approvalKind,
decision: target.decision,
};
}
}
return null;
+11 -19
View File
@@ -153,7 +153,6 @@ export class IMessageRpcClient {
private terminalResolve: ((error: Error) => void) | null = null;
private readonly reaped: Promise<void>;
private reapedResolve: (() => void) | null = null;
private isReaped = false;
private child: ChildProcessWithoutNullStreams | null = null;
private stopPromise: Promise<void> | null = null;
private readonly stdoutFramer = createLfLineFramer((line) => this.handleStdoutLine(line));
@@ -194,19 +193,16 @@ export class IMessageRpcClient {
});
this.child = child;
child.stdout.on("data", (chunk) => {
if (this.child !== child) {
return;
}
this.stdoutFramer.write(chunk);
});
child.stderr?.on("data", (chunk) => {
if (this.child !== child) {
return;
}
this.stderrFramer.write(chunk);
});
for (const [stream, framer] of [
[child.stdout, this.stdoutFramer],
[child.stderr, this.stderrFramer],
] as const) {
stream.on("data", (chunk) => {
if (this.child === child) {
framer.write(chunk);
}
});
}
// Every process/stdio error is terminal for this RPC transport. Settle the
// client once and terminate a helper whose pipe failed; otherwise the
@@ -356,7 +352,7 @@ export class IMessageRpcClient {
}
private async waitForReap(timeoutMs: number): Promise<boolean> {
if (this.isReaped) {
if (!this.reapedResolve) {
return true;
}
return await raceWithTimeout(
@@ -492,10 +488,6 @@ export class IMessageRpcClient {
}
private markReaped(): void {
if (this.isReaped) {
return;
}
this.isReaped = true;
const resolve = this.reapedResolve;
this.reapedResolve = null;
resolve?.();
+15 -21
View File
@@ -207,28 +207,22 @@ export async function rememberIMessageReplyCache(
const cutoff = Date.now() - IMESSAGE_REPLY_CACHE_TTL_MS;
const deletedMessageIds: string[] = [];
for (const [key, value] of imessageReplyCacheByMessageId) {
if (value.timestamp >= cutoff) {
break;
const pruneUntil = (retain: (key: string, cached: IMessageReplyCacheEntry) => boolean) => {
for (const [key, cached] of imessageReplyCacheByMessageId) {
if (retain(key, cached)) {
break;
}
imessageReplyCacheByMessageId.delete(key);
deletedMessageIds.push(key);
if (cached.shortId) {
imessageShortIdToUuid.delete(cached.shortId);
}
}
imessageReplyCacheByMessageId.delete(key);
deletedMessageIds.push(key);
if (value.shortId) {
imessageShortIdToUuid.delete(value.shortId);
}
}
while (imessageReplyCacheByMessageId.size > IMESSAGE_REPLY_CACHE_MAX_ENTRIES) {
const oldest = imessageReplyCacheByMessageId.keys().next().value;
if (!oldest) {
break;
}
const oldEntry = imessageReplyCacheByMessageId.get(oldest);
imessageReplyCacheByMessageId.delete(oldest);
deletedMessageIds.push(oldest);
if (oldEntry?.shortId) {
imessageShortIdToUuid.delete(oldEntry.shortId);
}
}
};
pruneUntil((_key, cached) => cached.timestamp >= cutoff);
pruneUntil(
(key) => !key || imessageReplyCacheByMessageId.size <= IMESSAGE_REPLY_CACHE_MAX_ENTRIES,
);
const counter = imessageShortIdCounter;
// Publish memory without yielding, then persist each admitted mutation in order.
+20 -29
View File
@@ -73,47 +73,38 @@ export function createSentMessageCache() {
) {
return true;
}
const matchesWithin = (cache: Map<string, number>, key: string | undefined, ttlMs: number) => {
const timestamp = key ? cache.get(`${scope}:${key}`) : undefined;
return Boolean(timestamp && Date.now() - timestamp <= ttlMs);
};
const hasContentOnlyMatch = (
cache: Map<string, number>,
backedById: Map<string, number>,
key: string | undefined,
) => {
const timestamp = key ? cache.get(`${scope}:${key}`) : undefined;
const idTimestamp = key ? backedById.get(`${scope}:${key}`) : undefined;
return typeof timestamp === "number" && (!idTimestamp || timestamp > idTimestamp);
};
const textKey = normalizeIMessageEchoText(lookup.text);
const mediaKey = resolveIMessageEchoMediaKey(lookup.media);
const messageIdKey = normalizeIMessageMessageId(lookup.messageId);
let canUseMediaFallback = !messageIdKey;
if (messageIdKey) {
const idTimestamp = messageIdCache.get(`${scope}:${messageIdKey}`);
if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) {
if (matchesWithin(messageIdCache, messageIdKey, SENT_MESSAGE_ID_TTL_MS)) {
return true;
}
const textTimestamp = textKey ? textCache.get(`${scope}:${textKey}`) : undefined;
const textBackedByIdTimestamp = textKey
? textBackedByIdCache.get(`${scope}:${textKey}`)
: undefined;
const hasTextOnlyMatch =
typeof textTimestamp === "number" &&
(!textBackedByIdTimestamp || textTimestamp > textBackedByIdTimestamp);
const mediaTimestamp = mediaKey ? mediaCache.get(`${scope}:${mediaKey}`) : undefined;
const mediaBackedByIdTimestamp = mediaKey
? mediaBackedByIdCache.get(`${scope}:${mediaKey}`)
: undefined;
const hasMediaOnlyMatch =
typeof mediaTimestamp === "number" &&
(!mediaBackedByIdTimestamp || mediaTimestamp > mediaBackedByIdTimestamp);
const hasTextOnlyMatch = hasContentOnlyMatch(textCache, textBackedByIdCache, textKey);
const hasMediaOnlyMatch = hasContentOnlyMatch(mediaCache, mediaBackedByIdCache, mediaKey);
canUseMediaFallback = hasMediaOnlyMatch;
if (!resolvedOptions.skipIdShortCircuit && !hasTextOnlyMatch && !hasMediaOnlyMatch) {
return false;
}
}
if (textKey) {
const textTimestamp = textCache.get(`${scope}:${textKey}`);
if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
return true;
}
}
if (mediaKey && canUseMediaFallback) {
const mediaTimestamp = mediaCache.get(`${scope}:${mediaKey}`);
if (mediaTimestamp && Date.now() - mediaTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
return true;
}
}
return false;
return (
matchesWithin(textCache, textKey, SENT_MESSAGE_TEXT_TTL_MS) ||
(canUseMediaFallback && matchesWithin(mediaCache, mediaKey, SENT_MESSAGE_TEXT_TTL_MS))
);
}
function cleanup(): void {
@@ -120,6 +120,39 @@ function assertSafeIMessageOutboundMarkup(text: string): void {
}
}
function findIMessageMarkupEnd(
text: string,
start: number,
kind: "instruction" | "declaration" | "private",
contentOffset = 2,
): number {
const terminator = kind === "instruction" ? "?>" : ">";
let quote: "'" | '"' | undefined;
let bracketDepth = 0;
for (let index = start + contentOffset; index <= text.length - terminator.length; index += 1) {
if (kind === "private" && index - start > MAX_PRIVATE_MARKDOWN_UNWRAP_PASSES ** 2) {
throw new Error("iMessage outbound runtime scaffolding exceeded its limit");
}
const character = text[index];
if (quote) {
if (character === quote) {
quote = undefined;
}
} else if (character === "'" || character === '"') {
quote = character;
} else if (kind === "declaration" && character === "[") {
bracketDepth += 1;
} else if (kind === "declaration" && character === "]" && bracketDepth > 0) {
bracketDepth -= 1;
} else if (kind === "private" && character === "<") {
throw new Error("iMessage outbound runtime scaffolding is malformed");
} else if (bracketDepth === 0 && text.startsWith(terminator, index)) {
return index + terminator.length;
}
}
return -1;
}
function stripIMessageBalancedPrivateRuntimeBlocks(
text: string,
verifyProtectedRoles?: (visible: string) => void,
@@ -185,7 +218,7 @@ function stripIMessageBalancedPrivateRuntimeBlocks(
continue;
}
let end = -1;
let end: number;
let delimiterLength = 0;
if (text.startsWith("<!--", start)) {
end = text.indexOf("-->", start + 4);
@@ -194,42 +227,9 @@ function stripIMessageBalancedPrivateRuntimeBlocks(
end = text.indexOf("]]>", start + 9);
delimiterLength = 3;
} else if (text.startsWith("<?", start)) {
let quote: "'" | '"' | undefined;
for (let index = start + 2; index < text.length - 1; index += 1) {
const character = text[index];
if (quote) {
if (character === quote) {
quote = undefined;
}
} else if (character === "'" || character === '"') {
quote = character;
} else if (character === "?" && text[index + 1] === ">") {
end = index;
delimiterLength = 2;
break;
}
}
end = findIMessageMarkupEnd(text, start, "instruction");
} else if (/^<![a-z][a-z0-9_.:-]*\b/i.test(text.slice(start))) {
let quote: "'" | '"' | undefined;
let bracketDepth = 0;
for (let index = start + 2; index < text.length; index += 1) {
const character = text[index];
if (quote) {
if (character === quote) {
quote = undefined;
}
} else if (character === "'" || character === '"') {
quote = character;
} else if (character === "[") {
bracketDepth += 1;
} else if (character === "]" && bracketDepth > 0) {
bracketDepth -= 1;
} else if (character === ">" && bracketDepth === 0) {
end = index;
delimiterLength = 1;
break;
}
}
end = findIMessageMarkupEnd(text, start, "declaration");
} else if (
text.startsWith("<!", start) ||
(text.startsWith("</", start) &&
@@ -289,29 +289,10 @@ function stripIMessageBalancedPrivateRuntimeBlocks(
throw new Error("iMessage outbound runtime scaffolding exceeded its limit");
}
let quote: "'" | '"' | undefined;
let end = start + token[0].length;
for (; end < text.length; end += 1) {
if (end - start > MAX_PRIVATE_MARKDOWN_UNWRAP_PASSES ** 2) {
throw new Error("iMessage outbound runtime scaffolding exceeded its limit");
}
const character = text[end];
if (quote) {
if (character === quote) {
quote = undefined;
}
} else if (character === "'" || character === '"') {
quote = character;
} else if (character === "<") {
throw new Error("iMessage outbound runtime scaffolding is malformed");
} else if (character === ">") {
break;
}
}
if (end >= text.length || quote) {
const end = findIMessageMarkupEnd(text, start, "private", token[0].length);
if (end < 0) {
throw new Error("iMessage outbound runtime scaffolding is malformed");
}
end += 1;
if (enclosingBlock?.codeRegion && enclosingBlock.codeRegion === codeRegion) {
if (!enclosingBlock.nestedCodeRegions) {
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createSelfChatCache } from "./self-chat-cache.js";
type SelfChatCacheDebug = {
insertionOrder: Map<number, string>;
insertionOrder: { readonly size: number };
};
describe("createSelfChatCache", () => {
@@ -56,10 +56,9 @@ function buildScope(parts: SelfChatCacheKeyParts): string {
}
class DefaultSelfChatCache implements SelfChatCache {
private cache = new Map<string, Map<number, SelfChatCacheEntry>>();
private insertionOrder = new Map<number, string>();
private cache = new Map<string, Set<SelfChatCacheEntry>>();
private insertionOrder = new Map<SelfChatCacheEntry, string>();
private lastCleanupAt = 0;
private nextEntryId = 1;
private buildBucketKey(lookup: SelfChatLookup): string | null {
const text = normalizeText(lookup.text);
@@ -74,16 +73,15 @@ class DefaultSelfChatCache implements SelfChatCache {
if (!key || !isUsableTimestamp(lookup.createdAt)) {
return;
}
const entries = this.cache.get(key) ?? new Map<number, SelfChatCacheEntry>();
const entries = this.cache.get(key) ?? new Set<SelfChatCacheEntry>();
const entry = {
createdAt: lookup.createdAt,
createdAtSkewToleranceMs: lookup.allowCreatedAtSkew ? SELF_CHAT_CREATED_AT_TOLERANCE_MS : 0,
rememberedAt: Date.now(),
};
const id = this.nextEntryId++;
entries.set(id, entry);
entries.add(entry);
this.cache.set(key, entries);
this.insertionOrder.set(id, key);
this.insertionOrder.set(entry, key);
this.maybeCleanup();
}
@@ -108,37 +106,32 @@ class DefaultSelfChatCache implements SelfChatCache {
});
}
private removeEntry(entry: SelfChatCacheEntry, key: string): void {
this.insertionOrder.delete(entry);
const entries = this.cache.get(key);
entries?.delete(entry);
if (entries?.size === 0) {
this.cache.delete(key);
}
}
private maybeCleanup(): void {
const now = Date.now();
if (now - this.lastCleanupAt < CLEANUP_MIN_INTERVAL_MS) {
return;
}
this.lastCleanupAt = now;
for (const [key, entries] of this.cache.entries()) {
for (const [id, entry] of entries.entries()) {
if (now - entry.rememberedAt > SELF_CHAT_TTL_MS) {
entries.delete(id);
this.insertionOrder.delete(id);
}
}
if (entries.size === 0) {
this.cache.delete(key);
for (const [entry, key] of this.insertionOrder) {
if (now - entry.rememberedAt > SELF_CHAT_TTL_MS) {
this.removeEntry(entry, key);
}
}
while (this.insertionOrder.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
const [id, key] = expectDefined(
const [entry, key] = expectDefined(
this.insertionOrder.entries().next().value,
"oldest iMessage self-chat cache entry",
);
this.insertionOrder.delete(id);
const entries = this.cache.get(key);
if (!entries) {
continue;
}
entries.delete(id);
if (entries.size === 0) {
this.cache.delete(key);
}
this.removeEntry(entry, key);
}
}
}
+30 -55
View File
@@ -52,13 +52,6 @@ type DownloadCandidate =
}
| { kind: "unavailable"; mediaKind: MSTeamsInboundMedia["kind"]; sourceId?: string };
function withSourceId(
media: MSTeamsInboundMedia,
sourceId: string | undefined,
): MSTeamsInboundMedia {
return sourceId ? { ...media, sourceId } : media;
}
function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate | null {
const contentType = normalizeContentType(att.contentType);
const name = normalizeOptionalString(att.name) ?? "";
@@ -192,14 +185,16 @@ async function fetchWithAuthFallback(params: {
policy: MSTeamsAttachmentFetchPolicy;
deadline?: MSTeamsRequestDeadline;
}): Promise<Response> {
const firstAttempt = await safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn: params.fetchFn,
requestInit: params.requestInit,
resolveFn: params.resolveFn,
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
});
const fetchAttempt = (fetchFn: typeof fetch | undefined, requestInit?: RequestInit) =>
safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn,
requestInit,
resolveFn: params.resolveFn,
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
});
const firstAttempt = await fetchAttempt(params.fetchFn, params.requestInit);
if (firstAttempt.ok) {
return firstAttempt;
}
@@ -225,16 +220,9 @@ async function fetchWithAuthFallback(params: {
});
const authHeaders = new Headers(params.requestInit?.headers);
authHeaders.set("Authorization", `Bearer ${token}`);
const authAttempt = await safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
resolveFn: params.resolveFn,
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
const authAttempt = await fetchAttempt(fetchFn, {
...params.requestInit,
headers: authHeaders,
});
await fallbackAttempt.body?.cancel().catch(() => undefined);
if (authAttempt.ok || isRedirectStatus(authAttempt.status)) {
@@ -293,14 +281,6 @@ export async function downloadMSTeamsAttachments(params: {
const maxInlineBytes = params.maxBytes;
candidates.push(
...extractInlineImageReferences(list).map((candidate): DownloadCandidate => {
if (candidate.kind === "data") {
return {
kind: "data",
mediaKind: "image",
src: candidate.src,
sourceId: candidate.sourceId,
};
}
if (candidate.kind === "url") {
return {
kind: "remote",
@@ -311,7 +291,7 @@ export async function downloadMSTeamsAttachments(params: {
sourceId: candidate.sourceId,
};
}
return { kind: "unavailable", mediaKind: "image", sourceId: candidate.sourceId };
return Object.assign(candidate, { mediaKind: "image" as const });
}),
);
for (const sourceId of resolveUnrepresentedHtmlAttachmentIds(list)) {
@@ -325,26 +305,24 @@ export async function downloadMSTeamsAttachments(params: {
return [];
}
const out: MSTeamsInboundMedia[] = [];
let totalInlineBytes = 0;
for (const candidate of candidates) {
const downloadCandidate = async (
candidate: DownloadCandidate,
): Promise<MSTeamsInboundMedia | undefined> => {
if (candidate.kind === "unavailable") {
out.push(withSourceId({ kind: candidate.mediaKind }, candidate.sourceId));
continue;
return undefined;
}
if (candidate.kind === "data") {
const decoded = decodeInlineDataImage(candidate.src, maxInlineBytes, totalInlineBytes);
if (!decoded) {
out.push(withSourceId({ kind: candidate.mediaKind }, candidate.sourceId));
continue;
return undefined;
}
// Accepted bytes still consume the message budget when MIME detection or saving fails.
totalInlineBytes += decoded.estimatedBytes;
try {
const contentType = await resolveInlineDataImageMime(decoded);
if (!contentType) {
out.push(withSourceId({ kind: candidate.mediaKind }, candidate.sourceId));
continue;
return undefined;
}
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
decoded.data,
@@ -352,26 +330,19 @@ export async function downloadMSTeamsAttachments(params: {
"inbound",
params.maxBytes,
);
out.push(
withSourceId(
{ path: saved.path, contentType: saved.contentType, kind: "image" },
candidate.sourceId,
),
);
return { path: saved.path, contentType: saved.contentType, kind: "image" };
} catch (err) {
out.push(withSourceId({ kind: candidate.mediaKind }, candidate.sourceId));
params.logger?.warn?.("msteams inline attachment decode failed", {
error: coerceErrorMessage(err),
});
}
continue;
return undefined;
}
if (!isUrlAllowed(candidate.url, allowHosts)) {
out.push(withSourceId({ kind: candidate.mediaKind }, candidate.sourceId));
continue;
return undefined;
}
try {
const media = await downloadAndStoreMSTeamsRemoteMedia({
return await downloadAndStoreMSTeamsRemoteMedia({
url: candidate.url,
filePathHint: candidate.fileHint ?? candidate.url,
maxBytes: params.maxBytes,
@@ -391,14 +362,18 @@ export async function downloadMSTeamsAttachments(params: {
deadline: params.deadline,
}),
});
out.push(withSourceId(media, candidate.sourceId));
} catch (err) {
out.push(withSourceId({ kind: candidate.mediaKind }, candidate.sourceId));
const msg = coerceErrorMessage(err);
params.logger?.warn?.(
`msteams attachment download failed host=${safeHostForLog(candidate.url)} error=${msg}`,
);
}
return undefined;
};
const out: MSTeamsInboundMedia[] = [];
for (const candidate of candidates) {
const media = (await downloadCandidate(candidate)) ?? { kind: candidate.mediaKind };
out.push(candidate.sourceId ? { ...media, sourceId: candidate.sourceId } : media);
}
return out;
}
+23 -32
View File
@@ -253,6 +253,19 @@ export async function downloadMSTeamsGraphMedia(params: {
});
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
const messageUrl = params.messageUrl;
const logGraphFailure = (
phase: "token acquisition" | "message parse" | "message fetch",
err: unknown,
) => {
params.logger?.debug?.(`graph media ${phase} failed`, {
messageUrl,
error: coerceErrorMessage(err),
});
params.logger?.warn?.(`msteams graph ${phase} failed`, {
error: coerceErrorMessage(err),
...(phase === "message parse" ? { messageUrl } : {}),
});
};
let accessToken: string;
try {
accessToken = await withMSTeamsRequestDeadline({
@@ -261,13 +274,7 @@ export async function downloadMSTeamsGraphMedia(params: {
work: () => tokenProvider.getAccessToken("https://graph.microsoft.com"),
});
} catch (err) {
params.logger?.debug?.("graph media token acquisition failed", {
messageUrl,
error: coerceErrorMessage(err),
});
params.logger?.warn?.("msteams graph token acquisition failed", {
error: coerceErrorMessage(err),
});
logGraphFailure("token acquisition", err);
return { media: [], messageUrl, tokenError: true };
}
@@ -295,14 +302,7 @@ export async function downloadMSTeamsGraphMedia(params: {
"MS Teams Graph message",
);
} catch (err) {
params.logger?.debug?.("graph media message parse failed", {
messageUrl,
error: coerceErrorMessage(err),
});
params.logger?.warn?.("msteams graph message parse failed", {
error: coerceErrorMessage(err),
messageUrl,
});
logGraphFailure("message parse", err);
msgData = {};
}
messageAttachments = Array.isArray(msgData.attachments) ? msgData.attachments : [];
@@ -319,13 +319,7 @@ export async function downloadMSTeamsGraphMedia(params: {
},
);
} catch (err) {
params.logger?.debug?.("graph media message fetch failed", {
messageUrl,
error: coerceErrorMessage(err),
});
params.logger?.warn?.("msteams graph message fetch failed", {
error: coerceErrorMessage(err),
});
logGraphFailure("message fetch", err);
}
// The message response owns a pinned dispatcher. Release it before nested
@@ -411,16 +405,13 @@ export async function downloadMSTeamsGraphMedia(params: {
const normalizedAttachments = messageAttachments.map(normalizeGraphAttachment);
const filteredAttachments =
sharePointMedia.length > 0
? normalizedAttachments.filter((att) => {
if (att.contentType !== "reference") {
return true;
}
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
if (!url) {
return true;
}
return !downloadedReferenceUrls.has(url);
})
? normalizedAttachments.filter(
(att) =>
att.contentType !== "reference" ||
typeof att.contentUrl !== "string" ||
!att.contentUrl ||
!downloadedReferenceUrls.has(att.contentUrl),
)
: normalizedAttachments;
let attachmentMedia: MSTeamsInboundMedia[] = [];
try {
+35 -44
View File
@@ -96,10 +96,33 @@ type MSTeamsMessageTarget = {
messageId: string;
};
export async function getMessageMSTeams(params: MSTeamsMessageTarget) {
const token = await resolveGraphToken(params.cfg);
async function resolveGraphMessageContext(
params: Pick<MSTeamsMessageTarget, "cfg" | "to">,
options?: { preferDelegated?: boolean },
) {
const token = await resolveGraphToken(params.cfg, options);
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
return { token, conversationId, ...resolveConversationPath(conversationId) };
}
async function resolveGraphPinContext(
params: Pick<MSTeamsMessageTarget, "cfg" | "to">,
operation: "modify" | "list",
) {
const context = await resolveGraphMessageContext(params);
if (context.kind === "channel") {
throw new Error(
(operation === "list"
? "Listing pinned messages is not supported for channels on Graph v1.0. "
: "Pin/unpin is not supported for channel messages on Graph v1.0. ") +
"Only chat conversations support pinned messages.",
);
}
return context;
}
export async function getMessageMSTeams(params: MSTeamsMessageTarget) {
const { token, basePath } = await resolveGraphMessageContext(params);
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`;
const msg = await fetchGraphJson<GraphMessage>({ token, path });
return {
@@ -113,16 +136,7 @@ export async function getMessageMSTeams(params: MSTeamsMessageTarget) {
export async function pinMessageMSTeams(
params: MSTeamsMessageTarget,
): Promise<{ ok: true; pinnedMessageId?: string }> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
if (conv.kind === "channel") {
throw new Error(
"Pin/unpin is not supported for channel messages on Graph v1.0. " +
"Only chat conversations support pinned messages.",
);
}
const { token, conversationId, basePath } = await resolveGraphPinContext(params, "modify");
// Graph API expects message@odata.bind with the full message resource URI
const body = {
@@ -130,7 +144,7 @@ export async function pinMessageMSTeams(
};
const result = await mutateGraphJson<{ id?: string }>({
token,
path: `${conv.basePath}/pinnedMessages`,
path: `${basePath}/pinnedMessages`,
method: "POST",
body,
});
@@ -147,16 +161,8 @@ type UnpinMessageMSTeamsParams = {
export async function unpinMessageMSTeams(
params: UnpinMessageMSTeamsParams,
): Promise<{ ok: true }> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
if (conv.kind === "channel") {
throw new Error(
"Pin/unpin is not supported for channel messages on Graph v1.0. " +
"Only chat conversations support pinned messages.",
);
}
const path = `${conv.basePath}/pinnedMessages/${encodeURIComponent(params.pinnedMessageId)}`;
const { token, basePath } = await resolveGraphPinContext(params, "modify");
const path = `${basePath}/pinnedMessages/${encodeURIComponent(params.pinnedMessageId)}`;
await deleteGraphRequest({ token, path });
return { ok: true };
}
@@ -169,18 +175,9 @@ type ListPinsMSTeamsParams = {
const LIST_PINS_MAX_PAGES = 10;
export async function listPinsMSTeams(params: ListPinsMSTeamsParams) {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
const { token, basePath } = await resolveGraphPinContext(params, "list");
if (conv.kind === "channel") {
throw new Error(
"Listing pinned messages is not supported for channels on Graph v1.0. " +
"Only chat conversations support pinned messages.",
);
}
const path = `${conv.basePath}/pinnedMessages?$expand=message`;
const path = `${basePath}/pinnedMessages?$expand=message`;
const allPins: Array<{ id: string; pinnedMessageId: string; messageId?: string; text?: string }> =
[];
@@ -240,9 +237,7 @@ async function mutateMessageReaction(
operation: "setReaction" | "unsetReaction",
): Promise<{ ok: true }> {
const reactionType = resolveMSTeamsReactionEmoji(params.reactionType);
const token = await resolveGraphToken(params.cfg, { preferDelegated: true });
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
const { token, basePath } = await resolveGraphMessageContext(params, { preferDelegated: true });
await mutateGraphJson<unknown>({
token,
path: `${basePath}/messages/${encodeURIComponent(params.messageId)}/${operation}`,
@@ -266,9 +261,7 @@ export function unreactMessageMSTeams(params: ReactMessageMSTeamsParams): Promis
* Uses Graph v1.0 (reactions are included in the message resource).
*/
export async function listReactionsMSTeams(params: MSTeamsMessageTarget) {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
const { token, basePath } = await resolveGraphMessageContext(params);
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`;
const msg = await fetchGraphJson<GraphMessageWithReactions>({ token, path });
@@ -341,9 +334,7 @@ function matchesSearchSender(message: GraphMessage, from: string | undefined): b
* locally without widening the read to the account's global message index.
*/
export async function searchMessagesMSTeams(params: SearchMessagesMSTeamsParams) {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
const { token, basePath } = await resolveGraphMessageContext(params);
const rawLimit = params.limit ?? SEARCH_DEFAULT_LIMIT;
const top = Number.isFinite(rawLimit)
+27 -32
View File
@@ -41,23 +41,15 @@ function uniqueItemsById<T extends { id?: string }>(items: T[]): T[] {
return [...byId.values()];
}
function findExactNames<T extends { id?: string; displayName?: string }>(
function findExactMatches<T extends GraphUser>(
items: T[],
query: string,
fields: readonly ("displayName" | "mail" | "userPrincipalName")[] = ["displayName"],
): T[] {
const normalized = normalizeLowercaseStringOrEmpty(query);
return uniqueItemsById(
items.filter((item) => normalizeLowercaseStringOrEmpty(item.displayName) === normalized),
);
}
function findExactUsers(items: GraphUser[], query: string): GraphUser[] {
const normalized = normalizeLowercaseStringOrEmpty(query);
return uniqueItemsById(
items.filter((item) =>
[item.displayName, item.mail, item.userPrincipalName].some(
(value) => normalizeLowercaseStringOrEmpty(value) === normalized,
),
fields.some((field) => normalizeLowercaseStringOrEmpty(item[field]) === normalized),
),
);
}
@@ -78,27 +70,18 @@ function normalizeStaticMSTeamsAllowEntry(raw: string): string | undefined {
return isStableMSTeamsUserId(id) ? id : undefined;
}
export function projectStableMSTeamsUserAllowlist(entries?: string[]): string[] | undefined {
if (!entries) {
return undefined;
}
const projected = entries
.map((entry) => normalizeStaticMSTeamsAllowEntry(entry))
.filter((entry): entry is string => Boolean(entry));
return [
...new Map(projected.map((entry) => [normalizeLowercaseStringOrEmpty(entry), entry])).values(),
];
}
export function projectStableMSTeamsGroupAllowlist(entries?: string[]): string[] | undefined {
function projectStableMSTeamsAllowlist(
entries: string[] | undefined,
allowConversations: boolean,
): string[] | undefined {
if (!entries) {
return undefined;
}
const projected = entries
.map((entry) => {
const stableUserEntry = normalizeStaticMSTeamsAllowEntry(entry);
if (stableUserEntry) {
return stableUserEntry;
const stable = normalizeStaticMSTeamsAllowEntry(entry);
if (stable || !allowConversations) {
return stable;
}
const conversationId = normalizeMSTeamsConversationId(normalizeMSTeamsUserInput(entry));
return MSTEAMS_GROUP_CONVERSATION_ID.test(conversationId) ? conversationId : undefined;
@@ -107,7 +90,7 @@ export function projectStableMSTeamsGroupAllowlist(entries?: string[]): string[]
return [
...new Map(
projected.map((entry) => [
MSTEAMS_GROUP_CONVERSATION_ID.test(entry)
allowConversations && MSTEAMS_GROUP_CONVERSATION_ID.test(entry)
? `conversation:${entry}`
: normalizeLowercaseStringOrEmpty(entry),
entry,
@@ -116,6 +99,14 @@ export function projectStableMSTeamsGroupAllowlist(entries?: string[]): string[]
];
}
export function projectStableMSTeamsUserAllowlist(entries?: string[]): string[] | undefined {
return projectStableMSTeamsAllowlist(entries, false);
}
export function projectStableMSTeamsGroupAllowlist(entries?: string[]): string[] | undefined {
return projectStableMSTeamsAllowlist(entries, true);
}
function stripProviderPrefix(raw: string): string {
return raw.replace(/^(msteams|teams):/i, "");
}
@@ -333,7 +324,7 @@ export async function resolveMSTeamsChannelAllowlist(params: {
if (result.truncated) {
return { input, resolved: false, note: "team lookup incomplete" };
}
const exactTeams = findExactNames(result.items, team);
const exactTeams = findExactMatches(result.items, team);
const [exactTeam] = exactTeams;
if (!exactTeam) {
return { input, resolved: false, note: "team not found" };
@@ -368,7 +359,7 @@ export async function resolveMSTeamsChannelAllowlist(params: {
} catch {
return { input, resolved: false, note: "channel lookup failed" };
}
const generalChannels = findExactNames(teamChannels, "general");
const generalChannels = findExactMatches(teamChannels, "general");
if (params.teamIdMode !== "graph" && generalChannels.length !== 1) {
return {
input,
@@ -392,7 +383,7 @@ export async function resolveMSTeamsChannelAllowlist(params: {
};
}
const channelById = teamChannels.find((item) => item.id === channel);
const exactChannels = channelById ? [channelById] : findExactNames(teamChannels, channel);
const exactChannels = channelById ? [channelById] : findExactMatches(teamChannels, channel);
if (exactChannels.length === 0) {
return { input, resolved: false, note: "channel not found" };
}
@@ -535,7 +526,11 @@ export async function resolveMSTeamsUserAllowlist(params: {
if (result.truncated) {
return { input, resolved: false, note: "user lookup incomplete" };
}
const users = findExactUsers(result.items, query);
const users = findExactMatches(result.items, query, [
"displayName",
"mail",
"userPrincipalName",
]);
const [match] = users;
if (!match) {
return { input, resolved: false, note: "user not found" };
+15 -29
View File
@@ -166,40 +166,26 @@ export class PlivoProvider implements VoiceCallProvider {
let providerResponseBody: string;
// Special flows that exist only to return Plivo XML (no events).
if (flow === "xml-speak") {
const pending = callId ? this.pendingSpeakByCallId.get(callId) : undefined;
if (flow === "xml-speak" || flow === "xml-listen") {
const speaks = flow === "xml-speak";
const pendingSpeak = speaks && callId ? this.pendingSpeakByCallId.get(callId) : undefined;
const pendingListen = !speaks && callId ? this.pendingListenByCallId.get(callId) : undefined;
const language = speaks ? pendingSpeak?.locale : pendingListen?.language;
if (callId) {
this.pendingSpeakByCallId.delete(callId);
(speaks ? this.pendingSpeakByCallId : this.pendingListenByCallId).delete(callId);
}
const actionUrl =
pending?.listenAfterPlayback && callId ? this.buildActionUrl(ctx, callId) : null;
providerResponseBody = pending
? actionUrl
? PlivoProvider.xmlGetInputSpeech({
text: pending.text,
language: pending.locale,
actionUrl,
})
: PlivoProvider.xmlKeepAlive(
` <Speak language="${escapeXml(pending.locale || "en-US")}">${escapeXml(pending.text)}</Speak>\n`,
)
: PlivoProvider.xmlKeepAlive();
} else if (flow === "xml-listen") {
const pending = callId ? this.pendingListenByCallId.get(callId) : undefined;
if (callId) {
this.pendingListenByCallId.delete(callId);
}
const actionUrl = this.buildActionUrl(ctx, callId);
!speaks || (pendingSpeak?.listenAfterPlayback && callId)
? this.buildActionUrl(ctx, callId)
: null;
providerResponseBody =
actionUrl && callId
? PlivoProvider.xmlGetInputSpeech({
actionUrl,
language: pending?.language,
})
: PlivoProvider.xmlKeepAlive();
? PlivoProvider.xmlGetInputSpeech({ text: pendingSpeak?.text, language, actionUrl })
: pendingSpeak
? PlivoProvider.xmlKeepAlive(
` <Speak language="${escapeXml(language || "en-US")}">${escapeXml(pendingSpeak.text)}</Speak>\n`,
)
: PlivoProvider.xmlKeepAlive();
} else {
const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx);
event = this.normalizeEvent(parsed, callId, dedupeKey);
+2 -27
View File
@@ -9,7 +9,6 @@ import {
} from "openclaw/plugin-sdk/number-runtime";
import type { PluginLogger, PluginServiceSchedulerV1 } from "openclaw/plugin-sdk/plugin-entry";
import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
import type { TalkEvent } from "openclaw/plugin-sdk/realtime-voice";
import {
normalizeOptionalString,
normalizeStringEntries,
@@ -51,6 +50,7 @@ import {
StreamDisconnectGrace,
type StreamDisconnectLifecycle,
} from "./webhook/stream-disconnect-grace.js";
import { appendRecentTalkEventMetadata } from "./webhook/talk-event-metadata.js";
const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
@@ -64,31 +64,6 @@ const loadResponseGeneratorModule = createLazyRuntimeModule(
() => import("./response-generator.js"),
);
function appendRecentTalkEventMetadata(
metadata: CallRecord["metadata"],
event: TalkEvent,
): CallRecord["metadata"] {
const previous = metadata ?? {};
const recent = Array.isArray(previous.recentTalkEvents)
? previous.recentTalkEvents.filter(
(entry): entry is { at: string; type: string; sessionId: string; turnId?: string } =>
Boolean(entry) && typeof entry === "object" && !Array.isArray(entry),
)
: [];
recent.push({
at: event.timestamp,
type: event.type,
sessionId: event.sessionId,
turnId: event.turnId,
});
return {
...previous,
lastTalkEventAt: event.timestamp,
lastTalkEventType: event.type,
recentTalkEvents: recent.slice(-10),
};
}
function buildRequestUrl(requestUrl: string | undefined): URL {
return new URL(requestUrl ?? "/", "http://localhost");
}
@@ -398,7 +373,7 @@ export class VoiceCallWebhookServer {
void this.manager
.updateCallMetadata(current.call, (metadata) =>
this.getCurrentStream(providerCallId, streamSid)
? appendRecentTalkEventMetadata(metadata, event)
? appendRecentTalkEventMetadata(metadata, event, "streaming")
: metadata,
)
.catch((error: unknown) => {
@@ -44,7 +44,6 @@ import type { CallManager } from "../manager.js";
import { REALTIME_VOICE_END_CALL_TOOL_NAME } from "../realtime-call-control.js";
import type { CallRecord, EndReason, NormalizedEvent, ToolHandlerContext } from "../types.js";
import type { WebhookResponsePayload } from "../webhook.types.js";
import { appendRecentTalkEventMetadata } from "./realtime-call-metadata.js";
import { createRealtimeCallPlayback } from "./realtime-call-playback.js";
import {
buildForcedConsultSpeechPrompt,
@@ -60,6 +59,7 @@ import {
} from "./realtime-transcript-text.js";
import type { StreamDisconnectLifecycle } from "./stream-disconnect-grace.js";
import { StreamFrameAdapter } from "./stream-frame-adapter.js";
import { appendRecentTalkEventMetadata } from "./talk-event-metadata.js";
type ToolHandlerFn = (
args: unknown,
@@ -788,7 +788,7 @@ export class RealtimeCallHandler {
onTalkEvent: (event) => {
void this.manager
.updateCallMetadata(callRecord, (metadata) =>
appendRecentTalkEventMetadata(metadata, event),
appendRecentTalkEventMetadata(metadata, event, "realtime"),
)
.catch((error: unknown) => {
console.warn("[voice-call] Failed to update realtime call metadata:", error);
@@ -1130,7 +1130,7 @@ export class RealtimeCallHandler {
clearRecentFinalUserTranscript(state);
state.nativeConsultInvocation = undefined;
}
this.resetConsultSessionForContinuity(callId, owner);
this.cancelConsultSession(callId, owner, "continuity-reset");
}
harness.flushOutput(() => {
audioPacer.clearAudio();
@@ -1456,46 +1456,36 @@ export class RealtimeCallHandler {
return state === owner ? state : undefined;
}
private cancelNativeConsult(callId: string, owner: ActiveRealtimeVoiceBridge): void {
const state = this.nativeConsultsInFlightByCallId.get(callId);
if (!state || state.owner !== owner) {
return;
}
this.nativeConsultsInFlightByCallId.delete(callId);
state.cancel();
}
private cancelForcedConsult(callId: string, owner: ActiveRealtimeVoiceBridge): void {
const state = this.forcedConsultsByCallId.get(callId);
if (!state || state.owner !== owner) {
return;
}
state.cancelled = true;
state.sendSpeechPrompt = false;
state.cancel();
this.forcedConsultsByCallId.delete(callId);
}
private resetConsultSessionForContinuity(callId: string, owner: ActiveRealtimeVoiceBridge): void {
private cancelConsultSession(
callId: string,
owner: ActiveRealtimeVoiceBridge,
mode: "retire" | "continuity-reset" = "retire",
): void {
const session = this.consultSessionsByCallId.get(callId);
if (!session || session.owner !== owner) {
return;
}
this.cancelForcedConsult(callId, owner);
this.cancelNativeConsult(callId, owner);
// A fresh provider session must not inherit cancelled/recent consult dedupe.
session.coordinator.clear();
}
private cancelConsultSession(callId: string, owner: ActiveRealtimeVoiceBridge): void {
const session = this.consultSessionsByCallId.get(callId);
if (!session || session.owner !== owner) {
return;
if (mode === "retire") {
session.coordinator.clearPending();
}
const forced = this.forcedConsultsByCallId.get(callId);
if (forced?.owner === owner) {
forced.cancelled = true;
forced.sendSpeechPrompt = false;
forced.cancel();
this.forcedConsultsByCallId.delete(callId);
}
const native = this.nativeConsultsInFlightByCallId.get(callId);
if (native?.owner === owner) {
this.nativeConsultsInFlightByCallId.delete(callId);
native.cancel();
}
if (mode === "continuity-reset") {
// A fresh provider session must not inherit cancelled/recent consult dedupe.
session.coordinator.clear();
} else {
this.consultSessionsByCallId.delete(callId);
}
session.coordinator.clearPending();
this.cancelForcedConsult(callId, owner);
this.cancelNativeConsult(callId, owner);
this.consultSessionsByCallId.delete(callId);
}
private isActiveBridgeOwner(callId: string, owner: ActiveRealtimeVoiceBridge): boolean {
@@ -4,16 +4,18 @@ import type { CallRecord } from "../types.js";
export function appendRecentTalkEventMetadata(
metadata: CallRecord["metadata"],
event: TalkEvent,
mode: "streaming" | "realtime",
): CallRecord["metadata"] {
const previous = metadata ?? {};
const recent = Array.isArray(previous.recentTalkEvents) ? previous.recentTalkEvents : [];
return {
...previous,
lastTalkEventAt: event.timestamp,
lastTalkEventType: event.type,
recentTalkEvents: [
...recent,
{
const streaming = mode === "streaming";
const retained = streaming
? recent.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
: recent;
// The two transports retain their existing history shapes and limits.
const next = streaming
? { at: event.timestamp, type: event.type, sessionId: event.sessionId, turnId: event.turnId }
: {
id: event.id,
brain: event.brain,
mode: event.mode,
@@ -25,7 +27,11 @@ export function appendRecentTalkEventMetadata(
type: event.type,
...(event.turnId ? { turnId: event.turnId } : {}),
...(event.final !== undefined ? { final: event.final } : {}),
},
].slice(-12),
};
return {
...previous,
lastTalkEventAt: event.timestamp,
lastTalkEventType: event.type,
recentTalkEvents: [...retained, next].slice(streaming ? -10 : -12),
};
}