2026-02-05 18:26:05 +08:00
import type * as Lark from "@larksuiteoapi/node-sdk" ;
import type { OpenClawPluginApi } from "openclaw/plugin-sdk" ;
import { Type } from "@sinclair/typebox" ;
import { Readable } from "stream" ;
import type { FeishuConfig } from "./types.js" ;
import { createFeishuClient } from "./client.js" ;
import { FeishuDocSchema , type FeishuDocParams } from "./doc-schema.js" ;
import { resolveToolsConfig } from "./tools-config.js" ;
// ============ Helpers ============
function json ( data : unknown ) {
return {
content : [ { type : "text" as const , text : JSON.stringify ( data , null , 2 ) } ] ,
details : data ,
} ;
}
/** Extract image URLs from markdown content */
function extractImageUrls ( markdown : string ) : string [ ] {
const regex = /!\[[^\]]*\]\(([^)]+)\)/g ;
const urls : string [ ] = [ ] ;
let match ;
while ( ( match = regex . exec ( markdown ) ) !== null ) {
const url = match [ 1 ] . trim ( ) ;
if ( url . startsWith ( "http://" ) || url . startsWith ( "https://" ) ) {
urls . push ( url ) ;
}
}
return urls ;
}
const BLOCK_TYPE_NAMES : Record < number , string > = {
1 : "Page" ,
2 : "Text" ,
3 : "Heading1" ,
4 : "Heading2" ,
5 : "Heading3" ,
12 : "Bullet" ,
13 : "Ordered" ,
14 : "Code" ,
15 : "Quote" ,
17 : "Todo" ,
18 : "Bitable" ,
21 : "Diagram" ,
22 : "Divider" ,
23 : "File" ,
27 : "Image" ,
30 : "Sheet" ,
31 : "Table" ,
32 : "TableCell" ,
} ;
// Block types that cannot be created via documentBlockChildren.create API
const UNSUPPORTED_CREATE_TYPES = new Set ( [ 31 , 32 ] ) ;
/** Clean blocks for insertion (remove unsupported types and read-only fields) */
2026-02-05 18:49:04 +08:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
function cleanBlocksForInsert ( blocks : any [ ] ) : { cleaned : unknown [ ] ; skipped : string [ ] } {
2026-02-05 18:26:05 +08:00
const skipped : string [ ] = [ ] ;
const cleaned = blocks
. filter ( ( block ) = > {
if ( UNSUPPORTED_CREATE_TYPES . has ( block . block_type ) ) {
const typeName = BLOCK_TYPE_NAMES [ block . block_type ] || ` type_ ${ block . block_type } ` ;
skipped . push ( typeName ) ;
return false ;
}
return true ;
} )
. map ( ( block ) = > {
if ( block . block_type === 31 && block . table ? . merge_info ) {
2026-02-05 18:49:04 +08:00
const { merge_info : _merge_info , . . . tableRest } = block . table ;
2026-02-05 18:26:05 +08:00
return { . . . block , table : tableRest } ;
}
return block ;
} ) ;
return { cleaned , skipped } ;
}
// ============ Core Functions ============
async function convertMarkdown ( client : Lark.Client , markdown : string ) {
const res = await client . docx . document . convert ( {
data : { content_type : "markdown" , content : markdown } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
return {
blocks : res.data?.blocks ? ? [ ] ,
firstLevelBlockIds : res.data?.first_level_block_ids ? ? [ ] ,
} ;
}
async function insertBlocks (
client : Lark.Client ,
docToken : string ,
2026-02-05 18:49:04 +08:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
2026-02-05 18:26:05 +08:00
blocks : any [ ] ,
parentBlockId? : string ,
2026-02-05 18:49:04 +08:00
) : Promise < { children : unknown [ ] ; skipped : string [ ] } > {
2026-02-05 18:26:05 +08:00
const { cleaned , skipped } = cleanBlocksForInsert ( blocks ) ;
const blockId = parentBlockId ? ? docToken ;
if ( cleaned . length === 0 ) {
return { children : [ ] , skipped } ;
}
const res = await client . docx . documentBlockChildren . create ( {
path : { document_id : docToken , block_id : blockId } ,
data : { children : cleaned } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
return { children : res.data?.children ? ? [ ] , skipped } ;
}
async function clearDocumentContent ( client : Lark.Client , docToken : string ) {
const existing = await client . docx . documentBlock . list ( {
path : { document_id : docToken } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( existing . code !== 0 ) {
throw new Error ( existing . msg ) ;
}
2026-02-05 18:26:05 +08:00
const childIds =
existing . data ? . items
? . filter ( ( b ) = > b . parent_id === docToken && b . block_type !== 1 )
. map ( ( b ) = > b . block_id ) ? ? [ ] ;
if ( childIds . length > 0 ) {
const res = await client . docx . documentBlockChildren . batchDelete ( {
path : { document_id : docToken , block_id : docToken } ,
data : { start_index : 0 , end_index : childIds.length } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
}
return childIds . length ;
}
async function uploadImageToDocx (
client : Lark.Client ,
blockId : string ,
imageBuffer : Buffer ,
fileName : string ,
) : Promise < string > {
const res = await client . drive . media . uploadAll ( {
data : {
file_name : fileName ,
parent_type : "docx_image" ,
parent_node : blockId ,
size : imageBuffer.length ,
2026-02-05 18:49:04 +08:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK expects stream
2026-02-05 18:26:05 +08:00
file : Readable.from ( imageBuffer ) as any ,
} ,
} ) ;
const fileToken = res ? . file_token ;
if ( ! fileToken ) {
throw new Error ( "Image upload failed: no file_token returned" ) ;
}
return fileToken ;
}
async function downloadImage ( url : string ) : Promise < Buffer > {
const response = await fetch ( url ) ;
if ( ! response . ok ) {
throw new Error ( ` Failed to download image: ${ response . status } ${ response . statusText } ` ) ;
}
return Buffer . from ( await response . arrayBuffer ( ) ) ;
}
async function processImages (
client : Lark.Client ,
docToken : string ,
markdown : string ,
2026-02-05 18:49:04 +08:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
2026-02-05 18:26:05 +08:00
insertedBlocks : any [ ] ,
) : Promise < number > {
const imageUrls = extractImageUrls ( markdown ) ;
2026-02-05 18:49:04 +08:00
if ( imageUrls . length === 0 ) {
return 0 ;
}
2026-02-05 18:26:05 +08:00
const imageBlocks = insertedBlocks . filter ( ( b ) = > b . block_type === 27 ) ;
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 ;
try {
const buffer = await downloadImage ( url ) ;
const urlPath = new URL ( url ) . pathname ;
const fileName = urlPath . split ( "/" ) . pop ( ) || ` image_ ${ i } .png ` ;
const fileToken = await uploadImageToDocx ( client , blockId , buffer , fileName ) ;
await client . docx . documentBlock . patch ( {
path : { document_id : docToken , block_id : blockId } ,
data : {
replace_image : { token : fileToken } ,
} ,
} ) ;
processed ++ ;
} catch ( err ) {
console . error ( ` Failed to process image ${ url } : ` , err ) ;
}
}
return processed ;
}
// ============ Actions ============
const STRUCTURED_BLOCK_TYPES = new Set ( [ 14 , 18 , 21 , 23 , 27 , 30 , 31 , 32 ] ) ;
async function readDoc ( client : Lark.Client , docToken : string ) {
const [ contentRes , infoRes , blocksRes ] = await Promise . all ( [
client . docx . document . rawContent ( { path : { document_id : docToken } } ) ,
client . docx . document . get ( { path : { document_id : docToken } } ) ,
client . docx . documentBlock . list ( { path : { document_id : docToken } } ) ,
] ) ;
2026-02-05 18:49:04 +08:00
if ( contentRes . code !== 0 ) {
throw new Error ( contentRes . msg ) ;
}
2026-02-05 18:26:05 +08:00
const blocks = blocksRes . data ? . items ? ? [ ] ;
const blockCounts : Record < string , number > = { } ;
const structuredTypes : string [ ] = [ ] ;
for ( const b of blocks ) {
const type = b . block_type ? ? 0 ;
const name = BLOCK_TYPE_NAMES [ type ] || ` type_ ${ type } ` ;
blockCounts [ name ] = ( blockCounts [ name ] || 0 ) + 1 ;
if ( STRUCTURED_BLOCK_TYPES . has ( type ) && ! structuredTypes . includes ( name ) ) {
structuredTypes . push ( name ) ;
}
}
let hint : string | undefined ;
if ( structuredTypes . length > 0 ) {
hint = ` This document contains ${ structuredTypes . join ( ", " ) } which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content. ` ;
}
return {
title : infoRes.data?.document?.title ,
content : contentRes.data?.content ,
revision_id : infoRes.data?.document?.revision_id ,
block_count : blocks.length ,
block_types : blockCounts ,
. . . ( hint && { hint } ) ,
} ;
}
async function createDoc ( client : Lark.Client , title : string , folderToken? : string ) {
const res = await client . docx . document . create ( {
data : { title , folder_token : folderToken } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
const doc = res . data ? . document ;
return {
document_id : doc?.document_id ,
title : doc?.title ,
url : ` https://feishu.cn/docx/ ${ doc ? . document_id } ` ,
} ;
}
async function writeDoc ( client : Lark.Client , docToken : string , markdown : string ) {
const deleted = await clearDocumentContent ( client , docToken ) ;
const { blocks } = await convertMarkdown ( client , markdown ) ;
if ( blocks . length === 0 ) {
return { success : true , blocks_deleted : deleted , blocks_added : 0 , images_processed : 0 } ;
}
const { children : inserted , skipped } = await insertBlocks ( client , docToken , blocks ) ;
const imagesProcessed = await processImages ( client , docToken , markdown , inserted ) ;
return {
success : true ,
blocks_deleted : deleted ,
blocks_added : inserted.length ,
images_processed : imagesProcessed ,
. . . ( skipped . length > 0 && {
warning : ` Skipped unsupported block types: ${ skipped . join ( ", " ) } . Tables are not supported via this API. ` ,
} ) ,
} ;
}
async function appendDoc ( client : Lark.Client , docToken : string , markdown : string ) {
const { blocks } = await convertMarkdown ( client , markdown ) ;
if ( blocks . length === 0 ) {
throw new Error ( "Content is empty" ) ;
}
const { children : inserted , skipped } = await insertBlocks ( client , docToken , blocks ) ;
const imagesProcessed = await processImages ( client , docToken , markdown , inserted ) ;
return {
success : true ,
blocks_added : inserted.length ,
images_processed : imagesProcessed ,
2026-02-05 18:49:04 +08:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
2026-02-05 18:26:05 +08:00
block_ids : inserted.map ( ( b : any ) = > b . block_id ) ,
. . . ( skipped . length > 0 && {
warning : ` Skipped unsupported block types: ${ skipped . join ( ", " ) } . Tables are not supported via this API. ` ,
} ) ,
} ;
}
async function updateBlock (
client : Lark.Client ,
docToken : string ,
blockId : string ,
content : string ,
) {
const blockInfo = await client . docx . documentBlock . get ( {
path : { document_id : docToken , block_id : blockId } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( blockInfo . code !== 0 ) {
throw new Error ( blockInfo . msg ) ;
}
2026-02-05 18:26:05 +08:00
const res = await client . docx . documentBlock . patch ( {
path : { document_id : docToken , block_id : blockId } ,
data : {
update_text_elements : {
elements : [ { text_run : { content } } ] ,
} ,
} ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
return { success : true , block_id : blockId } ;
}
async function deleteBlock ( client : Lark.Client , docToken : string , blockId : string ) {
const blockInfo = await client . docx . documentBlock . get ( {
path : { document_id : docToken , block_id : blockId } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( blockInfo . code !== 0 ) {
throw new Error ( blockInfo . msg ) ;
}
2026-02-05 18:26:05 +08:00
const parentId = blockInfo . data ? . block ? . parent_id ? ? docToken ;
const children = await client . docx . documentBlockChildren . get ( {
path : { document_id : docToken , block_id : parentId } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( children . code !== 0 ) {
throw new Error ( children . msg ) ;
}
2026-02-05 18:26:05 +08:00
const items = children . data ? . items ? ? [ ] ;
2026-02-05 18:49:04 +08:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
2026-02-05 18:26:05 +08:00
const index = items . findIndex ( ( item : any ) = > item . block_id === blockId ) ;
2026-02-05 18:49:04 +08:00
if ( index === - 1 ) {
throw new Error ( "Block not found" ) ;
}
2026-02-05 18:26:05 +08:00
const res = await client . docx . documentBlockChildren . batchDelete ( {
path : { document_id : docToken , block_id : parentId } ,
data : { start_index : index , end_index : index + 1 } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
return { success : true , deleted_block_id : blockId } ;
}
async function listBlocks ( client : Lark.Client , docToken : string ) {
const res = await client . docx . documentBlock . list ( {
path : { document_id : docToken } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
return {
blocks : res.data?.items ? ? [ ] ,
} ;
}
async function getBlock ( client : Lark.Client , docToken : string , blockId : string ) {
const res = await client . docx . documentBlock . get ( {
path : { document_id : docToken , block_id : blockId } ,
} ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
return {
block : res.data?.block ,
} ;
}
async function listAppScopes ( client : Lark.Client ) {
const res = await client . application . scope . list ( { } ) ;
2026-02-05 18:49:04 +08:00
if ( res . code !== 0 ) {
throw new Error ( res . msg ) ;
}
2026-02-05 18:26:05 +08:00
const scopes = res . data ? . scopes ? ? [ ] ;
const granted = scopes . filter ( ( s ) = > s . grant_status === 1 ) ;
const pending = scopes . filter ( ( s ) = > s . grant_status !== 1 ) ;
return {
granted : granted.map ( ( s ) = > ( { name : s.scope_name , type : s . scope_type } ) ) ,
pending : pending.map ( ( s ) = > ( { name : s.scope_name , type : s . scope_type } ) ) ,
summary : ` ${ granted . length } granted, ${ pending . length } pending ` ,
} ;
}
// ============ Tool Registration ============
export function registerFeishuDocTools ( api : OpenClawPluginApi ) {
const feishuCfg = api . config ? . channels ? . feishu as FeishuConfig | undefined ;
if ( ! feishuCfg ? . appId || ! feishuCfg ? . appSecret ) {
api . logger . debug ? . ( "feishu_doc: Feishu credentials not configured, skipping doc tools" ) ;
return ;
}
const toolsCfg = resolveToolsConfig ( feishuCfg . tools ) ;
const getClient = ( ) = > createFeishuClient ( feishuCfg ) ;
const registered : string [ ] = [ ] ;
// Main document tool with action-based dispatch
if ( toolsCfg . doc ) {
api . registerTool (
{
name : "feishu_doc" ,
label : "Feishu Doc" ,
description :
"Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block" ,
parameters : FeishuDocSchema ,
async execute ( _toolCallId , params ) {
const p = params as FeishuDocParams ;
try {
const client = getClient ( ) ;
switch ( p . action ) {
case "read" :
return json ( await readDoc ( client , p . doc_token ) ) ;
case "write" :
return json ( await writeDoc ( client , p . doc_token , p . content ) ) ;
case "append" :
return json ( await appendDoc ( client , p . doc_token , p . content ) ) ;
case "create" :
return json ( await createDoc ( client , p . title , p . folder_token ) ) ;
case "list_blocks" :
return json ( await listBlocks ( client , p . doc_token ) ) ;
case "get_block" :
return json ( await getBlock ( client , p . doc_token , p . block_id ) ) ;
case "update_block" :
return json ( await updateBlock ( client , p . doc_token , p . block_id , p . content ) ) ;
case "delete_block" :
return json ( await deleteBlock ( client , p . doc_token , p . block_id ) ) ;
default :
2026-02-05 18:49:04 +08:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
2026-02-05 18:26:05 +08:00
return json ( { error : ` Unknown action: ${ ( p as any ) . action } ` } ) ;
}
} catch ( err ) {
return json ( { error : err instanceof Error ? err.message : String ( err ) } ) ;
}
} ,
} ,
{ name : "feishu_doc" } ,
) ;
registered . push ( "feishu_doc" ) ;
}
// Keep feishu_app_scopes as independent tool
if ( toolsCfg . scopes ) {
api . registerTool (
{
name : "feishu_app_scopes" ,
label : "Feishu App Scopes" ,
description :
"List current app permissions (scopes). Use to debug permission issues or check available capabilities." ,
parameters : Type.Object ( { } ) ,
async execute() {
try {
const result = await listAppScopes ( getClient ( ) ) ;
return json ( result ) ;
} catch ( err ) {
return json ( { error : err instanceof Error ? err.message : String ( err ) } ) ;
}
} ,
} ,
{ name : "feishu_app_scopes" } ,
) ;
registered . push ( "feishu_app_scopes" ) ;
}
if ( registered . length > 0 ) {
api . logger . info ? . ( ` feishu_doc: Registered ${ registered . join ( ", " ) } ` ) ;
}
}