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
46 changes: 43 additions & 3 deletions src/community/feishu/messaging/message-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import type {
MarkdownElement,
} from "./types";

/** Maximum elements or components in one Feishu card. */
const MAX_FEISHU_CARD_ELEMENTS = 200;

/**
* Render assistant message content as a Feishu interactive card.
* @param messageContent - Array of content blocks (thinking, tool_use, text).
Expand Down Expand Up @@ -107,10 +110,10 @@ export async function renderMessageCard(
}
}

const stepCount = stepPanel.elements.length;
if (stepCount > 0) {
const totalStepCount = stepPanel.elements.length;
if (totalStepCount > 0) {
const stepCountText =
stepCount + " " + (stepCount === 1 ? "step" : "steps");
totalStepCount + " " + (totalStepCount === 1 ? "step" : "steps");
if (streaming) {
stepPanel.header.title.content = `Working on it (${stepCountText})`;
card.config!.summary.content = `Working on it (${stepCountText})`;
Expand Down Expand Up @@ -142,9 +145,46 @@ export async function renderMessageCard(
},
});
}
_trimCardElements(card);
return card;
}

/** Trim old step elements until the card fits Feishu's card limit. */
function _trimCardElements(card: Card) {
let elementCount = _countElements(card);
const stepPanel = card.body.elements.find(
(element): element is CollapsiblePanel => element.tag === "collapsible_panel",
);
if (!stepPanel || elementCount <= MAX_FEISHU_CARD_ELEMENTS) {
return;
}

while (
stepPanel.elements.length > 0 &&
elementCount > MAX_FEISHU_CARD_ELEMENTS
) {
const removedElement = stepPanel.elements.shift();
elementCount -= _countElements(removedElement);
}
}

function _countElements(value: unknown): number {
if (!value || typeof value !== "object") {
return 0;
}

const item = value as Record<string, unknown>;
const self = typeof item.tag === "string" ? 1 : 0;
return Object.values(item).reduce<number>(
(count, child) =>
count +
(Array.isArray(child)
? child.reduce<number>((sum, entry) => sum + _countElements(entry), 0)
: _countElements(child)),
self,
);
}

async function _uploadMessageResource(
text: string,
{
Expand Down
48 changes: 48 additions & 0 deletions tests/community/feishu/messaging/message-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";

import { renderMessageCard } from "@/community/feishu/messaging/message-renderer";

function countElements(value: unknown): number {
if (!value || typeof value !== "object") {
return 0;
}

const item = value as Record<string, unknown>;
const self = typeof item.tag === "string" ? 1 : 0;
return Object.values(item).reduce<number>(
(count, child) =>
count +
(Array.isArray(child)
? child.reduce<number>((sum, entry) => sum + countElements(entry), 0)
: countElements(child)),
self,
);
}

describe("renderMessageCard", () => {
test("keeps final cards within Feishu's official 200 element limit", async () => {
const card = await renderMessageCard(
[
...Array.from({ length: 100 }, (_, i) => ({
type: "tool_use" as const,
id: `tool-${i}`,
name: "Bash",
input: { command: `echo ${i}` },
})),
{ type: "text", text: "done" } as const,
],
{
streaming: false,
uploadImage: async () => "image-key",
},
);

const stepPanel = card.body.elements.find(
(element) => element.tag === "collapsible_panel",
);

expect(countElements(card)).toBeLessThanOrEqual(200);
expect(stepPanel?.tag).toBe("collapsible_panel");
expect(stepPanel?.elements.length).toBe(65);
});
});
Loading