diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/editorJumperProjectSettings.xml b/.idea/editorJumperProjectSettings.xml new file mode 100644 index 0000000..bd94242 --- /dev/null +++ b/.idea/editorJumperProjectSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..c51d73e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..39b9460 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/n8n-nodes-wecom.iml b/.idea/n8n-nodes-wecom.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/n8n-nodes-wecom.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e69de29..0000000 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 3b0de04..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,76 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at jan@n8n.io. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq diff --git a/README.md b/README.md index 06ea132..89d5799 100644 --- a/README.md +++ b/README.md @@ -1,247 +1,300 @@ -![Banner image](https://user-images.githubusercontent.com/10284570/173569848-c624317f-42b1-45a6-ab09-f0ea3c247648.png) +# n8n-nodes-wecom -# n8n-nodes-starter +这是一个 n8n 社区节点。它让你可以在 n8n 工作流中使用企业微信。 -This starter repository helps you build custom integrations for [n8n](https://n8n.io). It includes example nodes, credentials, the node linter, and all the tooling you need to get started. +企业微信是腾讯推出的企业通讯与办公工具,为企业提供即时通讯、视频会议、日程管理等功能。 -## Quick Start +[n8n](https://n8n.io/) 是一个 [fair-code licensed](https://docs.n8n.io/reference/license/) 的工作流自动化平台。 -> [!TIP] -> **New to building n8n nodes?** The fastest way to get started is with `npm create @n8n/node`. This command scaffolds a complete node package for you using the [@n8n/node-cli](https://www.npmjs.com/package/@n8n/node-cli). +[安装](#安装) +[操作](#操作) +[凭证](#凭证) +[兼容性](#兼容性) +[使用](#使用) +[资源](#资源) +[版本历史](#版本历史) -**To create a new node package from scratch:** +## 安装 -```bash -npm create @n8n/node -``` +按照 n8n 社区节点文档中的[安装指南](https://docs.n8n.io/integrations/community-nodes/installation/)进行安装。 -**Already using this starter? Start developing with:** +## 操作 -```bash -npm run dev -``` +本节点支持以下操作: -This starts n8n with your nodes loaded and hot reload enabled. +### 消息资源 -## What's Included +- **发送文本消息** - 发送纯文本消息给成员、部门或标签 +- **发送 Markdown 消息** - 发送格式化的 Markdown 消息 +- **发送图片消息** - 通过 Media ID 或直接上传文件发送图片 +- **发送语音消息** - 通过 Media ID 或直接上传文件发送语音(支持 AMR、MP3 格式) +- **发送视频消息** - 通过 Media ID 或直接上传文件发送视频(支持 MP4 格式) +- **发送文件消息** - 通过 Media ID 或直接上传文件发送文件 +- **发送文本卡片消息** - 发送带标题、描述和跳转链接的卡片消息 +- **发送图文消息** - 发送图文消息(news类型),支持1-8条图文 +- **发送图文消息(mpnews)** - 发送图文消息(mpnews类型),支持html内容 +- **发送小程序通知消息** - 发送小程序通知类型的消息 +- **发送任务卡片消息** - 发送带按钮的任务卡片消息,支持交互 +- **发送模板卡片消息** - 发送模板卡片消息,支持多种卡片类型(文本通知型、图文展示型、按钮交互型、投票选择型、多项选择型) +- **撤回应用消息** - 撤回已发送的应用消息(需要消息的 msgid) +- **更新模板卡片消息** - 更新已发送的模板卡片消息内容(需要 response_code,在24小时内有效) -This starter repository includes two example nodes to learn from: +### 通讯录资源 -- **[Example Node](nodes/Example/)** - A simple starter node that shows the basic structure with a custom `execute` method -- **[GitHub Issues Node](nodes/GithubIssues/)** - A complete, production-ready example built using the **declarative style**: - - **Low-code approach** - Define operations declaratively without writing request logic - - Multiple resources (Issues, Comments) - - Multiple operations (Get, Get All, Create) - - Two authentication methods (OAuth2 and Personal Access Token) - - List search functionality for dynamic dropdowns - - Proper error handling and typing - - Ideal for HTTP API-based integrations +- **获取成员信息** - 根据 UserID 获取成员详细信息,支持从所有成员列表动态选择 +- **获取部门成员列表** - 获取指定部门的成员列表(基本信息),支持递归获取子部门 +- **获取部门成员详情** - 获取指定部门的成员列表(完整信息),包含职位、邮箱等详细信息 +- **获取成员ID列表** - 获取企业所有成员的 UserID 列表,支持分页查询(每页最多1000条) +- **获取部门信息** - 获取企业部门列表和详细信息,支持从部门列表动态选择 +- **UserID转OpenID** - 将企业成员的 userid 转换为 openid +- **OpenID转UserID** - 将 openid 转换为企业成员的 userid +- **获取标签列表** - 获取企业标签列表,支持按类型筛选 +- **获取标签成员** - 获取指定标签的成员列表,支持从标签列表动态选择 -> [!TIP] -> The declarative/low-code style (used in GitHub Issues) is the recommended approach for building nodes that interact with HTTP APIs. It significantly reduces boilerplate code and handles requests automatically. +### 素材管理资源 -Browse these examples to understand both approaches, then modify them or create your own. +- **上传临时素材** - 上传临时素材文件(图片、语音、视频、文件),有效期3天 +- **获取临时素材** - 通过media_id下载临时素材文件 +- **上传永久素材** - 上传永久素材文件(图片、语音、视频、文件) +- **获取永久素材** - 通过media_id下载永久素材文件 -## Finding Inspiration +### 动态选项加载 -Looking for more examples? Check out these resources: +本节点支持从企业微信实时获取数据并展示在选项中,让你可以直接选择而无需手动输入 ID: -- **[npm Community Nodes](https://www.npmjs.com/search?q=keywords:n8n-community-node-package)** - Browse thousands of community-built nodes on npm using the `n8n-community-node-package` tag -- **[n8n Built-in Nodes](https://github.com/n8n-io/n8n/tree/master/packages/nodes-base/nodes)** - Study the source code of n8n's official nodes for production-ready patterns and best practices -- **[n8n Credentials](https://github.com/n8n-io/n8n/tree/master/packages/nodes-base/credentials)** - See how authentication is implemented for various services +- **消息接收人选择**: + - 从通讯录选择成员(支持多选) + - 从部门列表选择部门(支持多选) + - 从标签列表选择标签(支持多选) + - 快速选择发送给全体成员 + - 也支持传统的手动输入方式 -These are excellent resources to understand how to structure your nodes, handle different API patterns, and implement advanced features. +- **通讯录操作选择**: + - 选择部门时自动加载企业部门列表 + - 选择成员时自动加载企业成员列表 + - 选择标签时自动加载企业标签列表 -## Prerequisites +## 凭证 -Before you begin, install the following on your development machine: +使用此节点需要企业微信应用的凭证。 -### Required +### 前置条件 -- **[Node.js](https://nodejs.org/)** (v22 or higher) and npm - - Linux/Mac/WSL: Install via [nvm](https://github.com/nvm-sh/nvm) - - Windows: Follow [Microsoft's NodeJS guide](https://learn.microsoft.com/en-us/windows/dev-environment/javascript/nodejs-on-windows) -- **[git](https://git-scm.com/downloads)** +1. 拥有企业微信管理员权限 +2. 创建或使用现有的企业微信应用 -### Recommended +### 获取凭证 -- Follow n8n's [development environment setup guide](https://docs.n8n.io/integrations/creating-nodes/build/node-development-environment/) +1. 登录[企业微信管理后台](https://work.weixin.qq.com/) +2. 进入"我的企业" > "企业信息",复制**企业ID** (CorpID) +3. 进入"应用管理" > 选择或创建一个应用 +4. 复制**AgentId**(应用ID) +5. 点击"查看Secret",复制**Secret** -> [!NOTE] -> The `@n8n/node-cli` is included as a dev dependency and will be installed automatically when you run `npm install`. The CLI includes n8n for local development, so you don't need to install n8n globally. +### 在 n8n 中配置 -## Getting Started with this Starter +1. 在 n8n 中添加"企业微信"节点 +2. 点击"Credential to connect with" +3. 选择"创建新凭证 - 企业微信 API" +4. 填入以下信息: + - **企业 ID** - 你的企业 CorpID + - **应用 Secret** - 应用的 Secret + - **应用 ID** - 应用的 AgentID +5. 保存凭证 -Follow these steps to create your own n8n community node package: +### 权限配置 -### 1. Create Your Repository +确保你的企业微信应用具有以下权限: -[Generate a new repository](https://github.com/n8n-io/n8n-nodes-starter/generate) from this template, then clone it: +- **消息发送权限** - 用于发送各类消息(必须) +- **通讯录读取权限** - 用于查询成员、部门和标签信息(必须) + - 读取成员信息 + - 读取部门信息 + - 读取标签信息 +- **应用可见范围** - 设置哪些成员可以接收应用消息(建议设置为全员) -```bash -git clone https://github.com//.git -cd -``` +**配置方法**: +1. 进入企业微信管理后台 +2. 选择你的应用 +3. 点击"API权限" +4. 开启所需的权限 -### 2. Install Dependencies - -```bash -npm install -``` +## 兼容性 -This installs all required dependencies including the `@n8n/node-cli`. +- **最低 n8n 版本**: 1.0.0 +- **测试版本**: 1.0.0+ -### 3. Explore the Examples +此节点使用 n8n 的标准 API,与最新版本的 n8n 完全兼容。 -Browse the example nodes in [nodes/](nodes/) and [credentials/](credentials/) to understand the structure: +## 使用 -- Start with [nodes/Example/](nodes/Example/) for a basic node -- Study [nodes/GithubIssues/](nodes/GithubIssues/) for a real-world implementation +### 发送文本消息(使用动态选择) -### 4. Build Your Node +1. 添加"企业微信"节点到工作流 +2. 选择**资源**: 消息 +3. 选择**操作**: 发送文本消息 +4. 配置接收人: + - **接收人类型**: 选择"指定成员"、"指定部门"、"指定标签"或"全体成员" + - **成员/部门/标签**: 从自动加载的下拉列表中选择(支持多选) + - 也可以选择"手动输入"使用传统方式 +5. 输入**消息内容** -Edit the example nodes to fit your use case, or create new node files by copying the structure from [nodes/Example/](nodes/Example/). +### 发送文本消息(传统方式) -> [!TIP] -> If you want to scaffold a completely new node package, use `npm create @n8n/node` to start fresh with the CLI's interactive generator. +如果选择"手动输入"方式: +- **接收人**: 成员ID,用 `|` 分隔多个,或使用 `@all` 发送给所有人 +- **部门ID**: 可选,部门ID列表 +- **标签ID**: 可选,标签ID列表 -### 5. Configure Your Package +### 发送 Markdown 消息 -Update `package.json` with your details: +适合发送格式化的报告、通知等。支持标题、加粗、列表等 Markdown 语法。 -- `name` - Your package name (must start with `n8n-nodes-`) -- `author` - Your name and email -- `repository` - Your repository URL -- `description` - What your node does +### 发送图片或文件 -Make sure your node is registered in the `n8n.nodes` array. +1. 选择**文件来源**: 上传文件 +2. 输入**输入数据字段名**(默认为 `data`) +3. 节点会自动上传文件并发送 -### 6. Develop and Test Locally +### 接收人配置 -Start n8n with your node loaded: +**推荐方式(动态选择)**: +1. 选择**接收人类型**(指定成员/指定部门/指定标签/全体成员) +2. 从自动加载的下拉列表中选择目标(支持多选) +3. 节点自动转换为正确的格式 -```bash -npm run dev -``` +**传统方式(手动输入)**: +- **单个用户**: `touser: "zhangsan"` +- **多个用户**: `touser: "zhangsan|lisi|wangwu"` +- **所有人**: `touser: "@all"` +- **按部门**: `toparty: "1|2|3"` +- **按标签**: `totag: "1|2"` -This command runs `n8n-node dev` which: +### 使用表达式 -- Builds your node with watch mode -- Starts n8n with your node available -- Automatically rebuilds when you make changes -- Opens n8n in your browser (usually http://localhost:5678) +接收人字段支持 n8n 表达式: -You can now test your node in n8n workflows! +```javascript +// 从输入数据获取用户ID +{{ $json.userId }} -> [!NOTE] -> Learn more about CLI commands in the [@n8n/node-cli documentation](https://www.npmjs.com/package/@n8n/node-cli). +// 从数组拼接用户ID列表 +{{ $json.users.join('|') }} +``` -### 7. Lint Your Code +### 常见使用场景 -Check for errors: +#### 定时发送日报 -```bash -npm run lint ``` - -Auto-fix issues when possible: - -```bash -npm run lint:fix +Schedule Trigger → HTTP Request (获取数据) → 企业微信 (发送Markdown消息) ``` -### 8. Build for Production - -When ready to publish: +#### 监控告警 -```bash -npm run build ``` - -This compiles your TypeScript code to the `dist/` folder. - -### 9. Prepare for Publishing - -Before publishing: - -1. **Update documentation**: Replace this README with your node's documentation. Use [README_TEMPLATE.md](README_TEMPLATE.md) as a starting point. -2. **Update the LICENSE**: Add your details to the [LICENSE](LICENSE.md) file. -3. **Test thoroughly**: Ensure your node works in different scenarios. - -### 10. Publish to npm - -Publish your package to make it available to the n8n community: - -```bash -npm publish +Webhook (接收告警) → IF (判断严重程度) → 企业微信 (发送文本消息) ``` -Learn more about [publishing to npm](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry). - -### 11. Submit for Verification (Optional) - -Get your node verified for n8n Cloud: - -1. Ensure your node meets the [requirements](https://docs.n8n.io/integrations/creating-nodes/deploy/submit-community-nodes/): - - Uses MIT license ✅ (included in this starter) - - No external package dependencies - - Follows n8n's design guidelines - - Passes quality and security review +#### 批量通知 -2. Submit through the [n8n Creator Portal](https://creators.n8n.io/nodes) - -**Benefits of verification:** - -- Available directly in n8n Cloud -- Discoverable in the n8n nodes panel -- Verified badge for quality assurance -- Increased visibility in the n8n community - -## Available Scripts - -This starter includes several npm scripts to streamline development: - -| Script | Description | -| --------------------- | ---------------------------------------------------------------- | -| `npm run dev` | Start n8n with your node and watch for changes (runs `n8n-node dev`) | -| `npm run build` | Compile TypeScript to JavaScript for production (runs `n8n-node build`) | -| `npm run build:watch` | Build in watch mode (auto-rebuild on changes) | -| `npm run lint` | Check your code for errors and style issues (runs `n8n-node lint`) | -| `npm run lint:fix` | Automatically fix linting issues when possible (runs `n8n-node lint --fix`) | -| `npm run release` | Create a new release (runs `n8n-node release`) | - -> [!TIP] -> These scripts use the [@n8n/node-cli](https://www.npmjs.com/package/@n8n/node-cli) under the hood. You can also run CLI commands directly, e.g., `npx n8n-node dev`. - -## Troubleshooting - -### My node doesn't appear in n8n - -1. Make sure you ran `npm install` to install dependencies -2. Check that your node is listed in `package.json` under `n8n.nodes` -3. Restart the dev server with `npm run dev` -4. Check the console for any error messages - -### Linting errors - -Run `npm run lint:fix` to automatically fix most common issues. For remaining errors, check the [n8n node development guidelines](https://docs.n8n.io/integrations/creating-nodes/). - -### TypeScript errors - -Make sure you're using Node.js v22 or higher and have run `npm install` to get all type definitions. - -## Resources - -- **[n8n Node Documentation](https://docs.n8n.io/integrations/creating-nodes/)** - Complete guide to building nodes -- **[n8n Community Forum](https://community.n8n.io/)** - Get help and share your nodes -- **[@n8n/node-cli Documentation](https://www.npmjs.com/package/@n8n/node-cli)** - CLI tool reference -- **[n8n Creator Portal](https://creators.n8n.io/nodes)** - Submit your node for verification -- **[Submit Community Nodes Guide](https://docs.n8n.io/integrations/creating-nodes/deploy/submit-community-nodes/)** - Verification requirements and process - -## Contributing - -Have suggestions for improving this starter? [Open an issue](https://github.com/n8n-io/n8n-nodes-starter/issues) or submit a pull request! - -## License +``` +Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) +``` -[MIT](https://github.com/n8n-io/n8n-nodes-starter/blob/master/LICENSE.md) +### API 限制 + +根据企业微信官方文档: + +- 每个应用每分钟最多调用 200 次 API +- 文本消息最长 2048 字节 +- 图片文件不超过 2MB +- 普通文件不超过 20MB +- 接收人列表最多 1000 个成员 + +建议对高频场景启用消息去重功能。 + +## 资源 + +### n8n 相关 +- [n8n 社区节点文档](https://docs.n8n.io/integrations/#community-nodes) +- [n8n 节点开发文档](https://docs.n8n.io/integrations/creating-nodes/) + +### 企业微信 API 文档 +- [企业微信开发文档](https://developer.work.weixin.qq.com/document/) +- [消息发送 API](https://developer.work.weixin.qq.com/document/path/90236) +- [撤回应用消息 API](https://developer.work.weixin.qq.com/document/path/94867) +- [更新模板卡片消息 API](https://developer.work.weixin.qq.com/document/path/94888) + +### 通讯录管理 API +- [通讯录管理概览](https://developer.work.weixin.qq.com/document/path/90193) +- [获取成员](https://developer.work.weixin.qq.com/document/path/90196) +- [获取部门成员](https://developer.work.weixin.qq.com/document/path/90200) +- [获取部门成员详情](https://developer.work.weixin.qq.com/document/path/90201) +- [获取部门列表](https://developer.work.weixin.qq.com/document/path/90208) +- [获取标签成员](https://developer.work.weixin.qq.com/document/path/90213) +- [获取标签列表](https://developer.work.weixin.qq.com/document/path/90216) +- [userid与openid互转](https://developer.work.weixin.qq.com/document/path/90202) + +### 素材管理 API +- [上传临时素材](https://developer.work.weixin.qq.com/document/path/90253) +- [获取临时素材](https://developer.work.weixin.qq.com/document/path/90254) +- [上传永久素材](https://developer.work.weixin.qq.com/document/path/90255) +- [获取永久素材](https://developer.work.weixin.qq.com/document/path/90256) + +## 版本历史 + +### 1.0.0 (2025-10-18) + +初始版本发布,完全实现企业微信发送应用消息接口,包含以下功能: + +**消息功能**(完整支持官方文档所有消息类型): +- 发送文本消息(支持去重、ID转译、保密消息) +- 发送 Markdown 消息 +- 发送图片消息(支持 Media ID 或直接上传) +- 发送语音消息(支持 Media ID 或直接上传,AMR/MP3格式) +- 发送视频消息(支持 Media ID 或直接上传,MP4格式,支持标题和描述) +- 发送文件消息(支持 Media ID 或直接上传) +- 发送文本卡片消息(支持标题、描述、跳转链接和自定义按钮文字) +- 发送图文消息(news类型,支持1-8条图文,每条包含标题、描述、图片和链接) +- 发送图文消息(mpnews类型,支持html内容,适合富文本展示) +- 发送小程序通知消息(支持小程序跳转和内容键值对) +- 发送任务卡片消息(支持自定义按钮,实现交互功能) +- 发送模板卡片消息(支持5种卡片类型:文本通知型、图文展示型、按钮交互型、投票选择型、多项选择型) +- 撤回应用消息(支持撤回已发送的消息) +- 更新模板卡片消息(支持更新已发送的模板卡片,实现动态卡片更新) + +**通讯录功能**(共9个接口): +- 获取成员信息(支持从所有成员列表动态选择) +- 获取部门成员列表(基本信息,支持递归) +- 获取部门成员详情(完整信息,支持递归) +- 获取成员ID列表(支持分页,每页最多1000条) +- 获取部门信息(支持从部门列表动态选择) +- UserID与OpenID互转(2个接口) +- 获取标签列表(支持按类型筛选) +- 获取标签成员(支持从标签列表动态选择) + +**素材管理功能**(共4个接口): +- 上传临时素材(支持图片、语音、视频、文件,3天有效期) +- 获取临时素材(下载临时素材文件) +- 上传永久素材(支持图片、语音、视频、文件) +- 获取永久素材(下载永久素材文件) + +**动态选项加载功能**: +- 6个 loadOptions 方法实现实时数据加载 +- 消息接收人支持5种选择方式(指定成员、指定部门、指定标签、全体成员、手动输入) +- 通讯录操作支持从下拉列表选择部门、成员、标签 +- 支持多选,自动格式转换 +- 向后兼容传统手动输入方式 + +**技术特性**: +- Access Token 自动缓存管理(支持多租户) +- Token 失效自动重试 +- 完整的 TypeScript 类型支持 +- 遵循 n8n 节点开发最佳实践 +- 完善的错误处理 +- 友好的用户界面 + +## 许可证 + +[MIT](LICENSE.md) diff --git a/README_TEMPLATE.md b/README_TEMPLATE.md deleted file mode 100644 index a8d7860..0000000 --- a/README_TEMPLATE.md +++ /dev/null @@ -1,48 +0,0 @@ -# n8n-nodes-_node-name_ - -This is an n8n community node. It lets you use _app/service name_ in your n8n workflows. - -_App/service name_ is _one or two sentences describing the service this node integrates with_. - -[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform. - -[Installation](#installation) -[Operations](#operations) -[Credentials](#credentials) -[Compatibility](#compatibility) -[Usage](#usage) -[Resources](#resources) -[Version history](#version-history) - -## Installation - -Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation. - -## Operations - -_List the operations supported by your node._ - -## Credentials - -_If users need to authenticate with the app/service, provide details here. You should include prerequisites (such as signing up with the service), available authentication methods, and how to set them up._ - -## Compatibility - -_State the minimum n8n version, as well as which versions you test against. You can also include any known version incompatibility issues._ - -## Usage - -_This is an optional section. Use it to help users with any difficult or confusing aspects of the node._ - -_By the time users are looking for community nodes, they probably already know n8n basics. But if you expect new users, you can link to the [Try it out](https://docs.n8n.io/try-it-out/) documentation to help them get started._ - -## Resources - -* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes) -* _Link to app/service documentation._ - -## Version history - -_This is another optional section. If your node has multiple versions, include a short description of available versions and what changed, as well as any compatibility impact._ - - diff --git a/credentials/GithubIssuesApi.credentials.ts b/credentials/GithubIssuesApi.credentials.ts deleted file mode 100644 index f8b267e..0000000 --- a/credentials/GithubIssuesApi.credentials.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { - IAuthenticateGeneric, - Icon, - ICredentialTestRequest, - ICredentialType, - INodeProperties, -} from 'n8n-workflow'; - -export class GithubIssuesApi implements ICredentialType { - name = 'githubIssuesApi'; - - displayName = 'GitHub Issues API'; - - icon: Icon = { light: 'file:../icons/github.svg', dark: 'file:../icons/github.dark.svg' }; - - documentationUrl = - 'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#deleting-a-personal-access-token'; - - properties: INodeProperties[] = [ - { - displayName: 'Access Token', - name: 'accessToken', - type: 'string', - typeOptions: { password: true }, - default: '', - }, - ]; - - authenticate: IAuthenticateGeneric = { - type: 'generic', - properties: { - headers: { - Authorization: '=token {{$credentials?.accessToken}}', - }, - }, - }; - - test: ICredentialTestRequest = { - request: { - baseURL: 'https://api.github.com', - url: '/user', - method: 'GET', - }, - }; -} diff --git a/credentials/GithubIssuesOAuth2Api.credentials.ts b/credentials/GithubIssuesOAuth2Api.credentials.ts deleted file mode 100644 index 0eb98fc..0000000 --- a/credentials/GithubIssuesOAuth2Api.credentials.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Icon, ICredentialType, INodeProperties } from 'n8n-workflow'; - -export class GithubIssuesOAuth2Api implements ICredentialType { - name = 'githubIssuesOAuth2Api'; - - extends = ['oAuth2Api']; - - displayName = 'GitHub Issues OAuth2 API'; - - icon: Icon = { light: 'file:../icons/github.svg', dark: 'file:../icons/github.dark.svg' }; - - documentationUrl = 'https://docs.github.com/en/apps/oauth-apps'; - - properties: INodeProperties[] = [ - { - displayName: 'Grant Type', - name: 'grantType', - type: 'hidden', - default: 'authorizationCode', - }, - { - displayName: 'Authorization URL', - name: 'authUrl', - type: 'hidden', - default: 'https://github.com/login/oauth/authorize', - required: true, - }, - { - displayName: 'Access Token URL', - name: 'accessTokenUrl', - type: 'hidden', - default: 'https://github.com/login/oauth/access_token', - required: true, - }, - { - displayName: 'Scope', - name: 'scope', - type: 'hidden', - default: 'repo', - }, - { - displayName: 'Auth URI Query Parameters', - name: 'authQueryParameters', - type: 'hidden', - default: '', - }, - { - displayName: 'Authentication', - name: 'authentication', - type: 'hidden', - default: 'header', - }, - ]; -} diff --git a/credentials/WeComApi.credentials.ts b/credentials/WeComApi.credentials.ts new file mode 100644 index 0000000..d51e130 --- /dev/null +++ b/credentials/WeComApi.credentials.ts @@ -0,0 +1,67 @@ +import type { + IAuthenticateGeneric, + Icon, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class WeComApi implements ICredentialType { + name = 'weComApi'; + + displayName = '企业微信 API'; + + // eslint-disable-next-line @n8n/community-nodes/icon-validation + icon: Icon = { light: 'file:../icons/wecom.png', dark: 'file:../icons/wecom.dark.png' }; + + documentationUrl = 'https://developer.work.weixin.qq.com/document/path/90235'; + + properties: INodeProperties[] = [ + { + displayName: '企业 ID', + name: 'corpId', + type: 'string', + default: '', + required: true, + description: '企业微信的企业 ID(CorpID)', + }, + { + displayName: '应用 Secret', + name: 'corpSecret', + type: 'string', + typeOptions: { password: true }, + default: '', + required: true, + description: '企业微信应用的 Secret', + }, + { + displayName: '应用 ID', + name: 'agentId', + type: 'string', + default: '', + required: true, + description: '企业微信应用的 AgentID', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + qs: { + access_token: '={{$credentials.accessToken}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: 'https://qyapi.weixin.qq.com', + url: '/cgi-bin/user/get', + method: 'GET', + qs: { + userid: '@me', + }, + }, + }; +} + diff --git a/icons/github.dark.svg b/icons/github.dark.svg deleted file mode 100644 index 0366b08..0000000 --- a/icons/github.dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/icons/github.svg b/icons/github.svg deleted file mode 100644 index fe1ac05..0000000 --- a/icons/github.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/icons/wecom.dark.png b/icons/wecom.dark.png new file mode 100644 index 0000000..537735f Binary files /dev/null and b/icons/wecom.dark.png differ diff --git a/icons/wecom.png b/icons/wecom.png new file mode 100644 index 0000000..537735f Binary files /dev/null and b/icons/wecom.png differ diff --git a/nodes/Example/Example.node.json b/nodes/Example/Example.node.json deleted file mode 100644 index 266250f..0000000 --- a/nodes/Example/Example.node.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "node": "n8n-nodes-example", - "nodeVersion": "1.0", - "codexVersion": "1.0", - "categories": ["Development", "Developer Tools"], - "resources": { - "credentialDocumentation": [ - { - "url": "https://github.com/org/repo?tab=readme-ov-file#credentials" - } - ], - "primaryDocumentation": [ - { - "url": "https://github.com/org/repo?tab=readme-ov-file" - } - ] - } -} diff --git a/nodes/Example/Example.node.ts b/nodes/Example/Example.node.ts deleted file mode 100644 index fb5e019..0000000 --- a/nodes/Example/Example.node.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - IExecuteFunctions, - INodeExecutionData, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; -import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; - -export class Example implements INodeType { - description: INodeTypeDescription = { - displayName: 'Example', - name: 'example', - icon: { light: 'file:example.svg', dark: 'file:example.dark.svg' }, - group: ['input'], - version: 1, - description: 'Basic Example Node', - defaults: { - name: 'Example', - }, - inputs: [NodeConnectionTypes.Main], - outputs: [NodeConnectionTypes.Main], - usableAsTool: true, - properties: [ - // Node properties which the user gets displayed and - // can change on the node. - { - displayName: 'My String', - name: 'myString', - type: 'string', - default: '', - placeholder: 'Placeholder value', - description: 'The description text', - }, - ], - }; - - // The function below is responsible for actually doing whatever this node - // is supposed to do. In this case, we're just appending the `myString` property - // with whatever the user has entered. - // You can make async calls and use `await`. - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - - let item: INodeExecutionData; - let myString: string; - - // Iterates over all input items and add the key "myString" with the - // value the parameter "myString" resolves to. - // (This could be a different value for each item in case it contains an expression) - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - try { - myString = this.getNodeParameter('myString', itemIndex, '') as string; - item = items[itemIndex]; - - item.json.myString = myString; - } catch (error) { - // This node should never fail but we want to showcase how - // to handle errors. - if (this.continueOnFail()) { - items.push({ json: this.getInputData(itemIndex)[0].json, error, pairedItem: itemIndex }); - } else { - // Adding `itemIndex` allows other workflows to handle this error - if (error.context) { - // If the error thrown already contains the context property, - // only append the itemIndex - error.context.itemIndex = itemIndex; - throw error; - } - throw new NodeOperationError(this.getNode(), error, { - itemIndex, - }); - } - } - } - - return [items]; - } -} diff --git a/nodes/Example/example.dark.svg b/nodes/Example/example.dark.svg deleted file mode 100644 index c07cb10..0000000 --- a/nodes/Example/example.dark.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - diff --git a/nodes/Example/example.svg b/nodes/Example/example.svg deleted file mode 100644 index 703e1fe..0000000 --- a/nodes/Example/example.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - diff --git a/nodes/GithubIssues/GithubIssues.node.json b/nodes/GithubIssues/GithubIssues.node.json deleted file mode 100644 index 5eca62e..0000000 --- a/nodes/GithubIssues/GithubIssues.node.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "node": "n8n-nodes-github-issues", - "nodeVersion": "1.0", - "codexVersion": "1.0", - "categories": ["Development", "Developer Tools"], - "resources": { - "credentialDocumentation": [ - { - "url": "https://github.com/org/repo?tab=readme-ov-file#credentials" - } - ], - "primaryDocumentation": [ - { - "url": "https://github.com/org/repo?tab=readme-ov-file" - } - ] - } -} diff --git a/nodes/GithubIssues/GithubIssues.node.ts b/nodes/GithubIssues/GithubIssues.node.ts deleted file mode 100644 index 9965864..0000000 --- a/nodes/GithubIssues/GithubIssues.node.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow'; -import { issueDescription } from './resources/issue'; -import { issueCommentDescription } from './resources/issueComment'; -import { getRepositories } from './listSearch/getRepositories'; -import { getUsers } from './listSearch/getUsers'; -import { getIssues } from './listSearch/getIssues'; - -export class GithubIssues implements INodeType { - description: INodeTypeDescription = { - displayName: 'GitHub Issues', - name: 'githubIssues', - icon: { light: 'file:../../icons/github.svg', dark: 'file:../../icons/github.dark.svg' }, - group: ['input'], - version: 1, - subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Consume issues from the GitHub API', - defaults: { - name: 'GitHub Issues', - }, - usableAsTool: true, - inputs: [NodeConnectionTypes.Main], - outputs: [NodeConnectionTypes.Main], - credentials: [ - { - name: 'githubIssuesApi', - required: true, - displayOptions: { - show: { - authentication: ['accessToken'], - }, - }, - }, - { - name: 'githubIssuesOAuth2Api', - required: true, - displayOptions: { - show: { - authentication: ['oAuth2'], - }, - }, - }, - ], - requestDefaults: { - baseURL: 'https://api.github.com', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - }, - properties: [ - { - displayName: 'Authentication', - name: 'authentication', - type: 'options', - options: [ - { - name: 'Access Token', - value: 'accessToken', - }, - { - name: 'OAuth2', - value: 'oAuth2', - }, - ], - default: 'accessToken', - }, - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Issue', - value: 'issue', - }, - { - name: 'Issue Comment', - value: 'issueComment', - }, - ], - default: 'issue', - }, - ...issueDescription, - ...issueCommentDescription, - ], - }; - - methods = { - listSearch: { - getRepositories, - getUsers, - getIssues, - }, - }; -} diff --git a/nodes/GithubIssues/listSearch/getIssues.ts b/nodes/GithubIssues/listSearch/getIssues.ts deleted file mode 100644 index f340b03..0000000 --- a/nodes/GithubIssues/listSearch/getIssues.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { - ILoadOptionsFunctions, - INodeListSearchResult, - INodeListSearchItems, -} from 'n8n-workflow'; -import { githubApiRequest } from '../shared/transport'; - -type IssueSearchItem = { - number: number; - title: string; - html_url: string; -}; - -type IssueSearchResponse = { - items: IssueSearchItem[]; - total_count: number; -}; - -export async function getIssues( - this: ILoadOptionsFunctions, - filter?: string, - paginationToken?: string, -): Promise { - const page = paginationToken ? +paginationToken : 1; - const per_page = 100; - - let responseData: IssueSearchResponse = { - items: [], - total_count: 0, - }; - const owner = this.getNodeParameter('owner', '', { extractValue: true }); - const repository = this.getNodeParameter('repository', '', { extractValue: true }); - const filters = [filter, `repo:${owner}/${repository}`]; - - responseData = await githubApiRequest.call(this, 'GET', '/search/issues', { - q: filters.filter(Boolean).join(' '), - page, - per_page, - }); - - const results: INodeListSearchItems[] = responseData.items.map((item: IssueSearchItem) => ({ - name: item.title, - value: item.number, - url: item.html_url, - })); - - const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined; - return { results, paginationToken: nextPaginationToken }; -} diff --git a/nodes/GithubIssues/listSearch/getRepositories.ts b/nodes/GithubIssues/listSearch/getRepositories.ts deleted file mode 100644 index 9f5a6b1..0000000 --- a/nodes/GithubIssues/listSearch/getRepositories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { - ILoadOptionsFunctions, - INodeListSearchItems, - INodeListSearchResult, -} from 'n8n-workflow'; -import { githubApiRequest } from '../shared/transport'; - -type RepositorySearchItem = { - name: string; - html_url: string; -}; - -type RepositorySearchResponse = { - items: RepositorySearchItem[]; - total_count: number; -}; - -export async function getRepositories( - this: ILoadOptionsFunctions, - filter?: string, - paginationToken?: string, -): Promise { - const owner = this.getCurrentNodeParameter('owner', { extractValue: true }); - const page = paginationToken ? +paginationToken : 1; - const per_page = 100; - const q = `${filter ?? ''} user:${owner} fork:true`; - let responseData: RepositorySearchResponse = { - items: [], - total_count: 0, - }; - - try { - responseData = await githubApiRequest.call(this, 'GET', '/search/repositories', { - q, - page, - per_page, - }); - } catch { - // will fail if the owner does not have any repositories - } - - const results: INodeListSearchItems[] = responseData.items.map((item: RepositorySearchItem) => ({ - name: item.name, - value: item.name, - url: item.html_url, - })); - - const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined; - return { results, paginationToken: nextPaginationToken }; -} diff --git a/nodes/GithubIssues/listSearch/getUsers.ts b/nodes/GithubIssues/listSearch/getUsers.ts deleted file mode 100644 index d8e0853..0000000 --- a/nodes/GithubIssues/listSearch/getUsers.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { - ILoadOptionsFunctions, - INodeListSearchResult, - INodeListSearchItems, -} from 'n8n-workflow'; -import { githubApiRequest } from '../shared/transport'; - -type UserSearchItem = { - login: string; - html_url: string; -}; - -type UserSearchResponse = { - items: UserSearchItem[]; - total_count: number; -}; - -export async function getUsers( - this: ILoadOptionsFunctions, - filter?: string, - paginationToken?: string, -): Promise { - const page = paginationToken ? +paginationToken : 1; - const per_page = 100; - - let responseData: UserSearchResponse = { - items: [], - total_count: 0, - }; - - try { - responseData = await githubApiRequest.call(this, 'GET', '/search/users', { - q: filter, - page, - per_page, - }); - } catch { - // will fail if the owner does not have any users - } - - const results: INodeListSearchItems[] = responseData.items.map((item: UserSearchItem) => ({ - name: item.login, - value: item.login, - url: item.html_url, - })); - - const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined; - return { results, paginationToken: nextPaginationToken }; -} diff --git a/nodes/GithubIssues/resources/issue/create.ts b/nodes/GithubIssues/resources/issue/create.ts deleted file mode 100644 index 4c7fd7e..0000000 --- a/nodes/GithubIssues/resources/issue/create.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; - -const showOnlyForIssueCreate = { - operation: ['create'], - resource: ['issue'], -}; - -export const issueCreateDescription: INodeProperties[] = [ - { - displayName: 'Title', - name: 'title', - type: 'string', - default: '', - required: true, - displayOptions: { - show: showOnlyForIssueCreate, - }, - description: 'The title of the issue', - routing: { - send: { - type: 'body', - property: 'title', - }, - }, - }, - { - displayName: 'Body', - name: 'body', - type: 'string', - typeOptions: { - rows: 5, - }, - default: '', - displayOptions: { - show: showOnlyForIssueCreate, - }, - description: 'The body of the issue', - routing: { - send: { - type: 'body', - property: 'body', - }, - }, - }, - { - displayName: 'Labels', - name: 'labels', - type: 'collection', - typeOptions: { - multipleValues: true, - multipleValueButtonText: 'Add Label', - }, - displayOptions: { - show: showOnlyForIssueCreate, - }, - default: { label: '' }, - options: [ - { - displayName: 'Label', - name: 'label', - type: 'string', - default: '', - description: 'Label to add to issue', - }, - ], - routing: { - send: { - type: 'body', - property: 'labels', - value: '={{$value.map((data) => data.label)}}', - }, - }, - }, -]; diff --git a/nodes/GithubIssues/resources/issue/get.ts b/nodes/GithubIssues/resources/issue/get.ts deleted file mode 100644 index 7dc7181..0000000 --- a/nodes/GithubIssues/resources/issue/get.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; -import { issueSelect } from '../../shared/descriptions'; - -const showOnlyForIssueGet = { - operation: ['get'], - resource: ['issue'], -}; - -export const issueGetDescription: INodeProperties[] = [ - { - ...issueSelect, - displayOptions: { show: showOnlyForIssueGet }, - }, -]; diff --git a/nodes/GithubIssues/resources/issue/getAll.ts b/nodes/GithubIssues/resources/issue/getAll.ts deleted file mode 100644 index b5b5fed..0000000 --- a/nodes/GithubIssues/resources/issue/getAll.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; -import { parseLinkHeader } from '../../shared/utils'; - -const showOnlyForIssueGetMany = { - operation: ['getAll'], - resource: ['issue'], -}; - -export const issueGetManyDescription: INodeProperties[] = [ - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - ...showOnlyForIssueGetMany, - returnAll: [false], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 100, - }, - default: 50, - routing: { - send: { - type: 'query', - property: 'per_page', - }, - output: { - maxResults: '={{$value}}', - }, - }, - description: 'Max number of results to return', - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: showOnlyForIssueGetMany, - }, - default: false, - description: 'Whether to return all results or only up to a given limit', - routing: { - send: { - paginate: '={{ $value }}', - type: 'query', - property: 'per_page', - value: '100', - }, - operations: { - pagination: { - type: 'generic', - properties: { - continue: `={{ !!(${parseLinkHeader.toString()})($response.headers?.link).next }}`, - request: { - url: `={{ (${parseLinkHeader.toString()})($response.headers?.link)?.next ?? $request.url }}`, - }, - }, - }, - }, - }, - }, - { - displayName: 'Filters', - name: 'filters', - type: 'collection', - typeOptions: { - multipleValueButtonText: 'Add Filter', - }, - displayOptions: { - show: showOnlyForIssueGetMany, - }, - default: {}, - options: [ - { - displayName: 'Updated Since', - name: 'since', - type: 'dateTime', - default: '', - description: 'Return only issues updated at or after this time', - routing: { - request: { - qs: { - since: '={{$value}}', - }, - }, - }, - }, - { - displayName: 'State', - name: 'state', - type: 'options', - options: [ - { - name: 'All', - value: 'all', - description: 'Returns issues with any state', - }, - { - name: 'Closed', - value: 'closed', - description: 'Return issues with "closed" state', - }, - { - name: 'Open', - value: 'open', - description: 'Return issues with "open" state', - }, - ], - default: 'open', - description: 'The issue state to filter on', - routing: { - request: { - qs: { - state: '={{$value}}', - }, - }, - }, - }, - ], - }, -]; diff --git a/nodes/GithubIssues/resources/issue/index.ts b/nodes/GithubIssues/resources/issue/index.ts deleted file mode 100644 index 6c915d2..0000000 --- a/nodes/GithubIssues/resources/issue/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; -import { repoNameSelect, repoOwnerSelect } from '../../shared/descriptions'; -import { issueGetManyDescription } from './getAll'; -import { issueGetDescription } from './get'; -import { issueCreateDescription } from './create'; - -const showOnlyForIssues = { - resource: ['issue'], -}; - -export const issueDescription: INodeProperties[] = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { - show: showOnlyForIssues, - }, - options: [ - { - name: 'Get Many', - value: 'getAll', - action: 'Get issues in a repository', - description: 'Get many issues in a repository', - routing: { - request: { - method: 'GET', - url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues', - }, - }, - }, - { - name: 'Get', - value: 'get', - action: 'Get an issue', - description: 'Get the data of a single issue', - routing: { - request: { - method: 'GET', - url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues/{{$parameter.issue}}', - }, - }, - }, - { - name: 'Create', - value: 'create', - action: 'Create a new issue', - description: 'Create a new issue', - routing: { - request: { - method: 'POST', - url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues', - }, - }, - }, - ], - default: 'getAll', - }, - { - ...repoOwnerSelect, - displayOptions: { - show: showOnlyForIssues, - }, - }, - { - ...repoNameSelect, - displayOptions: { - show: showOnlyForIssues, - }, - }, - ...issueGetManyDescription, - ...issueGetDescription, - ...issueCreateDescription, -]; diff --git a/nodes/GithubIssues/resources/issueComment/getAll.ts b/nodes/GithubIssues/resources/issueComment/getAll.ts deleted file mode 100644 index 53b2057..0000000 --- a/nodes/GithubIssues/resources/issueComment/getAll.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; -import { parseLinkHeader } from '../../shared/utils'; - -const showOnlyForIssueCommentGetMany = { - operation: ['getAll'], - resource: ['issueComment'], -}; - -export const issueCommentGetManyDescription: INodeProperties[] = [ - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - ...showOnlyForIssueCommentGetMany, - returnAll: [false], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 100, - }, - default: 50, - routing: { - send: { - type: 'query', - property: 'per_page', - }, - output: { - maxResults: '={{$value}}', - }, - }, - description: 'Max number of results to return', - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: showOnlyForIssueCommentGetMany, - }, - default: false, - description: 'Whether to return all results or only up to a given limit', - routing: { - send: { - paginate: '={{ $value }}', - type: 'query', - property: 'per_page', - value: '100', - }, - operations: { - pagination: { - type: 'generic', - properties: { - continue: `={{ !!(${parseLinkHeader.toString()})($response.headers?.link).next }}`, - request: { - url: `={{ (${parseLinkHeader.toString()})($response.headers?.link)?.next ?? $request.url }}`, - }, - }, - }, - }, - }, - }, -]; diff --git a/nodes/GithubIssues/resources/issueComment/index.ts b/nodes/GithubIssues/resources/issueComment/index.ts deleted file mode 100644 index 886f7c2..0000000 --- a/nodes/GithubIssues/resources/issueComment/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; -import { repoNameSelect, repoOwnerSelect } from '../../shared/descriptions'; -import { issueCommentGetManyDescription } from './getAll'; - -const showOnlyForIssueComments = { - resource: ['issueComment'], -}; - -export const issueCommentDescription: INodeProperties[] = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { - show: showOnlyForIssueComments, - }, - options: [ - { - name: 'Get Many', - value: 'getAll', - action: 'Get issue comments', - description: 'Get issue comments', - routing: { - request: { - method: 'GET', - url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues/comments', - }, - }, - }, - ], - default: 'getAll', - }, - { - ...repoOwnerSelect, - displayOptions: { - show: showOnlyForIssueComments, - }, - }, - { - ...repoNameSelect, - displayOptions: { - show: showOnlyForIssueComments, - }, - }, - ...issueCommentGetManyDescription, -]; diff --git a/nodes/GithubIssues/shared/descriptions.ts b/nodes/GithubIssues/shared/descriptions.ts deleted file mode 100644 index aaeaaa8..0000000 --- a/nodes/GithubIssues/shared/descriptions.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; - -export const repoOwnerSelect: INodeProperties = { - displayName: 'Repository Owner', - name: 'owner', - type: 'resourceLocator', - default: { mode: 'list', value: '' }, - required: true, - modes: [ - { - displayName: 'Repository Owner', - name: 'list', - type: 'list', - placeholder: 'Select an owner...', - typeOptions: { - searchListMethod: 'getUsers', - searchable: true, - searchFilterRequired: false, - }, - }, - { - displayName: 'Link', - name: 'url', - type: 'string', - placeholder: 'e.g. https://github.com/n8n-io', - extractValue: { - type: 'regex', - regex: 'https:\\/\\/github.com\\/([-_0-9a-zA-Z]+)', - }, - validation: [ - { - type: 'regex', - properties: { - regex: 'https:\\/\\/github.com\\/([-_0-9a-zA-Z]+)(?:.*)', - errorMessage: 'Not a valid GitHub URL', - }, - }, - ], - }, - { - displayName: 'By Name', - name: 'name', - type: 'string', - placeholder: 'e.g. n8n-io', - validation: [ - { - type: 'regex', - properties: { - regex: '[-_a-zA-Z0-9]+', - errorMessage: 'Not a valid GitHub Owner Name', - }, - }, - ], - url: '=https://github.com/{{$value}}', - }, - ], -}; - -export const repoNameSelect: INodeProperties = { - displayName: 'Repository Name', - name: 'repository', - type: 'resourceLocator', - default: { - mode: 'list', - value: '', - }, - required: true, - modes: [ - { - displayName: 'Repository Name', - name: 'list', - type: 'list', - placeholder: 'Select an Repository...', - typeOptions: { - searchListMethod: 'getRepositories', - searchable: true, - }, - }, - { - displayName: 'Link', - name: 'url', - type: 'string', - placeholder: 'e.g. https://github.com/n8n-io/n8n', - extractValue: { - type: 'regex', - regex: 'https:\\/\\/github.com\\/(?:[-_0-9a-zA-Z]+)\\/([-_.0-9a-zA-Z]+)', - }, - validation: [ - { - type: 'regex', - properties: { - regex: 'https:\\/\\/github.com\\/(?:[-_0-9a-zA-Z]+)\\/([-_.0-9a-zA-Z]+)(?:.*)', - errorMessage: 'Not a valid GitHub Repository URL', - }, - }, - ], - }, - { - displayName: 'By Name', - name: 'name', - type: 'string', - placeholder: 'e.g. n8n', - validation: [ - { - type: 'regex', - properties: { - regex: '[-_.0-9a-zA-Z]+', - errorMessage: 'Not a valid GitHub Repository Name', - }, - }, - ], - url: '=https://github.com/{{$parameter["owner"]}}/{{$value}}', - }, - ], - displayOptions: { - hide: { - resource: ['user', 'organization'], - operation: ['getRepositories'], - }, - }, -}; - -export const issueSelect: INodeProperties = { - displayName: 'Issue', - name: 'issue', - type: 'resourceLocator', - default: { - mode: 'list', - value: '', - }, - required: true, - modes: [ - { - displayName: 'Issue', - name: 'list', - type: 'list', - placeholder: 'Select an Issue...', - typeOptions: { - searchListMethod: 'getIssues', - searchable: true, - }, - }, - { - displayName: 'By ID', - name: 'name', - type: 'string', - placeholder: 'e.g. 123', - url: '=https://github.com/{{$parameter.owner}}/{{$parameter.repository}}/issues/{{$value}}', - }, - ], -}; diff --git a/nodes/GithubIssues/shared/transport.ts b/nodes/GithubIssues/shared/transport.ts deleted file mode 100644 index 3555ee0..0000000 --- a/nodes/GithubIssues/shared/transport.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - IHookFunctions, - IExecuteFunctions, - IExecuteSingleFunctions, - ILoadOptionsFunctions, - IHttpRequestMethods, - IDataObject, - IHttpRequestOptions, -} from 'n8n-workflow'; - -export async function githubApiRequest( - this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, - method: IHttpRequestMethods, - resource: string, - qs: IDataObject = {}, - body: IDataObject | undefined = undefined, -) { - const authenticationMethod = this.getNodeParameter('authentication', 0); - - const options: IHttpRequestOptions = { - method: method, - qs, - body, - url: `https://api.github.com${resource}`, - json: true, - }; - - const credentialType = - authenticationMethod === 'accessToken' ? 'githubIssuesApi' : 'githubIssuesOAuth2Api'; - - return this.helpers.httpRequestWithAuthentication.call(this, credentialType, options); -} diff --git a/nodes/GithubIssues/shared/utils.ts b/nodes/GithubIssues/shared/utils.ts deleted file mode 100644 index 2b91882..0000000 --- a/nodes/GithubIssues/shared/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function parseLinkHeader(header?: string): { [rel: string]: string } { - const links: { [rel: string]: string } = {}; - - for (const part of header?.split(',') ?? []) { - const section = part.trim(); - const match = section.match(/^<([^>]+)>\s*;\s*rel="?([^"]+)"?/); - if (match) { - const [, url, rel] = match; - links[rel] = url; - } - } - - return links; -} diff --git a/nodes/WeCom/WeCom.node.json b/nodes/WeCom/WeCom.node.json new file mode 100644 index 0000000..1b9b9e4 --- /dev/null +++ b/nodes/WeCom/WeCom.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.weCom", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Communication"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://developer.work.weixin.qq.com/document/path/90665" + } + ], + "primaryDocumentation": [ + { + "url": "https://developer.work.weixin.qq.com/document/path/90235" + } + ] + }, + "alias": ["企业微信", "WeCom", "Work Weixin"] +} + diff --git a/nodes/WeCom/WeCom.node.ts b/nodes/WeCom/WeCom.node.ts new file mode 100644 index 0000000..608685f --- /dev/null +++ b/nodes/WeCom/WeCom.node.ts @@ -0,0 +1,198 @@ +import type { + IExecuteFunctions, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; +import { messageDescription } from './resources/message'; +import { contactDescription } from './resources/contact'; +import { materialDescription } from './resources/material'; +import { executeMessage } from './resources/message/execute'; +import { executeContact } from './resources/contact/execute'; +import { executeMaterial } from './resources/material/execute'; +import { weComApiRequest } from './shared/transport'; + +export class WeCom implements INodeType { + description: INodeTypeDescription = { + displayName: '企业微信', + name: 'weCom', + // eslint-disable-next-line @n8n/community-nodes/icon-validation + icon: { light: 'file:../../icons/wecom.png', dark: 'file:../../icons/wecom.dark.png' }, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: '企业微信消息发送与通讯录管理', + defaults: { + name: '企业微信', + }, + usableAsTool: true, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'weComApi', + required: true, + }, + ], + requestDefaults: { + baseURL: 'https://qyapi.weixin.qq.com', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }, + properties: [ + { + displayName: '资源', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: '消息', + value: 'message', + description: '发送各类消息(文本、图片、文件等)', + }, + { + name: '通讯录', + value: 'contact', + description: '管理通讯录(成员、部门)', + }, + { + name: '素材管理', + value: 'material', + description: '上传和管理素材文件', + }, + ], + default: 'message', + }, + ...messageDescription, + ...contactDescription, + ...materialDescription, + ], + }; + + methods = { + loadOptions: { + // 获取部门列表 + async getDepartments(this: ILoadOptionsFunctions): Promise { + const response = await weComApiRequest.call(this, 'GET', '/cgi-bin/department/list', {}); + const departments = response.department as Array<{ id: number; name: string }>; + return departments.map((dept) => ({ + name: dept.name, + value: dept.id.toString(), + })); + }, + + // 获取部门成员列表 + async getDepartmentUsers(this: ILoadOptionsFunctions): Promise { + const departmentId = (this.getNodeParameter('department_id', 0) as string) || '1'; + const response = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/user/simplelist', + {}, + { + department_id: departmentId, + fetch_child: 0, + }, + ); + const users = response.userlist as Array<{ userid: string; name: string }>; + return users.map((user) => ({ + name: `${user.name} (${user.userid})`, + value: user.userid, + })); + }, + + // 获取部门成员详情列表 + async getDepartmentUsersDetail(this: ILoadOptionsFunctions): Promise { + const departmentId = (this.getNodeParameter('department_id', 0) as string) || '1'; + const response = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/user/list', + {}, + { + department_id: departmentId, + fetch_child: 0, + }, + ); + const users = response.userlist as Array<{ userid: string; name: string; position?: string }>; + return users.map((user) => ({ + name: user.position ? `${user.name} - ${user.position} (${user.userid})` : `${user.name} (${user.userid})`, + value: user.userid, + })); + }, + + // 获取标签列表 + async getTags(this: ILoadOptionsFunctions): Promise { + const response = await weComApiRequest.call(this, 'GET', '/cgi-bin/tag/list', {}); + const tags = response.taglist as Array<{ tagid: number; tagname: string }>; + return tags.map((tag) => ({ + name: tag.tagname, + value: tag.tagid.toString(), + })); + }, + + // 获取标签成员列表 + async getTagUsers(this: ILoadOptionsFunctions): Promise { + const tagId = this.getNodeParameter('tagid', 0) as string; + const response = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/tag/get', + {}, + { + tagid: tagId, + }, + ); + const users = response.userlist as Array<{ userid: string; name: string }>; + return users.map((user) => ({ + name: `${user.name} (${user.userid})`, + value: user.userid, + })); + }, + + // 获取所有成员列表(从根部门递归获取) + async getAllUsers(this: ILoadOptionsFunctions): Promise { + const response = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/user/list', + {}, + { + department_id: '1', + fetch_child: 1, + }, + ); + const users = response.userlist as Array<{ userid: string; name: string; department?: number[] }>; + return users.map((user) => ({ + name: `${user.name} (${user.userid})`, + value: user.userid, + })); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + let returnData: INodeExecutionData[] = []; + + if (resource === 'message') { + returnData = await executeMessage.call(this, operation as string, items); + } else if (resource === 'contact') { + returnData = await executeContact.call(this, operation as string, items); + } else if (resource === 'material') { + returnData = await executeMaterial.call(this, operation as string, items); + } + + return [returnData]; + } +} + diff --git a/nodes/WeCom/resources/contact/convertToOpenid.ts b/nodes/WeCom/resources/contact/convertToOpenid.ts new file mode 100644 index 0000000..f165e80 --- /dev/null +++ b/nodes/WeCom/resources/contact/convertToOpenid.ts @@ -0,0 +1,24 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyConvertToOpenid = { + resource: ['contact'], + operation: ['convertToOpenid'], +}; + +export const convertToOpenidDescription: INodeProperties[] = [ + { + displayName: '成员', + name: 'userid', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAllUsers', + }, + required: true, + default: '', + displayOptions: { + show: showOnlyConvertToOpenid, + }, + description: '企业成员的 userid', + }, +]; + diff --git a/nodes/WeCom/resources/contact/convertToUserid.ts b/nodes/WeCom/resources/contact/convertToUserid.ts new file mode 100644 index 0000000..412fdd3 --- /dev/null +++ b/nodes/WeCom/resources/contact/convertToUserid.ts @@ -0,0 +1,21 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyConvertToUserid = { + resource: ['contact'], + operation: ['convertToUserid'], +}; + +export const convertToUseridDescription: INodeProperties[] = [ + { + displayName: 'OpenID', + name: 'openid', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlyConvertToUserid, + }, + description: '在使用企业支付之后,返回结果的 openid', + }, +]; + diff --git a/nodes/WeCom/resources/contact/execute.ts b/nodes/WeCom/resources/contact/execute.ts new file mode 100644 index 0000000..7f333f0 --- /dev/null +++ b/nodes/WeCom/resources/contact/execute.ts @@ -0,0 +1,117 @@ +import type { IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { weComApiRequest } from '../../shared/transport'; + +export async function executeContact( + this: IExecuteFunctions, + operation: string, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + let response: IDataObject; + + if (operation === 'getUser') { + const userid = this.getNodeParameter('userid', i) as string; + response = await weComApiRequest.call(this, 'GET', '/cgi-bin/user/get', {}, { userid }); + } else if (operation === 'listUsers') { + const department_id = this.getNodeParameter('department_id', i, '1') as string; + const fetch_child = this.getNodeParameter('fetch_child', i, false) as boolean; + + response = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/user/simplelist', + {}, + { + department_id, + fetch_child: fetch_child ? 1 : 0, + }, + ); + } else if (operation === 'listUsersDetail') { + const department_id = this.getNodeParameter('department_id', i, '1') as string; + const fetch_child = this.getNodeParameter('fetch_child', i, false) as boolean; + + response = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/user/list', + {}, + { + department_id, + fetch_child: fetch_child ? 1 : 0, + }, + ); + } else if (operation === 'listUserIds') { + const cursor = this.getNodeParameter('cursor', i, '') as string; + const limit = this.getNodeParameter('limit', i, 1000) as number; + + response = await weComApiRequest.call( + this, + 'POST', + '/cgi-bin/user/list_id', + { + cursor, + limit, + }, + ); + } else if (operation === 'getDepartment') { + const id = this.getNodeParameter('id', i, '') as string; + const qs: IDataObject = {}; + if (id) { + qs.id = id; + } + + response = await weComApiRequest.call(this, 'GET', '/cgi-bin/department/list', {}, qs); + } else if (operation === 'convertToOpenid') { + const userid = this.getNodeParameter('userid', i) as string; + response = await weComApiRequest.call( + this, + 'POST', + '/cgi-bin/user/convert_to_openid', + { userid }, + ); + } else if (operation === 'convertToUserid') { + const openid = this.getNodeParameter('openid', i) as string; + response = await weComApiRequest.call( + this, + 'POST', + '/cgi-bin/user/convert_to_userid', + { openid }, + ); + } else if (operation === 'getTagList') { + const tag_type = this.getNodeParameter('tag_type', i, '') as string; + const qs: IDataObject = {}; + if (tag_type) { + qs.tag_type = tag_type; + } + response = await weComApiRequest.call(this, 'GET', '/cgi-bin/tag/list', {}, qs); + } else if (operation === 'getTag') { + const tagid = this.getNodeParameter('tagid', i) as string; + response = await weComApiRequest.call(this, 'GET', '/cgi-bin/tag/get', {}, { tagid }); + } else { + response = {}; + } + + returnData.push({ + json: response, + pairedItem: { item: i }, + }); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { + error: error.message, + }, + pairedItem: { item: i }, + }); + continue; + } + throw error; + } + } + + return returnData; +} + diff --git a/nodes/WeCom/resources/contact/getDepartment.ts b/nodes/WeCom/resources/contact/getDepartment.ts new file mode 100644 index 0000000..247d4ee --- /dev/null +++ b/nodes/WeCom/resources/contact/getDepartment.ts @@ -0,0 +1,23 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetDepartment = { + resource: ['contact'], + operation: ['getDepartment'], +}; + +export const getDepartmentDescription: INodeProperties[] = [ + { + displayName: '部门', + name: 'id', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDepartments', + }, + default: '1', + displayOptions: { + show: showOnlyGetDepartment, + }, + description: '部门ID。(注:如果不填,默认获取全量组织架构)', + }, +]; + diff --git a/nodes/WeCom/resources/contact/getTag.ts b/nodes/WeCom/resources/contact/getTag.ts new file mode 100644 index 0000000..38d5cbe --- /dev/null +++ b/nodes/WeCom/resources/contact/getTag.ts @@ -0,0 +1,24 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetTag = { + resource: ['contact'], + operation: ['getTag'], +}; + +export const getTagDescription: INodeProperties[] = [ + { + displayName: '标签', + name: 'tagid', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + required: true, + default: '', + displayOptions: { + show: showOnlyGetTag, + }, + description: '标签 ID', + }, +]; + diff --git a/nodes/WeCom/resources/contact/getTagList.ts b/nodes/WeCom/resources/contact/getTagList.ts new file mode 100644 index 0000000..08e55c0 --- /dev/null +++ b/nodes/WeCom/resources/contact/getTagList.ts @@ -0,0 +1,34 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetTagList = { + resource: ['contact'], + operation: ['getTagList'], +}; + +export const getTagListDescription: INodeProperties[] = [ + { + displayName: '标签类型', + name: 'tag_type', + type: 'options', + options: [ + { + name: '全部标签', + value: '', + }, + { + name: '个人标签', + value: 'person', + }, + { + name: '部门标签', + value: 'department', + }, + ], + default: '', + displayOptions: { + show: showOnlyGetTagList, + }, + description: '标签类型:不填则获取全部标签,person-个人标签,department-部门标签', + }, +]; + diff --git a/nodes/WeCom/resources/contact/getUser.ts b/nodes/WeCom/resources/contact/getUser.ts new file mode 100644 index 0000000..c546b54 --- /dev/null +++ b/nodes/WeCom/resources/contact/getUser.ts @@ -0,0 +1,24 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetUser = { + resource: ['contact'], + operation: ['getUser'], +}; + +export const getUserDescription: INodeProperties[] = [ + { + displayName: '成员', + name: 'userid', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAllUsers', + }, + required: true, + default: '', + displayOptions: { + show: showOnlyGetUser, + }, + description: '成员UserID。对应管理端的账号,企业内必须唯一', + }, +]; + diff --git a/nodes/WeCom/resources/contact/index.ts b/nodes/WeCom/resources/contact/index.ts new file mode 100644 index 0000000..a5ea7b6 --- /dev/null +++ b/nodes/WeCom/resources/contact/index.ts @@ -0,0 +1,93 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getUserDescription } from './getUser'; +import { listUsersDescription } from './listUsers'; +import { listUsersDetailDescription } from './listUsersDetail'; +import { listUserIdsDescription } from './listUserIds'; +import { getDepartmentDescription } from './getDepartment'; +import { convertToOpenidDescription } from './convertToOpenid'; +import { convertToUseridDescription } from './convertToUserid'; +import { getTagListDescription } from './getTagList'; +import { getTagDescription } from './getTag'; + +const showOnlyForContact = { + resource: ['contact'], +}; + +export const contactDescription: INodeProperties[] = [ + { + displayName: '操作', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: showOnlyForContact, + }, + options: [ + { + name: 'OpenID转UserID', + value: 'convertToUserid', + action: 'openID转userid', + description: '将 openid 转换为企业成员的 userid', + }, + { + name: 'UserID转OpenID', + value: 'convertToOpenid', + action: 'userid转openID', + description: '将企业成员的 userid 转换为 openid', + }, + { + name: '获取成员ID列表', + value: 'listUserIds', + action: '获取成员ID列表', + description: '获取企业所有成员的UserID列表,支持分页', + }, + { + name: '获取成员信息', + value: 'getUser', + action: '获取成员信息', + description: '读取成员详细信息', + }, + { + name: '获取标签列表', + value: 'getTagList', + action: '获取标签列表', + description: '获取企业标签列表', + }, + { + name: '获取标签成员', + value: 'getTag', + action: '获取标签成员', + description: '获取标签成员列表', + }, + { + name: '获取部门信息', + value: 'getDepartment', + action: '获取部门信息', + description: '获取部门列表', + }, + { + name: '获取部门成员列表', + value: 'listUsers', + action: '获取部门成员列表', + description: '获取部门成员列表(仅包含基本信息)', + }, + { + name: '获取部门成员详情', + value: 'listUsersDetail', + action: '获取部门成员详情列表', + description: '获取部门成员列表(包含完整信息)', + }, + ], + default: 'getUser', + }, + ...getUserDescription, + ...listUsersDescription, + ...listUsersDetailDescription, + ...listUserIdsDescription, + ...getDepartmentDescription, + ...convertToOpenidDescription, + ...convertToUseridDescription, + ...getTagListDescription, + ...getTagDescription, +]; + diff --git a/nodes/WeCom/resources/contact/listUserIds.ts b/nodes/WeCom/resources/contact/listUserIds.ts new file mode 100644 index 0000000..23c3b75 --- /dev/null +++ b/nodes/WeCom/resources/contact/listUserIds.ts @@ -0,0 +1,35 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyListUserIds = { + resource: ['contact'], + operation: ['listUserIds'], +}; + +export const listUserIdsDescription: INodeProperties[] = [ + { + displayName: '游标', + name: 'cursor', + type: 'string', + default: '', + displayOptions: { + show: showOnlyListUserIds, + }, + description: '分页查询的游标,首次调用不填,后续调用填上次调用返回的 next_cursor 值', + }, + { + displayName: '每页数量', + name: 'limit', + type: 'number', + default: 50, + displayOptions: { + show: showOnlyListUserIds, + }, + hint: '每次拉取的数据量,默认值50,最大值1000', + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + }, +]; + diff --git a/nodes/WeCom/resources/contact/listUsers.ts b/nodes/WeCom/resources/contact/listUsers.ts new file mode 100644 index 0000000..b5d8bff --- /dev/null +++ b/nodes/WeCom/resources/contact/listUsers.ts @@ -0,0 +1,34 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyListUsers = { + resource: ['contact'], + operation: ['listUsers'], +}; + +export const listUsersDescription: INodeProperties[] = [ + { + displayName: '部门', + name: 'department_id', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDepartments', + }, + required: true, + default: '1', + displayOptions: { + show: showOnlyListUsers, + }, + description: '获取的部门ID,默认为根部门', + }, + { + displayName: '是否递归获取', + name: 'fetch_child', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlyListUsers, + }, + description: 'Whether to recursively fetch members from sub-departments: 1 - fetch recursively, 0 - fetch only from this department', + }, +]; + diff --git a/nodes/WeCom/resources/contact/listUsersDetail.ts b/nodes/WeCom/resources/contact/listUsersDetail.ts new file mode 100644 index 0000000..3ff35b1 --- /dev/null +++ b/nodes/WeCom/resources/contact/listUsersDetail.ts @@ -0,0 +1,34 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyListUsersDetail = { + resource: ['contact'], + operation: ['listUsersDetail'], +}; + +export const listUsersDetailDescription: INodeProperties[] = [ + { + displayName: '部门', + name: 'department_id', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDepartments', + }, + required: true, + default: '1', + displayOptions: { + show: showOnlyListUsersDetail, + }, + description: '获取的部门ID,默认为根部门', + }, + { + displayName: '是否递归获取', + name: 'fetch_child', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlyListUsersDetail, + }, + description: '是否递归获取子部门下的成员:1-递归获取,0-只获取本部门', + }, +]; + diff --git a/nodes/WeCom/resources/material/execute.ts b/nodes/WeCom/resources/material/execute.ts new file mode 100644 index 0000000..65ef70e --- /dev/null +++ b/nodes/WeCom/resources/material/execute.ts @@ -0,0 +1,177 @@ +import type { IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { weComApiRequest } from '../../shared/transport'; + +export async function executeMaterial( + this: IExecuteFunctions, + operation: string, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + let response: IDataObject; + + if (operation === 'uploadTemp') { + const type = this.getNodeParameter('type', i) as string; + const binaryPropertyName = this.getNodeParameter('binaryProperty', i) as string; + + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const dataBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + // 使用n8n内置的FormData构建方式 + const formData = { + media: { + value: dataBuffer, + options: { + filename: binaryData.fileName || 'file', + contentType: binaryData.mimeType, + }, + }, + }; + + response = await weComApiRequest.call( + this, + 'POST', + '/cgi-bin/media/upload', + {}, + { type }, + {}, + { formData }, + ); + } else if (operation === 'getTemp') { + const media_id = this.getNodeParameter('media_id', i) as string; + const binaryPropertyName = this.getNodeParameter('binaryProperty', i) as string; + + const responseData = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/media/get', + {}, + { media_id }, + {}, + { encoding: null, resolveWithFullResponse: true }, + ); + + // 处理响应数据 + let buffer: Buffer; + let filename = 'file'; + + if (Buffer.isBuffer(responseData.body)) { + buffer = responseData.body; + } else { + buffer = Buffer.from(responseData.body as string); + } + + // 尝试从响应头获取文件名 + if (responseData.headers && responseData.headers['content-disposition']) { + const match = (responseData.headers['content-disposition'] as string).match(/filename="?(.+?)"?$/); + if (match) { + filename = match[1]; + } + } + + const binaryData = await this.helpers.prepareBinaryData(buffer, filename); + + returnData.push({ + json: { media_id }, + binary: { + [binaryPropertyName]: binaryData, + }, + pairedItem: { item: i }, + }); + continue; + } else if (operation === 'uploadPermanent') { + const type = this.getNodeParameter('type', i) as string; + const binaryPropertyName = this.getNodeParameter('binaryProperty', i) as string; + + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const dataBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + // 使用n8n内置的FormData构建方式 + const formData = { + media: { + value: dataBuffer, + options: { + filename: binaryData.fileName || 'file', + contentType: binaryData.mimeType, + }, + }, + }; + + response = await weComApiRequest.call( + this, + 'POST', + '/cgi-bin/material/add_material', + {}, + { type }, + {}, + { formData }, + ); + } else if (operation === 'getPermanent') { + const media_id = this.getNodeParameter('media_id', i) as string; + const binaryPropertyName = this.getNodeParameter('binaryProperty', i) as string; + + const responseData = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/material/get', + {}, + { media_id }, + {}, + { encoding: null, resolveWithFullResponse: true }, + ); + + // 处理响应数据 + let buffer: Buffer; + let filename = 'file'; + + if (Buffer.isBuffer(responseData.body)) { + buffer = responseData.body; + } else { + buffer = Buffer.from(responseData.body as string); + } + + // 尝试从响应头获取文件名 + if (responseData.headers && responseData.headers['content-disposition']) { + const match = (responseData.headers['content-disposition'] as string).match(/filename="?(.+?)"?$/); + if (match) { + filename = match[1]; + } + } + + const binaryData = await this.helpers.prepareBinaryData(buffer, filename); + + returnData.push({ + json: { media_id }, + binary: { + [binaryPropertyName]: binaryData, + }, + pairedItem: { item: i }, + }); + continue; + } else { + response = {}; + } + + returnData.push({ + json: response, + pairedItem: { item: i }, + }); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { + error: error.message, + }, + pairedItem: { item: i }, + }); + continue; + } + throw error; + } + } + + return returnData; +} + diff --git a/nodes/WeCom/resources/material/getPermanent.ts b/nodes/WeCom/resources/material/getPermanent.ts new file mode 100644 index 0000000..552321a --- /dev/null +++ b/nodes/WeCom/resources/material/getPermanent.ts @@ -0,0 +1,31 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetPermanent = { + resource: ['material'], + operation: ['getPermanent'], +}; + +export const getPermanentDescription: INodeProperties[] = [ + { + displayName: '素材ID', + name: 'media_id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: showOnlyGetPermanent, + }, + description: '永久素材的media_id', + }, + { + displayName: '下载到二进制属性', + name: 'binaryProperty', + type: 'string', + default: 'data', + displayOptions: { + show: showOnlyGetPermanent, + }, + description: '将下载的文件存储到的二进制属性名称', + }, +]; + diff --git a/nodes/WeCom/resources/material/getTemp.ts b/nodes/WeCom/resources/material/getTemp.ts new file mode 100644 index 0000000..edcf6a5 --- /dev/null +++ b/nodes/WeCom/resources/material/getTemp.ts @@ -0,0 +1,31 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetTemp = { + resource: ['material'], + operation: ['getTemp'], +}; + +export const getTempDescription: INodeProperties[] = [ + { + displayName: '素材ID', + name: 'media_id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: showOnlyGetTemp, + }, + description: '临时素材的media_id', + }, + { + displayName: '下载到二进制属性', + name: 'binaryProperty', + type: 'string', + default: 'data', + displayOptions: { + show: showOnlyGetTemp, + }, + description: '将下载的文件存储到的二进制属性名称', + }, +]; + diff --git a/nodes/WeCom/resources/material/index.ts b/nodes/WeCom/resources/material/index.ts new file mode 100644 index 0000000..5eb8f08 --- /dev/null +++ b/nodes/WeCom/resources/material/index.ts @@ -0,0 +1,53 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { uploadTempDescription } from './uploadTemp'; +import { getTempDescription } from './getTemp'; +import { uploadPermanentDescription } from './uploadPermanent'; +import { getPermanentDescription } from './getPermanent'; + +const showOnlyForMaterial = { + resource: ['material'], +}; + +export const materialDescription: INodeProperties[] = [ + { + displayName: '操作', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: showOnlyForMaterial, + }, + options: [ + { + name: '上传临时素材', + value: 'uploadTemp', + action: '上传临时素材', + description: '上传临时素材(3天有效期)', + }, + { + name: '获取临时素材', + value: 'getTemp', + action: '获取临时素材', + description: '获取临时素材文件', + }, + { + name: '上传永久素材', + value: 'uploadPermanent', + action: '上传永久素材', + description: '上传永久素材', + }, + { + name: '获取永久素材', + value: 'getPermanent', + action: '获取永久素材', + description: '获取永久素材文件', + }, + ], + default: 'uploadTemp', + }, + ...uploadTempDescription, + ...getTempDescription, + ...uploadPermanentDescription, + ...getPermanentDescription, +]; + diff --git a/nodes/WeCom/resources/material/uploadPermanent.ts b/nodes/WeCom/resources/material/uploadPermanent.ts new file mode 100644 index 0000000..c2d1305 --- /dev/null +++ b/nodes/WeCom/resources/material/uploadPermanent.ts @@ -0,0 +1,50 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyUploadPermanent = { + resource: ['material'], + operation: ['uploadPermanent'], +}; + +export const uploadPermanentDescription: INodeProperties[] = [ + { + displayName: '素材类型', + name: 'type', + type: 'options', + options: [ + { + name: '图片', + value: 'image', + }, + { + name: '语音', + value: 'voice', + }, + { + name: '视频', + value: 'video', + }, + { + name: '文件', + value: 'file', + }, + ], + default: 'image', + required: true, + displayOptions: { + show: showOnlyUploadPermanent, + }, + description: '素材类型', + }, + { + displayName: '文件', + name: 'binaryProperty', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: showOnlyUploadPermanent, + }, + description: '要上传的二进制文件属性名称', + }, +]; + diff --git a/nodes/WeCom/resources/material/uploadTemp.ts b/nodes/WeCom/resources/material/uploadTemp.ts new file mode 100644 index 0000000..3af5454 --- /dev/null +++ b/nodes/WeCom/resources/material/uploadTemp.ts @@ -0,0 +1,50 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyUploadTemp = { + resource: ['material'], + operation: ['uploadTemp'], +}; + +export const uploadTempDescription: INodeProperties[] = [ + { + displayName: '素材类型', + name: 'type', + type: 'options', + options: [ + { + name: '图片', + value: 'image', + }, + { + name: '语音', + value: 'voice', + }, + { + name: '视频', + value: 'video', + }, + { + name: '文件', + value: 'file', + }, + ], + default: 'image', + required: true, + displayOptions: { + show: showOnlyUploadTemp, + }, + description: '素材类型', + }, + { + displayName: '文件', + name: 'binaryProperty', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: showOnlyUploadTemp, + }, + description: '要上传的二进制文件属性名称', + }, +]; + diff --git a/nodes/WeCom/resources/message/commonFields.ts b/nodes/WeCom/resources/message/commonFields.ts new file mode 100644 index 0000000..88baa91 --- /dev/null +++ b/nodes/WeCom/resources/message/commonFields.ts @@ -0,0 +1,176 @@ +import type { INodeProperties } from 'n8n-workflow'; + +/** + * 消息接收人通用字段定义 + * 可在所有消息发送操作中复用 + */ +export function getRecipientFields(operation: string): INodeProperties[] { + const showCondition = { + resource: ['message'], + operation: [operation], + }; + + return [ + { + displayName: '接收人类型', + name: 'recipientType', + type: 'options', + options: [ + { + name: '指定成员', + value: 'users', + }, + { + name: '指定部门', + value: 'departments', + }, + { + name: '指定标签', + value: 'tags', + }, + { + name: '全体成员', + value: 'all', + }, + { + name: '手动输入', + value: 'manual', + }, + ], + default: 'users', + displayOptions: { + show: showCondition, + }, + description: '选择接收人的方式', + }, + { + displayName: '成员', + name: 'touser', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getAllUsers', + }, + default: [], + displayOptions: { + show: { + ...showCondition, + recipientType: ['users'], + }, + }, + description: '成员ID列表(最多支持1000个)', + }, + { + displayName: '部门', + name: 'toparty', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getDepartments', + }, + default: [], + displayOptions: { + show: { + ...showCondition, + recipientType: ['departments'], + }, + }, + description: '部门ID列表(最多支持100个)', + }, + { + displayName: '标签', + name: 'totag', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + default: [], + displayOptions: { + show: { + ...showCondition, + recipientType: ['tags'], + }, + }, + description: '标签ID列表(最多支持100个)', + }, + { + displayName: '手动输入接收人', + name: 'touser_manual', + type: 'string', + default: '', + displayOptions: { + show: { + ...showCondition, + recipientType: ['manual'], + }, + }, + description: '成员ID列表(多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID(手动输入)', + name: 'toparty_manual', + type: 'string', + default: '', + displayOptions: { + show: { + ...showCondition, + recipientType: ['manual'], + }, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个', + }, + { + displayName: '标签ID(手动输入)', + name: 'totag_manual', + type: 'string', + default: '', + displayOptions: { + show: { + ...showCondition, + recipientType: ['manual'], + }, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个', + }, + ]; +} + +/** + * 从节点参数中提取接收人信息 + */ +export function extractRecipients( + recipientType: string, + touser: string | string[], + toparty: string | string[], + totag: string | string[], + touser_manual?: string, + toparty_manual?: string, + totag_manual?: string, +): { touser?: string; toparty?: string; totag?: string } { + if (recipientType === 'all') { + return { touser: '@all' }; + } + + if (recipientType === 'manual') { + return { + touser: touser_manual || undefined, + toparty: toparty_manual || undefined, + totag: totag_manual || undefined, + }; + } + + const result: { touser?: string; toparty?: string; totag?: string } = {}; + + if (recipientType === 'users' && Array.isArray(touser) && touser.length > 0) { + result.touser = touser.join('|'); + } + + if (recipientType === 'departments' && Array.isArray(toparty) && toparty.length > 0) { + result.toparty = toparty.join('|'); + } + + if (recipientType === 'tags' && Array.isArray(totag) && totag.length > 0) { + result.totag = totag.join('|'); + } + + return result; +} + diff --git a/nodes/WeCom/resources/message/execute.ts b/nodes/WeCom/resources/message/execute.ts new file mode 100644 index 0000000..1a57b2b --- /dev/null +++ b/nodes/WeCom/resources/message/execute.ts @@ -0,0 +1,865 @@ +import type { IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { weComApiRequest, uploadMedia } from '../../shared/transport'; +import { extractRecipients } from './commonFields'; + +export async function executeMessage( + this: IExecuteFunctions, + operation: string, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const credentials = await this.getCredentials('weComApi'); + const agentId = credentials.agentId as string; + + // 获取接收人信息(支持新旧两种方式) + let touser = ''; + let toparty = ''; + let totag = ''; + + // 检查是否使用新的接收人选择方式 + const recipientType = this.getNodeParameter('recipientType', i, null) as string | null; + + if (recipientType !== null) { + // 新方式:使用 recipientType 选择 + const touserArray = this.getNodeParameter('touser', i, []) as string[]; + const topartyArray = this.getNodeParameter('toparty', i, []) as string[]; + const totagArray = this.getNodeParameter('totag', i, []) as string[]; + const touserManual = this.getNodeParameter('touser_manual', i, '') as string; + const topartyManual = this.getNodeParameter('toparty_manual', i, '') as string; + const totagManual = this.getNodeParameter('totag_manual', i, '') as string; + + const recipients = extractRecipients( + recipientType, + touserArray, + topartyArray, + totagArray, + touserManual, + topartyManual, + totagManual, + ); + + touser = recipients.touser || ''; + toparty = recipients.toparty || ''; + totag = recipients.totag || ''; + } else { + // 旧方式:直接获取字段(向后兼容) + touser = this.getNodeParameter('touser', i, '') as string; + toparty = this.getNodeParameter('toparty', i, '') as string; + totag = this.getNodeParameter('totag', i, '') as string; + } + + if (!touser && !toparty && !totag) { + throw new NodeOperationError( + this.getNode(), + '必须指定至少一个接收人(成员ID、部门ID或标签ID)', + { itemIndex: i }, + ); + } + + let body: IDataObject = { + touser, + toparty, + totag, + agentid: agentId, + }; + + if (operation === 'sendText') { + const content = this.getNodeParameter('content', i) as string; + const safe = this.getNodeParameter('safe', i, false) as boolean; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + + body = { + ...body, + msgtype: 'text', + text: { + content, + }, + safe: safe ? 1 : 0, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'sendMarkdown') { + const content = this.getNodeParameter('content', i) as string; + + body = { + ...body, + msgtype: 'markdown', + markdown: { + content, + }, + }; + } else if (operation === 'sendImage') { + const imageSource = this.getNodeParameter('imageSource', i) as string; + let mediaId: string; + + if (imageSource === 'mediaId') { + mediaId = this.getNodeParameter('media_id', i) as string; + } else { + // 上传图片 + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + mediaId = await uploadMedia.call( + this, + 'image', + buffer, + binaryData.fileName || 'image.jpg', + ); + } + + body = { + ...body, + msgtype: 'image', + image: { + media_id: mediaId, + }, + }; + } else if (operation === 'sendFile') { + const fileSource = this.getNodeParameter('fileSource', i) as string; + let mediaId: string; + + if (fileSource === 'mediaId') { + mediaId = this.getNodeParameter('media_id', i) as string; + } else { + // 上传文件 + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + mediaId = await uploadMedia.call( + this, + 'file', + buffer, + binaryData.fileName || 'file.bin', + ); + } + + body = { + ...body, + msgtype: 'file', + file: { + media_id: mediaId, + }, + }; + } else if (operation === 'sendVoice') { + const voiceSource = this.getNodeParameter('voiceSource', i) as string; + const safe = this.getNodeParameter('safe', i, false) as boolean; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + let mediaId: string; + + if (voiceSource === 'mediaId') { + mediaId = this.getNodeParameter('media_id', i) as string; + } else { + // 上传语音 + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + mediaId = await uploadMedia.call( + this, + 'voice', + buffer, + binaryData.fileName || 'voice.amr', + ); + } + + body = { + ...body, + msgtype: 'voice', + voice: { + media_id: mediaId, + }, + safe: safe ? 1 : 0, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'sendVideo') { + const videoSource = this.getNodeParameter('videoSource', i) as string; + const title = this.getNodeParameter('title', i, '') as string; + const description = this.getNodeParameter('description', i, '') as string; + const safe = this.getNodeParameter('safe', i, false) as boolean; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + let mediaId: string; + + if (videoSource === 'mediaId') { + mediaId = this.getNodeParameter('media_id', i) as string; + } else { + // 上传视频 + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + + mediaId = await uploadMedia.call( + this, + 'video', + buffer, + binaryData.fileName || 'video.mp4', + ); + } + + body = { + ...body, + msgtype: 'video', + video: { + media_id: mediaId, + title, + description, + }, + safe: safe ? 1 : 0, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'sendTextCard') { + const title = this.getNodeParameter('title', i) as string; + const description = this.getNodeParameter('description', i) as string; + const url = this.getNodeParameter('url', i) as string; + const btntxt = this.getNodeParameter('btntxt', i, '详情') as string; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + + body = { + ...body, + msgtype: 'textcard', + textcard: { + title, + description, + url, + btntxt, + }, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'sendNews') { + const articles = this.getNodeParameter('articles', i, {}) as IDataObject; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + + const articleList = (articles.article as IDataObject[]) || []; + + body = { + ...body, + msgtype: 'news', + news: { + articles: articleList, + }, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'sendMpNews') { + const articles = this.getNodeParameter('articles', i, {}) as IDataObject; + const safe = this.getNodeParameter('safe', i, false) as boolean; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + + const articleList = (articles.article as IDataObject[]) || []; + + body = { + ...body, + msgtype: 'mpnews', + mpnews: { + articles: articleList, + }, + safe: safe ? 1 : 0, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'sendMiniprogramNotice') { + const appid = this.getNodeParameter('appid', i) as string; + const page = this.getNodeParameter('page', i, '') as string; + const title = this.getNodeParameter('title', i) as string; + const description = this.getNodeParameter('description', i, '') as string; + const emphasis_first_item = this.getNodeParameter( + 'emphasis_first_item', + i, + false, + ) as boolean; + const content_items = this.getNodeParameter('content_items', i, {}) as IDataObject; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + + const contentItemList = (content_items.item as IDataObject[]) || []; + + body = { + ...body, + msgtype: 'miniprogram_notice', + miniprogram_notice: { + appid, + page, + title, + description, + emphasis_first_item: emphasis_first_item ? true : false, + content_item: contentItemList, + }, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'sendTaskCard') { + const title = this.getNodeParameter('title', i) as string; + const description = this.getNodeParameter('description', i) as string; + const url = this.getNodeParameter('url', i, '') as string; + const task_id = this.getNodeParameter('task_id', i) as string; + const buttons = this.getNodeParameter('buttons', i, {}) as IDataObject; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + + const buttonList = ((buttons.button as IDataObject[]) || []).map((btn) => ({ + key: btn.key, + name: btn.name, + replace_name: btn.replace_name || '已处理', + color: btn.color || 'blue', + is_bold: btn.is_bold ? true : false, + })); + + body = { + ...body, + msgtype: 'taskcard', + taskcard: { + title, + description, + url, + task_id, + btn: buttonList, + }, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'sendTemplateCard') { + const card_type = this.getNodeParameter('card_type', i) as string; + const enable_id_trans = this.getNodeParameter('enable_id_trans', i, false) as boolean; + const enable_duplicate_check = this.getNodeParameter( + 'enable_duplicate_check', + i, + false, + ) as boolean; + + // 解析JSON字段 + const source = this.getNodeParameter('source', i, '{}') as string; + const main_title = this.getNodeParameter('main_title', i, '{"title": ""}') as string; + const emphasis_content = this.getNodeParameter('emphasis_content', i, '{}') as string; + const quote_area = this.getNodeParameter('quote_area', i, '{}') as string; + const sub_title_text = this.getNodeParameter('sub_title_text', i, '') as string; + const horizontal_content_list = this.getNodeParameter( + 'horizontal_content_list', + i, + '[]', + ) as string; + const jump_list = this.getNodeParameter('jump_list', i, '[]') as string; + const card_action = this.getNodeParameter('card_action', i, '{}') as string; + const task_id = this.getNodeParameter('task_id', i, '') as string; + const action_menu = this.getNodeParameter('action_menu', i, '{}') as string; + + const template_card: IDataObject = { + card_type, + }; + + // 添加source + try { + const sourceObj = JSON.parse(source); + if (Object.keys(sourceObj).length > 0) { + template_card.source = sourceObj; + } + } catch { + // 忽略解析错误 + } + + // 添加main_title + try { + template_card.main_title = JSON.parse(main_title); + } catch { + template_card.main_title = { title: '' }; + } + + // 添加emphasis_content + try { + const emphasisObj = JSON.parse(emphasis_content); + if (Object.keys(emphasisObj).length > 0) { + template_card.emphasis_content = emphasisObj; + } + } catch { + // 忽略解析错误 + } + + // 添加quote_area + try { + const quoteObj = JSON.parse(quote_area); + if (Object.keys(quoteObj).length > 0) { + template_card.quote_area = quoteObj; + } + } catch { + // 忽略解析错误 + } + + // 添加sub_title_text + if (sub_title_text) { + template_card.sub_title_text = sub_title_text; + } + + // 添加horizontal_content_list + try { + const horizontalList = JSON.parse(horizontal_content_list); + if (Array.isArray(horizontalList) && horizontalList.length > 0) { + template_card.horizontal_content_list = horizontalList; + } + } catch { + // 忽略解析错误 + } + + // 添加jump_list + try { + const jumpListObj = JSON.parse(jump_list); + if (Array.isArray(jumpListObj) && jumpListObj.length > 0) { + template_card.jump_list = jumpListObj; + } + } catch { + // 忽略解析错误 + } + + // 添加card_action + try { + const cardActionObj = JSON.parse(card_action); + if (Object.keys(cardActionObj).length > 0) { + template_card.card_action = cardActionObj; + } + } catch { + // 忽略解析错误 + } + + // 添加task_id + if (task_id) { + template_card.task_id = task_id; + } + + // 针对不同卡片类型的特殊处理 + if (card_type === 'button_interaction') { + const button_list = this.getNodeParameter('button_list', i, '[]') as string; + try { + const buttonListObj = JSON.parse(button_list); + if (Array.isArray(buttonListObj) && buttonListObj.length > 0) { + template_card.button_list = buttonListObj; + } + } catch (error) { + // 忽略解析错误 + } + } else if ( + card_type === 'vote_interaction' || + card_type === 'multiple_interaction' + ) { + const checkbox_question_key = this.getNodeParameter( + 'checkbox_question_key', + i, + '', + ) as string; + const checkbox_mode = this.getNodeParameter( + 'checkbox_mode', + i, + 'single', + ) as string; + const option_list = this.getNodeParameter('option_list', i, '[]') as string; + const submit_button_text = this.getNodeParameter( + 'submit_button_text', + i, + '提交', + ) as string; + const submit_button_key = this.getNodeParameter('submit_button_key', i) as string; + + if (checkbox_question_key) { + template_card.checkbox = { + question_key: checkbox_question_key, + mode: checkbox_mode, + option_list: JSON.parse(option_list), + }; + } + + template_card.submit_button = { + text: submit_button_text, + key: submit_button_key, + }; + } else if (card_type === 'news_notice') { + const image_text_area = this.getNodeParameter('image_text_area', i, '{}') as string; + try { + const imageTextObj = JSON.parse(image_text_area); + if (Object.keys(imageTextObj).length > 0) { + template_card.image_text_area = imageTextObj; + } + } catch (error) { + // 忽略解析错误 + } + } + + // 添加action_menu + try { + const actionMenuObj = JSON.parse(action_menu); + if (Object.keys(actionMenuObj).length > 0) { + template_card.action_menu = actionMenuObj; + } + } catch { + // 忽略解析错误 + } + + body = { + ...body, + msgtype: 'template_card', + template_card, + enable_id_trans: enable_id_trans ? 1 : 0, + enable_duplicate_check: enable_duplicate_check ? 1 : 0, + }; + + if (enable_duplicate_check) { + const duplicate_check_interval = this.getNodeParameter( + 'duplicate_check_interval', + i, + 1800, + ) as number; + body.duplicate_check_interval = duplicate_check_interval; + } + } else if (operation === 'updateTemplateCard') { + const response_code = this.getNodeParameter('response_code', i) as string; + const card_type = this.getNodeParameter('card_type', i) as string; + const button_key = this.getNodeParameter('button_key', i, '') as string; + + // 解析JSON字段 + const source = this.getNodeParameter('source', i, '{}') as string; + const main_title = this.getNodeParameter('main_title', i, '{"title": ""}') as string; + const emphasis_content = this.getNodeParameter('emphasis_content', i, '{}') as string; + const quote_area = this.getNodeParameter('quote_area', i, '{}') as string; + const sub_title_text = this.getNodeParameter('sub_title_text', i, '') as string; + const horizontal_content_list = this.getNodeParameter( + 'horizontal_content_list', + i, + '[]', + ) as string; + const jump_list = this.getNodeParameter('jump_list', i, '[]') as string; + const card_action = this.getNodeParameter('card_action', i, '{}') as string; + const task_id = this.getNodeParameter('task_id', i, '') as string; + const action_menu = this.getNodeParameter('action_menu', i, '{}') as string; + + const template_card: IDataObject = { + card_type, + }; + + // 添加source + try { + const sourceObj = JSON.parse(source); + if (Object.keys(sourceObj).length > 0) { + template_card.source = sourceObj; + } + } catch { + // 忽略解析错误 + } + + // 添加main_title + try { + template_card.main_title = JSON.parse(main_title); + } catch { + template_card.main_title = { title: '' }; + } + + // 添加emphasis_content + try { + const emphasisObj = JSON.parse(emphasis_content); + if (Object.keys(emphasisObj).length > 0) { + template_card.emphasis_content = emphasisObj; + } + } catch { + // 忽略解析错误 + } + + // 添加quote_area + try { + const quoteObj = JSON.parse(quote_area); + if (Object.keys(quoteObj).length > 0) { + template_card.quote_area = quoteObj; + } + } catch { + // 忽略解析错误 + } + + // 添加sub_title_text + if (sub_title_text) { + template_card.sub_title_text = sub_title_text; + } + + // 添加horizontal_content_list + try { + const horizontalList = JSON.parse(horizontal_content_list); + if (Array.isArray(horizontalList) && horizontalList.length > 0) { + template_card.horizontal_content_list = horizontalList; + } + } catch { + // 忽略解析错误 + } + + // 添加jump_list + try { + const jumpListObj = JSON.parse(jump_list); + if (Array.isArray(jumpListObj) && jumpListObj.length > 0) { + template_card.jump_list = jumpListObj; + } + } catch { + // 忽略解析错误 + } + + // 添加card_action + try { + const cardActionObj = JSON.parse(card_action); + if (Object.keys(cardActionObj).length > 0) { + template_card.card_action = cardActionObj; + } + } catch { + // 忽略解析错误 + } + + // 添加task_id + if (task_id) { + template_card.task_id = task_id; + } + + // 针对不同卡片类型的特殊处理 + if (card_type === 'button_interaction') { + const button_list = this.getNodeParameter('button_list', i, '[]') as string; + try { + const buttonListObj = JSON.parse(button_list); + if (Array.isArray(buttonListObj) && buttonListObj.length > 0) { + template_card.button_list = buttonListObj; + } + } catch (error) { + // 忽略解析错误 + } + } else if ( + card_type === 'vote_interaction' || + card_type === 'multiple_interaction' + ) { + const checkbox_question_key = this.getNodeParameter( + 'checkbox_question_key', + i, + '', + ) as string; + const checkbox_mode = this.getNodeParameter( + 'checkbox_mode', + i, + 'single', + ) as string; + const option_list = this.getNodeParameter('option_list', i, '[]') as string; + const submit_button_text = this.getNodeParameter( + 'submit_button_text', + i, + '提交', + ) as string; + const submit_button_key = this.getNodeParameter('submit_button_key', i) as string; + + if (checkbox_question_key) { + template_card.checkbox = { + question_key: checkbox_question_key, + mode: checkbox_mode, + option_list: JSON.parse(option_list), + }; + } + + template_card.submit_button = { + text: submit_button_text, + key: submit_button_key, + }; + } else if (card_type === 'news_notice') { + const image_text_area = this.getNodeParameter('image_text_area', i, '{}') as string; + try { + const imageTextObj = JSON.parse(image_text_area); + if (Object.keys(imageTextObj).length > 0) { + template_card.image_text_area = imageTextObj; + } + } catch (error) { + // 忽略解析错误 + } + } + + // 添加action_menu + try { + const actionMenuObj = JSON.parse(action_menu); + if (Object.keys(actionMenuObj).length > 0) { + template_card.action_menu = actionMenuObj; + } + } catch { + // 忽略解析错误 + } + + // 构建更新请求body + body = { + ...body, + response_code, + template_card, + }; + + // 添加button_key(如果有) + if (button_key) { + body.button_key = button_key; + } + + // 使用更新接口 + const response = await weComApiRequest.call( + this, + 'POST', + '/cgi-bin/message/update_template_card', + body, + ); + + returnData.push({ + json: response as IDataObject, + pairedItem: { item: i }, + }); + continue; + } else if (operation === 'recallMessage') { + const msgid = this.getNodeParameter('msgid', i) as string; + + const recallBody = { + msgid, + }; + + // 使用撤回消息接口 + const response = await weComApiRequest.call( + this, + 'POST', + '/cgi-bin/message/recall', + recallBody, + ); + + returnData.push({ + json: response as IDataObject, + pairedItem: { item: i }, + }); + continue; + } + + const response = await weComApiRequest.call(this, 'POST', '/cgi-bin/message/send', body); + + returnData.push({ + json: response as IDataObject, + pairedItem: { item: i }, + }); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { + error: error.message, + }, + pairedItem: { item: i }, + }); + continue; + } + throw error; + } + } + + return returnData; +} + diff --git a/nodes/WeCom/resources/message/index.ts b/nodes/WeCom/resources/message/index.ts new file mode 100644 index 0000000..12ac7ac --- /dev/null +++ b/nodes/WeCom/resources/message/index.ts @@ -0,0 +1,133 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { sendTextDescription } from './sendText'; +import { sendMarkdownDescription } from './sendMarkdown'; +import { sendImageDescription } from './sendImage'; +import { sendFileDescription } from './sendFile'; +import { sendVoiceDescription } from './sendVoice'; +import { sendVideoDescription } from './sendVideo'; +import { sendTextCardDescription } from './sendTextCard'; +import { sendNewsDescription } from './sendNews'; +import { sendMpNewsDescription } from './sendMpNews'; +import { sendMiniprogramNoticeDescription } from './sendMiniprogramNotice'; +import { sendTaskCardDescription } from './sendTaskCard'; +import { sendTemplateCardDescription } from './sendTemplateCard'; +import { updateTemplateCardDescription } from './updateTemplateCard'; +import { recallMessageDescription } from './recallMessage'; + +const showOnlyForMessage = { + resource: ['message'], +}; + +export const messageDescription: INodeProperties[] = [ + { + displayName: '操作', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: showOnlyForMessage, + }, + options: [ + { + name: '发送 Markdown 消息', + value: 'sendMarkdown', + action: '发送 Markdown 消息', + description: '发送 Markdown 格式的消息', + }, + { + name: '发送任务卡片消息', + value: 'sendTaskCard', + action: '发送任务卡片消息', + description: '发送任务卡片类型的消息', + }, + { + name: '发送图文消息', + value: 'sendNews', + action: '发送图文消息', + description: '发送图文消息(news类型)', + }, + { + name: '发送图文消息(Mpnews)', + value: 'sendMpNews', + action: 'Mpnews', + description: '发送图文消息(mpnews类型)', + }, + { + name: '发送图片消息', + value: 'sendImage', + action: '发送图片消息', + description: '发送图片类型的消息', + }, + { + name: '发送小程序通知消息', + value: 'sendMiniprogramNotice', + action: '发送小程序通知消息', + description: '发送小程序通知类型的消息', + }, + { + name: '发送文件消息', + value: 'sendFile', + action: '发送文件消息', + description: '发送文件类型的消息', + }, + { + name: '发送文本卡片消息', + value: 'sendTextCard', + action: '发送文本卡片消息', + description: '发送文本卡片类型的消息', + }, + { + name: '发送文本消息', + value: 'sendText', + action: '发送文本消息', + description: '发送文本类型的消息', + }, + { + name: '发送模板卡片消息', + value: 'sendTemplateCard', + action: '发送模板卡片消息', + description: '发送模板卡片类型的消息', + }, + { + name: '发送视频消息', + value: 'sendVideo', + action: '发送视频消息', + description: '发送视频类型的消息', + }, + { + name: '发送语音消息', + value: 'sendVoice', + action: '发送语音消息', + description: '发送语音类型的消息', + }, + { + name: '撤回应用消息', + value: 'recallMessage', + action: '撤回应用消息', + description: '撤回已发送的应用消息', + }, + { + name: '更新模板卡片消息', + value: 'updateTemplateCard', + action: '更新模板卡片消息', + description: '更新已发送的模板卡片消息内容', + }, + ], + default: 'sendText', + }, + ...sendTextDescription, + ...sendMarkdownDescription, + ...sendImageDescription, + ...sendVoiceDescription, + ...sendVideoDescription, + ...sendFileDescription, + ...sendTextCardDescription, + ...sendNewsDescription, + ...sendMpNewsDescription, + ...sendMiniprogramNoticeDescription, + ...sendTaskCardDescription, + ...sendTemplateCardDescription, + ...recallMessageDescription, + ...updateTemplateCardDescription, +]; + diff --git a/nodes/WeCom/resources/message/recallMessage.ts b/nodes/WeCom/resources/message/recallMessage.ts new file mode 100644 index 0000000..b608213 --- /dev/null +++ b/nodes/WeCom/resources/message/recallMessage.ts @@ -0,0 +1,21 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyRecallMessage = { + resource: ['message'], + operation: ['recallMessage'], +}; + +export const recallMessageDescription: INodeProperties[] = [ + { + displayName: 'Msgid', + name: 'msgid', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlyRecallMessage, + }, + description: '消息 ID。从发送消息接口返回的 msgid', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendFile.ts b/nodes/WeCom/resources/message/sendFile.ts new file mode 100644 index 0000000..ac5c9da --- /dev/null +++ b/nodes/WeCom/resources/message/sendFile.ts @@ -0,0 +1,62 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendFile = { + resource: ['message'], + operation: ['sendFile'], +}; + +export const sendFileDescription: INodeProperties[] = [ + ...getRecipientFields('sendFile'), + { + displayName: '文件来源', + name: 'fileSource', + type: 'options', + required: true, + default: 'mediaId', + displayOptions: { + show: showOnlySendFile, + }, + options: [ + { + name: 'Media ID', + value: 'mediaId', + description: '使用已上传的 Media ID', + }, + { + name: '上传文件', + value: 'upload', + description: '从输入数据中上传文件', + }, + ], + }, + { + displayName: 'Media ID', + name: 'media_id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + ...showOnlySendFile, + fileSource: ['mediaId'], + }, + }, + description: '文件ID,可以调用上传临时素材接口获取', + }, + { + displayName: '输入数据字段名', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + ...showOnlySendFile, + fileSource: ['upload'], + }, + }, + description: '包含文件数据的二进制属性名称', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendImage.ts b/nodes/WeCom/resources/message/sendImage.ts new file mode 100644 index 0000000..89adc8a --- /dev/null +++ b/nodes/WeCom/resources/message/sendImage.ts @@ -0,0 +1,62 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendImage = { + resource: ['message'], + operation: ['sendImage'], +}; + +export const sendImageDescription: INodeProperties[] = [ + ...getRecipientFields('sendImage'), + { + displayName: '图片来源', + name: 'imageSource', + type: 'options', + required: true, + default: 'mediaId', + displayOptions: { + show: showOnlySendImage, + }, + options: [ + { + name: 'Media ID', + value: 'mediaId', + description: '使用已上传的 Media ID', + }, + { + name: '上传文件', + value: 'upload', + description: '从输入数据中上传图片文件', + }, + ], + }, + { + displayName: 'Media ID', + name: 'media_id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + ...showOnlySendImage, + imageSource: ['mediaId'], + }, + }, + description: '图片媒体文件ID,可以调用上传临时素材接口获取', + }, + { + displayName: '输入数据字段名', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + ...showOnlySendImage, + imageSource: ['upload'], + }, + }, + description: '包含图片文件数据的二进制属性名称', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendMarkdown.ts b/nodes/WeCom/resources/message/sendMarkdown.ts new file mode 100644 index 0000000..b81361c --- /dev/null +++ b/nodes/WeCom/resources/message/sendMarkdown.ts @@ -0,0 +1,26 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendMarkdown = { + resource: ['message'], + operation: ['sendMarkdown'], +}; + +export const sendMarkdownDescription: INodeProperties[] = [ + ...getRecipientFields('sendMarkdown'), + { + displayName: 'Markdown 内容', + name: 'content', + type: 'string', + typeOptions: { + rows: 6, + }, + required: true, + default: '', + displayOptions: { + show: showOnlySendMarkdown, + }, + description: 'Markdown 格式的消息内容,最长不超过2048个字节,必须是utf8编码', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendMiniprogramNotice.ts b/nodes/WeCom/resources/message/sendMiniprogramNotice.ts new file mode 100644 index 0000000..f510487 --- /dev/null +++ b/nodes/WeCom/resources/message/sendMiniprogramNotice.ts @@ -0,0 +1,138 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendMiniprogramNotice = { + resource: ['message'], + operation: ['sendMiniprogramNotice'], +}; + +export const sendMiniprogramNoticeDescription: INodeProperties[] = [ + ...getRecipientFields('sendMiniprogramNotice'), + { + displayName: '小程序Appid', + name: 'appid', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: '小程序appid,必须是与当前应用关联的小程序', + }, + { + displayName: '小程序Page路径', + name: 'page', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: '点击消息卡片后的小程序页面,最长1024字节,仅限本小程序内的页面。该字段不填则消息点击后不跳转。', + }, + { + displayName: '消息标题', + name: 'title', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: '消息标题,长度限制4-12个汉字(支持ID转译)', + }, + { + displayName: '消息描述', + name: 'description', + type: 'string', + typeOptions: { + rows: 2, + }, + default: '', + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: '消息描述,长度限制4-12个汉字(支持ID转译)', + }, + { + displayName: '是否放大第一个Content_item', + name: 'emphasis_first_item', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: 'Whether to emphasize the first content item', + }, + { + displayName: '消息内容键值对', + name: 'content_items', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + required: true, + default: {}, + placeholder: '添加内容项', + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: '消息内容键值对,最多允许10个item', + options: [ + { + displayName: '内容项', + name: 'item', + values: [ + { + displayName: '长度10个汉字以内', + name: 'key', + type: 'string', + required: true, + default: '', + }, + { + displayName: '长度30个汉字以内', + name: 'value', + type: 'string', + required: true, + default: '', + description: '长度30个汉字以内(支持ID转译)', + }, + ], + }, + ], + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendMiniprogramNotice, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendMpNews.ts b/nodes/WeCom/resources/message/sendMpNews.ts new file mode 100644 index 0000000..355a24c --- /dev/null +++ b/nodes/WeCom/resources/message/sendMpNews.ts @@ -0,0 +1,122 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendMpNews = { + resource: ['message'], + operation: ['sendMpNews'], +}; + +export const sendMpNewsDescription: INodeProperties[] = [ + ...getRecipientFields('sendMpNews'), + { + displayName: '图文列表', + name: 'articles', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + required: true, + default: {}, + placeholder: '添加图文', + displayOptions: { + show: showOnlySendMpNews, + }, + description: 'Mpnews类型的图文消息,一个图文消息支持1到8条图文', + options: [ + { + displayName: '图文', + name: 'article', + values: [ + { + displayName: '作者', + name: 'author', + type: 'string', + default: '', + description: '图文消息的作者,不超过64个字节', + }, + { + displayName: '图文消息点击\'阅读原文\'之后的页面链接', + name: 'content_source_url', + type: 'string', + default: '', + }, + { + displayName: '图文消息的内容', + name: 'content', + type: 'string', + required: true, + default: '', + description: '图文消息的内容,支持html标签,不超过666 K个字节', + }, + { + displayName: '图文消息的描述', + name: 'digest', + type: 'string', + default: '', + description: '图文消息的描述,不超过512个字节,超过会自动截断', + }, + { + displayName: '图文消息缩略图的Media_id', + name: 'thumb_media_id', + type: 'string', + required: true, + default: '', + description: '图文消息缩略图的media_id,可以在上传多媒体文件接口中获得。此处thumb_media_id即上传接口返回的media_id', + }, + { + displayName: '标题', + name: 'title', + type: 'string', + required: true, + default: '', + description: '标题,不超过128个字节,超过会自动截断', + }, + ], + }, + ], + }, + { + displayName: '安全保密消息', + name: 'safe', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendMpNews, + }, + description: 'Whether this is a confidential message. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendMpNews, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendMpNews, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendMpNews, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendNews.ts b/nodes/WeCom/resources/message/sendNews.ts new file mode 100644 index 0000000..bfafe3c --- /dev/null +++ b/nodes/WeCom/resources/message/sendNews.ts @@ -0,0 +1,101 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendNews = { + resource: ['message'], + operation: ['sendNews'], +}; + +export const sendNewsDescription: INodeProperties[] = [ + ...getRecipientFields('sendNews'), + { + displayName: '图文列表', + name: 'articles', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + required: true, + default: {}, + placeholder: '添加图文', + displayOptions: { + show: showOnlySendNews, + }, + description: '图文消息,一个图文消息支持1到8条图文', + options: [ + { + displayName: '图文', + name: 'article', + values: [ + { + displayName: '标题', + name: 'title', + type: 'string', + required: true, + default: '', + description: '标题,不超过128个字节,超过会自动截断', + }, + { + displayName: '描述', + name: 'description', + type: 'string', + typeOptions: { + rows: 2, + }, + default: '', + description: '描述,不超过512个字节,超过会自动截断', + }, + { + displayName: '跳转链接', + name: 'url', + type: 'string', + required: true, + default: '', + description: '点击后跳转的链接', + }, + { + displayName: '图片链接', + name: 'picurl', + type: 'string', + default: '', + description: '图文消息的图片链接,支持JPG、PNG格式,较好的效果为大图 1068*455,小图150*150', + }, + ], + }, + ], + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendNews, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendNews, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendNews, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendTaskCard.ts b/nodes/WeCom/resources/message/sendTaskCard.ts new file mode 100644 index 0000000..45b48b4 --- /dev/null +++ b/nodes/WeCom/resources/message/sendTaskCard.ts @@ -0,0 +1,160 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendTaskCard = { + resource: ['message'], + operation: ['sendTaskCard'], +}; + +export const sendTaskCardDescription: INodeProperties[] = [ + ...getRecipientFields('sendTaskCard'), + { + displayName: '标题', + name: 'title', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlySendTaskCard, + }, + description: '标题,不超过128个字节,超过会自动截断(支持ID转译)', + }, + { + displayName: '描述', + name: 'description', + type: 'string', + typeOptions: { + rows: 4, + }, + required: true, + default: '', + displayOptions: { + show: showOnlySendTaskCard, + }, + description: '描述,不超过512个字节,超过会自动截断(支持ID转译)', + }, + { + displayName: '跳转链接', + name: 'url', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTaskCard, + }, + description: '点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https),小程序或者URL必须填写一个', + }, + { + displayName: '任务ID', + name: 'task_id', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlySendTaskCard, + }, + description: '任务 ID,同一个应用发送的任务卡片消息的任务id不能重复,只能由数字、字母和"_-@"组成,最长支持128字节', + }, + { + displayName: '按钮列表', + name: 'buttons', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + required: true, + default: {}, + placeholder: '添加按钮', + displayOptions: { + show: showOnlySendTaskCard, + }, + description: '按钮列表,按钮个数为1~2个', + options: [ + { + displayName: '按钮', + name: 'button', + values: [ + { + displayName: '按钮Key值', + name: 'key', + type: 'string', + required: true, + default: '', + description: '按钮key值,用户点击后,会产生任务卡片回调事件,回调事件会将本参数作为EventKey返回,只能由数字、字母和\'_-@\'组成,最长支持128字节', + }, + { + displayName: '按钮名称', + name: 'name', + type: 'string', + required: true, + default: '', + }, + { + displayName: '按钮字体是否加粗', + name: 'is_bold', + type: 'boolean', + default: false, + description: 'Whether the button font is bold, default is false', + }, + { + displayName: '按钮字体颜色', + name: 'color', + type: 'options', + options: [ + { + name: '红色', + value: 'red', + }, + { + name: '蓝色', + value: 'blue', + }, + ], + default: 'blue', + description: '按钮字体颜色,可选red或者blue,默认为blue', + }, + { + displayName: '点击按钮后替换文案', + name: 'replace_name', + type: 'string', + default: '', + description: '点击按钮后显示的名称,默认为\'已处理\'', + }, + ], + }, + ], + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendTaskCard, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendTaskCard, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendTaskCard, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendTemplateCard.ts b/nodes/WeCom/resources/message/sendTemplateCard.ts new file mode 100644 index 0000000..46a1808 --- /dev/null +++ b/nodes/WeCom/resources/message/sendTemplateCard.ts @@ -0,0 +1,298 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendTemplateCard = { + resource: ['message'], + operation: ['sendTemplateCard'], +}; + +export const sendTemplateCardDescription: INodeProperties[] = [ + ...getRecipientFields('sendTemplateCard'), + { + displayName: '模板卡片类型', + name: 'card_type', + type: 'options', + options: [ + { + name: '图文展示型', + value: 'news_notice', + description: '图文展示型模板卡片', + }, + { + name: '多项选择型', + value: 'multiple_interaction', + description: '多项选择型模板卡片', + }, + { + name: '投票选择型', + value: 'vote_interaction', + description: '投票选择型模板卡片', + }, + { + name: '按钮交互型', + value: 'button_interaction', + description: '按钮交互型模板卡片', + }, + { + name: '文本通知型', + value: 'text_notice', + description: '文本通知型模板卡片', + }, + ], + required: true, + default: 'text_notice', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '选择模板卡片的类型', + }, + { + displayName: '模板卡片来源样式信息', + name: 'source', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '模板卡片来源样式信息,不需要来源样式可不填写,JSON格式。参考文档:https://developer.work.weixin.qq.com/document/path/90236#%E6%A8%A1%E6%9D%BF%E5%8D%A1%E7%89%87%E6%B6%88%E6%81%AF', + }, + { + displayName: '一级标题', + name: 'main_title', + type: 'json', + default: '{"title": ""}', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '模板卡片的主要内容,包含一级标题和标题辅助信息,JSON格式', + }, + { + displayName: '关键数据样式', + name: 'emphasis_content', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '关键数据样式,JSON格式', + }, + { + displayName: '引用文献样式', + name: 'quote_area', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '引用文献样式,建议不与关键数据共用,JSON格式', + }, + { + displayName: '二级普通文本', + name: 'sub_title_text', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '二级普通文本,建议不超过160个字,(支持ID转译)', + }, + { + displayName: '二级标题+文本列表', + name: 'horizontal_content_list', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '[]', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '二级标题+文本列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过6,JSON数组格式', + }, + { + displayName: '跳转指引样式的列表', + name: 'jump_list', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '[]', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '跳转指引样式的列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过3,JSON数组格式', + }, + { + displayName: '整体卡片的点击跳转事件', + name: 'card_action', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '整体卡片的点击跳转事件,text_notice必填,news_notice不需要,JSON格式', + }, + { + displayName: '任务ID', + name: 'task_id', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '任务 ID,同一个应用任务id不能重复,只能由数字、字母和"_-@"组成,最长128字节,填了action_menu字段的话本字段必填', + }, + { + displayName: '按钮列表', + name: 'button_list', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '[]', + displayOptions: { + show: { + ...showOnlySendTemplateCard, + card_type: ['button_interaction'], + }, + }, + description: '按钮交互型卡片的按钮列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过6,JSON数组格式', + }, + { + displayName: '选择题Key值', + name: 'checkbox_question_key', + type: 'string', + default: '', + displayOptions: { + show: { + ...showOnlySendTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + description: '选择题key值,用户提交选项后,会产生回调事件,回调事件会将本参数作为EventKey返回,最长支持1024字节', + }, + { + displayName: '选择题模式', + name: 'checkbox_mode', + type: 'options', + options: [ + { + name: '单选', + value: 'single', + }, + { + name: '多选', + value: 'multiple', + }, + ], + default: 'single', + displayOptions: { + show: { + ...showOnlySendTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + description: '选择题模式,单选为single,多选为multiple,不填默认单选', + }, + { + displayName: '选项列表', + name: 'option_list', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '[]', + displayOptions: { + show: { + ...showOnlySendTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + required: true, + description: '选项列表,列表长度不超过10,JSON数组格式。每个选项包含ID和text字段', + }, + { + displayName: '提交按钮文案', + name: 'submit_button_text', + type: 'string', + default: '提交', + displayOptions: { + show: { + ...showOnlySendTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + description: '提交按钮文案,建议不超过10个字,不填默认为提交', + }, + { + displayName: '提交按钮Key值', + name: 'submit_button_key', + type: 'string', + default: '', + displayOptions: { + show: { + ...showOnlySendTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + required: true, + description: '提交按钮key值,用户提交选项后,会产生回调事件,回调事件会将本参数作为EventKey返回,最长支持1024字节', + }, + { + displayName: '图片样式', + name: 'image_text_area', + type: 'json', + default: '{}', + displayOptions: { + show: { + ...showOnlySendTemplateCard, + card_type: ['news_notice'], + }, + }, + description: '左图右文样式,JSON格式', + }, + { + displayName: '卡片右上角更多操作按钮', + name: 'action_menu', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '卡片右上角更多操作按钮,JSON格式', + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendTemplateCard, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendText.ts b/nodes/WeCom/resources/message/sendText.ts new file mode 100644 index 0000000..bc629aa --- /dev/null +++ b/nodes/WeCom/resources/message/sendText.ts @@ -0,0 +1,69 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendText = { + resource: ['message'], + operation: ['sendText'], +}; + +export const sendTextDescription: INodeProperties[] = [ + ...getRecipientFields('sendText'), + { + displayName: '消息内容', + name: 'content', + type: 'string', + typeOptions: { + rows: 4, + }, + required: true, + default: '', + displayOptions: { + show: showOnlySendText, + }, + description: '消息内容,最长不超过2048个字节', + }, + { + displayName: '安全保密消息', + name: 'safe', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendText, + }, + description: 'Whether this is a confidential message. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendText, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendText, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendText, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendTextCard.ts b/nodes/WeCom/resources/message/sendTextCard.ts new file mode 100644 index 0000000..8cf8ad0 --- /dev/null +++ b/nodes/WeCom/resources/message/sendTextCard.ts @@ -0,0 +1,91 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendTextCard = { + resource: ['message'], + operation: ['sendTextCard'], +}; + +export const sendTextCardDescription: INodeProperties[] = [ + ...getRecipientFields('sendTextCard'), + { + displayName: '卡片标题', + name: 'title', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlySendTextCard, + }, + description: '标题,不超过128个字节,超过会自动截断', + }, + { + displayName: '卡片描述', + name: 'description', + type: 'string', + typeOptions: { + rows: 4, + }, + required: true, + default: '', + displayOptions: { + show: showOnlySendTextCard, + }, + description: '描述,不超过512个字节,超过会自动截断', + }, + { + displayName: '跳转链接', + name: 'url', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlySendTextCard, + }, + description: '点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https)', + }, + { + displayName: '按钮文字', + name: 'btntxt', + type: 'string', + default: '详情', + displayOptions: { + show: showOnlySendTextCard, + }, + description: '按钮文字。默认为"详情",不超过4个文字,超过自动截断', + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendTextCard, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendTextCard, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendTextCard, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendVideo.ts b/nodes/WeCom/resources/message/sendVideo.ts new file mode 100644 index 0000000..cf75459 --- /dev/null +++ b/nodes/WeCom/resources/message/sendVideo.ts @@ -0,0 +1,126 @@ +import type { INodeProperties} from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendVideo = { + resource: ['message'], + operation: ['sendVideo'], +}; + +export const sendVideoDescription: INodeProperties[] = [ + ...getRecipientFields('sendVideo'), + { + displayName: '视频来源', + name: 'videoSource', + type: 'options', + options: [ + { + name: '使用已有Media ID', + value: 'mediaId', + }, + { + name: '上传视频文件', + value: 'upload', + }, + ], + default: 'upload', + displayOptions: { + show: showOnlySendVideo, + }, + description: '选择视频来源方式', + }, + { + displayName: 'Media ID', + name: 'media_id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + ...showOnlySendVideo, + videoSource: ['mediaId'], + }, + }, + description: '视频文件的media_id,可以调用上传临时素材接口获取', + }, + { + displayName: '二进制属性名', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + ...showOnlySendVideo, + videoSource: ['upload'], + }, + }, + description: '包含视频文件的输入字段名称', + }, + { + displayName: '视频标题', + name: 'title', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendVideo, + }, + description: '视频消息的标题,不超过128个字节,超过会自动截断', + }, + { + displayName: '视频描述', + name: 'description', + type: 'string', + typeOptions: { + rows: 2, + }, + default: '', + displayOptions: { + show: showOnlySendVideo, + }, + description: '视频消息的描述,不超过512个字节,超过会自动截断', + }, + { + displayName: '安全保密消息', + name: 'safe', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendVideo, + }, + description: 'Whether this is a confidential message. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendVideo, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendVideo, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendVideo, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/sendVoice.ts b/nodes/WeCom/resources/message/sendVoice.ts new file mode 100644 index 0000000..e7a4939 --- /dev/null +++ b/nodes/WeCom/resources/message/sendVoice.ts @@ -0,0 +1,103 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlySendVoice = { + resource: ['message'], + operation: ['sendVoice'], +}; + +export const sendVoiceDescription: INodeProperties[] = [ + ...getRecipientFields('sendVoice'), + { + displayName: '语音来源', + name: 'voiceSource', + type: 'options', + options: [ + { + name: '使用已有Media ID', + value: 'mediaId', + }, + { + name: '上传语音文件', + value: 'upload', + }, + ], + default: 'upload', + displayOptions: { + show: showOnlySendVoice, + }, + description: '选择语音来源方式', + }, + { + displayName: 'Media ID', + name: 'media_id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + ...showOnlySendVoice, + voiceSource: ['mediaId'], + }, + }, + description: '语音文件的media_id,可以调用上传临时素材接口获取', + }, + { + displayName: '二进制属性名', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + ...showOnlySendVoice, + voiceSource: ['upload'], + }, + }, + description: '包含语音文件的输入字段名称', + }, + { + displayName: '安全保密消息', + name: 'safe', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendVoice, + }, + description: 'Whether this is a confidential message. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendVoice, + }, + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendVoice, + }, + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', + }, + { + displayName: '重复消息检查时间', + name: 'duplicate_check_interval', + type: 'number', + default: 1800, + displayOptions: { + show: { + ...showOnlySendVoice, + enable_duplicate_check: [true], + }, + }, + description: '表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时', + }, +]; + diff --git a/nodes/WeCom/resources/message/updateTemplateCard.ts b/nodes/WeCom/resources/message/updateTemplateCard.ts new file mode 100644 index 0000000..0612987 --- /dev/null +++ b/nodes/WeCom/resources/message/updateTemplateCard.ts @@ -0,0 +1,286 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; + +const showOnlyUpdateTemplateCard = { + resource: ['message'], + operation: ['updateTemplateCard'], +}; + +export const updateTemplateCardDescription: INodeProperties[] = [ + ...getRecipientFields('updateTemplateCard'), + { + displayName: '原消息的Response_code', + name: 'response_code', + type: 'string', + required: true, + default: '', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '更新卡片所需要消费的code,可通过发消息接口及回调接口返回值获取,一个code只能调用一次更新接口,且只能在24小时内更新', + }, + { + displayName: '模板卡片类型', + name: 'card_type', + type: 'options', + options: [ + { + name: '图文展示型', + value: 'news_notice', + description: '图文展示型模板卡片', + }, + { + name: '多项选择型', + value: 'multiple_interaction', + description: '多项选择型模板卡片', + }, + { + name: '投票选择型', + value: 'vote_interaction', + description: '投票选择型模板卡片', + }, + { + name: '按钮交互型', + value: 'button_interaction', + description: '按钮交互型模板卡片', + }, + { + name: '文本通知型', + value: 'text_notice', + description: '文本通知型模板卡片', + }, + ], + required: true, + default: 'text_notice', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '选择模板卡片的类型', + }, + { + displayName: '模板卡片来源样式信息', + name: 'source', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '模板卡片来源样式信息,不需要来源样式可不填写,JSON格式。参考文档:https://developer.work.weixin.qq.com/document/path/94888', + }, + { + displayName: '一级标题', + name: 'main_title', + type: 'json', + default: '{"title": ""}', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '模板卡片的主要内容,包含一级标题和标题辅助信息,JSON格式', + }, + { + displayName: '关键数据样式', + name: 'emphasis_content', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '关键数据样式,JSON格式', + }, + { + displayName: '引用文献样式', + name: 'quote_area', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '引用文献样式,建议不与关键数据共用,JSON格式', + }, + { + displayName: '二级普通文本', + name: 'sub_title_text', + type: 'string', + default: '', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '二级普通文本,建议不超过160个字,(支持ID转译)', + }, + { + displayName: '二级标题+文本列表', + name: 'horizontal_content_list', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '[]', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '二级标题+文本列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过6,JSON数组格式', + }, + { + displayName: '跳转指引样式的列表', + name: 'jump_list', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '[]', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '跳转指引样式的列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过3,JSON数组格式', + }, + { + displayName: '整体卡片的点击跳转事件', + name: 'card_action', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '整体卡片的点击跳转事件,text_notice必填,news_notice不需要,JSON格式', + }, + { + displayName: '任务ID', + name: 'task_id', + type: 'string', + default: '', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '任务 ID,同一个应用任务id不能重复,只能由数字、字母和"_-@"组成,最长128字节', + }, + { + displayName: '按钮列表', + name: 'button_list', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '[]', + displayOptions: { + show: { + ...showOnlyUpdateTemplateCard, + card_type: ['button_interaction'], + }, + }, + description: '按钮交互型卡片的按钮列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过6,JSON数组格式', + }, + { + displayName: '选择题Key值', + name: 'checkbox_question_key', + type: 'string', + default: '', + displayOptions: { + show: { + ...showOnlyUpdateTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + description: '选择题key值,用户提交选项后,会产生回调事件,回调事件会将本参数作为EventKey返回,最长支持1024字节', + }, + { + displayName: '选择题模式', + name: 'checkbox_mode', + type: 'options', + options: [ + { + name: '单选', + value: 'single', + }, + { + name: '多选', + value: 'multiple', + }, + ], + default: 'single', + displayOptions: { + show: { + ...showOnlyUpdateTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + description: '选择题模式,单选为single,多选为multiple,不填默认单选', + }, + { + displayName: '选项列表', + name: 'option_list', + type: 'json', + typeOptions: { + rows: 4, + }, + default: '[]', + displayOptions: { + show: { + ...showOnlyUpdateTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + required: true, + description: '选项列表,列表长度不超过10,JSON数组格式。每个选项包含ID和text字段', + }, + { + displayName: '提交按钮文案', + name: 'submit_button_text', + type: 'string', + default: '提交', + displayOptions: { + show: { + ...showOnlyUpdateTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + description: '提交按钮文案,建议不超过10个字,不填默认为提交', + }, + { + displayName: '提交按钮Key值', + name: 'submit_button_key', + type: 'string', + default: '', + displayOptions: { + show: { + ...showOnlyUpdateTemplateCard, + card_type: ['vote_interaction', 'multiple_interaction'], + }, + }, + required: true, + description: '提交按钮key值,用户提交选项后,会产生回调事件,回调事件会将本参数作为EventKey返回,最长支持1024字节', + }, + { + displayName: '图片样式', + name: 'image_text_area', + type: 'json', + default: '{}', + displayOptions: { + show: { + ...showOnlyUpdateTemplateCard, + card_type: ['news_notice'], + }, + }, + description: '左图右文样式,JSON格式', + }, + { + displayName: '卡片右上角更多操作按钮', + name: 'action_menu', + type: 'json', + default: '{}', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '卡片右上角更多操作按钮,JSON格式', + }, + { + displayName: '更新卡片所需要消费的按钮Key值', + name: 'button_key', + type: 'string', + default: '', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '仅用于任务卡片消息的升级场景,如果需要更新任务卡片消息时需要填写', + }, +]; + diff --git a/nodes/WeCom/shared/transport.ts b/nodes/WeCom/shared/transport.ts new file mode 100644 index 0000000..f2e3ebb --- /dev/null +++ b/nodes/WeCom/shared/transport.ts @@ -0,0 +1,257 @@ +import type { + IExecuteFunctions, + ILoadOptionsFunctions, + IHttpRequestMethods, + IDataObject, + IHttpRequestOptions, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import type { IWeComAccessTokenResponse, IWeComCredentials } from './types'; + +/** + * Access Token 缓存结构 + * 使用 Map 按凭证隔离,支持多租户场景 + */ +interface TokenCache { + token: string; + expiresAt: number; + pending?: Promise; +} + +const accessTokenCache = new Map(); + +/** + * 生成缓存 Key(基于凭证信息) + */ +function getCacheKey(credentials: IWeComCredentials): string { + return `${credentials.corpId}-${credentials.corpSecret}`; +} + +/** + * 清除指定凭证的 Access Token 缓存 + */ +export function clearAccessTokenCache(credentials: IWeComCredentials): void { + const cacheKey = getCacheKey(credentials); + accessTokenCache.delete(cacheKey); +} + +/** + * 获取企业微信 Access Token + * + * 特性: + * - 按凭证缓存,支持多租户 + * - 并发请求控制,避免重复调用 + * - 提前 5 分钟过期,确保安全性 + */ +export async function getAccessToken( + this: IExecuteFunctions | ILoadOptionsFunctions, +): Promise { + const credentials = (await this.getCredentials('weComApi')) as IWeComCredentials; + const cacheKey = getCacheKey(credentials); + const cached = accessTokenCache.get(cacheKey); + + // 如果有正在进行的请求,等待它完成(避免并发重复请求) + if (cached?.pending) { + return await cached.pending; + } + + // 检查缓存的 token 是否有效 + if (cached && cached.expiresAt > Date.now()) { + return cached.token; + } + + // 创建获取 token 的 Promise + const tokenPromise = fetchAccessToken.call(this, credentials, cacheKey); + + // 保存 pending 状态,防止并发重复请求 + accessTokenCache.set(cacheKey, { + token: '', + expiresAt: 0, + pending: tokenPromise, + }); + + try { + const token = await tokenPromise; + return token; + } catch (error) { + // 请求失败时清除缓存 + accessTokenCache.delete(cacheKey); + throw error; + } +} + +/** + * 实际获取 Access Token 的函数 + */ +async function fetchAccessToken( + this: IExecuteFunctions | ILoadOptionsFunctions, + credentials: IWeComCredentials, + cacheKey: string, +): Promise { + const options: IHttpRequestOptions = { + method: 'GET', + url: 'https://qyapi.weixin.qq.com/cgi-bin/gettoken', + qs: { + corpid: credentials.corpId, + corpsecret: credentials.corpSecret, + }, + json: true, + }; + + const response = (await this.helpers.httpRequest(options)) as IWeComAccessTokenResponse; + + if (response.errcode !== 0 || !response.access_token) { + throw new NodeOperationError( + this.getNode(), + `获取 Access Token 失败: ${response.errmsg} (错误码: ${response.errcode})`, + ); + } + + // 缓存 token,提前 5 分钟过期以确保安全(企业微信官方建议) + const expiresIn = response.expires_in || 7200; // 默认 7200 秒(2 小时) + accessTokenCache.set(cacheKey, { + token: response.access_token, + expiresAt: Date.now() + (expiresIn - 300) * 1000, // 提前 5 分钟过期 + }); + + return response.access_token; +} + +/** + * 发送企业微信 API 请求 + * + * 特性: + * - 自动处理 Access Token 失效(40014, 42001)并重试 + * - 完善的错误处理 + */ +export async function weComApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + headers: IDataObject = {}, + option: IDataObject = {}, + maxRetries: number = 1, +): Promise { + const accessToken = await getAccessToken.call(this); + + const options: IHttpRequestOptions = { + method, + qs: { + ...qs, + access_token: accessToken, + }, + url: `https://qyapi.weixin.qq.com${resource}`, + json: true, + ...option, + }; + + // 如果有formData,使用formData;否则使用body + if (option.formData) { + options.body = option.formData; + delete options.json; // formData不需要json + } else { + options.body = body; + } + + // 合并自定义headers + if (Object.keys(headers).length > 0) { + options.headers = { ...options.headers, ...headers }; + } + + try { + const response = await this.helpers.httpRequest(options); + + // 如果是二进制响应(如下载文件),直接返回 + if (option.encoding === null || option.resolveWithFullResponse) { + return response; + } + + const jsonResponse = response as IDataObject; + + // 处理 Access Token 失效错误(40014: 不合法的access_token, 42001: access_token已过期) + if ((jsonResponse.errcode === 40014 || jsonResponse.errcode === 42001) && maxRetries > 0) { + // 清除缓存的 token + const credentials = (await this.getCredentials('weComApi')) as IWeComCredentials; + clearAccessTokenCache(credentials); + + // 重试请求 + return await weComApiRequest.call(this, method, resource, body, qs, headers, option, maxRetries - 1); + } + + if (jsonResponse.errcode !== undefined && jsonResponse.errcode !== 0) { + throw new NodeOperationError( + this.getNode(), + `企业微信 API 错误: ${jsonResponse.errmsg} (错误码: ${jsonResponse.errcode})`, + ); + } + + return jsonResponse; + } catch (error) { + const err = error as Error; + throw new NodeOperationError(this.getNode(), `API 请求失败: ${err.message}`); + } +} + +/** + * 上传媒体文件到企业微信 + */ +export async function uploadMedia( + this: IExecuteFunctions, + mediaType: 'image' | 'voice' | 'video' | 'file', + buffer: Uint8Array, + filename: string, +): Promise { + const accessToken = await getAccessToken.call(this); + + const options: IHttpRequestOptions = { + method: 'POST', + url: 'https://qyapi.weixin.qq.com/cgi-bin/media/upload', + qs: { + access_token: accessToken, + type: mediaType, + }, + body: { + media: { + value: buffer, + options: { + filename, + contentType: getContentType(filename), + }, + }, + }, + json: true, + }; + + const response = (await this.helpers.httpRequest(options)) as IDataObject; + + if ((response.errcode as number) !== 0 || !response.media_id) { + throw new NodeOperationError( + this.getNode(), + `上传媒体文件失败: ${response.errmsg} (错误码: ${response.errcode})`, + ); + } + + return response.media_id as string; +} + +function getContentType(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase(); + const mimeTypes: { [key: string]: string } = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + mp3: 'audio/mpeg', + amr: 'audio/amr', + mp4: 'video/mp4', + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }; + return mimeTypes[ext || ''] || 'application/octet-stream'; +} + diff --git a/nodes/WeCom/shared/types.ts b/nodes/WeCom/shared/types.ts new file mode 100644 index 0000000..559ae5d --- /dev/null +++ b/nodes/WeCom/shared/types.ts @@ -0,0 +1,25 @@ +export interface IWeComCredentials { + corpId: string; + corpSecret: string; + agentId: string; +} + +export interface IWeComAccessTokenResponse { + errcode: number; + errmsg: string; + access_token?: string; + expires_in?: number; +} + +export interface IWeComMessageResponse { + errcode: number; + errmsg: string; + invaliduser?: string; + invalidparty?: string; + invalidtag?: string; + msgid?: string; + response_code?: string; +} + +export type MessageType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'textcard' | 'news' | 'mpnews' | 'markdown'; + diff --git a/package-lock.json b/package-lock.json index 566f960..31c1452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "n8n-nodes-<...>", + "name": "n8n-nodes-wecom", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "n8n-nodes-<...>", + "name": "n8n-nodes-wecom", "version": "0.1.0", "license": "MIT", "devDependencies": { diff --git a/package.json b/package.json index 66b8c45..fde87c5 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,22 @@ { - "name": "n8n-nodes-<...>", + "name": "n8n-nodes-wecom", "version": "0.1.0", - "description": "", + "description": "n8n community node for WeCom (WeChat Work) integration. Send messages and manage contacts.", "license": "MIT", - "homepage": "", + "homepage": "https://github.com/yourusername/n8n-nodes-wecom", "keywords": [ - "n8n-community-node-package" + "n8n-community-node-package", + "wecom", + "wechat work", + "enterprise wechat" ], "author": { - "name": "", - "email": "" + "name": "Your Name", + "email": "your.email@example.com" }, "repository": { "type": "git", - "url": "https://github.com/<...>/n8n-nodes-<...>.git" + "url": "https://github.com/yourusername/n8n-nodes-wecom.git" }, "scripts": { "build": "n8n-node build", @@ -22,7 +25,10 @@ "lint": "n8n-node lint", "lint:fix": "n8n-node lint --fix", "release": "n8n-node release", - "prepublishOnly": "n8n-node prerelease" + "prepublishOnly": "n8n-node prerelease", + "install:local": "./scripts/dev-install.sh", + "uninstall:local": "./scripts/dev-uninstall.sh", + "start:local": "./scripts/dev-start.sh" }, "files": [ "dist" @@ -31,12 +37,10 @@ "n8nNodesApiVersion": 1, "strict": true, "credentials": [ - "dist/credentials/GithubIssuesApi.credentials.js", - "dist/credentials/GithubIssuesOAuth2Api.credentials.js" + "dist/credentials/WeComApi.credentials.js" ], "nodes": [ - "dist/nodes/GithubIssues/GithubIssues.node.js", - "dist/nodes/Example/Example.node.js" + "dist/nodes/WeCom/WeCom.node.js" ] }, "devDependencies": { diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d18a7aa --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,169 @@ +# 本地开发部署脚本 + +本目录包含用于在本地 n8n 实例中测试企业微信节点的脚本。 + +## 快速开始 + +### 一键安装节点到本地 n8n + +```bash +npm run install:local +``` + +这个命令会自动: +1. ✅ 检查并安装 n8n(如果未安装) +2. ✅ 构建项目 +3. ✅ 创建 npm 链接 +4. ✅ 配置 n8n 自定义节点目录 +5. ✅ 链接节点到 n8n + +### 启动 n8n + +```bash +npm run start:local +``` + +或直接使用: + +```bash +n8n start +``` + +然后在浏览器中访问 [http://localhost:5678](http://localhost:5678) + +### 卸载节点 + +```bash +npm run uninstall:local +``` + +## 使用方法 + +### 方式 1: 使用 n8n-node dev(推荐) + +这是最简单的方式,支持热重载: + +```bash +npm run dev +``` + +这个命令会: +- 自动构建项目 +- 启动 n8n +- 监听文件变化并自动重新加载 + +### 方式 2: 手动安装和测试 + +如果你需要在已有的 n8n 实例中测试: + +```bash +# 安装到本地 n8n +npm run install:local + +# 启动 n8n +npm run start:local +``` + +## 开发流程 + +### 推荐的开发流程: + +```bash +# 1. 安装节点到本地 n8n(首次) +npm run install:local + +# 2. 修改代码后重新构建 +npm run build + +# 3. 重启 n8n 查看更改 +npm run start:local +``` + +### 或使用自动重载: + +```bash +# 使用 dev 模式,自动监听变化 +npm run dev +``` + +## 脚本说明 + +### dev-install.sh + +安装脚本,执行以下操作: + +1. 检查 n8n 是否全局安装,如未安装则安装 +2. 构建项目 (`npm run build`) +3. 创建本地 npm 链接 (`npm link`) +4. 确保 `~/.n8n/custom` 目录存在 +5. 链接节点到 n8n (`npm link n8n-nodes-wecom`) + +### dev-uninstall.sh + +卸载脚本,执行以下操作: + +1. 从 n8n 中取消链接节点 +2. 取消项目的本地 npm 链接 +3. 清理链接关系 + +### dev-start.sh + +启动脚本,直接启动 n8n 服务器。 + +## 故障排查 + +### 问题 1: 找不到节点 + +**解决方案**: +1. 确认安装脚本运行成功 +2. 检查 `~/.n8n/custom/node_modules` 是否包含 `n8n-nodes-wecom` +3. 尝试重启 n8n + +### 问题 2: 节点更改未生效 + +**解决方案**: +1. 确保运行了 `npm run build` +2. 重启 n8n(Ctrl+C 然后重新启动) +3. 或使用 `npm run dev` 模式 + +### 问题 3: custom 目录不存在 + +**解决方案**: +```bash +mkdir -p ~/.n8n/custom +cd ~/.n8n/custom +npm init -y +``` + +然后重新运行安装脚本。 + +### 问题 4: 权限错误 + +**解决方案**: +```bash +# 确保脚本有执行权限 +chmod +x scripts/*.sh +``` + +## 参考文档 + +- [n8n 官方文档 - 本地测试节点](https://docs.n8n.io/integrations/creating-nodes/test/run-node-locally/) +- [n8n 社区节点开发指南](https://docs.n8n.io/integrations/community-nodes/building-community-nodes/) + +## 环境要求 + +- Node.js >= 18 +- npm >= 8 +- n8n 全局安装(脚本会自动安装) + +## 目录结构 + +``` +~/.n8n/ +├── custom/ # n8n 自定义节点目录 +│ ├── node_modules/ +│ │ └── n8n-nodes-wecom/ # 链接到你的项目 +│ └── package.json +└── ... +``` + diff --git a/scripts/dev-install.sh b/scripts/dev-install.sh new file mode 100755 index 0000000..932fc52 --- /dev/null +++ b/scripts/dev-install.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +# 企业微信节点本地开发安装脚本 +# 参考:https://docs.n8n.io/integrations/creating-nodes/test/run-node-locally/ + +set -e + +echo "🚀 开始安装企业微信节点到本地 n8n..." + +# 颜色定义 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# 获取包名 +PACKAGE_NAME=$(node -p "require('./package.json').name") +echo -e "${GREEN}📦 包名: $PACKAGE_NAME${NC}" + +# 1. 检查 n8n 是否已全局安装 +echo -e "\n${YELLOW}步骤 1/5: 检查 n8n 安装...${NC}" +if ! command -v n8n &> /dev/null; then + echo -e "${RED}❌ n8n 未安装${NC}" + echo "正在全局安装 n8n..." + npm install n8n -g + echo -e "${GREEN}✅ n8n 安装完成${NC}" +else + echo -e "${GREEN}✅ n8n 已安装${NC}" +fi + +# 2. 构建项目 +echo -e "\n${YELLOW}步骤 2/5: 构建项目...${NC}" +npm run build +echo -e "${GREEN}✅ 项目构建完成${NC}" + +# 3. 创建本地链接 +echo -e "\n${YELLOW}步骤 3/5: 创建项目链接...${NC}" +npm link +echo -e "${GREEN}✅ 项目链接创建完成${NC}" + +# 4. 确保 ~/.n8n/custom 目录存在 +echo -e "\n${YELLOW}步骤 4/5: 配置 n8n 自定义节点目录...${NC}" +N8N_CUSTOM_DIR="$HOME/.n8n/custom" + +if [ ! -d "$N8N_CUSTOM_DIR" ]; then + echo "创建 $N8N_CUSTOM_DIR 目录..." + mkdir -p "$N8N_CUSTOM_DIR" + cd "$N8N_CUSTOM_DIR" + npm init -y + echo -e "${GREEN}✅ 自定义节点目录创建完成${NC}" +else + echo -e "${GREEN}✅ 自定义节点目录已存在${NC}" +fi + +# 5. 链接到 n8n +echo -e "\n${YELLOW}步骤 5/5: 链接节点到 n8n...${NC}" +cd "$N8N_CUSTOM_DIR" +npm link "$PACKAGE_NAME" +echo -e "${GREEN}✅ 节点链接完成${NC}" + +echo -e "\n${GREEN}🎉 安装完成!${NC}" +echo -e "\n使用以下命令启动 n8n:" +echo -e "${YELLOW}n8n start${NC}" +echo -e "\n或使用开发模式(自动重新加载):" +echo -e "${YELLOW}npm run dev${NC}" +echo -e "\n然后在浏览器中访问: ${YELLOW}http://localhost:5678${NC}" +echo -e "\n在节点面板中搜索 ${GREEN}企业微信${NC} 或 ${GREEN}WeCom${NC} 即可找到节点" + diff --git a/scripts/dev-start.sh b/scripts/dev-start.sh new file mode 100755 index 0000000..099b57e --- /dev/null +++ b/scripts/dev-start.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# 企业微信节点本地开发启动脚本 + +# 颜色定义 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🚀 启动 n8n 开发环境...${NC}" +echo -e "\n${YELLOW}提示:${NC}" +echo -e "1. n8n 将在 http://localhost:5678 启动" +echo -e "2. 在节点面板中搜索 ${GREEN}企业微信${NC} 或 ${GREEN}WeCom${NC}" +echo -e "3. 按 Ctrl+C 停止 n8n\n" + +# 启动 n8n +n8n start + diff --git a/scripts/dev-uninstall.sh b/scripts/dev-uninstall.sh new file mode 100755 index 0000000..77a3734 --- /dev/null +++ b/scripts/dev-uninstall.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# 企业微信节点本地开发卸载脚本 + +set -e + +echo "🗑️ 开始卸载企业微信节点..." + +# 颜色定义 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# 获取包名 +PACKAGE_NAME=$(node -p "require('./package.json').name") +echo -e "${GREEN}📦 包名: $PACKAGE_NAME${NC}" + +# 1. 从 n8n 中取消链接 +echo -e "\n${YELLOW}步骤 1/2: 从 n8n 中取消链接...${NC}" +N8N_CUSTOM_DIR="$HOME/.n8n/custom" + +if [ -d "$N8N_CUSTOM_DIR" ]; then + cd "$N8N_CUSTOM_DIR" + npm unlink "$PACKAGE_NAME" --no-save 2>/dev/null || echo "节点未链接或已取消链接" + echo -e "${GREEN}✅ n8n 链接已移除${NC}" +else + echo -e "${YELLOW}⚠️ 自定义节点目录不存在${NC}" +fi + +# 2. 取消项目本地链接 +echo -e "\n${YELLOW}步骤 2/2: 取消项目链接...${NC}" +cd "$(dirname "$0")/.." +npm unlink 2>/dev/null || echo "项目未链接或已取消链接" +echo -e "${GREEN}✅ 项目链接已移除${NC}" + +echo -e "\n${GREEN}🎉 卸载完成!${NC}" +echo -e "\n如需重新安装,请运行:" +echo -e "${YELLOW}npm run install:local${NC}" +