feat: 飞书单点登录和通知功能
This commit is contained in:
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