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() 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 { 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 { 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 { 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 { 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, } }