mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-26 10:13:27 +08:00
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:
32 files changed
+692
-1050
No files matched your search
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+15
-9
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user