gongxiangp2/lib/dingtalk-client.ts
2026-07-15 17:44:08 +08:00

557 lines
18 KiB
TypeScript

type DingtalkWorkNoticeInput = {
userIds: string[]
title: string
text: string
actionUrl?: string | null
actionTitle?: string | null
}
type DingtalkWorkNoticeResult = {
skipped?: boolean
reason?: string
taskId?: string
requestId?: string
raw?: unknown
}
type DingtalkRobotOtoMessageInput = DingtalkWorkNoticeInput
type DingtalkRobotOtoMessageResult = {
skipped?: boolean
reason?: string
processQueryKey?: string
invalidUserIds: string[]
filteredUserIds: string[]
flowControlledUserIds: string[]
raw?: unknown
}
export type DingtalkRobotOtoReadResult = {
readUserIds: string[]
unreadUserIds: string[]
sendStatus: string
deliveryFailed: boolean
raw: unknown
}
export type DingtalkPerformanceSurveyNotificationChannel = 'work_notice' | 'robot_oto' | 'notification_center'
export type DingtalkWorkNoticeSendResult = {
readUserIds: string[]
unreadUserIds: string[]
failedUserIds: string[]
invalidUserIds: string[]
forbiddenUserIds: 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 getDingtalkRobotCode() {
return String(process.env.DINGTALK_ROBOT_CODE || '').trim()
}
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,
}
}
export function getDingtalkRobotOtoConfigStatus() {
const robotCode = getDingtalkRobotCode()
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 workNoticeAppPartiallyConfigured = Boolean(workNoticeAppKey || workNoticeAppSecret)
const workNoticeAppConfigured = Boolean(workNoticeAppKey && workNoticeAppSecret)
const loginAppConfigured = Boolean(
(process.env.DINGTALK_CLIENT_ID || process.env.DINGTALK_APP_KEY) &&
(process.env.DINGTALK_CLIENT_SECRET || process.env.DINGTALK_APP_SECRET),
)
const appConfigured = workNoticeAppPartiallyConfigured
? workNoticeAppConfigured
: loginAppConfigured
const missing: string[] = []
if (!robotCode) missing.push('DINGTALK_ROBOT_CODE')
if (!appConfigured) {
missing.push(
workNoticeAppPartiallyConfigured
? 'DINGTALK_WORK_NOTICE_CLIENT_ID/DINGTALK_WORK_NOTICE_CLIENT_SECRET'
: '钉钉应用 Client ID/Client Secret',
)
}
return {
ready: missing.length === 0,
robotCodeConfigured: Boolean(robotCode),
appConfigured,
appSource: workNoticeAppPartiallyConfigured ? 'workNotice' : loginAppConfigured ? 'login' : null,
missing,
}
}
export function getDingtalkPerformanceSurveyNotificationChannel(): DingtalkPerformanceSurveyNotificationChannel {
const value = String(
process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL || 'work_notice',
).trim().toLowerCase()
if (value === 'work_notice' || value === 'robot_oto' || value === 'notification_center') return value
throw new Error(
'DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL 仅支持 work_notice、robot_oto 或 notification_center',
)
}
export function getDingtalkRobotOtoTestUserIds() {
return Array.from(new Set(
String(process.env.DINGTALK_ROBOT_OTO_TEST_USER_IDS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
))
}
export function getDingtalkPerformanceSurveyNotificationChannelForUser(
dingtalkUserId: string,
): DingtalkPerformanceSurveyNotificationChannel {
const channel = getDingtalkPerformanceSurveyNotificationChannel()
if (channel === 'work_notice') return channel
const testUserIds = channel === 'notification_center'
? Array.from(new Set(
String(process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
))
: getDingtalkRobotOtoTestUserIds()
if (testUserIds.length === 0) return channel
return testUserIds.includes(String(dingtalkUserId || '').trim()) ? channel : 'work_notice'
}
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 actionTitle = truncateText(input.actionTitle || '查看详情', 20)
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: actionTitle,
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,
}
}
export async function sendDingtalkRobotOtoMessage(
input: DingtalkRobotOtoMessageInput,
): Promise<DingtalkRobotOtoMessageResult> {
const robotCode = getDingtalkRobotCode()
if (!robotCode) {
throw new Error('钉钉机器人单聊未配置 DINGTALK_ROBOT_CODE')
}
const userIds = Array.from(
new Set(input.userIds.map((item) => String(item || '').trim()).filter(Boolean)),
)
if (userIds.length === 0) {
return {
skipped: true,
reason: '没有可投递的钉钉 userId',
invalidUserIds: [],
filteredUserIds: [],
flowControlledUserIds: [],
}
}
if (userIds.length > 20) {
throw new Error('钉钉机器人单聊每次最多发送给 20 人')
}
const token = await getDingtalkWorkNoticeAccessToken()
const messageTitle = truncateText(input.title, 120)
const actionTitle = truncateText(input.actionTitle || '查看详情', 20)
const actionUrl = input.actionUrl ? String(input.actionUrl).trim() : ''
const msgKey = actionUrl ? 'sampleActionCard' : 'sampleMarkdown'
const msgParam = actionUrl
? {
title: messageTitle,
text: truncateText(input.text),
singleTitle: actionTitle,
singleURL: actionUrl,
}
: {
title: messageTitle,
text: truncateText(input.text),
}
const response = await fetch('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-acs-dingtalk-access-token': token,
},
body: JSON.stringify({
robotCode,
userIds,
msgKey,
msgParam: JSON.stringify(msgParam),
}),
})
const data = await response.json().catch(async () => ({ raw: await response.text() }))
if (!response.ok) {
throw new Error(`钉钉机器人单聊发送失败: HTTP ${response.status}`)
}
const stringList = (value: unknown) => Array.isArray(value)
? value.map((item) => String(item || '').trim()).filter(Boolean)
: []
const invalidUserIds = stringList(data.invalidStaffIdList)
const filteredUserIds = stringList(data.filteredStaffIdList)
const flowControlledUserIds = stringList(data.flowControlledStaffIdList)
const acceptedUserIds = userIds.filter(
(userId) => !invalidUserIds.includes(userId) &&
!filteredUserIds.includes(userId) &&
!flowControlledUserIds.includes(userId),
)
const processQueryKey = String(data.processQueryKey || '').trim()
if (acceptedUserIds.length === 0) {
const reason = invalidUserIds.length > 0
? `收件人钉钉 userId 无效: ${invalidUserIds.join(', ')}`
: filteredUserIds.length > 0
? `收件人被钉钉过滤: ${filteredUserIds.join(', ')}`
: `钉钉机器人单聊发送被限流: ${flowControlledUserIds.join(', ')}`
throw new Error(reason)
}
if (!processQueryKey) {
throw new Error('钉钉机器人单聊发送失败: 未返回 processQueryKey')
}
return {
processQueryKey,
invalidUserIds,
filteredUserIds,
flowControlledUserIds,
raw: data,
}
}
export async function getDingtalkWorkNoticeSendResult(
taskId: string,
): Promise<DingtalkWorkNoticeSendResult> {
const agentId = Number(getDingtalkWorkNoticeAgentId())
if (!Number.isFinite(agentId) || agentId <= 0) {
throw new Error('钉钉工作通知 DINGTALK_WORK_NOTICE_AGENT_ID 必须是正数')
}
const normalizedTaskId = String(taskId || '').trim()
if (!normalizedTaskId) throw new Error('缺少钉钉工作通知 taskId')
const token = await getDingtalkWorkNoticeAccessToken()
const response = await fetch(
`https://oapi.dingtalk.com/topapi/message/corpconversation/getsendresult?access_token=${encodeURIComponent(token)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_id: agentId,
task_id: normalizedTaskId,
}),
},
)
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 || '未知错误'}`)
}
const result = data.send_result && typeof data.send_result === 'object' ? data.send_result : {}
const stringList = (value: unknown) => Array.isArray(value)
? value.map((item) => String(item || '').trim()).filter(Boolean)
: []
return {
readUserIds: stringList(result.read_user_id_list),
unreadUserIds: stringList(result.unread_user_id_list),
failedUserIds: stringList(result.failed_user_id_list),
invalidUserIds: stringList(result.invalid_user_id_list),
forbiddenUserIds: stringList(result.forbidden_user_id_list),
raw: data,
}
}
export async function getDingtalkRobotOtoReadResult(
processQueryKey: string,
): Promise<DingtalkRobotOtoReadResult> {
const robotCode = getDingtalkRobotCode()
if (!robotCode) throw new Error('钉钉机器人单聊未配置 DINGTALK_ROBOT_CODE')
const normalizedQueryKey = String(processQueryKey || '').trim()
if (!normalizedQueryKey) throw new Error('缺少钉钉机器人单聊 processQueryKey')
const token = await getDingtalkWorkNoticeAccessToken()
const url = new URL('https://api.dingtalk.com/v1.0/robot/oToMessages/readStatus')
url.searchParams.set('robotCode', robotCode)
url.searchParams.set('processQueryKey', normalizedQueryKey)
const response = await fetch(url.toString(), {
method: 'GET',
headers: { 'x-acs-dingtalk-access-token': token },
})
const data = await response.json().catch(async () => ({ raw: await response.text() }))
if (!response.ok) {
throw new Error(`查询钉钉机器人单聊已读状态失败: HTTP ${response.status}`)
}
const readUserIds: string[] = []
const unreadUserIds: string[] = []
const rows = Array.isArray(data.messageReadInfoList) ? data.messageReadInfoList : []
for (const row of rows) {
const userId = String(row?.userId || '').trim()
const readStatus = String(row?.readStatus || '').trim().toLowerCase()
if (!userId) continue
if (readStatus === 'read') readUserIds.push(userId)
if (readStatus === 'unread') unreadUserIds.push(userId)
}
const sendStatus = String(data.sendStatus || '').trim().toLowerCase()
return {
readUserIds,
unreadUserIds,
sendStatus,
deliveryFailed: sendStatus.includes('fail'),
raw: data,
}
}