Files
gitea-jira-task-bot/src/services/lark.js

267 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 飞书消息服务
* 支持应用机器人(私聊/群聊)和自定义机器人(群聊 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
};