/**
* Feishu API Client
*
* Encapsulates all Feishu API calls with automatic token management,
* error handling, and pagination support.
*/
import axios, { AxiosInstance, AxiosError } from 'axios'
import { getAuthService } from './auth.js'
import {
FEISHU_API_BASE,
API_ENDPOINTS,
ERROR_MESSAGES,
ERROR_CODES,
getLanguageCode,
getHeadingBlockType,
getHeadingPropertyName,
} from '../constants.js'
import type {
FeishuApiResponse,
DocumentInfo,
CreateDocumentResponse,
RawContentResponse,
Block,
BlocksResponse,
BlockChildrenResponse,
TextElement,
BlockType,
} from '../types.js'
// 是否启用详细日志(用于调试验证)
const DEBUG_API = process.env.DEBUG_API === 'true'
export class FeishuApiClient {
private client: AxiosInstance
private authService = getAuthService()
constructor() {
this.client = axios.create({
baseURL: FEISHU_API_BASE,
timeout: 30000,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
})
// Add auth interceptor
this.client.interceptors.request.use(async (config) => {
const token = await this.authService.getToken()
config.headers.Authorization = `Bearer ${token}`
// 详细日志:记录每个 API 请求
if (DEBUG_API) {
console.error(`\n[API REQUEST] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
if (config.params) {
console.error('[API PARAMS]', JSON.stringify(config.params))
}
if (config.data) {
console.error('[API BODY]', JSON.stringify(config.data, null, 2).slice(0, 500))
}
}
return config
})
// Add response logging interceptor
this.client.interceptors.response.use(
(response) => {
if (DEBUG_API) {
console.error(`[API RESPONSE] ${response.status} ${response.config.url}`)
console.error('[API RESPONSE DATA]', JSON.stringify(response.data, null, 2).slice(0, 500))
}
return response
},
async (error: AxiosError<FeishuApiResponse>) => {
const originalRequest = error.config
if (DEBUG_API && error.response) {
console.error(`[API ERROR] ${error.response.status} ${error.config?.url}`)
console.error('[API ERROR DATA]', JSON.stringify(error.response.data, null, 2))
}
if (!originalRequest) {
throw error
}
// Check if token expired
const responseData = error.response?.data
if (
responseData?.code === ERROR_CODES.TOKEN_EXPIRED ||
responseData?.code === ERROR_CODES.INVALID_TOKEN
) {
console.error('[FeishuApiClient] Token expired, refreshing...')
await this.authService.forceRefresh()
// Retry the request
return this.client.request(originalRequest)
}
throw error
}
)
}
// ============================================================================
// Generic Request Method
// ============================================================================
/**
* Generic request method for any Feishu API
*/
async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
endpoint: string,
data?: Record<string, unknown>,
params?: Record<string, string>
): Promise<T> {
const response = await this.client.request<FeishuApiResponse<T>>({
method,
url: endpoint,
data,
params,
})
this.checkResponse(response.data)
return response.data.data!
}
// ============================================================================
// Document Operations
// ============================================================================
/**
* Create a new document
*/
async createDocument(
title: string,
folderToken?: string
): Promise<{ documentId: string; url: string }> {
const body: Record<string, unknown> = { title }
if (folderToken) {
body.folder_token = folderToken
}
const response = await this.client.post<FeishuApiResponse<CreateDocumentResponse>>(
API_ENDPOINTS.CREATE_DOCUMENT,
body
)
this.checkResponse(response.data)
const doc = response.data.data!.document
return {
documentId: doc.document_id,
url: `https://feishu.cn/docx/${doc.document_id}`,
}
}
/**
* Get document info
*/
async getDocument(documentId: string): Promise<DocumentInfo> {
const response = await this.client.get<FeishuApiResponse<{ document: DocumentInfo }>>(
API_ENDPOINTS.GET_DOCUMENT(documentId)
)
this.checkResponse(response.data)
return response.data.data!.document
}
/**
* Get document raw content (plain text)
*/
async getRawContent(documentId: string): Promise<string> {
const response = await this.client.get<FeishuApiResponse<RawContentResponse>>(
API_ENDPOINTS.GET_RAW_CONTENT(documentId)
)
this.checkResponse(response.data)
return response.data.data!.content
}
/**
* Get all blocks from a document with pagination
*/
async getAllBlocks(documentId: string): Promise<Block[]> {
const allBlocks: Block[] = []
let pageToken: string | undefined
do {
const params: Record<string, string> = {
page_size: '500',
document_revision_id: '-1', // Latest revision
}
if (pageToken) {
params.page_token = pageToken
}
const response = await this.client.get<FeishuApiResponse<BlocksResponse>>(
API_ENDPOINTS.GET_BLOCKS(documentId),
{ params }
)
this.checkResponse(response.data)
const data = response.data.data!
allBlocks.push(...data.items)
pageToken = data.has_more ? data.page_token : undefined
} while (pageToken)
return allBlocks
}
/**
* Create blocks as children of a parent block
*/
async createBlocks(
documentId: string,
parentBlockId: string,
children: Partial<Block>[],
index?: number
): Promise<Block[]> {
const body: Record<string, unknown> = {
children,
document_revision_id: -1,
}
if (index !== undefined) {
body.index = index
}
try {
const response = await this.client.post<FeishuApiResponse<BlockChildrenResponse>>(
API_ENDPOINTS.CREATE_BLOCK(documentId, parentBlockId),
body
)
this.checkResponse(response.data)
return response.data.data!.children as unknown as Block[]
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
console.error('[createBlocks] API error:', JSON.stringify(error.response.data, null, 2))
}
throw error
}
}
/**
* Update a block
*/
async updateBlock(
documentId: string,
blockId: string,
updateBody: Partial<Block>
): Promise<void> {
const response = await this.client.patch<FeishuApiResponse>(
API_ENDPOINTS.UPDATE_BLOCK(documentId, blockId),
{
...updateBody,
document_revision_id: -1,
}
)
this.checkResponse(response.data)
}
/**
* Delete block children (batch delete)
*
* 根据飞书 API 文档,删除块使用:
* DELETE /docx/v1/documents/:document_id/blocks/:block_id/children/batch_delete
*
* @param documentId - 文档 ID
* @param parentBlockId - 父块 ID(通常是 page block 或容器块)
* @param startIndex - 开始删除的子块索引(从 0 开始)
* @param endIndex - 结束删除的子块索引(不包含)
*/
async deleteBlockChildren(
documentId: string,
parentBlockId: string,
startIndex: number,
endIndex: number
): Promise<void> {
const response = await this.client.delete<FeishuApiResponse>(
API_ENDPOINTS.DELETE_BLOCK_CHILDREN(documentId, parentBlockId),
{
params: { document_revision_id: -1 },
data: {
start_index: startIndex,
end_index: endIndex,
},
}
)
this.checkResponse(response.data)
}
/**
* Delete a specific block by finding its index in parent
*
* @param documentId - 文档 ID
* @param blockId - 要删除的块 ID
*/
async deleteBlock(documentId: string, blockId: string): Promise<void> {
// First, get all blocks to find the parent and index
const allBlocks = await this.getAllBlocks(documentId)
// Find the block and its parent
let parentBlock = null
let blockIndex = -1
for (const block of allBlocks) {
if (block.children && block.children.includes(blockId)) {
parentBlock = block
blockIndex = block.children.indexOf(blockId)
break
}
}
if (!parentBlock || blockIndex === -1) {
throw new Error(`Block ${blockId} not found or has no parent`)
}
// Delete the block using batch_delete
await this.deleteBlockChildren(
documentId,
parentBlock.block_id,
blockIndex,
blockIndex + 1
)
}
// ============================================================================
// Block Building Helpers
// ============================================================================
/**
* Build a text block with inline style support
*/
buildTextBlock(content: string): Partial<Block> {
return {
block_type: 2, // Text
text: {
elements: this.buildElementsWithStyles(content),
},
}
}
/**
* Build a heading block with inline style support
*/
buildHeadingBlock(content: string, level: number): Partial<Block> {
const blockType = getHeadingBlockType(level)
const propName = getHeadingPropertyName(level)
return {
block_type: blockType,
[propName]: {
elements: this.buildElementsWithStyles(content),
},
}
}
/**
* Build a code block (no inline style parsing - raw code)
*/
buildCodeBlock(code: string, language: string): Partial<Block> {
return {
block_type: 14, // Code
code: {
style: {
language: getLanguageCode(language),
wrap: false,
},
elements: [{ text_run: { content: code } }],
},
}
}
/**
* Build a bullet list item with inline style support
*/
buildBulletBlock(content: string): Partial<Block> {
return {
block_type: 12, // Bullet
bullet: {
elements: this.buildElementsWithStyles(content),
},
}
}
/**
* Build an ordered list item with inline style support
*/
buildOrderedBlock(content: string): Partial<Block> {
return {
block_type: 13, // Ordered
ordered: {
elements: this.buildElementsWithStyles(content),
},
}
}
/**
* Build a quote block with inline style support
*/
buildQuoteBlock(content: string): Partial<Block> {
return {
block_type: 15, // Quote
quote: {
elements: this.buildElementsWithStyles(content),
},
}
}
/**
* Build a divider block
*/
buildDividerBlock(): Partial<Block> {
return {
block_type: 22, // Divider
divider: {},
}
}
// ============================================================================
// Markdown Conversion
// ============================================================================
/**
* Parse inline Markdown styles and convert to TextElement array
* Supports: **bold**, *italic*, _italic_, `code`, [text](url), ~~strikethrough~~
*/
parseInlineStyles(text: string): TextElement[] {
const elements: TextElement[] = []
// Regex patterns for inline styles (order matters - more specific first)
// Pattern captures: bold, italic, strikethrough, inline code, links
const inlinePattern = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(_(.+?)_)|(~~(.+?)~~)|(`(.+?)`)|(\[([^\]]+)\]\(([^)]+)\))/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = inlinePattern.exec(text)) !== null) {
// Add plain text before the match
if (match.index > lastIndex) {
const plainText = text.slice(lastIndex, match.index)
if (plainText) {
elements.push({ text_run: { content: plainText } })
}
}
if (match[1]) {
// **bold**
elements.push({
text_run: {
content: match[2],
text_element_style: { bold: true }
}
})
} else if (match[3]) {
// *italic*
elements.push({
text_run: {
content: match[4],
text_element_style: { italic: true }
}
})
} else if (match[5]) {
// _italic_
elements.push({
text_run: {
content: match[6],
text_element_style: { italic: true }
}
})
} else if (match[7]) {
// ~~strikethrough~~
elements.push({
text_run: {
content: match[8],
text_element_style: { strikethrough: true }
}
})
} else if (match[9]) {
// `code`
elements.push({
text_run: {
content: match[10],
text_element_style: { inline_code: true }
}
})
} else if (match[11]) {
// [text](url)
elements.push({
text_run: {
content: match[12],
text_element_style: { link: { url: match[13] } }
}
})
}
lastIndex = match.index + match[0].length
}
// Add remaining plain text
if (lastIndex < text.length) {
const remainingText = text.slice(lastIndex)
if (remainingText) {
elements.push({ text_run: { content: remainingText } })
}
}
// If no matches, return single plain text element
if (elements.length === 0 && text) {
elements.push({ text_run: { content: text } })
}
return elements
}
/**
* Build elements with inline style support
*/
buildElementsWithStyles(content: string): TextElement[] {
return this.parseInlineStyles(content)
}
/**
* Convert blocks to Markdown format
*/
blocksToMarkdown(blocks: Block[]): string {
const lines: string[] = []
for (const block of blocks) {
const line = this.blockToMarkdownLine(block)
if (line !== null) {
lines.push(line)
}
}
return lines.join('\n')
}
private blockToMarkdownLine(block: Block): string | null {
const getTextContent = (elements?: TextElement[]): string => {
if (!elements) return ''
return elements
.map((el) => {
if (el.text_run) {
let text = el.text_run.content
const style = el.text_run.text_element_style
if (style?.bold) text = `**${text}**`
if (style?.italic) text = `*${text}*`
if (style?.strikethrough) text = `~~${text}~~`
if (style?.inline_code) text = `\`${text}\``
if (style?.link) text = `[${text}](${style.link.url})`
return text
}
return ''
})
.join('')
}
switch (block.block_type) {
case 1: // Page - skip
return null
case 2: // Text
return getTextContent(block.text?.elements)
case 3: // Heading1
return `# ${getTextContent(block.heading1?.elements)}`
case 4: // Heading2
return `## ${getTextContent(block.heading2?.elements)}`
case 5: // Heading3
return `### ${getTextContent(block.heading3?.elements)}`
case 6: // Heading4
return `#### ${getTextContent(block.heading4?.elements)}`
case 7: // Heading5
return `##### ${getTextContent(block.heading5?.elements)}`
case 8: // Heading6
return `###### ${getTextContent(block.heading6?.elements)}`
case 9: // Heading7
return `####### ${getTextContent(block.heading7?.elements)}`
case 10: // Heading8
return `######## ${getTextContent(block.heading8?.elements)}`
case 11: // Heading9
return `######### ${getTextContent(block.heading9?.elements)}`
case 12: // Bullet
return `- ${getTextContent(block.bullet?.elements)}`
case 13: // Ordered
return `1. ${getTextContent(block.ordered?.elements)}`
case 14: // Code
const lang = this.getLanguageName(block.code?.style?.language)
return `\`\`\`${lang}\n${getTextContent(block.code?.elements)}\n\`\`\``
case 15: // Quote
return `> ${getTextContent(block.quote?.elements)}`
case 17: // Todo
const checked = block.todo?.done ? 'x' : ' '
return `- [${checked}] ${getTextContent(block.todo?.elements)}`
case 22: // Divider
return '---'
case 27: // Image
return block.image?.token ? `` : null
default:
return null
}
}
private getLanguageName(code?: number): string {
if (!code) return ''
// Reverse lookup in LANGUAGE_MAP
const entries = Object.entries({
plaintext: 1,
bash: 7,
csharp: 8,
cpp: 9,
c: 10,
css: 12,
go: 22,
html: 24,
json: 28,
java: 29,
javascript: 30,
kotlin: 32,
markdown: 39,
php: 43,
python: 49,
ruby: 52,
rust: 53,
sql: 56,
typescript: 63,
xml: 66,
yaml: 67,
mermaid: 76,
})
for (const [name, num] of entries) {
if (num === code) return name
}
return ''
}
/**
* Parse Markdown to Feishu blocks with nested list support
*/
markdownToBlocks(markdown: string): Partial<Block>[] {
const lines = markdown.split('\n')
const blocks: Partial<Block>[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
// Code block
if (line.startsWith('```')) {
const lang = line.slice(3).trim() || 'plaintext'
const codeLines: string[] = []
i++
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i])
i++
}
blocks.push(this.buildCodeBlock(codeLines.join('\n'), lang))
i++ // Skip closing ```
continue
}
// Heading
const headingMatch = line.match(/^(#{1,9})\s+(.+)$/)
if (headingMatch) {
blocks.push(this.buildHeadingBlock(headingMatch[2], headingMatch[1].length))
i++
continue
}
// Bullet list (with nested indent support)
const bulletMatch = line.match(/^(\s*)[-*]\s+(.+)$/)
if (bulletMatch) {
const content = bulletMatch[2]
blocks.push(this.buildBulletBlock(content))
i++
continue
}
// Ordered list (with nested indent support)
const orderedMatch = line.match(/^(\s*)\d+\.\s+(.+)$/)
if (orderedMatch) {
const content = orderedMatch[2]
blocks.push(this.buildOrderedBlock(content))
i++
continue
}
// Quote
if (line.startsWith('> ')) {
blocks.push(this.buildQuoteBlock(line.slice(2)))
i++
continue
}
// Divider
if (line.match(/^---+$/)) {
blocks.push(this.buildDividerBlock())
i++
continue
}
// Empty line - skip
if (line.trim() === '') {
i++
continue
}
// Regular text
blocks.push(this.buildTextBlock(line))
i++
}
return blocks
}
// ============================================================================
// Error Handling
// ============================================================================
private checkResponse(response: FeishuApiResponse): void {
if (response.code !== 0) {
const errorMessage = this.getErrorMessage(response.code, response.msg)
throw new Error(errorMessage)
}
}
private getErrorMessage(code: number, msg: string): string {
switch (code) {
case ERROR_CODES.PERMISSION_DENIED:
return ERROR_MESSAGES.PERMISSION_DENIED
case ERROR_CODES.DOCUMENT_NOT_FOUND:
return ERROR_MESSAGES.DOCUMENT_NOT_FOUND
case ERROR_CODES.RATE_LIMITED:
return ERROR_MESSAGES.RATE_LIMITED
default:
return `Feishu API error: ${msg} (code: ${code})`
}
}
// ============================================================================
// Media Upload
// ============================================================================
/**
* Upload media file (image) to Feishu Drive
*
* @param filePath - Local file path
* @param fileName - File name for upload
* @param parentType - Parent type: 'docx_image' for document images
* @param parentNode - Parent document token
* @returns file_token for inserting into documents
*/
async uploadMedia(
fileBuffer: Buffer,
fileName: string,
parentType: string = 'docx_image',
parentNode: string = ''
): Promise<string> {
const FormData = (await import('form-data')).default
const form = new FormData()
form.append('file_name', fileName)
form.append('parent_type', parentType)
if (parentNode) {
form.append('parent_node', parentNode)
}
form.append('size', fileBuffer.length.toString())
form.append('file', fileBuffer, { filename: fileName })
const token = await this.authService.getToken()
const response = await axios.post<FeishuApiResponse<{ file_token: string }>>(
`${FEISHU_API_BASE}/drive/v1/medias/upload_all`,
form,
{
headers: {
...form.getHeaders(),
'Authorization': `Bearer ${token}`,
},
timeout: 60000, // 60s for large files
}
)
this.checkResponse(response.data)
return response.data.data!.file_token
}
}
// Singleton instance
let apiClientInstance: FeishuApiClient | null = null
export function getApiClient(): FeishuApiClient {
if (!apiClientInstance) {
apiClientInstance = new FeishuApiClient()
}
return apiClientInstance
}