feishu-mcp
Version:
Model Context Protocol server for Feishu integration
404 lines (403 loc) • 19.6 kB
JavaScript
import { Logger } from '../../utils/logger.js';
import { ParamUtils } from '../../utils/paramUtils.js';
import { BlockFactory } from '../blockFactory.js';
import { FeishuBaseApiService } from './FeishuBaseApiService.js';
/**
* 飞书文档块服务
* 负责文档的创建、查询及块的 CRUD 操作
*/
export class FeishuDocumentBlockService extends FeishuBaseApiService {
constructor(authService, blockFactory) {
super(authService);
Object.defineProperty(this, "blockFactory", {
enumerable: true,
configurable: true,
writable: true,
value: blockFactory
});
}
/**
* 创建飞书文档
* @param title 文档标题
* @param folderToken 目标文件夹的 Token,文档将创建在此文件夹下
* @returns 创建的文档信息,包含 document_id、title 等字段
*/
async createDocument(title, folderToken) {
try {
const response = await this.post('/docx/v1/documents', { title, folder_token: folderToken });
return response;
}
catch (error) {
this.handleApiError(error, '创建飞书文档失败');
}
}
/**
* 获取文档信息,支持普通文档和 Wiki 文档
* 当 documentType 未指定时,自动根据 documentId 是否包含 /wiki/ 路径判断类型
* @param documentId 文档 ID、文档 URL 或 Wiki 节点 Token/URL
* @param documentType 文档类型,'document' 为普通文档,'wiki' 为知识库文档;不传则自动检测
* @returns 文档信息对象,额外附加 _type 字段标识来源('document' | 'wiki'),
* Wiki 文档额外附加 documentId 字段作为 obj_token 的别名
*/
async getDocumentInfo(documentId, documentType) {
try {
let isWikiLink;
if (documentType === 'wiki') {
isWikiLink = true;
}
else if (documentType === 'document') {
isWikiLink = false;
}
else {
isWikiLink = documentId.includes('/wiki/');
}
if (isWikiLink) {
const wikiToken = ParamUtils.processWikiToken(documentId);
const response = await this.get('/wiki/v2/spaces/get_node', { token: wikiToken, obj_type: 'wiki' });
if (!response.node || !response.node.obj_token) {
throw new Error(`无法从Wiki节点获取文档ID: ${wikiToken}`);
}
const node = response.node;
Logger.debug(`获取Wiki文档信息: ${wikiToken} -> documentId: ${node.obj_token}`);
return { ...node, documentId: node.obj_token, _type: 'wiki' };
}
else {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const response = await this.get(`/docx/v1/documents/${normalizedDocId}`);
Logger.debug(`获取普通文档信息: ${normalizedDocId}`);
return { ...response, _type: 'document' };
}
}
catch (error) {
this.handleApiError(error, '获取文档信息失败');
}
}
/**
* 获取文档的纯文本内容
* @param documentId 文档 ID 或 URL
* @param lang 语言,0 为中文,1 为英文,默认 0
* @returns 文档的纯文本内容字符串
*/
async getDocumentContent(documentId, lang = 0) {
try {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const response = await this.get(`/docx/v1/documents/${normalizedDocId}/raw_content`, { lang });
return response.content;
}
catch (error) {
this.handleApiError(error, '获取文档内容失败');
}
}
/**
* 获取文档的所有块结构,自动处理分页直到获取全部数据
* @param documentId 文档 ID 或 URL
* @param pageSize 每次分页请求的块数量,默认 500
* @returns 文档的所有块对象数组
*/
async getDocumentBlocks(documentId, pageSize = 500) {
try {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks`;
let pageToken = '';
let allBlocks = [];
do {
const params = { page_size: pageSize, document_revision_id: -1 };
if (pageToken)
params.page_token = pageToken;
const response = await this.get(endpoint, params);
allBlocks = [...allBlocks, ...(response.items || [])];
pageToken = response.page_token;
} while (pageToken);
return allBlocks;
}
catch (error) {
this.handleApiError(error, '获取文档块结构失败');
}
}
/**
* 获取指定块的详细内容
* @param documentId 文档 ID 或 URL
* @param blockId 目标块的 ID
* @returns 块的详细信息,包含 block_type、block_id 及对应类型的内容字段
*/
async getBlockContent(documentId, blockId) {
try {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const safeBlockId = ParamUtils.processBlockId(blockId);
return await this.get(`/docx/v1/documents/${normalizedDocId}/blocks/${safeBlockId}`, { document_revision_id: -1 });
}
catch (error) {
this.handleApiError(error, '获取块内容失败');
}
}
/**
* 更新块的文本内容,支持普通文本和行内公式混排
* @param documentId 文档 ID 或 URL
* @param blockId 目标块的 ID
* @param textElements 文本元素数组,每个元素可包含 text(普通文本)或 equation(LaTeX 公式),
* 以及可选的 style 样式(bold、italic、underline 等)
* @returns 更新后的块信息
*/
async updateBlockTextContent(documentId, blockId, textElements) {
try {
const docId = ParamUtils.processDocumentId(documentId);
const endpoint = `/docx/v1/documents/${docId}/blocks/${blockId}?document_revision_id=-1`;
Logger.debug(`准备请求API端点: ${endpoint}`);
const elements = textElements.map(item => {
if (item.equation !== undefined) {
return { equation: { content: item.equation, text_element_style: BlockFactory.applyDefaultTextStyle(item.style) } };
}
return { text_run: { content: item.text || '', text_element_style: BlockFactory.applyDefaultTextStyle(item.style) } };
});
const data = { update_text_elements: { elements } };
Logger.debug(`请求数据: ${JSON.stringify(data, null, 2)}`);
return await this.patch(endpoint, data);
}
catch (error) {
this.handleApiError(error, '更新块文本内容失败');
return null;
}
}
/**
* 在指定父块下创建单个子块
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID,子块将插入到该块的子节点列表中
* @param blockContent 块内容对象,使用 BlockFactory 或 createBlockContent 生成
* @param index 插入位置的索引,0 表示插入到第一个子节点,默认 0
* @returns 创建结果,包含新块的 block_id、block_type 等信息
*/
async createDocumentBlock(documentId, parentBlockId, blockContent, index = 0) {
try {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children?document_revision_id=-1`;
Logger.debug(`准备请求API端点: ${endpoint}`);
const payload = { children: [blockContent], index };
Logger.debug(`请求数据: ${JSON.stringify(payload, null, 2)}`);
return await this.post(endpoint, payload);
}
catch (error) {
this.handleApiError(error, '创建文档块失败');
return null;
}
}
/**
* 在指定父块下批量创建多个子块,一次 API 调用插入全部内容
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param blockContents 块内容对象数组,按顺序插入
* @param index 起始插入位置的索引,默认 0
* @returns 创建结果,包含各新块的 block_id 等信息
*/
async createDocumentBlocks(documentId, parentBlockId, blockContents, index = 0) {
try {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children?document_revision_id=-1`;
Logger.debug(`准备请求API端点: ${endpoint}`);
const payload = { children: blockContents, index };
Logger.debug(`请求数据: ${JSON.stringify(payload, null, 2)}`);
return await this.post(endpoint, payload);
}
catch (error) {
this.handleApiError(error, '批量创建文档块失败');
return null;
}
}
/**
* 创建文本段落块,支持普通文本与行内公式混排
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param textContents 文本内容数组,每项可包含 text(普通文本)或 equation(LaTeX 公式)及 style
* @param align 对齐方式,1 左对齐,2 居中,3 右对齐,默认 1
* @param index 插入位置索引,默认 0
* @returns 创建结果
*/
async createTextBlock(documentId, parentBlockId, textContents, align = 1, index = 0) {
const processedTextContents = textContents.map(item => {
if (item.equation !== undefined) {
return { equation: item.equation, style: BlockFactory.applyDefaultTextStyle(item.style) };
}
return { text: item.text || '', style: BlockFactory.applyDefaultTextStyle(item.style) };
});
const blockContent = this.blockFactory.createTextBlock({ textContents: processedTextContents, align });
return this.createDocumentBlock(documentId, parentBlockId, blockContent, index);
}
/**
* 创建代码块
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param code 代码内容字符串
* @param language 编程语言代码(飞书 API 枚举值,1-75),0 表示纯文本,默认 0
* @param wrap 是否开启自动换行,默认 false
* @param index 插入位置索引,默认 0
* @returns 创建结果
*/
async createCodeBlock(documentId, parentBlockId, code, language = 0, wrap = false, index = 0) {
const blockContent = this.blockFactory.createCodeBlock({ code, language, wrap });
return this.createDocumentBlock(documentId, parentBlockId, blockContent, index);
}
/**
* 创建标题块
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param text 标题文本内容
* @param level 标题级别,1-9,默认 1(一级标题)
* @param index 插入位置索引,默认 0
* @param align 对齐方式,1 左对齐,2 居中,3 右对齐,默认 1
* @returns 创建结果
*/
async createHeadingBlock(documentId, parentBlockId, text, level = 1, index = 0, align = 1) {
const blockContent = this.blockFactory.createHeadingBlock({ text, level, align });
return this.createDocumentBlock(documentId, parentBlockId, blockContent, index);
}
/**
* 创建列表块(有序或无序)
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param text 列表项文本内容
* @param isOrdered 是否为有序列表,true 为有序,false 为无序,默认 false
* @param index 插入位置索引,默认 0
* @param align 对齐方式,1 左对齐,2 居中,3 右对齐,默认 1
* @returns 创建结果
*/
async createListBlock(documentId, parentBlockId, text, isOrdered = false, index = 0, align = 1) {
const blockContent = this.blockFactory.createListBlock({ text, isOrdered, align });
return this.createDocumentBlock(documentId, parentBlockId, blockContent, index);
}
/**
* 创建 Mermaid 图表块
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param mermaidCode Mermaid 图表代码
* @param index 插入位置索引,默认 0
* @returns 创建结果
*/
async createMermaidBlock(documentId, parentBlockId, mermaidCode, index = 0) {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children?document_revision_id=-1`;
const blockContent = {
block_type: 40,
add_ons: {
component_id: '',
component_type_id: 'blk_631fefbbae02400430b8f9f4',
record: JSON.stringify({ data: mermaidCode })
}
};
const payload = { children: [blockContent], index };
Logger.info(`请求创建Mermaid块: ${JSON.stringify(payload).slice(0, 500)}...`);
return await this.post(endpoint, payload);
}
/**
* 创建表格块,支持自定义单元格内容
* 使用 descendant API 一次性创建表格及所有子块,并返回图片单元格的 blockId 映射
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param tableConfig 表格配置
* @param tableConfig.columnSize 表格列数
* @param tableConfig.rowSize 表格行数
* @param tableConfig.cells 单元格内容配置,每项指定坐标(row/column)和内容(blockType/options)
* @param index 插入位置索引,默认 0
* @returns 创建结果,额外附加 imageTokens 字段,包含图片单元格的坐标与 blockId 映射
*/
async createTableBlock(documentId, parentBlockId, tableConfig, index = 0) {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/descendant?document_revision_id=-1`;
const processedTableConfig = {
...tableConfig,
cells: tableConfig.cells?.map(cell => ({
...cell,
content: this.blockFactory.createBlockContentFromOptions(cell.content.blockType, cell.content.options)
}))
};
const tableStructure = this.blockFactory.createTableBlock(processedTableConfig);
const payload = { children_id: tableStructure.children_id, descendants: tableStructure.descendants, index };
Logger.info(`请求创建表格块: ${tableConfig.rowSize}x${tableConfig.columnSize},单元格数量: ${tableConfig.cells?.length || 0}`);
const response = await this.post(endpoint, payload);
const imageTokens = await this.extractImageTokensFromTable(response, tableStructure.imageBlocks);
return { ...response, imageTokens };
}
async extractImageTokensFromTable(tableResponse, cells) {
try {
const imageTokens = [];
Logger.info(`tableResponse: ${JSON.stringify(tableResponse)}`);
if (!cells || cells.length === 0) {
Logger.info('表格中没有图片单元格,跳过图片块信息提取');
return imageTokens;
}
const blockIdMap = new Map();
if (tableResponse?.block_id_relations) {
for (const relation of tableResponse.block_id_relations) {
blockIdMap.set(relation.temporary_block_id, relation.block_id);
}
Logger.debug(`创建了 ${blockIdMap.size} 个块ID映射关系`);
}
for (const cell of cells) {
const { coordinate, localBlockId } = cell;
const blockId = blockIdMap.get(localBlockId);
if (!blockId) {
Logger.warn(`未找到 localBlockId ${localBlockId} 对应的 block_id`);
continue;
}
imageTokens.push({ row: coordinate.row, column: coordinate.column, blockId });
Logger.info(`提取到图片块信息: 位置(${coordinate.row}, ${coordinate.column}),blockId: ${blockId}`);
}
Logger.info(`成功提取 ${imageTokens.length} 个图片块信息`);
return imageTokens;
}
catch (error) {
Logger.error(`提取表格图片块信息失败: ${error}`);
return [];
}
}
/**
* 批量删除指定父块下的连续子块(按索引范围)
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param startIndex 起始索引(含),必须 >= 0
* @param endIndex 结束索引(含),必须 >= startIndex
* @returns 操作结果
*/
async deleteDocumentBlocks(documentId, parentBlockId, startIndex, endIndex) {
try {
const normalizedDocId = ParamUtils.processDocumentId(documentId);
const endpoint = `/docx/v1/documents/${normalizedDocId}/blocks/${parentBlockId}/children/batch_delete`;
if (startIndex < 0 || endIndex < startIndex) {
throw new Error('无效的索引范围:起始索引必须大于等于0,结束索引必须大于等于起始索引');
}
Logger.info(`开始删除文档块,文档ID: ${normalizedDocId},父块ID: ${parentBlockId},索引范围: ${startIndex}-${endIndex}`);
const response = await this.delete(endpoint, { start_index: startIndex, end_index: endIndex });
Logger.info('文档块删除成功');
return response;
}
catch (error) {
this.handleApiError(error, '删除文档块失败');
}
}
/**
* 删除指定父块下的单个子块
* 等价于 deleteDocumentBlocks(documentId, parentBlockId, blockIndex, blockIndex + 1)
* @param documentId 文档 ID 或 URL
* @param parentBlockId 父块 ID
* @param blockIndex 目标块在父块子节点列表中的索引
* @returns 操作结果
*/
async deleteDocumentBlock(documentId, parentBlockId, blockIndex) {
return this.deleteDocumentBlocks(documentId, parentBlockId, blockIndex, blockIndex + 1);
}
/**
* 根据块类型字符串和选项对象创建块内容对象
* 委托给 BlockFactory.createBlockContentFromOptions 完成实际转换
* @param blockType 块类型字符串,如 'text'、'code'、'heading1'~'heading9'、'list'、'image' 等
* @param options 块配置选项对象,结构因 blockType 而异
* @returns 可传入 createDocumentBlock / createDocumentBlocks 的块内容对象,失败时返回 null
*/
createBlockContent(blockType, options) {
return this.blockFactory.createBlockContentFromOptions(blockType, options);
}
/**
* 获取当前服务持有的 BlockFactory 实例
* @returns BlockFactory 单例实例
*/
getBlockFactory() {
return this.blockFactory;
}
}