Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/channels/lark/image-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@ export interface SendLarkImageMessageResult {
openMessageId?: string;
}

export async function sendLarkImageMessage(input: {
export async function uploadLarkImageMessageAsset(input: {
installationId: string;
chatId: string;
replyToMessageId?: string | null;
imagePath: string;
clients?: {
getOrCreate(installationId: string): LarkSdkClient;
};
}): Promise<SendLarkImageMessageResult> {
}): Promise<{ imageKey: string }> {
if (input.clients == null) {
throw new Error("Lark image sending is not configured in this runtime.");
}
Expand All @@ -43,6 +42,24 @@ export async function sendLarkImageMessage(input: {
imageKey,
});

return { imageKey };
}

export async function sendLarkImageMessage(input: {
installationId: string;
chatId: string;
replyToMessageId?: string | null;
imagePath: string;
clients?: {
getOrCreate(installationId: string): LarkSdkClient;
};
}): Promise<SendLarkImageMessageResult> {
if (input.clients == null) {
throw new Error("Lark image sending is not configured in this runtime.");
}

const client = input.clients.getOrCreate(input.installationId);
const { imageKey } = await uploadLarkImageMessageAsset(input);
const content = JSON.stringify({ image_key: imageKey });
const response =
input.replyToMessageId != null && input.replyToMessageId.length > 0
Expand Down
198 changes: 191 additions & 7 deletions src/channels/lark/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import {
} from "@/src/channels/lark/cardkit-mutations.js";
import type { LarkSdkClient } from "@/src/channels/lark/client.js";
import { listLarkDeliveryTargets, readStringValue } from "@/src/channels/lark/delivery-targets.js";
import { sendLarkImageMessage } from "@/src/channels/lark/image-message.js";
import {
sendLarkImageMessage,
uploadLarkImageMessageAsset,
} from "@/src/channels/lark/image-message.js";
import {
addLarkMessageReaction,
removeLarkMessageReaction,
Expand All @@ -41,6 +44,7 @@ import {
describeTaskRunTerminal,
getLarkTaskTerminalMessagePresentation,
type LarkSubagentCreationRequestCardState,
type LarkTaskCardImage,
} from "@/src/channels/lark/render.js";
import {
type LarkRunState,
Expand Down Expand Up @@ -1007,14 +1011,9 @@ export function createLarkOutboundRuntime(
internalObjectId,
});
const existingMetadata = parseBindingMetadata(existing?.metadataJson ?? null);
const rendered = buildLarkRenderedTaskCard(state, {
...(taskTitle == null ? {} : { title: taskTitle }),
});
const terminalMessage = getLarkTaskTerminalMessagePresentation(state.terminalMessage);
const snapshotKey = `${target.channelInstallationId}:${TASK_STATUS_DELIVERY_PREFIX}${taskRunId}`;
const snapshot = deliverySnapshots.get(snapshotKey) ?? null;
const shouldRenderUpdate =
snapshot == null || snapshot.structureSignature !== rendered.structureSignature;
let metadataJson = buildBindingMetadata(
{
sessionId: state.sessionId,
Expand All @@ -1032,6 +1031,32 @@ export function createLarkOutboundRuntime(
},
existing?.metadataJson ?? null,
);
let taskCardImages: { metadataJson: string; images: LarkTaskCardImage[] };
try {
taskCardImages = await resolveTaskCardImages({
channelInstallationId: target.channelInstallationId,
chatId,
client,
state,
metadataJson,
cacheMetadataJson: existing?.metadataJson ?? null,
});
} catch (error) {
scheduleDependentRetry(`${TASK_STATUS_DELIVERY_PREFIX}${taskRunId}`, {
reason: "task_result_image_upload_failed",
taskRunId,
channelInstallationId: target.channelInstallationId,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
metadataJson = taskCardImages.metadataJson;
const rendered = buildLarkRenderedTaskCard(state, {
...(taskTitle == null ? {} : { title: taskTitle }),
...(taskCardImages.images.length === 0 ? {} : { images: taskCardImages.images }),
});
const shouldRenderUpdate =
snapshot == null || snapshot.structureSignature !== rendered.structureSignature;
const status = state.terminal === "running" ? "active" : "finalized";

if (existing?.larkCardId == null || existing.larkMessageId == null) {
Expand Down Expand Up @@ -1828,7 +1853,30 @@ export function createLarkOutboundRuntime(
continue;
}

const replyToMessageId = readStringValue(target.surfaceObject.reply_to_message_id);
const replyTarget = resolveOutboundAttachmentReplyToMessageId({
bindingsRepo: new LarkObjectBindingsRepo(input.storage),
channelInstallationId: target.channelInstallationId,
surfaceObject: target.surfaceObject,
taskRunId: envelope.taskRun.taskRunId,
taskRunType: envelope.taskRun.runType,
});
if (replyTarget.taskStatusCardMissing) {
logger.error(
"falling back to lark surface reply target because task status card is missing",
{
eventId: envelope.event.eventId,
conversationId: envelope.target.conversationId,
branchId: envelope.target.branchId,
taskRunId: envelope.taskRun.taskRunId,
taskRunType: envelope.taskRun.runType,
channelInstallationId: target.channelInstallationId,
chatId,
fallbackReplyToMessageId: replyTarget.replyToMessageId,
attachmentPath: envelope.event.attachmentPath,
},
);
}
const replyToMessageId = replyTarget.replyToMessageId;
try {
const result = await sendLarkImageMessage({
installationId: target.channelInstallationId,
Expand Down Expand Up @@ -2134,6 +2182,142 @@ function parseSurfaceObject(raw: string): Record<string, unknown> {
return {};
}

function resolveOutboundAttachmentReplyToMessageId(input: {
bindingsRepo: LarkObjectBindingsRepo;
channelInstallationId: string;
surfaceObject: Record<string, unknown>;
taskRunId: string | null;
taskRunType: string | null;
}): { replyToMessageId: string | null; taskStatusCardMissing: boolean } {
const surfaceReplyToMessageId = readStringValue(input.surfaceObject.reply_to_message_id);
if (input.taskRunId == null || !shouldRenderStandaloneTaskCard(input.taskRunType)) {
return {
replyToMessageId: surfaceReplyToMessageId,
taskStatusCardMissing: false,
};
}

const taskRootBinding = input.bindingsRepo.getByInternalObject({
channelInstallationId: input.channelInstallationId,
internalObjectKind: RUN_CARD_OBJECT_KIND,
internalObjectId: buildTaskRunCardObjectId(input.taskRunId),
});

return {
replyToMessageId: taskRootBinding?.larkMessageId ?? surfaceReplyToMessageId,
taskStatusCardMissing: taskRootBinding?.larkMessageId == null,
};
}

interface CachedTaskResultImage {
path: string;
displayPath: string;
alt?: string;
imageKey: string;
}

async function resolveTaskCardImages(input: {
channelInstallationId: string;
chatId: string;
client: LarkSdkClient;
state: LarkRunState;
metadataJson: string;
cacheMetadataJson?: string | null;
}): Promise<{ metadataJson: string; images: LarkTaskCardImage[] }> {
if (input.state.terminalImageAttachments.length === 0) {
return {
metadataJson: input.metadataJson,
images: [],
};
}

const metadata = parseBindingMetadata(input.metadataJson);
const cacheMetadata = parseBindingMetadata(input.cacheMetadataJson ?? input.metadataJson);
const cachedImages = readCachedTaskResultImages(cacheMetadata.taskResultImages);
const nextCache: CachedTaskResultImage[] = [];
const images: LarkTaskCardImage[] = [];

for (const attachment of input.state.terminalImageAttachments) {
const cached = cachedImages.find((item) => item.path === attachment.path) ?? null;
let imageKey = cached?.imageKey ?? null;
if (imageKey == null) {
try {
const uploaded = await uploadLarkImageMessageAsset({
installationId: input.channelInstallationId,
chatId: input.chatId,
imagePath: attachment.path,
clients: {
getOrCreate: (installationId) => {
if (installationId !== input.channelInstallationId) {
throw new Error(`Unexpected lark installation id ${installationId}`);
}
return input.client;
},
},
});
imageKey = uploaded.imageKey;
} catch (error) {
logger.error("failed to upload lark task result image for task card", {
channelInstallationId: input.channelInstallationId,
taskRunId: input.state.taskRunId,
path: attachment.path,
displayPath: attachment.displayPath,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}

const cachedImage: CachedTaskResultImage = {
path: attachment.path,
displayPath: attachment.displayPath,
...(attachment.alt == null ? {} : { alt: attachment.alt }),
imageKey,
};
nextCache.push(cachedImage);
images.push({
imageKey,
displayPath: attachment.displayPath,
...(attachment.alt == null ? {} : { alt: attachment.alt }),
});
}

return {
metadataJson: JSON.stringify({
...metadata,
taskResultImages: nextCache,
}),
images,
};
}

function readCachedTaskResultImages(value: unknown): CachedTaskResultImage[] {
if (!Array.isArray(value)) {
return [];
}

const images: CachedTaskResultImage[] = [];
for (const item of value) {
if (!isRecord(item)) {
continue;
}
const path = readStringValue(item.path);
const displayPath = readStringValue(item.displayPath);
const imageKey = readStringValue(item.imageKey);
if (path == null || displayPath == null || imageKey == null) {
continue;
}
const alt = readStringValue(item.alt);
images.push({
path,
displayPath,
imageKey,
...(alt == null ? {} : { alt }),
});
}
return images;
}

async function maybeSendFeishuDocPreviewMessages(input: {
channelInstallationId: string;
chatId: string;
Expand Down
1 change: 1 addition & 0 deletions src/channels/lark/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export {
describeTaskRunTerminal,
getLarkTaskTerminalMessagePresentation,
type LarkRenderedTaskCard,
type LarkTaskCardImage,
type LarkTaskTerminalMessagePresentation,
} from "@/src/channels/lark/render/task-card.js";

Expand Down
Loading
Loading