diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ba441f79..98385543 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -212,6 +212,63 @@ bun run start - 验证构建后的代码在 `dist` 目录中正确生成 - 测试生产环境的启动和运行 +## 工具开发规范 + +### 1. 错误处理规范 +**⚠️ 重要**: 工具函数内部不需要进行顶层的 try-catch,直接把错误抛出到外面处理 + +```typescript +// ✅ 正确:直接抛出错误 +export async function tool(input: ToolInput): Promise { + // 1. 获取 access_token + const result = await handleGetAuthToken({ + grant_type: 'client_credential', + appid: input.appId!, + secret: input.appSecret! + }); + + if ('errcode' in result && result.errcode !== 0) { + return { + error_message: `获取 access_token 失败: ${result.errmsg}` + }; + } + + // 直接执行操作,让错误自然抛出 + const processedData = await processData(result.access_token); + return processedData; +} + +// ❌ 错误:顶层 try-catch +export async function tool(input: ToolInput): Promise { + try { + // 业务逻辑 + const result = await someOperation(); + return result; + } catch (error) { + // 不要在这里处理所有错误 + return { + error_message: error.message + }; + } +} +``` + +### 2. 测试规范 +**⚠️ 重要**: 测试应该使用 `bun run test` 而不是 `bun test` + +```bash +# ✅ 正确的测试命令 +bun run test + +# ❌ 错误的测试命令 +bun test +``` + +### 3. 工具结构规范 +- 每个工具都应该有自己的目录:`children/toolName/` +- 必须包含:`config.ts`, `src/index.ts`, `index.ts` +- 可选包含:`test/index.test.ts`, `DESIGN.md` + ## 最佳实践 ### 1. 代码兼容性 @@ -229,6 +286,11 @@ bun run start - 执行完整的测试套件 - 验证跨环境兼容性 +### 4. 错误处理 +- 工具函数内部避免顶层 try-catch +- 让错误自然抛出,由外部处理 +- 对于已知的业务错误,返回结构化的错误信息 + ## 常见问题 ### Q: 如何确保代码在两个环境都兼容? @@ -241,4 +303,4 @@ A: 使用 git sparse checkout,配置只需要的目录和文件。 A: 检查 `dist` 目录结构,确保所有依赖都正确安装,验证 Node.js 版本兼容性。 ### Q: 如何调试生产环境问题? -A: 在 `dist` 目录中设置断点,使用 Node.js 调试工具,检查构建日志。 \ No newline at end of file +A: 在 `dist` 目录中设置断点,使用 Node.js 调试工具,检查构建日志。 diff --git a/lib/s3/const.ts b/lib/s3/const.ts index 31a728b0..d45fb15d 100644 --- a/lib/s3/const.ts +++ b/lib/s3/const.ts @@ -19,3 +19,7 @@ export const mimeMap: Record = { '.js': 'application/javascript', '.md': 'text/markdown' }; + +export const PublicBucketBaseURL = process.env.S3_EXTERNAL_BASE_URL + ? `${process.env.S3_EXTERNAL_BASE_URL}/${process.env.S3_PUBLIC_BUCKET}` + : `${process.env.S3_USE_SSL ? 'https' : 'http'}://${process.env.S3_ENDPOINT}/${process.env.S3_PUBLIC_BUCKET}`; diff --git a/lib/type/env.d.ts b/lib/type/env.d.ts index 11b6e4f9..302b5cba 100644 --- a/lib/type/env.d.ts +++ b/lib/type/env.d.ts @@ -1,13 +1,24 @@ declare namespace NodeJS { interface ProcessEnv { + PORT: string; + AUTH_TOKEN: string; + LOG_LEVEL: string; + MODEL_PROVIDER_PRIORITY: string; + SIGNOZ_BASE_URL: string; + SIGNOZ_SERVICE_NAME: string; + MONGODB_URI: string; + REDIS_URL: string; + SERVICE_REQUEST_MAX_CONTENT_LENGTH: string; + MAX_API_SIZE: string; + DISABLE_DEV_TOOLS: string; + S3_PRIVATE_BUCKET: string; + S3_PUBLIC_BUCKET: string; S3_EXTERNAL_BASE_URL: string; S3_ENDPOINT: string; S3_PORT: string; S3_USE_SSL: string; S3_ACCESS_KEY: string; S3_SECRET_KEY: string; - S3_BUCKET: string; MAX_FILE_SIZE: string; - RETENTION_DAYS: string; } } diff --git a/lib/worker/loadTool.ts b/lib/worker/loadTool.ts new file mode 100644 index 00000000..57c1c66d --- /dev/null +++ b/lib/worker/loadTool.ts @@ -0,0 +1,82 @@ +import { isProd } from '@/constants'; +import { addLog } from '@/utils/log'; +import { basePath, devToolIds } from '@tool/constants'; +import { LoadToolsByFilename } from '@tool/loadToolProd'; +import { getIconPath } from '@tool/parseMod'; +import type { ToolSetType, ToolType } from '@tool/type'; +import { ToolTagEnum } from '@tool/type/tags'; +import { existsSync } from 'fs'; +import { readdir } from 'fs/promises'; +import { join } from 'path'; + +const LoadToolsDev = async (filename: string): Promise => { + if (isProd) { + addLog.error('Can not load dev tool in prod mode'); + return []; + } + + const tools: ToolType[] = []; + + const toolPath = join(basePath, 'modules', 'tool', 'packages', filename); + + const rootMod = (await import(toolPath)).default as ToolSetType | ToolType; + + const childrenPath = join(toolPath, 'children'); + const isToolSet = existsSync(childrenPath); + + const toolsetId = rootMod.toolId || filename; + const parentIcon = rootMod.icon ?? getIconPath(`${toolsetId}/logo`); + + if (isToolSet) { + tools.push({ + ...rootMod, + tags: rootMod.tags || [ToolTagEnum.enum.other], + toolId: toolsetId, + icon: parentIcon, + toolFilename: filename, + cb: () => Promise.resolve({}), + versionList: [] + }); + + const children: ToolType[] = []; + + { + const files = await readdir(childrenPath); + for (const file of files) { + const childPath = join(childrenPath, file); + + const childMod = (await import(childPath)).default as ToolType; + const toolId = childMod.toolId || `${toolsetId}/${file}`; + + const childIcon = childMod.icon ?? rootMod.icon ?? getIconPath(`${toolsetId}/${file}/logo`); + children.push({ + ...childMod, + toolId, + toolFilename: filename, + icon: childIcon, + parentId: toolsetId + }); + } + } + + tools.push(...children); + } else { + // is not toolset + const icon = rootMod.icon ?? getIconPath(`${toolsetId}/logo`); + + tools.push({ + ...(rootMod as ToolType), + tags: rootMod.tags || [ToolTagEnum.enum.other], + toolId: toolsetId, + icon, + toolFilename: filename + }); + } + + tools.forEach((tool) => devToolIds.add(tool.toolId)); + return tools; +}; + +export const loadTool = async (filename: string, dev: boolean) => { + return dev ? await LoadToolsDev(filename) : await LoadToolsByFilename(filename); +}; diff --git a/lib/worker/worker.ts b/lib/worker/worker.ts index 076b30be..a59b13a4 100644 --- a/lib/worker/worker.ts +++ b/lib/worker/worker.ts @@ -1,10 +1,9 @@ import { parentPort } from 'worker_threads'; import type { Main2WorkerMessageType } from './type'; import { setupProxy } from '../utils/setupProxy'; -import { LoadToolsByFilename } from '@tool/utils'; import { getErrText } from '@tool/utils/err'; -import { LoadToolsDev } from '@tool/loadToolDev'; import type { ToolCallbackReturnSchemaType } from '@tool/type/req'; +import { loadTool } from './loadTool'; setupProxy(); @@ -20,11 +19,9 @@ parentPort?.on('message', async (params: Main2WorkerMessageType) => { const { type, data } = params; switch (type) { case 'runTool': { - const tools = data.dev - ? await LoadToolsDev(data.filename) - : await LoadToolsByFilename(data.filename); - - const tool = tools.find((tool) => tool.toolId === data.toolId); + const tool = (await loadTool(data.filename, data.dev)).find( + (tool) => tool.toolId === data.toolId + ); if (!tool || !tool.cb) { parentPort?.postMessage({ diff --git a/modules/tool/init.ts b/modules/tool/init.ts index d02b90cd..556bc7ad 100644 --- a/modules/tool/init.ts +++ b/modules/tool/init.ts @@ -8,11 +8,11 @@ import { refreshDir } from '@/utils/fs'; import { addLog } from '@/utils/log'; import { basePath, toolsDir, UploadToolsS3Path } from './constants'; import { privateS3Server } from '@/s3'; -import { LoadToolsByFilename } from './utils'; import { stat } from 'fs/promises'; import { getCachedData } from '@/cache'; import { SystemCacheKeyEnum } from '@/cache/type'; import { batch } from '@/utils/parallel'; +import { LoadToolsByFilename } from './loadToolProd'; const filterToolList = ['.DS_Store', '.git', '.github', 'node_modules', 'dist', 'scripts']; diff --git a/modules/tool/loadToolProd.ts b/modules/tool/loadToolProd.ts new file mode 100644 index 00000000..4492effe --- /dev/null +++ b/modules/tool/loadToolProd.ts @@ -0,0 +1,17 @@ +import { toolsDir } from './constants'; +import type { ToolSetType, ToolType } from './type'; +import { addLog } from '@/utils/log'; +import { join } from 'path'; +import { parseMod } from './parseMod'; + +// Load tool or toolset and its children +export const LoadToolsByFilename = async (filename: string): Promise => { + const rootMod = (await import(join(toolsDir, filename))).default as ToolType | ToolSetType; + + if (!rootMod.toolId) { + addLog.error(`Can not parse toolId, filename: ${filename}`); + return []; + } + + return parseMod({ rootMod, filename }); +}; diff --git a/modules/tool/packages/wechatOfficialAccount/DESIGN.md b/modules/tool/packages/wechatOfficialAccount/DESIGN.md new file mode 100644 index 00000000..f53a6234 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/DESIGN.md @@ -0,0 +1,66 @@ +# 微信公众号工具集 + +### 项目结构 + +``` +wechatOfficialAccount/ +├── children/ +│ └── getAuthToken/ # 获取微信公众号鉴权信息子工具 +│ ├── config.ts # 工具配置文件 +│ ├── src/ +│ │ └── index.ts # 工具核心逻辑实现 +│ └── test/ +│ └── index.test.ts # 测试文件 +├── lib/ +│ ├── api.ts # 微信公众号 API 定义 +│ └── auth.ts # 通用 API 处理器 +├── assets/ # 静态资源 +├── lib/ # 构建输出目录 +├── config.ts # 工具集配置 +├── index.ts # 工具集入口文件 +├── package.json # 包配置 +├── DESIGN.md # 设计文档 +└── README.md # 使用说明 +``` + +### 工具集/子工具列表 + +#### 1. 获取微信公众号鉴权信息 (getAuthToken) +- **功能**: 通过 AppID 和 AppSecret 获取微信公众号的 access_token +- **API**: `GET https://api.weixin.qq.com/cgi-bin/token` +- **输入**: AppID, AppSecret +- **输出**: access_token, expires_in + +#### 2. 上传素材 (uploadImage) +- **功能**: 上传图片素材到微信公众号素材库 +- **API**: `POST https://api.weixin.qq.com/cgi-bin/media/uploadimg` +- **输入**: access_token, 图片文件 +- **输出**: 图片URL + +#### 3. 获取素材 media_id (getMaterial) +- **功能**: 根据媒体ID获取素材内容 +- **API**: `POST https://api.weixin.qq.com/cgi-bin/material/get_material` +- **输入**: access_token, media_id +- **输出**: 素材内容 + +#### 4. 发布 markdown 格式的内容到草稿箱 (addDraft) +- **功能**: 将 markdown 格式内容转换为图文素材并添加到草稿箱 +- **API**: `POST https://api.weixin.qq.com/cgi-bin/draft/add` +- **输入**: access_token, 文章列表 +- **输出**: media_id + +#### 5. 获取草稿箱中的内容列表 (batchGetDraft) +- **功能**: 获取草稿箱中的图文列表 +- **API**: `POST https://api.weixin.qq.com/cgi-bin/draft/batchget` +- **输入**: access_token, offset, count +- **输出**: 草稿列表 + +#### 6. 发布草稿箱中的内容 (submitPublish) +- **功能**: 将草稿箱中的内容发布 +- **API**: `POST https://api.weixin.qq.com/cgi-bin/freepublish/submit` +- **输入**: access_token, media_id +- **输出**: publish_id, msg_data_id + +--- + +下面由 AI 生成完整的设计文档 diff --git a/modules/tool/packages/wechatOfficialAccount/README.md b/modules/tool/packages/wechatOfficialAccount/README.md new file mode 100644 index 00000000..718217ff --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/README.md @@ -0,0 +1,4 @@ +# 获取密钥 + +按照如图所示的方式获取密钥 +![](./assets/get-secrets.jpg) diff --git a/modules/tool/packages/wechatOfficialAccount/assets/get-secrets.jpg b/modules/tool/packages/wechatOfficialAccount/assets/get-secrets.jpg new file mode 100644 index 00000000..6bf4c2ac Binary files /dev/null and b/modules/tool/packages/wechatOfficialAccount/assets/get-secrets.jpg differ diff --git a/modules/tool/packages/wechatOfficialAccount/bun.lock b/modules/tool/packages/wechatOfficialAccount/bun.lock new file mode 100644 index 00000000..0663dd4e --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/bun.lock @@ -0,0 +1,87 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@fastgpt-plugins/tool-wechat-ofi-account", + "dependencies": { + "cheerio": "^1.1.2", + "formdata-node": "^6.0.3", + "marked": "^12.0.0", + "zod": "^3.25.76", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "@types/react": ["@types/react@19.2.3", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-k5dJVszUiNr1DSe8Cs+knKR6IrqhqdhpUwzqhkS8ecQTSf3THNtbfIp/umqHMpX2bv+9dkx3fwDv/86LcSfvSg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], + + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "formdata-node": ["formdata-node@6.0.3", "", {}, "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg=="], + + "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + } +} diff --git a/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/config.ts b/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/config.ts new file mode 100644 index 00000000..a688c857 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/config.ts @@ -0,0 +1,36 @@ +import { defineTool } from '@tool/type'; +import { WorkflowIOValueTypeEnum } from '@tool/type/fastgpt'; + +export default defineTool({ + name: { + 'zh-CN': '获取微信公众号鉴权信息', + en: 'Get WeChat Official Account Auth Token' + }, + description: { + 'zh-CN': '通过 AppID 和 AppSecret 获取微信公众号的 access_token,用于后续 API 调用认证', + en: 'Get WeChat Official Account access_token using AppID and AppSecret for subsequent API authentication' + }, + toolDescription: + '获取微信公众号的 access_token。需要提供微信公众号的 AppID 和 AppSecret。返回的 access_token 有效期为 7200 秒,请在过期前重新获取。', + versionList: [ + { + value: '0.1.0', + description: 'Default version', + inputs: [], + outputs: [ + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'access_token', + label: 'AccessToken', + description: '微信公众号 API 访问令牌' + }, + { + valueType: WorkflowIOValueTypeEnum.number, + key: 'expires_in', + label: 'ExpiresIn', + description: '微信公众号 API 访问令牌过期时间(秒)' + } + ] + } + ] +}); diff --git a/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/index.ts b/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/index.ts new file mode 100644 index 00000000..d698ed48 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/index.ts @@ -0,0 +1,10 @@ +import config from './config'; +import { InputType, OutputType, tool as toolCb } from './src'; +import { exportTool } from '@tool/utils/tool'; + +export default exportTool({ + toolCb, + InputType, + OutputType, + config +}); diff --git a/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/src/index.ts b/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/src/index.ts new file mode 100644 index 00000000..c042a3ff --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/getAuthToken/src/index.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { handleGetAuthToken } from '../../../lib/handler'; +import { addLog } from '@/utils/log'; + +export const InputType = z.object({ + appId: z.string().min(1, 'AppID 不能为空'), + secret: z.string().min(1, 'AppSecret 不能为空') +}); + +export const OutputType = z.object({ + access_token: z.string(), + expires_in: z.number() +}); + +export async function tool({ + appId: appid, + secret +}: z.infer): Promise> { + const data = await handleGetAuthToken({ + appid, + secret, + grant_type: 'client_credential' + }); + + addLog.debug(`access_token: ${JSON.stringify(data, null, 2)}`); + + return { + access_token: data.access_token, + expires_in: data.expires_in + }; +} diff --git a/modules/tool/packages/wechatOfficialAccount/children/publishDraft/README.md b/modules/tool/packages/wechatOfficialAccount/children/publishDraft/README.md new file mode 100644 index 00000000..3303492d --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/publishDraft/README.md @@ -0,0 +1,114 @@ +# publishDraft 工具使用说明 + +## 功能描述 + +`publishDraft` 工具用于发布已创建的微信公众号草稿到公众号,支持将草稿内容发布为正式的文章。 + +## 使用场景 + +1. **内容发布工作流**:配合 `uploadMarkdownToDraft` 工具使用,先创建草稿再发布 +2. **批量内容管理**:批量处理草稿并在适当时机发布 +3. **自动化内容发布**:集成到自动化系统中定时发布内容 + +## 参数说明 + +### 认证参数(二选一) + +1. **accessToken**(推荐):直接提供微信公众号访问令牌 +2. **appId + appSecret**:通过应用ID和密钥自动获取访问令牌 + +### 发布参数 + +- **mediaId**:要发布的草稿的 media_id(从创建草稿获得) + +## 使用示例 + +### 方式一:使用 access_token 直接发布 + +```typescript +{ + "accessToken": "your_access_token_here", + "mediaId": "MEDIA_ID_123456789" +} +``` + +### 方式二:使用 app_id 和 app_secret + +```typescript +{ + "appId": "your_wechat_appid", + "appSecret": "your_wechat_appsecret", + "mediaId": "MEDIA_ID_123456789" +} +``` + +## 完整工作流示例 + +### 1. 上传 Markdown 并创建草稿 + +```javascript +// 使用 uploadMarkdownToDraft 工具 +const draftResult = await uploadMarkdownToDraft({ + accessToken: "your_access_token", + markdownContent: "# 我的文章\n\n这是文章内容...", + coverImage: "https://example.com/cover.jpg", + title: "我的新文章", + author: "作者名" +}); + +// 草稿创建成功,返回 media_id +const mediaId = draftResult.media_id; +``` + +### 2. 发布草稿 + +```javascript +// 使用 publishDraft 工具 +const publishResult = await publishDraft({ + accessToken: "your_access_token", + mediaId: mediaId // 使用上一步获得的 media_id +}); + +// 发布成功,返回发布任务ID +console.log("发布任务ID:", publishResult.publishId); +console.log("消息数据ID:", publishResult.msgDataId); +``` + +## 返回结果 + +### 成功响应 + +```json +{ + "publishId": "PUBLISH_ID_123456789", + "msgDataId": "MSG_DATA_ID_123456789" +} +``` + +- **publishId**:发布任务ID,可用于查询发布状态 +- **msgDataId**:消息数据ID,用于标识发布的消息 + +### 错误响应 + +```json +{ + "error_message": "发布草稿失败: 具体错误信息" +} +``` + +## 常见错误及处理 + +1. **media_id 不存在**:确保草稿已成功创建且未被删除 +2. **access_token 过期**:更新 access_token 或使用 appId/appSecret 自动获取 +3. **草稿内容违规**:检查草稿内容是否符合微信公众号规范 +4. **发布频率限制**:注意微信公众号的发布频率限制 + +## 相关工具 + +- **uploadMarkdownToDraft**:创建草稿工具,常与此工具配合使用 +- **uploadPermanentMaterial**:上传永久素材,用于获取封面图片的 media_id + +## API 文档 + +- [微信公众号发布接口文档](https://developers.weixin.qq.com/doc/subscription/api/public/api_freepublish_submit.html) +- [微信公众号发布状态查询](https://developers.weixin.qq.com/doc/subscription/api/public/api_freepublish_get.html) diff --git a/modules/tool/packages/wechatOfficialAccount/children/publishDraft/config.ts b/modules/tool/packages/wechatOfficialAccount/children/publishDraft/config.ts new file mode 100644 index 00000000..86d1eea4 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/publishDraft/config.ts @@ -0,0 +1,54 @@ +import { defineTool } from '@tool/type'; +import { FlowNodeInputTypeEnum, WorkflowIOValueTypeEnum } from '@tool/type/fastgpt'; + +export default defineTool({ + name: { + 'zh-CN': '发布微信公众号草稿', + en: 'Publish WeChat Official Account Draft' + }, + description: { + 'zh-CN': '发布已创建的微信公众号草稿到公众号', + en: 'Publish created WeChat Official Account draft to the official account' + }, + toolDescription: + '将指定的草稿media_id发布到微信公众号,支持使用access_token或appId/appSecret进行认证。发布成功后返回publish_id和msg_data_id,可用于后续的状态查询。', + versionList: [ + { + value: '0.1.0', + description: 'Default version', + inputs: [ + { + key: 'accessToken', + label: '访问令牌', + description: '微信公众号 API 访问令牌(可选,与 appId/appSecret 二选一)', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false + }, + { + key: 'mediaId', + label: '草稿媒体ID', + description: '要发布的草稿media_id(从创建草稿或获取草稿列表中获得)', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: true, + toolDescription: 'Draft media_id to be published' + } + ], + outputs: [ + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'publishId', + label: '发布任务ID', + description: '发布任务ID,可用于查询发布状态' + }, + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'msgDataId', + label: '消息数据ID', + description: '消息数据ID,用于标识发布的消息' + } + ] + } + ] +}); diff --git a/modules/tool/packages/wechatOfficialAccount/children/publishDraft/index.ts b/modules/tool/packages/wechatOfficialAccount/children/publishDraft/index.ts new file mode 100644 index 00000000..d698ed48 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/publishDraft/index.ts @@ -0,0 +1,10 @@ +import config from './config'; +import { InputType, OutputType, tool as toolCb } from './src'; +import { exportTool } from '@tool/utils/tool'; + +export default exportTool({ + toolCb, + InputType, + OutputType, + config +}); diff --git a/modules/tool/packages/wechatOfficialAccount/children/publishDraft/src/index.ts b/modules/tool/packages/wechatOfficialAccount/children/publishDraft/src/index.ts new file mode 100644 index 00000000..3a678225 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/publishDraft/src/index.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import { handleGetAuthToken, handleSubmitPublish } from '../../../lib/handler'; + +export const InputType = z + .object({ + // 认证参数(二选一) + accessToken: z.string().optional(), + appId: z.string().optional(), + appSecret: z.string().optional(), + + // 必需参数 + mediaId: z.string().min(1, '草稿media_id不能为空') + }) + .refine( + (data) => { + // 验证认证参数:要么提供 accessToken,要么同时提供 appId 和 appSecret + return data.accessToken || (data.appId && data.appSecret); + }, + { + message: '必须提供 accessToken,或者同时提供 appId 和 appSecret', + path: ['认证参数'] + } + ); + +export const OutputType = z.object({ + publishId: z.string().optional(), + msgDataId: z.string().optional(), + error_message: z.string().optional() +}); + +export async function tool({ + accessToken, + appId, + appSecret, + mediaId +}: z.infer): Promise> { + try { + // 1. 获取 access_token + let token = accessToken; + if (!token) { + const result = await handleGetAuthToken({ + grant_type: 'client_credential', + appid: appId!, + secret: appSecret! + }); + + if ('access_token' in result && result.access_token) { + token = result.access_token; + } else { + const errorMsg = (result as any).errmsg || '未知错误'; + return { + error_message: `获取 access_token 失败: ${errorMsg} (错误码: ${(result as any).errcode})` + }; + } + } + + // 2. 发布草稿 + const result = await handleSubmitPublish({ + access_token: token, + media_id: mediaId + }); + + // 3. 返回发布结果 + return { + publishId: result.publish_id, + msgDataId: result.msg_data_id + }; + } catch (error) { + // 处理错误情况 + if (error instanceof Error) { + return { + error_message: `发布草稿失败: ${error.message}` + }; + } + + // 处理微信API错误 + if (typeof error === 'object' && error !== null && 'errcode' in error && 'errmsg' in error) { + return { + error_message: `发布草稿失败: ${(error as any).errmsg} (错误码: ${(error as any).errcode})` + }; + } + + return { + error_message: `发布草稿失败: 未知错误` + }; + } +} diff --git a/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/DESIGN.md b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/DESIGN.md new file mode 100644 index 00000000..fd33ffae --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/DESIGN.md @@ -0,0 +1,436 @@ +# Markdown 上传到草稿箱工具设计文档 + +## 工具概述 + +`uploadMarkdownToDraft` 工具的主要功能是将 Markdown 格式的内容转换为微信公众号图文消息,并上传到草稿箱。 + +## 核心功能 + +### 1. Access Token 智能处理 +- **模式一**:直接传入 `accessToken` 参数 +- **模式二**:传入 `appId` 和 `appSecret`,工具自动获取 access_token +- 自动优先使用传入的 access_token,未提供时才调用获取函数 + +### 2. Markdown 转 HTML +- 使用 `marked` 库解析 Markdown 内容 +- 使用 `dompurify` 清理 HTML,确保安全性 +- 支持标准 Markdown 语法:标题、段落、列表、链接、图片等 + +### 3. 图片处理 +- 提取 Markdown 中的图片链接 +- 下载图片并通过微信公众号上传接口上传 +- 替换 HTML 中的图片链接为微信 CDN 链接 + +### 4. 封面图处理 +- 支持两种输入:URL 或 media_id +- URL:下载图片并上传为永久素材 +- media_id:直接使用,无需上传 + +### 5. 文章元数据处理 +- 标题:支持手动输入或自动提取 +- 作者:可选输入 +- 摘要:支持手动输入或自动提取 +- 原文链接:可选输入 +- 评论设置:支持开启/关闭评论,粉丝专属评论 + +## 输入参数设计 + +```typescript +interface ToolInput { + // 认证参数(二选一) + accessToken?: string; // 可选:直接提供 access_token + appId?: string; // 可选:用于获取 access_token + appSecret?: string; // 可选:用于获取 access_token + + // 必需参数 + markdownContent: string; // Markdown 内容 + coverImage: string; // 封面图 URL 或 media_id + + // 可选参数 + title?: string; // 文章标题 + author?: string; // 作者 + digest?: string; // 文章摘要 + contentSourceUrl?: string; // 原文链接 + needOpenComment?: number; // 是否开启评论 + onlyFansCanComment?: number; // 仅粉丝评论 +} +``` + +## 技术实现方案 + +### 1. 依赖包 + +```json +{ + "dependencies": { + "zod": "^3.25.76", + "marked": "^12.0.0", // Markdown 解析 + "dompurify": "^3.0.8" // HTML 清理 + }, + "devDependencies": { + "@types/dompurify": "^3.0.5" // TypeScript 类型定义 + } +} +``` + +### 2. Access Token 获取函数 + +```typescript +import { handleGetAuthToken } from '../../../lib/auth'; + +async function getAccessToken(appId: string, appSecret: string): Promise { + try { + const result = await handleGetAuthToken({ + grant_type: 'client_credential', + appid: appId, + secret: appSecret + }); + + if ('access_token' in result && result.access_token) { + return result.access_token; + } + + return null; + } catch (error) { + console.error('获取 access_token 失败:', error); + return null; + } +} +``` + +### 3. 核心函数设计 + +#### 3.1 Markdown 转 HTML +```typescript +function convertMarkdownToHtml(markdown: string): string { + const marked = require('marked'); + const DOMPurify = require('dompurify'); + + // 配置 marked 选项 + marked.setOptions({ + breaks: true, // 支持换行 + gfm: true, // GitHub Flavored Markdown + sanitize: false // 我们手动清理 + }); + + const rawHtml = marked(markdown); + return DOMPurify.sanitize(rawHtml); +} +``` + +#### 3.2 图片链接提取 +```typescript +function extractImageUrls(html: string): string[] { + const imgRegex = /]+src="([^"]+)"/g; + const urls = []; + let match; + + while ((match = imgRegex.exec(html)) !== null) { + urls.push(match[1]); + } + + return urls; +} +``` + +#### 3.3 图片上传处理 +```typescript +async function uploadImageToWeChat( + accessToken: string, + imageUrl: string +): Promise { + // 下载图片 + const response = await fetch(imageUrl); + const imageBuffer = await response.arrayBuffer(); + const imageBlob = new Blob([imageBuffer]); + + // 创建 FormData + const formData = new FormData(); + formData.append('media', imageBlob, 'image.jpg'); + formData.append('access_token', accessToken); + + // 上传到微信 + const uploadResponse = await fetch( + 'https://api.weixin.qq.com/cgi-bin/media/uploadimg', + { + method: 'POST', + body: formData + } + ); + + const result = await uploadResponse.json(); + if (result.errcode && result.errcode !== 0) { + throw new Error(`上传图片失败: ${result.errmsg}`); + } + + return result.url; +} +``` + +#### 3.4 封面图处理 +```typescript +async function processCoverImage( + accessToken: string, + coverImage: string +): Promise { + // 检查是否为 media_id(通常长度较长且只包含数字字母) + if (coverImage.length > 20 && /^[a-zA-Z0-9_-]+$/.test(coverImage)) { + return coverImage; // 已为 media_id + } + + // 否则作为 URL 处理 + return await uploadImageToWeChat(accessToken, coverImage); +} +``` + +#### 3.5 标题提取 +```typescript +function extractTitle(markdown: string): string | null { + // 提取第一个 H1 标题 + const h1Match = markdown.match(/^#\s+(.+)$/m); + if (h1Match) { + return h1Match[1].trim(); + } + + // 提取第一个 H2 标题 + const h2Match = markdown.match(/^##\s+(.+)$/m); + if (h2Match) { + return h2Match[1].trim(); + } + + return null; +} +``` + +#### 3.6 摘要生成 +```typescript +function generateDigest(html: string, maxLength: number = 120): string { + // 移除 HTML 标签 + const text = html.replace(/<[^>]*>/g, ''); + + // 提取前几个句子 + const sentences = text.split(/[。!?.!?]/).filter(s => s.trim()); + + let digest = ''; + for (const sentence of sentences) { + if (digest.length + sentence.length > maxLength) { + break; + } + digest += sentence + '。'; + } + + return digest.trim() || text.substring(0, maxLength) + '...'; +} +``` + +### 4. 主要处理流程 + +```typescript +export async function tool({ + accessToken, // 可选参数 + appId, // 必需参数(当 accessToken 未提供时) + appSecret, // 必需参数(当 accessToken 未提供时) + markdownContent, + coverImage, + title, + author, + digest, + contentSourceUrl, + needOpenComment = 0, + onlyFansCanComment = 0 +}: ToolInput): Promise { + try { + // 1. 获取 access_token + let token = accessToken; + if (!token) { + if (!appId || !appSecret) { + return { + error_message: '缺少必要参数:当未提供 accessToken 时,必须提供 appId 和 appSecret' + }; + } + + // 调用封装好的获取 access_token 函数 + token = await getAccessToken(appId, appSecret); + if (!token) { + return { + error_message: '获取 access_token 失败,请检查 appId 和 appSecret 是否正确' + }; + } + } + + // 2. Markdown 转 HTML + const html = convertMarkdownToHtml(markdownContent); + + // 3. 提取并处理图片 + const imageUrls = extractImageUrls(html); + let processedHtml = html; + + for (const imageUrl of imageUrls) { + try { + const wechatImageUrl = await uploadImageToWeChat(token, imageUrl); + processedHtml = processedHtml.replace(imageUrl, wechatImageUrl); + } catch (error) { + console.warn(`上传图片失败: ${imageUrl}`, error); + // 保持原链接,继续处理其他图片 + } + } + + // 4. 处理封面图 + const thumbMediaId = await processCoverImage(token, coverImage); + + // 5. 处理文章元数据 + const articleTitle = title || extractTitle(markdownContent) || '未命名文章'; + const articleDigest = digest || generateDigest(processedHtml); + + // 6. 构建文章对象 + const article = { + title: articleTitle, + author: author, + digest: articleDigest, + content: processedHtml, + content_source_url: contentSourceUrl, + thumb_media_id: thumbMediaId, + need_open_comment: needOpenComment, + only_fans_can_comment: onlyFansCanComment, + article_type: 'news' + }; + + // 7. 上传到草稿箱 + const draftResponse = await fetch( + `https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${token}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + articles: [article] + }) + } + ); + + const result = await draftResponse.json(); + + if (result.errcode && result.errcode !== 0) { + return { + error_message: `上传草稿失败: ${result.errmsg} (错误码: ${result.errcode})` + }; + } + + return { + media_id: result.media_id + }; + + } catch (error) { + return { + error_message: error instanceof Error + ? error.message + : '处理过程中发生未知错误' + }; + } +} +``` + +## 输入参数配置更新 + +需要更新 `config.ts` 文件,增加认证相关的输入参数: + +```typescript +inputs: [ + // 认证参数组 + { + key: 'accessToken', + label: { 'zh-CN': '访问令牌', en: 'Access Token' }, + description: { + 'zh-CN': '微信公众号 API 访问令牌(可选,与 appId/appSecret 二选一)', + en: 'WeChat Official Account API access token (optional, choose one with appId/appSecret)' + }, + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false + }, + { + key: 'appId', + label: { 'zh-CN': 'AppID', en: 'AppID' }, + description: { + 'zh-CN': '微信公众号 AppID(当未提供 accessToken 时必需)', + en: 'WeChat Official Account AppID (required when accessToken not provided)' + }, + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false + }, + { + key: 'appSecret', + label: { 'zh-CN': 'AppSecret', en: 'AppSecret' }, + description: { + 'zh-CN': '微信公众号 AppSecret(当未提供 accessToken 时必需)', + en: 'WeChat Official Account AppSecret (required when accessToken not provided)' + }, + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false + }, + // ... 其他参数 +] +``` + +## 错误处理策略 + +### 1. Access Token 相关错误 +- 参数验证:确保提供了足够的认证信息 +- Token 获取失败:提供清晰的错误信息 +- Token 过期:建议重新获取 + +### 2. 图片上传失败 +- 记录警告日志,但不中断整体流程 +- 保持原始图片链接,允许用户手动处理 + +### 3. 封面图处理失败 +- 返回具体错误信息 +- 建议用户检查图片 URL 或提供 media_id + +### 4. 草稿上传失败 +- 返回微信 API 的具体错误信息 +- 包含错误码和错误描述 + +## 使用示例 + +### 方式一:直接提供 access_token +```typescript +const result = await tool.cb({ + accessToken: 'your_access_token', + markdownContent: '# 标题\n\n这是一篇**文章**内容。', + coverImage: 'https://example.com/cover.jpg' +}); +``` + +### 方式二:通过 appId/appSecret 获取 +```typescript +const result = await tool.cb({ + appId: 'your_app_id', + appSecret: 'your_app_secret', + markdownContent: '# 标题\n\n这是一篇**文章**内容。', + coverImage: 'https://example.com/cover.jpg' +}); +``` + +### 完整配置示例 +```typescript +const result = await tool.cb({ + appId: 'your_app_id', + appSecret: 'your_app_secret', + markdownContent: '# 文章标题\n\n这里是文章内容...', + coverImage: 'https://example.com/cover.jpg', + title: '自定义标题', + author: '作者名', + digest: '文章摘要', + contentSourceUrl: 'https://example.com/original', + needOpenComment: 1, + onlyFansCanComment: 0 +}); +``` + +## 配套函数 + +为了支持 access_token 的智能处理,我们需要在 `lib/auth.ts` 中导出 `handleGetAuthToken` 函数,供这个工具调用。 diff --git a/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/config.ts b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/config.ts new file mode 100644 index 00000000..d57a4ef0 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/config.ts @@ -0,0 +1,123 @@ +import { defineTool } from '@tool/type'; +import { FlowNodeInputTypeEnum, WorkflowIOValueTypeEnum } from '@tool/type/fastgpt'; + +export default defineTool({ + name: { + 'zh-CN': '上传 Markdown 到草稿箱', + en: 'Upload Markdown to Draft' + }, + description: { + 'zh-CN': '将 Markdown 格式的内容转换为图文消息并上传到微信公众号草稿箱', + en: 'Convert Markdown content to news article and upload to WeChat Official Account draft box' + }, + toolDescription: + '将 Markdown 内容转换为微信公众号图文消息格式,自动处理图片上传和封面图,然后保存到草稿箱。支持标题、作者、摘要等信息的自定义配置。', + versionList: [ + { + value: '0.1.0', + description: 'Default version', + inputs: [ + { + key: 'accessToken', + label: '访问令牌', + description: '微信公众号 API 访问令牌(可选,与 appId/appSecret 二选一)', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false + }, + { + key: 'markdownContent', + label: 'Markdown 内容', + description: '要转换的 Markdown 格式文章内容', + renderTypeList: [ + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference, + FlowNodeInputTypeEnum.textarea + ], + valueType: WorkflowIOValueTypeEnum.string, + toolDescription: 'markdown format content', + required: true + }, + { + key: 'coverImage', + label: '封面图', + description: '封面图片 URL 或 media_id,如果是 URL 将自动上传为永久素材', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: true, + toolDescription: 'cover image url or media_id' + }, + { + key: 'title', + label: '文章标题', + description: '图文消息的标题,如果不填写将尝试从 Markdown 中提取', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: true, + toolDescription: 'article title' + }, + { + key: 'author', + label: '作者', + description: '文章作者信息', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false, + toolDescription: 'article author' + }, + { + key: 'digest', + label: '文章摘要', + description: '文章摘要信息,如果不填写将自动从内容中提取', + renderTypeList: [ + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference, + FlowNodeInputTypeEnum.textarea + ], + valueType: WorkflowIOValueTypeEnum.string, + required: false, + toolDescription: 'article digest, optional, less than 120 characters' + }, + { + key: 'contentSourceUrl', + label: '原文链接', + description: '原文阅读链接地址', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false, + toolDescription: 'original article link' + }, + { + key: 'needOpenComment', + label: '开启评论', + description: '是否开启评论功能,0 表示关闭,1 表示开启', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.number, + required: false + }, + { + key: 'onlyFansCanComment', + label: '仅粉丝评论', + description: '是否仅允许粉丝评论,0 表示所有人可评论,1 表示仅粉丝可评论', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.number, + required: false + } + ], + outputs: [ + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'media_id', + label: '素材ID', + description: '草稿箱中图文消息的媒体标识符' + }, + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'error_message', + label: '错误信息', + description: '处理过程中的错误信息' + } + ] + } + ] +}); diff --git a/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/index.ts b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/index.ts new file mode 100644 index 00000000..d698ed48 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/index.ts @@ -0,0 +1,10 @@ +import config from './config'; +import { InputType, OutputType, tool as toolCb } from './src'; +import { exportTool } from '@tool/utils/tool'; + +export default exportTool({ + toolCb, + InputType, + OutputType, + config +}); diff --git a/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/src/index.ts b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/src/index.ts new file mode 100644 index 00000000..e065ca57 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/src/index.ts @@ -0,0 +1,245 @@ +import { z } from 'zod'; +import { marked } from 'marked'; +import { + handleGetAuthToken, + handleUploadImage, + handleAddDraft, + handleAddMaterial, + downloadImageFromUrl +} from '../../../lib/handler'; +import { addInlineStyles } from './styles'; + +export const InputType = z + .object({ + // 认证参数(二选一) + accessToken: z.string().optional(), + appId: z.string().optional(), + secret: z.string().optional(), + + // 必需参数 + markdownContent: z.string().min(1, 'Markdown 内容不能为空'), + coverImage: z.string().min(1, '封面图不能为空'), + + // 可选参数 + title: z.string().optional(), + author: z.string().optional(), + digest: z.string().optional(), + contentSourceUrl: z.string().optional(), + needOpenComment: z.number().optional().default(0), + onlyFansCanComment: z.number().optional().default(0) + }) + .refine( + (data) => { + // 验证认证参数:要么提供 accessToken,要么同时提供 appId 和 appSecret + return data.accessToken || (data.appId && data.secret); + }, + { + message: '必须提供 accessToken,或者同时提供 appId 和 appSecret', + path: ['认证参数'] + } + ); + +export const OutputType = z.object({ + media_id: z.string().optional(), + error_message: z.string().optional() +}); + +export async function tool({ + accessToken, + appId, + secret, + markdownContent, + coverImage, + title, + author, + digest, + contentSourceUrl, + needOpenComment = 0, + onlyFansCanComment = 0 +}: z.infer): Promise> { + // 1. 获取 access_token + let token = accessToken; + if (!token) { + const result = await handleGetAuthToken({ + grant_type: 'client_credential', + appid: appId!, + secret: secret! + }); + + if ('access_token' in result && result.access_token) { + token = result.access_token; + } else { + const errorMsg = (result as any).errmsg || '未知错误'; + return { + error_message: `获取 access_token 失败: ${errorMsg} (错误码: ${(result as any).errcode})` + }; + } + } + + // 2. Markdown 转 HTML + const html = convertMarkdownToHtml(markdownContent); + + // 3. 提取并处理图片 + const imageUrls = extractImageUrls(html); + let processedHtml = html; + + for (const imageUrl of imageUrls) { + try { + const wechatImageUrl = await uploadImageToWeChat(token, imageUrl); + processedHtml = processedHtml.replace(imageUrl, wechatImageUrl); + } catch (error) { + console.warn(`上传图片失败: ${imageUrl}`, error); + // 保持原链接,继续处理其他图片 + } + } + + // 4. 处理封面图 + const thumbMediaId = await processCoverImage(token, coverImage); + + // 5. 构建文章对象 + const article = { + title: title, + author: author, + digest: digest, + content: processedHtml, + content_source_url: contentSourceUrl, + thumb_media_id: thumbMediaId, + need_open_comment: needOpenComment, + only_fans_can_comment: onlyFansCanComment, + article_type: 'news' as const + }; + + // 6. 上传到草稿箱 + const result = await handleAddDraft({ + access_token: token, + articles: [article] + }); + + return { + media_id: result.media_id + }; +} + +/** + * Markdown 转 HTML + */ +function convertMarkdownToHtml(markdown: string): string { + // 配置 marked 选项 + marked.setOptions({ + breaks: true, // 支持换行 + gfm: true // GitHub Flavored Markdown + }); + + // 解析 Markdown 为 HTML + const rawHtml = marked.parse(markdown) as string; + + // 清理 HTML 并添加内联样式,适用于微信公众号环境 + return sanitizeAndAddStyles(rawHtml); +} + +/** + * 清理 HTML 并添加内联样式 + */ +function sanitizeAndAddStyles(html: string): string { + // 移除微信不支持的脚本标签 + html = html.replace(/)<[^<]*)*<\/script>/gi, ''); + + // 移除危险的事件处理器属性 + html = html.replace(/\s*on\w+="[^"]*"/gi, ''); + + // 移除 javascript: 协议 + html = html.replace(/javascript:/gi, ''); + + // 移除微信不支持的标签,保留基本格式化标签 + const unsupportedTags = [ + 'iframe', + 'object', + 'embed', + 'form', + 'input', + 'button', + 'select', + 'textarea', + 'style' + ]; + unsupportedTags.forEach((tag) => { + const regex = new RegExp(`<${tag}[^>]*>.*?`, 'gis'); + html = html.replace(regex, ''); + }); + + // 添加内联样式 + return addInlineStyles(html); +} + +/** + * 提取图片链接 + */ +function extractImageUrls(html: string): string[] { + const imgRegex = /]+src="([^"]+)"/g; + const urls = []; + let match; + + while ((match = imgRegex.exec(html)) !== null) { + urls.push(match[1]); + } + + return urls; +} + +/** + * 上传图片到微信(临时素材) + */ +async function uploadImageToWeChat(accessToken: string, imageUrl: string): Promise { + try { + // 使用统一的图片下载方法 + const imageBlob = await downloadImageFromUrl(imageUrl, 'image.jpg', 'image/jpeg'); + + const result = await handleUploadImage({ + access_token: accessToken, + media: imageBlob + }); + + if ('errcode' in result && result.errcode !== 0) { + const errorMsg = (result as any).errmsg || '未知错误'; + throw new Error(`上传图片失败: ${errorMsg} (错误码: ${result.errcode})`); + } + + return result.url; + } catch (error) { + throw new Error( + `上传图片失败: ${imageUrl} - ${error instanceof Error ? error.message : '未知错误'}` + ); + } +} + +/** + * 处理封面图(上传为永久素材) + */ +async function processCoverImage(accessToken: string, coverImage: string): Promise { + // 检查是否为 media_id(通常长度较长且只包含数字字母下划线) + if (coverImage.length > 20 && /^[a-zA-Z0-9_-]+$/.test(coverImage)) { + return coverImage; // 已为 media_id + } + + try { + // 使用统一的图片下载方法 + const imageBlob = await downloadImageFromUrl(coverImage, 'cover.jpg', 'image/jpeg'); + + const result = await handleAddMaterial({ + access_token: accessToken, + type: 'image', + media: imageBlob + }); + + if ('errcode' in result && result.errcode !== 0) { + const errorMsg = (result as any).errmsg || '未知错误'; + throw new Error(`上传封面图失败: ${errorMsg} (错误码: ${result.errcode})`); + } + + return result.media_id; // 返回永久素材的 media_id + } catch (error) { + throw new Error( + `处理封面图失败: ${coverImage} - ${error instanceof Error ? error.message : '未知错误'}` + ); + } +} diff --git a/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/src/styles.ts b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/src/styles.ts new file mode 100644 index 00000000..fb43c40d --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/uploadMarkdownToDraft/src/styles.ts @@ -0,0 +1,255 @@ +import * as cheerio from 'cheerio'; + +/** + * 使用 cheerio 为 HTML 元素添加内联样式 + */ +export function addInlineStyles(html: string): string { + const $ = cheerio.load(html, { + xmlMode: false + }); + + // 标题样式 + $('h1').css({ + color: '#000000', + 'font-weight': 'bold', + 'font-size': '24px', + 'margin-top': '1.5em', + 'margin-bottom': '0.8em', + 'line-height': '1.3', + 'border-bottom': '2px solid #e0e0e0', + 'padding-bottom': '10px' + }); + + $('h2').css({ + color: '#000000', + 'font-weight': 'bold', + 'font-size': '20px', + 'margin-top': '1.5em', + 'margin-bottom': '0.8em', + 'line-height': '1.3', + 'border-bottom': '1px solid #e0e0e0', + 'padding-bottom': '8px' + }); + + $('h3').css({ + color: '#000000', + 'font-weight': 'bold', + 'font-size': '18px', + 'margin-top': '1.5em', + 'margin-bottom': '0.8em', + 'line-height': '1.3' + }); + + $('h4').css({ + color: '#000000', + 'font-weight': 'bold', + 'font-size': '16px', + 'margin-top': '1.5em', + 'margin-bottom': '0.8em', + 'line-height': '1.3' + }); + + $('h5').css({ + color: '#000000', + 'font-weight': 'bold', + 'font-size': '14px', + 'margin-top': '1.5em', + 'margin-bottom': '0.8em', + 'line-height': '1.3' + }); + + $('h6').css({ + color: '#000000', + 'font-weight': 'bold', + 'font-size': '12px', + 'margin-top': '1.5em', + 'margin-bottom': '0.8em', + 'line-height': '1.3' + }); + + // 段落样式 + $('p').css({ + margin: '1em 0', + 'text-align': 'justify', + 'font-size': '16px', + 'line-height': '1.6' + }); + + // 链接样式 + $('a').css({ + color: '#576b95', + 'text-decoration': 'none' + }); + + // 强调样式 + $('strong').css({ + 'font-weight': 'bold', + color: '#000000' + }); + + $('b').css({ + 'font-weight': 'bold', + color: '#000000' + }); + + $('em').css({ + 'font-style': 'italic', + color: '#666666' + }); + + $('i').css({ + 'font-style': 'italic', + color: '#666666' + }); + + // 代码样式(排除 pre 内的 code) + $('code').each(function () { + const $code = $(this); + // 如果不是 pre 内的 code,才应用行内代码样式 + if ($code.parent().prop('tagName') !== 'PRE') { + $code.css({ + 'font-family': "'Consolas', 'Monaco', 'Courier New', monospace", + 'background-color': '#f5f5f5', + padding: '2px 4px', + 'border-radius': '3px', + 'font-size': '0.9em', + color: '#e74c3c' + }); + } + }); + + // 预格式化代码块样式 + $('pre').css({ + 'background-color': '#f8f9fa', + border: '1px solid #e9ecef', + 'border-radius': '4px', + padding: '12px', + 'overflow-x': 'auto', + margin: '1em 0', + 'font-size': '14px' + }); + + // pre 内的 code 元素(重置样式) + $('pre code').css({ + 'background-color': 'transparent', + padding: '0', + 'border-radius': '0', + 'font-size': '0.9em', + color: '#495057', + 'font-family': "'Consolas', 'Monaco', 'Courier New', monospace" + }); + + // 引用样式 + $('blockquote').css({ + 'border-left': '4px solid #576b95', + margin: '1em 0', + padding: '0.5em 1em', + 'background-color': '#f8f9fa', + color: '#6c757d' + }); + + // 列表样式 + $('ul').css({ + margin: '1em 0', + 'padding-left': '2em', + 'font-size': '16px', + 'line-height': '1.6' + }); + + $('ol').css({ + margin: '1em 0', + 'padding-left': '2em', + 'font-size': '16px', + 'line-height': '1.6' + }); + + $('li').css({ + margin: '0.5em 0', + 'font-size': '16px', + 'line-height': '1.6' + }); + + // 表格样式 + $('table').css({ + width: '100%', + 'border-collapse': 'collapse', + margin: '1em 0', + 'font-size': '14px' + }); + + $('th').css({ + border: '1px solid #e0e0e0', + padding: '8px 12px', + 'text-align': 'left', + 'background-color': '#f5f5f5', + 'font-weight': 'bold' + }); + + $('td').css({ + border: '1px solid #e0e0e0', + padding: '8px 12px', + 'text-align': 'left' + }); + + // 分隔线样式 + $('hr').css({ + border: 'none', + 'border-top': '1px solid #e0e0e0', + margin: '2em 0' + }); + + // 图片样式 + $('img').css({ + 'max-width': '100%', + height: 'auto', + display: 'block', + margin: '1em auto', + 'border-radius': '4px' + }); + + // 特殊样式类处理 + $('.highlight').css({ + 'background-color': '#fff3cd', + border: '1px solid #ffeaa7', + 'border-radius': '4px', + padding: '8px 12px', + margin: '1em 0' + }); + + $('.info').css({ + 'background-color': '#d1ecf1', + border: '1px solid #bee5eb', + 'border-radius': '4px', + padding: '8px 12px', + margin: '1em 0' + }); + + $('.warning').css({ + 'background-color': '#fff3cd', + border: '1px solid #ffeaa7', + 'border-radius': '4px', + padding: '8px 12px', + margin: '1em 0' + }); + + $('.error').css({ + 'background-color': '#f8d7da', + border: '1px solid #f5c6cb', + 'border-radius': '4px', + padding: '8px 12px', + margin: '1em 0' + }); + + $('.success').css({ + 'background-color': '#d4edda', + border: '1px solid #c3e6cb', + 'border-radius': '4px', + padding: '8px 12px', + margin: '1em 0' + }); + + // 移除原有的 class 属性(因为已经转为内联样式) + $('[class]').removeAttr('class'); + + return $.html(); +} diff --git a/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/config.ts b/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/config.ts new file mode 100644 index 00000000..5d5f44f4 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/config.ts @@ -0,0 +1,113 @@ +import { defineTool } from '@tool/type'; +import { FlowNodeInputTypeEnum, WorkflowIOValueTypeEnum } from '@tool/type/fastgpt'; + +export default defineTool({ + name: { + 'zh-CN': '上传永久素材', + en: 'Upload Permanent Material' + }, + description: { + 'zh-CN': '上传永久素材到微信公众号,支持图片、语音、视频和缩略图等类型', + en: 'Upload permanent materials to WeChat Official Account, supporting images, voice, video and thumbnails' + }, + toolDescription: + '上传永久素材到微信公众号素材库。支持图片、语音、视频和缩略图等类型。素材上传后会永久保存在公众号素材库中,可用于后续的图文消息和群发消息。支持文件路径、Base64编码和URL三种输入方式。', + versionList: [ + { + value: '0.1.0', + description: 'Default version', + inputs: [ + { + key: 'type', + label: '素材类型', + description: '要上传的素材类型', + renderTypeList: [FlowNodeInputTypeEnum.select], + valueType: WorkflowIOValueTypeEnum.string, + required: true, + toolDescription: 'Material type to upload', + list: [ + { label: '图片', value: 'image' }, + { label: '语音', value: 'voice' }, + { label: '视频', value: 'video' } + ] + }, + { + key: 'mediaUrl', + label: '媒体文件 URL', + description: '媒体文件的 URL 地址', + renderTypeList: [ + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference, + FlowNodeInputTypeEnum.textarea + ], + valueType: WorkflowIOValueTypeEnum.string, + required: true, + toolDescription: 'Media file content (Base64, file path or URL)' + }, + { + key: 'title', + label: '素材标题', + description: '素材标题,视频素材必填', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false, + toolDescription: 'Material title (required for video)' + }, + { + key: 'introduction', + label: '素材简介', + description: '素材简介,视频素材必填', + renderTypeList: [ + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference, + FlowNodeInputTypeEnum.textarea + ], + valueType: WorkflowIOValueTypeEnum.string, + required: false, + toolDescription: 'Material introduction (required for video)' + }, + { + key: 'accessToken', + label: '访问令牌', + description: '微信公众号访问令牌(可选,与 appId/appSecret 二选一)', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + required: false, + toolDescription: 'WeChat API access token (optional, alternative to appId/appSecret)' + } + ], + outputs: [ + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'media_id', + label: '媒体 ID', + description: '上传成功后返回的媒体文件 ID' + }, + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'url', + label: '文件 URL', + description: '图片素材返回的 URL 地址' + }, + { + valueType: WorkflowIOValueTypeEnum.boolean, + key: 'success', + label: '上传状态', + description: '是否上传成功' + }, + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'message', + label: '响应消息', + description: '操作结果说明' + }, + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'error_message', + label: '错误信息', + description: '处理过程中的错误信息' + } + ] + } + ] +}); diff --git a/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/index.ts b/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/index.ts new file mode 100644 index 00000000..d698ed48 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/index.ts @@ -0,0 +1,10 @@ +import config from './config'; +import { InputType, OutputType, tool as toolCb } from './src'; +import { exportTool } from '@tool/utils/tool'; + +export default exportTool({ + toolCb, + InputType, + OutputType, + config +}); diff --git a/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/src/index.ts b/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/src/index.ts new file mode 100644 index 00000000..f1996274 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/children/uploadPermanentMaterial/src/index.ts @@ -0,0 +1,173 @@ +import { z } from 'zod'; +import { handleGetAuthToken, handleAddMaterial } from '../../../lib/handler'; +import { addLog } from '@/utils/log'; + +export const InputType = z + .object({ + // 认证参数(二选一) + accessToken: z.string().optional(), + appId: z.string().optional(), + secret: z.string().optional(), + + // 必需参数 + type: z.enum(['image', 'voice', 'video']), + mediaUrl: z.string().url('请提供有效的文件URL'), + + // 可选参数(视频素材需要) + title: z.string().optional(), + introduction: z.string().optional() + }) + .refine( + (data) => { + // 验证认证参数:要么提供 accessToken,要么同时提供 appId 和 secret + return data.accessToken || (data.appId && data.secret); + }, + { + message: '必须提供 accessToken,或者同时提供 appId 和 appSecret', + path: ['认证参数'] + } + ) + .refine( + (data) => { + // 对于视频类型,title 和 introduction 是必需的 + if (data.type === 'video') { + return !!(data.title && data.introduction); + } + return true; + }, + { + message: '视频素材必须提供标题和简介', + path: ['视频参数'] + } + ); + +export const OutputType = z.object({ + media_id: z.string().optional(), + url: z.string().optional(), + success: z.boolean(), + message: z.string().optional(), + error_message: z.string().optional() +}); + +// Helper function to get MIME type from file extension +function getMimeType(extension: string): string { + const mimeTypes: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + bmp: 'image/bmp', + webp: 'image/webp', + mp3: 'audio/mpeg', + amr: 'audio/amr', + mp4: 'video/mp4', + m4v: 'video/mp4' + }; + return mimeTypes[extension.toLowerCase()] || 'application/octet-stream'; +} + +// Helper function to get file extension for specific material type +function getFileExtensionForType(type: string, originalExtension?: string): string { + switch (type) { + case 'voice': + return 'amr'; + case 'thumb': + return 'jpg'; + case 'video': + return 'mp4'; + case 'image': + default: + return originalExtension || 'jpg'; + } +} + +// Helper function to download file from URL and create File object +async function downloadFileFromUrl(mediaUrl: string, type: string): Promise { + try { + const response = await fetch(mediaUrl); + if (!response.ok) { + throw new Error(`下载文件失败: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const urlPath = new URL(mediaUrl).pathname; + const originalExtension = urlPath.split('.').pop() || ''; + const extension = getFileExtensionForType(type, originalExtension); + const filename = `file.${extension}`; + + return new File([arrayBuffer], filename, { type: getMimeType(extension) }); + } catch (error) { + throw new Error(`下载文件失败: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +export async function tool({ + accessToken, + appId, + secret, + type, + mediaUrl, + title, + introduction +}: z.infer): Promise> { + try { + // 1. 获取 access_token + let token = accessToken; + if (!token) { + const result = await handleGetAuthToken({ + grant_type: 'client_credential', + appid: appId!, + secret: secret! + }); + + if ('access_token' in result && result.access_token) { + token = result.access_token; + } else { + const errorMsg = (result as any).errmsg || '未知错误'; + return { + success: false, + error_message: `获取 access_token 失败: ${errorMsg} (错误码: ${(result as any).errcode})` + }; + } + } + + // 3. 准备上传参数 + const uploadParams: any = { + access_token: token, + type: type + }; + + // 4. 为视频类型添加描述信息 + if (type === 'video') { + uploadParams.description = { + title: title!, + introduction: introduction! + }; + } + + // 5. 调用微信 API 上传永久素材 + const result = await handleAddMaterial({ + access_token: token, + type: type, + description: { + title: title!, + introduction: introduction! + }, + media: await downloadFileFromUrl(mediaUrl, type) + }); + + addLog.debug(`Upload permanent material result: ${JSON.stringify(result, null, 2)}`); + + return { + success: true, + media_id: result.media_id, + url: result.url, // 仅图片类型返回 + message: '永久素材上传成功' + }; + } catch (error) { + return { + success: false, + error_message: `上传失败: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} diff --git a/modules/tool/packages/wechatOfficialAccount/config.ts b/modules/tool/packages/wechatOfficialAccount/config.ts new file mode 100644 index 00000000..9d5d722b --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/config.ts @@ -0,0 +1,32 @@ +import { defineToolSet } from '@tool/type'; +import { ToolTagEnum } from '@tool/type/tags'; + +export default defineToolSet({ + name: { + 'zh-CN': '微信公众号工具集', + en: 'WeChat Official Account Tool Set' + }, + tags: [ToolTagEnum.enum.social], + description: { + 'zh-CN': '微信公众号素材管理、草稿管理和发布工具集', + en: 'WeChat Official Account materials, drafts and publishing management tool set' + }, + toolDescription: + 'WeChat Official Account API tools for managing materials, drafts and publishing articles', + secretInputConfig: [ + { + key: 'appId', + label: 'AppID', + description: '微信公众号开发者ID(AppID)', + required: false, + inputType: 'input' + }, + { + key: 'secret', + label: 'AppSecret', + description: '微信公众号开发者密钥(AppSecret)', + required: false, + inputType: 'secret' + } + ] +}); diff --git a/modules/tool/packages/wechatOfficialAccount/index.ts b/modules/tool/packages/wechatOfficialAccount/index.ts new file mode 100644 index 00000000..22bccae7 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/index.ts @@ -0,0 +1,8 @@ +// You should not modify this file, if you need to modify the tool set configuration, please modify the config.ts file + +import config from './config'; +import { exportToolSet } from '@tool/utils/tool'; + +export default exportToolSet({ + config +}); diff --git a/modules/tool/packages/wechatOfficialAccount/lib/api.ts b/modules/tool/packages/wechatOfficialAccount/lib/api.ts new file mode 100644 index 00000000..5cd49a37 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/lib/api.ts @@ -0,0 +1,399 @@ +export const OffiAccountURL = { + // https://developers.weixin.qq.com/doc/subscription/api/base/api_getaccesstoken.html + getAuthToken: { + url: 'https://api.weixin.qq.com/cgi-bin/token', + method: 'get' as const + }, + + // Materials API + // https://developers.weixin.qq.com/doc/subscription/api/material/permanent/api_getmaterial.html + getMaterial: { + url: 'https://api.weixin.qq.com/cgi-bin/material/get_material', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/material/permanent/api_batchgetmaterial.html + batchGetMaterial: { + url: 'https://api.weixin.qq.com/cgi-bin/material/batchget_material', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/material/permanent/api_uploadimage.html + uploadImage: { + url: 'https://api.weixin.qq.com/cgi-bin/media/uploadimg', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/material/permanent/api_addmaterial.html + addMaterial: { + url: 'https://api.weixin.qq.com/cgi-bin/material/add_material', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/material/permanent/api_delmaterial.html + deleteMaterial: { + url: 'https://api.weixin.qq.com/cgi-bin/material/del_material', + method: 'post' as const + }, + + // Draft API + // https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_switch.html + draftSwitch: { + url: 'https://api.weixin.qq.com/cgi-bin/draft/switch', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_update.html + updateDraft: { + url: 'https://api.weixin.qq.com/cgi-bin/draft/update', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_batchget.html + batchGetDraft: { + url: 'https://api.weixin.qq.com/cgi-bin/draft/batchget', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_add.html + addDraft: { + url: 'https://api.weixin.qq.com/cgi-bin/draft/add', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_getdraft.html + getDraft: { + url: 'https://api.weixin.qq.com/cgi-bin/draft/get', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_delete.html + deleteDraft: { + url: 'https://api.weixin.qq.com/cgi-bin/draft/delete', + method: 'post' as const + }, + + // Release API + // https://developers.weixin.qq.com/doc/subscription/api/public/api_freepublish_batchget.html + batchGetPublished: { + url: 'https://api.weixin.qq.com/cgi-bin/freepublish/batchget', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/public/api_freepublish_submit.html + submitPublish: { + url: 'https://api.weixin.qq.com/cgi-bin/freepublish/submit', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/public/api_freepublishdelete.html + deletePublished: { + url: 'https://api.weixin.qq.com/cgi-bin/freepublish/delete', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/public/api_freepublish_get.html + getPublishStatus: { + url: 'https://api.weixin.qq.com/cgi-bin/freepublish/get', + method: 'post' as const + }, + + // https://developers.weixin.qq.com/doc/subscription/api/public/api_freepublishgetarticle.html + getPublishedArticle: { + url: 'https://api.weixin.qq.com/cgi-bin/freepublish/getarticle', + method: 'post' as const + } +} as const; +// Common Types +export interface NewsItem { + title: string; + thumb_media_id: string; + show_cover_pic: number; + author?: string; + digest?: string; + content: string; + url: string; + content_source_url?: string; +} + +export interface MaterialItem { + media_id: string; + content?: { + news_item: NewsItem[]; + }; + update_time: number; + name?: string; + url?: string; +} + +export interface DraftItem { + media_id: string; + content: { + news_item: (Article & { + url?: string; + is_deleted?: boolean; + })[]; + }; + update_time: number; +} + +export interface PublishedItem { + article_id: string; + content: { + news_item: (Article & { + url?: string; + })[]; + }; + update_time: number; +} + +export type WeChatError = { + errcode: number; + errmsg: string; +}; + +// Material types +export type MaterialType = 'image' | 'video' | 'voice' | 'news'; + +// Article types for drafts +export type ArticleType = 'news' | 'newspic'; + +// Publish status types +export type PublishStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0=success, 1=publishing, 2=original fail, 3=normal fail, 4=audit fail, 5=deleted, 6=banned + +// Image info for articles +export interface ImageInfo { + image_list: Array<{ + image_media_id: string; + }>; +} + +// Product info for articles +export interface ProductInfo { + footer_product_info?: { + product_key?: string; + }; +} + +// Complete article structure for drafts +export interface Article { + article_type?: ArticleType; + title?: string; + author?: string; + digest?: string; + content: string; + content_source_url?: string; + thumb_media_id?: string; + need_open_comment?: number; + only_fans_can_comment?: number; + pic_crop_235_1?: string; + pic_crop_1_1?: string; + image_info?: ImageInfo; + cover_info?: any; + product_info?: ProductInfo; +} + +export type OffiAccountAPIType = { + getAuthToken: { + req: { + grant_type: 'client_credential'; + appid: string; + secret: string; + }; + res: { + access_token: string; + expires_in: number; + }; + }; + + // Materials API + getMaterial: { + req: { + access_token: string; + media_id: string; + }; + res: + | { news_item: NewsItem[] } + | { title: string; description: string; down_url: string } + | Blob; // For other media types + }; + + batchGetMaterial: { + req: { + access_token: string; + type: MaterialType; + offset: number; + count: number; + }; + res: { + total_count: number; + item_count: number; + item: MaterialItem[]; + }; + }; + + uploadImage: { + req: { + access_token: string; + media: File; // FormData file + }; + res: { + url: string; + errcode?: number; + errmsg?: string; + } & WeChatError; + }; + + addMaterial: { + req: { + access_token: string; + type: MaterialType; + media: File; // FormData file + description?: { + title?: string; + introduction?: string; + }; + }; + res: { + media_id: string; + url?: string; // Only for image type + }; + }; + + deleteMaterial: { + req: { + access_token: string; + media_id: string; + }; + res: WeChatError; + }; + + // Draft API + draftSwitch: { + req: { + access_token: string; + checkonly?: number; // 1 for check only + }; + res: WeChatError & { + is_open?: number; // 0: closed, 1: open + }; + }; + + updateDraft: { + req: { + access_token: string; + media_id: string; + index: number; // Position in articles array (0-based) + articles: Article; + }; + res: WeChatError; + }; + + batchGetDraft: { + req: { + access_token: string; + offset: number; + count: number; // 1-20 + no_content?: number; // 1 to exclude content field + }; + res: { + total_count: number; + item_count: number; + item: DraftItem[]; + }; + }; + + addDraft: { + req: { + access_token: string; + articles: Article[]; + }; + res: { + media_id: string; + }; + }; + + getDraft: { + req: { + access_token: string; + media_id: string; + }; + res: { + news_item: (Article & { + thumb_url?: string; + url?: string; + is_deleted?: boolean; + })[]; + }; + }; + + deleteDraft: { + req: { + access_token: string; + media_id: string; + }; + res: WeChatError; + }; + + batchGetPublished: { + req: { + access_token: string; + offset: number; + count: number; // 1-20 + no_content?: number; // 1 to exclude content field + }; + res: { + total_count: number; + item_count: number; + item: PublishedItem[]; + }; + }; + + submitPublish: { + req: { + access_token: string; + media_id: string; + }; + res: WeChatError & { + publish_id: string; + msg_data_id: string; + }; + }; + + deletePublished: { + req: { + access_token: string; + article_id: string; + index?: number; // Position (1-based), 0 or empty deletes all + }; + res: WeChatError; + }; + + getPublishStatus: { + req: { + access_token: string; + publish_id: string; + }; + res: WeChatError & { + publish_id: string; + publish_status: PublishStatus; + article_id?: string; + article_detail?: any; + fail_idx?: number[]; // Indices of failed articles + }; + }; + + getPublishedArticle: { + req: { + access_token: string; + article_id: string; + }; + res: WeChatError & { + news_item: (Article & { + thumb_url?: string; + url?: string; + is_deleted?: boolean; + })[]; + }; + }; +}; diff --git a/modules/tool/packages/wechatOfficialAccount/lib/handler.ts b/modules/tool/packages/wechatOfficialAccount/lib/handler.ts new file mode 100644 index 00000000..4d364f9a --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/lib/handler.ts @@ -0,0 +1,336 @@ +import { addLog } from '@/utils/log.js'; +import { OffiAccountURL } from './api.js'; +import type { OffiAccountAPIType, WeChatError } from './api.js'; + +// ===== 工具函数 ===== + +/** + * 从 URL 下载图片并创建 File 对象 + * @param imageUrl 图片 URL + * @param filename 可选文件名,默认为 'image.jpg' + * @param mimeType 可选 MIME 类型,默认为 'image/jpeg' + * @returns Promise 返回 File 对象 + */ +export async function downloadImageFromUrl( + imageUrl: string, + filename: string = 'image.jpg', + mimeType: string = 'image/jpeg' +): Promise { + try { + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`下载图片失败: ${response.statusText} (${response.status})`); + } + + const arrayBuffer = await response.arrayBuffer(); + + addLog.debug( + `Successfully downloaded image from ${imageUrl}, size: ${arrayBuffer.byteLength} bytes` + ); + + return new File([arrayBuffer], filename, { type: mimeType }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + addLog.error(`Failed to download image from ${imageUrl}: ${errorMessage}`); + throw new Error(`图片下载失败: ${imageUrl} - ${errorMessage}`); + } +} + +// 微信 API 响应的通用包装类型 +type WeChatApiResponse = T & WeChatError; + +/** + * 通用的微信 API 调用函数 + */ +async function callWeChatAPI>( + url: string, + method: string, + accessToken?: string, + body?: BodyInit | null, + headers?: Record +): Promise { + try { + const fullUrl = accessToken ? `${url}?access_token=${accessToken}` : url; + + const response = await fetch(fullUrl, { + method, + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body + }); + + const result = (await response.json()) as WeChatApiResponse; + + // 检查微信 API 错误 + if (result.errcode && result.errcode !== 0) { + const error = new WeChatAPIError(result.errmsg || '未知错误', result.errcode); + throw error; + } + + return result as T; + } catch (error) { + if (error instanceof WeChatAPIError) { + throw error; + } + + // 处理网络错误或其他异常 + const errorMessage = error instanceof Error ? error.message : '未知错误'; + throw new Error(`API 调用失败: ${errorMessage}`); + } +} + +/** + * 自定义微信 API 错误类 + */ +export class WeChatAPIError extends Error { + constructor( + message: string, + public readonly errcode: number, + public readonly errmsg?: string + ) { + super(message); + this.name = 'WeChatAPIError'; + } +} + +// GET 请求 - 参数通过 query string 传递 +async function callGetAPI>( + url: string, + params: Record +): Promise { + const queryString = new URLSearchParams( + Object.entries(params).reduce( + (acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, + {} as Record + ) + ).toString(); + + return callWeChatAPI(`${url}?${queryString}`, 'get'); +} + +// POST 请求 - access_token 在 query,其他参数在 body(JSON) +async function callPostJSON>( + url: string, + accessToken: string, + data: any +): Promise { + return callWeChatAPI(url, 'post', accessToken, JSON.stringify(data)); +} + +// POST 请求 - access_token 在 query,其他参数在 body(FormData) +async function callPostFormData>( + url: string, + accessToken: string, + formData: FormData +): Promise { + return callWeChatAPI(url, 'post', accessToken, formData, { + 'Content-Type': 'multipart/form-data' + }); +} + +// ===== 认证接口 ===== + +export async function handleGetAuthToken( + params: OffiAccountAPIType['getAuthToken']['req'] +): Promise { + const { url } = OffiAccountURL.getAuthToken; + return callGetAPI(url, params); +} + +// ===== 素材接口 ===== + +export async function handleUploadImage( + params: OffiAccountAPIType['uploadImage']['req'] +): Promise { + const { url } = OffiAccountURL.uploadImage; + const formData = new FormData(); + formData.append('media', params.media); + + return callPostFormData(url, params.access_token, formData); +} + +export async function handleAddMaterial( + params: OffiAccountAPIType['addMaterial']['req'] +): Promise { + const { url } = OffiAccountURL.addMaterial; + const formData = new FormData(); + formData.append('media', params.media); + + if (params.description) { + formData.append('description', JSON.stringify(params.description)); + } + + // type should be query parameter, not form data + const fullUrl = `${url}?access_token=${params.access_token}&type=${params.type}`; + return callPostFormData(fullUrl, params.access_token, formData); +} + +export async function handleGetMaterial( + params: OffiAccountAPIType['getMaterial']['req'] +): Promise { + const { url } = OffiAccountURL.getMaterial; + + // Use unified API call for consistency + const result = await callPostJSON(url, params.access_token, { + media_id: params.media_id + }); + + return result as OffiAccountAPIType['getMaterial']['res']; +} + +export async function handleBatchGetMaterial( + params: OffiAccountAPIType['batchGetMaterial']['req'] +): Promise { + const { url } = OffiAccountURL.batchGetMaterial; + return callPostJSON(url, params.access_token, { + type: params.type, + offset: params.offset, + count: params.count + }); +} + +export async function handleDeleteMaterial( + params: OffiAccountAPIType['deleteMaterial']['req'] +): Promise { + const { url } = OffiAccountURL.deleteMaterial; + return callPostJSON(url, params.access_token, { + media_id: params.media_id + }); +} + +// ===== 草稿接口 ===== + +export async function handleAddDraft( + params: OffiAccountAPIType['addDraft']['req'] +): Promise { + const { url } = OffiAccountURL.addDraft; + return callPostJSON(url, params.access_token, { + articles: params.articles + }); +} + +export async function handleUpdateDraft( + params: OffiAccountAPIType['updateDraft']['req'] +): Promise { + const { url } = OffiAccountURL.updateDraft; + return callPostJSON(url, params.access_token, { + media_id: params.media_id, + index: params.index, + articles: params.articles + }); +} + +export async function handleGetDraft( + params: OffiAccountAPIType['getDraft']['req'] +): Promise { + const { url } = OffiAccountURL.getDraft; + return callPostJSON(url, params.access_token, { + media_id: params.media_id + }); +} + +export async function handleDeleteDraft( + params: OffiAccountAPIType['deleteDraft']['req'] +): Promise { + const { url } = OffiAccountURL.deleteDraft; + return callPostJSON(url, params.access_token, { + media_id: params.media_id + }); +} + +export async function handleBatchGetDraft( + params: OffiAccountAPIType['batchGetDraft']['req'] +): Promise { + const { url } = OffiAccountURL.batchGetDraft; + + const body: any = { + offset: params.offset, + count: params.count + }; + + if (params.no_content !== undefined) { + body.no_content = params.no_content; + } + + return callPostJSON(url, params.access_token, body); +} + +export async function handleDraftSwitch( + params: OffiAccountAPIType['draftSwitch']['req'] +): Promise { + const { url } = OffiAccountURL.draftSwitch; + + const body: any = {}; + if (params.checkonly !== undefined) { + body.checkonly = params.checkonly; + } + + return callPostJSON(url, params.access_token, body); +} + +// ===== 发布接口 ===== + +export async function handleSubmitPublish( + params: OffiAccountAPIType['submitPublish']['req'] +): Promise { + const { url } = OffiAccountURL.submitPublish; + return callPostJSON(url, params.access_token, { + media_id: params.media_id + }); +} + +export async function handleDeletePublished( + params: OffiAccountAPIType['deletePublished']['req'] +): Promise { + const { url } = OffiAccountURL.deletePublished; + + const body: any = { + article_id: params.article_id + }; + + if (params.index !== undefined) { + body.index = params.index; + } + + return callPostJSON(url, params.access_token, body); +} + +export async function handleBatchGetPublished( + params: OffiAccountAPIType['batchGetPublished']['req'] +): Promise { + const { url } = OffiAccountURL.batchGetPublished; + + const body: any = { + offset: params.offset, + count: params.count + }; + + if (params.no_content !== undefined) { + body.no_content = params.no_content; + } + + return callPostJSON(url, params.access_token, body); +} + +export async function handleGetPublishStatus( + params: OffiAccountAPIType['getPublishStatus']['req'] +): Promise { + const { url } = OffiAccountURL.getPublishStatus; + return callPostJSON(url, params.access_token, { + publish_id: params.publish_id + }); +} + +export async function handleGetPublishedArticle( + params: OffiAccountAPIType['getPublishedArticle']['req'] +): Promise { + const { url } = OffiAccountURL.getPublishedArticle; + return callPostJSON(url, params.access_token, { + article_id: params.article_id + }); +} diff --git a/modules/tool/packages/wechatOfficialAccount/logo.svg b/modules/tool/packages/wechatOfficialAccount/logo.svg new file mode 100644 index 00000000..b99be717 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/tool/packages/wechatOfficialAccount/package.json b/modules/tool/packages/wechatOfficialAccount/package.json new file mode 100644 index 00000000..33c9b109 --- /dev/null +++ b/modules/tool/packages/wechatOfficialAccount/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fastgpt-plugins/tool-wechat-ofi-account", + "module": "index.ts", + "type": "module", + "scripts": { + "build": "bun ../../../../scripts/build.ts" + }, + "dependencies": { + "cheerio": "^1.1.2", + "formdata-node": "^6.0.3", + "marked": "^12.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/modules/tool/parseMod.ts b/modules/tool/parseMod.ts new file mode 100644 index 00000000..5969156c --- /dev/null +++ b/modules/tool/parseMod.ts @@ -0,0 +1,69 @@ +import { ToolTagEnum } from 'sdk/client'; +import { UploadToolsS3Path } from './constants'; +import type { ToolSetType, ToolType } from './type'; +import { PublicBucketBaseURL } from '@/s3/const'; + +export const getIconPath = (name: string) => `${PublicBucketBaseURL}${UploadToolsS3Path}/${name}`; + +export const parseMod = async ({ + rootMod, + filename +}: { + rootMod: ToolSetType | ToolType; + filename: string; +}) => { + const tools: ToolType[] = []; + const checkRootModToolSet = (rootMod: ToolType | ToolSetType): rootMod is ToolSetType => { + return 'children' in rootMod; + }; + if (checkRootModToolSet(rootMod)) { + const toolsetId = rootMod.toolId; + + const parentIcon = rootMod.icon || getIconPath(`${toolsetId}/logo`); + + // push parent + tools.push({ + ...rootMod, + tags: rootMod.tags || [ToolTagEnum.enum.other], + toolId: toolsetId, + icon: parentIcon, + toolFilename: `${filename}`, + cb: () => Promise.resolve({}), + versionList: [] + }); + + const children = rootMod.children; + + for (const child of children) { + const childToolId = child.toolId; + + const childIcon = + child.icon || rootMod.icon || getIconPath(`${toolsetId}/${childToolId}/logo`); + + tools.push({ + ...child, + toolId: childToolId, + parentId: toolsetId, + tags: rootMod.tags, + courseUrl: rootMod.courseUrl, + author: rootMod.author, + icon: childIcon, + toolFilename: filename + }); + } + } else { + // is not toolset + const toolId = rootMod.toolId; + + const icon = rootMod.icon || getIconPath(`${toolId}/logo`); + + tools.push({ + ...rootMod, + tags: rootMod.tags || [ToolTagEnum.enum.tools], + icon, + toolId, + toolFilename: filename + }); + } + return tools; +}; diff --git a/modules/tool/utils.ts b/modules/tool/utils.ts index 14715c8c..07d36d26 100644 --- a/modules/tool/utils.ts +++ b/modules/tool/utils.ts @@ -3,13 +3,13 @@ import { addLog } from '@/utils/log'; import { unpkg } from '@/utils/zip'; import { readdir, stat } from 'fs/promises'; import { join, parse } from 'path'; -import { tempPkgDir, tempToolsDir, toolsDir, UploadToolsS3Path } from './constants'; +import { tempPkgDir, tempToolsDir, UploadToolsS3Path } from './constants'; import type { ToolSetType, ToolType } from './type'; -import { ToolTagEnum } from './type/tags'; import { ToolDetailSchema } from './type/api'; import { catchError } from '@/utils/catch'; import { mimeMap } from '@/s3/const'; import { rm } from 'fs/promises'; +import { parseMod } from './parseMod'; /** * Move files from unzipped structure to dist directory @@ -19,86 +19,6 @@ import { rm } from 'fs/promises'; * - all logo.* including subdirs * - assets dir */ -const parseMod = async ({ - rootMod, - filename -}: { - rootMod: ToolSetType | ToolType; - filename: string; -}) => { - const tools: ToolType[] = []; - const checkRootModToolSet = (rootMod: ToolType | ToolSetType): rootMod is ToolSetType => { - return 'children' in rootMod; - }; - if (checkRootModToolSet(rootMod)) { - const toolsetId = rootMod.toolId; - - const parentIcon = - rootMod.icon || - (await publicS3Server.generateExternalUrl(`${UploadToolsS3Path}/${toolsetId}/logo`)); - - // push parent - tools.push({ - ...rootMod, - tags: rootMod.tags || [ToolTagEnum.enum.other], - toolId: toolsetId, - icon: parentIcon, - toolFilename: `${filename}`, - cb: () => Promise.resolve({}), - versionList: [] - }); - - const children = rootMod.children; - - for (const child of children) { - const childToolId = child.toolId; - - const childIcon = - child.icon || - rootMod.icon || - (await publicS3Server.generateExternalUrl(`${UploadToolsS3Path}/${childToolId}/logo`)); - - tools.push({ - ...child, - toolId: childToolId, - parentId: toolsetId, - tags: rootMod.tags, - courseUrl: rootMod.courseUrl, - author: rootMod.author, - icon: childIcon, - toolFilename: filename - }); - } - } else { - // is not toolset - const toolId = rootMod.toolId; - - const icon = - rootMod.icon || - (await publicS3Server.generateExternalUrl(`${UploadToolsS3Path}/${toolId}/logo`)); - - tools.push({ - ...rootMod, - tags: rootMod.tags || [ToolTagEnum.enum.tools], - icon, - toolId, - toolFilename: filename - }); - } - return tools; -}; - -// Load tool or toolset and its children -export const LoadToolsByFilename = async (filename: string): Promise => { - const rootMod = (await import(join(toolsDir, filename))).default as ToolType | ToolSetType; - - if (!rootMod.toolId) { - addLog.error(`Can not parse toolId, filename: ${filename}`); - return []; - } - - return parseMod({ rootMod, filename }); -}; export const parsePkg = async (filepath: string, temp: boolean = true) => { const filename = filepath.split('/').pop() as string; diff --git a/runtime/env.d.ts b/runtime/env.d.ts deleted file mode 100644 index 11b6e4f9..00000000 --- a/runtime/env.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare namespace NodeJS { - interface ProcessEnv { - S3_EXTERNAL_BASE_URL: string; - S3_ENDPOINT: string; - S3_PORT: string; - S3_USE_SSL: string; - S3_ACCESS_KEY: string; - S3_SECRET_KEY: string; - S3_BUCKET: string; - MAX_FILE_SIZE: string; - RETENTION_DAYS: string; - } -} diff --git a/scripts/newTool/index.ts b/scripts/newTool/index.ts index b792dfd9..606739a6 100644 --- a/scripts/newTool/index.ts +++ b/scripts/newTool/index.ts @@ -12,36 +12,54 @@ const isSparseCheckout = await (async () => { return true; })(); -const isToolset = - (await select({ - message: 'What kind of tool/toolset do you want to create?', - choices: [ - { - name: 'Tool', - value: 'tool' - }, - { - name: 'Toolset', - value: 'toolset' - } - ] - })) === 'toolset'; +const toolsNow = await fs.promises.readdir('modules/tool/packages'); -const name = await input({ - message: 'What is the name of your tool/toolset?', - validate: (value) => { - if (value.length < 1) { - return 'Please enter a name'; +const createType = await select({ + message: 'What kind of tool/toolset do you want to create?', + choices: [ + { + name: 'Tool', + value: 'tool' + }, + { + name: 'Toolset', + value: 'toolset' + }, + { + name: 'child Tool for a existing toolset', + value: 'childTool' } - return true; - } + ] as const }); +const name = + createType === 'childTool' + ? await select({ + message: 'What is the name of your tool?', + choices: toolsNow.map((tool) => ({ + name: tool, + value: tool + })) + }) + : await input({ + message: 'What is the name of your tool/toolset?', + validate: (value) => { + if (value.length < 1) { + return 'Please enter a name'; + } + return true; + } + }); + // name validation: -// 1. less than 20 characters +// 1. less than 30 characters // 2. camelCase -if (name.length > 20) { - console.error('Tool name must be less than 20 characters'); +if (toolsNow.includes(name) && createType !== 'childTool') { + console.error(`Tool/Toolset already exists`); + process.exit(1); +} +if (name.length > 30) { + console.error('Tool name must be less than 30 characters'); process.exit(1); } if (name.includes('-')) { @@ -62,7 +80,7 @@ if (isSparseCheckout) { } // 1. Create directory -if (fs.existsSync(toolDir)) { +if (fs.existsSync(toolDir) && createType !== 'childTool') { console.error('Tool already exists'); process.exit(1); } else { @@ -83,25 +101,32 @@ const copyTemplate = (src: string, dest: string) => { // 2. Copy template to target directory const templateDir = path.join(__dirname, 'template'); -if (isToolset) { - copyTemplate(path.join(templateDir, 'toolSet'), toolDir); +if (createType === 'childTool') { + copyTemplate( + path.join(templateDir, 'toolSet', 'children', 'tool'), + path.join(toolDir, 'children', 'newTool') + ); } else { - copyTemplate(path.join(templateDir, 'tool'), toolDir); -} + if (createType === 'toolset') { + copyTemplate(path.join(templateDir, 'toolSet'), toolDir); + } else if (createType === 'tool') { + copyTemplate(path.join(templateDir, 'tool'), toolDir); + } -// 3. Rewrite new tool package.json -const packageJsonPath = toolDir + '/package.json'; -const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + // 3. Rewrite new tool package.json + const packageJsonPath = toolDir + '/package.json'; + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); -const nameFormatToKebabCase = (name: string) => - name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + const nameFormatToKebabCase = (name: string) => + name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); -packageJson.name = `@fastgpt-plugins/tool-${nameFormatToKebabCase(name)}`; -fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + packageJson.name = `@fastgpt-plugins/tool-${nameFormatToKebabCase(name)}`; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); -// 4. Copy DESIGN.md to dir -const designMdPath = toolDir + '/DESIGN.md'; -copyTemplate(path.join(__dirname, 'DESIGN.md'), designMdPath); + // 4. Copy DESIGN.md to dir + const designMdPath = toolDir + '/DESIGN.md'; + copyTemplate(path.join(__dirname, 'DESIGN.md'), designMdPath); + console.log(`You can edit the ${designMdPath}, and code with AI`); +} // output success message -console.log(`Tool/Toolset created successfully! 🎉`); -console.log(`You can edit the ${designMdPath}, and code with AI`); +console.log(`Tool/Toolset created successfully at ${toolDir}! 🎉`);