/** * 飞书消息服务 * 支持应用机器人(私聊/群聊)和自定义机器人(群聊 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' ? '所有人' : `` ).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' ? '所有人' : `` ).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 };