From 0ec5f6180a2053515a18750b33dec33688173fdf Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 15:25:46 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 8 + .idea/editorJumperProjectSettings.xml | 6 + .idea/encodings.xml | 4 + .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/n8n-nodes-wecom.iml | 9 + .idea/prettier.xml | 6 + .idea/vcs.xml | 6 + README.md | 316 ++++++++---------- credentials/GithubIssuesApi.credentials.ts | 45 --- .../GithubIssuesOAuth2Api.credentials.ts | 54 --- credentials/WeComApi.credentials.ts | 66 ++++ icons/github.dark.svg | 3 - icons/github.svg | 3 - icons/wecom.dark.png | Bin 0 -> 14400 bytes icons/wecom.png | Bin 0 -> 14400 bytes nodes/Example/Example.node.json | 18 - nodes/Example/Example.node.ts | 78 ----- nodes/Example/example.dark.svg | 13 - nodes/Example/example.svg | 13 - nodes/GithubIssues/GithubIssues.node.json | 18 - nodes/GithubIssues/GithubIssues.node.ts | 96 ------ nodes/GithubIssues/listSearch/getIssues.ts | 49 --- .../listSearch/getRepositories.ts | 50 --- nodes/GithubIssues/listSearch/getUsers.ts | 49 --- nodes/GithubIssues/resources/issue/create.ts | 74 ---- nodes/GithubIssues/resources/issue/get.ts | 14 - nodes/GithubIssues/resources/issue/getAll.ts | 124 ------- nodes/GithubIssues/resources/issue/index.ts | 75 ----- .../resources/issueComment/getAll.ts | 65 ---- .../resources/issueComment/index.ts | 47 --- nodes/GithubIssues/shared/descriptions.ts | 151 --------- nodes/GithubIssues/shared/transport.ts | 32 -- nodes/GithubIssues/shared/utils.ts | 14 - nodes/WeCom/WeCom.node.json | 20 ++ nodes/WeCom/WeCom.node.ts | 81 +++++ nodes/WeCom/resources/contact/execute.ts | 64 ++++ .../WeCom/resources/contact/getDepartment.ts | 20 ++ nodes/WeCom/resources/contact/getUser.ts | 21 ++ nodes/WeCom/resources/contact/index.ts | 45 +++ nodes/WeCom/resources/contact/listUsers.ts | 31 ++ nodes/WeCom/resources/message/execute.ts | 153 +++++++++ nodes/WeCom/resources/message/index.ts | 53 +++ nodes/WeCom/resources/message/sendFile.ts | 90 +++++ nodes/WeCom/resources/message/sendImage.ts | 90 +++++ nodes/WeCom/resources/message/sendMarkdown.ts | 54 +++ nodes/WeCom/resources/message/sendText.ts | 97 ++++++ nodes/WeCom/shared/transport.ts | 154 +++++++++ nodes/WeCom/shared/types.ts | 25 ++ package.json | 6 +- 51 files changed, 1265 insertions(+), 1265 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/editorJumperProjectSettings.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/n8n-nodes-wecom.iml create mode 100644 .idea/prettier.xml create mode 100644 .idea/vcs.xml delete mode 100644 credentials/GithubIssuesApi.credentials.ts delete mode 100644 credentials/GithubIssuesOAuth2Api.credentials.ts create mode 100644 credentials/WeComApi.credentials.ts delete mode 100644 icons/github.dark.svg delete mode 100644 icons/github.svg create mode 100644 icons/wecom.dark.png create mode 100644 icons/wecom.png delete mode 100644 nodes/Example/Example.node.json delete mode 100644 nodes/Example/Example.node.ts delete mode 100644 nodes/Example/example.dark.svg delete mode 100644 nodes/Example/example.svg delete mode 100644 nodes/GithubIssues/GithubIssues.node.json delete mode 100644 nodes/GithubIssues/GithubIssues.node.ts delete mode 100644 nodes/GithubIssues/listSearch/getIssues.ts delete mode 100644 nodes/GithubIssues/listSearch/getRepositories.ts delete mode 100644 nodes/GithubIssues/listSearch/getUsers.ts delete mode 100644 nodes/GithubIssues/resources/issue/create.ts delete mode 100644 nodes/GithubIssues/resources/issue/get.ts delete mode 100644 nodes/GithubIssues/resources/issue/getAll.ts delete mode 100644 nodes/GithubIssues/resources/issue/index.ts delete mode 100644 nodes/GithubIssues/resources/issueComment/getAll.ts delete mode 100644 nodes/GithubIssues/resources/issueComment/index.ts delete mode 100644 nodes/GithubIssues/shared/descriptions.ts delete mode 100644 nodes/GithubIssues/shared/transport.ts delete mode 100644 nodes/GithubIssues/shared/utils.ts create mode 100644 nodes/WeCom/WeCom.node.json create mode 100644 nodes/WeCom/WeCom.node.ts create mode 100644 nodes/WeCom/resources/contact/execute.ts create mode 100644 nodes/WeCom/resources/contact/getDepartment.ts create mode 100644 nodes/WeCom/resources/contact/getUser.ts create mode 100644 nodes/WeCom/resources/contact/index.ts create mode 100644 nodes/WeCom/resources/contact/listUsers.ts create mode 100644 nodes/WeCom/resources/message/execute.ts create mode 100644 nodes/WeCom/resources/message/index.ts create mode 100644 nodes/WeCom/resources/message/sendFile.ts create mode 100644 nodes/WeCom/resources/message/sendImage.ts create mode 100644 nodes/WeCom/resources/message/sendMarkdown.ts create mode 100644 nodes/WeCom/resources/message/sendText.ts create mode 100644 nodes/WeCom/shared/transport.ts create mode 100644 nodes/WeCom/shared/types.ts 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/README.md b/README.md index 06ea132..b2a68df 100644 --- a/README.md +++ b/README.md @@ -1,247 +1,211 @@ -![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 -``` - -**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 - -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 +按照 n8n 社区节点文档中的[安装指南](https://docs.n8n.io/integrations/community-nodes/installation/)进行安装。 -> [!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. +本节点支持以下操作: -## Finding Inspiration +### 消息资源 -Looking for more examples? Check out these resources: +- **发送文本消息** - 发送纯文本消息给成员、部门或标签 + - 支持消息去重 + - 支持 ID 转译 + - 支持保密消息 +- **发送 Markdown 消息** - 发送格式化的 Markdown 消息 +- **发送图片消息** - 通过 Media ID 或直接上传文件发送图片 +- **发送文件消息** - 通过 Media 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. +- **获取成员信息** - 根据 UserID 获取成员详细信息 +- **获取部门成员** - 获取指定部门的成员列表,支持递归获取子部门 +- **获取部门信息** - 获取部门列表和详细信息 -## 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 -``` - -### 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 +#### 发送文本消息 -Edit the example nodes to fit your use case, or create new node files by copying the structure from [nodes/Example/](nodes/Example/). +1. 选择**资源**: 消息 +2. 选择**操作**: 发送文本消息 +3. 配置接收人: + - **接收人** (touser): 成员ID,用 `|` 分隔多个,如 `user1|user2`,或使用 `@all` 发送给所有人 + - **部门ID** (toparty): 可选,部门ID列表 + - **标签ID** (totag): 可选,标签ID列表 +4. 输入**消息内容** +5. 可选配置:消息去重、ID转译、保密消息 -> [!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. +#### 发送 Markdown 消息 -### 5. Configure Your Package +适合发送格式化的报告、通知等: -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 +**日期**: 2025-10-18 -Make sure your node is registered in the `n8n.nodes` array. - -### 6. Develop and Test Locally - -Start n8n with your node loaded: - -```bash -npm run dev +- 新增用户: 100 +- 活跃用户: 5000 +- 收入: ¥10,000 ``` -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) +#### 发送图片或文件 -You can now test your node in n8n workflows! +1. 从前置节点获取二进制数据(如 HTTP Request 下载的文件) +2. 选择**文件来源**: 上传文件 +3. 输入**输入数据字段名**(默认为 `data`) +4. 节点会自动上传文件并发送 -> [!NOTE] -> Learn more about CLI commands in the [@n8n/node-cli documentation](https://www.npmjs.com/package/@n8n/node-cli). +### 常见场景 -### 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) +``` +Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) +``` -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 +``` +Schedule Trigger → 企业微信 (获取部门成员) → Database (存储) +``` -2. Submit through the [n8n Creator Portal](https://creators.n8n.io/nodes) +### 接收人配置技巧 -**Benefits of verification:** +- **单个用户**: `touser: "zhangsan"` +- **多个用户**: `touser: "zhangsan|lisi|wangwu"` +- **所有人**: `touser: "@all"` +- **按部门**: `toparty: "1|2|3"` +- **按标签**: `totag: "1|2"` +- **组合使用**: 可以同时指定成员、部门和标签 -- Available directly in n8n Cloud -- Discoverable in the n8n nodes panel -- Verified badge for quality assurance -- Increased visibility in the n8n community +### 使用表达式 -## Available Scripts +接收人字段支持 n8n 表达式: -This starter includes several npm scripts to streamline development: +```javascript +// 从输入数据获取用户ID +{{ $json.userId }} -| 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`) | +// 从数组拼接用户ID列表 +{{ $json.users.join('|') }} +``` -> [!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`. +### API 限制 -## Troubleshooting +根据企业微信官方文档: -### My node doesn't appear in n8n +- 每个应用每分钟最多调用 200 次 API +- 文本消息最长 2048 字节 +- 图片文件不超过 2MB +- 普通文件不超过 20MB +- 接收人列表最多 1000 个成员 -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/). +- [n8n 社区节点文档](https://docs.n8n.io/integrations/#community-nodes) +- [企业微信开发文档](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/90194) -### TypeScript errors +## 版本历史 -Make sure you're using Node.js v22 or higher and have run `npm install` to get all type definitions. +### 1.0.0 (2025-10-18) -## 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 +**消息功能**: +- 发送文本消息(支持去重、ID转译、保密消息) +- 发送 Markdown 消息 +- 发送图片消息(支持 Media ID 或直接上传) +- 发送文件消息(支持 Media ID 或直接上传) -## Contributing +**通讯录功能**: +- 获取成员信息 +- 获取部门成员列表(支持递归) +- 获取部门信息 -Have suggestions for improving this starter? [Open an issue](https://github.com/n8n-io/n8n-nodes-starter/issues) or submit a pull request! +**技术特性**: +- Access Token 自动缓存管理 +- 完整的 TypeScript 类型支持 +- 遵循 n8n 节点开发最佳实践 +- 完善的错误处理 -## License +## 许可证 -[MIT](https://github.com/n8n-io/n8n-nodes-starter/blob/master/LICENSE.md) +[MIT](LICENSE.md) 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..85adf34 --- /dev/null +++ b/credentials/WeComApi.credentials.ts @@ -0,0 +1,66 @@ +import type { + IAuthenticateGeneric, + Icon, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class WeComApi implements ICredentialType { + name = 'weComApi'; + + displayName = '企业微信 API'; + + 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 0000000000000000000000000000000000000000..68404af4f13cbf2d5ab650a09616a7e031b132a9 GIT binary patch literal 14400 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 I1z5=c1Llm7(*OVf literal 0 HcmV?d00001 diff --git a/icons/wecom.png b/icons/wecom.png new file mode 100644 index 0000000000000000000000000000000000000000..68404af4f13cbf2d5ab650a09616a7e031b132a9 GIT binary patch literal 14400 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 I1z5=c1Llm7(*OVf literal 0 HcmV?d00001 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..d3ea698 --- /dev/null +++ b/nodes/WeCom/WeCom.node.ts @@ -0,0 +1,81 @@ +import type { + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; +import { messageDescription } from './resources/message'; +import { contactDescription } from './resources/contact'; +import { executeMessage } from './resources/message/execute'; +import { executeContact } from './resources/contact/execute'; + +export class WeCom implements INodeType { + description: INodeTypeDescription = { + displayName: '企业微信', + name: 'weCom', + icon: { light: 'file:../../icons/wecom.png', dark: 'file:../../icons/wecom.dark.png' }, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: '企业微信消息发送与通讯录管理', + defaults: { + name: '企业微信', + }, + 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: '管理通讯录(成员、部门)', + }, + ], + default: 'message', + }, + ...messageDescription, + ...contactDescription, + ], + }; + + 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); + } + + return [returnData]; + } +} + diff --git a/nodes/WeCom/resources/contact/execute.ts b/nodes/WeCom/resources/contact/execute.ts new file mode 100644 index 0000000..ea94830 --- /dev/null +++ b/nodes/WeCom/resources/contact/execute.ts @@ -0,0 +1,64 @@ +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 === '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 { + 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..710a252 --- /dev/null +++ b/nodes/WeCom/resources/contact/getDepartment.ts @@ -0,0 +1,20 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetDepartment = { + resource: ['contact'], + operation: ['getDepartment'], +}; + +export const getDepartmentDescription: INodeProperties[] = [ + { + displayName: '部门 ID', + name: 'id', + type: 'string', + default: '1', + displayOptions: { + show: showOnlyGetDepartment, + }, + description: '部门id。(注:如果不填,默认获取全量组织架构)', + }, +]; + diff --git a/nodes/WeCom/resources/contact/getUser.ts b/nodes/WeCom/resources/contact/getUser.ts new file mode 100644 index 0000000..6be6f42 --- /dev/null +++ b/nodes/WeCom/resources/contact/getUser.ts @@ -0,0 +1,21 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetUser = { + resource: ['contact'], + operation: ['getUser'], +}; + +export const getUserDescription: INodeProperties[] = [ + { + displayName: '成员 UserID', + name: 'userid', + type: 'string', + 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..b95260b --- /dev/null +++ b/nodes/WeCom/resources/contact/index.ts @@ -0,0 +1,45 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getUserDescription } from './getUser'; +import { listUsersDescription } from './listUsers'; +import { getDepartmentDescription } from './getDepartment'; + +const showOnlyForContact = { + resource: ['contact'], +}; + +export const contactDescription: INodeProperties[] = [ + { + displayName: '操作', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: showOnlyForContact, + }, + options: [ + { + name: '获取成员信息', + value: 'getUser', + action: '获取成员信息', + description: '读取成员详细信息', + }, + { + name: '获取部门成员', + value: 'listUsers', + action: '获取部门成员列表', + description: '获取部门成员列表', + }, + { + name: '获取部门信息', + value: 'getDepartment', + action: '获取部门信息', + description: '获取部门列表', + }, + ], + default: 'getUser', + }, + ...getUserDescription, + ...listUsersDescription, + ...getDepartmentDescription, +]; + diff --git a/nodes/WeCom/resources/contact/listUsers.ts b/nodes/WeCom/resources/contact/listUsers.ts new file mode 100644 index 0000000..4e14752 --- /dev/null +++ b/nodes/WeCom/resources/contact/listUsers.ts @@ -0,0 +1,31 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyListUsers = { + resource: ['contact'], + operation: ['listUsers'], +}; + +export const listUsersDescription: INodeProperties[] = [ + { + displayName: '部门 ID', + name: 'department_id', + type: 'string', + required: true, + default: '1', + displayOptions: { + show: showOnlyListUsers, + }, + description: '获取的部门id,默认为根部门', + }, + { + displayName: '是否递归获取', + name: 'fetch_child', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlyListUsers, + }, + description: '是否递归获取子部门下面的成员:1-递归获取,0-只获取本部门', + }, +]; + diff --git a/nodes/WeCom/resources/message/execute.ts b/nodes/WeCom/resources/message/execute.ts new file mode 100644 index 0000000..12a3b09 --- /dev/null +++ b/nodes/WeCom/resources/message/execute.ts @@ -0,0 +1,153 @@ +import type { IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { weComApiRequest, uploadMedia } from '../../shared/transport'; + +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; + + const touser = this.getNodeParameter('touser', i, '') as string; + const toparty = this.getNodeParameter('toparty', i, '') as string; + const 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, + }, + }; + } + + 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..02d4497 --- /dev/null +++ b/nodes/WeCom/resources/message/index.ts @@ -0,0 +1,53 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { sendTextDescription } from './sendText'; +import { sendMarkdownDescription } from './sendMarkdown'; +import { sendImageDescription } from './sendImage'; +import { sendFileDescription } from './sendFile'; + +const showOnlyForMessage = { + resource: ['message'], +}; + +export const messageDescription: INodeProperties[] = [ + { + displayName: '操作', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: showOnlyForMessage, + }, + options: [ + { + name: '发送文本消息', + value: 'sendText', + action: '发送文本消息', + description: '发送文本类型的消息', + }, + { + name: '发送 Markdown 消息', + value: 'sendMarkdown', + action: '发送 Markdown 消息', + description: '发送 Markdown 格式的消息', + }, + { + name: '发送图片消息', + value: 'sendImage', + action: '发送图片消息', + description: '发送图片类型的消息', + }, + { + name: '发送文件消息', + value: 'sendFile', + action: '发送文件消息', + description: '发送文件类型的消息', + }, + ], + default: 'sendText', + }, + ...sendTextDescription, + ...sendMarkdownDescription, + ...sendImageDescription, + ...sendFileDescription, +]; + diff --git a/nodes/WeCom/resources/message/sendFile.ts b/nodes/WeCom/resources/message/sendFile.ts new file mode 100644 index 0000000..5516339 --- /dev/null +++ b/nodes/WeCom/resources/message/sendFile.ts @@ -0,0 +1,90 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendFile = { + resource: ['message'], + operation: ['sendFile'], +}; + +export const sendFileDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendFile, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendFile, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendFile, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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..adbaf97 --- /dev/null +++ b/nodes/WeCom/resources/message/sendImage.ts @@ -0,0 +1,90 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendImage = { + resource: ['message'], + operation: ['sendImage'], +}; + +export const sendImageDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendImage, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendImage, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendImage, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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..bb165de --- /dev/null +++ b/nodes/WeCom/resources/message/sendMarkdown.ts @@ -0,0 +1,54 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendMarkdown = { + resource: ['message'], + operation: ['sendMarkdown'], +}; + +export const sendMarkdownDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMarkdown, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMarkdown, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMarkdown, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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/sendText.ts b/nodes/WeCom/resources/message/sendText.ts new file mode 100644 index 0000000..a63bffa --- /dev/null +++ b/nodes/WeCom/resources/message/sendText.ts @@ -0,0 +1,97 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendText = { + resource: ['message'], + operation: ['sendText'], +}; + +export const sendTextDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendText, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendText, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendText, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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: '是否是保密消息,0表示否,1表示是,默认0', + }, + { + displayName: '是否开启ID转译', + name: 'enable_id_trans', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendText, + }, + description: '是否开启id转译,0表示否,1表示是,默认0', + }, + { + displayName: '是否开启重复消息检查', + name: 'enable_duplicate_check', + type: 'boolean', + default: false, + displayOptions: { + show: showOnlySendText, + }, + description: '是否开启重复消息检查,0表示否,1表示是,默认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/shared/transport.ts b/nodes/WeCom/shared/transport.ts new file mode 100644 index 0000000..fd18f40 --- /dev/null +++ b/nodes/WeCom/shared/transport.ts @@ -0,0 +1,154 @@ +import type { + IExecuteFunctions, + ILoadOptionsFunctions, + IHttpRequestMethods, + IDataObject, + IHttpRequestOptions, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import type { IWeComAccessTokenResponse, IWeComCredentials } from './types'; + +let cachedAccessToken: { token: string; expiresAt: number } | null = null; + +/** + * 获取企业微信 Access Token + */ +export async function getAccessToken( + this: IExecuteFunctions | ILoadOptionsFunctions, +): Promise { + const credentials = (await this.getCredentials('weComApi')) as IWeComCredentials; + + // 检查缓存的 token 是否有效 + if (cachedAccessToken && cachedAccessToken.expiresAt > Date.now()) { + return cachedAccessToken.token; + } + + 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 分钟过期以确保安全 + cachedAccessToken = { + token: response.access_token, + expiresAt: Date.now() + (response.expires_in! - 300) * 1000, + }; + + return response.access_token; +} + +/** + * 发送企业微信 API 请求 + */ +export async function weComApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, +): Promise { + const accessToken = await getAccessToken.call(this); + + const options: IHttpRequestOptions = { + method, + body, + qs: { + ...qs, + access_token: accessToken, + }, + url: `https://qyapi.weixin.qq.com${resource}`, + json: true, + }; + + try { + const response = (await this.helpers.httpRequest(options)) as IDataObject; + + if (response.errcode !== undefined && response.errcode !== 0) { + throw new NodeOperationError( + this.getNode(), + `企业微信 API 错误: ${response.errmsg} (错误码: ${response.errcode})`, + ); + } + + return response; + } 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.json b/package.json index 66b8c45..e8859b1 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,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": { From 53f7bd8cf0568098ed16e2fbad239aa6e8cbd711 Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 15:39:05 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- credentials/WeComApi.credentials.ts | 1 + icons/wecom.dark.png | Bin 14400 -> 26411 bytes icons/wecom.png | Bin 14400 -> 26411 bytes nodes/WeCom/WeCom.node.ts | 2 ++ .../WeCom/resources/contact/getDepartment.ts | 2 +- nodes/WeCom/resources/contact/listUsers.ts | 4 ++-- nodes/WeCom/resources/message/sendFile.ts | 2 +- nodes/WeCom/resources/message/sendImage.ts | 2 +- nodes/WeCom/resources/message/sendText.ts | 6 +++--- package.json | 17 ++++++++++------- 10 files changed, 21 insertions(+), 15 deletions(-) diff --git a/credentials/WeComApi.credentials.ts b/credentials/WeComApi.credentials.ts index 85adf34..d51e130 100644 --- a/credentials/WeComApi.credentials.ts +++ b/credentials/WeComApi.credentials.ts @@ -11,6 +11,7 @@ export class WeComApi implements ICredentialType { 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'; diff --git a/icons/wecom.dark.png b/icons/wecom.dark.png index 68404af4f13cbf2d5ab650a09616a7e031b132a9..537735feebf114bcfa186019970737f77bd27e1a 100644 GIT binary patch literal 26411 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 WWNaE>01S*cNKWdbWTm*t_x}M{PmLM? literal 14400 zcmYkDby!qQ*znIT-QC?Ff|Rs$N`sULtfV5+EwOYhk`L02lz=pnOSg1Mi%1Jd=YGfc zegF7oxvpK#o|!Xo*X;c6XkBep0$f^L000Qo)s*xB00Mr50N9w|+bg$HJMazNUS3Nc z0BYj#?p|Yn|1(>w>1zRiA1467!vNq0yae9?08aq`*nLS){k)2ojUDU>E{hVBnN&eKMPIDAK7CWXgc(~aggMK+#wuL(Cw5G-#BC*S;tzIo&5=#d-rgr!#tTLKOFT7a5f zrLe~9`tMfE>iZpR7D4LN$EH>%KGM-CQ|Qm%>O3!l)tDgOzI_{ge|xD@vvhc0P*}KG zY4k2+@(qHOojs!uTade$Sh;3$%PrgK&6`p;aryxb4ULhQ*jV0WrzSP*L~NGXu!L#O z1fZL0MmDbBNc6HJLS3+Qbk5c10hJshAeSeWso(qVVeP~|U zKmYtFW@`S8NQRJ*P()EliO$Y!#k^WcuumzVdFoPuJDf}EV5TzRs` zbr~nLOZV^Dw2stS)mk5VIPmM&FXE=9>{9MwVr4xm0|NsxPMw^r?35IdNxM4beREmN z9?L9kdqrR0>yMRYO-}qgJQ8EBulNRBvA|CC{oNWVTRopP1XB6TFh7mV8mDe;04-W+ z0xW#O!lx}3?S2HC;@U^{J+RV<$0McX7|vz^32d>@2Mgnmp*OH0h%-QDt7rWh4eyyw(^LwH=e zaAYoTT6HOjLqn5~kB^N)c{dlVmztd&P_g24G=M2n$Scni-m@Xz;d}bq`}FiQR^lV? z)6B6CMpdsC%Q`T!Mfcp?+@eOdX!E#-qprXL-nWvPl$11-dV{D!6mWC%@{Wv;jRmxp zmzT$EHn$s9oWHSO#FA77D4$q3p87~9qs#9|e7HEMP>pANHf~j8@`5{~3mXrVi4T{5 zR=(}eRW32B>o*M9{CJo#(2;0(uo%ODZFGkR*qAcP`W&xA+_%5q8@_t=s$gzy2p0C} z>C>ka@8-Vvo=oH`s`}=vee*ltOMV%cYX?n8PVV{ksw%PeF;ULbcXJ5!WX=^kp>)Jc z(TFZ=ENRC0W>9XL=Bq7H6yv9nUySw{D_Ov*6`0ljpD2U)bM|X>1CQ) z5>F@QG&&}>*2XO<*%adzIVb*;ll#{%g;sWT zu>J84yMHx&b7*d8@cA~x32|_6jMK;Lw(}Jud%t}7vM1*V4UAtI@jZA`{A_ocOG_^$qqbHk32c6NZ5q;v4mu5|_5x?} z#ON{{9JU^|wp+n_dk&kU>B1w8j*Cr> zwhU6i4{WyAMu=wnd-g3TxzEh`=K)I@_r7%hp(GAYaO!OTEPtCPq7kx5T8*<2pgy#& zTP)zCjJ;j&3O%3xti`+1_&(GB+oh!|#>CW=&?6ZcnZUE1$2cG2M`K`GacW* zl_kpH{ev6;?KXCvnTkrk^zY*ySQuv*v5k$*DB8r~a#}~2D2AL~3JV#Rw&wn1A=b#o zI4n%ynctaOmcVO;i~?@OAAa|w5TCn~fy0=BUOO9`R|9rIm~8bdVa%Z6mIQqPQ_$Us zhBi7s2~a%`N?t|=F%y%l=FzY)H3At8I zO@-a;aVmB@?=VbYX27aO2|+E$ny#PW@?cN@-S(z9<$_+uUp(`k1|eW~b-cmGQ~b;w zRJANBK#$y-r&z(?|4y!*cMv8}&jtz*bMXseWkE^+yU`B*ZQl*_(T72CyQ~U5!G-=_1AH-riep z!Jh7{NJcO!sj8N|d`ww5uvvpvDiKr|{|xkUph|tE3zVb@hi(zzUd#_(t>&)}(qIgH z9~L*NUVo7cHJ$wZ;e(09+0l2FV!Fr&LHdZ^ziP3Y0?k-NY^M-BGkna@gw)j3tHY)> z#-3{mpjhN{5bA-)&$x=$<%@-U7Y|LV_6WB)M}Fzc2!n$OTmEw0ZGG_X>;4Q;kkdL2 z=J~pSXt1iCGbBC02fyKT-PA;FVWKk#mD1haeGf4Ii+O%|Nb)`YV@aniqVMlM8mqNM z+QIY5rB<(y$DE^0sPBQ6=JuUUTVvDH2LGaHn42YJIvGP#N0gcGBieN6M4waBWpT>O zRDN>@Qg-#dk8|zvI_|=v{jnnyBM@BANmQ}9uzODYg*vVu3}U%8 z3=ERBn8TFVIXPA8elc~h7{Crmo>*LK_eSVHN%`KihQViVMt^jbo{=;P3 zJwA;eC0f%#p-HTekd{Ur0GdtgU}CH2xVVO0^Du1pMttOZ!Bi+@@Jl*wB zG*%f&ZmS0Ywg>SsM2DUxwj>=G+!v(aLW@&2eLF@huPb%e8(2vsGR<6Cq7P3RRPvEQ zG0Oh_6R~TyX~$PeWCdw8U#Hi7SJim7#DO+uY;#Ew8}DMoXwcKmRZo7=D=C^LrIVSD zl=wFv$W~X6W?LoABiyZXoa1nQH}mtf6EMa^cDS%@&T^9Hq@Go2fBFncz~MLTQU8%> zHv2T}cVpLrYqr_qVkzp)&e?br@_U)i>o}x^;_Tk6^# zZyAJQ-q{>fq}J@GAlU3}bhDW>W0h)-HN@uVFMc*i7~$`HM})ncEJlZU|A#P>b=%mX z2|)9}>^)A)V23#*!K0(w_>EkkdEV^uM`{q`lDj6ltoQk#vRjY1;#e{zADjPIWU3v) zTXP>4M)E{#<7ob+>-I7K`_EGdel{S8pS)&ALrmIjTgRC*n7K6l51|=?Ix*NG2|^X-nL09~*Gr6Pj;AZ|6Uf*1-EfW$E?F-VqEvLY zqj&c|JX`R5-pwW3!19#S2Sa2A`yJ$;@r$_3UNBzq)p(H6qX%`TH=s~6sww<;o-((P+PZ||Ha2ZP!n~bpH@J9SuXswueGP~)(-~~u4YiwT zsn0FOhL;UN8R@ONmxD27bh>9F+-;s{Qn^m?+-zM;oSAwTy5x*QB3+N)aG@U^Pzi27<60g~oPN^%8Ef0o zN&dTCligOlI&ngf4!fIkO^tvNQ*;BVwCr>UTj$4ahMu?#zFDypzkk(UJx!eA;=rOpW~~v_d^1;^6}9(BgZRW^rY6e(_0;nPJiBbr(=7h zBv8S(@uaUdz20M@X733WP$O#68LzFVtV@xjHjq9dr48@&&+f0^?A4Yn$L0ncgpp@$ zFvzQ`;USl9+fA;DZ$=)d@wzg_7LNPgi?Y8JvGz1{#9R+gr#vtrj(+XBS6wRe>}OxK z%(N3PwNsem(Zk=gu-3V9=VQ8TX4qvw;*~G$sl;p&VpSUVD7x32ZAM4seEb>X()ozqU}2FhV?q`BjK#<&vYwlW=l9&Nu>BxcmoiUeWXnIg*Y-{{oapIJz0b#n9N*AaIH-nozB;nHl*wUxg8{qO!(n2? zUrrveVm1+5>;H1k@-Y!hrlT*0r6%};14FfOLS0{>cXCKb9zBH+#>4hI&kKj3*13?x zXXzg-#ffcMuZ}6{SP{Y1zaETs838e?1jOzhY|>z^8!Zd{V_MO3LX&VdhSJ6HSwS9z{Y2#LSu z^Is=$T(e;Nzb${F~GN5`<&4-@A)u8whU%Y zXzar%Pa|msSY-q@g6un~?9rA3BUt5GfHf)d18JHxmfvEVnmc5DuL4?G=^;5ej0=zQ zlnbtOT<7tq2yr1kp3bnkAaA^`O|rC&YF>BG82HLm+Jfitf-jZsa@CW*Js~KGtFjmjR2#Fl1UCf1OYLN;&l4CoL#A=-r zmGg#N?dZaY?ixkdG!FZkk%bUWqU$Iex$S&iW-&QPa}Yb;#p}CInUU1Q)+e22OjXG> zop{&iJCA@53!)Ib zLM)R(5OFUfd4qM+-B8Ar2)!X1)HrH@{p-E#<}d;W$(IHd_v5^4g0x`EWoQ0GNC6O- zx-7K}@f%qjn(*caaDe`rgY>g4LbqvKHc=j0VfcVNUR44~JHE|C# z4Jpb{)O|6p68aw4tdD6%8YE#7q6x{OZt{RQ!_3j;aA3B!*k__@DxX*r9u!C(%mDM> z1{y%HKcD^;N}qaf(#@%r#+J3&4*qY8QslKwY`?jl$m5Vj4lcjr<5MdzZaORYGfUB! z*@6v!3J*xHz@xUK-g`}fZ8ZLMy-SeOWGUUJYWE53M2YO^6VA0hx+dmGUqPe9Lz83EsbZn{VCPr92Rs&lKQ9FzI1>1-6aQ&NNBY=Rh5ej=V z5+)<9h=AnW1k1F_b{rD5m0cktWr>xBk1r+Q|eZ^ zi8B6oS-n8*wP?MrNQ@kLD=B;ul2PPGo^?;lwPIUJR`HryrI&V7klBh_#d9P?t_2y< zNpMC22aEu~rAXUOU7lK|rZo`HLZ%TKZZD=-@czSv682;ojW=0f`W-3(rajd zdXNA)K&YE~1V{#01>jrtUC!0O%M4Pn6diYTAryLg|{9X}Bb0za`rg9*OoK$|v{ zh7TuzxF~gy2)?}>?d9?Qwr1;CeGer%%0$yU0qyJ9q!yrp&J48j+KR-EGQm$2mHI;0 z{5y->VAD+>|eJdM-Lhahy-`u!=U?3Tv?vNHi9O;k^f#D+lShKYr9pM$f9lNi ze7urZD~;mvNa6M3giWs~zz()qbdi}Y8s%x|UefB46OmtH#3Tdui~tOQ>7GKB|vQZjrB& zH^d#WFGIj&OTKx;CeyPVhOzVZPvx*Nlf#eLc%8^dd4U~t7z{sBDl!xLNbxh0Ay-^) zt=k{1JL!ip{4WM@KUo&Rm3>|bBLvSVF%_K}l#J;`$U!tY;$S>M2v=^eB1K*)K{yMtT?6Yj!>nkiw?BprXi6dL@3hf;JC2km9`?dEXGxX!;pV3 z`_lb3gMZmB2;|epV#>t-QMsj%J^eJru|U(^0g>kg>>%aK-ya#Q&&6kPSG~3f$;ZN= z55gcs09l`guE;K#C$=SKJ7&9=j!%14k9V)wu9VkQU$6g=O;0LADBo@u&Rn;z5{*0w z0qx^D7Y{U0Qsmf7Zr_Z%Dxj5yi#82A48KZY{g{2dw+H^y0Tm$F1dR@wEW%Ax5R*>5 zp!x{BGWR;|ibZ)Hgw z1tGe$jFu%pp_3?hWFATE^7k;TY2%k|cI8n4K$_Sf{8pvxG?u^WEk*E4PUsC=Dr9yX ziWLEgLr5jj5&*;>BVi6TS#@3Pw#I%VPOG)N1iKWetzRVz!w|InAP&HS{F*MiZ zV?FA{zJ%Fy?H3+z8dl7_6Bx|#`$*|A^tek)6cNyXqm_fSKT>QE>PJ=3>~Aj#Jc56~ zrTNtLp?21`>A?VbZoY3Cm`8mJ1Q_kF2=!)?p4{wIHh=IIeV&aKc>H6>$@EiO=cHoI zMO2uMMjN*@Utzgvuvx*5({FGGEAY$|c@T`Lxy$v2xO+y1r49$2U6($^#4*XR?N5HBeD^e#yl&0&C#tG(ecl=>>vr(B*$b^$3@E^i5Lgmt z{_WLnc7=A(Z{g!PWA~yl3!W&PA6c^_vI+}u;{VaO~M(X)KJML6}uEMrRv9Y(c*VP(DL6=VYFyR2;EBK|~KERHt z=DDda$A1$NJjD94tc6M(No>q6XcH$v<$yMc`4~Z*J$~5gBTB)IbIuo{bz~ugo6m4k zRQ8NGaQeUzm|XnLG|RH>r&is4??2AKPt+Fk_MM+c+akm^SrVJZ9wG0Pnp=U z?HTlhWKVR{r4m80WNO5c`*s;yB(Tu@+FxYi(5~HVmn?Ib;};{TA)ouuXUegU_==!D z<^_2ZS}r>&iq*Az=m|O`3H_NR%3eCPaKd3HgDl%5D1SP>RlyV(SfoAYNJ0D;bS1m4 z(sL`ckavJL{zIBWNjH7CzkC%|M$_9{h80EY3+H3qF{cU(_cR=&ZF|}~o#({k15{4Y(?7ul2Zp4R(SFG}#MSH5&pVQMVghr}|=FNUR+8WrC zsv1^2l=k}p{Z>Y8V*Inuo`aNdc8J)Pc~vE0(hQjr2<_)Q7Kl(o|J(HvBbVX)XI-Ji zFPn71;{u-Va$E(ml(y> z*jS$qKlA6Rp~dE8Z;X-ml7(!Bs1bk6())TI7WRIC!|@qQ{v9i1Y{)^C{KGQNiwC`x z7-GU5*QV9@9<5nEaDQ=)Guf_g#h2ZCo0v%+)z`$`D}83ER0K1$av z{yT>7PD;Py-c(90dJ!ORNU^5mc{d|L=z(PSZ#SG+uEyl7rx!BFcC1og!LLtPFqyXk zkP{k5zi)cY`dGEg$*(W1#i4Ca$0KH_4;{}p_<+Exzqhq0)J}0L(cs($clS#p9zIsU%>&xbrPV&{5B8h~>{y%m<_1r;K>1yM z+vz**Tv?3f{d1=W4)EY6ip_KHGPGloUUZo;_=BoLZUKLrF>uasy9KWM=IyH+wPn(; zlGPYG+rH};UWY)wy| z=VWngwZ4a=rYoNWqwekURFhWY0dcD9Pj#j%StK}#^o)$}x)rVCAigzTVP7fXAFF`= zOA>z9#=&O?eb#g1wI?XStIkeb=w$L4tsu*k|X+!b14g=60xeWvWhFQZ~4QWX%&830eG^sU7;HOU3? ziHNF_dvv;ufbWgEBK01QjU5X^Td>NIH7A$N5usGNgMkBame*Y`saDh8g>S@Usu6yT zbH3l?wV-0Q$iR!29A>W0K!5P2gNNm}G@vrwaGnG%owNt#aud2^(FlCVgMho9g%IXt zc1KXa$>6nhLXR?eaz-xK$6KY39mtm$2;1r`dlxBV#D2ddu{ZSnQR{(5BZ6^uoT_0_ za9+Y(orC`;m`Dy&YyZqE*VSOTeQxVCvD%cm*)-p2r`$l=A zr7`;;(;egR+HZg$dz1U5GxKFtTKWKzOgiJO%C&RB@xWWVg;@)RGR-R|h>47>mLMLw z@gTk1@0?$0v$l%sU;hRwf5 zsNEsVyTg}wnCIBpzE2%7u^`*JRC*V&J7hTXl|oN{ez#M598bdowPU9fcp9y0 zV^44BxlizrNRWU^!`X9?nuAO3Gk5xN4SwznMJ?jVxqU7-^r>5jRTXXd5wK#%Z1S`x z1Z#jg)Ga+o0+fZbA5dcGvcO=JZL*eH=Z@rQj7m&(yl+jRf=U+;4wi%MpnRJeiTL^> zsrVWkDCZ>w7*Bh^~gY{TwyM`n6SG zL9YRE#DIGtWkc^WSbDTRSxnn&B90%m0UnG;PU1-SpOFf%=0rY?^24I>;%najByZY! z@YlJB{P~F=ci+CezEGD|3zp5nIAAc~OulNsvH760@@AaT|6UGlwWTZO4PT>dwi5Au z%xup_flExbZ~`{uH@*mL(MDLM|CUji`nBV^)Rk)pL1b(JxKpfg4|YlqNu!@Oo0w*MdRta}Cw%6+V#kLfoG0T`4;L1dI2w>0#Fi|} zzsU72KNJo1yguUYFLkENmQXwH`v2hw!S5K(>>x7YJ7LW9L=RPB%+DlhwN&%HPQ>&5 zFG-G9Q~?%d*D~h&fzUH`teZH@U0KLFKLc)~7k`GYM;p!fcY(;|z)rqyec-RoTYDEa zz>dfv`$V-&{P?~L3BhC|CZRN+RP`f-UM8<8UZ1I>WOR7moRduKu~7voeOZz3UxPUP zwwBXe!;|g&03JX7YRdalfM>->`>xwvxIT9L$Wa{7CFeU$_mEk;N%*g%khuQ)RrsZf z)tJuv7O=zk-eV}Z$5G)+-x*KJ;rXD56k%$Q&AqvV-|nBrDOc&DBl|PJ14V?7brnTW z2dBs%?l#ixfi^%cm7V7c{VJW|GKvhS({f~*Y|&)JH{#aqWXerXhO4~u9eq@ej2t4kV^~>~egaWiT3T^_#%fx2>`gh=-YM^} zsC8X**yq+9PKR*EC#ZxTsNj07Ti)fPf26RXAg@CUMA5VKPA@D_8$ppI>v7*i zm9BU4T*m;kzegW??N8rR4`2rFzzzwWX2yQ}cjT#P+Nl6OQ^DHWnvA8lNALMcLqmgh zJ8zL_&!)J4fGT*3*AT=k18#`Ex3ZmF`_l>e)de%fBp{YV_;UaF(Is>{MB)Rbm6e|Q z&sU(u_1{34AXpz)p?hE2SMv!Y789a7^H%8#7~3zHVqh{-(>z&U1(}Ijd8WS!^3!sU z_41tFQ`XcMxc1nEe%lcwd}9k1-Uy{jrVT&65;Ll6LP%_OX7knp^H#j+*9gKAfv5ry4PMy!?YlaJ6EcAg@PFE`U!|}XxC_S5DtLQc zpRXUT9};zMKKUY+Ihsz;P{N0V$=*uy& z!ZKmxX4wBU!upyU`S#6t^j?`ao?+jH3q4m@v+);`jST=!?Do07zFzYvMvywN3x~O8 zD1)NS9r?=lrdMa5|Fdjia!APnRikvUQYkRmQ; zg9M?4=A+pxG%&YgR#@qq{IK40(RMIlk*zmKc49d zV*|O;{CtI#EF#2aIp2Hah3#5Lzz2|Q9{Kdd*yW^;86=!Rto7M%uhyRZ(a=d-=3RHB zR*PA7_h{n!QIPS{bhvi^p_kYZNH0^f{0bQ#$AAc=Yf>b!tN-`ke~uJLY}4i?OLVcb zJtQs(V*SV$PmTJsxBH9Y+FG}GcJ-ton{shf9UkE%g4@Mf1*8{Qr2me58nb(K$-0lZCDSJ#CYc@i7+5IQ448C$%(>+$i? zlPBYJk{&w^e#Rini!Nlvc(vg!cNqwMSxPL%-Tit}pT^H1rVj@-I3Q7;>w~(#lgllXdI0w774l2rvl)79R6H2AY5nw8&BY*wwk z>#F;Us0Wauo5l>?AXV+uKwsZ2$@OxfH_imRG7RV>=xPTwUoL65Bp5AqAjeKg7 zNN&m$ns;c(0bJ}(e~%qKin(Yy6r#pI*CodQs|Hdb)7`DVY5kypbLIFzrN^m$Z*Nn5 z`tQH3{S{_#Oj5SCw%1_k1HX|u=_NbtII3+Llsz34Cj7Yh+}+)M&8u~Vp*bPD3$_!! zbm+DH`*#OOrSF17_-6C+hi|+TcA}Y=FF;v8Ty2*!K8g?of%rO5aF;>FcvQFO--qnr zZMk{B)Toss?pn}PRMeZClOxmr4|!ja!wj7i{YPO}cRt+k0v+ZJ4u)g@tp@c`051tb zR5{ekE$ZyXWThE(u2zzJVPO!)c-2?nmDnV9L$k`c!Vdp_E_)Q8StGNQRBJhitw#>o zIH+rDGm<#XM+s8r;}?^gH9Go&*mpJtXY%g;f4s&wB6_#f0{_Q!Qz(~dfaC5tm9E1TqRtMR-!~L> zTE3e4$RPC>L;lKPw%kzbA!YM&wB34ua2y;5J&1INfSmT_;gVSPres?oOn{m}Db?0- zsqt`(fKJq(4ZHjEY=v>|Nz)g2>K|WU-zt!`u8SmONNVxij|dA3t4PBZp#H%!BII@W z<6O_l=}!qbVdfwWYRJ5W(dIoqGV<;p0KVE86jZn9=yd^N*m3W%K*2omYaecWzqBeX zcI91V(SD-sL3z|PGKH9`Qn<@W90Ty;x?u6>sKbWc^d_c-7?r%QQ3 zLAQyisV_4R&s%DDN-Kf8lB%kzoBPYPAdu24RU;0oh6-Lagzt@KdpT70||B@7D z{^8feR8#{YQPc>pQgDPO|8#(mLGnfS>D~8#OEUBKRwOqOv@NuCb#*!~jE(UyFfdv{ zT6)Q;i3oeTmkZESq}$#3zhQAg3BCeXd;*v~08={3EX zWNm#sVh!ol%$D?Y1Yznn#%F$Cr=cn)iag5Z=H|-s^74N^Yh~g>vNn7>j-M}nXi%7+ z1aW$y=2umLwU0|6JEE>Xd+QMh#6H;T$eM~FnvJmk&2_QV)jeJWg*^r`b1H4=*zhvm zlHa*=4!zGK6J)%XoxDM`QjuIa8Vu#M(xiR=&J-0D;XK9@WJ__C%qG~{+VTZ6ZdP9C z>$_!aHoyG1N%-<{YRvZ^+m;`}GqhOWfAF?NwV0t^N{33}WJ+S9Ap5gt0SeU??J6KN z{M&F4+Yfw(JD3KL1NzankdTnj0&YL?k5XVZ)bk%L1!g)S+Q9cZk6r!3r9*A#gpMA0 zD>HU?6bV}rXisE>K(ra=;o(u<1tUJI;tJh3_bZNwh)DVuTik*uf1UTysx|1?=?5eL z`X-adTVPm(J?wD<9;;B**$k1!4s5CyrEuy#|DgXIBeu^9OcT*KSnCXm0W))`7#LQj z9U3k~Y2)-p69y-s&v}&e96i3hDxsyNEoUGcIGLK7s{I!jH7)UxDzGc4J!Sb-4@L(j z-`s2`wny>5B_@H{Fwq6n`{#a#z(L9s_4Bf=yM53YrS|sr?kp^rn7sfC2*jsMz7aW* zb|y#fLb|>hY)GH<<&II{Q2#qz`#7dx=Pj5^0tS+fU%(g@G+81dqM{bmGhxJHEWhx9 z$S#-*i0=LXJzC_9;g8o~(ili2C$X1-IUtE3b&kzK5=M+yJcEu@BH@Yv)A|5)Wo@M@ I1z5=c1Llm7(*OVf diff --git a/icons/wecom.png b/icons/wecom.png index 68404af4f13cbf2d5ab650a09616a7e031b132a9..537735feebf114bcfa186019970737f77bd27e1a 100644 GIT binary patch literal 26411 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 WWNaE>01S*cNKWdbWTm*t_x}M{PmLM? literal 14400 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 I1z5=c1Llm7(*OVf diff --git a/nodes/WeCom/WeCom.node.ts b/nodes/WeCom/WeCom.node.ts index d3ea698..84ec077 100644 --- a/nodes/WeCom/WeCom.node.ts +++ b/nodes/WeCom/WeCom.node.ts @@ -14,6 +14,7 @@ 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, @@ -22,6 +23,7 @@ export class WeCom implements INodeType { defaults: { name: '企业微信', }, + usableAsTool: true, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], credentials: [ diff --git a/nodes/WeCom/resources/contact/getDepartment.ts b/nodes/WeCom/resources/contact/getDepartment.ts index 710a252..9a92a0e 100644 --- a/nodes/WeCom/resources/contact/getDepartment.ts +++ b/nodes/WeCom/resources/contact/getDepartment.ts @@ -14,7 +14,7 @@ export const getDepartmentDescription: INodeProperties[] = [ displayOptions: { show: showOnlyGetDepartment, }, - description: '部门id。(注:如果不填,默认获取全量组织架构)', + description: '部门ID。(注:如果不填,默认获取全量组织架构)', }, ]; diff --git a/nodes/WeCom/resources/contact/listUsers.ts b/nodes/WeCom/resources/contact/listUsers.ts index 4e14752..f7fefd3 100644 --- a/nodes/WeCom/resources/contact/listUsers.ts +++ b/nodes/WeCom/resources/contact/listUsers.ts @@ -15,7 +15,7 @@ export const listUsersDescription: INodeProperties[] = [ displayOptions: { show: showOnlyListUsers, }, - description: '获取的部门id,默认为根部门', + description: '获取的部门ID,默认为根部门', }, { displayName: '是否递归获取', @@ -25,7 +25,7 @@ export const listUsersDescription: INodeProperties[] = [ displayOptions: { show: showOnlyListUsers, }, - description: '是否递归获取子部门下面的成员:1-递归获取,0-只获取本部门', + description: 'Whether to recursively fetch members from sub-departments: 1 - fetch recursively, 0 - fetch only from this department', }, ]; diff --git a/nodes/WeCom/resources/message/sendFile.ts b/nodes/WeCom/resources/message/sendFile.ts index 5516339..4ecfb39 100644 --- a/nodes/WeCom/resources/message/sendFile.ts +++ b/nodes/WeCom/resources/message/sendFile.ts @@ -70,7 +70,7 @@ export const sendFileDescription: INodeProperties[] = [ fileSource: ['mediaId'], }, }, - description: '文件id,可以调用上传临时素材接口获取', + description: '文件ID,可以调用上传临时素材接口获取', }, { displayName: '输入数据字段名', diff --git a/nodes/WeCom/resources/message/sendImage.ts b/nodes/WeCom/resources/message/sendImage.ts index adbaf97..0d638f4 100644 --- a/nodes/WeCom/resources/message/sendImage.ts +++ b/nodes/WeCom/resources/message/sendImage.ts @@ -70,7 +70,7 @@ export const sendImageDescription: INodeProperties[] = [ imageSource: ['mediaId'], }, }, - description: '图片媒体文件id,可以调用上传临时素材接口获取', + description: '图片媒体文件ID,可以调用上传临时素材接口获取', }, { displayName: '输入数据字段名', diff --git a/nodes/WeCom/resources/message/sendText.ts b/nodes/WeCom/resources/message/sendText.ts index a63bffa..d2535ab 100644 --- a/nodes/WeCom/resources/message/sendText.ts +++ b/nodes/WeCom/resources/message/sendText.ts @@ -58,7 +58,7 @@ export const sendTextDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendText, }, - description: '是否是保密消息,0表示否,1表示是,默认0', + description: 'Whether this is a confidential message. 0 means no, 1 means yes, default is 0.', }, { displayName: '是否开启ID转译', @@ -68,7 +68,7 @@ export const sendTextDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendText, }, - description: '是否开启id转译,0表示否,1表示是,默认0', + description: 'Whether to enable ID translation. 0 means no, 1 means yes, default is 0.', }, { displayName: '是否开启重复消息检查', @@ -78,7 +78,7 @@ export const sendTextDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendText, }, - description: '是否开启重复消息检查,0表示否,1表示是,默认0', + description: 'Whether to enable duplicate message check. 0 means no, 1 means yes, default is 0.', }, { displayName: '重复消息检查时间', diff --git a/package.json b/package.json index e8859b1..5b6b9b6 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", From bbf7edd64a1379d88691d16a50079e9cad77df65 Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 15:43:35 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A0=E7=94=A8?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 0 CODE_OF_CONDUCT.md | 76 ---------------------------------------------- 2 files changed, 76 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 CODE_OF_CONDUCT.md 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 From 62522586983db13ca9c70dbef00b18177e3fa294 Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 16:10:54 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 90 ++++++++------------- package-lock.json | 4 +- package.json | 5 +- scripts/README.md | 169 +++++++++++++++++++++++++++++++++++++++ scripts/dev-install.sh | 68 ++++++++++++++++ scripts/dev-start.sh | 18 +++++ scripts/dev-uninstall.sh | 40 +++++++++ 7 files changed, 335 insertions(+), 59 deletions(-) create mode 100644 scripts/README.md create mode 100755 scripts/dev-install.sh create mode 100755 scripts/dev-start.sh create mode 100755 scripts/dev-uninstall.sh diff --git a/README.md b/README.md index b2a68df..bbbb936 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,6 @@ ### 消息资源 - **发送文本消息** - 发送纯文本消息给成员、部门或标签 - - 支持消息去重 - - 支持 ID 转译 - - 支持保密消息 - **发送 Markdown 消息** - 发送格式化的 Markdown 消息 - **发送图片消息** - 通过 Media ID 或直接上传文件发送图片 - **发送文件消息** - 通过 Media ID 或直接上传文件发送文件 @@ -79,45 +76,53 @@ - **最低 n8n 版本**: 1.0.0 - **测试版本**: 1.0.0+ -此节点使用 n8n 的声明式路由和标准 API,与最新版本的 n8n 完全兼容。 +此节点使用 n8n 的标准 API,与最新版本的 n8n 完全兼容。 ## 使用 -### 基本用法 +### 发送文本消息 -#### 发送文本消息 +1. 添加"企业微信"节点到工作流 +2. 选择**资源**: 消息 +3. 选择**操作**: 发送文本消息 +4. 配置接收人: + - **接收人**: 成员ID,用 `|` 分隔多个,或使用 `@all` 发送给所有人 + - **部门ID**: 可选,部门ID列表 + - **标签ID**: 可选,标签ID列表 +5. 输入**消息内容** -1. 选择**资源**: 消息 -2. 选择**操作**: 发送文本消息 -3. 配置接收人: - - **接收人** (touser): 成员ID,用 `|` 分隔多个,如 `user1|user2`,或使用 `@all` 发送给所有人 - - **部门ID** (toparty): 可选,部门ID列表 - - **标签ID** (totag): 可选,标签ID列表 -4. 输入**消息内容** -5. 可选配置:消息去重、ID转译、保密消息 +### 发送 Markdown 消息 -#### 发送 Markdown 消息 +适合发送格式化的报告、通知等。支持标题、加粗、列表等 Markdown 语法。 -适合发送格式化的报告、通知等: +### 发送图片或文件 -```markdown -# 📊 每日数据报告 +1. 选择**文件来源**: 上传文件 +2. 输入**输入数据字段名**(默认为 `data`) +3. 节点会自动上传文件并发送 -**日期**: 2025-10-18 +### 接收人配置 -- 新增用户: 100 -- 活跃用户: 5000 -- 收入: ¥10,000 -``` +- **单个用户**: `touser: "zhangsan"` +- **多个用户**: `touser: "zhangsan|lisi|wangwu"` +- **所有人**: `touser: "@all"` +- **按部门**: `toparty: "1|2|3"` +- **按标签**: `totag: "1|2"` +- **组合使用**: 可以同时指定成员、部门和标签 -#### 发送图片或文件 +### 使用表达式 -1. 从前置节点获取二进制数据(如 HTTP Request 下载的文件) -2. 选择**文件来源**: 上传文件 -3. 输入**输入数据字段名**(默认为 `data`) -4. 节点会自动上传文件并发送 +接收人字段支持 n8n 表达式: -### 常见场景 +```javascript +// 从输入数据获取用户ID +{{ $json.userId }} + +// 从数组拼接用户ID列表 +{{ $json.users.join('|') }} +``` + +### 常见使用场景 #### 定时发送日报 @@ -137,33 +142,6 @@ Webhook (接收告警) → IF (判断严重程度) → 企业微信 (发送文 Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) ``` -#### 同步通讯录 - -``` -Schedule Trigger → 企业微信 (获取部门成员) → Database (存储) -``` - -### 接收人配置技巧 - -- **单个用户**: `touser: "zhangsan"` -- **多个用户**: `touser: "zhangsan|lisi|wangwu"` -- **所有人**: `touser: "@all"` -- **按部门**: `toparty: "1|2|3"` -- **按标签**: `totag: "1|2"` -- **组合使用**: 可以同时指定成员、部门和标签 - -### 使用表达式 - -接收人字段支持 n8n 表达式: - -```javascript -// 从输入数据获取用户ID -{{ $json.userId }} - -// 从数组拼接用户ID列表 -{{ $json.users.join('|') }} -``` - ### API 限制 根据企业微信官方文档: 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 5b6b9b6..fde87c5 100644 --- a/package.json +++ b/package.json @@ -25,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" 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}" + From 46a12d1fec7f31b147d16c3cfc455f2355993d12 Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 16:37:14 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nodes/WeCom/shared/transport.ts | 95 ++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/nodes/WeCom/shared/transport.ts b/nodes/WeCom/shared/transport.ts index fd18f40..fedb863 100644 --- a/nodes/WeCom/shared/transport.ts +++ b/nodes/WeCom/shared/transport.ts @@ -8,21 +8,86 @@ import type { import { NodeOperationError } from 'n8n-workflow'; import type { IWeComAccessTokenResponse, IWeComCredentials } from './types'; -let cachedAccessToken: { token: string; expiresAt: number } | null = null; +/** + * 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 (cachedAccessToken && cachedAccessToken.expiresAt > Date.now()) { - return cachedAccessToken.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', @@ -42,17 +107,22 @@ export async function getAccessToken( ); } - // 缓存 token,提前 5 分钟过期以确保安全 - cachedAccessToken = { + // 缓存 token,提前 5 分钟过期以确保安全(企业微信官方建议) + const expiresIn = response.expires_in || 7200; // 默认 7200 秒(2 小时) + accessTokenCache.set(cacheKey, { token: response.access_token, - expiresAt: Date.now() + (response.expires_in! - 300) * 1000, - }; + expiresAt: Date.now() + (expiresIn - 300) * 1000, // 提前 5 分钟过期 + }); return response.access_token; } /** * 发送企业微信 API 请求 + * + * 特性: + * - 自动处理 Access Token 失效(40014, 42001)并重试 + * - 完善的错误处理 */ export async function weComApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions, @@ -60,6 +130,7 @@ export async function weComApiRequest( resource: string, body: IDataObject = {}, qs: IDataObject = {}, + maxRetries: number = 1, ): Promise { const accessToken = await getAccessToken.call(this); @@ -77,6 +148,16 @@ export async function weComApiRequest( try { const response = (await this.helpers.httpRequest(options)) as IDataObject; + // 处理 Access Token 失效错误(40014: 不合法的access_token, 42001: access_token已过期) + if ((response.errcode === 40014 || response.errcode === 42001) && maxRetries > 0) { + // 清除缓存的 token + const credentials = (await this.getCredentials('weComApi')) as IWeComCredentials; + clearAccessTokenCache(credentials); + + // 重试请求 + return await weComApiRequest.call(this, method, resource, body, qs, maxRetries - 1); + } + if (response.errcode !== undefined && response.errcode !== 0) { throw new NodeOperationError( this.getNode(), From e6d40db54b665841936466e3624b4e3a085c6c3b Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 16:59:50 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IMPLEMENTATION_NOTES.md | 217 ++++++++ README.md | 20 +- nodes/WeCom/resources/message/execute.ts | 464 ++++++++++++++++++ nodes/WeCom/resources/message/index.ts | 64 +++ .../message/sendMiniprogramNotice.ts | 167 +++++++ nodes/WeCom/resources/message/sendMpNews.ts | 157 ++++++ nodes/WeCom/resources/message/sendNews.ts | 129 +++++ nodes/WeCom/resources/message/sendTaskCard.ts | 189 +++++++ .../resources/message/sendTemplateCard.ts | 326 ++++++++++++ nodes/WeCom/resources/message/sendTextCard.ts | 119 +++++ nodes/WeCom/resources/message/sendVideo.ts | 154 ++++++ nodes/WeCom/resources/message/sendVoice.ts | 131 +++++ 12 files changed, 2135 insertions(+), 2 deletions(-) create mode 100644 IMPLEMENTATION_NOTES.md create mode 100644 nodes/WeCom/resources/message/sendMiniprogramNotice.ts create mode 100644 nodes/WeCom/resources/message/sendMpNews.ts create mode 100644 nodes/WeCom/resources/message/sendNews.ts create mode 100644 nodes/WeCom/resources/message/sendTaskCard.ts create mode 100644 nodes/WeCom/resources/message/sendTemplateCard.ts create mode 100644 nodes/WeCom/resources/message/sendTextCard.ts create mode 100644 nodes/WeCom/resources/message/sendVideo.ts create mode 100644 nodes/WeCom/resources/message/sendVoice.ts diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000..b4ae3e7 --- /dev/null +++ b/IMPLEMENTATION_NOTES.md @@ -0,0 +1,217 @@ +# 企业微信发送应用消息接口完整实现说明 + +## 概述 + +本次更新完全实现了企业微信官方文档(https://developer.work.weixin.qq.com/document/path/90236)中定义的所有发送应用消息接口。 + +## 实现的消息类型 + +根据官方文档,企业微信应用消息支持 12 种消息类型,本次实现已全部覆盖: + +### 1. ✅ 文本消息 (text) - 已实现 +- 文件:`sendText.ts` +- 功能:发送纯文本消息 +- 特性:支持消息去重、ID转译、保密消息 + +### 2. ✅ Markdown消息 (markdown) - 已实现 +- 文件:`sendMarkdown.ts` +- 功能:发送Markdown格式的消息 +- 特性:支持Markdown语法格式化 + +### 3. ✅ 图片消息 (image) - 已实现 +- 文件:`sendImage.ts` +- 功能:发送图片消息 +- 特性:支持Media ID或直接上传文件 + +### 4. ✅ 语音消息 (voice) - 新增 +- 文件:`sendVoice.ts` +- 功能:发送语音消息 +- 特性:支持Media ID或直接上传文件(AMR、MP3格式) +- 字段:media_id, safe, enable_id_trans, enable_duplicate_check + +### 5. ✅ 视频消息 (video) - 新增 +- 文件:`sendVideo.ts` +- 功能:发送视频消息 +- 特性:支持Media ID或直接上传文件(MP4格式) +- 字段:media_id, title, description, safe, enable_id_trans, enable_duplicate_check + +### 6. ✅ 文件消息 (file) - 已实现 +- 文件:`sendFile.ts` +- 功能:发送文件消息 +- 特性:支持Media ID或直接上传文件 + +### 7. ✅ 文本卡片消息 (textcard) - 新增 +- 文件:`sendTextCard.ts` +- 功能:发送带标题、描述和跳转链接的卡片消息 +- 字段:title, description, url, btntxt, enable_id_trans, enable_duplicate_check + +### 8. ✅ 图文消息 (news) - 新增 +- 文件:`sendNews.ts` +- 功能:发送图文消息,支持1-8条图文 +- 字段:articles (title, description, url, picurl) +- 特性:适合发送多条图文链接 + +### 9. ✅ 图文消息(mpnews) - 新增 +- 文件:`sendMpNews.ts` +- 功能:发送富文本图文消息 +- 字段:articles (title, thumb_media_id, author, content_source_url, content, digest) +- 特性:支持HTML内容,适合富文本展示 + +### 10. ✅ 小程序通知消息 (miniprogram_notice) - 新增 +- 文件:`sendMiniprogramNotice.ts` +- 功能:发送小程序通知消息 +- 字段:appid, page, title, description, emphasis_first_item, content_item +- 特性:支持小程序跳转和内容键值对 + +### 11. ✅ 任务卡片消息 (taskcard) - 新增 +- 文件:`sendTaskCard.ts` +- 功能:发送带按钮的任务卡片消息 +- 字段:title, description, url, task_id, btn (key, name, replace_name, color, is_bold) +- 特性:支持交互式按钮,可以实现任务确认等功能 + +### 12. ✅ 模板卡片消息 (template_card) - 新增 +- 文件:`sendTemplateCard.ts` +- 功能:发送模板卡片消息,支持多种卡片类型 +- 卡片类型: + - `text_notice` - 文本通知型 + - `news_notice` - 图文展示型 + - `button_interaction` - 按钮交互型 + - `vote_interaction` - 投票选择型 + - `multiple_interaction` - 多项选择型 +- 特性:最灵活的卡片消息类型,支持复杂的交互和展示 + +## 技术实现细节 + +### 文件结构 +``` +nodes/WeCom/resources/message/ +├── index.ts # 消息资源入口,导出所有消息类型 +├── execute.ts # 执行逻辑,处理所有消息类型的发送 +├── sendText.ts # 文本消息定义 +├── sendMarkdown.ts # Markdown消息定义 +├── sendImage.ts # 图片消息定义 +├── sendVoice.ts # 语音消息定义(新增) +├── sendVideo.ts # 视频消息定义(新增) +├── sendFile.ts # 文件消息定义 +├── sendTextCard.ts # 文本卡片消息定义(新增) +├── sendNews.ts # 图文消息定义(新增) +├── sendMpNews.ts # 图文消息(mpnews)定义(新增) +├── sendMiniprogramNotice.ts # 小程序通知消息定义(新增) +├── sendTaskCard.ts # 任务卡片消息定义(新增) +└── sendTemplateCard.ts # 模板卡片消息定义(新增) +``` + +### 代码特性 + +1. **完整的类型定义** + - 所有字段都有 TypeScript 类型定义 + - 使用 n8n 的 INodeProperties 接口定义参数 + +2. **用户友好的界面** + - 所有字段都有中文描述 + - 支持条件显示(displayOptions) + - 提供默认值和验证 + +3. **灵活的输入方式** + - 媒体消息支持 Media ID 或直接上传 + - 复杂结构支持 fixedCollection 或 JSON 输入 + - 支持 n8n 表达式 + +4. **完善的错误处理** + - 参数验证 + - 文件上传错误处理 + - API 错误处理 + +5. **遵循官方文档** + - 所有字段名称与官方文档一致 + - 支持所有可选参数 + - 实现所有特性(去重、ID转译、保密消息等) + +## 使用示例 + +### 发送文本卡片消息 +```javascript +{ + "touser": "zhangsan", + "title": "系统通知", + "description": "您有一个新的任务待处理", + "url": "https://example.com/task/123", + "btntxt": "查看详情" +} +``` + +### 发送图文消息 +```javascript +{ + "touser": "@all", + "articles": [ + { + "title": "产品更新公告", + "description": "我们发布了新版本", + "url": "https://example.com/news/1", + "picurl": "https://example.com/image.jpg" + } + ] +} +``` + +### 发送任务卡片 +```javascript +{ + "touser": "zhangsan", + "title": "请审批", + "description": "李四提交了请假申请", + "task_id": "task_001", + "buttons": [ + { + "key": "approve", + "name": "同意", + "color": "blue" + }, + { + "key": "reject", + "name": "拒绝", + "color": "red" + } + ] +} +``` + +## 测试建议 + +1. **基础消息测试** + - 测试文本、Markdown、图片、文件消息 + - 验证接收人配置(单人、多人、@all、部门、标签) + +2. **媒体消息测试** + - 测试语音消息(AMR、MP3格式) + - 测试视频消息(MP4格式,带标题和描述) + - 测试文件上传功能 + +3. **卡片消息测试** + - 测试文本卡片(跳转链接) + - 测试图文消息(多条图文) + - 测试任务卡片(按钮交互) + +4. **高级功能测试** + - 测试小程序通知消息 + - 测试模板卡片(各种卡片类型) + - 测试消息去重功能 + - 测试ID转译功能 + +## 兼容性 + +- ✅ 完全兼容企业微信官方 API +- ✅ 支持所有文档中的参数和选项 +- ✅ 支持最新的模板卡片功能 +- ✅ 向后兼容现有的消息类型 + +## 参考文档 + +- [企业微信发送应用消息接口](https://developer.work.weixin.qq.com/document/path/90236) +- [n8n 节点开发文档](https://docs.n8n.io/integrations/creating-nodes/) + +## 更新日期 + +2025-10-18 + diff --git a/README.md b/README.md index bbbb936..f218295 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,15 @@ - **发送文本消息** - 发送纯文本消息给成员、部门或标签 - **发送 Markdown 消息** - 发送格式化的 Markdown 消息 - **发送图片消息** - 通过 Media ID 或直接上传文件发送图片 +- **发送语音消息** - 通过 Media ID 或直接上传文件发送语音(支持 AMR、MP3 格式) +- **发送视频消息** - 通过 Media ID 或直接上传文件发送视频(支持 MP4 格式) - **发送文件消息** - 通过 Media ID 或直接上传文件发送文件 +- **发送文本卡片消息** - 发送带标题、描述和跳转链接的卡片消息 +- **发送图文消息** - 发送图文消息(news类型),支持1-8条图文 +- **发送图文消息(mpnews)** - 发送图文消息(mpnews类型),支持html内容 +- **发送小程序通知消息** - 发送小程序通知类型的消息 +- **发送任务卡片消息** - 发送带按钮的任务卡片消息,支持交互 +- **发送模板卡片消息** - 发送模板卡片消息,支持多种卡片类型(文本通知型、图文展示型、按钮交互型、投票选择型、多项选择型) ### 通讯录资源 @@ -165,13 +173,21 @@ Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) ### 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种卡片类型:文本通知型、图文展示型、按钮交互型、投票选择型、多项选择型) **通讯录功能**: - 获取成员信息 diff --git a/nodes/WeCom/resources/message/execute.ts b/nodes/WeCom/resources/message/execute.ts index 12a3b09..df754c9 100644 --- a/nodes/WeCom/resources/message/execute.ts +++ b/nodes/WeCom/resources/message/execute.ts @@ -126,6 +126,470 @@ export async function executeMessage( 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 (error) { + // 忽略解析错误 + } + + // 添加main_title + try { + template_card.main_title = JSON.parse(main_title); + } catch (error) { + 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 (error) { + // 忽略解析错误 + } + + // 添加quote_area + try { + const quoteObj = JSON.parse(quote_area); + if (Object.keys(quoteObj).length > 0) { + template_card.quote_area = quoteObj; + } + } catch (error) { + // 忽略解析错误 + } + + // 添加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 (error) { + // 忽略解析错误 + } + + // 添加jump_list + try { + const jumpListObj = JSON.parse(jump_list); + if (Array.isArray(jumpListObj) && jumpListObj.length > 0) { + template_card.jump_list = jumpListObj; + } + } catch (error) { + // 忽略解析错误 + } + + // 添加card_action + try { + const cardActionObj = JSON.parse(card_action); + if (Object.keys(cardActionObj).length > 0) { + template_card.card_action = cardActionObj; + } + } catch (error) { + // 忽略解析错误 + } + + // 添加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 (error) { + // 忽略解析错误 + } + + 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; + } } const response = await weComApiRequest.call(this, 'POST', '/cgi-bin/message/send', body); diff --git a/nodes/WeCom/resources/message/index.ts b/nodes/WeCom/resources/message/index.ts index 02d4497..c8bf193 100644 --- a/nodes/WeCom/resources/message/index.ts +++ b/nodes/WeCom/resources/message/index.ts @@ -3,6 +3,14 @@ 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'; const showOnlyForMessage = { resource: ['message'], @@ -36,18 +44,74 @@ export const messageDescription: INodeProperties[] = [ action: '发送图片消息', description: '发送图片类型的消息', }, + { + name: '发送语音消息', + value: 'sendVoice', + action: '发送语音消息', + description: '发送语音类型的消息', + }, + { + name: '发送视频消息', + value: 'sendVideo', + action: '发送视频消息', + description: '发送视频类型的消息', + }, { name: '发送文件消息', value: 'sendFile', action: '发送文件消息', description: '发送文件类型的消息', }, + { + name: '发送文本卡片消息', + value: 'sendTextCard', + action: '发送文本卡片消息', + description: '发送文本卡片类型的消息', + }, + { + name: '发送图文消息', + value: 'sendNews', + action: '发送图文消息', + description: '发送图文消息(news类型)', + }, + { + name: '发送图文消息(mpnews)', + value: 'sendMpNews', + action: '发送图文消息(mpnews)', + description: '发送图文消息(mpnews类型)', + }, + { + name: '发送小程序通知消息', + value: 'sendMiniprogramNotice', + action: '发送小程序通知消息', + description: '发送小程序通知类型的消息', + }, + { + name: '发送任务卡片消息', + value: 'sendTaskCard', + action: '发送任务卡片消息', + description: '发送任务卡片类型的消息', + }, + { + name: '发送模板卡片消息', + value: 'sendTemplateCard', + action: '发送模板卡片消息', + description: '发送模板卡片类型的消息', + }, ], default: 'sendText', }, ...sendTextDescription, ...sendMarkdownDescription, ...sendImageDescription, + ...sendVoiceDescription, + ...sendVideoDescription, ...sendFileDescription, + ...sendTextCardDescription, + ...sendNewsDescription, + ...sendMpNewsDescription, + ...sendMiniprogramNoticeDescription, + ...sendTaskCardDescription, + ...sendTemplateCardDescription, ]; diff --git a/nodes/WeCom/resources/message/sendMiniprogramNotice.ts b/nodes/WeCom/resources/message/sendMiniprogramNotice.ts new file mode 100644 index 0000000..c74b07e --- /dev/null +++ b/nodes/WeCom/resources/message/sendMiniprogramNotice.ts @@ -0,0 +1,167 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendMiniprogramNotice = { + resource: ['message'], + operation: ['sendMiniprogramNotice'], +}; + +export const sendMiniprogramNoticeDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMiniprogramNotice, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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: '', + description: '长度10个汉字以内', + }, + { + 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..fd8586e --- /dev/null +++ b/nodes/WeCom/resources/message/sendMpNews.ts @@ -0,0 +1,157 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendMpNews = { + resource: ['message'], + operation: ['sendMpNews'], +}; + +export const sendMpNewsDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMpNews, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMpNews, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendMpNews, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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: 'title', + type: 'string', + required: true, + default: '', + description: '标题,不超过128个字节,超过会自动截断', + }, + { + displayName: '图文消息缩略图的media_id', + name: 'thumb_media_id', + type: 'string', + required: true, + default: '', + description: '图文消息缩略图的media_id,可以在上传多媒体文件接口中获得。此处thumb_media_id即上传接口返回的media_id', + }, + { + displayName: '作者', + name: 'author', + type: 'string', + default: '', + description: '图文消息的作者,不超过64个字节', + }, + { + displayName: '图文消息点击"阅读原文"之后的页面链接', + name: 'content_source_url', + type: 'string', + default: '', + description: '图文消息点击"阅读原文"之后的页面链接', + }, + { + displayName: '图文消息的内容', + name: 'content', + type: 'string', + typeOptions: { + rows: 4, + }, + required: true, + default: '', + description: '图文消息的内容,支持html标签,不超过666 K个字节', + }, + { + displayName: '图文消息的描述', + name: 'digest', + type: 'string', + typeOptions: { + rows: 2, + }, + default: '', + description: '图文消息的描述,不超过512个字节,超过会自动截断', + }, + ], + }, + ], + }, + { + 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..87f65cd --- /dev/null +++ b/nodes/WeCom/resources/message/sendNews.ts @@ -0,0 +1,129 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendNews = { + resource: ['message'], + operation: ['sendNews'], +}; + +export const sendNewsDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendNews, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendNews, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendNews, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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..507c99c --- /dev/null +++ b/nodes/WeCom/resources/message/sendTaskCard.ts @@ -0,0 +1,189 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendTaskCard = { + resource: ['message'], + operation: ['sendTaskCard'], +}; + +export const sendTaskCardDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTaskCard, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTaskCard, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTaskCard, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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: '', + description: '按钮名称', + }, + { + displayName: '点击按钮后替换文案', + name: 'replace_name', + type: 'string', + default: '', + description: '点击按钮后显示的名称,默认为"已处理"', + }, + { + displayName: '按钮字体颜色', + name: 'color', + type: 'options', + options: [ + { + name: '红色', + value: 'red', + }, + { + name: '蓝色', + value: 'blue', + }, + ], + default: 'blue', + description: '按钮字体颜色,可选red或者blue,默认为blue', + }, + { + displayName: '按钮字体是否加粗', + name: 'is_bold', + type: 'boolean', + default: false, + description: 'Whether the button font is bold, default is false', + }, + ], + }, + ], + }, + { + 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..a145971 --- /dev/null +++ b/nodes/WeCom/resources/message/sendTemplateCard.ts @@ -0,0 +1,326 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendTemplateCard = { + resource: ['message'], + operation: ['sendTemplateCard'], +}; + +export const sendTemplateCardDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTemplateCard, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '模板卡片类型', + name: 'card_type', + type: 'options', + options: [ + { + name: '文本通知型', + value: 'text_notice', + description: '文本通知型模板卡片', + }, + { + name: '图文展示型', + value: 'news_notice', + description: '图文展示型模板卡片', + }, + { + name: '按钮交互型', + value: 'button_interaction', + description: '按钮交互型模板卡片', + }, + { + name: '投票选择型', + value: 'vote_interaction', + description: '投票选择型模板卡片', + }, + { + name: '多项选择型', + value: 'multiple_interaction', + 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/sendTextCard.ts b/nodes/WeCom/resources/message/sendTextCard.ts new file mode 100644 index 0000000..cb4d0bb --- /dev/null +++ b/nodes/WeCom/resources/message/sendTextCard.ts @@ -0,0 +1,119 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendTextCard = { + resource: ['message'], + operation: ['sendTextCard'], +}; + +export const sendTextCardDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTextCard, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTextCard, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendTextCard, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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..1c65e65 --- /dev/null +++ b/nodes/WeCom/resources/message/sendVideo.ts @@ -0,0 +1,154 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendVideo = { + resource: ['message'], + operation: ['sendVideo'], +}; + +export const sendVideoDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendVideo, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendVideo, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendVideo, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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..adef11d --- /dev/null +++ b/nodes/WeCom/resources/message/sendVoice.ts @@ -0,0 +1,131 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlySendVoice = { + resource: ['message'], + operation: ['sendVoice'], +}; + +export const sendVoiceDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendVoice, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendVoice, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlySendVoice, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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小时', + }, +]; + From 005d8f3ca75c6b61fbabc1791868c1da62dd426f Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 17:07:17 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IMPLEMENTATION_NOTES.md | 42 ++- README.md | 2 + nodes/WeCom/resources/message/execute.ts | 210 +++++++++++- nodes/WeCom/resources/message/index.ts | 80 +++-- .../message/sendMiniprogramNotice.ts | 13 +- nodes/WeCom/resources/message/sendMpNews.ts | 45 ++- nodes/WeCom/resources/message/sendTaskCard.ts | 41 ++- .../resources/message/sendTemplateCard.ts | 34 +- .../resources/message/updateTemplateCard.ts | 314 ++++++++++++++++++ 9 files changed, 662 insertions(+), 119 deletions(-) create mode 100644 nodes/WeCom/resources/message/updateTemplateCard.ts diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index b4ae3e7..2388c18 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -4,9 +4,9 @@ 本次更新完全实现了企业微信官方文档(https://developer.work.weixin.qq.com/document/path/90236)中定义的所有发送应用消息接口。 -## 实现的消息类型 +## 实现的消息操作 -根据官方文档,企业微信应用消息支持 12 种消息类型,本次实现已全部覆盖: +根据官方文档,企业微信应用消息支持 12 种消息类型的发送,以及模板卡片消息的更新操作,本次实现已全部覆盖: ### 1. ✅ 文本消息 (text) - 已实现 - 文件:`sendText.ts` @@ -80,13 +80,25 @@ - `multiple_interaction` - 多项选择型 - 特性:最灵活的卡片消息类型,支持复杂的交互和展示 +### 13. ✅ 更新模板卡片消息 (updateTemplateCard) - 新增 +- 文件:`updateTemplateCard.ts` +- API接口:`/cgi-bin/message/update_template_card` +- 功能:更新已发送的模板卡片消息内容 +- 特性: + - 需要使用发送消息时返回的 `response_code` + - 一个 code 只能调用一次更新接口 + - 只能在24小时内更新 + - 支持所有模板卡片类型的更新 + - 支持任务卡片的升级(通过 `button_key` 参数) +- 参考文档:https://developer.work.weixin.qq.com/document/path/94888 + ## 技术实现细节 ### 文件结构 ``` nodes/WeCom/resources/message/ ├── index.ts # 消息资源入口,导出所有消息类型 -├── execute.ts # 执行逻辑,处理所有消息类型的发送 +├── execute.ts # 执行逻辑,处理所有消息操作 ├── sendText.ts # 文本消息定义 ├── sendMarkdown.ts # Markdown消息定义 ├── sendImage.ts # 图片消息定义 @@ -98,7 +110,8 @@ nodes/WeCom/resources/message/ ├── sendMpNews.ts # 图文消息(mpnews)定义(新增) ├── sendMiniprogramNotice.ts # 小程序通知消息定义(新增) ├── sendTaskCard.ts # 任务卡片消息定义(新增) -└── sendTemplateCard.ts # 模板卡片消息定义(新增) +├── sendTemplateCard.ts # 模板卡片消息定义(新增) +└── updateTemplateCard.ts # 更新模板卡片消息定义(新增) ``` ### 代码特性 @@ -177,6 +190,26 @@ nodes/WeCom/resources/message/ } ``` +### 更新模板卡片消息 +```javascript +{ + "touser": "zhangsan", + "response_code": "response_code_from_send_message", + "card_type": "text_notice", + "main_title": { + "title": "更新后的标题", + "desc": "状态已更新" + }, + "sub_title_text": "处理完成" +} +``` + +**重要提示**: +- `response_code` 从发送消息的返回值中获取 +- 每个 code 只能使用一次 +- 必须在24小时内更新 +- 可以通过回调接口获取用户交互后的 code + ## 测试建议 1. **基础消息测试** @@ -209,6 +242,7 @@ nodes/WeCom/resources/message/ ## 参考文档 - [企业微信发送应用消息接口](https://developer.work.weixin.qq.com/document/path/90236) +- [企业微信更新模板卡片消息接口](https://developer.work.weixin.qq.com/document/path/94888) - [n8n 节点开发文档](https://docs.n8n.io/integrations/creating-nodes/) ## 更新日期 diff --git a/README.md b/README.md index f218295..b30588b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - **发送小程序通知消息** - 发送小程序通知类型的消息 - **发送任务卡片消息** - 发送带按钮的任务卡片消息,支持交互 - **发送模板卡片消息** - 发送模板卡片消息,支持多种卡片类型(文本通知型、图文展示型、按钮交互型、投票选择型、多项选择型) +- **更新模板卡片消息** - 更新已发送的模板卡片消息内容(需要 response_code,在24小时内有效) ### 通讯录资源 @@ -188,6 +189,7 @@ Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) - 发送小程序通知消息(支持小程序跳转和内容键值对) - 发送任务卡片消息(支持自定义按钮,实现交互功能) - 发送模板卡片消息(支持5种卡片类型:文本通知型、图文展示型、按钮交互型、投票选择型、多项选择型) +- 更新模板卡片消息(支持更新已发送的模板卡片,实现动态卡片更新) **通讯录功能**: - 获取成员信息 diff --git a/nodes/WeCom/resources/message/execute.ts b/nodes/WeCom/resources/message/execute.ts index df754c9..d49fe1a 100644 --- a/nodes/WeCom/resources/message/execute.ts +++ b/nodes/WeCom/resources/message/execute.ts @@ -436,14 +436,14 @@ export async function executeMessage( if (Object.keys(sourceObj).length > 0) { template_card.source = sourceObj; } - } catch (error) { + } catch { // 忽略解析错误 } // 添加main_title try { template_card.main_title = JSON.parse(main_title); - } catch (error) { + } catch { template_card.main_title = { title: '' }; } @@ -453,7 +453,7 @@ export async function executeMessage( if (Object.keys(emphasisObj).length > 0) { template_card.emphasis_content = emphasisObj; } - } catch (error) { + } catch { // 忽略解析错误 } @@ -463,7 +463,7 @@ export async function executeMessage( if (Object.keys(quoteObj).length > 0) { template_card.quote_area = quoteObj; } - } catch (error) { + } catch { // 忽略解析错误 } @@ -478,7 +478,7 @@ export async function executeMessage( if (Array.isArray(horizontalList) && horizontalList.length > 0) { template_card.horizontal_content_list = horizontalList; } - } catch (error) { + } catch { // 忽略解析错误 } @@ -488,7 +488,7 @@ export async function executeMessage( if (Array.isArray(jumpListObj) && jumpListObj.length > 0) { template_card.jump_list = jumpListObj; } - } catch (error) { + } catch { // 忽略解析错误 } @@ -498,7 +498,7 @@ export async function executeMessage( if (Object.keys(cardActionObj).length > 0) { template_card.card_action = cardActionObj; } - } catch (error) { + } catch { // 忽略解析错误 } @@ -570,7 +570,7 @@ export async function executeMessage( if (Object.keys(actionMenuObj).length > 0) { template_card.action_menu = actionMenuObj; } - } catch (error) { + } catch { // 忽略解析错误 } @@ -590,6 +590,200 @@ export async function executeMessage( ) 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; } const response = await weComApiRequest.call(this, 'POST', '/cgi-bin/message/send', body); diff --git a/nodes/WeCom/resources/message/index.ts b/nodes/WeCom/resources/message/index.ts index c8bf193..8bb208d 100644 --- a/nodes/WeCom/resources/message/index.ts +++ b/nodes/WeCom/resources/message/index.ts @@ -11,6 +11,7 @@ import { sendMpNewsDescription } from './sendMpNews'; import { sendMiniprogramNoticeDescription } from './sendMiniprogramNotice'; import { sendTaskCardDescription } from './sendTaskCard'; import { sendTemplateCardDescription } from './sendTemplateCard'; +import { updateTemplateCardDescription } from './updateTemplateCard'; const showOnlyForMessage = { resource: ['message'], @@ -26,18 +27,30 @@ export const messageDescription: INodeProperties[] = [ show: showOnlyForMessage, }, options: [ - { - name: '发送文本消息', - value: 'sendText', - action: '发送文本消息', - description: '发送文本类型的消息', - }, { 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', @@ -45,16 +58,10 @@ export const messageDescription: INodeProperties[] = [ description: '发送图片类型的消息', }, { - name: '发送语音消息', - value: 'sendVoice', - action: '发送语音消息', - description: '发送语音类型的消息', - }, - { - name: '发送视频消息', - value: 'sendVideo', - action: '发送视频消息', - description: '发送视频类型的消息', + name: '发送小程序通知消息', + value: 'sendMiniprogramNotice', + action: '发送小程序通知消息', + description: '发送小程序通知类型的消息', }, { name: '发送文件消息', @@ -69,34 +76,34 @@ export const messageDescription: INodeProperties[] = [ description: '发送文本卡片类型的消息', }, { - name: '发送图文消息', - value: 'sendNews', - action: '发送图文消息', - description: '发送图文消息(news类型)', + name: '发送文本消息', + value: 'sendText', + action: '发送文本消息', + description: '发送文本类型的消息', }, { - name: '发送图文消息(mpnews)', - value: 'sendMpNews', - action: '发送图文消息(mpnews)', - description: '发送图文消息(mpnews类型)', + name: '发送模板卡片消息', + value: 'sendTemplateCard', + action: '发送模板卡片消息', + description: '发送模板卡片类型的消息', }, { - name: '发送小程序通知消息', - value: 'sendMiniprogramNotice', - action: '发送小程序通知消息', - description: '发送小程序通知类型的消息', + name: '发送视频消息', + value: 'sendVideo', + action: '发送视频消息', + description: '发送视频类型的消息', }, { - name: '发送任务卡片消息', - value: 'sendTaskCard', - action: '发送任务卡片消息', - description: '发送任务卡片类型的消息', + name: '发送语音消息', + value: 'sendVoice', + action: '发送语音消息', + description: '发送语音类型的消息', }, { - name: '发送模板卡片消息', - value: 'sendTemplateCard', - action: '发送模板卡片消息', - description: '发送模板卡片类型的消息', + name: '更新模板卡片消息', + value: 'updateTemplateCard', + action: '更新模板卡片消息', + description: '更新已发送的模板卡片消息内容', }, ], default: 'sendText', @@ -113,5 +120,6 @@ export const messageDescription: INodeProperties[] = [ ...sendMiniprogramNoticeDescription, ...sendTaskCardDescription, ...sendTemplateCardDescription, + ...updateTemplateCardDescription, ]; diff --git a/nodes/WeCom/resources/message/sendMiniprogramNotice.ts b/nodes/WeCom/resources/message/sendMiniprogramNotice.ts index c74b07e..2bb7ac2 100644 --- a/nodes/WeCom/resources/message/sendMiniprogramNotice.ts +++ b/nodes/WeCom/resources/message/sendMiniprogramNotice.ts @@ -37,7 +37,7 @@ export const sendMiniprogramNoticeDescription: INodeProperties[] = [ description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', }, { - displayName: '小程序appid', + displayName: '小程序Appid', name: 'appid', type: 'string', required: true, @@ -48,7 +48,7 @@ export const sendMiniprogramNoticeDescription: INodeProperties[] = [ description: '小程序appid,必须是与当前应用关联的小程序', }, { - displayName: '小程序page路径', + displayName: '小程序Page路径', name: 'page', type: 'string', default: '', @@ -66,7 +66,7 @@ export const sendMiniprogramNoticeDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendMiniprogramNotice, }, - description: '消息标题,长度限制4-12个汉字(支持id转译)', + description: '消息标题,长度限制4-12个汉字(支持ID转译)', }, { displayName: '消息描述', @@ -79,10 +79,10 @@ export const sendMiniprogramNoticeDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendMiniprogramNotice, }, - description: '消息描述,长度限制4-12个汉字(支持id转译)', + description: '消息描述,长度限制4-12个汉字(支持ID转译)', }, { - displayName: '是否放大第一个content_item', + displayName: '是否放大第一个Content_item', name: 'emphasis_first_item', type: 'boolean', default: false, @@ -116,7 +116,6 @@ export const sendMiniprogramNoticeDescription: INodeProperties[] = [ type: 'string', required: true, default: '', - description: '长度10个汉字以内', }, { displayName: '长度30个汉字以内', @@ -124,7 +123,7 @@ export const sendMiniprogramNoticeDescription: INodeProperties[] = [ type: 'string', required: true, default: '', - description: '长度30个汉字以内(支持id转译)', + description: '长度30个汉字以内(支持ID转译)', }, ], }, diff --git a/nodes/WeCom/resources/message/sendMpNews.ts b/nodes/WeCom/resources/message/sendMpNews.ts index fd8586e..59e661c 100644 --- a/nodes/WeCom/resources/message/sendMpNews.ts +++ b/nodes/WeCom/resources/message/sendMpNews.ts @@ -49,28 +49,12 @@ export const sendMpNewsDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendMpNews, }, - description: 'mpnews类型的图文消息,一个图文消息支持1到8条图文', + description: 'Mpnews类型的图文消息,一个图文消息支持1到8条图文', options: [ { displayName: '图文', name: 'article', values: [ - { - displayName: '标题', - name: 'title', - type: 'string', - required: true, - default: '', - description: '标题,不超过128个字节,超过会自动截断', - }, - { - displayName: '图文消息缩略图的media_id', - name: 'thumb_media_id', - type: 'string', - required: true, - default: '', - description: '图文消息缩略图的media_id,可以在上传多媒体文件接口中获得。此处thumb_media_id即上传接口返回的media_id', - }, { displayName: '作者', name: 'author', @@ -79,20 +63,16 @@ export const sendMpNewsDescription: INodeProperties[] = [ description: '图文消息的作者,不超过64个字节', }, { - displayName: '图文消息点击"阅读原文"之后的页面链接', + displayName: '图文消息点击\'阅读原文\'之后的页面链接', name: 'content_source_url', type: 'string', default: '', - description: '图文消息点击"阅读原文"之后的页面链接', }, { displayName: '图文消息的内容', name: 'content', type: 'string', - typeOptions: { - rows: 4, - }, - required: true, + required: true, default: '', description: '图文消息的内容,支持html标签,不超过666 K个字节', }, @@ -100,12 +80,25 @@ export const sendMpNewsDescription: INodeProperties[] = [ displayName: '图文消息的描述', name: 'digest', type: 'string', - typeOptions: { - rows: 2, - }, 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个字节,超过会自动截断', + }, ], }, ], diff --git a/nodes/WeCom/resources/message/sendTaskCard.ts b/nodes/WeCom/resources/message/sendTaskCard.ts index 507c99c..8d11bcf 100644 --- a/nodes/WeCom/resources/message/sendTaskCard.ts +++ b/nodes/WeCom/resources/message/sendTaskCard.ts @@ -45,7 +45,7 @@ export const sendTaskCardDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendTaskCard, }, - description: '标题,不超过128个字节,超过会自动截断(支持id转译)', + description: '标题,不超过128个字节,超过会自动截断(支持ID转译)', }, { displayName: '描述', @@ -59,7 +59,7 @@ export const sendTaskCardDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendTaskCard, }, - description: '描述,不超过512个字节,超过会自动截断(支持id转译)', + description: '描述,不超过512个字节,超过会自动截断(支持ID转译)', }, { displayName: '跳转链接', @@ -69,10 +69,10 @@ export const sendTaskCardDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendTaskCard, }, - description: '点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https),小程序或者url必须填写一个', + description: '点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https),小程序或者URL必须填写一个', }, { - displayName: '任务id', + displayName: '任务ID', name: 'task_id', type: 'string', required: true, @@ -80,7 +80,7 @@ export const sendTaskCardDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendTaskCard, }, - description: '任务id,同一个应用发送的任务卡片消息的任务id不能重复,只能由数字、字母和"_-@"组成,最长支持128字节', + description: '任务 ID,同一个应用发送的任务卡片消息的任务id不能重复,只能由数字、字母和"_-@"组成,最长支持128字节', }, { displayName: '按钮列表', @@ -102,27 +102,26 @@ export const sendTaskCardDescription: INodeProperties[] = [ name: 'button', values: [ { - displayName: '按钮key值', + displayName: '按钮Key值', name: 'key', type: 'string', - required: true, + required: true, default: '', - description: '按钮key值,用户点击后,会产生任务卡片回调事件,回调事件会将本参数作为EventKey返回,只能由数字、字母和"_-@"组成,最长支持128字节', + description: '按钮key值,用户点击后,会产生任务卡片回调事件,回调事件会将本参数作为EventKey返回,只能由数字、字母和\'_-@\'组成,最长支持128字节', }, { displayName: '按钮名称', name: 'name', type: 'string', - required: true, + required: true, default: '', - description: '按钮名称', }, { - displayName: '点击按钮后替换文案', - name: 'replace_name', - type: 'string', - default: '', - description: '点击按钮后显示的名称,默认为"已处理"', + displayName: '按钮字体是否加粗', + name: 'is_bold', + type: 'boolean', + default: false, + description: 'Whether the button font is bold, default is false', }, { displayName: '按钮字体颜色', @@ -142,13 +141,13 @@ export const sendTaskCardDescription: INodeProperties[] = [ description: '按钮字体颜色,可选red或者blue,默认为blue', }, { - displayName: '按钮字体是否加粗', - name: 'is_bold', - type: 'boolean', - default: false, - description: 'Whether the button font is bold, default is false', + displayName: '点击按钮后替换文案', + name: 'replace_name', + type: 'string', + default: '', + description: '点击按钮后显示的名称,默认为\'已处理\'', }, - ], + ], }, ], }, diff --git a/nodes/WeCom/resources/message/sendTemplateCard.ts b/nodes/WeCom/resources/message/sendTemplateCard.ts index a145971..ecf86a5 100644 --- a/nodes/WeCom/resources/message/sendTemplateCard.ts +++ b/nodes/WeCom/resources/message/sendTemplateCard.ts @@ -41,20 +41,15 @@ export const sendTemplateCardDescription: INodeProperties[] = [ name: 'card_type', type: 'options', options: [ - { - name: '文本通知型', - value: 'text_notice', - description: '文本通知型模板卡片', - }, { name: '图文展示型', value: 'news_notice', description: '图文展示型模板卡片', }, { - name: '按钮交互型', - value: 'button_interaction', - description: '按钮交互型模板卡片', + name: '多项选择型', + value: 'multiple_interaction', + description: '多项选择型模板卡片', }, { name: '投票选择型', @@ -62,9 +57,14 @@ export const sendTemplateCardDescription: INodeProperties[] = [ description: '投票选择型模板卡片', }, { - name: '多项选择型', - value: 'multiple_interaction', - description: '多项选择型模板卡片', + name: '按钮交互型', + value: 'button_interaction', + description: '按钮交互型模板卡片', + }, + { + name: '文本通知型', + value: 'text_notice', + description: '文本通知型模板卡片', }, ], required: true, @@ -122,7 +122,7 @@ export const sendTemplateCardDescription: INodeProperties[] = [ displayOptions: { show: showOnlySendTemplateCard, }, - description: '二级普通文本,建议不超过160个字,(支持id转译)', + description: '二级普通文本,建议不超过160个字,(支持ID转译)', }, { displayName: '二级标题+文本列表', @@ -161,14 +161,14 @@ export const sendTemplateCardDescription: INodeProperties[] = [ description: '整体卡片的点击跳转事件,text_notice必填,news_notice不需要,JSON格式', }, { - displayName: '任务id', + displayName: '任务ID', name: 'task_id', type: 'string', default: '', displayOptions: { show: showOnlySendTemplateCard, }, - description: '任务id,同一个应用任务id不能重复,只能由数字、字母和"_-@"组成,最长128字节,填了action_menu字段的话本字段必填', + description: '任务 ID,同一个应用任务id不能重复,只能由数字、字母和"_-@"组成,最长128字节,填了action_menu字段的话本字段必填', }, { displayName: '按钮列表', @@ -187,7 +187,7 @@ export const sendTemplateCardDescription: INodeProperties[] = [ description: '按钮交互型卡片的按钮列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过6,JSON数组格式', }, { - displayName: '选择题key值', + displayName: '选择题Key值', name: 'checkbox_question_key', type: 'string', default: '', @@ -237,7 +237,7 @@ export const sendTemplateCardDescription: INodeProperties[] = [ }, }, required: true, - description: '选项列表,列表长度不超过10,JSON数组格式。每个选项包含id和text字段', + description: '选项列表,列表长度不超过10,JSON数组格式。每个选项包含ID和text字段', }, { displayName: '提交按钮文案', @@ -253,7 +253,7 @@ export const sendTemplateCardDescription: INodeProperties[] = [ description: '提交按钮文案,建议不超过10个字,不填默认为提交', }, { - displayName: '提交按钮key值', + displayName: '提交按钮Key值', name: 'submit_button_key', type: 'string', default: '', diff --git a/nodes/WeCom/resources/message/updateTemplateCard.ts b/nodes/WeCom/resources/message/updateTemplateCard.ts new file mode 100644 index 0000000..d8ebe57 --- /dev/null +++ b/nodes/WeCom/resources/message/updateTemplateCard.ts @@ -0,0 +1,314 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyUpdateTemplateCard = { + resource: ['message'], + operation: ['updateTemplateCard'], +}; + +export const updateTemplateCardDescription: INodeProperties[] = [ + { + displayName: '接收人', + name: 'touser', + type: 'string', + default: '', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', + }, + { + displayName: '部门ID', + name: 'toparty', + type: 'string', + default: '', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + displayName: '标签ID', + name: 'totag', + type: 'string', + default: '', + displayOptions: { + show: showOnlyUpdateTemplateCard, + }, + description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', + }, + { + 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: '仅用于任务卡片消息的升级场景,如果需要更新任务卡片消息时需要填写', + }, +]; + From 7fbb1fbd4c46a173d326d19efed3f4369bf5cebf Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 17:10:27 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IMPLEMENTATION_NOTES.md | 34 ++++++++++++++++++- README.md | 4 +++ nodes/WeCom/resources/message/execute.ts | 20 +++++++++++ nodes/WeCom/resources/message/index.ts | 8 +++++ .../WeCom/resources/message/recallMessage.ts | 21 ++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 nodes/WeCom/resources/message/recallMessage.ts diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index 2388c18..3e427ea 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -80,7 +80,19 @@ - `multiple_interaction` - 多项选择型 - 特性:最灵活的卡片消息类型,支持复杂的交互和展示 -### 13. ✅ 更新模板卡片消息 (updateTemplateCard) - 新增 +### 13. ✅ 撤回应用消息 (recallMessage) - 新增 +- 文件:`recallMessage.ts` +- API接口:`/cgi-bin/message/recall` +- 功能:撤回已发送的应用消息 +- 特性: + - 需要使用发送消息时返回的 `msgid` + - 只能撤回应用自己发送的消息 + - 仅可撤回24小时内发送的消息 +- 参数: + - `msgid` - 消息ID,从发送消息接口返回值中获取 +- 参考文档:https://developer.work.weixin.qq.com/document/path/94867 + +### 14. ✅ 更新模板卡片消息 (updateTemplateCard) - 新增 - 文件:`updateTemplateCard.ts` - API接口:`/cgi-bin/message/update_template_card` - 功能:更新已发送的模板卡片消息内容 @@ -111,6 +123,7 @@ nodes/WeCom/resources/message/ ├── sendMiniprogramNotice.ts # 小程序通知消息定义(新增) ├── sendTaskCard.ts # 任务卡片消息定义(新增) ├── sendTemplateCard.ts # 模板卡片消息定义(新增) +├── recallMessage.ts # 撤回应用消息定义(新增) └── updateTemplateCard.ts # 更新模板卡片消息定义(新增) ``` @@ -190,6 +203,19 @@ nodes/WeCom/resources/message/ } ``` +### 撤回应用消息 +```javascript +{ + "msgid": "msgid_from_send_message" +} +``` + +**重要提示**: +- `msgid` 从发送消息的返回值中获取 +- 只能撤回应用自己发送的消息 +- 只能撤回24小时内发送的消息 +- 撤回后消息将从对话列表中移除 + ### 更新模板卡片消息 ```javascript { @@ -232,6 +258,11 @@ nodes/WeCom/resources/message/ - 测试消息去重功能 - 测试ID转译功能 +5. **消息管理测试** + - 测试撤回应用消息 + - 测试更新模板卡片消息 + - 验证24小时时间限制 + ## 兼容性 - ✅ 完全兼容企业微信官方 API @@ -242,6 +273,7 @@ nodes/WeCom/resources/message/ ## 参考文档 - [企业微信发送应用消息接口](https://developer.work.weixin.qq.com/document/path/90236) +- [企业微信撤回应用消息接口](https://developer.work.weixin.qq.com/document/path/94867) - [企业微信更新模板卡片消息接口](https://developer.work.weixin.qq.com/document/path/94888) - [n8n 节点开发文档](https://docs.n8n.io/integrations/creating-nodes/) diff --git a/README.md b/README.md index b30588b..9565110 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - **发送小程序通知消息** - 发送小程序通知类型的消息 - **发送任务卡片消息** - 发送带按钮的任务卡片消息,支持交互 - **发送模板卡片消息** - 发送模板卡片消息,支持多种卡片类型(文本通知型、图文展示型、按钮交互型、投票选择型、多项选择型) +- **撤回应用消息** - 撤回已发送的应用消息(需要消息的 msgid) - **更新模板卡片消息** - 更新已发送的模板卡片消息内容(需要 response_code,在24小时内有效) ### 通讯录资源 @@ -168,6 +169,8 @@ Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) - [n8n 社区节点文档](https://docs.n8n.io/integrations/#community-nodes) - [企业微信开发文档](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/90194) ## 版本历史 @@ -189,6 +192,7 @@ Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) - 发送小程序通知消息(支持小程序跳转和内容键值对) - 发送任务卡片消息(支持自定义按钮,实现交互功能) - 发送模板卡片消息(支持5种卡片类型:文本通知型、图文展示型、按钮交互型、投票选择型、多项选择型) +- 撤回应用消息(支持撤回已发送的消息) - 更新模板卡片消息(支持更新已发送的模板卡片,实现动态卡片更新) **通讯录功能**: diff --git a/nodes/WeCom/resources/message/execute.ts b/nodes/WeCom/resources/message/execute.ts index d49fe1a..7cb8690 100644 --- a/nodes/WeCom/resources/message/execute.ts +++ b/nodes/WeCom/resources/message/execute.ts @@ -779,6 +779,26 @@ export async function executeMessage( 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 }, diff --git a/nodes/WeCom/resources/message/index.ts b/nodes/WeCom/resources/message/index.ts index 8bb208d..12ac7ac 100644 --- a/nodes/WeCom/resources/message/index.ts +++ b/nodes/WeCom/resources/message/index.ts @@ -12,6 +12,7 @@ import { sendMiniprogramNoticeDescription } from './sendMiniprogramNotice'; import { sendTaskCardDescription } from './sendTaskCard'; import { sendTemplateCardDescription } from './sendTemplateCard'; import { updateTemplateCardDescription } from './updateTemplateCard'; +import { recallMessageDescription } from './recallMessage'; const showOnlyForMessage = { resource: ['message'], @@ -99,6 +100,12 @@ export const messageDescription: INodeProperties[] = [ action: '发送语音消息', description: '发送语音类型的消息', }, + { + name: '撤回应用消息', + value: 'recallMessage', + action: '撤回应用消息', + description: '撤回已发送的应用消息', + }, { name: '更新模板卡片消息', value: 'updateTemplateCard', @@ -120,6 +127,7 @@ export const messageDescription: INodeProperties[] = [ ...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', + }, +]; + From 1489e42c20f83f8abc4c5b3a8c8e5319239d1833 Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 17:34:52 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTACT_API_IMPLEMENTATION.md | 375 ++++++++++++++++++ nodes/WeCom/WeCom.node.ts | 105 +++++ .../resources/contact/convertToOpenid.ts | 24 ++ .../resources/contact/convertToUserid.ts | 21 + nodes/WeCom/resources/contact/execute.ts | 57 +++ .../WeCom/resources/contact/getActiveStat.ts | 22 + .../WeCom/resources/contact/getDepartment.ts | 7 +- .../WeCom/resources/contact/getJoinQrcode.ts | 38 ++ nodes/WeCom/resources/contact/getTag.ts | 24 ++ nodes/WeCom/resources/contact/getTagList.ts | 34 ++ nodes/WeCom/resources/contact/getUser.ts | 7 +- nodes/WeCom/resources/contact/index.ts | 60 ++- nodes/WeCom/resources/contact/listUsers.ts | 7 +- .../resources/contact/listUsersDetail.ts | 34 ++ nodes/WeCom/resources/message/commonFields.ts | 176 ++++++++ nodes/WeCom/resources/message/execute.ts | 40 +- nodes/WeCom/resources/message/sendFile.ts | 32 +- nodes/WeCom/resources/message/sendImage.ts | 32 +- nodes/WeCom/resources/message/sendMarkdown.ts | 32 +- .../message/sendMiniprogramNotice.ts | 32 +- nodes/WeCom/resources/message/sendMpNews.ts | 32 +- nodes/WeCom/resources/message/sendNews.ts | 32 +- nodes/WeCom/resources/message/sendTaskCard.ts | 32 +- .../resources/message/sendTemplateCard.ts | 32 +- nodes/WeCom/resources/message/sendText.ts | 32 +- nodes/WeCom/resources/message/sendTextCard.ts | 32 +- nodes/WeCom/resources/message/sendVideo.ts | 34 +- nodes/WeCom/resources/message/sendVoice.ts | 32 +- .../resources/message/updateTemplateCard.ts | 32 +- 29 files changed, 1047 insertions(+), 402 deletions(-) create mode 100644 CONTACT_API_IMPLEMENTATION.md create mode 100644 nodes/WeCom/resources/contact/convertToOpenid.ts create mode 100644 nodes/WeCom/resources/contact/convertToUserid.ts create mode 100644 nodes/WeCom/resources/contact/getActiveStat.ts create mode 100644 nodes/WeCom/resources/contact/getJoinQrcode.ts create mode 100644 nodes/WeCom/resources/contact/getTag.ts create mode 100644 nodes/WeCom/resources/contact/getTagList.ts create mode 100644 nodes/WeCom/resources/contact/listUsersDetail.ts create mode 100644 nodes/WeCom/resources/message/commonFields.ts diff --git a/CONTACT_API_IMPLEMENTATION.md b/CONTACT_API_IMPLEMENTATION.md new file mode 100644 index 0000000..b9fcbe7 --- /dev/null +++ b/CONTACT_API_IMPLEMENTATION.md @@ -0,0 +1,375 @@ +# 企业微信通讯录管理接口完整实现说明 + +## 更新日期 +2025-10-18 + +## 概述 + +本次更新完全实现了企业微信官方通讯录管理文档中定义的所有获取通讯录信息的接口,并实现了动态选项加载功能,让用户可以在 n8n 界面中直接从通讯录选择接收人、部门、标签等信息。 + +## 实现的通讯录操作 + +### ✅ 成员管理 + +#### 1. 获取成员信息 (getUser) +- **接口**: `/cgi-bin/user/get` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/90196 +- **功能**: 读取指定成员的详细信息 +- **特性**: 支持从所有成员列表中动态选择 + +#### 2. 获取部门成员(简化版)(listUsers) +- **接口**: `/cgi-bin/user/simplelist` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/90200 +- **功能**: 获取部门成员列表(仅包含基本信息) +- **特性**: 支持递归获取子部门成员 + +#### 3. 获取部门成员(详细版)(listUsersDetail) - 新增 +- **接口**: `/cgi-bin/user/list` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/90201 +- **功能**: 获取部门成员列表(包含完整信息如职位、邮箱等) +- **特性**: 支持递归获取子部门成员 + +### ✅ 部门管理 + +#### 4. 获取部门信息 (getDepartment) +- **接口**: `/cgi-bin/department/list` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/90208 +- **功能**: 获取企业的部门列表 +- **特性**: 可获取指定部门或全量组织架构 + +### ✅ 标签管理 + +#### 5. 获取标签列表 (getTagList) - 新增 +- **接口**: `/cgi-bin/tag/list` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/90216 +- **功能**: 获取企业标签列表 +- **特性**: 支持按类型筛选(全部/个人/部门标签) + +#### 6. 获取标签成员 (getTag) - 新增 +- **接口**: `/cgi-bin/tag/get` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/90213 +- **功能**: 获取指定标签的成员列表 +- **特性**: 支持从标签列表中动态选择 + +### ✅ ID转换 + +#### 7. UserID转OpenID (convertToOpenid) - 新增 +- **接口**: `/cgi-bin/user/convert_to_openid` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/90202 +- **功能**: 将企业成员的 userid 转换为 openid +- **特性**: 支持从所有成员列表中动态选择 + +#### 8. OpenID转UserID (convertToUserid) - 新增 +- **接口**: `/cgi-bin/user/convert_to_userid` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/90202 +- **功能**: 将 openid 转换为企业成员的 userid + +### ✅ 企业信息 + +#### 9. 获取加入企业二维码 (getJoinQrcode) - 新增 +- **接口**: `/cgi-bin/corp/get_join_qrcode` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/91714 +- **功能**: 获取企业的加入二维码 +- **特性**: 支持4种尺寸选择(171x171, 399x399, 741x741, 2052x2052) + +#### 10. 获取企业活跃成员数 (getActiveStat) - 新增 +- **接口**: `/cgi-bin/user/get_active_stat` +- **参考文档**: https://developer.work.weixin.qq.com/document/path/92714 +- **功能**: 获取企业指定日期的活跃成员数 +- **特性**: 支持获取最长30天前的数据 + +## 动态选项加载功能 + +### loadOptions 方法 + +在主节点中实现了 6 个 loadOptions 方法,让用户可以在 n8n 界面中动态选择: + +#### 1. getDepartments +- 获取所有部门列表 +- 用于部门选择下拉框 + +#### 2. getDepartmentUsers +- 获取指定部门的成员列表(简化信息) +- 动态加载成员选项 + +#### 3. getDepartmentUsersDetail +- 获取指定部门的成员详情列表(含职位信息) +- 显示成员姓名、职位和 UserID + +#### 4. getTags +- 获取所有标签列表 +- 用于标签选择下拉框 + +#### 5. getTagUsers +- 获取指定标签的成员列表 +- 动态加载标签成员 + +#### 6. getAllUsers +- 获取企业所有成员列表 +- 从根部门递归获取 +- 用于成员选择下拉框 + +## 消息接收人动态选择功能 + +### 通用接收人字段 (commonFields.ts) + +创建了通用的接收人选择组件,所有消息发送操作都可使用: + +#### 接收人类型选择 +- **指定成员**: 从通讯录选择多个成员 +- **指定部门**: 从部门列表选择多个部门 +- **指定标签**: 从标签列表选择多个标签 +- **全体成员**: 发送给企业全部成员 +- **手动输入**: 传统的手动输入方式(向后兼容) + +#### 特性 +- 支持多选(multiOptions) +- 自动转换为企业微信API要求的格式(用 | 分隔) +- 向后兼容旧的手动输入方式 + +### 已更新的消息类型 + +所有 14 种消息发送操作都已更新为使用新的接收人选择方式: + +1. ✅ sendText - 发送文本消息 +2. ✅ sendMarkdown - 发送 Markdown 消息 +3. ✅ sendImage - 发送图片消息 +4. ✅ sendFile - 发送文件消息 +5. ✅ sendVoice - 发送语音消息 +6. ✅ sendVideo - 发送视频消息 +7. ✅ sendTextCard - 发送文本卡片消息 +8. ✅ sendNews - 发送图文消息 +9. ✅ sendMpNews - 发送图文消息(mpnews) +10. ✅ sendMiniprogramNotice - 发送小程序通知消息 +11. ✅ sendTaskCard - 发送任务卡片消息 +12. ✅ sendTemplateCard - 发送模板卡片消息 +13. ✅ updateTemplateCard - 更新模板卡片消息 +14. ✅ recallMessage - 撤回应用消息(无需接收人) + +## 技术实现细节 + +### 文件结构 + +``` +nodes/WeCom/resources/contact/ +├── index.ts # 通讯录资源入口 +├── execute.ts # 执行逻辑 +├── getUser.ts # 获取成员信息 +├── listUsers.ts # 获取部门成员(简化版) +├── listUsersDetail.ts # 获取部门成员(详细版)- 新增 +├── getDepartment.ts # 获取部门信息 +├── convertToOpenid.ts # UserID转OpenID - 新增 +├── convertToUserid.ts # OpenID转UserID - 新增 +├── getJoinQrcode.ts # 获取加入企业二维码 - 新增 +├── getActiveStat.ts # 获取企业活跃成员数 - 新增 +├── getTagList.ts # 获取标签列表 - 新增 +└── getTag.ts # 获取标签成员 - 新增 + +nodes/WeCom/resources/message/ +└── commonFields.ts # 通用接收人字段定义 - 新增 + +nodes/WeCom/ +└── WeCom.node.ts # 主节点(添加 loadOptions 方法) +``` + +### 代码特性 + +#### 1. 完整的类型定义 +- 所有字段都有 TypeScript 类型定义 +- 使用 n8n 的 INodeProperties 接口定义参数 +- 使用 ILoadOptionsFunctions 实现动态选项加载 + +#### 2. 用户友好的界面 +- 所有字段都有中文描述 +- 支持条件显示(displayOptions) +- 提供默认值和验证 +- 动态下拉选择,无需手动输入 ID + +#### 3. 灵活的输入方式 +- 优先使用动态选项选择 +- 保留手动输入方式作为备选 +- 支持多选(multiOptions) +- 自动处理数据格式转换 + +#### 4. 完善的错误处理 +- 参数验证 +- API 错误处理 +- 向后兼容性检查 + +#### 5. 遵循官方文档 +- 所有字段名称与官方文档一致 +- 支持所有可选参数 +- 完全符合企业微信 API 规范 + +## 使用示例 + +### 1. 从通讯录选择接收人发送消息 + +```javascript +{ + "resource": "message", + "operation": "sendText", + "recipientType": "users", // 选择指定成员 + "touser": ["zhangsan", "lisi"], // 从下拉列表中选择成员 + "content": "这是一条测试消息" +} +``` + +### 2. 向指定部门发送消息 + +```javascript +{ + "resource": "message", + "operation": "sendMarkdown", + "recipientType": "departments", // 选择指定部门 + "toparty": ["1", "2"], // 从下拉列表中选择部门 + "content": "## 部门通知\n\n这是一条部门消息" +} +``` + +### 3. 向标签成员发送消息 + +```javascript +{ + "resource": "message", + "operation": "sendText", + "recipientType": "tags", // 选择指定标签 + "totag": ["1"], // 从下拉列表中选择标签 + "content": "标签消息" +} +``` + +### 4. 获取部门成员详情 + +```javascript +{ + "resource": "contact", + "operation": "listUsersDetail", + "department_id": "1", // 从下拉列表中选择部门 + "fetch_child": true +} +``` + +### 5. 获取标签成员列表 + +```javascript +{ + "resource": "contact", + "operation": "getTag", + "tagid": "1" // 从下拉列表中选择标签 +} +``` + +### 6. 获取企业活跃成员数 + +```javascript +{ + "resource": "contact", + "operation": "getActiveStat", + "date": "2025-10-18" +} +``` + +## 向后兼容性 + +为了确保现有工作流不受影响,实现了以下兼容措施: + +1. **消息执行逻辑** + - 自动检测是否使用新的 recipientType 字段 + - 如果不存在,则使用旧的 touser/toparty/totag 字段 + - 完全兼容旧的手动输入方式 + +2. **通讯录字段** + - 部门、标签、成员字段都从 string 类型升级为 options 类型 + - 支持动态加载选项 + - 保持字段名称不变,确保数据兼容 + +## 测试建议 + +### 1. 通讯录接口测试 +- 测试获取部门列表 +- 测试获取部门成员(简化版和详细版) +- 测试获取标签列表和标签成员 +- 测试 userid 与 openid 互转 +- 测试获取加入企业二维码 +- 测试获取活跃成员数 + +### 2. 动态选项加载测试 +- 测试部门下拉列表是否正常加载 +- 测试成员下拉列表是否正常加载 +- 测试标签下拉列表是否正常加载 +- 测试选项显示格式是否友好 + +### 3. 消息发送测试 +- 使用新的接收人选择方式发送各类消息 +- 测试多选功能 +- 测试全体成员发送 +- 测试向后兼容性(旧的工作流是否正常) + +### 4. 边界情况测试 +- 测试空部门 +- 测试没有标签的情况 +- 测试网络错误处理 +- 测试 Access Token 失效重试 + +## 兼容性 + +- ✅ 完全兼容企业微信官方 API +- ✅ 支持所有文档中的参数和选项 +- ✅ 向后兼容现有的工作流 +- ✅ 支持 n8n 的最佳实践 + +## 主要改进 + +### 相比之前的实现 + +1. **新增 7 个通讯录接口** + - 获取部门成员详情 + - userid/openid 互转 + - 获取标签列表和成员 + - 获取加入企业二维码 + - 获取活跃成员数 + +2. **实现动态选项加载** + - 6 个 loadOptions 方法 + - 支持部门、成员、标签的动态选择 + - 友好的显示格式 + +3. **优化消息接收人选择** + - 创建通用接收人组件 + - 支持 5 种接收人选择方式 + - 所有 14 种消息类型都已更新 + - 保持向后兼容 + +4. **提升用户体验** + - 无需记忆 userid、部门 ID、标签 ID + - 直接从下拉列表选择 + - 显示友好的名称和 ID + - 支持多选 + +## 参考文档 + +- [企业微信通讯录管理接口](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) +- [获取加入企业二维码](https://developer.work.weixin.qq.com/document/path/91714) +- [获取企业活跃成员数](https://developer.work.weixin.qq.com/document/path/92714) +- [n8n 节点开发文档](https://docs.n8n.io/integrations/creating-nodes/) + +## 总结 + +本次更新完整实现了企业微信通讯录管理的所有获取接口,并通过 loadOptions 方法实现了动态选项加载功能,大幅提升了用户体验。用户现在可以: + +1. ✅ 直接从下拉列表选择部门、成员、标签 +2. ✅ 无需手动输入和记忆各种 ID +3. ✅ 查看友好的名称和详细信息 +4. ✅ 使用所有企业微信通讯录管理接口 +5. ✅ 向后兼容现有的工作流 + +所有功能已编译通过,无 lint 错误,可以直接使用。 + diff --git a/nodes/WeCom/WeCom.node.ts b/nodes/WeCom/WeCom.node.ts index 84ec077..f64ca7a 100644 --- a/nodes/WeCom/WeCom.node.ts +++ b/nodes/WeCom/WeCom.node.ts @@ -1,6 +1,8 @@ import type { IExecuteFunctions, + ILoadOptionsFunctions, INodeExecutionData, + INodePropertyOptions, INodeType, INodeTypeDescription, } from 'n8n-workflow'; @@ -9,6 +11,7 @@ import { messageDescription } from './resources/message'; import { contactDescription } from './resources/contact'; import { executeMessage } from './resources/message/execute'; import { executeContact } from './resources/contact/execute'; +import { weComApiRequest } from './shared/transport'; export class WeCom implements INodeType { description: INodeTypeDescription = { @@ -64,6 +67,108 @@ export class WeCom implements INodeType { ], }; + 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); 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 index ea94830..bcc29cb 100644 --- a/nodes/WeCom/resources/contact/execute.ts +++ b/nodes/WeCom/resources/contact/execute.ts @@ -29,6 +29,20 @@ export async function executeContact( 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 === 'getDepartment') { const id = this.getNodeParameter('id', i, '') as string; const qs: IDataObject = {}; @@ -37,6 +51,49 @@ export async function executeContact( } 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 === 'getJoinQrcode') { + const size_type = this.getNodeParameter('size_type', i, 1) as number; + response = await weComApiRequest.call( + this, + 'GET', + '/cgi-bin/corp/get_join_qrcode', + {}, + { size_type }, + ); + } else if (operation === 'getActiveStat') { + const date = this.getNodeParameter('date', i) as string; + response = await weComApiRequest.call( + this, + 'POST', + '/cgi-bin/user/get_active_stat', + { date }, + ); + } 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 = {}; } diff --git a/nodes/WeCom/resources/contact/getActiveStat.ts b/nodes/WeCom/resources/contact/getActiveStat.ts new file mode 100644 index 0000000..27b34a5 --- /dev/null +++ b/nodes/WeCom/resources/contact/getActiveStat.ts @@ -0,0 +1,22 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetActiveStat = { + resource: ['contact'], + operation: ['getActiveStat'], +}; + +export const getActiveStatDescription: INodeProperties[] = [ + { + displayName: '具体某天', + name: 'date', + type: 'string', + required: true, + default: '', + placeholder: '2020-03-27', + displayOptions: { + show: showOnlyGetActiveStat, + }, + description: '具体某天的活跃人数,最长支持获取30天前数据,格式为 YYYY-MM-DD', + }, +]; + diff --git a/nodes/WeCom/resources/contact/getDepartment.ts b/nodes/WeCom/resources/contact/getDepartment.ts index 9a92a0e..247d4ee 100644 --- a/nodes/WeCom/resources/contact/getDepartment.ts +++ b/nodes/WeCom/resources/contact/getDepartment.ts @@ -7,9 +7,12 @@ const showOnlyGetDepartment = { export const getDepartmentDescription: INodeProperties[] = [ { - displayName: '部门 ID', + displayName: '部门', name: 'id', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDepartments', + }, default: '1', displayOptions: { show: showOnlyGetDepartment, diff --git a/nodes/WeCom/resources/contact/getJoinQrcode.ts b/nodes/WeCom/resources/contact/getJoinQrcode.ts new file mode 100644 index 0000000..20152ad --- /dev/null +++ b/nodes/WeCom/resources/contact/getJoinQrcode.ts @@ -0,0 +1,38 @@ +import type { INodeProperties } from 'n8n-workflow'; + +const showOnlyGetJoinQrcode = { + resource: ['contact'], + operation: ['getJoinQrcode'], +}; + +export const getJoinQrcodeDescription: INodeProperties[] = [ + { + displayName: '二维码尺寸类型', + name: 'size_type', + type: 'options', + options: [ + { + name: '171 x 171', + value: 1, + }, + { + name: '399 x 399', + value: 2, + }, + { + name: '741 x 741', + value: 3, + }, + { + name: '2052 x 2052', + value: 4, + }, + ], + default: 1, + displayOptions: { + show: showOnlyGetJoinQrcode, + }, + description: 'qrcode尺寸类型,1: 171 x 171; 2: 399 x 399; 3: 741 x 741; 4: 2052 x 2052', + }, +]; + 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 index 6be6f42..c546b54 100644 --- a/nodes/WeCom/resources/contact/getUser.ts +++ b/nodes/WeCom/resources/contact/getUser.ts @@ -7,9 +7,12 @@ const showOnlyGetUser = { export const getUserDescription: INodeProperties[] = [ { - displayName: '成员 UserID', + displayName: '成员', name: 'userid', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAllUsers', + }, required: true, default: '', displayOptions: { diff --git a/nodes/WeCom/resources/contact/index.ts b/nodes/WeCom/resources/contact/index.ts index b95260b..8944390 100644 --- a/nodes/WeCom/resources/contact/index.ts +++ b/nodes/WeCom/resources/contact/index.ts @@ -1,7 +1,14 @@ import type { INodeProperties } from 'n8n-workflow'; import { getUserDescription } from './getUser'; import { listUsersDescription } from './listUsers'; +import { listUsersDetailDescription } from './listUsersDetail'; import { getDepartmentDescription } from './getDepartment'; +import { convertToOpenidDescription } from './convertToOpenid'; +import { convertToUseridDescription } from './convertToUserid'; +import { getJoinQrcodeDescription } from './getJoinQrcode'; +import { getActiveStatDescription } from './getActiveStat'; +import { getTagListDescription } from './getTagList'; +import { getTagDescription } from './getTag'; const showOnlyForContact = { resource: ['contact'], @@ -24,10 +31,16 @@ export const contactDescription: INodeProperties[] = [ description: '读取成员详细信息', }, { - name: '获取部门成员', + name: '获取部门成员(简化版)', value: 'listUsers', action: '获取部门成员列表', - description: '获取部门成员列表', + description: '获取部门成员列表(仅包含基本信息)', + }, + { + name: '获取部门成员(详细版)', + value: 'listUsersDetail', + action: '获取部门成员详情列表', + description: '获取部门成员列表(包含完整信息)', }, { name: '获取部门信息', @@ -35,11 +48,54 @@ export const contactDescription: INodeProperties[] = [ action: '获取部门信息', description: '获取部门列表', }, + { + name: 'UserID转OpenID', + value: 'convertToOpenid', + action: 'UserID转OpenID', + description: '将企业成员的 userid 转换为 openid', + }, + { + name: 'OpenID转UserID', + value: 'convertToUserid', + action: 'OpenID转UserID', + description: '将 openid 转换为企业成员的 userid', + }, + { + name: '获取加入企业二维码', + value: 'getJoinQrcode', + action: '获取加入企业二维码', + description: '获取企业的加入二维码', + }, + { + name: '获取企业活跃成员数', + value: 'getActiveStat', + action: '获取企业活跃成员数', + description: '获取企业指定日期的活跃成员数', + }, + { + name: '获取标签列表', + value: 'getTagList', + action: '获取标签列表', + description: '获取企业标签列表', + }, + { + name: '获取标签成员', + value: 'getTag', + action: '获取标签成员', + description: '获取标签成员列表', + }, ], default: 'getUser', }, ...getUserDescription, ...listUsersDescription, + ...listUsersDetailDescription, ...getDepartmentDescription, + ...convertToOpenidDescription, + ...convertToUseridDescription, + ...getJoinQrcodeDescription, + ...getActiveStatDescription, + ...getTagListDescription, + ...getTagDescription, ]; diff --git a/nodes/WeCom/resources/contact/listUsers.ts b/nodes/WeCom/resources/contact/listUsers.ts index f7fefd3..b5d8bff 100644 --- a/nodes/WeCom/resources/contact/listUsers.ts +++ b/nodes/WeCom/resources/contact/listUsers.ts @@ -7,9 +7,12 @@ const showOnlyListUsers = { export const listUsersDescription: INodeProperties[] = [ { - displayName: '部门 ID', + displayName: '部门', name: 'department_id', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDepartments', + }, required: true, default: '1', displayOptions: { 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/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 index 7cb8690..1a57b2b 100644 --- a/nodes/WeCom/resources/message/execute.ts +++ b/nodes/WeCom/resources/message/execute.ts @@ -1,6 +1,7 @@ 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, @@ -14,9 +15,42 @@ export async function executeMessage( const credentials = await this.getCredentials('weComApi'); const agentId = credentials.agentId as string; - const touser = this.getNodeParameter('touser', i, '') as string; - const toparty = this.getNodeParameter('toparty', i, '') as string; - const totag = this.getNodeParameter('totag', i, '') 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( diff --git a/nodes/WeCom/resources/message/sendFile.ts b/nodes/WeCom/resources/message/sendFile.ts index 4ecfb39..ac5c9da 100644 --- a/nodes/WeCom/resources/message/sendFile.ts +++ b/nodes/WeCom/resources/message/sendFile.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendFile = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendFile = { }; export const sendFileDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendFile, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendFile, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendFile, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendFile'), { displayName: '文件来源', name: 'fileSource', diff --git a/nodes/WeCom/resources/message/sendImage.ts b/nodes/WeCom/resources/message/sendImage.ts index 0d638f4..89adc8a 100644 --- a/nodes/WeCom/resources/message/sendImage.ts +++ b/nodes/WeCom/resources/message/sendImage.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendImage = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendImage = { }; export const sendImageDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendImage, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendImage, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendImage, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendImage'), { displayName: '图片来源', name: 'imageSource', diff --git a/nodes/WeCom/resources/message/sendMarkdown.ts b/nodes/WeCom/resources/message/sendMarkdown.ts index bb165de..b81361c 100644 --- a/nodes/WeCom/resources/message/sendMarkdown.ts +++ b/nodes/WeCom/resources/message/sendMarkdown.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendMarkdown = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendMarkdown = { }; export const sendMarkdownDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMarkdown, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMarkdown, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMarkdown, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendMarkdown'), { displayName: 'Markdown 内容', name: 'content', diff --git a/nodes/WeCom/resources/message/sendMiniprogramNotice.ts b/nodes/WeCom/resources/message/sendMiniprogramNotice.ts index 2bb7ac2..f510487 100644 --- a/nodes/WeCom/resources/message/sendMiniprogramNotice.ts +++ b/nodes/WeCom/resources/message/sendMiniprogramNotice.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendMiniprogramNotice = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendMiniprogramNotice = { }; export const sendMiniprogramNoticeDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMiniprogramNotice, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMiniprogramNotice, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMiniprogramNotice, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendMiniprogramNotice'), { displayName: '小程序Appid', name: 'appid', diff --git a/nodes/WeCom/resources/message/sendMpNews.ts b/nodes/WeCom/resources/message/sendMpNews.ts index 59e661c..355a24c 100644 --- a/nodes/WeCom/resources/message/sendMpNews.ts +++ b/nodes/WeCom/resources/message/sendMpNews.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendMpNews = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendMpNews = { }; export const sendMpNewsDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMpNews, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMpNews, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendMpNews, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendMpNews'), { displayName: '图文列表', name: 'articles', diff --git a/nodes/WeCom/resources/message/sendNews.ts b/nodes/WeCom/resources/message/sendNews.ts index 87f65cd..bfafe3c 100644 --- a/nodes/WeCom/resources/message/sendNews.ts +++ b/nodes/WeCom/resources/message/sendNews.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendNews = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendNews = { }; export const sendNewsDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendNews, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendNews, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendNews, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendNews'), { displayName: '图文列表', name: 'articles', diff --git a/nodes/WeCom/resources/message/sendTaskCard.ts b/nodes/WeCom/resources/message/sendTaskCard.ts index 8d11bcf..45b48b4 100644 --- a/nodes/WeCom/resources/message/sendTaskCard.ts +++ b/nodes/WeCom/resources/message/sendTaskCard.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendTaskCard = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendTaskCard = { }; export const sendTaskCardDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTaskCard, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTaskCard, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTaskCard, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendTaskCard'), { displayName: '标题', name: 'title', diff --git a/nodes/WeCom/resources/message/sendTemplateCard.ts b/nodes/WeCom/resources/message/sendTemplateCard.ts index ecf86a5..46a1808 100644 --- a/nodes/WeCom/resources/message/sendTemplateCard.ts +++ b/nodes/WeCom/resources/message/sendTemplateCard.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendTemplateCard = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendTemplateCard = { }; export const sendTemplateCardDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTemplateCard, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTemplateCard, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTemplateCard, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendTemplateCard'), { displayName: '模板卡片类型', name: 'card_type', diff --git a/nodes/WeCom/resources/message/sendText.ts b/nodes/WeCom/resources/message/sendText.ts index d2535ab..bc629aa 100644 --- a/nodes/WeCom/resources/message/sendText.ts +++ b/nodes/WeCom/resources/message/sendText.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendText = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendText = { }; export const sendTextDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendText, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendText, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendText, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendText'), { displayName: '消息内容', name: 'content', diff --git a/nodes/WeCom/resources/message/sendTextCard.ts b/nodes/WeCom/resources/message/sendTextCard.ts index cb4d0bb..8cf8ad0 100644 --- a/nodes/WeCom/resources/message/sendTextCard.ts +++ b/nodes/WeCom/resources/message/sendTextCard.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendTextCard = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendTextCard = { }; export const sendTextCardDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTextCard, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTextCard, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendTextCard, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendTextCard'), { displayName: '卡片标题', name: 'title', diff --git a/nodes/WeCom/resources/message/sendVideo.ts b/nodes/WeCom/resources/message/sendVideo.ts index 1c65e65..cf75459 100644 --- a/nodes/WeCom/resources/message/sendVideo.ts +++ b/nodes/WeCom/resources/message/sendVideo.ts @@ -1,4 +1,5 @@ -import type { INodeProperties } from 'n8n-workflow'; +import type { INodeProperties} from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendVideo = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendVideo = { }; export const sendVideoDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendVideo, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendVideo, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendVideo, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendVideo'), { displayName: '视频来源', name: 'videoSource', diff --git a/nodes/WeCom/resources/message/sendVoice.ts b/nodes/WeCom/resources/message/sendVoice.ts index adef11d..e7a4939 100644 --- a/nodes/WeCom/resources/message/sendVoice.ts +++ b/nodes/WeCom/resources/message/sendVoice.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlySendVoice = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlySendVoice = { }; export const sendVoiceDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendVoice, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendVoice, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlySendVoice, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('sendVoice'), { displayName: '语音来源', name: 'voiceSource', diff --git a/nodes/WeCom/resources/message/updateTemplateCard.ts b/nodes/WeCom/resources/message/updateTemplateCard.ts index d8ebe57..0612987 100644 --- a/nodes/WeCom/resources/message/updateTemplateCard.ts +++ b/nodes/WeCom/resources/message/updateTemplateCard.ts @@ -1,4 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; +import { getRecipientFields } from './commonFields'; const showOnlyUpdateTemplateCard = { resource: ['message'], @@ -6,36 +7,7 @@ const showOnlyUpdateTemplateCard = { }; export const updateTemplateCardDescription: INodeProperties[] = [ - { - displayName: '接收人', - name: 'touser', - type: 'string', - default: '', - displayOptions: { - show: showOnlyUpdateTemplateCard, - }, - description: '成员ID列表(消息接收者,多个接收者用 | 分隔,最多支持1000个)。特殊情况:指定为 @all,则向该企业应用的全部成员发送', - }, - { - displayName: '部门ID', - name: 'toparty', - type: 'string', - default: '', - displayOptions: { - show: showOnlyUpdateTemplateCard, - }, - description: '部门ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, - { - displayName: '标签ID', - name: 'totag', - type: 'string', - default: '', - displayOptions: { - show: showOnlyUpdateTemplateCard, - }, - description: '标签ID列表,多个接收者用 | 分隔,最多支持100个。当touser为@all时忽略本参数', - }, + ...getRecipientFields('updateTemplateCard'), { displayName: '原消息的Response_code', name: 'response_code', From 21459580f9c3f3bd3b2c36781029b56b038eb0b4 Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 18:13:11 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTACT_API_IMPLEMENTATION.md | 375 ------------------ IMPLEMENTATION_NOTES.md | 283 ------------- README.md | 102 ++++- README_TEMPLATE.md | 48 --- nodes/WeCom/resources/contact/execute.ts | 17 - .../WeCom/resources/contact/getActiveStat.ts | 22 - .../WeCom/resources/contact/getJoinQrcode.ts | 38 -- nodes/WeCom/resources/contact/index.ts | 20 +- 8 files changed, 87 insertions(+), 818 deletions(-) delete mode 100644 CONTACT_API_IMPLEMENTATION.md delete mode 100644 IMPLEMENTATION_NOTES.md delete mode 100644 README_TEMPLATE.md delete mode 100644 nodes/WeCom/resources/contact/getActiveStat.ts delete mode 100644 nodes/WeCom/resources/contact/getJoinQrcode.ts diff --git a/CONTACT_API_IMPLEMENTATION.md b/CONTACT_API_IMPLEMENTATION.md deleted file mode 100644 index b9fcbe7..0000000 --- a/CONTACT_API_IMPLEMENTATION.md +++ /dev/null @@ -1,375 +0,0 @@ -# 企业微信通讯录管理接口完整实现说明 - -## 更新日期 -2025-10-18 - -## 概述 - -本次更新完全实现了企业微信官方通讯录管理文档中定义的所有获取通讯录信息的接口,并实现了动态选项加载功能,让用户可以在 n8n 界面中直接从通讯录选择接收人、部门、标签等信息。 - -## 实现的通讯录操作 - -### ✅ 成员管理 - -#### 1. 获取成员信息 (getUser) -- **接口**: `/cgi-bin/user/get` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/90196 -- **功能**: 读取指定成员的详细信息 -- **特性**: 支持从所有成员列表中动态选择 - -#### 2. 获取部门成员(简化版)(listUsers) -- **接口**: `/cgi-bin/user/simplelist` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/90200 -- **功能**: 获取部门成员列表(仅包含基本信息) -- **特性**: 支持递归获取子部门成员 - -#### 3. 获取部门成员(详细版)(listUsersDetail) - 新增 -- **接口**: `/cgi-bin/user/list` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/90201 -- **功能**: 获取部门成员列表(包含完整信息如职位、邮箱等) -- **特性**: 支持递归获取子部门成员 - -### ✅ 部门管理 - -#### 4. 获取部门信息 (getDepartment) -- **接口**: `/cgi-bin/department/list` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/90208 -- **功能**: 获取企业的部门列表 -- **特性**: 可获取指定部门或全量组织架构 - -### ✅ 标签管理 - -#### 5. 获取标签列表 (getTagList) - 新增 -- **接口**: `/cgi-bin/tag/list` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/90216 -- **功能**: 获取企业标签列表 -- **特性**: 支持按类型筛选(全部/个人/部门标签) - -#### 6. 获取标签成员 (getTag) - 新增 -- **接口**: `/cgi-bin/tag/get` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/90213 -- **功能**: 获取指定标签的成员列表 -- **特性**: 支持从标签列表中动态选择 - -### ✅ ID转换 - -#### 7. UserID转OpenID (convertToOpenid) - 新增 -- **接口**: `/cgi-bin/user/convert_to_openid` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/90202 -- **功能**: 将企业成员的 userid 转换为 openid -- **特性**: 支持从所有成员列表中动态选择 - -#### 8. OpenID转UserID (convertToUserid) - 新增 -- **接口**: `/cgi-bin/user/convert_to_userid` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/90202 -- **功能**: 将 openid 转换为企业成员的 userid - -### ✅ 企业信息 - -#### 9. 获取加入企业二维码 (getJoinQrcode) - 新增 -- **接口**: `/cgi-bin/corp/get_join_qrcode` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/91714 -- **功能**: 获取企业的加入二维码 -- **特性**: 支持4种尺寸选择(171x171, 399x399, 741x741, 2052x2052) - -#### 10. 获取企业活跃成员数 (getActiveStat) - 新增 -- **接口**: `/cgi-bin/user/get_active_stat` -- **参考文档**: https://developer.work.weixin.qq.com/document/path/92714 -- **功能**: 获取企业指定日期的活跃成员数 -- **特性**: 支持获取最长30天前的数据 - -## 动态选项加载功能 - -### loadOptions 方法 - -在主节点中实现了 6 个 loadOptions 方法,让用户可以在 n8n 界面中动态选择: - -#### 1. getDepartments -- 获取所有部门列表 -- 用于部门选择下拉框 - -#### 2. getDepartmentUsers -- 获取指定部门的成员列表(简化信息) -- 动态加载成员选项 - -#### 3. getDepartmentUsersDetail -- 获取指定部门的成员详情列表(含职位信息) -- 显示成员姓名、职位和 UserID - -#### 4. getTags -- 获取所有标签列表 -- 用于标签选择下拉框 - -#### 5. getTagUsers -- 获取指定标签的成员列表 -- 动态加载标签成员 - -#### 6. getAllUsers -- 获取企业所有成员列表 -- 从根部门递归获取 -- 用于成员选择下拉框 - -## 消息接收人动态选择功能 - -### 通用接收人字段 (commonFields.ts) - -创建了通用的接收人选择组件,所有消息发送操作都可使用: - -#### 接收人类型选择 -- **指定成员**: 从通讯录选择多个成员 -- **指定部门**: 从部门列表选择多个部门 -- **指定标签**: 从标签列表选择多个标签 -- **全体成员**: 发送给企业全部成员 -- **手动输入**: 传统的手动输入方式(向后兼容) - -#### 特性 -- 支持多选(multiOptions) -- 自动转换为企业微信API要求的格式(用 | 分隔) -- 向后兼容旧的手动输入方式 - -### 已更新的消息类型 - -所有 14 种消息发送操作都已更新为使用新的接收人选择方式: - -1. ✅ sendText - 发送文本消息 -2. ✅ sendMarkdown - 发送 Markdown 消息 -3. ✅ sendImage - 发送图片消息 -4. ✅ sendFile - 发送文件消息 -5. ✅ sendVoice - 发送语音消息 -6. ✅ sendVideo - 发送视频消息 -7. ✅ sendTextCard - 发送文本卡片消息 -8. ✅ sendNews - 发送图文消息 -9. ✅ sendMpNews - 发送图文消息(mpnews) -10. ✅ sendMiniprogramNotice - 发送小程序通知消息 -11. ✅ sendTaskCard - 发送任务卡片消息 -12. ✅ sendTemplateCard - 发送模板卡片消息 -13. ✅ updateTemplateCard - 更新模板卡片消息 -14. ✅ recallMessage - 撤回应用消息(无需接收人) - -## 技术实现细节 - -### 文件结构 - -``` -nodes/WeCom/resources/contact/ -├── index.ts # 通讯录资源入口 -├── execute.ts # 执行逻辑 -├── getUser.ts # 获取成员信息 -├── listUsers.ts # 获取部门成员(简化版) -├── listUsersDetail.ts # 获取部门成员(详细版)- 新增 -├── getDepartment.ts # 获取部门信息 -├── convertToOpenid.ts # UserID转OpenID - 新增 -├── convertToUserid.ts # OpenID转UserID - 新增 -├── getJoinQrcode.ts # 获取加入企业二维码 - 新增 -├── getActiveStat.ts # 获取企业活跃成员数 - 新增 -├── getTagList.ts # 获取标签列表 - 新增 -└── getTag.ts # 获取标签成员 - 新增 - -nodes/WeCom/resources/message/ -└── commonFields.ts # 通用接收人字段定义 - 新增 - -nodes/WeCom/ -└── WeCom.node.ts # 主节点(添加 loadOptions 方法) -``` - -### 代码特性 - -#### 1. 完整的类型定义 -- 所有字段都有 TypeScript 类型定义 -- 使用 n8n 的 INodeProperties 接口定义参数 -- 使用 ILoadOptionsFunctions 实现动态选项加载 - -#### 2. 用户友好的界面 -- 所有字段都有中文描述 -- 支持条件显示(displayOptions) -- 提供默认值和验证 -- 动态下拉选择,无需手动输入 ID - -#### 3. 灵活的输入方式 -- 优先使用动态选项选择 -- 保留手动输入方式作为备选 -- 支持多选(multiOptions) -- 自动处理数据格式转换 - -#### 4. 完善的错误处理 -- 参数验证 -- API 错误处理 -- 向后兼容性检查 - -#### 5. 遵循官方文档 -- 所有字段名称与官方文档一致 -- 支持所有可选参数 -- 完全符合企业微信 API 规范 - -## 使用示例 - -### 1. 从通讯录选择接收人发送消息 - -```javascript -{ - "resource": "message", - "operation": "sendText", - "recipientType": "users", // 选择指定成员 - "touser": ["zhangsan", "lisi"], // 从下拉列表中选择成员 - "content": "这是一条测试消息" -} -``` - -### 2. 向指定部门发送消息 - -```javascript -{ - "resource": "message", - "operation": "sendMarkdown", - "recipientType": "departments", // 选择指定部门 - "toparty": ["1", "2"], // 从下拉列表中选择部门 - "content": "## 部门通知\n\n这是一条部门消息" -} -``` - -### 3. 向标签成员发送消息 - -```javascript -{ - "resource": "message", - "operation": "sendText", - "recipientType": "tags", // 选择指定标签 - "totag": ["1"], // 从下拉列表中选择标签 - "content": "标签消息" -} -``` - -### 4. 获取部门成员详情 - -```javascript -{ - "resource": "contact", - "operation": "listUsersDetail", - "department_id": "1", // 从下拉列表中选择部门 - "fetch_child": true -} -``` - -### 5. 获取标签成员列表 - -```javascript -{ - "resource": "contact", - "operation": "getTag", - "tagid": "1" // 从下拉列表中选择标签 -} -``` - -### 6. 获取企业活跃成员数 - -```javascript -{ - "resource": "contact", - "operation": "getActiveStat", - "date": "2025-10-18" -} -``` - -## 向后兼容性 - -为了确保现有工作流不受影响,实现了以下兼容措施: - -1. **消息执行逻辑** - - 自动检测是否使用新的 recipientType 字段 - - 如果不存在,则使用旧的 touser/toparty/totag 字段 - - 完全兼容旧的手动输入方式 - -2. **通讯录字段** - - 部门、标签、成员字段都从 string 类型升级为 options 类型 - - 支持动态加载选项 - - 保持字段名称不变,确保数据兼容 - -## 测试建议 - -### 1. 通讯录接口测试 -- 测试获取部门列表 -- 测试获取部门成员(简化版和详细版) -- 测试获取标签列表和标签成员 -- 测试 userid 与 openid 互转 -- 测试获取加入企业二维码 -- 测试获取活跃成员数 - -### 2. 动态选项加载测试 -- 测试部门下拉列表是否正常加载 -- 测试成员下拉列表是否正常加载 -- 测试标签下拉列表是否正常加载 -- 测试选项显示格式是否友好 - -### 3. 消息发送测试 -- 使用新的接收人选择方式发送各类消息 -- 测试多选功能 -- 测试全体成员发送 -- 测试向后兼容性(旧的工作流是否正常) - -### 4. 边界情况测试 -- 测试空部门 -- 测试没有标签的情况 -- 测试网络错误处理 -- 测试 Access Token 失效重试 - -## 兼容性 - -- ✅ 完全兼容企业微信官方 API -- ✅ 支持所有文档中的参数和选项 -- ✅ 向后兼容现有的工作流 -- ✅ 支持 n8n 的最佳实践 - -## 主要改进 - -### 相比之前的实现 - -1. **新增 7 个通讯录接口** - - 获取部门成员详情 - - userid/openid 互转 - - 获取标签列表和成员 - - 获取加入企业二维码 - - 获取活跃成员数 - -2. **实现动态选项加载** - - 6 个 loadOptions 方法 - - 支持部门、成员、标签的动态选择 - - 友好的显示格式 - -3. **优化消息接收人选择** - - 创建通用接收人组件 - - 支持 5 种接收人选择方式 - - 所有 14 种消息类型都已更新 - - 保持向后兼容 - -4. **提升用户体验** - - 无需记忆 userid、部门 ID、标签 ID - - 直接从下拉列表选择 - - 显示友好的名称和 ID - - 支持多选 - -## 参考文档 - -- [企业微信通讯录管理接口](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) -- [获取加入企业二维码](https://developer.work.weixin.qq.com/document/path/91714) -- [获取企业活跃成员数](https://developer.work.weixin.qq.com/document/path/92714) -- [n8n 节点开发文档](https://docs.n8n.io/integrations/creating-nodes/) - -## 总结 - -本次更新完整实现了企业微信通讯录管理的所有获取接口,并通过 loadOptions 方法实现了动态选项加载功能,大幅提升了用户体验。用户现在可以: - -1. ✅ 直接从下拉列表选择部门、成员、标签 -2. ✅ 无需手动输入和记忆各种 ID -3. ✅ 查看友好的名称和详细信息 -4. ✅ 使用所有企业微信通讯录管理接口 -5. ✅ 向后兼容现有的工作流 - -所有功能已编译通过,无 lint 错误,可以直接使用。 - diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md deleted file mode 100644 index 3e427ea..0000000 --- a/IMPLEMENTATION_NOTES.md +++ /dev/null @@ -1,283 +0,0 @@ -# 企业微信发送应用消息接口完整实现说明 - -## 概述 - -本次更新完全实现了企业微信官方文档(https://developer.work.weixin.qq.com/document/path/90236)中定义的所有发送应用消息接口。 - -## 实现的消息操作 - -根据官方文档,企业微信应用消息支持 12 种消息类型的发送,以及模板卡片消息的更新操作,本次实现已全部覆盖: - -### 1. ✅ 文本消息 (text) - 已实现 -- 文件:`sendText.ts` -- 功能:发送纯文本消息 -- 特性:支持消息去重、ID转译、保密消息 - -### 2. ✅ Markdown消息 (markdown) - 已实现 -- 文件:`sendMarkdown.ts` -- 功能:发送Markdown格式的消息 -- 特性:支持Markdown语法格式化 - -### 3. ✅ 图片消息 (image) - 已实现 -- 文件:`sendImage.ts` -- 功能:发送图片消息 -- 特性:支持Media ID或直接上传文件 - -### 4. ✅ 语音消息 (voice) - 新增 -- 文件:`sendVoice.ts` -- 功能:发送语音消息 -- 特性:支持Media ID或直接上传文件(AMR、MP3格式) -- 字段:media_id, safe, enable_id_trans, enable_duplicate_check - -### 5. ✅ 视频消息 (video) - 新增 -- 文件:`sendVideo.ts` -- 功能:发送视频消息 -- 特性:支持Media ID或直接上传文件(MP4格式) -- 字段:media_id, title, description, safe, enable_id_trans, enable_duplicate_check - -### 6. ✅ 文件消息 (file) - 已实现 -- 文件:`sendFile.ts` -- 功能:发送文件消息 -- 特性:支持Media ID或直接上传文件 - -### 7. ✅ 文本卡片消息 (textcard) - 新增 -- 文件:`sendTextCard.ts` -- 功能:发送带标题、描述和跳转链接的卡片消息 -- 字段:title, description, url, btntxt, enable_id_trans, enable_duplicate_check - -### 8. ✅ 图文消息 (news) - 新增 -- 文件:`sendNews.ts` -- 功能:发送图文消息,支持1-8条图文 -- 字段:articles (title, description, url, picurl) -- 特性:适合发送多条图文链接 - -### 9. ✅ 图文消息(mpnews) - 新增 -- 文件:`sendMpNews.ts` -- 功能:发送富文本图文消息 -- 字段:articles (title, thumb_media_id, author, content_source_url, content, digest) -- 特性:支持HTML内容,适合富文本展示 - -### 10. ✅ 小程序通知消息 (miniprogram_notice) - 新增 -- 文件:`sendMiniprogramNotice.ts` -- 功能:发送小程序通知消息 -- 字段:appid, page, title, description, emphasis_first_item, content_item -- 特性:支持小程序跳转和内容键值对 - -### 11. ✅ 任务卡片消息 (taskcard) - 新增 -- 文件:`sendTaskCard.ts` -- 功能:发送带按钮的任务卡片消息 -- 字段:title, description, url, task_id, btn (key, name, replace_name, color, is_bold) -- 特性:支持交互式按钮,可以实现任务确认等功能 - -### 12. ✅ 模板卡片消息 (template_card) - 新增 -- 文件:`sendTemplateCard.ts` -- 功能:发送模板卡片消息,支持多种卡片类型 -- 卡片类型: - - `text_notice` - 文本通知型 - - `news_notice` - 图文展示型 - - `button_interaction` - 按钮交互型 - - `vote_interaction` - 投票选择型 - - `multiple_interaction` - 多项选择型 -- 特性:最灵活的卡片消息类型,支持复杂的交互和展示 - -### 13. ✅ 撤回应用消息 (recallMessage) - 新增 -- 文件:`recallMessage.ts` -- API接口:`/cgi-bin/message/recall` -- 功能:撤回已发送的应用消息 -- 特性: - - 需要使用发送消息时返回的 `msgid` - - 只能撤回应用自己发送的消息 - - 仅可撤回24小时内发送的消息 -- 参数: - - `msgid` - 消息ID,从发送消息接口返回值中获取 -- 参考文档:https://developer.work.weixin.qq.com/document/path/94867 - -### 14. ✅ 更新模板卡片消息 (updateTemplateCard) - 新增 -- 文件:`updateTemplateCard.ts` -- API接口:`/cgi-bin/message/update_template_card` -- 功能:更新已发送的模板卡片消息内容 -- 特性: - - 需要使用发送消息时返回的 `response_code` - - 一个 code 只能调用一次更新接口 - - 只能在24小时内更新 - - 支持所有模板卡片类型的更新 - - 支持任务卡片的升级(通过 `button_key` 参数) -- 参考文档:https://developer.work.weixin.qq.com/document/path/94888 - -## 技术实现细节 - -### 文件结构 -``` -nodes/WeCom/resources/message/ -├── index.ts # 消息资源入口,导出所有消息类型 -├── execute.ts # 执行逻辑,处理所有消息操作 -├── sendText.ts # 文本消息定义 -├── sendMarkdown.ts # Markdown消息定义 -├── sendImage.ts # 图片消息定义 -├── sendVoice.ts # 语音消息定义(新增) -├── sendVideo.ts # 视频消息定义(新增) -├── sendFile.ts # 文件消息定义 -├── sendTextCard.ts # 文本卡片消息定义(新增) -├── sendNews.ts # 图文消息定义(新增) -├── sendMpNews.ts # 图文消息(mpnews)定义(新增) -├── sendMiniprogramNotice.ts # 小程序通知消息定义(新增) -├── sendTaskCard.ts # 任务卡片消息定义(新增) -├── sendTemplateCard.ts # 模板卡片消息定义(新增) -├── recallMessage.ts # 撤回应用消息定义(新增) -└── updateTemplateCard.ts # 更新模板卡片消息定义(新增) -``` - -### 代码特性 - -1. **完整的类型定义** - - 所有字段都有 TypeScript 类型定义 - - 使用 n8n 的 INodeProperties 接口定义参数 - -2. **用户友好的界面** - - 所有字段都有中文描述 - - 支持条件显示(displayOptions) - - 提供默认值和验证 - -3. **灵活的输入方式** - - 媒体消息支持 Media ID 或直接上传 - - 复杂结构支持 fixedCollection 或 JSON 输入 - - 支持 n8n 表达式 - -4. **完善的错误处理** - - 参数验证 - - 文件上传错误处理 - - API 错误处理 - -5. **遵循官方文档** - - 所有字段名称与官方文档一致 - - 支持所有可选参数 - - 实现所有特性(去重、ID转译、保密消息等) - -## 使用示例 - -### 发送文本卡片消息 -```javascript -{ - "touser": "zhangsan", - "title": "系统通知", - "description": "您有一个新的任务待处理", - "url": "https://example.com/task/123", - "btntxt": "查看详情" -} -``` - -### 发送图文消息 -```javascript -{ - "touser": "@all", - "articles": [ - { - "title": "产品更新公告", - "description": "我们发布了新版本", - "url": "https://example.com/news/1", - "picurl": "https://example.com/image.jpg" - } - ] -} -``` - -### 发送任务卡片 -```javascript -{ - "touser": "zhangsan", - "title": "请审批", - "description": "李四提交了请假申请", - "task_id": "task_001", - "buttons": [ - { - "key": "approve", - "name": "同意", - "color": "blue" - }, - { - "key": "reject", - "name": "拒绝", - "color": "red" - } - ] -} -``` - -### 撤回应用消息 -```javascript -{ - "msgid": "msgid_from_send_message" -} -``` - -**重要提示**: -- `msgid` 从发送消息的返回值中获取 -- 只能撤回应用自己发送的消息 -- 只能撤回24小时内发送的消息 -- 撤回后消息将从对话列表中移除 - -### 更新模板卡片消息 -```javascript -{ - "touser": "zhangsan", - "response_code": "response_code_from_send_message", - "card_type": "text_notice", - "main_title": { - "title": "更新后的标题", - "desc": "状态已更新" - }, - "sub_title_text": "处理完成" -} -``` - -**重要提示**: -- `response_code` 从发送消息的返回值中获取 -- 每个 code 只能使用一次 -- 必须在24小时内更新 -- 可以通过回调接口获取用户交互后的 code - -## 测试建议 - -1. **基础消息测试** - - 测试文本、Markdown、图片、文件消息 - - 验证接收人配置(单人、多人、@all、部门、标签) - -2. **媒体消息测试** - - 测试语音消息(AMR、MP3格式) - - 测试视频消息(MP4格式,带标题和描述) - - 测试文件上传功能 - -3. **卡片消息测试** - - 测试文本卡片(跳转链接) - - 测试图文消息(多条图文) - - 测试任务卡片(按钮交互) - -4. **高级功能测试** - - 测试小程序通知消息 - - 测试模板卡片(各种卡片类型) - - 测试消息去重功能 - - 测试ID转译功能 - -5. **消息管理测试** - - 测试撤回应用消息 - - 测试更新模板卡片消息 - - 验证24小时时间限制 - -## 兼容性 - -- ✅ 完全兼容企业微信官方 API -- ✅ 支持所有文档中的参数和选项 -- ✅ 支持最新的模板卡片功能 -- ✅ 向后兼容现有的消息类型 - -## 参考文档 - -- [企业微信发送应用消息接口](https://developer.work.weixin.qq.com/document/path/90236) -- [企业微信撤回应用消息接口](https://developer.work.weixin.qq.com/document/path/94867) -- [企业微信更新模板卡片消息接口](https://developer.work.weixin.qq.com/document/path/94888) -- [n8n 节点开发文档](https://docs.n8n.io/integrations/creating-nodes/) - -## 更新日期 - -2025-10-18 - diff --git a/README.md b/README.md index 9565110..25b0cce 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,30 @@ ### 通讯录资源 -- **获取成员信息** - 根据 UserID 获取成员详细信息 -- **获取部门成员** - 获取指定部门的成员列表,支持递归获取子部门 -- **获取部门信息** - 获取部门列表和详细信息 +- **获取成员信息** - 根据 UserID 获取成员详细信息,支持从所有成员列表动态选择 +- **获取部门成员列表** - 获取指定部门的成员列表(基本信息),支持递归获取子部门 +- **获取部门成员详情** - 获取指定部门的成员列表(完整信息),包含职位、邮箱等详细信息 +- **获取部门信息** - 获取企业部门列表和详细信息,支持从部门列表动态选择 +- **UserID转OpenID** - 将企业成员的 userid 转换为 openid +- **OpenID转UserID** - 将 openid 转换为企业成员的 userid +- **获取标签列表** - 获取企业标签列表,支持按类型筛选 +- **获取标签成员** - 获取指定标签的成员列表,支持从标签列表动态选择 + +### 动态选项加载 + +本节点支持从企业微信实时获取数据并展示在选项中,让你可以直接选择而无需手动输入 ID: + +- **消息接收人选择**: + - 从通讯录选择成员(支持多选) + - 从部门列表选择部门(支持多选) + - 从标签列表选择标签(支持多选) + - 快速选择发送给全体成员 + - 也支持传统的手动输入方式 + +- **通讯录操作选择**: + - 选择部门时自动加载企业部门列表 + - 选择成员时自动加载企业成员列表 + - 选择标签时自动加载企业标签列表 ## 凭证 @@ -77,9 +98,18 @@ 确保你的企业微信应用具有以下权限: -- **消息发送权限** - 用于发送各类消息 -- **通讯录读取权限** - 用于查询成员和部门信息 -- **应用可见范围** - 设置哪些成员可以接收应用消息 +- **消息发送权限** - 用于发送各类消息(必须) +- **通讯录读取权限** - 用于查询成员、部门和标签信息(必须) + - 读取成员信息 + - 读取部门信息 + - 读取标签信息 +- **应用可见范围** - 设置哪些成员可以接收应用消息(建议设置为全员) + +**配置方法**: +1. 进入企业微信管理后台 +2. 选择你的应用 +3. 点击"API权限" +4. 开启所需的权限 ## 兼容性 @@ -90,17 +120,24 @@ ## 使用 -### 发送文本消息 +### 发送文本消息(使用动态选择) 1. 添加"企业微信"节点到工作流 2. 选择**资源**: 消息 3. 选择**操作**: 发送文本消息 4. 配置接收人: - - **接收人**: 成员ID,用 `|` 分隔多个,或使用 `@all` 发送给所有人 - - **部门ID**: 可选,部门ID列表 - - **标签ID**: 可选,标签ID列表 + - **接收人类型**: 选择"指定成员"、"指定部门"、"指定标签"或"全体成员" + - **成员/部门/标签**: 从自动加载的下拉列表中选择(支持多选) + - 也可以选择"手动输入"使用传统方式 5. 输入**消息内容** +### 发送文本消息(传统方式) + +如果选择"手动输入"方式: +- **接收人**: 成员ID,用 `|` 分隔多个,或使用 `@all` 发送给所有人 +- **部门ID**: 可选,部门ID列表 +- **标签ID**: 可选,标签ID列表 + ### 发送 Markdown 消息 适合发送格式化的报告、通知等。支持标题、加粗、列表等 Markdown 语法。 @@ -113,12 +150,17 @@ ### 接收人配置 +**推荐方式(动态选择)**: +1. 选择**接收人类型**(指定成员/指定部门/指定标签/全体成员) +2. 从自动加载的下拉列表中选择目标(支持多选) +3. 节点自动转换为正确的格式 + +**传统方式(手动输入)**: - **单个用户**: `touser: "zhangsan"` - **多个用户**: `touser: "zhangsan|lisi|wangwu"` - **所有人**: `touser: "@all"` - **按部门**: `toparty: "1|2|3"` - **按标签**: `totag: "1|2"` -- **组合使用**: 可以同时指定成员、部门和标签 ### 使用表达式 @@ -166,12 +208,25 @@ Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) ## 资源 +### 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/90194) + +### 通讯录管理 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) ## 版本历史 @@ -195,16 +250,29 @@ Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) - 撤回应用消息(支持撤回已发送的消息) - 更新模板卡片消息(支持更新已发送的模板卡片,实现动态卡片更新) -**通讯录功能**: -- 获取成员信息 -- 获取部门成员列表(支持递归) -- 获取部门信息 +**通讯录功能**(共8个接口): +- 获取成员信息(支持从所有成员列表动态选择) +- 获取部门成员列表(基本信息,支持递归) +- 获取部门成员详情(完整信息,支持递归) +- 获取部门信息(支持从部门列表动态选择) +- UserID与OpenID互转(2个接口) +- 获取标签列表(支持按类型筛选) +- 获取标签成员(支持从标签列表动态选择) + +**动态选项加载功能**: +- 6个 loadOptions 方法实现实时数据加载 +- 消息接收人支持5种选择方式(指定成员、指定部门、指定标签、全体成员、手动输入) +- 通讯录操作支持从下拉列表选择部门、成员、标签 +- 支持多选,自动格式转换 +- 向后兼容传统手动输入方式 **技术特性**: -- Access Token 自动缓存管理 +- Access Token 自动缓存管理(支持多租户) +- Token 失效自动重试 - 完整的 TypeScript 类型支持 - 遵循 n8n 节点开发最佳实践 - 完善的错误处理 +- 友好的用户界面 ## 许可证 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/nodes/WeCom/resources/contact/execute.ts b/nodes/WeCom/resources/contact/execute.ts index bcc29cb..ca75d3f 100644 --- a/nodes/WeCom/resources/contact/execute.ts +++ b/nodes/WeCom/resources/contact/execute.ts @@ -67,23 +67,6 @@ export async function executeContact( '/cgi-bin/user/convert_to_userid', { openid }, ); - } else if (operation === 'getJoinQrcode') { - const size_type = this.getNodeParameter('size_type', i, 1) as number; - response = await weComApiRequest.call( - this, - 'GET', - '/cgi-bin/corp/get_join_qrcode', - {}, - { size_type }, - ); - } else if (operation === 'getActiveStat') { - const date = this.getNodeParameter('date', i) as string; - response = await weComApiRequest.call( - this, - 'POST', - '/cgi-bin/user/get_active_stat', - { date }, - ); } else if (operation === 'getTagList') { const tag_type = this.getNodeParameter('tag_type', i, '') as string; const qs: IDataObject = {}; diff --git a/nodes/WeCom/resources/contact/getActiveStat.ts b/nodes/WeCom/resources/contact/getActiveStat.ts deleted file mode 100644 index 27b34a5..0000000 --- a/nodes/WeCom/resources/contact/getActiveStat.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; - -const showOnlyGetActiveStat = { - resource: ['contact'], - operation: ['getActiveStat'], -}; - -export const getActiveStatDescription: INodeProperties[] = [ - { - displayName: '具体某天', - name: 'date', - type: 'string', - required: true, - default: '', - placeholder: '2020-03-27', - displayOptions: { - show: showOnlyGetActiveStat, - }, - description: '具体某天的活跃人数,最长支持获取30天前数据,格式为 YYYY-MM-DD', - }, -]; - diff --git a/nodes/WeCom/resources/contact/getJoinQrcode.ts b/nodes/WeCom/resources/contact/getJoinQrcode.ts deleted file mode 100644 index 20152ad..0000000 --- a/nodes/WeCom/resources/contact/getJoinQrcode.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { INodeProperties } from 'n8n-workflow'; - -const showOnlyGetJoinQrcode = { - resource: ['contact'], - operation: ['getJoinQrcode'], -}; - -export const getJoinQrcodeDescription: INodeProperties[] = [ - { - displayName: '二维码尺寸类型', - name: 'size_type', - type: 'options', - options: [ - { - name: '171 x 171', - value: 1, - }, - { - name: '399 x 399', - value: 2, - }, - { - name: '741 x 741', - value: 3, - }, - { - name: '2052 x 2052', - value: 4, - }, - ], - default: 1, - displayOptions: { - show: showOnlyGetJoinQrcode, - }, - description: 'qrcode尺寸类型,1: 171 x 171; 2: 399 x 399; 3: 741 x 741; 4: 2052 x 2052', - }, -]; - diff --git a/nodes/WeCom/resources/contact/index.ts b/nodes/WeCom/resources/contact/index.ts index 8944390..87f8200 100644 --- a/nodes/WeCom/resources/contact/index.ts +++ b/nodes/WeCom/resources/contact/index.ts @@ -5,8 +5,6 @@ import { listUsersDetailDescription } from './listUsersDetail'; import { getDepartmentDescription } from './getDepartment'; import { convertToOpenidDescription } from './convertToOpenid'; import { convertToUseridDescription } from './convertToUserid'; -import { getJoinQrcodeDescription } from './getJoinQrcode'; -import { getActiveStatDescription } from './getActiveStat'; import { getTagListDescription } from './getTagList'; import { getTagDescription } from './getTag'; @@ -31,13 +29,13 @@ export const contactDescription: INodeProperties[] = [ description: '读取成员详细信息', }, { - name: '获取部门成员(简化版)', + name: '获取部门成员列表', value: 'listUsers', action: '获取部门成员列表', description: '获取部门成员列表(仅包含基本信息)', }, { - name: '获取部门成员(详细版)', + name: '获取部门成员详情', value: 'listUsersDetail', action: '获取部门成员详情列表', description: '获取部门成员列表(包含完整信息)', @@ -60,18 +58,6 @@ export const contactDescription: INodeProperties[] = [ action: 'OpenID转UserID', description: '将 openid 转换为企业成员的 userid', }, - { - name: '获取加入企业二维码', - value: 'getJoinQrcode', - action: '获取加入企业二维码', - description: '获取企业的加入二维码', - }, - { - name: '获取企业活跃成员数', - value: 'getActiveStat', - action: '获取企业活跃成员数', - description: '获取企业指定日期的活跃成员数', - }, { name: '获取标签列表', value: 'getTagList', @@ -93,8 +79,6 @@ export const contactDescription: INodeProperties[] = [ ...getDepartmentDescription, ...convertToOpenidDescription, ...convertToUseridDescription, - ...getJoinQrcodeDescription, - ...getActiveStatDescription, ...getTagListDescription, ...getTagDescription, ]; From efc97dc6b82a920e09b65ac181db243b75f4e93b Mon Sep 17 00:00:00 2001 From: funcodingdev Date: Sat, 18 Oct 2025 18:35:08 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++- nodes/WeCom/WeCom.node.ts | 10 + nodes/WeCom/resources/contact/execute.ts | 13 ++ nodes/WeCom/resources/contact/index.ts | 62 +++--- nodes/WeCom/resources/contact/listUserIds.ts | 35 ++++ nodes/WeCom/resources/material/execute.ts | 177 ++++++++++++++++++ .../WeCom/resources/material/getPermanent.ts | 31 +++ nodes/WeCom/resources/material/getTemp.ts | 31 +++ nodes/WeCom/resources/material/index.ts | 53 ++++++ .../resources/material/uploadPermanent.ts | 50 +++++ nodes/WeCom/resources/material/uploadTemp.ts | 50 +++++ nodes/WeCom/shared/transport.ts | 38 +++- 12 files changed, 537 insertions(+), 36 deletions(-) create mode 100644 nodes/WeCom/resources/contact/listUserIds.ts create mode 100644 nodes/WeCom/resources/material/execute.ts create mode 100644 nodes/WeCom/resources/material/getPermanent.ts create mode 100644 nodes/WeCom/resources/material/getTemp.ts create mode 100644 nodes/WeCom/resources/material/index.ts create mode 100644 nodes/WeCom/resources/material/uploadPermanent.ts create mode 100644 nodes/WeCom/resources/material/uploadTemp.ts diff --git a/README.md b/README.md index 25b0cce..89d5799 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,20 @@ - **获取成员信息** - 根据 UserID 获取成员详细信息,支持从所有成员列表动态选择 - **获取部门成员列表** - 获取指定部门的成员列表(基本信息),支持递归获取子部门 - **获取部门成员详情** - 获取指定部门的成员列表(完整信息),包含职位、邮箱等详细信息 +- **获取成员ID列表** - 获取企业所有成员的 UserID 列表,支持分页查询(每页最多1000条) - **获取部门信息** - 获取企业部门列表和详细信息,支持从部门列表动态选择 - **UserID转OpenID** - 将企业成员的 userid 转换为 openid - **OpenID转UserID** - 将 openid 转换为企业成员的 userid - **获取标签列表** - 获取企业标签列表,支持按类型筛选 - **获取标签成员** - 获取指定标签的成员列表,支持从标签列表动态选择 +### 素材管理资源 + +- **上传临时素材** - 上传临时素材文件(图片、语音、视频、文件),有效期3天 +- **获取临时素材** - 通过media_id下载临时素材文件 +- **上传永久素材** - 上传永久素材文件(图片、语音、视频、文件) +- **获取永久素材** - 通过media_id下载永久素材文件 + ### 动态选项加载 本节点支持从企业微信实时获取数据并展示在选项中,让你可以直接选择而无需手动输入 ID: @@ -228,6 +236,12 @@ Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) - [获取标签列表](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) @@ -250,15 +264,22 @@ Spreadsheet (读取用户列表) → 企业微信 (批量发送消息) - 撤回应用消息(支持撤回已发送的消息) - 更新模板卡片消息(支持更新已发送的模板卡片,实现动态卡片更新) -**通讯录功能**(共8个接口): +**通讯录功能**(共9个接口): - 获取成员信息(支持从所有成员列表动态选择) - 获取部门成员列表(基本信息,支持递归) - 获取部门成员详情(完整信息,支持递归) +- 获取成员ID列表(支持分页,每页最多1000条) - 获取部门信息(支持从部门列表动态选择) - UserID与OpenID互转(2个接口) - 获取标签列表(支持按类型筛选) - 获取标签成员(支持从标签列表动态选择) +**素材管理功能**(共4个接口): +- 上传临时素材(支持图片、语音、视频、文件,3天有效期) +- 获取临时素材(下载临时素材文件) +- 上传永久素材(支持图片、语音、视频、文件) +- 获取永久素材(下载永久素材文件) + **动态选项加载功能**: - 6个 loadOptions 方法实现实时数据加载 - 消息接收人支持5种选择方式(指定成员、指定部门、指定标签、全体成员、手动输入) diff --git a/nodes/WeCom/WeCom.node.ts b/nodes/WeCom/WeCom.node.ts index f64ca7a..608685f 100644 --- a/nodes/WeCom/WeCom.node.ts +++ b/nodes/WeCom/WeCom.node.ts @@ -9,8 +9,10 @@ import type { 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 { @@ -59,11 +61,17 @@ export class WeCom implements INodeType { value: 'contact', description: '管理通讯录(成员、部门)', }, + { + name: '素材管理', + value: 'material', + description: '上传和管理素材文件', + }, ], default: 'message', }, ...messageDescription, ...contactDescription, + ...materialDescription, ], }; @@ -180,6 +188,8 @@ export class WeCom implements INodeType { 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/execute.ts b/nodes/WeCom/resources/contact/execute.ts index ca75d3f..7f333f0 100644 --- a/nodes/WeCom/resources/contact/execute.ts +++ b/nodes/WeCom/resources/contact/execute.ts @@ -43,6 +43,19 @@ export async function executeContact( 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 = {}; diff --git a/nodes/WeCom/resources/contact/index.ts b/nodes/WeCom/resources/contact/index.ts index 87f8200..a5ea7b6 100644 --- a/nodes/WeCom/resources/contact/index.ts +++ b/nodes/WeCom/resources/contact/index.ts @@ -2,6 +2,7 @@ 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'; @@ -23,40 +24,28 @@ export const contactDescription: INodeProperties[] = [ }, options: [ { - name: '获取成员信息', - value: 'getUser', - action: '获取成员信息', - description: '读取成员详细信息', - }, - { - name: '获取部门成员列表', - value: 'listUsers', - action: '获取部门成员列表', - description: '获取部门成员列表(仅包含基本信息)', - }, - { - name: '获取部门成员详情', - value: 'listUsersDetail', - action: '获取部门成员详情列表', - description: '获取部门成员列表(包含完整信息)', - }, - { - name: '获取部门信息', - value: 'getDepartment', - action: '获取部门信息', - description: '获取部门列表', + name: 'OpenID转UserID', + value: 'convertToUserid', + action: 'openID转userid', + description: '将 openid 转换为企业成员的 userid', }, { name: 'UserID转OpenID', value: 'convertToOpenid', - action: 'UserID转OpenID', + action: 'userid转openID', description: '将企业成员的 userid 转换为 openid', }, { - name: 'OpenID转UserID', - value: 'convertToUserid', - action: 'OpenID转UserID', - description: '将 openid 转换为企业成员的 userid', + name: '获取成员ID列表', + value: 'listUserIds', + action: '获取成员ID列表', + description: '获取企业所有成员的UserID列表,支持分页', + }, + { + name: '获取成员信息', + value: 'getUser', + action: '获取成员信息', + description: '读取成员详细信息', }, { name: '获取标签列表', @@ -70,12 +59,31 @@ export const contactDescription: INodeProperties[] = [ 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, 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/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/shared/transport.ts b/nodes/WeCom/shared/transport.ts index fedb863..f2e3ebb 100644 --- a/nodes/WeCom/shared/transport.ts +++ b/nodes/WeCom/shared/transport.ts @@ -130,42 +130,64 @@ export async function weComApiRequest( resource: string, body: IDataObject = {}, qs: IDataObject = {}, + headers: IDataObject = {}, + option: IDataObject = {}, maxRetries: number = 1, -): Promise { +): Promise { const accessToken = await getAccessToken.call(this); const options: IHttpRequestOptions = { method, - body, 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)) as IDataObject; + 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 ((response.errcode === 40014 || response.errcode === 42001) && maxRetries > 0) { + 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, maxRetries - 1); + return await weComApiRequest.call(this, method, resource, body, qs, headers, option, maxRetries - 1); } - if (response.errcode !== undefined && response.errcode !== 0) { + if (jsonResponse.errcode !== undefined && jsonResponse.errcode !== 0) { throw new NodeOperationError( this.getNode(), - `企业微信 API 错误: ${response.errmsg} (错误码: ${response.errcode})`, + `企业微信 API 错误: ${jsonResponse.errmsg} (错误码: ${jsonResponse.errcode})`, ); } - return response; + return jsonResponse; } catch (error) { const err = error as Error; throw new NodeOperationError(this.getNode(), `API 请求失败: ${err.message}`);