267 lines
7.1 KiB
JavaScript
267 lines
7.1 KiB
JavaScript
/**
|
||
* 飞书消息服务
|
||
* 支持应用机器人(私聊/群聊)和自定义机器人(群聊 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
|
||
};
|
||
|