mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 02:41:07 +00:00
refactor(feishu): type docx and media sdk seams
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
38
extensions/feishu/src/docx-types.ts
Normal file
38
extensions/feishu/src/docx-types.ts
Normal 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;
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user