134 lines
3.8 KiB
TypeScript
134 lines
3.8 KiB
TypeScript
type DingtalkWorkNoticeInput = {
|
|
userIds: string[]
|
|
title: string
|
|
text: string
|
|
actionUrl?: string | null
|
|
}
|
|
|
|
type DingtalkWorkNoticeResult = {
|
|
skipped?: boolean
|
|
reason?: string
|
|
taskId?: string
|
|
requestId?: string
|
|
raw?: unknown
|
|
}
|
|
|
|
let cachedAppToken: { token: string; expiresAt: number } | null = null
|
|
|
|
function getDingtalkAppConfig() {
|
|
const appKey = process.env.DINGTALK_CLIENT_ID || process.env.DINGTALK_APP_KEY
|
|
const appSecret = process.env.DINGTALK_CLIENT_SECRET || process.env.DINGTALK_APP_SECRET
|
|
|
|
if (!appKey || !appSecret) {
|
|
throw new Error('钉钉应用未配置 DINGTALK_CLIENT_ID/DINGTALK_CLIENT_SECRET')
|
|
}
|
|
|
|
return { appKey, appSecret }
|
|
}
|
|
|
|
function getDingtalkWorkNoticeAgentId() {
|
|
return process.env.DINGTALK_WORK_NOTICE_AGENT_ID || process.env.DINGTALK_AGENT_ID || ''
|
|
}
|
|
|
|
function truncateText(value: string, maxLength = 4800) {
|
|
const normalized = String(value || '').trim()
|
|
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized
|
|
}
|
|
|
|
export async function getDingtalkAppAccessToken() {
|
|
if (cachedAppToken && cachedAppToken.expiresAt > Date.now() + 60_000) {
|
|
return cachedAppToken.token
|
|
}
|
|
|
|
const { appKey, appSecret } = getDingtalkAppConfig()
|
|
const response = await fetch(
|
|
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}`,
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`获取钉钉应用访问令牌失败: HTTP ${response.status}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
if (data.errcode !== 0 || !data.access_token) {
|
|
throw new Error(`获取钉钉应用访问令牌失败: ${data.errmsg || '返回数据异常'}`)
|
|
}
|
|
|
|
const expiresIn = Number(data.expires_in || 7200)
|
|
cachedAppToken = {
|
|
token: String(data.access_token),
|
|
expiresAt: Date.now() + Math.max(60, expiresIn - 120) * 1000,
|
|
}
|
|
|
|
return cachedAppToken.token
|
|
}
|
|
|
|
export async function sendDingtalkWorkNotice(
|
|
input: DingtalkWorkNoticeInput,
|
|
): Promise<DingtalkWorkNoticeResult> {
|
|
const agentId = getDingtalkWorkNoticeAgentId()
|
|
if (!agentId) {
|
|
return {
|
|
skipped: true,
|
|
reason: '钉钉工作通知未配置 DINGTALK_WORK_NOTICE_AGENT_ID',
|
|
}
|
|
}
|
|
|
|
const numericAgentId = Number(agentId)
|
|
if (!Number.isFinite(numericAgentId) || numericAgentId <= 0) {
|
|
return {
|
|
skipped: true,
|
|
reason: '钉钉工作通知 DINGTALK_WORK_NOTICE_AGENT_ID 必须是正数',
|
|
}
|
|
}
|
|
|
|
const userIds = Array.from(
|
|
new Set(input.userIds.map((item) => String(item || '').trim()).filter(Boolean)),
|
|
)
|
|
|
|
if (userIds.length === 0) {
|
|
return {
|
|
skipped: true,
|
|
reason: '没有可投递的钉钉 userId',
|
|
}
|
|
}
|
|
|
|
const token = await getDingtalkAppAccessToken()
|
|
const actionText = input.actionUrl ? `\n\n[打开素材库查看](${input.actionUrl})` : ''
|
|
const markdownText = truncateText(`${input.text}${actionText}`)
|
|
|
|
const response = await fetch(
|
|
`https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token=${encodeURIComponent(token)}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agent_id: numericAgentId,
|
|
userid_list: userIds.join(','),
|
|
msg: {
|
|
msgtype: 'markdown',
|
|
markdown: {
|
|
title: truncateText(input.title, 120),
|
|
text: markdownText,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
)
|
|
|
|
const data = await response.json().catch(async () => ({ raw: await response.text() }))
|
|
if (!response.ok) {
|
|
throw new Error(`钉钉工作通知发送失败: HTTP ${response.status}`)
|
|
}
|
|
|
|
if (data.errcode !== 0) {
|
|
throw new Error(`钉钉工作通知发送失败: ${data.errmsg || '未知错误'}`)
|
|
}
|
|
|
|
return {
|
|
taskId: data.task_id != null ? String(data.task_id) : undefined,
|
|
requestId: data.request_id != null ? String(data.request_id) : undefined,
|
|
raw: data,
|
|
}
|
|
}
|