feat: 扩展Word与邮件自动化能力

This commit is contained in:
yu committed 2026-10-27 15:17:55 +08:00
1 parent b78795ccdc
commit 741dc773c1
8 files changed
+195 -12

No files matched your search

@@ -143,6 +143,14 @@ describe("capability registration smoke", () => {
}, component);
expect(received).toContain("RPA smoke");
expect(received).toContain("mail delivered");
const union = getHandler("Mail_UnionSend_v1");
expect(union).toBeTruthy();
await union!(context, node("Mail_UnionSend_v1"), {
account_obj: { host: "127.0.0.1", port, account: "robot@example.com" },
receiver: "owner@example.com", subject: "Union smoke", content: "union delivered"
}, component);
expect(received).toContain("Union smoke");
expect(received).toContain("union delivered");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
+1
View File
@@ -9,6 +9,7 @@ import "./office";
import "./desktop";
import "./pdf";
import "./word";
import "./word-extended";
import "./message";
import "./vision";
import "./extended";
+15 -6
View File
@@ -10,13 +10,22 @@ function list(value: string): string[] {
return value.split(/[;,,;\s]+/).map((item) => item.trim()).filter(Boolean);
}
register(["Mail_Send", "Mail_UnionSend"], async (_ctx, node, args) => {
const account = text(args, ["account", "from", "account_obj"]);
const password = text(args, ["password", "pass"]);
const host = text(args, ["host", "smtp"]);
function accountObject(value: unknown): Record<string, unknown> {
if (value && typeof value === "object") return value as Record<string, unknown>;
if (typeof value === "string" && value.trim().startsWith("{")) {
try { return JSON.parse(value); } catch { return {}; }
}
return {};
}
register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], async (_ctx, node, args) => {
const configured = accountObject(args.account_obj);
const account = text({ ...configured, ...args }, ["account", "from", "username", "user"]);
const password = text({ ...configured, ...args }, ["password", "pass"]);
const host = text({ ...configured, ...args }, ["host", "smtp", "smtpHost", "server"]);
if (!host || !account) throw new Error(`邮件配置不完整(${node.componentId})`);
const port = Number(text(args, ["port"], "25"));
const secure = ["true", "1", "是", "True"].includes(text(args, ["is_ssl"], "False"));
const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25"));
const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False"));
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
const result = await transporter.sendMail({
from: account,
@@ -0,0 +1,36 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./word";
import "./word-extended";
const directories: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close"];
describe("Word extended capabilities", () => {
it("registers automated Word operations", () => { for (const id of ids) expect(getHandler(id), id).toBeTruthy(); });
it("creates, writes, reads and replaces Word content", async () => {
if (process.platform !== "win32") return;
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-extended-")); directories.push(directory);
const handle = await run("Word_New_V1", { filePath: directory, name: "extended.docx" });
await run("Word_Write", { objWord: handle, text: "原始内容" });
await expect(run("Word_ReadAll", { objWord: handle })).resolves.toContain("原始内容");
await run("Word_Replace", { objWord: handle, oldText: "原始", newText: "替换后" });
await expect(run("Word_ReadAll", { objWord: handle })).resolves.toContain("替换后内容");
await run("Word_SetFontSize", { objWord: handle, fontSize: 12 });
await run("Word_SetFontStyle", { objWord: handle, isBold: "True", isItalic: "False", isUnderline: "False" });
await run("Word_Enter", { objWord: handle });
expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000);
}, 60000);
});
+124
View File
@@ -0,0 +1,124 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { getHandler, register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
interface WordHandle { path: string; }
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
const current = args[key];
if (current !== undefined && current !== null && current !== "") return String(current);
}
return fallback;
}
function bool(value: unknown): boolean {
return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never);
}
function handlePath(args: Record<string, unknown>, nodeId: string, componentId: string): string {
const raw = args.objWord ?? args.filePath ?? args.path;
const candidate = raw && typeof raw === "object" ? (raw as Partial<WordHandle>).path : raw;
const result = String(candidate || "").trim();
if (!result || result === "word对象") throw new RuntimeError("validation_error", "Word文档路径为空", { nodeId, componentId });
return path.resolve(result);
}
function script(filePath: string, body: string, save = true): string {
return `$word=$null;$doc=$null;try{$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});${body};${save ? "$doc.Save();" : ""}}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`;
}
register("Word_New_V1", async (ctx, node, args, component) => {
const base = getHandler("Word_New");
if (!base) throw new RuntimeError("capability_not_implemented", "基础 Word 新建能力不可用", { nodeId: node.id, componentId: node.componentId });
return base(ctx, node, args, component);
});
register("Word_GetPath", (_ctx, node, args) => handlePath(args, node.id, node.componentId));
register("Word_ReadAll", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const encoded = await powershell(script(filePath, "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($doc.Content.Text))", false));
return Buffer.from(encoded.trim(), "base64").toString("utf8");
});
register("Word_Replace", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const oldText = text(args, ["oldText"]);
if (!oldText) throw new RuntimeError("validation_error", "原内容为空", { nodeId: node.id, componentId: node.componentId });
const newText = text(args, ["newText"]);
await powershell(script(filePath, `$find=$doc.Content.Find;[void]$find.Execute(${psString(oldText)},$false,$false,$false,$false,$false,$true,1,$false,${psString(newText)},2)`));
return { path: filePath } satisfies WordHandle;
});
register("Word_Enter", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
await powershell(script(filePath, "$range=$doc.Content;$range.Collapse(0);$range.InsertParagraphAfter()"));
return { path: filePath } satisfies WordHandle;
});
register("Word_InsertPage", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
await powershell(script(filePath, "$range=$doc.Content;$range.Collapse(0);$range.InsertBreak(7)"));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetFontName", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
await powershell(script(filePath, `$doc.Content.Font.Name=${psString(text(args, ["font"], "宋体"))}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetFontSize", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const size = Number(text(args, ["fontSize"], "9"));
if (!(size > 0)) throw new RuntimeError("validation_error", "字体大小无效", { nodeId: node.id, componentId: node.componentId });
await powershell(script(filePath, `$doc.Content.Font.Size=${size}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetFontStyle", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
await powershell(script(filePath, `$doc.Content.Font.Bold=${bool(args.isBold) ? 1 : 0};$doc.Content.Font.Italic=${bool(args.isItalic) ? 1 : 0};$doc.Content.Font.Underline=${bool(args.isUnderline) ? 1 : 0}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetFontColor", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const hex = text(args, ["fontColor"], "#000000").replace("#", "");
if (!/^[0-9a-fA-F]{6}$/.test(hex)) throw new RuntimeError("validation_error", "字体颜色格式无效", { nodeId: node.id, componentId: node.componentId });
const red = Number.parseInt(hex.slice(0, 2), 16); const green = Number.parseInt(hex.slice(2, 4), 16); const blue = Number.parseInt(hex.slice(4, 6), 16);
await powershell(script(filePath, `$doc.Content.Font.Color=${red + green * 256 + blue * 65536}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetAlign", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const align = text(args, ["align"], "left").toLowerCase();
const value = align.includes("center") || align === "1" ? 1 : align.includes("right") || align === "2" ? 2 : align.includes("justify") || align === "3" ? 3 : 0;
await powershell(script(filePath, `$doc.Content.ParagraphFormat.Alignment=${value}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_InsertPicture", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const image = path.resolve(text(args, ["imageaddr"]));
await fs.access(image);
await powershell(script(filePath, `$range=$doc.Content;$range.Collapse(0);[void]$doc.InlineShapes.AddPicture(${psString(image)},$false,$true,$range)`));
return { path: filePath } satisfies WordHandle;
});
register("Word_WriteTable", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
let data: unknown = args.data;
if (typeof data === "string") { try { data = JSON.parse(data); } catch { data = [[data]]; } }
const rows = Array.isArray(data) ? data.map((row) => Array.isArray(row) ? row : [row]) : [[data]];
const columns = Math.max(1, ...rows.map((row) => row.length));
const encoded = Buffer.from(JSON.stringify(rows), "utf8").toString("base64");
await powershell(script(filePath, `$json=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${psString(encoded)}));$data=$json|ConvertFrom-Json;$range=$doc.Content;$range.Collapse(0);$table=$doc.Tables.Add($range,${rows.length},${columns});for($r=0;$r-lt${rows.length};$r++){for($c=0;$c-lt${columns};$c++){if($c-lt$data[$r].Count){$table.Cell($r+1,$c+1).Range.Text=[string]$data[$r][$c]}}};$table.Borders.Enable=${bool(args.is_border ?? "1") ? 1 : 0}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_Close", (_ctx, node, args) => ({ path: handlePath(args, node.id, node.componentId), closed: true }));