Fix Feishu sheet data writes

This commit is contained in:
wuyang6
2026-05-11 18:05:35 +08:00
parent 3a4da782ec
commit a68302659d
2 changed files with 94 additions and 19 deletions
+54 -11
View File
@@ -2634,10 +2634,10 @@ def _create_feishu_online_spreadsheet(
create_params: dict[str, Any] = {'title': title} create_params: dict[str, Any] = {'title': title}
if folder_token and folder_token.strip(): if folder_token and folder_token.strip():
create_params['folder_token'] = folder_token.strip() create_params['folder_token'] = folder_token.strip()
rendered, metadata = runtime.call_tool( rendered, metadata = _call_feishu_mcp_tool_checked(
runtime,
'sheet_ops', 'sheet_ops',
arguments={'action': 'create', 'params': create_params}, arguments={'action': 'create', 'params': create_params},
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS, max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0, timeout_seconds=60.0,
) )
@@ -2645,13 +2645,14 @@ def _create_feishu_online_spreadsheet(
if not spreadsheet_token: if not spreadsheet_token:
raise DataAgentInputError('飞书表格已创建,但没有解析到 spreadsheet token') raise DataAgentInputError('飞书表格已创建,但没有解析到 spreadsheet token')
sheet_refs = _discover_feishu_sheet_refs(runtime, spreadsheet_token)
for index, sheet_data in enumerate(sheets): for index, sheet_data in enumerate(sheets):
rows = sheet_data['rows'] rows = sheet_data['rows']
if not rows: if not rows:
continue continue
if index == 0: if index == 0:
sheet_ref = sheet_refs[0] if sheet_refs else 'Sheet1' # 新建飞书表格默认会带一个名为 Sheet1 的工作表。
# 不从 spreadsheet meta 里取顶层 title,避免把文件名误当成工作表名。
sheet_ref = 'Sheet1'
else: else:
sheet_ref = _create_feishu_worksheet( sheet_ref = _create_feishu_worksheet(
runtime, runtime,
@@ -2663,6 +2664,44 @@ def _create_feishu_online_spreadsheet(
return rendered, metadata return rendered, metadata
def _call_feishu_mcp_tool_checked(
runtime: MCPRuntime,
tool_name: str,
*,
arguments: dict[str, Any],
max_chars: int,
timeout_seconds: float,
) -> tuple[str, dict[str, Any]]:
rendered, metadata = runtime.call_tool(
tool_name,
arguments=arguments,
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=max_chars,
timeout_seconds=timeout_seconds,
)
error_message = _extract_feishu_mcp_error(rendered)
if metadata.get('is_error') or error_message:
raise DataAgentInputError(error_message or f'飞书 MCP 工具 {tool_name} 调用失败')
return rendered, metadata
def _extract_feishu_mcp_error(text: str) -> str | None:
payload = _parse_first_json_object(text)
if not isinstance(payload, dict):
return None
error_value = payload.get('error')
if not error_value:
return None
suggestion = payload.get('suggestion')
code = payload.get('code')
parts = [str(error_value)]
if code is not None:
parts.append(f'code={code}')
if suggestion:
parts.append(str(suggestion))
return ''.join(parts)
def _load_file_as_feishu_sheets(file_path: Path) -> list[dict[str, Any]]: def _load_file_as_feishu_sheets(file_path: Path) -> list[dict[str, Any]]:
suffix = file_path.suffix.lower() suffix = file_path.suffix.lower()
if suffix == '.csv': if suffix == '.csv':
@@ -2762,10 +2801,10 @@ def _extract_feishu_spreadsheet_token(text: str) -> str | None:
def _discover_feishu_sheet_refs(runtime: MCPRuntime, spreadsheet_token: str) -> list[str]: def _discover_feishu_sheet_refs(runtime: MCPRuntime, spreadsheet_token: str) -> list[str]:
try: try:
rendered, _metadata = runtime.call_tool( rendered, _metadata = _call_feishu_mcp_tool_checked(
runtime,
'sheet_ops', 'sheet_ops',
arguments={'action': 'meta', 'params': {'url_or_token': spreadsheet_token}}, arguments={'action': 'meta', 'params': {'url_or_token': spreadsheet_token}},
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS, max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0, timeout_seconds=60.0,
) )
@@ -2785,7 +2824,11 @@ def _extract_feishu_sheet_refs(text: str) -> list[str]:
raw_title = value.get('title') or value.get('name') raw_title = value.get('title') or value.get('name')
if isinstance(raw_id, str) and raw_id.strip(): if isinstance(raw_id, str) and raw_id.strip():
refs.append(raw_id.strip()) refs.append(raw_id.strip())
elif isinstance(raw_title, str) and raw_title.strip(): elif (
isinstance(raw_title, str)
and raw_title.strip()
and ('index' in value or 'row_count' in value or 'col_count' in value)
):
refs.append(raw_title.strip()) refs.append(raw_title.strip())
for child in value.values(): for child in value.values():
visit(child) visit(child)
@@ -2816,7 +2859,8 @@ def _create_feishu_worksheet(
index: int, index: int,
) -> str: ) -> str:
sheet_title = _clean_feishu_sheet_title(title) or f'Sheet{index + 1}' sheet_title = _clean_feishu_sheet_title(title) or f'Sheet{index + 1}'
rendered, _metadata = runtime.call_tool( rendered, _metadata = _call_feishu_mcp_tool_checked(
runtime,
'sheet_ops', 'sheet_ops',
arguments={ arguments={
'action': 'add_sheet', 'action': 'add_sheet',
@@ -2826,7 +2870,6 @@ def _create_feishu_worksheet(
'index': index, 'index': index,
}, },
}, },
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS, max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0, timeout_seconds=60.0,
) )
@@ -2848,7 +2891,8 @@ def _write_feishu_sheet_rows(
chunk = rows[start : start + FEISHU_SPREADSHEET_WRITE_BATCH_ROWS] chunk = rows[start : start + FEISHU_SPREADSHEET_WRITE_BATCH_ROWS]
start_row = start + 1 start_row = start + 1
end_row = start + len(chunk) end_row = start + len(chunk)
runtime.call_tool( _call_feishu_mcp_tool_checked(
runtime,
'sheet_ops', 'sheet_ops',
arguments={ arguments={
'action': 'write', 'action': 'write',
@@ -2858,7 +2902,6 @@ def _write_feishu_sheet_rows(
'values': json.dumps(chunk, ensure_ascii=False), 'values': json.dumps(chunk, ensure_ascii=False),
}, },
}, },
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS, max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0, timeout_seconds=60.0,
) )
+40 -8
View File
@@ -130,10 +130,6 @@ class FeishuIntegrationTests(unittest.TestCase):
'{"url":"https://mi.feishu.cn/sheets/shtcn123","spreadsheet_token":"shtcn123"}', '{"url":"https://mi.feishu.cn/sheets/shtcn123","spreadsheet_token":"shtcn123"}',
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'}, {'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}', '{"ok":true}',
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'}, {'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
@@ -149,12 +145,12 @@ class FeishuIntegrationTests(unittest.TestCase):
payload = response.json() payload = response.json()
self.assertEqual(payload['kind'], 'sheet') self.assertEqual(payload['kind'], 'sheet')
self.assertEqual(payload['url'], 'https://mi.feishu.cn/sheets/shtcn123') self.assertEqual(payload['url'], 'https://mi.feishu.cn/sheets/shtcn123')
self.assertEqual(call_tool.call_count, 3) self.assertEqual(call_tool.call_count, 2)
self.assertEqual(call_tool.call_args_list[0].args[0], 'sheet_ops') 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[0].kwargs['arguments']['action'], 'create')
self.assertEqual(call_tool.call_args_list[2].kwargs['arguments']['action'], 'write') self.assertEqual(call_tool.call_args_list[1].kwargs['arguments']['action'], 'write')
write_params = call_tool.call_args_list[2].kwargs['arguments']['params'] write_params = call_tool.call_args_list[1].kwargs['arguments']['params']
self.assertEqual(write_params['range'], 'abc123!A1:B2') self.assertEqual(write_params['range'], 'Sheet1!A1:B2')
self.assertEqual( self.assertEqual(
json.loads(write_params['values']), json.loads(write_params['values']),
[['query', 'label'], ['导航到公司', 'Agent(tag="地图导航")']], [['query', 'label'], ['导航到公司', 'Agent(tag="地图导航")']],
@@ -168,6 +164,42 @@ class FeishuIntegrationTests(unittest.TestCase):
online_docs = json.loads(map_path.read_text(encoding='utf-8')) online_docs = json.loads(map_path.read_text(encoding='utf-8'))
self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'sheet') self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'sheet')
def test_create_online_doc_surfaces_feishu_sheet_write_error(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导航到公司,地图导航\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'},
),
(
'{"error":"Sheet not found","code":123}',
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
),
],
):
response = client.post(
'/api/files/online-doc',
json={'account_id': 'alice', 'path': str(file_path)},
)
self.assertEqual(response.status_code, 400)
self.assertIn('Sheet not found', response.json()['detail'])
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(
'{"url":"https://mi.feishu.cn/docx/CZIldpM3QofbbXxxAq1cEininTd"}' '{"url":"https://mi.feishu.cn/docx/CZIldpM3QofbbXxxAq1cEininTd"}'