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