refactor(feishu): type docx and media sdk seams

This commit is contained in:
Ayaan Zaidi
2026-03-27 11:12:54 +05:30
parent 2f979e9be0
commit bd4ecbfe49
5 changed files with 301 additions and 137 deletions

View File

@@ -8,18 +8,43 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { cleanBlocksForDescendant } from "./docx-table-ops.js";
import type { FeishuDocxBlock, FeishuDocxBlockChild } from "./docx-types.js";
export const BATCH_SIZE = 1000; // Feishu API limit per request
type Logger = { info?: (msg: string) => void };
type DocxDescendantCreatePayload = NonNullable<
Parameters<Lark.Client["docx"]["documentBlockDescendant"]["create"]>[0]
>;
type DocxDescendantCreateBlock = NonNullable<
NonNullable<DocxDescendantCreatePayload["data"]>["descendants"]
>[number];
function normalizeChildIds(children: string[] | string | undefined): string[] | undefined {
if (Array.isArray(children)) {
return children;
}
return typeof children === "string" ? [children] : undefined;
}
function toDescendantBlock(block: FeishuDocxBlock): DocxDescendantCreateBlock {
const children = normalizeChildIds(block.children);
return {
...block,
...(children ? { children } : {}),
} as DocxDescendantCreateBlock;
}
/**
* Collect all descendant blocks for a given first-level block ID.
* Recursively traverses the block tree to gather all children.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
function collectDescendants(blockMap: Map<string, any>, rootId: string): any[] {
const result: any[] = [];
function collectDescendants(
blockMap: Map<string, FeishuDocxBlock>,
rootId: string,
): FeishuDocxBlock[] {
const result: FeishuDocxBlock[] = [];
const visited = new Set<string>();
function collect(blockId: string) {
@@ -57,11 +82,11 @@ function collectDescendants(blockMap: Map<string, any>, rootId: string): any[] {
async function insertBatch(
client: Lark.Client,
docToken: string,
blocks: any[],
blocks: FeishuDocxBlock[],
firstLevelBlockIds: string[],
parentBlockId: string = docToken,
index: number = -1,
): Promise<any[]> {
): Promise<FeishuDocxBlockChild[]> {
const descendants = cleanBlocksForDescendant(blocks);
if (descendants.length === 0) {
@@ -72,7 +97,7 @@ async function insertBatch(
path: { document_id: docToken, block_id: parentBlockId },
data: {
children_id: firstLevelBlockIds,
descendants,
descendants: descendants.map(toDescendantBlock),
index,
},
});
@@ -104,26 +129,31 @@ async function insertBatch(
export async function insertBlocksInBatches(
client: Lark.Client,
docToken: string,
blocks: any[],
blocks: FeishuDocxBlock[],
firstLevelBlockIds: string[],
logger?: Logger,
parentBlockId: string = docToken,
startIndex: number = -1,
): Promise<{ children: any[]; skipped: string[] }> {
const allChildren: any[] = [];
): Promise<{ children: FeishuDocxBlockChild[]; skipped: string[] }> {
const allChildren: FeishuDocxBlockChild[] = [];
// Build batches ensuring each batch has ≤1000 total descendants
const batches: { firstLevelIds: string[]; blocks: any[] }[] = [];
let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] };
const batches: Array<{ firstLevelIds: string[]; blocks: FeishuDocxBlock[] }> = [];
let currentBatch: { firstLevelIds: string[]; blocks: FeishuDocxBlock[] } = {
firstLevelIds: [],
blocks: [],
};
const usedBlockIds = new Set<string>();
const blockMap = new Map<string, any>();
const blockMap = new Map<string, FeishuDocxBlock>();
for (const block of blocks) {
blockMap.set(block.block_id, block);
if (block.block_id) {
blockMap.set(block.block_id, block);
}
}
for (const firstLevelId of firstLevelBlockIds) {
const descendants = collectDescendants(blockMap, firstLevelId);
const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id));
const newBlocks = descendants.filter((b) => b.block_id && !usedBlockIds.has(b.block_id));
// A single block whose subtree exceeds the API limit cannot be split
// (a table or other compound block must be inserted atomically).
@@ -148,7 +178,9 @@ export async function insertBlocksInBatches(
currentBatch.firstLevelIds.push(firstLevelId);
for (const block of newBlocks) {
currentBatch.blocks.push(block);
usedBlockIds.add(block.block_id);
if (block.block_id) {
usedBlockIds.add(block.block_id);
}
}
}

View File

@@ -8,6 +8,7 @@
*/
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { FeishuDocxBlock } from "./docx-types.js";
// ============ Table Utilities ============
@@ -33,9 +34,15 @@ const DEFAULT_TABLE_WIDTH = 730; // Approximate Feishu page content width
* @param tableBlockId - The block_id of the table block
* @returns Array of column widths in pixels
*/
function normalizeChildBlockIds(children: string[] | string | undefined): string[] {
if (Array.isArray(children)) {
return children;
}
return typeof children === "string" ? [children] : [];
}
export function calculateAdaptiveColumnWidths(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
blocks: any[],
blocks: FeishuDocxBlock[],
tableBlockId: string,
): number[] {
// Find the table block
@@ -46,28 +53,31 @@ export function calculateAdaptiveColumnWidths(
}
const { row_size, column_size, column_width: originalWidths } = tableBlock.table.property;
if (!row_size || !column_size) {
return [];
}
// Use original total width from Convert API, or fall back to default
const totalWidth =
originalWidths && originalWidths.length > 0
? originalWidths.reduce((a: number, b: number) => a + b, 0)
: DEFAULT_TABLE_WIDTH;
const cellIds: string[] = tableBlock.children || [];
const cellIds = normalizeChildBlockIds(tableBlock.children);
// Build block lookup map
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const blockMap = new Map<string, any>();
const blockMap = new Map<string, FeishuDocxBlock>();
for (const block of blocks) {
blockMap.set(block.block_id, block);
if (block.block_id) {
blockMap.set(block.block_id, block);
}
}
// Extract text content from a table cell
function getCellText(cellId: string): string {
const cell = blockMap.get(cellId);
if (!cell?.children) return "";
let text = "";
const childIds = Array.isArray(cell.children) ? cell.children : [cell.children];
const childIds = normalizeChildBlockIds(cell?.children);
for (const childId of childIds) {
const child = blockMap.get(childId);
@@ -158,12 +168,11 @@ export function calculateAdaptiveColumnWidths(
* @param blocks - Array of blocks from Convert API
* @returns Cleaned blocks ready for Descendant API
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function cleanBlocksForDescendant(blocks: any[]): any[] {
export function cleanBlocksForDescendant(blocks: FeishuDocxBlock[]): FeishuDocxBlock[] {
// Pre-calculate adaptive widths for all tables
const tableWidths = new Map<string, number[]>();
for (const block of blocks) {
if (block.block_type === 31) {
if (block.block_type === 31 && block.block_id) {
const widths = calculateAdaptiveColumnWidths(blocks, block.block_id);
tableWidths.set(block.block_id, widths);
}
@@ -183,7 +192,7 @@ export function cleanBlocksForDescendant(blocks: any[]): any[] {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { cells: _cells, ...tableWithoutCells } = cleanBlock.table;
const { row_size, column_size } = tableWithoutCells.property || {};
const adaptiveWidths = tableWidths.get(block.block_id);
const adaptiveWidths = block.block_id ? tableWidths.get(block.block_id) : undefined;
cleanBlock.table = {
property: {

View File

@@ -0,0 +1,38 @@
export type FeishuBlockText = {
elements?: Array<{
text_run?: {
content?: string;
};
}>;
};
export type FeishuBlockTableProperty = {
row_size?: number;
column_size?: number;
column_width?: number[];
};
export type FeishuBlockTable = {
property?: FeishuBlockTableProperty;
merge_info?: Array<{ row_span?: number; col_span?: number }>;
cells?: string[];
};
export type FeishuDocxBlock = {
block_id?: string;
parent_id?: string;
children?: string[] | string;
block_type: number;
text?: FeishuBlockText;
table?: FeishuBlockTable;
image?: object;
[key: string]: object | string | number | boolean | string[] | undefined;
};
export type FeishuDocxBlockChild = {
block_id?: string;
parent_id?: string;
block_type?: number;
children?: string[] | FeishuDocxBlockChild[];
table?: FeishuBlockTable;
};

View File

@@ -17,6 +17,7 @@ import {
deleteTableColumns,
mergeTableCells,
} from "./docx-table-ops.js";
import type { FeishuDocxBlock, FeishuDocxBlockChild } from "./docx-types.js";
import { getFeishuRuntime } from "./runtime.js";
import {
createFeishuToolClient,
@@ -72,8 +73,10 @@ const BLOCK_TYPE_NAMES: Record<number, string> = {
const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]);
/** Clean blocks for insertion (remove unsupported types and read-only fields) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
function cleanBlocksForInsert(blocks: FeishuDocxBlock[]): {
cleaned: FeishuDocxBlock[];
skipped: string[];
} {
const skipped: string[] = [];
const cleaned = blocks
.filter((block) => {
@@ -123,20 +126,57 @@ function normalizeChildIds(children: unknown): string[] {
return [];
}
type DocxChildrenCreatePayload = NonNullable<
Parameters<Lark.Client["docx"]["documentBlockChildren"]["create"]>[0]
>;
type DocxChildrenCreateChild = NonNullable<
NonNullable<DocxChildrenCreatePayload["data"]>["children"]
>[number];
type DocxDescendantCreatePayload = NonNullable<
Parameters<Lark.Client["docx"]["documentBlockDescendant"]["create"]>[0]
>;
type DocxDescendantCreateBlock = NonNullable<
NonNullable<DocxDescendantCreatePayload["data"]>["descendants"]
>[number];
function toCreateChildBlock(block: FeishuDocxBlock): DocxChildrenCreateChild {
return block as DocxChildrenCreateChild;
}
function toDescendantBlock(block: FeishuDocxBlock): DocxDescendantCreateBlock {
const children = normalizeChildIds(block.children);
return {
...(block.block_id ? { block_id: block.block_id } : {}),
...(children.length > 0 ? { children } : {}),
...block,
} as DocxDescendantCreateBlock;
}
function normalizeInsertedChildBlocks(
children: string[] | FeishuDocxBlockChild[] | undefined,
): FeishuDocxBlockChild[] {
if (!Array.isArray(children)) {
return [];
}
return children.filter(
(child): child is FeishuDocxBlockChild => typeof child === "object" && child !== null,
);
}
// Convert API may return `blocks` in a non-render order.
// Reconstruct the document tree using first_level_block_ids plus children/parent links,
// then emit blocks in pre-order so Descendant/Children APIs receive one normalized tree contract.
function normalizeConvertedBlockTree(
blocks: any[],
blocks: FeishuDocxBlock[],
firstLevelIds: string[],
): { orderedBlocks: any[]; rootIds: string[] } {
): { orderedBlocks: FeishuDocxBlock[]; rootIds: string[] } {
if (blocks.length <= 1) {
const rootIds =
blocks.length === 1 && typeof blocks[0]?.block_id === "string" ? [blocks[0].block_id] : [];
return { orderedBlocks: blocks, rootIds };
}
const byId = new Map<string, any>();
const byId = new Map<string, FeishuDocxBlock>();
const originalOrder = new Map<string, number>();
for (const [index, block] of blocks.entries()) {
if (typeof block?.block_id === "string") {
@@ -161,14 +201,19 @@ function normalizeConvertedBlockTree(
const parentId = typeof block?.parent_id === "string" ? block.parent_id : "";
return !childIds.has(blockId) && (!parentId || !byId.has(parentId));
})
.sort((a, b) => (originalOrder.get(a.block_id) ?? 0) - (originalOrder.get(b.block_id) ?? 0))
.map((block) => block.block_id);
.sort(
(a, b) =>
(originalOrder.get(a.block_id ?? "__missing__") ?? 0) -
(originalOrder.get(b.block_id ?? "__missing__") ?? 0),
)
.map((block) => block.block_id)
.filter((blockId): blockId is string => typeof blockId === "string");
const rootIds = (
firstLevelIds && firstLevelIds.length > 0 ? firstLevelIds : inferredTopLevelIds
).filter((id, index, arr) => typeof id === "string" && byId.has(id) && arr.indexOf(id) === index);
const orderedBlocks: any[] = [];
const orderedBlocks: FeishuDocxBlock[] = [];
const visited = new Set<string>();
const visit = (blockId: string) => {
@@ -177,6 +222,9 @@ function normalizeConvertedBlockTree(
}
visited.add(blockId);
const block = byId.get(blockId);
if (!block) {
return;
}
orderedBlocks.push(block);
for (const childId of normalizeChildIds(block?.children)) {
visit(childId);
@@ -196,18 +244,17 @@ function normalizeConvertedBlockTree(
}
}
return { orderedBlocks, rootIds };
return { orderedBlocks, rootIds: rootIds.filter((id): id is string => typeof id === "string") };
}
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
async function insertBlocks(
client: Lark.Client,
docToken: string,
blocks: any[],
blocks: FeishuDocxBlock[],
parentBlockId?: string,
index?: number,
): Promise<{ children: any[]; skipped: string[] }> {
/* eslint-enable @typescript-eslint/no-explicit-any */
): Promise<{ children: FeishuDocxBlockChild[]; skipped: string[] }> {
const { cleaned, skipped } = cleanBlocksForInsert(blocks);
const blockId = parentBlockId ?? docToken;
@@ -219,12 +266,12 @@ async function insertBlocks(
// The batch API (sending all children at once) does not guarantee ordering
// because Feishu processes the batch asynchronously. Sequential single-block
// inserts (each appended to the end) produce deterministic results.
const allInserted: any[] = [];
const allInserted: FeishuDocxBlockChild[] = [];
for (const [offset, block] of cleaned.entries()) {
const res = await client.docx.documentBlockChildren.create({
path: { document_id: docToken, block_id: blockId },
data: {
children: [block],
children: [toCreateChildBlock(block)],
...(index !== undefined ? { index: index + offset } : {}),
},
});
@@ -319,7 +366,7 @@ async function convertMarkdownWithFallback(client: Lark.Client, markdown: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
const blocks: any[] = [];
const blocks: FeishuDocxBlock[] = [];
const firstLevelBlockIds: string[] = [];
for (const chunk of chunks) {
@@ -336,7 +383,7 @@ async function convertMarkdownWithFallback(client: Lark.Client, markdown: string
async function chunkedConvertMarkdown(client: Lark.Client, markdown: string) {
const chunks = splitMarkdownByHeadings(markdown);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
const allBlocks: any[] = [];
const allBlocks: FeishuDocxBlock[] = [];
const allRootIds: string[] = [];
for (const chunk of chunks) {
const { blocks, firstLevelBlockIds } = await convertMarkdownWithFallback(client, chunk);
@@ -352,12 +399,10 @@ async function chunkedConvertMarkdown(client: Lark.Client, markdown: string) {
async function chunkedInsertBlocks(
client: Lark.Client,
docToken: string,
blocks: any[],
blocks: FeishuDocxBlock[],
parentBlockId?: string,
): Promise<{ children: any[]; skipped: string[] }> {
/* eslint-enable @typescript-eslint/no-explicit-any */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
const allChildren: any[] = [];
): Promise<{ children: FeishuDocxBlockChild[]; skipped: string[] }> {
const allChildren: FeishuDocxBlockChild[] = [];
const allSkipped: string[] = [];
for (let i = 0; i < blocks.length; i += MAX_BLOCKS_PER_INSERT) {
@@ -383,11 +428,10 @@ type Logger = { info?: (msg: string) => void };
async function insertBlocksWithDescendant(
client: Lark.Client,
docToken: string,
blocks: any[],
blocks: FeishuDocxBlock[],
firstLevelBlockIds: string[],
{ parentBlockId = docToken, index = -1 }: { parentBlockId?: string; index?: number } = {},
): Promise<{ children: any[] }> {
/* eslint-enable @typescript-eslint/no-explicit-any */
): Promise<{ children: FeishuDocxBlockChild[] }> {
const descendants = cleanBlocksForDescendant(blocks);
if (descendants.length === 0) {
return { children: [] };
@@ -395,7 +439,11 @@ async function insertBlocksWithDescendant(
const res = await client.docx.documentBlockDescendant.create({
path: { document_id: docToken, block_id: parentBlockId },
data: { children_id: firstLevelBlockIds, descendants, index },
data: {
children_id: firstLevelBlockIds,
descendants: descendants.map(toDescendantBlock),
index,
},
});
if (res.code !== 0) {
@@ -616,10 +664,9 @@ async function processImages(
client: Lark.Client,
docToken: string,
markdown: string,
insertedBlocks: any[],
insertedBlocks: FeishuDocxBlockChild[],
maxBytes: number,
): Promise<number> {
/* eslint-enable @typescript-eslint/no-explicit-any */
const imageUrls = extractImageUrls(markdown);
if (imageUrls.length === 0) {
return 0;
@@ -630,7 +677,10 @@ async function processImages(
let processed = 0;
for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) {
const url = imageUrls[i];
const blockId = imageBlocks[i].block_id;
const blockId = imageBlocks[i]?.block_id;
if (!blockId) {
continue;
}
try {
const buffer = await downloadImage(url, maxBytes);
@@ -670,14 +720,12 @@ async function uploadImageBlock(
const insertRes = await client.docx.documentBlockChildren.create({
path: { document_id: docToken, block_id: parentBlockId ?? docToken },
params: { document_revision_id: -1 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK type
data: { children: [{ block_type: 27, image: {} as any }], index: index ?? -1 },
data: { children: [{ block_type: 27, image: {} }], index: index ?? -1 },
});
if (insertRes.code !== 0) {
throw new Error(`Failed to create image block: ${insertRes.msg}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape
const imageBlockId = insertRes.data?.children?.find((b: any) => b.block_type === 27)?.block_id;
const imageBlockId = insertRes.data?.children?.find((b) => b.block_type === 27)?.block_id;
if (!imageBlockId) {
throw new Error("Failed to create image block");
}
@@ -736,7 +784,6 @@ async function uploadFileBlock(
const { children: inserted } = await insertBlocks(client, docToken, orderedBlocks, blockId);
// Get the first inserted block - we'll delete it and create the file in its place
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape
const placeholderBlock = inserted[0];
if (!placeholderBlock?.block_id) {
throw new Error("Failed to create placeholder block for file upload");
@@ -751,10 +798,7 @@ async function uploadFileBlock(
throw new Error(childrenRes.msg);
}
const items = childrenRes.data?.items ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
const placeholderIdx = items.findIndex(
(item: any) => item.block_id === placeholderBlock.block_id,
);
const placeholderIdx = items.findIndex((item) => item.block_id === placeholderBlock.block_id);
if (placeholderIdx >= 0) {
const deleteRes = await client.docx.documentBlockChildren.batchDelete({
path: { document_id: docToken, block_id: parentId },
@@ -955,7 +999,7 @@ async function appendDoc(
blocks_added: blocks.length,
images_processed: imagesProcessed,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
block_ids: inserted.map((b: any) => b.block_id),
block_ids: inserted.map((b) => b.block_id),
};
}
@@ -977,8 +1021,7 @@ async function insertDoc(
// Paginate through all children to reliably locate after_block_id.
// documentBlockChildren.get returns up to 200 children per page; large
// parents require multiple requests.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
const items: any[] = [];
const items: FeishuDocxBlock[] = [];
let pageToken: string | undefined;
do {
const childrenRes = await client.docx.documentBlockChildren.get({
@@ -1031,7 +1074,7 @@ async function insertDoc(
blocks_added: blocks.length,
images_processed: imagesProcessed,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
block_ids: inserted.map((b: any) => b.block_id),
block_ids: inserted.map((b) => b.block_id),
};
}
@@ -1070,10 +1113,8 @@ async function createTable(
throw new Error(res.msg);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return type
const tableBlock = (res.data?.children as any[] | undefined)?.find((b) => b.block_type === 31);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape may vary by version
const cells = (tableBlock?.children as any[] | undefined) ?? [];
const tableBlock = res.data?.children?.find((b) => b.block_type === 31);
const cells = normalizeInsertedChildBlocks(tableBlock?.children);
return {
success: true,
@@ -1081,8 +1122,7 @@ async function createTable(
row_size: rowSize,
column_size: columnSize,
// row-major cell ids, if API returns them directly
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return type
table_cell_block_ids: cells.map((c: any) => c.block_id).filter(Boolean),
table_cell_block_ids: cells.map((c) => c.block_id).filter(Boolean),
raw_children_count: res.data?.children?.length ?? 0,
};
}
@@ -1109,13 +1149,10 @@ async function writeTableCells(
throw new Error("table_block_id is not a table block");
}
// SDK types are loose here across versions
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block payload
const tableData = (tableBlock as any).table;
const tableData = tableBlock.table;
const rows = tableData?.property?.row_size as number | undefined;
const cols = tableData?.property?.column_size as number | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block payload
const cellIds = (tableData?.cells as any[] | undefined) ?? [];
const cellIds = tableData?.cells ?? [];
if (!rows || !cols || !cellIds.length) {
throw new Error(
@@ -1256,7 +1293,7 @@ async function deleteBlock(client: Lark.Client, docToken: string, blockId: strin
const items = children.data?.items ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
const index = items.findIndex((item: any) => item.block_id === blockId);
const index = items.findIndex((item) => item.block_id === blockId);
if (index === -1) {
throw new Error("Block not found");
}

View File

@@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import type * as Lark from "@larksuiteoapi/node-sdk";
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { withTempDownloadPath, type ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
@@ -41,21 +42,56 @@ function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accoun
};
}
type FeishuUploadResponse =
| Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
| Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
type FeishuDownloadResponse =
| Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
| Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
| Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
type FeishuHeaderMap = Record<string, string | string[]>;
function asHeaderMap(value: object | undefined): FeishuHeaderMap | undefined {
if (!value) {
return undefined;
}
const entries = Object.entries(value);
if (entries.every(([, entry]) => typeof entry === "string" || Array.isArray(entry))) {
return Object.fromEntries(entries) as FeishuHeaderMap;
}
return undefined;
}
function extractFeishuUploadKey(
response: unknown,
response: FeishuUploadResponse,
params: {
key: "image_key" | "file_key";
errorPrefix: string;
},
): string {
// SDK v1.30+ returns data directly without code wrapper on success.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
const responseAny = response as any;
if (responseAny.code !== undefined && responseAny.code !== 0) {
throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
if (!response) {
throw new Error(`${params.errorPrefix}: empty response`);
}
const key = responseAny[params.key] ?? responseAny.data?.[params.key];
const wrappedResponse = response as {
image_key?: string;
file_key?: string;
code?: number;
msg?: string;
data?: Partial<Record<"image_key" | "file_key", string>>;
};
if (wrappedResponse.code !== undefined && wrappedResponse.code !== 0) {
throw new Error(
`${params.errorPrefix}: ${wrappedResponse.msg || `code ${wrappedResponse.code}`}`,
);
}
const key =
params.key === "image_key"
? (wrappedResponse.image_key ?? wrappedResponse.data?.image_key)
: (wrappedResponse.file_key ?? wrappedResponse.data?.file_key);
if (!key) {
throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
}
@@ -101,92 +137,106 @@ function decodeDispositionFileName(value: string): string | undefined {
return plainMatch?.[1]?.trim();
}
function extractFeishuDownloadMetadata(response: unknown): {
function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): {
contentType?: string;
fileName?: string;
} {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
const responseAny = response as any;
const responseWithOptionalFields = response as FeishuDownloadResponse & {
header?: object;
contentType?: string;
mime_type?: string;
data?: {
contentType?: string;
mime_type?: string;
file_name?: string;
fileName?: string;
};
file_name?: string;
fileName?: string;
};
const headers =
(responseAny.headers as Record<string, unknown> | undefined) ??
(responseAny.header as Record<string, unknown> | undefined);
asHeaderMap(responseWithOptionalFields.headers) ??
asHeaderMap(responseWithOptionalFields.header);
const contentType =
readHeaderValue(headers, "content-type") ??
(typeof responseAny.contentType === "string" ? responseAny.contentType : undefined) ??
(typeof responseAny.mime_type === "string" ? responseAny.mime_type : undefined) ??
(typeof responseAny.data?.contentType === "string"
? responseAny.data.contentType
: undefined) ??
(typeof responseAny.data?.mime_type === "string" ? responseAny.data.mime_type : undefined);
responseWithOptionalFields.contentType ??
responseWithOptionalFields.mime_type ??
responseWithOptionalFields.data?.contentType ??
responseWithOptionalFields.data?.mime_type;
const disposition = readHeaderValue(headers, "content-disposition");
const fileName =
(disposition ? decodeDispositionFileName(disposition) : undefined) ??
(typeof responseAny.file_name === "string" ? responseAny.file_name : undefined) ??
(typeof responseAny.fileName === "string" ? responseAny.fileName : undefined) ??
(typeof responseAny.data?.file_name === "string" ? responseAny.data.file_name : undefined) ??
(typeof responseAny.data?.fileName === "string" ? responseAny.data.fileName : undefined);
responseWithOptionalFields.file_name ??
responseWithOptionalFields.fileName ??
responseWithOptionalFields.data?.file_name ??
responseWithOptionalFields.data?.fileName;
return { contentType, fileName };
}
async function readReadableBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function readFeishuResponseBuffer(params: {
response: unknown;
response: FeishuDownloadResponse;
tmpDirPrefix: string;
errorPrefix: string;
}): Promise<Buffer> {
const { response } = params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
const responseAny = response as any;
if (responseAny.code !== undefined && responseAny.code !== 0) {
throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
}
if (Buffer.isBuffer(response)) {
return response;
}
if (response instanceof ArrayBuffer) {
return Buffer.from(response);
}
if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
return responseAny.data;
const responseWithOptionalFields = response as FeishuDownloadResponse & {
code?: number;
msg?: string;
data?: Buffer | ArrayBuffer;
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
};
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
throw new Error(
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
);
}
if (responseAny.data instanceof ArrayBuffer) {
return Buffer.from(responseAny.data);
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
return responseWithOptionalFields.data;
}
if (typeof responseAny.getReadableStream === "function") {
const stream = responseAny.getReadableStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
return Buffer.from(responseWithOptionalFields.data);
}
if (typeof responseAny.writeFile === "function") {
if (typeof response.getReadableStream === "function") {
return readReadableBuffer(response.getReadableStream());
}
if (typeof response.writeFile === "function") {
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
await responseAny.writeFile(tmpPath);
await response.writeFile(tmpPath);
return await fs.promises.readFile(tmpPath);
});
}
if (typeof responseAny[Symbol.asyncIterator] === "function") {
if (responseWithOptionalFields[Symbol.asyncIterator]) {
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
const chunks: Buffer[] = [];
for await (const chunk of responseAny) {
for await (const chunk of asyncIterable) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
if (typeof responseAny.read === "function") {
const chunks: Buffer[] = [];
for await (const chunk of responseAny as Readable) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
if (response instanceof Readable) {
return readReadableBuffer(response);
}
const keys = Object.keys(responseAny);
const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${types}]`);
const keys = Object.keys(response as object);
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
}
/**
@@ -283,8 +333,7 @@ export async function uploadImageFeishu(params: {
const response = await client.im.image.create({
data: {
image_type: imageType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
image: imageData as any,
image: imageData,
},
});
@@ -336,8 +385,7 @@ export async function uploadFileFeishu(params: {
data: {
file_type: fileType,
file_name: safeFileName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
file: fileData as any,
file: fileData,
...(duration !== undefined && { duration }),
},
});