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