feat: add dingtalk excel image-link file workflow

This commit is contained in:
Mayorz 2026-03-04 14:55:14 +08:00
parent 90236eaf4c
commit 5fc195f71d
5 changed files with 630 additions and 4 deletions

View File

@ -12,6 +12,7 @@ import mimetypes
import os import os
import re import re
import subprocess import subprocess
import sys
import tempfile import tempfile
import time import time
import urllib.parse import urllib.parse
@ -296,6 +297,22 @@ class OpenClawDingTalkBot(ChatbotHandler):
self.video_analysis_timeout = max(15, min(int(os.getenv("VIDEO_ANALYSIS_TIMEOUT", "360")), 900)) self.video_analysis_timeout = max(15, min(int(os.getenv("VIDEO_ANALYSIS_TIMEOUT", "360")), 900))
self.video_analysis_max_tokens = max(256, min(int(os.getenv("VIDEO_ANALYSIS_MAX_TOKENS", "1800")), 8192)) self.video_analysis_max_tokens = max(256, min(int(os.getenv("VIDEO_ANALYSIS_MAX_TOKENS", "1800")), 8192))
self.video_analysis_max_urls = max(1, min(int(os.getenv("VIDEO_ANALYSIS_MAX_URLS", "2")), 3)) self.video_analysis_max_urls = max(1, min(int(os.getenv("VIDEO_ANALYSIS_MAX_URLS", "2")), 3))
self.excel_link_feature_enabled = bool_env("EXCEL_IMAGE_LINK_FEATURE_ENABLED", True)
self.excel_converter_script = (
os.getenv(
"EXCEL_IMAGE_CONVERTER_SCRIPT",
"/opt/openclaw-dingtalk-bridge/skills/dingtalk-excel-image-return/convert_excel_image_links.py",
).strip()
or "/opt/openclaw-dingtalk-bridge/skills/dingtalk-excel-image-return/convert_excel_image_links.py"
)
self.excel_converter_timeout = max(60, min(int(os.getenv("EXCEL_IMAGE_CONVERTER_TIMEOUT", "480")), 3600))
self.excel_link_column = os.getenv("EXCEL_IMAGE_LINK_COLUMN", "图片链接").strip() or "图片链接"
self.excel_image_column_header = os.getenv("EXCEL_IMAGE_COLUMN_HEADER", "图片").strip() or "图片"
self.excel_image_max_width = max(80, min(int(os.getenv("EXCEL_IMAGE_MAX_WIDTH", "140")), 1200))
self.excel_image_max_height = max(80, min(int(os.getenv("EXCEL_IMAGE_MAX_HEIGHT", "140")), 1200))
self.excel_image_download_timeout = max(3, min(int(os.getenv("EXCEL_IMAGE_DOWNLOAD_TIMEOUT", "20")), 120))
self.excel_file_download_timeout = max(5, min(int(os.getenv("EXCEL_FILE_DOWNLOAD_TIMEOUT", "60")), 300))
self.excel_skip_tls_verify = bool_env("EXCEL_IMAGE_SKIP_TLS_VERIFY", False)
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool: def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
if text.startswith(self.prefix): if text.startswith(self.prefix):
@ -1401,6 +1418,72 @@ class OpenClawDingTalkBot(ChatbotHandler):
break break
return codes return codes
def _normalize_file_name(self, raw_name: str) -> str:
name = str(raw_name or "").strip().replace("\\", "_").replace("/", "_")
name = re.sub(r"\s+", "_", name)
name = re.sub(r"[^0-9A-Za-z._-]+", "_", name).strip("._")
if not name:
return f"upload_{int(time.time())}.xlsx"
return name[:120]
def _is_supported_excel_file(self, file_name: str) -> bool:
return str(file_name or "").strip().lower().endswith(".xlsx")
def _find_download_code_payload(self, value):
stack = [value]
while stack:
current = stack.pop()
if current is None:
continue
if isinstance(current, str):
content = current.strip()
if not content:
continue
if content.startswith("{") or content.startswith("["):
try:
stack.append(json.loads(content))
continue
except Exception:
continue
if isinstance(current, dict):
lowered = {str(k).lower(): k for k in current.keys()}
if "downloadcode" in lowered:
raw_code = current.get(lowered["downloadcode"])
code = str(raw_code or "").strip()
if code:
file_name = ""
for candidate in ("fileName", "filename", "file_name", "name", "title"):
if candidate in current and str(current.get(candidate) or "").strip():
file_name = str(current.get(candidate)).strip()
break
return {"download_code": code, "file_name": file_name}
for child in current.values():
stack.append(child)
continue
if isinstance(current, list):
for child in current:
stack.append(child)
return None
def _extract_incoming_file_payload(self, callback_data: dict, msg: ChatbotMessage) -> dict:
if str(getattr(msg, "message_type", "")).lower() != "file":
return {}
payload = self._find_download_code_payload(callback_data)
if not payload:
payload = self._find_download_code_payload(getattr(msg, "extensions", {}))
if not payload:
return {}
file_name = payload.get("file_name") or ""
if not file_name and isinstance(callback_data, dict):
for key in ("fileName", "filename", "name", "title"):
raw = callback_data.get(key)
if raw:
file_name = str(raw).strip()
if file_name:
break
payload["file_name"] = self._normalize_file_name(file_name or "upload.xlsx")
return payload
def _guess_image_ext(self, content_type: str, source_url: str) -> str: def _guess_image_ext(self, content_type: str, source_url: str) -> str:
ctype = (content_type or "").split(";")[0].strip().lower() ctype = (content_type or "").split(";")[0].strip().lower()
ext = "" ext = ""
@ -2079,6 +2162,134 @@ class OpenClawDingTalkBot(ChatbotHandler):
raw = (proc.stdout or "").strip() raw = (proc.stdout or "").strip()
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}" return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
def _reply_file_by_media_id(self, msg: ChatbotMessage, media_id: str) -> bool:
if not media_id:
return False
request_headers = {
"Content-Type": "application/json",
"Accept": "*/*",
}
values = {
"msgtype": "file",
"file": {
"media_id": media_id,
},
"at": {
"atUserIds": [msg.sender_staff_id] if msg.sender_staff_id else [],
},
}
try:
response_text = ""
response = requests.post(
msg.session_webhook,
headers=request_headers,
data=json.dumps(values),
timeout=20,
)
response_text = response.text
response.raise_for_status()
except Exception as exc:
self.logger.error("reply file failed, error=%s, response.text=%s", exc, response_text)
return False
return True
def _run_excel_image_link_job(self, msg: ChatbotMessage, file_payload: dict) -> tuple[str, bool]:
if not self.excel_link_feature_enabled:
return "Excel 图片链接转图片功能未启用。", False
script_path = self.excel_converter_script
if not os.path.exists(script_path):
return f"转换脚本不存在:{script_path}", False
download_code = str(file_payload.get("download_code") or "").strip()
file_name = self._normalize_file_name(file_payload.get("file_name") or "upload.xlsx")
if not download_code:
return "未获取到文件下载码,请重试上传。", False
if not self._is_supported_excel_file(file_name):
return "当前仅支持 .xlsx 文件,请上传Excel后重试。", False
download_url = self.get_image_download_url(download_code)
if not download_url:
return "文件下载地址获取失败,请重试。", False
with tempfile.TemporaryDirectory(prefix="dingtalk_excel_link_") as tmpdir:
input_path = os.path.join(tmpdir, file_name)
output_name = f"{os.path.splitext(file_name)[0]}_图片回填.xlsx"
output_path = os.path.join(tmpdir, output_name)
try:
response = requests.get(download_url, timeout=self.excel_file_download_timeout)
response.raise_for_status()
with open(input_path, "wb") as fh:
fh.write(response.content)
except Exception as exc:
self.logger.exception("download incoming excel failed")
return f"下载源文件失败:{exc}", False
cmd = [
sys.executable,
script_path,
"--input",
input_path,
"--output",
output_path,
"--link-column",
self.excel_link_column,
"--image-column-header",
self.excel_image_column_header,
"--max-width",
str(self.excel_image_max_width),
"--max-height",
str(self.excel_image_max_height),
"--timeout",
str(self.excel_image_download_timeout),
]
if self.excel_skip_tls_verify:
cmd.append("--skip-tls-verify")
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.excel_converter_timeout,
)
except subprocess.TimeoutExpired:
return f"Excel 转换超时(>{self.excel_converter_timeout}秒),请稍后重试。", False
stdout_text = (proc.stdout or "").strip()
stderr_text = (proc.stderr or "").strip()
if proc.returncode != 0 or not os.path.exists(output_path):
detail = stderr_text or stdout_text or "未知错误"
self.logger.error("excel converter failed code=%s detail=%s", proc.returncode, detail[:800])
return f"Excel 转换失败:{detail[:500]}", False
summary_line = ""
for line in stdout_text.splitlines():
if line.startswith("SUMMARY:"):
summary_line = line.replace("SUMMARY:", "").strip()
break
try:
with open(output_path, "rb") as fh:
media_content = fh.read()
media_id = self.dingtalk_client.upload_to_dingtalk(
media_content,
filetype="file",
filename=output_name,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
except Exception as exc:
self.logger.exception("upload converted excel failed")
return f"处理完成但上传失败:{exc}", False
if not media_id:
return "处理完成但上传媒体失败,请稍后重试。", False
sent = self._reply_file_by_media_id(msg, media_id)
if not sent:
return "处理完成但回传文件失败,请稍后重试。", False
summary_text = summary_line or "已完成图片回填。"
return f"文件已回传:{output_name}\n处理结果:{summary_text}", True
def _is_image_url(self, raw_url: str) -> bool: def _is_image_url(self, raw_url: str) -> bool:
try: try:
parsed = urllib.parse.urlparse(raw_url) parsed = urllib.parse.urlparse(raw_url)
@ -2210,16 +2421,20 @@ class OpenClawDingTalkBot(ChatbotHandler):
text = text_list[0].strip() if text_list else "" text = text_list[0].strip() if text_list else ""
incoming_image_codes = self._extract_incoming_download_codes(msg) incoming_image_codes = self._extract_incoming_download_codes(msg)
incoming_file_payload = self._extract_incoming_file_payload(callback.data, msg)
has_excel_file = bool(incoming_file_payload)
if not text and not incoming_image_codes: if not text and not incoming_image_codes and not has_excel_file:
return AckMessage.STATUS_OK, "ignored" return AckMessage.STATUS_OK, "ignored"
if not self._should_handle(msg, text): if not has_excel_file and not self._should_handle(msg, text):
return AckMessage.STATUS_OK, "ignored" return AckMessage.STATUS_OK, "ignored"
prompt = self._normalize_prompt(text) prompt = self._normalize_prompt(text)
if not prompt: if not prompt:
if incoming_image_codes: if has_excel_file:
prompt = "请处理我上传的Excel文件,把图片链接转成图片并回传。"
elif incoming_image_codes:
prompt = "请识别并分析我上传的图片内容,输出结论和建议。" prompt = "请识别并分析我上传的图片内容,输出结论和建议。"
else: else:
self._reply_message( self._reply_message(
@ -2268,7 +2483,22 @@ class OpenClawDingTalkBot(ChatbotHandler):
conversation_key, conversation_key,
self.max_parallel_jobs, self.max_parallel_jobs,
) )
if incoming_image_codes and not self._is_install_media_prompt(prompt): if has_excel_file:
file_answer, file_sent = await asyncio.to_thread(
self._run_excel_image_link_job,
msg,
incoming_file_payload,
)
if file_sent:
self._reply_message(msg, file_answer, prefer_card=False)
self.logger.info(
"excel file processed: session=%s file=%s",
session_id,
incoming_file_payload.get("file_name", ""),
)
return AckMessage.STATUS_OK, "excel_file_ok"
answer = file_answer
elif incoming_image_codes and not self._is_install_media_prompt(prompt):
image_context_result = await asyncio.to_thread( image_context_result = await asyncio.to_thread(
self._build_image_context_from_dingtalk, self._build_image_context_from_dingtalk,
msg, msg,

View File

@ -0,0 +1,68 @@
---
name: dingtalk-excel-image-return
description: 在钉钉应用机器人(推荐 Stream 模式)场景处理用户上传的 Excel(.xlsx)文件:识别图片链接列,下载链接图片并插入新增图片列,再上传并回传结果文件。用户提到“钉钉机器人”“上传表格”“图片链接转图片”“新增图片列”“回传文件”“批量插图”“Excel 图片列”时使用此技能;涉及钉钉文件下载、Excel 图片写入、文件回传时优先使用此技能。
---
# dingtalk-excel-image-return
## 触发关键词
优先匹配以下关键词或等价表达:
- `钉钉机器人`
- `上传表格`
- `Excel 图片链接`
- `图片链接转图片`
- `新增图片列`
- `回传文件`
- `批量插图`
## 执行流程(最稳方案)
1. 使用钉钉应用机器人接收消息,优先使用 Stream 模式。
2. 在用户与机器人单聊中接收 `file` 消息,提取 `downloadCode`。
3. 调用钉钉文件下载接口获取原始 `.xlsx`。
4. 运行 `scripts/convert_excel_image_links.py` 处理表格。
5. 上传结果文件到钉钉文件存储。
6. 调用会话文件发送接口回传结果。
如果用户要求“群里 @ 机器人并直接传文件”,先提示群聊文件接收限制,再引导改为单聊上传,或提供上传入口卡片。
## Excel 处理命令
执行:
```bash
python3 scripts/convert_excel_image_links.py \
--input /tmp/input.xlsx \
--output /tmp/output.xlsx \
--link-column 图片链接 \
--image-column-header 图片
```
常用参数:
- `--link-column`:链接列(表头名或列字母,如 `C`)
- `--header-row`:表头行号(默认 `1`)
- `--sheet`:只处理指定工作表(可重复)
- `--max-width` / `--max-height`:限制图片尺寸
- `--skip-tls-verify`:图片链接为自签名证书时临时使用
## 钉钉接口编排
按顺序调用:
1. 获取用户上传文件(通过 `downloadCode`)。
2. 调用下载接口拉取原表格。
3. 本地转换并生成新文件。
4. 上传新文件。
5. 发送文件消息回会话。
接口详情、字段和最小实现建议见 `references/dingtalk-workflow.md`。
## 失败兜底
- 下载图片失败时不中断整表,记录失败行并继续。
- 非 `http/https` 链接标记为失败。
- 处理完成后始终输出结果文件;失败行通过日志返回。
- 涉及认证或证书错误时先打印明确错误,再提示运维修复证书/权限。

View File

@ -0,0 +1,4 @@
interface:
display_name: "钉钉表格图片回填"
short_description: "把钉钉Excel里的图片链接批量转成嵌入图片并自动回传"
default_prompt: "Use $dingtalk-excel-image-return to convert image URLs in an uploaded .xlsx file into embedded images and return the processed file."

View File

@ -0,0 +1,49 @@
# DingTalk workflow for Excel image-link conversion
## 1) Recommended architecture
- Use an **app bot** (not a group custom bot).
- Use **Stream mode** to receive messages.
- Prefer **1:1 chat** for file uploads.
## 2) End-to-end API sequence
1. Receive message event (`msgtype=file`) and parse:
- `conversationId`
- `senderStaffId`
- `downloadCode`
- original `fileName`
2. Download source file:
- `POST /v1.0/robot/messageFiles/download`
- Body includes `downloadCode`.
3. Run local conversion script:
- `scripts/convert_excel_image_links.py`
4. Upload output file to DingTalk file service (get file storage identifiers).
5. Send output file back:
- `POST /v1.0/convFile/conversations/files/send`
- Include `conversationId` and uploaded file info.
## 3) Message handling policy
- Reject non-`.xlsx` files with clear error text.
- If file size exceeds service limit, return size-limit guidance.
- Keep idempotency key (`conversationId + messageId`) to avoid duplicate processing.
- Return progress checkpoints in long jobs: received -> processing -> done/failed.
## 4) Security and reliability
- Keep app secrets in env vars; never hardcode credentials.
- Restrict outbound image downloads with timeout and max size.
- Allow partial success for broken image URLs.
- Record each failed row with reason for troubleshooting.
## 5) Suggested runtime contract
Input contract:
- `.xlsx` file
- URL column header default: `图片链接`
Output contract:
- New `.xlsx` with inserted column header default: `图片`
- Same sheet names and original data preserved
- Failed URL rows logged

View File

@ -0,0 +1,275 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import io
import re
import ssl
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from openpyxl import load_workbook
from openpyxl.drawing.image import Image as XLImage
from openpyxl.utils import column_index_from_string, get_column_letter
CONTENT_TYPE_TO_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/gif": ".gif",
}
SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
@dataclass
class RowResult:
row: int
url: str
status: str
detail: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Download image URLs from an Excel column and insert images into a new column."
)
parser.add_argument("--input", required=True, help="Input .xlsx path")
parser.add_argument("--output", required=True, help="Output .xlsx path")
parser.add_argument(
"--link-column",
default="图片链接",
help="URL column by header text or column letter (default: 图片链接)",
)
parser.add_argument(
"--image-column-header",
default="图片",
help="Header value for inserted image column",
)
parser.add_argument(
"--header-row",
type=int,
default=1,
help="Header row number (default: 1)",
)
parser.add_argument(
"--start-row",
type=int,
default=0,
help="Start processing row (default: header_row + 1)",
)
parser.add_argument(
"--sheet",
action="append",
default=[],
help="Sheet name to process (repeatable). Default: process all sheets",
)
parser.add_argument(
"--max-width",
type=int,
default=140,
help="Max image width in pixels (default: 140)",
)
parser.add_argument(
"--max-height",
type=int,
default=140,
help="Max image height in pixels (default: 140)",
)
parser.add_argument(
"--timeout",
type=int,
default=15,
help="Image download timeout in seconds (default: 15)",
)
parser.add_argument(
"--skip-tls-verify",
action="store_true",
help="Disable TLS certificate verification for image URLs",
)
return parser.parse_args()
def resolve_link_column(sheet, header_row: int, link_column: str) -> int:
column_spec = link_column.strip()
if re.fullmatch(r"[A-Za-z]{1,3}", column_spec):
return column_index_from_string(column_spec.upper())
for col_idx in range(1, sheet.max_column + 1):
value = sheet.cell(row=header_row, column=col_idx).value
if value is None:
continue
if str(value).strip() == column_spec:
return col_idx
raise ValueError(
f"Sheet '{sheet.title}' cannot find link column '{link_column}' in header row {header_row}."
)
def choose_sheets(workbook, names: Iterable[str]):
if not names:
return list(workbook.worksheets)
chosen = []
existing = {ws.title: ws for ws in workbook.worksheets}
for name in names:
if name not in existing:
raise ValueError(f"Sheet '{name}' does not exist.")
chosen.append(existing[name])
return chosen
def build_ssl_context(skip_verify: bool):
if not skip_verify:
return None
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def guess_extension(url: str, content_type: str | None) -> str:
parsed = urllib.parse.urlparse(url)
suffix = Path(parsed.path).suffix.lower()
if suffix in SUPPORTED_EXTENSIONS:
return suffix
if content_type:
clean_content_type = content_type.split(";")[0].strip().lower()
if clean_content_type in CONTENT_TYPE_TO_EXT:
return CONTENT_TYPE_TO_EXT[clean_content_type]
return ".png"
def download_image(url: str, timeout: int, ssl_context):
request = urllib.request.Request(
url,
headers={"User-Agent": "Mozilla/5.0 CodexSkill/1.0"},
)
with urllib.request.urlopen(request, timeout=timeout, context=ssl_context) as response:
content_type = response.headers.get("Content-Type")
data = response.read()
return data, guess_extension(url, content_type)
def pixels_to_excel_width(px: int) -> float:
return max(12.0, round((px + 12) / 7, 2))
def pixels_to_points(px: int) -> float:
return round(px * 0.75, 2)
def resize_image(image: XLImage, max_width: int, max_height: int) -> None:
width = image.width or max_width
height = image.height or max_height
scale = min(max_width / width, max_height / height, 1.0)
image.width = int(width * scale)
image.height = int(height * scale)
def process_sheet(sheet, args: argparse.Namespace, ssl_context) -> list[RowResult]:
results: list[RowResult] = []
header_row = args.header_row
start_row = args.start_row if args.start_row > 0 else header_row + 1
link_column_idx = resolve_link_column(sheet, header_row, args.link_column)
image_column_idx = link_column_idx + 1
sheet.insert_cols(image_column_idx)
sheet.cell(row=header_row, column=image_column_idx, value=args.image_column_header)
image_column_letter = get_column_letter(image_column_idx)
column_width = pixels_to_excel_width(args.max_width)
existing_width = sheet.column_dimensions[image_column_letter].width
if existing_width is None or existing_width < column_width:
sheet.column_dimensions[image_column_letter].width = column_width
for row in range(start_row, sheet.max_row + 1):
source = sheet.cell(row=row, column=link_column_idx).value
if source is None:
continue
url = str(source).strip()
if not url:
continue
if not url.lower().startswith(("http://", "https://")):
results.append(RowResult(row=row, url=url, status="failed", detail="invalid_url"))
continue
try:
payload, extension = download_image(url, args.timeout, ssl_context)
image_buffer = io.BytesIO(payload)
image_buffer.name = f"row_{row}{extension}"
image = XLImage(image_buffer)
resize_image(image, args.max_width, args.max_height)
anchor = f"{image_column_letter}{row}"
sheet.add_image(image, anchor)
needed_height = pixels_to_points(image.height + 8)
current_height = sheet.row_dimensions[row].height
if current_height is None or current_height < needed_height:
sheet.row_dimensions[row].height = needed_height
results.append(RowResult(row=row, url=url, status="ok", detail="inserted"))
except urllib.error.URLError as error:
results.append(RowResult(row=row, url=url, status="failed", detail=f"download_error:{error.reason}"))
except Exception as error: # noqa: BLE001
results.append(RowResult(row=row, url=url, status="failed", detail=f"image_error:{error}"))
return results
def main() -> int:
args = parse_args()
input_path = Path(args.input)
output_path = Path(args.output)
if input_path.suffix.lower() != ".xlsx":
print("ERROR: input must be .xlsx")
return 1
if not input_path.exists():
print(f"ERROR: input does not exist: {input_path}")
return 1
ssl_context = build_ssl_context(args.skip_tls_verify)
workbook = load_workbook(input_path)
sheets = choose_sheets(workbook, args.sheet)
all_results: list[tuple[str, RowResult]] = []
for sheet in sheets:
sheet_results = process_sheet(sheet, args, ssl_context)
all_results.extend((sheet.title, item) for item in sheet_results)
output_path.parent.mkdir(parents=True, exist_ok=True)
workbook.save(output_path)
success_count = sum(1 for _, item in all_results if item.status == "ok")
fail_count = sum(1 for _, item in all_results if item.status == "failed")
print(f"DONE: output={output_path}")
print(f"SUMMARY: success={success_count} failed={fail_count}")
for sheet_name, result in all_results:
if result.status == "failed":
print(
f"FAILED: sheet={sheet_name} row={result.row} url={result.url} reason={result.detail}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())