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
|
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':
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user