Skill: aowind/sjht-ppt-delivery (version 1.0.0)

--- name: ppt-delivery description: | Convert HTML slide deck to PDF and send to Feishu user. Use when the user asks to generate a PPT/presentation and deliver it as a PDF file via Feishu message. Triggers on: "做PPT", "做个演示", "生成PPT", "做幻灯片", "发送PDF", "转PDF", "做报告", or when a PPT/slide task is completed and needs delivery. This skill covers the full pipeline: HTML slides → font scaling → PDF conversion → Feishu file upload & send. --- # PPT Delivery — HTML 演示文稿转 PDF 并发送飞书 完整流程：生成 HTML 幻灯片 → 放大字体 → 转 PDF → 上传飞书发送给用户。 ## 前置依赖 - `chromium-browser`（已安装） - `puppeteer-core`（全局 npm 包） - `pdf-lib`（全局 npm 包） - Python 3 + `requests`（已安装） - 飞书机器人已配置（openclaw.json 中有 APP_ID/SECRET） ## 工作流程 ### Step 1: 生成 HTML 幻灯片 使用 `frontend-slides` 或 `jobs-style-ppt-generator` skill 生成 HTML 文件。 ### Step 2: 字体放大（必须） 用户通常反馈字体太小，默认执行两轮放大： **第一轮放大**（CSS 修改）： - body font-size → `22px` - 所有 ≤1rem → ×1.25 - 所有 1~1.5rem → ×1.35 - 所有 clamp() 值 → ×1.2 - 卡片 padding → ×1.2 **第二轮放大**（如用户仍嫌小）： - body font-size → `26px` - 所有字号 → 再 ×1.2 - clamp() 值 → 再 ×1.15 - 卡片 padding → 再 ×1.15 始终保持标题/正文层级关系。 ### Step 3: HTML 转 PDF 使用脚本逐 slide 截图嵌入 PDF，保证视觉一致性： ```bash NODE_PATH=$(npm root -g) node <skill_dir>/scripts/html2pdf.cjs <input.html> <output.pdf> ``` 参数： - `--width 1920`（默认） - `--height 1080`（默认） 输出：多页 PDF（每页一张幻灯片截图）。 ### Step 4: 发送飞书文件 将 PDF 通过飞书 Bot API 发送给用户： ```bash python3 <skill_dir>/scripts/send_file_feishu.py <pdf_path> <user_open_id> ``` user_open_id 从消息的 inbound metadata `sender_id` 获取。 ## 完整示例 ``` 1. UI agent 生成 /root/projects/report.html 2. 字体放大（两轮） 3. NODE_PATH=$(npm root -g) node ppt-delivery/scripts/html2pdf.cjs /root/projects/report.html /root/projects/report.pdf 4. python3 ppt-delivery/scripts/send_file_feishu.py /root/projects/report.pdf ou_xxxxx 5. 回复用户："PDF 已发送 📎" ``` ## 注意事项 - PDF 文件大小通常 1-3MB（5-10 页） - 如果 chromium 截图有渲染问题，检查字体是否加载完成（脚本内置 3 秒等待） - 飞书发送需要 bot 有 `im:message:send_as_bot` 权限 - 文件类型支持：pdf、doc、xls、ppt、mp4、opus

path: html2pdf.cjs
content: #!/usr/bin/env node
/**
 * html2pdf.cjs — Convert HTML slide deck to multi-page PDF via screenshots.
 *
 * Usage: node html2pdf.cjs <input.html> <output.pdf> [--width 1920] [--height 1080]
 *
 * Dependencies: puppeteer-core (global), pdf-lib (global)
 * Browser: chromium-browser
 */
const puppeteer = require('puppeteer-core');
const { PDFDocument } = require('pdf-lib/cjs/index.js');
const fs = require('fs');
const path = require('path');

const args = process.argv.slice(2);
let inputFile = null;
let outputFile = null;
let width = 1920;
let height = 1080;

for (let i = 0; i < args.length; i++) {
  if (args[i] === '--width' && args[i + 1]) width = parseInt(args[++i]);
  else if (args[i] === '--height' && args[i + 1]) height = parseInt(args[++i]);
  else if (!inputFile) inputFile = args[i];
  else if (!outputFile) outputFile = args[i];
}

if (!inputFile || !outputFile) {
  console.error('Usage: node html2pdf.cjs <input.html> <output.pdf> [--width 1920] [--height 1080]');
  process.exit(1);
}

inputFile = path.resolve(inputFile);
outputFile = path.resolve(outputFile);

if (!fs.existsSync(inputFile)) {
  console.error(`File not found: ${inputFile}`);
  process.exit(1);
}

(async () => {
  const browser = await puppeteer.launch({
    executablePath: '/usr/bin/chromium-browser',
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
  });

  const page = await browser.newPage();
  await page.setViewport({ width, height });
  await page.goto(`file://${inputFile}`, { waitUntil: 'networkidle0', timeout: 30000 });

  // Wait for fonts and animations
  await page.evaluate(() => document.fonts.ready);
  await new Promise(r => setTimeout(r, 3000));

  const slideCount = await page.evaluate(() => document.querySelectorAll('.slide').length);
  if (slideCount === 0) {
    console.error('No .slide elements found in HTML');
    await browser.close();
    process.exit(1);
  }

  const pdfDoc = await PDFDocument.create();

  for (let i = 0; i < slideCount; i++) {
    await page.evaluate((idx) => {
      const slides = document.querySelectorAll('.slide');
      slides[idx].scrollIntoView({ block: 'start' });
      slides[idx].classList.add('visible');
    }, i);
    await new Promise(r => setTimeout(r, 800));

    const buf = await page.screenshot({ type: 'png', fullPage: false });
    const img = await pdfDoc.embedPng(buf);
    const pg = pdfDoc.addPage([width, height]);
    pg.drawImage(img, { x: 0, y: 0, width, height });
    console.log(`Page ${i + 1}/${slideCount} done`);
  }

  const pdfBytes = await pdfDoc.save();
  fs.writeFileSync(outputFile, pdfBytes);

  await browser.close();
  console.log(`PDF generated: ${outputFile} (${slideCount} pages, ${(pdfBytes.length / 1024).toFixed(0)} KB)`);
})();


path: send_file_feishu.py
content: #!/usr/bin/env python3
"""
send_file_feishu.py — Send a file to a Feishu user via bot API.

Usage: python3 send_file_feishu.py <file_path> <receive_open_id> [--file-type pdf]

Uses the OpenClaw Feishu bot credentials from config.
"""
import requests, json, sys, os

# Read Feishu credentials from OpenClaw config
OPENCLAW_JSON = os.path.expanduser("~/.openclaw/openclaw.json")
with open(OPENCLAW_JSON) as f:
    cfg = json.load(f)

# Navigate to find APP_ID and APP_SECRET
def find_val(obj, keys):
    for k in keys:
        v = obj.get(k)
        if v: return v
    return None

channels = cfg.get("channels", {})
feishu_cfg = channels.get("feishu", {})
accounts = feishu_cfg.get("accounts", {})
main_account = accounts.get("main", {})
env = cfg.get("env", {})

APP_ID = main_account.get("appId") or env.get("FEISHU_APP_ID")
APP_SECRET = main_account.get("appSecret") or env.get("FEISHU_APP_SECRET")

if not APP_ID or not APP_SECRET:
    print("ERROR: Feishu APP_ID or APP_SECRET not found in openclaw.json")
    sys.exit(1)

# Parse arguments
args = sys.argv[1:]
file_path = None
receive_id = None
file_type = "pdf"

i = 0
while i < len(args):
    if args[i] == "--file-type" and i + 1 < len(args):
        file_type = args[i + 1]
        i += 2
    elif not file_path:
        file_path = args[i]
        i += 1
    elif not receive_id:
        receive_id = args[i]
        i += 1
    else:
        i += 1

if not file_path or not receive_id:
    print("Usage: python3 send_file_feishu.py <file_path> <receive_open_id> [--file-type pdf]")
    sys.exit(1)

file_name = os.path.basename(file_path)
ext_map = {
    ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
    ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
    ".mp4": "mp4", ".opus": "opus", ".mp3": "opus"
}
ext = os.path.splitext(file_name)[1].lower()
mime_type = ext_map.get(ext, file_type)

# 1. Get tenant_access_token
r = requests.post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
    json={"app_id": APP_ID, "app_secret": APP_SECRET})
[REDACTED_SECRET]).get("tenant_access_token")
if not [REDACTED_SECRET]"ERROR: Failed to get token:", r.json())
    sys.exit(1)

# 2. Upload file
headers = {"Authorization": f"Bearer {token}"}
mime_types = {"pdf": "application/pdf", "doc": "application/msword", "xls": "application/vnd.ms-excel", "ppt": "application/vnd.ms-powerpoint"}
content_type = mime_types.get(mime_type, "application/octet-stream")

with open(file_path, "rb") as f:
    r = requests.post("https://open.feishu.cn/open-apis/im/v1/files",
        headers=headers,
        data={"file_type": mime_type, "file_name": file_name},
        files={"file": (file_name, f, content_type)})
resp = r.json()
if resp.get("code") != 0:
    print("ERROR: Upload failed:", resp)
    sys.exit(1)
file_key = resp["data"]["file_key"]
print(f"File uploaded: {file_key}")

# 3. Send file message
r = requests.post(
    "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
    headers={**headers, "Content-Type": "application/json"},
    json={
        "receive_id": receive_id,
        "msg_type": "file",
        "content": json.dumps({"file_key": file_key})
    })
result = r.json()
if result.get("code") == 0:
    print(f"File sent to {receive_id}: {file_name}")
else:
    print("ERROR: Send failed:", result)
    sys.exit(1)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)