Create Feishu sheets for spreadsheet files

This commit is contained in:
wuyang6
2026-05-11 17:56:58 +08:00
parent dba827b9b0
commit 3a4da782ec
4 changed files with 410 additions and 23 deletions
+320 -17
View File
@@ -7,6 +7,7 @@ chat, slash commands, and saved sessions, and serves the static SPA.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import csv
import json import json
import os import os
import queue import queue
@@ -58,9 +59,15 @@ API_TOOL_CONTENT_MAX_CHARS = 20000
FEISHU_NPM_REGISTRY = 'https://pkgs.d.xiaomi.net/artifactory/api/npm/mi-npm/' FEISHU_NPM_REGISTRY = 'https://pkgs.d.xiaomi.net/artifactory/api/npm/mi-npm/'
FEISHU_MCP_PACKAGE = '@mi/feishu-mcp-pro@latest' FEISHU_MCP_PACKAGE = '@mi/feishu-mcp-pro@latest'
FEISHU_MCP_SERVER_NAME = 'feishu-mcp-pro' FEISHU_MCP_SERVER_NAME = 'feishu-mcp-pro'
FEISHU_SUPPORTED_DOCUMENT_SUFFIXES = {'.csv', '.docx', '.md', '.txt', '.xlsx'} FEISHU_DOCUMENT_SUFFIXES = {'.docx', '.md', '.txt'}
FEISHU_SPREADSHEET_SUFFIXES = {'.csv', '.xlsx'}
FEISHU_SUPPORTED_DOCUMENT_SUFFIXES = FEISHU_DOCUMENT_SUFFIXES | FEISHU_SPREADSHEET_SUFFIXES
FEISHU_UNSUPPORTED_DOCUMENT_SUFFIXES = {'.json', '.jsonl'} FEISHU_UNSUPPORTED_DOCUMENT_SUFFIXES = {'.json', '.jsonl'}
FEISHU_DOC_MARKDOWN_MAX_CHARS = 100_000 FEISHU_DOC_MARKDOWN_MAX_CHARS = 100_000
FEISHU_SPREADSHEET_MAX_ROWS = 5000
FEISHU_SPREADSHEET_MAX_COLS = 200
FEISHU_SPREADSHEET_MAX_CELL_CHARS = 5000
FEISHU_SPREADSHEET_WRITE_BATCH_ROWS = 500
FEISHU_ONLINE_DOCS_FILENAME = 'online-docs.json' FEISHU_ONLINE_DOCS_FILENAME = 'online-docs.json'
@@ -1076,29 +1083,37 @@ def create_app(state: AgentState) -> FastAPI:
'status': status, 'status': status,
}, },
) )
paths = _feishu_paths(state, payload.account_id)
runtime = MCPRuntime(servers=(_feishu_server_profile(paths),))
title = _clean_feishu_doc_title(payload.title or file_path.stem) title = _clean_feishu_doc_title(payload.title or file_path.stem)
kind = 'sheet' if suffix in FEISHU_SPREADSHEET_SUFFIXES else 'doc'
try: try:
markdown = _convert_file_to_feishu_markdown(file_path, title=title) if kind == 'sheet':
rendered, metadata = _create_feishu_online_spreadsheet(
runtime,
file_path,
title=title,
folder_token=payload.folder_token,
)
else:
markdown = _convert_file_to_feishu_markdown(file_path, title=title)
arguments: dict[str, Any] = {'title': title, 'markdown': markdown}
if payload.folder_token and payload.folder_token.strip():
arguments['folder_token'] = payload.folder_token.strip()
rendered, metadata = runtime.call_tool(
'doc_create',
arguments=arguments,
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
except DataAgentInputError as exc: except DataAgentInputError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
except OSError as exc: except OSError as exc:
raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}') raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}')
arguments: dict[str, Any] = {'title': title, 'markdown': markdown}
if payload.folder_token and payload.folder_token.strip():
arguments['folder_token'] = payload.folder_token.strip()
paths = _feishu_paths(state, payload.account_id)
runtime = MCPRuntime(servers=(_feishu_server_profile(paths),))
try:
rendered, metadata = runtime.call_tool(
'doc_create',
arguments=arguments,
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=502, detail=f'飞书文档创建失败: {exc}') target_name = '飞书表格' if kind == 'sheet' else '飞书文档'
raise HTTPException(status_code=502, detail=f'{target_name}创建失败: {exc}')
url = _extract_first_url(rendered) url = _extract_first_url(rendered)
if url: if url:
_record_feishu_online_doc( _record_feishu_online_doc(
@@ -1107,11 +1122,13 @@ def create_app(state: AgentState) -> FastAPI:
file_path=file_path, file_path=file_path,
title=title, title=title,
url=url, url=url,
kind=kind,
) )
return { return {
'ok': True, 'ok': True,
'title': title, 'title': title,
'url': url, 'url': url,
'kind': kind,
'file_path': str(file_path), 'file_path': str(file_path),
'file_type': suffix.lstrip('.'), 'file_type': suffix.lstrip('.'),
'result': rendered, 'result': rendered,
@@ -2553,6 +2570,7 @@ def _record_feishu_online_doc(
file_path: Path, file_path: Path,
title: str, title: str,
url: str, url: str,
kind: str = 'doc',
) -> None: ) -> None:
paths = _feishu_paths(state, account_id) paths = _feishu_paths(state, account_id)
map_path = paths['root'] / FEISHU_ONLINE_DOCS_FILENAME map_path = paths['root'] / FEISHU_ONLINE_DOCS_FILENAME
@@ -2575,6 +2593,7 @@ def _record_feishu_online_doc(
files[str(file_path)] = { files[str(file_path)] = {
'url': clean_url, 'url': clean_url,
'title': title, 'title': title,
'kind': kind,
'file_path': str(file_path), 'file_path': str(file_path),
'created_at': created_at, 'created_at': created_at,
'updated_at': now, 'updated_at': now,
@@ -2604,6 +2623,290 @@ def _clean_feishu_doc_title(value: str) -> str:
return title[:80].strip() or 'Claw 生成文档' return title[:80].strip() or 'Claw 生成文档'
def _create_feishu_online_spreadsheet(
runtime: MCPRuntime,
file_path: Path,
*,
title: str,
folder_token: str | None = None,
) -> tuple[str, dict[str, Any]]:
sheets = _load_file_as_feishu_sheets(file_path)
create_params: dict[str, Any] = {'title': title}
if folder_token and folder_token.strip():
create_params['folder_token'] = folder_token.strip()
rendered, metadata = runtime.call_tool(
'sheet_ops',
arguments={'action': 'create', 'params': create_params},
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
spreadsheet_token = _extract_feishu_spreadsheet_token(rendered)
if not spreadsheet_token:
raise DataAgentInputError('飞书表格已创建,但没有解析到 spreadsheet token')
sheet_refs = _discover_feishu_sheet_refs(runtime, spreadsheet_token)
for index, sheet_data in enumerate(sheets):
rows = sheet_data['rows']
if not rows:
continue
if index == 0:
sheet_ref = sheet_refs[0] if sheet_refs else 'Sheet1'
else:
sheet_ref = _create_feishu_worksheet(
runtime,
spreadsheet_token,
title=str(sheet_data.get('title') or f'Sheet{index + 1}'),
index=index,
)
_write_feishu_sheet_rows(runtime, spreadsheet_token, sheet_ref, rows)
return rendered, metadata
def _load_file_as_feishu_sheets(file_path: Path) -> list[dict[str, Any]]:
suffix = file_path.suffix.lower()
if suffix == '.csv':
rows = _load_csv_rows_for_feishu(file_path)
return [{'title': _clean_feishu_sheet_title(file_path.stem) or 'Sheet1', 'rows': rows}]
if suffix == '.xlsx':
return _load_xlsx_rows_for_feishu(file_path)
raise DataAgentInputError(f'不支持转为飞书表格的文件类型: {suffix}')
def _load_csv_rows_for_feishu(file_path: Path) -> list[list[str]]:
rows: list[list[str]] = []
with file_path.open('r', encoding='utf-8-sig', errors='replace', newline='') as handle:
reader = csv.reader(handle)
for row_index, row in enumerate(reader):
if row_index >= FEISHU_SPREADSHEET_MAX_ROWS:
break
rows.append(_clean_feishu_sheet_row(row))
normalized_rows = _normalize_feishu_sheet_rows(rows)
if not normalized_rows:
raise DataAgentInputError('没有解析到可转成飞书表格的数据')
return normalized_rows
def _load_xlsx_rows_for_feishu(file_path: Path) -> list[dict[str, Any]]:
try:
import openpyxl # type: ignore[import-not-found]
except ImportError as exc:
raise DataAgentInputError('解析 xlsx 需要 openpyxl') from exc
workbook = openpyxl.load_workbook(file_path, data_only=True, read_only=True)
sheets: list[dict[str, Any]] = []
for index, sheet in enumerate(workbook.worksheets):
if hasattr(sheet, 'reset_dimensions'):
sheet.reset_dimensions()
rows: list[list[str]] = []
for row_index, row in enumerate(sheet.iter_rows(values_only=True)):
if row_index >= FEISHU_SPREADSHEET_MAX_ROWS:
break
rows.append(_clean_feishu_sheet_row(row))
normalized_rows = _normalize_feishu_sheet_rows(rows)
if normalized_rows:
sheets.append(
{
'title': _clean_feishu_sheet_title(str(sheet.title)) or f'Sheet{index + 1}',
'rows': normalized_rows,
}
)
if not sheets:
raise DataAgentInputError('没有解析到可转成飞书表格的数据')
return sheets
def _clean_feishu_sheet_row(row: Any) -> list[str]:
if not isinstance(row, (list, tuple)):
return []
return [_clean_feishu_sheet_cell(cell) for cell in row[:FEISHU_SPREADSHEET_MAX_COLS]]
def _clean_feishu_sheet_cell(value: Any) -> str:
if value is None:
return ''
text = str(value)
if len(text) > FEISHU_SPREADSHEET_MAX_CELL_CHARS:
return text[:FEISHU_SPREADSHEET_MAX_CELL_CHARS]
return text
def _normalize_feishu_sheet_rows(rows: list[list[str]]) -> list[list[str]]:
while rows and not any(cell for cell in rows[-1]):
rows.pop()
if not rows:
return []
width = max((len(row) for row in rows), default=0)
width = min(max(width, 1), FEISHU_SPREADSHEET_MAX_COLS)
return [row[:width] + [''] * max(0, width - len(row)) for row in rows]
def _clean_feishu_sheet_title(value: str) -> str:
title = re.sub(r'[\r\n\t/\\?*\[\]:]+', ' ', value).strip()
return title[:80].strip()
def _extract_feishu_spreadsheet_token(text: str) -> str | None:
url = _extract_first_url(text)
candidates = [item for item in (url, text) if item]
for candidate in candidates:
match = re.search(r'/sheets/([A-Za-z0-9]+)', candidate)
if match:
return match.group(1)
payload = _parse_first_json_object(text)
token = _find_first_string_value(
payload,
{'spreadsheet_token', 'spreadsheetToken', 'token'},
)
return token.strip() if token else None
def _discover_feishu_sheet_refs(runtime: MCPRuntime, spreadsheet_token: str) -> list[str]:
try:
rendered, _metadata = runtime.call_tool(
'sheet_ops',
arguments={'action': 'meta', 'params': {'url_or_token': spreadsheet_token}},
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
except Exception:
return []
refs = _extract_feishu_sheet_refs(rendered)
return refs
def _extract_feishu_sheet_refs(text: str) -> list[str]:
payload = _parse_first_json_object(text)
refs: list[str] = []
def visit(value: Any) -> None:
if isinstance(value, dict):
raw_id = value.get('sheet_id') or value.get('sheetId')
raw_title = value.get('title') or value.get('name')
if isinstance(raw_id, str) and raw_id.strip():
refs.append(raw_id.strip())
elif isinstance(raw_title, str) and raw_title.strip():
refs.append(raw_title.strip())
for child in value.values():
visit(child)
elif isinstance(value, list):
for child in value:
visit(child)
visit(payload)
return _dedupe_strings([ref for ref in refs if ref])
def _dedupe_strings(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value in seen:
continue
seen.add(value)
result.append(value)
return result
def _create_feishu_worksheet(
runtime: MCPRuntime,
spreadsheet_token: str,
*,
title: str,
index: int,
) -> str:
sheet_title = _clean_feishu_sheet_title(title) or f'Sheet{index + 1}'
rendered, _metadata = runtime.call_tool(
'sheet_ops',
arguments={
'action': 'add_sheet',
'params': {
'spreadsheet_token': spreadsheet_token,
'title': sheet_title,
'index': index,
},
},
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
refs = _extract_feishu_sheet_refs(rendered)
return refs[0] if refs else sheet_title
def _write_feishu_sheet_rows(
runtime: MCPRuntime,
spreadsheet_token: str,
sheet_ref: str,
rows: list[list[str]],
) -> None:
if not rows:
return
width = max(len(row) for row in rows)
end_column = _spreadsheet_column_name(width)
for start in range(0, len(rows), FEISHU_SPREADSHEET_WRITE_BATCH_ROWS):
chunk = rows[start : start + FEISHU_SPREADSHEET_WRITE_BATCH_ROWS]
start_row = start + 1
end_row = start + len(chunk)
runtime.call_tool(
'sheet_ops',
arguments={
'action': 'write',
'params': {
'spreadsheet_token': spreadsheet_token,
'range': f'{sheet_ref}!A{start_row}:{end_column}{end_row}',
'values': json.dumps(chunk, ensure_ascii=False),
},
},
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
def _spreadsheet_column_name(index: int) -> str:
if index <= 0:
raise ValueError('index must be positive')
name = ''
current = index
while current:
current, remainder = divmod(current - 1, 26)
name = chr(ord('A') + remainder) + name
return name
def _parse_first_json_object(text: str) -> Any:
stripped = text.strip()
candidates = [stripped]
json_match = re.search(r'(\{.*\})', stripped, flags=re.DOTALL)
if json_match:
candidates.append(json_match.group(1))
for candidate in candidates:
try:
return json.loads(candidate)
except json.JSONDecodeError:
continue
return None
def _find_first_string_value(value: Any, keys: set[str]) -> str | None:
if isinstance(value, dict):
for key in keys:
item = value.get(key)
if isinstance(item, str) and item.strip():
return item
for child in value.values():
found = _find_first_string_value(child, keys)
if found:
return found
elif isinstance(value, list):
for child in value:
found = _find_first_string_value(child, keys)
if found:
return found
return None
def _convert_file_to_feishu_markdown(file_path: Path, *, title: str) -> str: def _convert_file_to_feishu_markdown(file_path: Path, *, title: str) -> str:
suffix = file_path.suffix.lower() suffix = file_path.suffix.lower()
if suffix == '.md': if suffix == '.md':
+2
View File
@@ -107,6 +107,7 @@ async function listSessionFiles(
download_url: `/api/claw/files?path=${encodeURIComponent(filePath)}`, download_url: `/api/claw/files?path=${encodeURIComponent(filePath)}`,
online_doc_url: cleanOnlineDocUrl(onlineDoc?.url) ?? null, online_doc_url: cleanOnlineDocUrl(onlineDoc?.url) ?? null,
online_doc_title: onlineDoc?.title ?? null, online_doc_title: onlineDoc?.title ?? null,
online_doc_kind: onlineDoc?.kind ?? null,
online_doc_updated_at: onlineDoc?.updated_at ?? null, online_doc_updated_at: onlineDoc?.updated_at ?? null,
}; };
}), }),
@@ -125,6 +126,7 @@ function cleanOnlineDocUrl(value: unknown) {
type OnlineDocRecord = { type OnlineDocRecord = {
url?: string; url?: string;
title?: string; title?: string;
kind?: string;
updated_at?: number; updated_at?: number;
}; };
@@ -223,6 +223,7 @@ type SessionFile = {
download_url: string; download_url: string;
online_doc_url?: string | null; online_doc_url?: string | null;
online_doc_title?: string | null; online_doc_title?: string | null;
online_doc_kind?: string | null;
online_doc_updated_at?: number | null; online_doc_updated_at?: number | null;
}; };
@@ -392,10 +393,14 @@ function SessionFileRow({ file }: { file: SessionFile }) {
status: "idle" | "loading" | "auth" | "done" | "error"; status: "idle" | "loading" | "auth" | "done" | "error";
message?: string; message?: string;
url?: string | null; url?: string | null;
kind?: string | null;
}>({ }>({
status: file.online_doc_url ? "done" : "idle", status: file.online_doc_url ? "done" : "idle",
message: file.online_doc_url ? "已转在线文档" : undefined, message: file.online_doc_url
? `已转${onlineFileKindLabel(file.online_doc_kind, file.name)}`
: undefined,
url: file.online_doc_url ?? null, url: file.online_doc_url ?? null,
kind: file.online_doc_kind ?? null,
}); });
const canCreateOnlineDoc = isOnlineDocSupported(file.name); const canCreateOnlineDoc = isOnlineDocSupported(file.name);
const onlineDocUrl = onlineDoc.url ?? file.online_doc_url ?? null; const onlineDocUrl = onlineDoc.url ?? file.online_doc_url ?? null;
@@ -406,7 +411,7 @@ function SessionFileRow({ file }: { file: SessionFile }) {
try { try {
pendingWindow = window.open("about:blank", "_blank"); pendingWindow = window.open("about:blank", "_blank");
if (pendingWindow) { if (pendingWindow) {
pendingWindow.document.write("正在创建飞书在线文..."); pendingWindow.document.write("正在创建飞书在线文...");
} }
} catch { } catch {
pendingWindow = null; pendingWindow = null;
@@ -472,6 +477,7 @@ function SessionFileRow({ file }: { file: SessionFile }) {
return; return;
} }
const url = typeof payload.url === "string" ? payload.url : null; const url = typeof payload.url === "string" ? payload.url : null;
const kind = typeof payload.kind === "string" ? payload.kind : null;
if (url) { if (url) {
if (pendingWindow) { if (pendingWindow) {
try { try {
@@ -488,8 +494,11 @@ function SessionFileRow({ file }: { file: SessionFile }) {
} }
setOnlineDoc({ setOnlineDoc({
status: "done", status: "done",
message: url ? "已生成在线文档" : "已生成", message: url
? `已生成${onlineFileKindLabel(kind, file.name)}`
: "已生成",
url, url,
kind,
}); });
window.dispatchEvent(new CustomEvent("claw-session-files-changed")); window.dispatchEvent(new CustomEvent("claw-session-files-changed"));
} catch (error) { } catch (error) {
@@ -513,7 +522,7 @@ function SessionFileRow({ file }: { file: SessionFile }) {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="block truncate font-medium text-primary text-sm underline-offset-4 hover:underline" className="block truncate font-medium text-primary text-sm underline-offset-4 hover:underline"
title={`${file.name} · 打开在线文档`} title={`${file.name} · 打开${onlineFileKindLabel(onlineDoc.kind ?? file.online_doc_kind, file.name)}`}
> >
{file.name} {file.name}
</a> </a>
@@ -561,8 +570,8 @@ function SessionFileRow({ file }: { file: SessionFile }) {
<button <button
type="button" type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50" className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`${file.name} 转为在线文`} aria-label={`${file.name} 转为飞书在线文`}
title="转为在线文" title="转为飞书在线文"
disabled={onlineDoc.status === "loading"} disabled={onlineDoc.status === "loading"}
onClick={createOnlineDoc} onClick={createOnlineDoc}
> >
@@ -590,6 +599,18 @@ function isOnlineDocSupported(fileName: string) {
); );
} }
function onlineFileKindLabel(
kind: string | null | undefined,
fileName: string,
) {
const normalized = kind?.toLowerCase();
if (normalized === "sheet") return "在线表格";
if (["csv", "xlsx"].includes(fileExtension(fileName).toLowerCase())) {
return "在线表格";
}
return "在线文档";
}
function extractErrorMessage(payload: Record<string, unknown>) { function extractErrorMessage(payload: Record<string, unknown>) {
const detail = payload.detail; const detail = payload.detail;
if (typeof detail === "string") return detail; if (typeof detail === "string") return detail;
+61
View File
@@ -106,6 +106,67 @@ class FeishuIntegrationTests(unittest.TestCase):
online_docs['files'][str(file_path)]['url'], online_docs['files'][str(file_path)]['url'],
'https://mi.feishu.cn/docx/example', 'https://mi.feishu.cn/docx/example',
) )
self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'doc')
def test_create_online_doc_converts_csv_to_feishu_sheet(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
client, state = _build_client(Path(tmp_dir))
file_path = _write_account_file(
state,
'alice',
'records.csv',
'query,label\n导航到公司,"Agent(tag=""地图导航"")"\n',
)
with patch.object(
gui_server,
'_feishu_status_payload',
return_value={'logged_in': True, 'status': 'logged_in'},
), patch.object(
gui_server.MCPRuntime,
'call_tool',
side_effect=[
(
'{"url":"https://mi.feishu.cn/sheets/shtcn123","spreadsheet_token":"shtcn123"}',
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
),
(
'{"sheets":[{"sheet_id":"abc123","title":"Sheet1"}]}',
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
),
(
'{"ok":true}',
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
),
],
) as call_tool:
response = client.post(
'/api/files/online-doc',
json={'account_id': 'alice', 'path': str(file_path)},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload['kind'], 'sheet')
self.assertEqual(payload['url'], 'https://mi.feishu.cn/sheets/shtcn123')
self.assertEqual(call_tool.call_count, 3)
self.assertEqual(call_tool.call_args_list[0].args[0], 'sheet_ops')
self.assertEqual(call_tool.call_args_list[0].kwargs['arguments']['action'], 'create')
self.assertEqual(call_tool.call_args_list[2].kwargs['arguments']['action'], 'write')
write_params = call_tool.call_args_list[2].kwargs['arguments']['params']
self.assertEqual(write_params['range'], 'abc123!A1:B2')
self.assertEqual(
json.loads(write_params['values']),
[['query', 'label'], ['导航到公司', 'Agent(tag="地图导航")']],
)
map_path = (
state.account_paths('alice')['base']
/ 'integrations'
/ 'feishu'
/ 'online-docs.json'
)
online_docs = json.loads(map_path.read_text(encoding='utf-8'))
self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'sheet')
def test_extract_first_url_ignores_wrapping_quotes(self) -> None: def test_extract_first_url_ignores_wrapping_quotes(self) -> None:
url = gui_server._extract_first_url( url = gui_server._extract_first_url(