gongxiangp2/lib/dingtalk-client.ts
2026-07-07 09:31:46 +08:00

267 lines
8.2 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
}
type DingtalkAppConfig = {
appKey: string
appSecret: string
source: 'login' | 'workNotice'
}
const cachedAppTokens = new Map<string, { token: string; expiresAt: number }>()
function getDingtalkAppConfig(): DingtalkAppConfig {
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, source: 'login' }
}
function getDingtalkWorkNoticeAppConfig(): DingtalkAppConfig {
const workNoticeAppKey =
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || process.env.DINGTALK_WORK_NOTICE_APP_KEY
const workNoticeAppSecret =
process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET || process.env.DINGTALK_WORK_NOTICE_APP_SECRET
if (workNoticeAppKey || workNoticeAppSecret) {
if (!workNoticeAppKey || !workNoticeAppSecret) {
throw new Error('钉钉工作通知应用未完整配置 DINGTALK_WORK_NOTICE_CLIENT_ID/DINGTALK_WORK_NOTICE_CLIENT_SECRET')
}
return {
appKey: workNoticeAppKey,
appSecret: workNoticeAppSecret,
source: 'workNotice',
}
}
return getDingtalkAppConfig()
}
function getDingtalkWorkNoticeAgentId() {
return process.env.DINGTALK_WORK_NOTICE_AGENT_ID || process.env.DINGTALK_AGENT_ID || ''
}
function getDingtalkWorkNoticeCorpId() {
return (
process.env.DINGTALK_WORK_NOTICE_CORP_ID ||
process.env.DINGTALK_CORP_ID ||
process.env.DINGTALK_CORPID ||
process.env.NEXT_PUBLIC_DINGTALK_CORP_ID ||
''
).trim()
}
function buildDingtalkWorkNoticeActionUrl(actionUrl: string, agentId: number) {
const corpId = getDingtalkWorkNoticeCorpId()
if (!corpId.startsWith('ding')) {
return actionUrl
}
return [
'dingtalk://dingtalkclient/action/openapp',
`?corpid=${encodeURIComponent(corpId)}`,
'&container_type=work_platform',
`&app_id=${encodeURIComponent(`0_${agentId}`)}`,
'&redirect_type=jump',
`&redirect_url=${encodeURIComponent(actionUrl)}`,
].join('')
}
export function getDingtalkWorkNoticeConfigStatus() {
const workNoticeAppKey =
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || process.env.DINGTALK_WORK_NOTICE_APP_KEY
const workNoticeAppSecret =
process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET || process.env.DINGTALK_WORK_NOTICE_APP_SECRET
const loginAppKey = process.env.DINGTALK_CLIENT_ID || process.env.DINGTALK_APP_KEY
const loginAppSecret = process.env.DINGTALK_CLIENT_SECRET || process.env.DINGTALK_APP_SECRET
const agentId = getDingtalkWorkNoticeAgentId()
const numericAgentId = Number(agentId)
const workNoticeAppPartiallyConfigured = Boolean(workNoticeAppKey || workNoticeAppSecret)
const workNoticeAppConfigured = Boolean(workNoticeAppKey && workNoticeAppSecret)
const loginAppConfigured = Boolean(loginAppKey && loginAppSecret)
const appConfigured = workNoticeAppPartiallyConfigured
? workNoticeAppConfigured
: loginAppConfigured
const agentConfigured = Boolean(agentId && Number.isFinite(numericAgentId) && numericAgentId > 0)
const missing: string[] = []
if (!appConfigured) {
missing.push(
workNoticeAppPartiallyConfigured
? 'DINGTALK_WORK_NOTICE_CLIENT_ID/DINGTALK_WORK_NOTICE_CLIENT_SECRET'
: 'DINGTALK_WORK_NOTICE_CLIENT_ID/DINGTALK_WORK_NOTICE_CLIENT_SECRET 或 DINGTALK_CLIENT_ID/DINGTALK_CLIENT_SECRET',
)
}
if (!agentConfigured) {
missing.push('DINGTALK_WORK_NOTICE_AGENT_ID')
}
return {
ready: appConfigured && agentConfigured,
appConfigured,
agentConfigured,
appSource: workNoticeAppPartiallyConfigured ? 'workNotice' : loginAppConfigured ? 'login' : null,
agentSource: process.env.DINGTALK_WORK_NOTICE_AGENT_ID
? 'DINGTALK_WORK_NOTICE_AGENT_ID'
: process.env.DINGTALK_AGENT_ID
? 'DINGTALK_AGENT_ID'
: null,
missing,
}
}
function truncateText(value: string, maxLength = 4800) {
const normalized = String(value || '').trim()
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized
}
export async function getDingtalkAppAccessToken() {
return getDingtalkAccessToken(getDingtalkAppConfig())
}
async function getDingtalkWorkNoticeAccessToken() {
return getDingtalkAccessToken(getDingtalkWorkNoticeAppConfig())
}
export function getDingtalkWorkNoticeClientId() {
return (
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID ||
process.env.DINGTALK_WORK_NOTICE_APP_KEY ||
process.env.DINGTALK_CLIENT_ID ||
process.env.DINGTALK_APP_KEY ||
''
)
}
export async function getDingtalkWorkNoticeAppAccessToken() {
return getDingtalkWorkNoticeAccessToken()
}
async function getDingtalkAccessToken(config: DingtalkAppConfig) {
const cacheKey = `${config.source}:${config.appKey}:${config.appSecret.slice(-8)}`
const cachedAppToken = cachedAppTokens.get(cacheKey)
if (cachedAppToken && cachedAppToken.expiresAt > Date.now() + 60_000) {
return cachedAppToken.token
}
const response = await fetch(
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(config.appKey)}&appsecret=${encodeURIComponent(config.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)
const nextToken = String(data.access_token)
cachedAppTokens.set(cacheKey, {
token: nextToken,
expiresAt: Date.now() + Math.max(60, expiresIn - 120) * 1000,
})
return nextToken
}
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 getDingtalkWorkNoticeAccessToken()
const messageTitle = truncateText(input.title, 120)
const actionUrl = input.actionUrl
? buildDingtalkWorkNoticeActionUrl(input.actionUrl, numericAgentId)
: null
const msg = actionUrl
? {
msgtype: 'action_card',
action_card: {
title: messageTitle,
markdown: truncateText(input.text),
single_title: '打开素材库查看',
single_url: actionUrl,
},
}
: {
msgtype: 'markdown',
markdown: {
title: messageTitle,
text: truncateText(input.text),
},
}
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,
}),
},
)
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,
}
}