feat: 飞书单点登录和通知功能

This commit is contained in:
2026-02-03 11:38:16 +08:00
parent c657fbe01a
commit 78ebc67e2a
18 changed files with 2136 additions and 105 deletions

View File

@@ -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
View 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
};

View File

@@ -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
View 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
View 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
};

View File

@@ -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
View 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 - 接收者 IDopen_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
};