gongxiangp2/lib/dingtalk-client.ts
2026-07-03 15:12:20 +08:00

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,
}
}