commit 98340a8244b7d24b95d3737cd8db4450753fb2fc Author: CORE-FOLDCC\Core <1813547935@qq.com> Date: Tue Dec 2 13:27:52 2025 +0800 initProject diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..68a6ab2 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Server +PORT=3000 + +# Gitea Webhook Secret (optional) +GITEA_WEBHOOK_SECRET=your_gitea_webhook_secret_here + +# Feishu Bot Webhook URL +FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxxxx + +# Logging +LOG_LEVEL=info \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab7dd91 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Gitea 到飞书 Webhook 中转服务 + +将 Gitea 的工单事件(创建、更新、关闭等)通过 Webhook 接收,转换为美观的飞书卡片消息,并转发到飞书群聊。 + +## 功能特性 + +- 接收 Gitea Webhook 事件(支持工单相关动作) +- 验证 Webhook 签名(可选) +- 将事件转换为飞书交互式卡片 +- 支持自定义卡片标题、颜色、字段 +- 完整的错误处理和日志记录 +- 健康检查端点 +- 易于部署和配置 + +## 系统架构 + +``` +Gitea → Webhook → 中转服务 (Express) → 飞书机器人 → 飞书群聊 +``` + +## 快速开始 + +### 前提条件 + +- Node.js 18+ +- 一个飞书机器人,并获取其 Webhook URL +- Gitea 实例(版本 1.20+) + +### 安装 + +1. 克隆仓库 +```bash +git clone +cd gitea-feishu-webhook-relay +``` + +2. 安装依赖 +```bash +npm install +``` + +3. 配置环境变量 +复制 `.env.example` 为 `.env` 并填写实际值: +```bash +cp .env.example .env +``` +编辑 `.env`: +```env +PORT=3000 +GITEA_WEBHOOK_SECRET=your_gitea_webhook_secret +FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxx +LOG_LEVEL=info +``` + +### 运行 + +开发模式(使用 nodemon): +```bash +npm run dev +``` + +生产模式: +```bash +npm start +``` + +服务将在 `http://localhost:3000` 启动。 + +## 配置 Gitea Webhook + +1. 进入 Gitea 仓库的设置 → Webhooks → 添加 Webhook +2. 选择 “Gitea” 类型 +3. 目标 URL:`http://your-server-ip:3000/webhook/gitea` +4. 密钥:填写与 `GITEA_WEBHOOK_SECRET` 相同的值 +5. 触发事件:选择 “工单事件” +6. 保存 + +## 飞书机器人配置 + +1. 在飞书开放平台创建一个自定义机器人,获取 Webhook URL。 +2. 将机器人添加到目标群聊。 +3. 将 Webhook URL 填入 `FEISHU_WEBHOOK_URL`。 + +## API 端点 + +- `GET /health` – 健康检查 +- `POST /webhook/gitea` – 接收 Gitea Webhook + +## 日志 + +日志使用 Winston 输出到控制台和文件: +- `logs/error.log` – 错误日志 +- `logs/combined.log` – 所有日志 + +## Docker 部署 + +提供 Dockerfile 便于容器化部署。 + +构建镜像: +```bash +docker build -t gitea-feishu-relay . +``` + +运行容器: +```bash +docker run -p 3000:3000 --env-file .env gitea-feishu-relay +``` + +## 环境变量 + +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| PORT | 服务监听端口 | 3000 | +| GITEA_WEBHOOK_SECRET | Gitea Webhook 签名密钥 | (空) | +| FEISHU_WEBHOOK_URL | 飞书机器人 Webhook URL | (必需) | +| LOG_LEVEL | 日志级别 (error, warn, info, debug) | info | +| NODE_ENV | 运行环境 (development, production) | development | + +## 开发 + +### 项目结构 + +``` +src/ +├── server.js # Express 服务器入口 +├── config.js # 配置管理 +├── webhooks/ +│ └── gitea.js # Webhook 处理器 +├── transformers/ +│ └── giteaToFeishu.js # 消息转换器 +├── clients/ +│ └── feishu.js # 飞书 API 客户端 +└── utils/ + └── logger.js # 日志工具 +``` + +### 测试 + +运行单元测试: +```bash +npm test +``` + +## 故障排除 + +1. **收不到飞书消息** + - 检查 `FEISHU_WEBHOOK_URL` 是否正确 + - 查看日志中是否有错误信息 + - 确认飞书机器人已加入群聊 + +2. **Gitea Webhook 验证失败** + - 确保 `GITEA_WEBHOOK_SECRET` 与 Gitea 中设置的密钥一致 + - 检查请求头中是否包含 `X-Gitea-Signature` + +3. **服务无法启动** + - 确认端口未被占用 + - 检查 Node.js 版本 + +## 许可证 + +MIT \ No newline at end of file diff --git a/design.md b/design.md new file mode 100644 index 0000000..7251094 --- /dev/null +++ b/design.md @@ -0,0 +1,237 @@ +# Gitea 到飞书 Webhook 中转服务设计 + +## 1. 概述 + +本服务作为 Gitea Webhook 和飞书机器人之间的中转层,接收 Gitea 发送的工单事件(创建、更新、关闭等),将其转换为更直观美观的飞书卡片消息,并转发给飞书群聊。 + +## 2. 系统架构 + +### 2.1 组件图 + +```mermaid +graph TD + A[Gitea] -->|发送 Webhook 事件| B(中转服务) + B -->|验证 & 解析| C{消息转换器} + C -->|生成飞书卡片| D[飞书 API 客户端] + D -->|POST 请求| E[飞书机器人] + E --> F[飞书群聊] + B --> G[日志存储] + B --> H[错误处理] +``` + +### 2.2 数据流 + +1. **Gitea 触发事件**:用户在 Gitea 的 Kanban 中创建、更新或关闭工单。 +2. **Webhook 发送**:Gitea 向预设的中转服务 URL 发送 HTTP POST 请求,载荷为 JSON。 +3. **中转服务接收**:Express 服务器在 `/webhook/gitea` 端点接收请求。 +4. **验证与解析**:验证请求签名(可选),解析 JSON 载荷。 +5. **消息转换**:根据事件类型提取关键字段,映射为飞书卡片消息结构。 +6. **飞书 API 调用**:使用飞书机器人的 Webhook URL 发送卡片消息。 +7. **响应与日志**:记录成功/失败日志,返回适当 HTTP 状态码。 + +## 3. Gitea Webhook 数据结构 + +基于 Gitea 1.20+ 的 Webhook 文档,工单事件(Issue)的 JSON 示例: + +```json +{ + "action": "opened", + "issue": { + "id": 123, + "number": 45, + "title": "工单标题", + "body": "工单描述", + "state": "open", + "created_at": "2025-12-02T05:00:00Z", + "updated_at": "2025-12-02T05:00:00Z", + "closed_at": null, + "user": { + "id": 1, + "login": "user1", + "full_name": "用户姓名" + }, + "assignee": { + "id": 2, + "login": "user2", + "full_name": "指派给" + }, + "labels": [ + {"name": "bug", "color": "d73a4a"} + ], + "milestone": { + "title": "里程碑" + } + }, + "repository": { + "id": 1, + "name": "project", + "full_name": "org/project", + "html_url": "https://gitea.example.com/org/project" + }, + "sender": { + "id": 1, + "login": "user1" + } +} +``` + +**支持的事件类型**: +- `opened`:工单创建 +- `closed`:工单关闭 +- `reopened`:工单重新打开 +- `edited`:工单编辑 +- `assigned`:指派 +- `unassigned`:取消指派 +- `labeled`:添加标签 +- `unlabeled`:移除标签 + +## 4. 飞书卡片消息格式 + +飞书卡片消息使用交互式卡片模板。基本结构: + +```json +{ + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "工单状态更新" + }, + "template": "blue" // 根据状态变化颜色 + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**工单标题**:工单标题" + } + }, + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**状态**:打开" + } + }, + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**创建者**:用户姓名" + } + }, + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**优先级**:高" + } + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "查看工单" + }, + "type": "primary", + "url": "https://gitea.example.com/org/project/issues/45" + } + ] + } + ] + } +} +``` + +## 5. 消息转换逻辑 + +### 5.1 事件类型映射 + +| Gitea 事件 | 飞书卡片标题 | 颜色模板 | +|------------|--------------|----------| +| opened | 新工单创建 | blue | +| closed | 工单关闭 | red | +| reopened | 工单重新打开 | orange | +| edited | 工单已编辑 | green | +| assigned | 工单已指派 | purple | +| unassigned | 工单取消指派 | grey | + +### 5.2 字段提取 + +- **标题**:`issue.title` +- **状态**:`issue.state` (open/closed) +- **创建者**:`issue.user.full_name` 或 `issue.user.login` +- **链接**:`repository.html_url` + `/issues/` + `issue.number` +- **优先级**:从 `issue.labels` 中查找包含 "priority:" 的标签,或默认 "中" + +### 5.3 转换规则 + +1. 如果 `action` 是 `opened`,卡片标题为 "新工单创建"。 +2. 如果 `action` 是 `closed`,卡片标题为 "工单关闭",并显示关闭时间。 +3. 如果存在 `assignee`,添加 "指派给" 字段。 +4. 标签列表可显示为标签组(可选)。 + +## 6. 配置项 + +服务需要以下配置: + +- `PORT`:服务监听端口(默认 3000) +- `FEISHU_WEBHOOK_URL`:飞书机器人 Webhook URL +- `GITEA_WEBHOOK_SECRET`:Gitea Webhook 密钥(用于验证) +- `LOG_LEVEL`:日志级别(info, debug, error) + +## 7. 部署说明 + +### 7.1 本地运行 + +```bash +npm install +npm start +``` + +### 7.2 Docker 部署 + +```dockerfile +FROM node:18-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY . . +EXPOSE 3000 +CMD ["node", "src/server.js"] +``` + +### 7.3 环境变量 + +在 `.env` 文件中设置: + +```env +PORT=3000 +FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxx +GITEA_WEBHOOK_SECRET=your_secret +``` + +## 8. 待办事项 + +- [x] 收集需求并明确功能范围 +- [x] 设计系统架构和数据流 +- [x] 确定 Gitea webhook 数据结构 +- [x] 确定飞书机器人消息格式 +- [x] 设计消息转换逻辑 +- [ ] 创建项目结构 +- [ ] 实现 Express 服务器 +- [ ] 实现 webhook 接收端点 +- [ ] 实现消息转换器 +- [ ] 实现飞书 API 调用 +- [ ] 添加错误处理和日志 +- [ ] 编写配置文件 +- [ ] 编写部署说明 +- [ ] 测试与验证 \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f40463e --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "gitea-feishu-webhook-relay", + "version": "1.0.0", + "description": "A webhook relay service that transforms Gitea issue events into Feishu card messages.", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js", + "test": "jest" + }, + "keywords": ["gitea", "feishu", "webhook", "relay"], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "axios": "^1.6.0", + "dotenv": "^16.3.1", + "winston": "^3.11.0", + "crypto": "^1.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "jest": "^29.7.0", + "supertest": "^6.3.3" + } +} \ No newline at end of file diff --git a/src/clients/feishu.js b/src/clients/feishu.js new file mode 100644 index 0000000..f8f37ba --- /dev/null +++ b/src/clients/feishu.js @@ -0,0 +1,39 @@ +const axios = require('axios'); +const logger = require('../utils/logger'); + +const FEISHU_WEBHOOK_URL = process.env.FEISHU_WEBHOOK_URL; + +if (!FEISHU_WEBHOOK_URL) { + logger.warn('FEISHU_WEBHOOK_URL is not set, Feishu notifications will fail'); +} + +/** + * Send interactive card to Feishu + * @param {Object} card - Feishu card payload + * @returns {Promise} Feishu API response + */ +async function sendToFeishu(card) { + if (!FEISHU_WEBHOOK_URL) { + throw new Error('FEISHU_WEBHOOK_URL not configured'); + } + + try { + const response = await axios.post(FEISHU_WEBHOOK_URL, card, { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000, + }); + + if (response.data.code !== 0) { + logger.error('Feishu API returned error', response.data); + throw new Error(`Feishu error: ${response.data.msg}`); + } + + logger.debug('Feishu API success', response.data); + return response.data; + } catch (error) { + logger.error('Failed to send message to Feishu', error); + throw error; + } +} + +module.exports = { sendToFeishu }; \ No newline at end of file diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..280d987 --- /dev/null +++ b/src/config.js @@ -0,0 +1,17 @@ +require('dotenv').config(); + +module.exports = { + server: { + port: process.env.PORT || 3000, + env: process.env.NODE_ENV || 'development', + }, + gitea: { + webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '', + }, + feishu: { + webhookUrl: process.env.FEISHU_WEBHOOK_URL || '', + }, + logging: { + level: process.env.LOG_LEVEL || 'info', + }, +}; \ No newline at end of file diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..52c6492 --- /dev/null +++ b/src/server.js @@ -0,0 +1,38 @@ +const express = require('express'); +const dotenv = require('dotenv'); +const logger = require('./utils/logger'); +const giteaWebhookHandler = require('./webhooks/gitea'); + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Health check +app.get('/health', (req, res) => { + res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Gitea webhook endpoint +app.post('/webhook/gitea', giteaWebhookHandler); + +// 404 handler +app.use('*', (req, res) => { + res.status(404).json({ error: 'Not Found' }); +}); + +// Global error handler +app.use((err, req, res, next) => { + logger.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal Server Error' }); +}); + +app.listen(PORT, () => { + logger.info(`Server listening on port ${PORT}`); +}); + +module.exports = app; \ No newline at end of file diff --git a/src/transformers/giteaToFeishu.js b/src/transformers/giteaToFeishu.js new file mode 100644 index 0000000..f5cdccc --- /dev/null +++ b/src/transformers/giteaToFeishu.js @@ -0,0 +1,125 @@ +const logger = require('../utils/logger'); + +/** + * Map Gitea action to Feishu card title and color + */ +function getCardInfo(action) { + const mapping = { + opened: { title: '新工单创建', color: 'blue' }, + closed: { title: '工单关闭', color: 'red' }, + reopened: { title: '工单重新打开', color: 'orange' }, + edited: { title: '工单已编辑', color: 'green' }, + assigned: { title: '工单已指派', color: 'purple' }, + unassigned: { title: '工单取消指派', color: 'grey' }, + labeled: { title: '工单添加标签', color: 'turquoise' }, + unlabeled: { title: '工单移除标签', color: 'grey' }, + }; + return mapping[action] || { title: '工单状态更新', color: 'blue' }; +} + +/** + * Extract priority from labels + */ +function extractPriority(labels) { + if (!labels) return '中'; + const priorityLabels = labels.filter(l => l.name.includes('priority:') || l.name.includes('优先级')); + if (priorityLabels.length === 0) return '中'; + const label = priorityLabels[0].name.toLowerCase(); + if (label.includes('high') || label.includes('高')) return '高'; + if (label.includes('low') || label.includes('低')) return '低'; + return '中'; +} + +/** + * Transform Gitea webhook event to Feishu interactive card + */ +function transformToFeishuCard(giteaEvent) { + const { action, issue, repository } = giteaEvent; + if (!issue || !repository) { + logger.warn('Invalid Gitea event: missing issue or repository'); + return null; + } + + // Ignore certain actions if needed + const ignoredActions = ['labeled', 'unlabeled']; // optional + if (ignoredActions.includes(action)) { + logger.debug(`Action ${action} ignored`); + return null; + } + + const cardInfo = getCardInfo(action); + const priority = extractPriority(issue.labels); + const issueUrl = `${repository.html_url}/issues/${issue.number}`; + const assigneeText = issue.assignee ? issue.assignee.full_name || issue.assignee.login : '未指派'; + + const card = { + msg_type: 'interactive', + card: { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: cardInfo.title, + }, + template: cardInfo.color, + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**工单标题**:${issue.title}`, + }, + }, + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**状态**:${issue.state === 'open' ? '打开' : '关闭'}`, + }, + }, + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**创建者**:${issue.user.full_name || issue.user.login}`, + }, + }, + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**优先级**:${priority}`, + }, + }, + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**指派给**:${assigneeText}`, + }, + }, + { + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: '查看工单', + }, + type: 'primary', + url: issueUrl, + }, + ], + }, + ], + }, + }; + + return card; +} + +module.exports = { transformToFeishuCard }; \ No newline at end of file diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..2ec57e9 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,33 @@ +const winston = require('winston'); + +const logLevel = process.env.LOG_LEVEL || 'info'; + +const logger = winston.createLogger({ + level: logLevel, + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + defaultMeta: { service: 'gitea-feishu-relay' }, + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ), + }), + new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), + new winston.transports.File({ filename: 'logs/combined.log' }), + ], +}); + +// Create logs directory if not exists (optional) +const fs = require('fs'); +const path = require('path'); +const logsDir = path.join(__dirname, '../../logs'); +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +module.exports = logger; \ No newline at end of file diff --git a/src/webhooks/gitea.js b/src/webhooks/gitea.js new file mode 100644 index 0000000..d61ddd8 --- /dev/null +++ b/src/webhooks/gitea.js @@ -0,0 +1,55 @@ +const logger = require('../utils/logger'); +const { transformToFeishuCard } = require('../transformers/giteaToFeishu'); +const { sendToFeishu } = require('../clients/feishu'); +const crypto = require('crypto'); + +/** + * Validate Gitea webhook signature (if secret is configured) + */ +function verifySignature(req, secret) { + if (!secret) return true; + const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature']; + if (!signature) { + logger.warn('Missing signature header'); + return false; + } + const hmac = crypto.createHmac('sha256', secret); + hmac.update(JSON.stringify(req.body)); + const expected = `sha256=${hmac.digest('hex')}`; + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); +} + +/** + * Main webhook handler + */ +async function giteaWebhookHandler(req, res) { + const secret = process.env.GITEA_WEBHOOK_SECRET; + const isValid = verifySignature(req, secret); + if (!isValid) { + logger.warn('Invalid webhook signature'); + return res.status(401).json({ error: 'Invalid signature' }); + } + + const event = req.body; + logger.debug('Received Gitea webhook', { action: event.action, issue: event.issue?.number }); + + try { + // Transform Gitea event to Feishu card + const feishuCard = transformToFeishuCard(event); + if (!feishuCard) { + logger.info('Event ignored (not supported)'); + return res.status(200).json({ message: 'Event ignored' }); + } + + // Send to Feishu + const result = await sendToFeishu(feishuCard); + logger.info('Message sent to Feishu', { issue: event.issue?.number, result }); + + res.status(200).json({ message: 'Webhook processed successfully' }); + } catch (error) { + logger.error('Error processing webhook', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +module.exports = giteaWebhookHandler; \ No newline at end of file