From bd4ecbfe49bcc07193f909f2ba120456f156fbd1 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 27 Mar 2026 11:12:54 +0530 Subject: [PATCH] refactor(feishu): type docx and media sdk seams --- extensions/feishu/src/docx-batch-insert.ts | 62 ++++++-- extensions/feishu/src/docx-table-ops.ts | 33 +++-- extensions/feishu/src/docx-types.ts | 38 +++++ extensions/feishu/src/docx.ts | 143 +++++++++++------- extensions/feishu/src/media.ts | 162 +++++++++++++-------- 5 files changed, 301 insertions(+), 137 deletions(-) create mode 100644 extensions/feishu/src/docx-types.ts diff --git a/extensions/feishu/src/docx-batch-insert.ts b/extensions/feishu/src/docx-batch-insert.ts index b855e53a4a9..f60d11b69e4 100644 --- a/extensions/feishu/src/docx-batch-insert.ts +++ b/extensions/feishu/src/docx-batch-insert.ts @@ -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[0] +>; +type DocxDescendantCreateBlock = NonNullable< + NonNullable["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, rootId: string): any[] { - const result: any[] = []; +function collectDescendants( + blockMap: Map, + rootId: string, +): FeishuDocxBlock[] { + const result: FeishuDocxBlock[] = []; const visited = new Set(); function collect(blockId: string) { @@ -57,11 +82,11 @@ function collectDescendants(blockMap: Map, rootId: string): any[] { async function insertBatch( client: Lark.Client, docToken: string, - blocks: any[], + blocks: FeishuDocxBlock[], firstLevelBlockIds: string[], parentBlockId: string = docToken, index: number = -1, -): Promise { +): Promise { 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(); - const blockMap = new Map(); + const blockMap = new Map(); 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); + } } } diff --git a/extensions/feishu/src/docx-table-ops.ts b/extensions/feishu/src/docx-table-ops.ts index 83a3cd5b017..1fcd6abfe5f 100644 --- a/extensions/feishu/src/docx-table-ops.ts +++ b/extensions/feishu/src/docx-table-ops.ts @@ -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(); + const blockMap = new Map(); 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(); 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: { diff --git a/extensions/feishu/src/docx-types.ts b/extensions/feishu/src/docx-types.ts new file mode 100644 index 00000000000..7156be9843c --- /dev/null +++ b/extensions/feishu/src/docx-types.ts @@ -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; +}; diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index e27b8d2043d..6c6db75b3ce 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -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 = { 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[0] +>; +type DocxChildrenCreateChild = NonNullable< + NonNullable["children"] +>[number]; +type DocxDescendantCreatePayload = NonNullable< + Parameters[0] +>; +type DocxDescendantCreateBlock = NonNullable< + NonNullable["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(); + const byId = new Map(); const originalOrder = new Map(); 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(); 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 { - /* 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"); } diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index ba9048859df..7a5cb004d0b 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -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> + | Awaited>; + +type FeishuDownloadResponse = + | Awaited> + | Awaited> + | Awaited>; + +type FeishuHeaderMap = Record; + +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>; + }; + 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 | undefined) ?? - (responseAny.header as Record | 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 { + 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 { 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; + }; + 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; 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 }), }, });