feishu-mcp
Version:
Model Context Protocol server for Feishu integration
233 lines (232 loc) • 12.5 kB
JavaScript
import { Logger } from '../../../utils/logger.js';
import { Config } from '../../../utils/config.js';
import { FeishuBaseApiService } from '../../../services/feishu/FeishuBaseApiService.js';
/**
* 飞书搜索服务
* 负责文档和知识库的搜索
*/
export class FeishuSearchService extends FeishuBaseApiService {
constructor(authService) {
super(authService);
}
/**
* 搜索飞书文档(Docs),支持分页轮询直到获取指定数量的结果
* 注意:仅支持 tenant 和 user 两种认证模式
* @param searchKey 搜索关键字
* @param maxSize 最大返回数量;不指定时只返回第一页(约 50 条)
* @param offset 起始偏移量,用于分页续取,默认 0
* @returns 搜索结果对象,包含 items(文档列表)、hasMore(是否还有更多)、nextOffset(下次请求的偏移量)
*/
async searchDocuments(searchKey, maxSize, offset = 0) {
try {
Logger.info(`开始搜索文档,关键字: ${searchKey}, maxSize: ${maxSize || '未指定'}, offset: ${offset}`);
const endpoint = '/suite/docs-api/search/object';
const PAGE_SIZE = 50;
const allResults = [];
let currentOffset = offset;
let hasMore = true;
while (hasMore && (maxSize === undefined || allResults.length < maxSize)) {
const payload = {
search_key: searchKey,
docs_types: ['doc'],
count: PAGE_SIZE,
offset: currentOffset
};
Logger.debug(`请求搜索文档,offset: ${currentOffset}, count: ${PAGE_SIZE}`);
const response = await this.post(endpoint, payload);
Logger.debug('搜索响应:', JSON.stringify(response, null, 2));
if (response?.docs_entities) {
const resultCount = response.docs_entities.length;
const apiHasMore = response.has_more || false;
currentOffset += resultCount;
if (resultCount > 0) {
allResults.push(...response.docs_entities);
hasMore = apiHasMore;
if (maxSize == undefined || allResults.length >= maxSize) {
Logger.debug(`已达到maxSize ${maxSize},停止获取,但API还有更多: ${hasMore}`);
break;
}
}
else {
hasMore = false;
}
Logger.debug(`文档搜索进度: 已获取 ${allResults.length} 条,hasMore: ${hasMore}`);
}
else {
Logger.warn('搜索响应格式异常:', JSON.stringify(response, null, 2));
hasMore = false;
}
}
Logger.info(`文档搜索完成,找到 ${allResults.length} 个结果${maxSize ? `(maxSize: ${maxSize})` : ''}`);
return { items: allResults, hasMore, nextOffset: currentOffset };
}
catch (error) {
this.handleApiError(error, '搜索文档失败');
throw error;
}
}
/**
* 搜索飞书知识库(Wiki)节点,支持分页轮询直到获取指定数量的结果
* 注意:Wiki 搜索需要 user 认证,tenant 模式下不可用
* @param query 搜索关键字
* @param maxSize 最大返回数量;不指定时只返回第一页(约 20 条)
* @param pageToken 分页 Token,用于续取下一页,从上次返回的 pageToken 传入
* @returns 搜索结果对象,包含 items(节点列表)、hasMore、pageToken(下次请求用)、count(本次数量)
*/
async searchWikiNodes(query, maxSize, pageToken) {
try {
Logger.info(`开始搜索知识库,关键字: ${query}, maxSize: ${maxSize || '未指定'}, pageToken: ${pageToken || '无'}`);
const endpoint = '/wiki/v1/nodes/search';
const PAGE_SIZE = 20;
const allResults = [];
let currentPageToken = pageToken;
let hasMore = true;
while (hasMore && (maxSize === undefined || allResults.length < maxSize)) {
const size = Math.min(PAGE_SIZE, 100);
let url = `${endpoint}?page_size=${size}`;
if (currentPageToken)
url += `&page_token=${currentPageToken}`;
const payload = { query };
Logger.debug(`请求搜索知识库,pageSize: ${size}, pageToken: ${currentPageToken || '无'}`);
const response = await this.post(url, payload);
Logger.debug('知识库搜索响应:', JSON.stringify(response, null, 2));
if (response?.items) {
const resultCount = response.items?.length || 0;
const apiHasMore = response.has_more || false;
currentPageToken = response.page_token || null;
if (resultCount > 0) {
allResults.push(...response.items);
hasMore = apiHasMore;
if (maxSize !== undefined) {
if (allResults.length >= maxSize) {
Logger.debug(`已达到maxSize ${maxSize},停止获取,但API还有更多: ${hasMore}`);
break;
}
}
else {
break;
}
}
else {
hasMore = false;
}
Logger.debug(`知识库搜索进度: 已获取 ${allResults.length} 条,hasMore: ${hasMore}`);
}
else {
Logger.warn('知识库搜索响应格式异常:', JSON.stringify(response, null, 2));
hasMore = false;
}
}
Logger.info(`知识库搜索完成,找到 ${allResults.length} 个结果${maxSize ? `(maxSize: ${maxSize})` : ''}`);
return { items: allResults, hasMore, pageToken: currentPageToken, count: allResults.length };
}
catch (error) {
this.handleApiError(error, '搜索知识库失败');
throw error;
}
}
/**
* 统一搜索入口,可同时搜索文档和知识库节点
* 当 authType 为 tenant 时,wiki 搜索不可用,会自动降级为 document 搜索
* 单次调用最多返回 100 条合并结果(文档 + Wiki)
* @param searchKey 搜索关键字
* @param searchType 搜索范围:'document' 仅搜文档,'wiki' 仅搜知识库,'both' 同时搜索,默认 'both'
* @param offset 文档搜索的起始偏移量,用于分页续取,默认 0
* @param pageToken 知识库搜索的分页 Token,用于续取下一页
* @returns 搜索结果对象,包含 documents(文档列表)、wikis(知识库节点列表)和 paginationGuide(分页指导)
*/
async search(searchKey, searchType = 'both', offset, pageToken) {
try {
if (Config.getInstance().feishu.authType === 'tenant' && (searchType === 'wiki' || searchType === 'both')) {
Logger.info(`租户认证模式下wiki搜索不支持,强制将searchType从 ${searchType} 修改为 document`);
searchType = 'document';
}
const MAX_TOTAL_RESULTS = 100;
const docOffset = offset ?? 0;
Logger.info(`开始统一搜索,关键字: ${searchKey}, 类型: ${searchType}, offset: ${docOffset}, pageToken: ${pageToken || '无'}`);
const documents = [];
const wikis = [];
let documentOffset = docOffset;
let wikiPageToken = null;
let documentHasMore = false;
let wikiHasMore = false;
if (searchType === 'document' || searchType === 'both') {
const docResult = await this.searchDocuments(searchKey, MAX_TOTAL_RESULTS, docOffset);
if (docResult.items?.length > 0) {
documents.push(...docResult.items);
documentOffset = docResult.nextOffset;
documentHasMore = docResult.hasMore;
Logger.debug(`文档搜索: 获取 ${docResult.items.length} 条,新offset: ${documentOffset}, hasMore: ${documentHasMore}`);
}
else {
documentHasMore = false;
Logger.debug('文档搜索: 无结果');
}
}
if (searchType === 'wiki' || searchType === 'both') {
const remainingCount = MAX_TOTAL_RESULTS - documents.length;
if (remainingCount > 0) {
const wikiResult = await this.searchWikiNodes(searchKey, remainingCount, pageToken);
if (wikiResult.items?.length > 0) {
wikis.push(...wikiResult.items);
wikiPageToken = wikiResult.pageToken;
wikiHasMore = wikiResult.hasMore;
Logger.debug(`知识库搜索: 获取 ${wikiResult.items.length} 条,pageToken: ${wikiPageToken || '无'}, hasMore: ${wikiHasMore}`);
}
else {
wikiHasMore = false;
Logger.debug('知识库搜索: 无结果');
}
}
else {
Logger.info(`已达到总限制 ${MAX_TOTAL_RESULTS} 条,不再获取知识库`);
wikiHasMore = true;
}
}
const paginationGuide = this.generatePaginationGuide(searchType, documentHasMore, wikiHasMore, documentOffset, wikiPageToken);
const total = documents.length + wikis.length;
const hasMore = documentHasMore || wikiHasMore;
Logger.info(`统一搜索完成,文档: ${documents.length} 条, 知识库: ${wikis.length} 条, 总计: ${total} 条, hasMore: ${hasMore}`);
const result = { paginationGuide };
if (searchType === 'document' || searchType === 'both')
result.documents = documents;
if (searchType === 'wiki' || searchType === 'both')
result.wikis = wikis;
return result;
}
catch (error) {
this.handleApiError(error, '统一搜索失败');
throw error;
}
}
generatePaginationGuide(searchType, documentHasMore, wikiHasMore, documentOffset, wikiPageToken) {
const guide = { hasMore: documentHasMore || wikiHasMore, description: '' };
if (!guide.hasMore) {
guide.description = '没有更多结果了';
return guide;
}
if (searchType === 'document') {
if (documentHasMore) {
guide.nextPageParams = { searchType: 'document', offset: documentOffset };
guide.description = `请使用 search_feishu_documents工具获取下一页,searchType = document offset=${documentOffset} 获取文档的下一页`;
}
}
else if (searchType === 'wiki') {
if (wikiHasMore && wikiPageToken) {
guide.nextPageParams = { searchType: 'wiki', pageToken: wikiPageToken };
guide.description = `请使用 search_feishu_documents工具获取下一页,searchType = wiki pageToken="${wikiPageToken}" 获取知识库的下一页`;
}
}
else if (searchType === 'both') {
if (documentHasMore) {
guide.nextPageParams = { searchType: 'both', offset: documentOffset };
guide.description = `请使用 search_feishu_documents工具获取下一页,searchType = both offset=${documentOffset} 获取文档的下一页`;
}
else if (wikiHasMore && wikiPageToken) {
guide.nextPageParams = { searchType: 'wiki', pageToken: wikiPageToken };
guide.description = `请使用 search_feishu_documents工具获取下一页,searchType = wiki pageToken="${wikiPageToken}" 获取知识库的下一页wiki结果`;
}
}
return guide;
}
}