feat: 飞书单点登录和通知功能
This commit is contained in:
@@ -21,6 +21,14 @@ const config = {
|
||||
baseUrl: process.env.JIRA_BASE_URL,
|
||||
botId: process.env.JIRA_BOT_ID || '',
|
||||
botName: process.env.JIRA_BOT_NAME
|
||||
},
|
||||
lark: {
|
||||
appId: process.env.LARK_APP_ID || '',
|
||||
appSecret: process.env.LARK_APP_SECRET || '',
|
||||
webhookUrl: process.env.LARK_WEBHOOK_URL || '',
|
||||
webhookSecret: process.env.LARK_WEBHOOK_SECRET || '',
|
||||
redirectUri: process.env.LARK_REDIRECT_URI || '',
|
||||
adminIds: (process.env.ADMIN_IDS || '').split(',').map(id => id.trim()).filter(id => id)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
121
src/config/larkRules.js
Normal file
121
src/config/larkRules.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 飞书提醒规则管理
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const DATA_FILE = path.join(__dirname, '../../data/larkRules.json');
|
||||
|
||||
//确保数据文件存在
|
||||
function ensureDataFile() {
|
||||
const dir = path.dirname(DATA_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(DATA_FILE)) {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify({ rules: [] }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
//读取所有规则
|
||||
function getAllRules() {
|
||||
ensureDataFile();
|
||||
try {
|
||||
const data = fs.readFileSync(DATA_FILE, 'utf-8');
|
||||
return JSON.parse(data).rules || [];
|
||||
} catch (e) {
|
||||
logger.error('[LarkRules] Failed to read rules:', e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
//保存所有规则
|
||||
function saveAllRules(rules) {
|
||||
ensureDataFile();
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify({ rules }, null, 2));
|
||||
}
|
||||
|
||||
//获取单个规则
|
||||
function getRule(id) {
|
||||
return getAllRules().find(r => r.id === id);
|
||||
}
|
||||
|
||||
//创建规则
|
||||
function createRule(rule) {
|
||||
const rules = getAllRules();
|
||||
const newRule = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
enabled: true,
|
||||
...rule
|
||||
};
|
||||
rules.push(newRule);
|
||||
saveAllRules(rules);
|
||||
logger.info(`[LarkRules] created rule: ${newRule.name} (${newRule.id})`);
|
||||
return newRule;
|
||||
}
|
||||
|
||||
//更新规则
|
||||
function updateRule(id, updates) {
|
||||
const rules = getAllRules();
|
||||
const index = rules.findIndex(r => r.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('Rule not found');
|
||||
}
|
||||
rules[index] = { ...rules[index], ...updates, id, updatedAt: new Date().toISOString() };
|
||||
saveAllRules(rules);
|
||||
logger.info(`[LarkRules] updated rule: ${rules[index].name} (${id})`);
|
||||
return rules[index];
|
||||
}
|
||||
|
||||
//删除规则
|
||||
function deleteRule(id) {
|
||||
const rules = getAllRules();
|
||||
const index = rules.findIndex(r => r.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('Rule not found');
|
||||
}
|
||||
const deleted = rules.splice(index, 1)[0];
|
||||
saveAllRules(rules);
|
||||
logger.info(`[LarkRules] deleted rule: ${deleted.name} (${id})`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
//根据事件类型获取匹配的规则
|
||||
function getRulesByEvent(eventType) {
|
||||
return getAllRules().filter(r => r.enabled && r.event === eventType);
|
||||
}
|
||||
|
||||
//支持的事件类型
|
||||
const EVENT_TYPES = [
|
||||
{ value: 'issue.opened', label: '新建 Issue' },
|
||||
{ value: 'issue.closed', label: '关闭 Issue' },
|
||||
{ value: 'issue.reopened', label: '重开 Issue' },
|
||||
{ value: 'issue.assigned', label: '指派 Issue' },
|
||||
{ value: 'issue.unassigned', label: '取消指派 Issue' },
|
||||
{ value: 'issue.edited', label: '编辑 Issue' },
|
||||
{ value: 'issue.label_updated', label: 'Issue 标签变更' },
|
||||
{ value: 'issue.milestoned', label: 'Issue 里程碑变更' },
|
||||
{ value: 'issue.comment', label: 'Issue 评论' },
|
||||
{ value: 'pr.opened', label: '新建合并请求' },
|
||||
{ value: 'pr.closed', label: '关闭合并请求' },
|
||||
{ value: 'pr.merged', label: '合并请求已合并' },
|
||||
{ value: 'pr.reopened', label: '重开合并请求' },
|
||||
{ value: 'release.created', label: '创建发布' },
|
||||
{ value: 'release.published', label: '发布版本' },
|
||||
{ value: 'release.deleted', label: '删除发布' },
|
||||
{ value: 'release.updated', label: '更新发布' }
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
getAllRules,
|
||||
getRule,
|
||||
createRule,
|
||||
updateRule,
|
||||
deleteRule,
|
||||
getRulesByEvent,
|
||||
EVENT_TYPES
|
||||
};
|
||||
@@ -2,7 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
//读取配置文件路径
|
||||
const configPath = path.join(__dirname, '../../mappings.json');
|
||||
const configPath = path.join(__dirname, '../../data/mappings.json');
|
||||
|
||||
let mappingsConfig = null;
|
||||
|
||||
|
||||
226
src/logic/larkNotifier.js
Normal file
226
src/logic/larkNotifier.js
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 飞书通知调度器
|
||||
* 根据事件触发规则并发送飞书消息
|
||||
*/
|
||||
|
||||
const larkService = require('../services/lark');
|
||||
const larkRules = require('../config/larkRules');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../config/env');
|
||||
|
||||
/**
|
||||
* 构建消息内容
|
||||
*/
|
||||
function buildMessage(rule, eventType, payload) {
|
||||
const { repository, issue, pull_request, release, sender, action } = payload;
|
||||
const repoName = repository ? `${repository.owner?.username || repository.owner?.login}/${repository.name}` : 'Unknown';
|
||||
|
||||
//事件对象(Issue/PR/Release)
|
||||
const target = issue || pull_request || release;
|
||||
const targetTitle = target?.title || target?.name || 'Unknown';
|
||||
const targetNumber = target?.number || target?.id || '';
|
||||
const targetUrl = target?.html_url || target?.url || '';
|
||||
|
||||
//操作者
|
||||
const actor = sender?.username || sender?.login || 'Unknown';
|
||||
|
||||
//根据事件类型构建消息
|
||||
const eventLabels = {
|
||||
'issue.opened': '新建工单',
|
||||
'issue.closed': '关闭工单',
|
||||
'issue.reopened': '重开工单',
|
||||
'issue.assigned': '指派工单',
|
||||
'issue.unassigned': '取消指派',
|
||||
'issue.edited': '编辑工单',
|
||||
'issue.label_updated': '标签变更',
|
||||
'issue.milestoned': '里程碑变更',
|
||||
'issue.comment': '新评论',
|
||||
'pr.opened': '新合并请求',
|
||||
'pr.closed': '关闭合并请求',
|
||||
'pr.merged': '合并请求已合并',
|
||||
'pr.reopened': '重开合并请求',
|
||||
'release.created': '创建发布',
|
||||
'release.published': '发布版本',
|
||||
'release.deleted': '删除发布',
|
||||
'release.updated': '更新发布'
|
||||
};
|
||||
|
||||
const eventLabel = eventLabels[eventType] || eventType;
|
||||
|
||||
//如果用户配置了自定义模板,使用模板
|
||||
if (rule.messageTemplate) {
|
||||
return rule.messageTemplate
|
||||
.replace(/\{repo\}/g, repoName)
|
||||
.replace(/\{event\}/g, eventLabel)
|
||||
.replace(/\{title\}/g, targetTitle)
|
||||
.replace(/\{number\}/g, targetNumber)
|
||||
.replace(/\{url\}/g, targetUrl)
|
||||
.replace(/\{actor\}/g, actor)
|
||||
.replace(/\{action\}/g, action || '');
|
||||
}
|
||||
|
||||
//默认消息格式
|
||||
return `${eventLabel}\n仓库: ${repoName}\n标题: ${targetTitle}${targetNumber ? ` #${targetNumber}` : ''}\n操作者: ${actor}${targetUrl ? `\n链接: ${targetUrl}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通知
|
||||
*/
|
||||
async function sendNotification(rule, eventType, payload) {
|
||||
const message = buildMessage(rule, eventType, payload);
|
||||
let atOpenIds = [];
|
||||
|
||||
//将 @ 用户列表转换为 open_id(支持手机号/邮箱)
|
||||
if (rule.atUsers && rule.atUsers.length > 0) {
|
||||
for (const user of rule.atUsers) {
|
||||
if (user === 'all') {
|
||||
atOpenIds.push('all');
|
||||
} else if (user.startsWith('ou_')) {
|
||||
//已经是 open_id
|
||||
atOpenIds.push(user);
|
||||
} else {
|
||||
//尝试通过手机号/邮箱获取 open_id
|
||||
try {
|
||||
const openId = await larkService.getOpenIdByContact(user);
|
||||
atOpenIds.push(openId);
|
||||
} catch (e) {
|
||||
logger.warn(`[LarkNotifier] Failed to get open_id for ${user}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (rule.channel === 'group') {
|
||||
const webhookUrl = larkService.getDefaultWebhookUrl();
|
||||
const secret = larkService.getDefaultWebhookSecret();
|
||||
|
||||
if (!webhookUrl) {
|
||||
logger.warn(`[LarkNotifier] No webhook URL configured (LARK_WEBHOOK_URL)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await larkService.sendWebhookText(webhookUrl, message, secret, atOpenIds);
|
||||
logger.info(`[LarkNotifier] Sent webhook notification for ${eventType} (rule: ${rule.name})`);
|
||||
} else if (rule.channel === 'private' && rule.target) {
|
||||
//私聊 - 使用应用机器人 API
|
||||
let targetOpenId = rule.target;
|
||||
|
||||
//如果 target 不是 open_id,尝试转换
|
||||
if (!rule.target.startsWith('ou_')) {
|
||||
try {
|
||||
targetOpenId = await larkService.getOpenIdByContact(rule.target);
|
||||
} catch (e) {
|
||||
logger.error(`[LarkNotifier] Failed to get open_id for target ${rule.target}: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await larkService.sendText(targetOpenId, 'open_id', message, atOpenIds);
|
||||
logger.info(`[LarkNotifier] Sent private notification for ${eventType} to ${rule.target} (rule: ${rule.name})`);
|
||||
} else {
|
||||
logger.warn(`[LarkNotifier] Rule ${rule.name} has invalid channel/target config`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[LarkNotifier] Failed to send notification for ${eventType} (rule: ${rule.name}):`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发通知 - 主入口
|
||||
*/
|
||||
async function notify(eventType, payload) {
|
||||
let rules = larkRules.getRulesByEvent(eventType);
|
||||
|
||||
if (rules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
//根据条件过滤规则
|
||||
rules = rules.filter(rule => {
|
||||
//如果没有配置过滤条件,则匹配所有
|
||||
if (!rule.filterKey || !rule.filterValue) return true;
|
||||
|
||||
//指派工单 - 过滤指派人
|
||||
if (eventType === 'issue.assigned') {
|
||||
const filterValue = rule.filterValue;
|
||||
|
||||
// 1. 检查当前被指派的人 (payload.issue.assignee)
|
||||
const assignedUser = payload.issue?.assignee?.username || payload.issue?.assignee?.login;
|
||||
if (assignedUser === filterValue) return true;
|
||||
|
||||
// 2. 检查指派人列表 (payload.issue.assignees)
|
||||
const assignees = payload.issue?.assignees || [];
|
||||
const isInAssignees = assignees.some(u => (u.username || u.login) === filterValue);
|
||||
if (isInAssignees) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
//标签变更 - 过滤标签名
|
||||
if (eventType === 'issue.label_updated') {
|
||||
const labels = payload.issue?.labels || [];
|
||||
const hasLabel = labels.some(l => l.name === rule.filterValue);
|
||||
return hasLabel;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (rules.length === 0) return;
|
||||
|
||||
logger.info(`[LarkNotifier] Event ${eventType} matched ${rules.length} rule(s)`);
|
||||
|
||||
//并行发送所有匹配规则的通知
|
||||
await Promise.allSettled(
|
||||
rules.map(rule => sendNotification(rule, eventType, payload))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Gitea webhook 事件头和 payload 推断事件类型
|
||||
*/
|
||||
function inferEventType(eventHeader, payload) {
|
||||
const { action, pull_request, release, issue } = payload;
|
||||
|
||||
//Pull Request 事件
|
||||
if (eventHeader === 'pull_request' || pull_request) {
|
||||
if (action === 'opened') return 'pr.opened';
|
||||
if (action === 'closed' && pull_request?.merged) return 'pr.merged';
|
||||
if (action === 'closed') return 'pr.closed';
|
||||
if (action === 'reopened') return 'pr.reopened';
|
||||
return null;
|
||||
}
|
||||
|
||||
//Release 事件
|
||||
if (eventHeader === 'release' || release) {
|
||||
if (action === 'created') return 'release.created';
|
||||
if (action === 'published') return 'release.published';
|
||||
if (action === 'deleted') return 'release.deleted';
|
||||
if (action === 'updated') return 'release.updated';
|
||||
return null;
|
||||
}
|
||||
|
||||
//Issue 事件
|
||||
if (eventHeader === 'issues' || eventHeader === 'issue_comment' || issue) {
|
||||
if (eventHeader === 'issue_comment') return 'issue.comment';
|
||||
if (action === 'opened') return 'issue.opened';
|
||||
if (action === 'closed') return 'issue.closed';
|
||||
if (action === 'reopened') return 'issue.reopened';
|
||||
if (action === 'assigned') return 'issue.assigned';
|
||||
if (action === 'unassigned') return 'issue.unassigned';
|
||||
if (action === 'edited') return 'issue.edited';
|
||||
if (action === 'label_updated') return 'issue.label_updated';
|
||||
if (action === 'milestoned' || action === 'demilestoned') return 'issue.milestoned';
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
notify,
|
||||
inferEventType,
|
||||
buildMessage,
|
||||
sendNotification
|
||||
};
|
||||
179
src/middleware/larkAuth.js
Normal file
179
src/middleware/larkAuth.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const { getCookie, setCookie } = require('hono/cookie');
|
||||
const axios = require('axios');
|
||||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const SESSION_COOKIE_NAME = 'issuebot_session';
|
||||
const COOKIE_SECRET = config.lark.appSecret; // 使用 appSecret 作为 cookie 签名密钥
|
||||
|
||||
// 飞书 OAuth 配置
|
||||
const AUTH_URL = 'https://accounts.feishu.cn/open-apis/authen/v1/authorize';
|
||||
const TOKEN_URL = 'https://open.feishu.cn/open-apis/authen/v2/oauth/token';
|
||||
const USER_INFO_URL = 'https://open.feishu.cn/open-apis/authen/v1/user_info';
|
||||
|
||||
/**
|
||||
* 飞书鉴权中间件
|
||||
*/
|
||||
const larkAuthMiddleware = async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
|
||||
// 白名单路径
|
||||
const whitelist = ['/login', '/oauth/callback', '/favicon.ico'];
|
||||
if (path.startsWith('/hooks/') || whitelist.includes(path) || path.startsWith('/assets/')) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
// 检查 Session
|
||||
const session = await getCookie(c, SESSION_COOKIE_NAME, COOKIE_SECRET);
|
||||
if (!session) {
|
||||
// 未登录,重定向到登录页
|
||||
// 如果是 API 请求,返回 401
|
||||
if (path.startsWith('/api/')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
return c.redirect('/login');
|
||||
}
|
||||
|
||||
// 将用户信息注入 request
|
||||
const parsedSession = typeof session === 'string' ? JSON.parse(session) : session;
|
||||
c.set('user', parsedSession);
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录处理:重定向到飞书授权页
|
||||
*/
|
||||
const loginHandler = (c) => {
|
||||
if (!config.lark.redirectUri) {
|
||||
return c.text('Configuration Error: LARK_REDIRECT_URI is missing', 500);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.lark.appId,
|
||||
redirect_uri: config.lark.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'contact:user.base:readonly' // 基础权限,即可获取 Open ID
|
||||
});
|
||||
|
||||
return c.redirect(`${AUTH_URL}?${params.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* OAuth 回调处理
|
||||
*/
|
||||
const callbackHandler = async (c) => {
|
||||
const code = c.req.query('code');
|
||||
const error = c.req.query('error');
|
||||
|
||||
if (error) {
|
||||
logger.error(`[LarkAuth] Auth failed: ${error}`);
|
||||
return c.text(`Authentication failed: ${error}`, 403);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return c.text('Missing authorization code', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 获取 user_access_token
|
||||
const tokenRes = await axios.post(TOKEN_URL, {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.lark.appId,
|
||||
client_secret: config.lark.appSecret,
|
||||
code: code,
|
||||
redirect_uri: config.lark.redirectUri
|
||||
});
|
||||
|
||||
const tokenData = tokenRes.data;
|
||||
if (tokenData.code !== 0) {
|
||||
throw new Error(`Token request failed: ${tokenData.msg || tokenData.error_description}`);
|
||||
}
|
||||
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
// 2. 获取用户信息
|
||||
const userRes = await axios.get(USER_INFO_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
const userData = userRes.data;
|
||||
if (userData.code !== 0) {
|
||||
throw new Error(`User info request failed: ${userData.msg}`);
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
const openId = userData.data.open_id;
|
||||
const isAdmin = config.lark.adminIds.includes(openId);
|
||||
|
||||
// 3. 创建 Session
|
||||
const user = {
|
||||
name: userData.data.name,
|
||||
open_id: openId,
|
||||
avatar: userData.data.avatar_url,
|
||||
isAdmin: isAdmin,
|
||||
loggedInAt: Date.now()
|
||||
};
|
||||
|
||||
// 设置 Cookie
|
||||
await setCookie(c, SESSION_COOKIE_NAME, JSON.stringify(user), {
|
||||
httpOnly: true,
|
||||
secure: false, // 本地开发可能不是 HTTPS,如果上生产建议由反代处理或根据环境设置
|
||||
maxAge: 7 * 24 * 60 * 60, // 7天
|
||||
secret: COOKIE_SECRET
|
||||
});
|
||||
|
||||
logger.info(`[LarkAuth] User logged in: ${user.name} (${user.open_id}), Admin: ${isAdmin}`);
|
||||
if (!isAdmin) {
|
||||
logger.info(`[LarkAuth] To make this user admin, add their Open ID to .env: ADMIN_IDS=${user.open_id}`);
|
||||
}
|
||||
|
||||
return c.redirect('/dashboard');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[LarkAuth] Callback error:', error.message);
|
||||
return c.text(`Login failed: ${error.message}`, 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理员鉴权中间件
|
||||
*/
|
||||
const adminMiddleware = async (c, next) => {
|
||||
const session = await getCookie(c, SESSION_COOKIE_NAME, COOKIE_SECRET);
|
||||
|
||||
// 如果 Session 中没有 isAdmin 字段(旧Session),默认为 false
|
||||
const parsedSession = typeof session === 'string' ? JSON.parse(session) : session;
|
||||
const isAdmin = parsedSession && parsedSession.isAdmin;
|
||||
|
||||
if (!isAdmin) {
|
||||
logger.security(`Blocked non-admin access to ${c.req.url}`, { user: parsedSession?.name });
|
||||
return c.json({ error: 'Forbidden: Admin access only' }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前用户信息 API
|
||||
*/
|
||||
const meHandler = async (c) => {
|
||||
const session = await getCookie(c, SESSION_COOKIE_NAME, COOKIE_SECRET);
|
||||
if (!session) {
|
||||
return c.json({ loggedIn: false });
|
||||
}
|
||||
const parsedSession = typeof session === 'string' ? JSON.parse(session) : session;
|
||||
return c.json({
|
||||
loggedIn: true,
|
||||
name: parsedSession.name,
|
||||
avatar: parsedSession.avatar,
|
||||
isAdmin: !!parsedSession.isAdmin
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
larkAuthMiddleware,
|
||||
loginHandler,
|
||||
callbackHandler,
|
||||
adminMiddleware,
|
||||
meHandler
|
||||
};
|
||||
@@ -8,10 +8,12 @@ const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../config/env');
|
||||
const larkService = require('../services/lark');
|
||||
|
||||
const control = new Hono();
|
||||
|
||||
const MAPPINGS_PATH = path.join(__dirname, '../../mappings.json');
|
||||
const MAPPINGS_PATH = path.join(__dirname, '../../data/mappings.json');
|
||||
const LOGS_DIR = path.join(__dirname, '../../logs');
|
||||
const README_PATH = path.join(__dirname, '../../how-to-use.md');
|
||||
|
||||
@@ -536,4 +538,138 @@ control.post('/proxy-jira', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 飞书提醒规则 API ====================
|
||||
const larkRules = require('../config/larkRules');
|
||||
|
||||
//获取所有规则
|
||||
control.get('/lark/rules', (c) => {
|
||||
try {
|
||||
const rules = larkRules.getAllRules();
|
||||
return c.json({ success: true, rules });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Get rules error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
//获取支持的事件类型
|
||||
control.get('/lark/events', (c) => {
|
||||
return c.json({ success: true, events: larkRules.EVENT_TYPES });
|
||||
});
|
||||
|
||||
//创建规则
|
||||
control.post('/lark/rules', async (c) => {
|
||||
try {
|
||||
const rule = await c.req.json();
|
||||
const created = larkRules.createRule(rule);
|
||||
return c.json({ success: true, rule: created });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Create rule error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
//更新规则
|
||||
control.put('/lark/rules/:id', async (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
const updates = await c.req.json();
|
||||
const updated = larkRules.updateRule(id, updates);
|
||||
return c.json({ success: true, rule: updated });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Update rule error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
//删除规则
|
||||
control.delete('/lark/rules/:id', (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
larkRules.deleteRule(id);
|
||||
return c.json({ success: true });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Delete rule error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
//测试发送
|
||||
control.post('/lark/test', async (c) => {
|
||||
try {
|
||||
const { channel, target, atUsers, message } = await c.req.json();
|
||||
const testMessage = message || '这是一条测试消息\n来自 Gitea-Jira 同步机器人';
|
||||
|
||||
//处理 @ 用户列表(支持手机号/邮箱)
|
||||
let atOpenIds = [];
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
for (const user of atUsers) {
|
||||
if (user === 'all' || user.startsWith('ou_')) {
|
||||
atOpenIds.push(user);
|
||||
} else {
|
||||
try {
|
||||
const openId = await larkService.getOpenIdByContact(user);
|
||||
atOpenIds.push(openId);
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: `无法获取用户 ${user} 的 open_id: ${e.message}` }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (channel === 'group') {
|
||||
const webhookUrl = larkService.getDefaultWebhookUrl();
|
||||
const secret = larkService.getDefaultWebhookSecret();
|
||||
if (!webhookUrl) {
|
||||
return c.json({ success: false, error: '请在环境变量中配置 LARK_WEBHOOK_URL' }, 400);
|
||||
}
|
||||
await larkService.sendWebhookText(webhookUrl, testMessage, secret, atOpenIds);
|
||||
} else if (channel === 'private' && target) {
|
||||
let targetOpenId = target;
|
||||
if (!target.startsWith('ou_')) {
|
||||
try {
|
||||
targetOpenId = await larkService.getOpenIdByContact(target);
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: `无法获取目标用户 ${target} 的 open_id: ${e.message}` }, 400);
|
||||
}
|
||||
}
|
||||
await larkService.sendText(targetOpenId, 'open_id', testMessage, atOpenIds);
|
||||
} else {
|
||||
return c.json({ success: false, error: '请选择正确的渠道' }, 400);
|
||||
}
|
||||
|
||||
return c.json({ success: true, message: '测试消息发送成功' });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Test send error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// 申请管理员权限
|
||||
control.post('/request-permission', async (c) => {
|
||||
try {
|
||||
//从 Hono context 中获取已登录用户(由鉴权中间件注入)
|
||||
const user = c.get('user');
|
||||
if (!user || !user.open_id) {
|
||||
return c.json({ success: false, error: '未获取到用户信息,请重新登录' }, 401);
|
||||
}
|
||||
|
||||
const adminIds = config.lark.adminIds;
|
||||
if (!adminIds || adminIds.length === 0) {
|
||||
return c.json({ success: false, error: '系统未配置管理员,无法发送请求' }, 500);
|
||||
}
|
||||
|
||||
const targetAdmin = adminIds[0]; //默认发给第一位管理员
|
||||
const message = `@${user.name} 正在申请机器人面板管理员权限\nopenid:${user.open_id}`;
|
||||
|
||||
await larkService.sendText(targetAdmin, 'open_id', message);
|
||||
|
||||
return c.json({ success: true, message: '申请已发送,请等待管理员处理' });
|
||||
} catch (e) {
|
||||
logger.error('[Control] Request permission error:', e.message);
|
||||
return c.json({ success: false, error: `发送失败: ${e.message}` }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = control;
|
||||
|
||||
|
||||
266
src/services/lark.js
Normal file
266
src/services/lark.js
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 飞书消息服务
|
||||
* 支持应用机器人(私聊/群聊)和自定义机器人(群聊 webhook)
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
let accessToken = null;
|
||||
let tokenExpireAt = 0;
|
||||
|
||||
/**
|
||||
* 获取应用机器人的 access_token(用于私聊)
|
||||
*/
|
||||
async function getAccessToken() {
|
||||
const { appId, appSecret } = config.lark;
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
throw new Error('飞书应用未配置:请设置 LARK_APP_ID 和 LARK_APP_SECRET');
|
||||
}
|
||||
|
||||
//如果 token 还有效,直接返回
|
||||
if (accessToken && Date.now() < tokenExpireAt - 60000) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ app_id: appId, app_secret: appSecret })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`获取飞书 access_token 失败: ${data.msg}`);
|
||||
}
|
||||
|
||||
accessToken = data.tenant_access_token;
|
||||
tokenExpireAt = Date.now() + data.expire * 1000;
|
||||
|
||||
logger.info('[Lark] Access token refreshed');
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名(用于自定义机器人 webhook)
|
||||
*/
|
||||
function genSign(timestamp, secret) {
|
||||
const stringToSign = `${timestamp}\n${secret}`;
|
||||
const hmac = crypto.createHmac('sha256', stringToSign);
|
||||
return hmac.digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过自定义机器人 webhook 发送消息(群聊)
|
||||
*/
|
||||
async function sendWebhook(webhookUrl, message, secret = null) {
|
||||
const body = { ...message };
|
||||
|
||||
if (secret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
body.timestamp = timestamp;
|
||||
body.sign = genSign(timestamp, secret);
|
||||
}
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`飞书 webhook 发送失败: ${data.msg || JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过应用机器人 API 发送消息(私聊/群聊)
|
||||
* @param {string} receiveId - 接收者 ID(open_id/user_id/chat_id)
|
||||
* @param {string} receiveIdType - ID 类型:open_id | user_id | union_id | email | chat_id
|
||||
* @param {string} msgType - 消息类型:text | post | interactive
|
||||
* @param {object} content - 消息内容
|
||||
*/
|
||||
async function sendMessage(receiveId, receiveIdType, msgType, content) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
receive_id: receiveId,
|
||||
msg_type: msgType,
|
||||
content: typeof content === 'string' ? content : JSON.stringify(content)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`飞书消息发送失败: ${data.msg || JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息(便捷方法)
|
||||
*/
|
||||
async function sendText(receiveId, receiveIdType, text, atUsers = []) {
|
||||
let finalText = text;
|
||||
|
||||
//添加 @ 用户
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
const atTags = atUsers.map(uid =>
|
||||
uid === 'all' ? '<at user_id="all">所有人</at>' : `<at user_id="${uid}"></at>`
|
||||
).join(' ');
|
||||
finalText = `${atTags} ${text}`;
|
||||
}
|
||||
|
||||
return sendMessage(receiveId, receiveIdType, 'text', { text: finalText });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送富文本消息
|
||||
*/
|
||||
async function sendPost(receiveId, receiveIdType, title, content, atUsers = []) {
|
||||
const postContent = {
|
||||
zh_cn: {
|
||||
title,
|
||||
content: [[]]
|
||||
}
|
||||
};
|
||||
|
||||
//添加 @ 用户
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
atUsers.forEach(uid => {
|
||||
postContent.zh_cn.content[0].push({
|
||||
tag: 'at',
|
||||
user_id: uid
|
||||
});
|
||||
});
|
||||
postContent.zh_cn.content[0].push({ tag: 'text', text: ' ' });
|
||||
}
|
||||
|
||||
//添加正文
|
||||
postContent.zh_cn.content[0].push({ tag: 'text', text: content });
|
||||
|
||||
return sendMessage(receiveId, receiveIdType, 'post', { post: postContent });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 webhook 文本消息(便捷方法)
|
||||
*/
|
||||
async function sendWebhookText(webhookUrl, text, secret = null, atUsers = []) {
|
||||
let finalText = text;
|
||||
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
const atTags = atUsers.map(uid =>
|
||||
uid === 'all' ? '<at user_id="all">所有人</at>' : `<at user_id="${uid}"></at>`
|
||||
).join(' ');
|
||||
finalText = `${atTags} ${text}`;
|
||||
}
|
||||
|
||||
return sendWebhook(webhookUrl, {
|
||||
msg_type: 'text',
|
||||
content: { text: finalText }
|
||||
}, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 webhook 富文本消息
|
||||
*/
|
||||
async function sendWebhookPost(webhookUrl, title, content, secret = null, atUsers = []) {
|
||||
const postContent = [[{ tag: 'text', text: content }]];
|
||||
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
const atElements = atUsers.map(uid => ({
|
||||
tag: 'at',
|
||||
user_id: uid
|
||||
}));
|
||||
postContent[0] = [...atElements, { tag: 'text', text: ' ' }, ...postContent[0]];
|
||||
}
|
||||
|
||||
return sendWebhook(webhookUrl, {
|
||||
msg_type: 'post',
|
||||
content: {
|
||||
post: {
|
||||
zh_cn: { title, content: postContent }
|
||||
}
|
||||
}
|
||||
}, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过手机号或邮箱获取用户 open_id
|
||||
* @param {string} identifier - 手机号或邮箱
|
||||
* @returns {string} open_id
|
||||
*/
|
||||
async function getOpenIdByContact(identifier) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
const isEmail = identifier.includes('@');
|
||||
const payload = isEmail
|
||||
? { emails: [identifier] }
|
||||
: { mobiles: [identifier] };
|
||||
|
||||
const res = await fetch('https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`获取用户 open_id 失败: ${data.msg}`);
|
||||
}
|
||||
|
||||
const userList = data.data?.user_list || [];
|
||||
if (userList.length === 0 || !userList[0].user_id) {
|
||||
throw new Error(`未找到用户: ${identifier}`);
|
||||
}
|
||||
|
||||
return userList[0].user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置的默认 webhook URL
|
||||
*/
|
||||
function getDefaultWebhookUrl() {
|
||||
return config.lark.webhookUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置的默认 webhook secret
|
||||
*/
|
||||
function getDefaultWebhookSecret() {
|
||||
return config.lark.webhookSecret || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccessToken,
|
||||
genSign,
|
||||
sendWebhook,
|
||||
sendMessage,
|
||||
sendText,
|
||||
sendPost,
|
||||
sendWebhookText,
|
||||
sendWebhookPost,
|
||||
getOpenIdByContact,
|
||||
getDefaultWebhookUrl,
|
||||
getDefaultWebhookSecret
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user