You've already forked GiteaToFeishuMsg
initProject
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -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
|
||||||
161
README.md
Normal file
161
README.md
Normal file
@@ -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 <repository-url>
|
||||||
|
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
|
||||||
237
design.md
Normal file
237
design.md
Normal file
@@ -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 调用
|
||||||
|
- [ ] 添加错误处理和日志
|
||||||
|
- [ ] 编写配置文件
|
||||||
|
- [ ] 编写部署说明
|
||||||
|
- [ ] 测试与验证
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/clients/feishu.js
Normal file
39
src/clients/feishu.js
Normal file
@@ -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<Object>} 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 };
|
||||||
17
src/config.js
Normal file
17
src/config.js
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
38
src/server.js
Normal file
38
src/server.js
Normal file
@@ -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;
|
||||||
125
src/transformers/giteaToFeishu.js
Normal file
125
src/transformers/giteaToFeishu.js
Normal file
@@ -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 };
|
||||||
33
src/utils/logger.js
Normal file
33
src/utils/logger.js
Normal file
@@ -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;
|
||||||
55
src/webhooks/gitea.js
Normal file
55
src/webhooks/gitea.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user