Fix Feishu sheet data writes
This commit is contained in:
+54
-11
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"}'
|
||||||
|
|||||||
Reference in New Issue
Block a user