diff --git a/.github/workflows/github-issue-tracker.yml b/.github/workflows/github-issue-tracker.yml index a628f9f13c..ec3b46f74f 100644 --- a/.github/workflows/github-issue-tracker.yml +++ b/.github/workflows/github-issue-tracker.yml @@ -58,6 +58,10 @@ jobs: with: node-version: 22 + - name: Install dependencies + if: steps.check_time.outputs.should_delay == 'false' + run: pnpm install + - name: Process issue with Claude if: steps.check_time.outputs.should_delay == 'false' uses: anthropics/claude-code-action@main @@ -65,7 +69,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: "*" anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }} - claude_args: "--allowed-tools Bash(gh issue:*),Bash(node scripts/feishu-notify.js)" + claude_args: "--allowed-tools Bash(gh issue:*),Bash(pnpm tsx scripts/feishu-notify.ts issue:*),Bash(pnpm tsx scripts/feishu-notify.ts --help),Bash(pnpm tsx scripts/feishu-notify.ts issue --help)" prompt: | 你是一个GitHub Issue自动化处理助手。请完成以下任务: @@ -86,20 +90,21 @@ jobs: - 重要的技术细节 2. **发送飞书通知** - 使用以下命令发送飞书通知(注意:ISSUE_SUMMARY需要用引号包裹): + 使用CLI工具发送飞书通知,运行 `pnpm tsx scripts/feishu-notify.ts issue --help` 查看参数说明。 + 示例: ```bash - ISSUE_URL="${{ github.event.issue.html_url }}" \ - ISSUE_NUMBER="${{ github.event.issue.number }}" \ - ISSUE_TITLE="${{ github.event.issue.title }}" \ - ISSUE_AUTHOR="${{ github.event.issue.user.login }}" \ - ISSUE_LABELS="${{ join(github.event.issue.labels.*.name, ',') }}" \ - ISSUE_SUMMARY="<你生成的中文总结>" \ - node scripts/feishu-notify.js + pnpm tsx scripts/feishu-notify.ts issue \ + -u "${{ github.event.issue.html_url }}" \ + -n "${{ github.event.issue.number }}" \ + -t "${{ github.event.issue.title }}" \ + -a "${{ github.event.issue.user.login }}" \ + -l "${{ join(github.event.issue.labels.*.name, ',') }}" \ + -m "<你生成的中文总结>" ``` ## 注意事项 - 总结必须使用简体中文 - - ISSUE_SUMMARY 在传递给 node 命令时需要正确转义特殊字符 + - 命令行参数需要正确转义特殊字符 - 如果issue内容为空,也要提供一个简短的说明 请开始执行任务! @@ -125,13 +130,16 @@ jobs: with: node-version: 22 + - name: Install dependencies + run: pnpm install + - name: Process pending issues with Claude uses: anthropics/claude-code-action@main with: anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }} allowed_non_write_users: "*" github_token: ${{ secrets.GITHUB_TOKEN }} - claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(node scripts/feishu-notify.js)" + claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(pnpm tsx scripts/feishu-notify.ts issue:*),Bash(pnpm tsx scripts/feishu-notify.ts --help),Bash(pnpm tsx scripts/feishu-notify.ts issue --help)" prompt: | 你是一个GitHub Issue自动化处理助手。请完成以下任务: @@ -153,15 +161,16 @@ jobs: - 重要的技术细节 3. **发送飞书通知** - 对于每个issue,使用以下命令发送飞书通知: + 使用CLI工具发送飞书通知,运行 `pnpm tsx scripts/feishu-notify.ts issue --help` 查看参数说明。 + 示例: ```bash - ISSUE_URL="" \ - ISSUE_NUMBER="" \ - ISSUE_TITLE="" \ - ISSUE_AUTHOR="" \ - ISSUE_LABELS="<逗号分隔的标签列表,排除pending-feishu-notification>" \ - ISSUE_SUMMARY="<你生成的中文总结>" \ - node scripts/feishu-notify.js + pnpm tsx scripts/feishu-notify.ts issue \ + -u "" \ + -n "" \ + -t "" \ + -a "" \ + -l "<逗号分隔的标签列表,排除pending-feishu-notification>" \ + -m "<你生成的中文总结>" ``` 4. **移除标签** diff --git a/docs/en/references/feishu-notify.md b/docs/en/references/feishu-notify.md new file mode 100644 index 0000000000..26249a0c74 --- /dev/null +++ b/docs/en/references/feishu-notify.md @@ -0,0 +1,155 @@ +# Feishu Notification Script + +`scripts/feishu-notify.ts` is a CLI tool for sending notifications to Feishu (Lark) Webhook. This script is primarily used in GitHub Actions workflows to enable automatic notifications. + +## Features + +- Subcommand-based CLI structure for different notification types +- HMAC-SHA256 signature verification +- Sends Feishu interactive card messages +- Full TypeScript type support +- Credentials via environment variables for security + +## Usage + +### Prerequisites + +```bash +pnpm install +``` + +### CLI Structure + +```bash +pnpm tsx scripts/feishu-notify.ts [command] [options] +``` + +### Environment Variables (Required) + +| Variable | Description | +|----------|-------------| +| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | +| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret | + +## Commands + +### `send` - Send Simple Notification + +Send a generic notification without business-specific logic. + +```bash +pnpm tsx scripts/feishu-notify.ts send [options] +``` + +| Option | Short | Description | Required | +|--------|-------|-------------|----------| +| `--title` | `-t` | Card title | Yes | +| `--description` | `-d` | Card description (supports markdown) | Yes | +| `--color` | `-c` | Header color template | No (default: turquoise) | + +**Available colors:** `blue`, `wathet`, `turquoise`, `green`, `yellow`, `orange`, `red`, `carmine`, `violet`, `purple`, `indigo`, `grey`, `default` + +#### Example + +```bash +# Use $'...' syntax for proper newlines +pnpm tsx scripts/feishu-notify.ts send \ + -t "Deployment Completed" \ + -d $'**Status:** Success\n\n**Environment:** Production\n\n**Version:** v1.2.3' \ + -c green +``` + +```bash +# Send an error alert (red color) +pnpm tsx scripts/feishu-notify.ts send \ + -t "Error Alert" \ + -d $'**Error Type:** Connection failed\n\n**Severity:** High\n\nPlease check the system status' \ + -c red +``` + +**Note:** For proper newlines in the description, use bash's `$'...'` syntax. Do not use literal `\n` in double quotes, as it will be displayed as-is in the Feishu card. + +### `issue` - Send GitHub Issue Notification + +```bash +pnpm tsx scripts/feishu-notify.ts issue [options] +``` + +| Option | Short | Description | Required | +|--------|-------|-------------|----------| +| `--url` | `-u` | GitHub issue URL | Yes | +| `--number` | `-n` | Issue number | Yes | +| `--title` | `-t` | Issue title | Yes | +| `--summary` | `-m` | Issue summary | Yes | +| `--author` | `-a` | Issue author | No (default: "Unknown") | +| `--labels` | `-l` | Issue labels (comma-separated) | No | + +#### Example + +```bash +pnpm tsx scripts/feishu-notify.ts issue \ + -u "https://github.com/owner/repo/issues/123" \ + -n "123" \ + -t "Bug: Something is broken" \ + -m "This is a bug report about a feature" \ + -a "username" \ + -l "bug,high-priority" +``` + +## Usage in GitHub Actions + +This script is primarily used in `.github/workflows/github-issue-tracker.yml`: + +```yaml +- name: Install dependencies + run: pnpm install + +- name: Send notification + run: | + pnpm tsx scripts/feishu-notify.ts issue \ + -u "${{ github.event.issue.html_url }}" \ + -n "${{ github.event.issue.number }}" \ + -t "${{ github.event.issue.title }}" \ + -a "${{ github.event.issue.user.login }}" \ + -l "${{ join(github.event.issue.labels.*.name, ',') }}" \ + -m "Issue summary content" + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} + FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} +``` + +## Feishu Card Message Format + +The `issue` command sends an interactive card containing: + +- **Header**: `# - ` +- **Author**: Issue creator +- **Labels**: Issue labels (if any) +- **Summary**: Issue content summary +- **Action Button**: "View Issue" button linking to the GitHub Issue page + +## Configuring Feishu Webhook + +1. Add a custom bot to your Feishu group +2. Obtain the Webhook URL and signing secret +3. Configure them in GitHub Secrets: + - `FEISHU_WEBHOOK_URL`: Webhook address + - `FEISHU_WEBHOOK_SECRET`: Signing secret + +## Error Handling + +The script exits with a non-zero code when: + +- Required environment variables are missing (`FEISHU_WEBHOOK_URL`, `FEISHU_WEBHOOK_SECRET`) +- Required command options are missing +- Feishu API returns a non-2xx status code +- Network request fails + +## Extending with New Commands + +The CLI is designed to support multiple notification types. To add a new command: + +1. Define the command options interface +2. Create a card builder function +3. Add a new command handler +4. Register the command with `program.command()` diff --git a/docs/zh/references/feishu-notify.md b/docs/zh/references/feishu-notify.md new file mode 100644 index 0000000000..412b12815a --- /dev/null +++ b/docs/zh/references/feishu-notify.md @@ -0,0 +1,155 @@ +# 飞书通知脚本 + +`scripts/feishu-notify.ts` 是一个 CLI 工具,用于向飞书 Webhook 发送通知。该脚本主要在 GitHub Actions 工作流中使用,实现自动通知功能。 + +## 功能特性 + +- 基于子命令的 CLI 结构,支持不同类型的通知 +- 使用 HMAC-SHA256 签名验证 +- 发送飞书交互式卡片消息 +- 完整的 TypeScript 类型支持 +- 通过环境变量传递凭证,确保安全性 + +## 使用方式 + +### 前置依赖 + +```bash +pnpm install +``` + +### CLI 结构 + +```bash +pnpm tsx scripts/feishu-notify.ts [command] [options] +``` + +### 环境变量(必需) + +| 变量 | 说明 | +|------|------| +| `FEISHU_WEBHOOK_URL` | 飞书 Webhook URL | +| `FEISHU_WEBHOOK_SECRET` | 飞书 Webhook 签名密钥 | + +## 命令 + +### `send` - 发送简单通知 + +发送通用通知,不涉及具体业务逻辑。 + +```bash +pnpm tsx scripts/feishu-notify.ts send [options] +``` + +| 参数 | 短选项 | 说明 | 必需 | +|------|--------|------|------| +| `--title` | `-t` | 卡片标题 | 是 | +| `--description` | `-d` | 卡片描述(支持 markdown) | 是 | +| `--color` | `-c` | 标题栏颜色模板 | 否(默认:turquoise) | + +**可用颜色:** `blue`(蓝色), `wathet`(浅蓝), `turquoise`(青绿), `green`(绿色), `yellow`(黄色), `orange`(橙色), `red`(红色), `carmine`(深红), `violet`(紫罗兰), `purple`(紫色), `indigo`(靛蓝), `grey`(灰色), `default`(默认) + +#### 示例 + +```bash +# 使用 $'...' 语法实现正确的换行 +pnpm tsx scripts/feishu-notify.ts send \ + -t "部署完成" \ + -d $'**状态:** 成功\n\n**环境:** 生产环境\n\n**版本:** v1.2.3' \ + -c green +``` + +```bash +# 发送错误警报(红色) +pnpm tsx scripts/feishu-notify.ts send \ + -t "错误警报" \ + -d $'**错误类型:** 连接失败\n\n**严重程度:** 高\n\n请及时检查系统状态' \ + -c red +``` + +**注意:** 如需在描述中换行,请使用 bash 的 `$'...'` 语法。不要在双引号中使用字面量 `\n`,否则会原样显示在飞书卡片中。 + +### `issue` - 发送 GitHub Issue 通知 + +```bash +pnpm tsx scripts/feishu-notify.ts issue [options] +``` + +| 参数 | 短选项 | 说明 | 必需 | +|------|--------|------|------| +| `--url` | `-u` | GitHub Issue URL | 是 | +| `--number` | `-n` | Issue 编号 | 是 | +| `--title` | `-t` | Issue 标题 | 是 | +| `--summary` | `-m` | Issue 摘要 | 是 | +| `--author` | `-a` | Issue 作者 | 否(默认:"Unknown") | +| `--labels` | `-l` | Issue 标签(逗号分隔) | 否 | + +#### 示例 + +```bash +pnpm tsx scripts/feishu-notify.ts issue \ + -u "https://github.com/owner/repo/issues/123" \ + -n "123" \ + -t "Bug: Something is broken" \ + -m "这是一个关于某功能的 bug 报告" \ + -a "username" \ + -l "bug,high-priority" +``` + +## 在 GitHub Actions 中使用 + +该脚本主要在 `.github/workflows/github-issue-tracker.yml` 工作流中使用: + +```yaml +- name: Install dependencies + run: pnpm install + +- name: Send notification + run: | + pnpm tsx scripts/feishu-notify.ts issue \ + -u "${{ github.event.issue.html_url }}" \ + -n "${{ github.event.issue.number }}" \ + -t "${{ github.event.issue.title }}" \ + -a "${{ github.event.issue.user.login }}" \ + -l "${{ join(github.event.issue.labels.*.name, ',') }}" \ + -m "Issue 摘要内容" + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} + FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} +``` + +## 飞书卡片消息格式 + +`issue` 命令发送的交互式卡片包含以下内容: + +- **标题**: `# - ` +- **作者**: Issue 创建者 +- **标签**: Issue 标签列表(如有) +- **摘要**: Issue 内容摘要 +- **操作按钮**: "View Issue" 按钮,点击跳转到 GitHub Issue 页面 + +## 配置飞书 Webhook + +1. 在飞书群组中添加自定义机器人 +2. 获取 Webhook URL 和签名密钥 +3. 将 URL 和密钥配置到 GitHub Secrets: + - `FEISHU_WEBHOOK_URL`: Webhook 地址 + - `FEISHU_WEBHOOK_SECRET`: 签名密钥 + +## 错误处理 + +脚本在以下情况会返回非零退出码: + +- 缺少必需的环境变量(`FEISHU_WEBHOOK_URL`、`FEISHU_WEBHOOK_SECRET`) +- 缺少必需的命令参数 +- 飞书 API 返回非 2xx 状态码 +- 网络请求失败 + +## 扩展新命令 + +CLI 设计支持多种通知类型。添加新命令的步骤: + +1. 定义命令选项接口 +2. 创建卡片构建函数 +3. 添加新的命令处理函数 +4. 使用 `program.command()` 注册命令 diff --git a/package.json b/package.json index eb364a816f..afd9cbc6f7 100644 --- a/package.json +++ b/package.json @@ -265,6 +265,7 @@ "code-inspector-plugin": "^0.20.14", "codemirror-lang-mermaid": "0.5.0", "color": "^5.0.0", + "commander": "^14.0.2", "concurrently": "^9.2.1", "cors": "2.8.5", "country-flag-emoji-polyfill": "0.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3949ef661b..2f0a16951c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -673,6 +673,9 @@ importers: color: specifier: ^5.0.0 version: 5.0.3 + commander: + specifier: ^14.0.2 + version: 14.0.2 concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -6317,6 +6320,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -18604,6 +18611,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.2: {} + commander@2.20.3: {} commander@5.1.0: {} diff --git a/scripts/feishu-notify.js b/scripts/feishu-notify.js deleted file mode 100644 index d238dedb90..0000000000 --- a/scripts/feishu-notify.js +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Feishu (Lark) Webhook Notification Script - * Sends GitHub issue summaries to Feishu with signature verification - */ - -const crypto = require('crypto') -const https = require('https') - -/** - * Generate Feishu webhook signature - * @param {string} secret - Feishu webhook secret - * @param {number} timestamp - Unix timestamp in seconds - * @returns {string} Base64 encoded signature - */ -function generateSignature(secret, timestamp) { - const stringToSign = `${timestamp}\n${secret}` - const hmac = crypto.createHmac('sha256', stringToSign) - return hmac.digest('base64') -} - -/** - * Send message to Feishu webhook - * @param {string} webhookUrl - Feishu webhook URL - * @param {string} secret - Feishu webhook secret - * @param {object} content - Message content - * @returns {Promise} - */ -function sendToFeishu(webhookUrl, secret, content) { - return new Promise((resolve, reject) => { - const timestamp = Math.floor(Date.now() / 1000) - const sign = generateSignature(secret, timestamp) - - const payload = JSON.stringify({ - timestamp: timestamp.toString(), - sign: sign, - msg_type: 'interactive', - card: content - }) - - const url = new URL(webhookUrl) - const options = { - hostname: url.hostname, - path: url.pathname + url.search, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload) - } - } - - const req = https.request(options, (res) => { - let data = '' - res.on('data', (chunk) => { - data += chunk - }) - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - console.log('✅ Successfully sent to Feishu:', data) - resolve() - } else { - reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`)) - } - }) - }) - - req.on('error', (error) => { - reject(error) - }) - - req.write(payload) - req.end() - }) -} - -/** - * Create Feishu card message from issue data - * @param {object} issueData - GitHub issue data - * @returns {object} Feishu card content - */ -function createIssueCard(issueData) { - const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData - - // Build labels section if labels exist - const labelElements = - labels && labels.length > 0 - ? labels.map((label) => ({ - tag: 'markdown', - content: `\`${label}\`` - })) - : [] - - return { - elements: [ - { - tag: 'div', - text: { - tag: 'lark_md', - content: `**👤 Author:** ${issueAuthor}` - } - }, - ...(labelElements.length > 0 - ? [ - { - tag: 'div', - text: { - tag: 'lark_md', - content: `**🏷️ Labels:** ${labels.join(', ')}` - } - } - ] - : []), - { - tag: 'hr' - }, - { - tag: 'div', - text: { - tag: 'lark_md', - content: `**📋 Summary:**\n${issueSummary}` - } - }, - { - tag: 'hr' - }, - { - tag: 'action', - actions: [ - { - tag: 'button', - text: { - tag: 'plain_text', - content: '🔗 View Issue' - }, - type: 'primary', - url: issueUrl - } - ] - } - ], - header: { - template: 'blue', - title: { - tag: 'plain_text', - content: `#${issueNumber} - ${issueTitle}` - } - } - } -} - -/** - * Main function - */ -async function main() { - try { - // Get environment variables - const webhookUrl = process.env.FEISHU_WEBHOOK_URL - const secret = process.env.FEISHU_WEBHOOK_SECRET - const issueUrl = process.env.ISSUE_URL - const issueNumber = process.env.ISSUE_NUMBER - const issueTitle = process.env.ISSUE_TITLE - const issueSummary = process.env.ISSUE_SUMMARY - const issueAuthor = process.env.ISSUE_AUTHOR - const labelsStr = process.env.ISSUE_LABELS || '' - - // Validate required environment variables - if (!webhookUrl) { - throw new Error('FEISHU_WEBHOOK_URL environment variable is required') - } - if (!secret) { - throw new Error('FEISHU_WEBHOOK_SECRET environment variable is required') - } - if (!issueUrl || !issueNumber || !issueTitle || !issueSummary) { - throw new Error('Issue data environment variables are required') - } - - // Parse labels - const labels = labelsStr - ? labelsStr - .split(',') - .map((l) => l.trim()) - .filter(Boolean) - : [] - - // Create issue data object - const issueData = { - issueUrl, - issueNumber, - issueTitle, - issueSummary, - issueAuthor: issueAuthor || 'Unknown', - labels - } - - // Create card content - const card = createIssueCard(issueData) - - console.log('📤 Sending notification to Feishu...') - console.log(`Issue #${issueNumber}: ${issueTitle}`) - - // Send to Feishu - await sendToFeishu(webhookUrl, secret, card) - - console.log('✅ Notification sent successfully!') - } catch (error) { - console.error('❌ Error:', error.message) - process.exit(1) - } -} - -// Run main function -main() diff --git a/scripts/feishu-notify.ts b/scripts/feishu-notify.ts new file mode 100644 index 0000000000..8c195b8c6d --- /dev/null +++ b/scripts/feishu-notify.ts @@ -0,0 +1,421 @@ +#!/usr/bin/env npx tsx +/** + * @fileoverview Feishu (Lark) Webhook Notification CLI Tool + * @description Sends notifications to Feishu with signature verification. + * Supports subcommands for different notification types. + * @module feishu-notify + * @example + * // Send GitHub issue notification + * pnpm tsx feishu-notify.ts issue -u "https://..." -n "123" -t "Title" -m "Summary" + * + * // Using environment variables for credentials + * FEISHU_WEBHOOK_URL="..." FEISHU_WEBHOOK_SECRET="..." pnpm tsx feishu-notify.ts issue ... + */ + +import { Command } from 'commander' +import crypto from 'crypto' +import dotenv from 'dotenv' +import https from 'https' +import * as z from 'zod' + +// Load environment variables from .env file +dotenv.config() + +/** CLI tool version */ +const VERSION = '1.0.0' + +/** GitHub issue data structure */ +interface IssueData { + /** GitHub issue URL */ + issueUrl: string + /** Issue number */ + issueNumber: string + /** Issue title */ + issueTitle: string + /** Issue summary/description */ + issueSummary: string + /** Issue author username */ + issueAuthor: string + /** Issue labels */ + labels: string[] +} + +/** Feishu card text element */ +interface FeishuTextElement { + tag: 'div' + text: { + tag: 'lark_md' + content: string + } +} + +/** Feishu card horizontal rule element */ +interface FeishuHrElement { + tag: 'hr' +} + +/** Feishu card action button */ +interface FeishuActionElement { + tag: 'action' + actions: Array<{ + tag: 'button' + text: { + tag: 'plain_text' + content: string + } + type: 'primary' | 'default' + url: string + }> +} + +/** Feishu card element union type */ +type FeishuCardElement = FeishuTextElement | FeishuHrElement | FeishuActionElement + +/** Zod schema for Feishu header color template */ +const FeishuHeaderTemplateSchema = z.enum([ + 'blue', + 'wathet', + 'turquoise', + 'green', + 'yellow', + 'orange', + 'red', + 'carmine', + 'violet', + 'purple', + 'indigo', + 'grey', + 'default' +]) + +/** Feishu card header color template (inferred from schema) */ +type FeishuHeaderTemplate = z.infer + +/** Feishu interactive card structure */ +interface FeishuCard { + elements: FeishuCardElement[] + header: { + template: FeishuHeaderTemplate + title: { + tag: 'plain_text' + content: string + } + } +} + +/** Feishu webhook request payload */ +interface FeishuPayload { + timestamp: string + sign: string + msg_type: 'interactive' + card: FeishuCard +} + +/** Issue subcommand options */ +interface IssueOptions { + url: string + number: string + title: string + summary: string + author?: string + labels?: string +} + +/** Send subcommand options */ +interface SendOptions { + title: string + description: string + color?: string +} + +/** + * Generate Feishu webhook signature using HMAC-SHA256 + * @param secret - Feishu webhook secret + * @param timestamp - Unix timestamp in seconds + * @returns Base64 encoded signature + */ +function generateSignature(secret: string, timestamp: number): string { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', stringToSign) + return hmac.digest('base64') +} + +/** + * Send message to Feishu webhook + * @param webhookUrl - Feishu webhook URL + * @param secret - Feishu webhook secret + * @param content - Feishu card message content + * @returns Resolves when message is sent successfully + * @throws When Feishu API returns non-2xx status code or network error occurs + */ +function sendToFeishu(webhookUrl: string, secret: string, content: FeishuCard): Promise { + return new Promise((resolve, reject) => { + const timestamp = Math.floor(Date.now() / 1000) + const sign = generateSignature(secret, timestamp) + + const payload: FeishuPayload = { + timestamp: timestamp.toString(), + sign, + msg_type: 'interactive', + card: content + } + + const payloadStr = JSON.stringify(payload) + const url = new URL(webhookUrl) + + const options: https.RequestOptions = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payloadStr) + } + } + + const req = https.request(options, (res) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + console.log('Successfully sent to Feishu:', data) + resolve() + } else { + reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`)) + } + }) + }) + + req.on('error', (error: Error) => { + reject(error) + }) + + req.write(payloadStr) + req.end() + }) +} + +/** + * Create Feishu card message from issue data + * @param issueData - GitHub issue data + * @returns Feishu card content + */ +function createIssueCard(issueData: IssueData): FeishuCard { + const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData + + const elements: FeishuCardElement[] = [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**Author:** ${issueAuthor}` + } + } + ] + + if (labels.length > 0) { + elements.push({ + tag: 'div', + text: { + tag: 'lark_md', + content: `**Labels:** ${labels.join(', ')}` + } + }) + } + + elements.push( + { tag: 'hr' }, + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**Summary:**\n${issueSummary}` + } + }, + { tag: 'hr' }, + { + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: 'View Issue' + }, + type: 'primary', + url: issueUrl + } + ] + } + ) + + return { + elements, + header: { + template: 'blue', + title: { + tag: 'plain_text', + content: `#${issueNumber} - ${issueTitle}` + } + } + } +} + +/** + * Create a simple Feishu card message + * @param title - Card title + * @param description - Card description content + * @param color - Header color template (default: 'turquoise') + * @returns Feishu card content + */ +function createSimpleCard(title: string, description: string, color: FeishuHeaderTemplate = 'turquoise'): FeishuCard { + return { + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: description + } + } + ], + header: { + template: color, + title: { + tag: 'plain_text', + content: title + } + } + } +} + +/** + * Get Feishu credentials from environment variables + */ +function getCredentials(): { webhookUrl: string; secret: string } { + const webhookUrl = process.env.FEISHU_WEBHOOK_URL + const secret = process.env.FEISHU_WEBHOOK_SECRET + + if (!webhookUrl) { + console.error('Error: FEISHU_WEBHOOK_URL environment variable is required') + process.exit(1) + } + if (!secret) { + console.error('Error: FEISHU_WEBHOOK_SECRET environment variable is required') + process.exit(1) + } + + return { webhookUrl, secret } +} + +/** + * Handle send subcommand + */ +async function handleSendCommand(options: SendOptions): Promise { + const { webhookUrl, secret } = getCredentials() + + const { title, description, color = 'turquoise' } = options + + // Validate color parameter + const colorValidation = FeishuHeaderTemplateSchema.safeParse(color) + if (!colorValidation.success) { + console.error(`Error: Invalid color "${color}". Valid colors: ${FeishuHeaderTemplateSchema.options.join(', ')}`) + process.exit(1) + } + + const card = createSimpleCard(title, description, colorValidation.data) + + console.log('Sending notification to Feishu...') + console.log(`Title: ${title}`) + + await sendToFeishu(webhookUrl, secret, card) + + console.log('Notification sent successfully!') +} + +/** + * Handle issue subcommand + */ +async function handleIssueCommand(options: IssueOptions): Promise { + const { webhookUrl, secret } = getCredentials() + + const { url, number, title, summary, author = 'Unknown', labels: labelsStr = '' } = options + + if (!url || !number || !title || !summary) { + console.error('Error: --url, --number, --title, and --summary are required') + process.exit(1) + } + + const labels = labelsStr + ? labelsStr + .split(',') + .map((l) => l.trim()) + .filter(Boolean) + : [] + + const issueData: IssueData = { + issueUrl: url, + issueNumber: number, + issueTitle: title, + issueSummary: summary, + issueAuthor: author, + labels + } + + const card = createIssueCard(issueData) + + console.log('Sending notification to Feishu...') + console.log(`Issue #${number}: ${title}`) + + await sendToFeishu(webhookUrl, secret, card) + + console.log('Notification sent successfully!') +} + +// Configure CLI +const program = new Command() + +program.name('feishu-notify').description('Send notifications to Feishu webhook').version(VERSION) + +// Send subcommand (generic) +program + .command('send') + .description('Send a simple notification to Feishu') + .requiredOption('-t, --title ', 'Card title') + .requiredOption('-d, --description <description>', 'Card description (supports markdown)') + .option( + '-c, --color <color>', + `Header color template (default: turquoise). Options: ${FeishuHeaderTemplateSchema.options.join(', ')}`, + 'turquoise' + ) + .action(async (options: SendOptions) => { + try { + await handleSendCommand(options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error) + process.exit(1) + } + }) + +// Issue subcommand +program + .command('issue') + .description('Send GitHub issue notification to Feishu') + .requiredOption('-u, --url <url>', 'GitHub issue URL') + .requiredOption('-n, --number <number>', 'Issue number') + .requiredOption('-t, --title <title>', 'Issue title') + .requiredOption('-m, --summary <summary>', 'Issue summary') + .option('-a, --author <author>', 'Issue author', 'Unknown') + .option('-l, --labels <labels>', 'Issue labels, comma-separated') + .action(async (options: IssueOptions) => { + try { + await handleIssueCommand(options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error) + process.exit(1) + } + }) + +program.parse()