Create Feishu sheets for spreadsheet files
This commit is contained in:
+320
-17
@@ -7,6 +7,7 @@ chat, slash commands, and saved sessions, and serves the static SPA.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
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_MCP_PACKAGE = '@mi/feishu-mcp-pro@latest'
|
||||
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_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'
|
||||
|
||||
|
||||
@@ -1076,29 +1083,37 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
'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)
|
||||
kind = 'sheet' if suffix in FEISHU_SPREADSHEET_SUFFIXES else 'doc'
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except OSError as 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:
|
||||
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)
|
||||
if url:
|
||||
_record_feishu_online_doc(
|
||||
@@ -1107,11 +1122,13 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
file_path=file_path,
|
||||
title=title,
|
||||
url=url,
|
||||
kind=kind,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'kind': kind,
|
||||
'file_path': str(file_path),
|
||||
'file_type': suffix.lstrip('.'),
|
||||
'result': rendered,
|
||||
@@ -2553,6 +2570,7 @@ def _record_feishu_online_doc(
|
||||
file_path: Path,
|
||||
title: str,
|
||||
url: str,
|
||||
kind: str = 'doc',
|
||||
) -> None:
|
||||
paths = _feishu_paths(state, account_id)
|
||||
map_path = paths['root'] / FEISHU_ONLINE_DOCS_FILENAME
|
||||
@@ -2575,6 +2593,7 @@ def _record_feishu_online_doc(
|
||||
files[str(file_path)] = {
|
||||
'url': clean_url,
|
||||
'title': title,
|
||||
'kind': kind,
|
||||
'file_path': str(file_path),
|
||||
'created_at': created_at,
|
||||
'updated_at': now,
|
||||
@@ -2604,6 +2623,290 @@ def _clean_feishu_doc_title(value: str) -> str:
|
||||
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:
|
||||
suffix = file_path.suffix.lower()
|
||||
if suffix == '.md':
|
||||
|
||||
@@ -107,6 +107,7 @@ async function listSessionFiles(
|
||||
download_url: `/api/claw/files?path=${encodeURIComponent(filePath)}`,
|
||||
online_doc_url: cleanOnlineDocUrl(onlineDoc?.url) ?? null,
|
||||
online_doc_title: onlineDoc?.title ?? null,
|
||||
online_doc_kind: onlineDoc?.kind ?? null,
|
||||
online_doc_updated_at: onlineDoc?.updated_at ?? null,
|
||||
};
|
||||
}),
|
||||
@@ -125,6 +126,7 @@ function cleanOnlineDocUrl(value: unknown) {
|
||||
type OnlineDocRecord = {
|
||||
url?: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
updated_at?: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ type SessionFile = {
|
||||
download_url: string;
|
||||
online_doc_url?: string | null;
|
||||
online_doc_title?: string | null;
|
||||
online_doc_kind?: string | null;
|
||||
online_doc_updated_at?: number | null;
|
||||
};
|
||||
|
||||
@@ -392,10 +393,14 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
status: "idle" | "loading" | "auth" | "done" | "error";
|
||||
message?: string;
|
||||
url?: string | null;
|
||||
kind?: string | null;
|
||||
}>({
|
||||
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,
|
||||
kind: file.online_doc_kind ?? null,
|
||||
});
|
||||
const canCreateOnlineDoc = isOnlineDocSupported(file.name);
|
||||
const onlineDocUrl = onlineDoc.url ?? file.online_doc_url ?? null;
|
||||
@@ -406,7 +411,7 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
try {
|
||||
pendingWindow = window.open("about:blank", "_blank");
|
||||
if (pendingWindow) {
|
||||
pendingWindow.document.write("正在创建飞书在线文档...");
|
||||
pendingWindow.document.write("正在创建飞书在线文件...");
|
||||
}
|
||||
} catch {
|
||||
pendingWindow = null;
|
||||
@@ -472,6 +477,7 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
return;
|
||||
}
|
||||
const url = typeof payload.url === "string" ? payload.url : null;
|
||||
const kind = typeof payload.kind === "string" ? payload.kind : null;
|
||||
if (url) {
|
||||
if (pendingWindow) {
|
||||
try {
|
||||
@@ -488,8 +494,11 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
}
|
||||
setOnlineDoc({
|
||||
status: "done",
|
||||
message: url ? "已生成在线文档" : "已生成",
|
||||
message: url
|
||||
? `已生成${onlineFileKindLabel(kind, file.name)}`
|
||||
: "已生成",
|
||||
url,
|
||||
kind,
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("claw-session-files-changed"));
|
||||
} catch (error) {
|
||||
@@ -513,7 +522,7 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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}
|
||||
</a>
|
||||
@@ -561,8 +570,8 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
<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"
|
||||
aria-label={`将 ${file.name} 转为在线文档`}
|
||||
title="转为在线文档"
|
||||
aria-label={`将 ${file.name} 转为飞书在线文件`}
|
||||
title="转为飞书在线文件"
|
||||
disabled={onlineDoc.status === "loading"}
|
||||
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>) {
|
||||
const detail = payload.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
|
||||
@@ -106,6 +106,67 @@ class FeishuIntegrationTests(unittest.TestCase):
|
||||
online_docs['files'][str(file_path)]['url'],
|
||||
'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:
|
||||
url = gui_server._extract_first_url(
|
||||
|
||||
Reference in New Issue
Block a user