支持钉钉工作通知独立应用凭证
This commit is contained in:
parent
01c9af89a6
commit
222df24ba8
74
lib/__tests__/dingtalk-client.test.ts
Normal file
74
lib/__tests__/dingtalk-client.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
describe('dingtalk client', () => {
|
||||
const originalEnv = process.env
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules()
|
||||
process.env = { ...originalEnv }
|
||||
delete process.env.DINGTALK_CLIENT_ID
|
||||
delete process.env.DINGTALK_CLIENT_SECRET
|
||||
delete process.env.DINGTALK_WORK_NOTICE_CLIENT_ID
|
||||
delete process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET
|
||||
delete process.env.DINGTALK_WORK_NOTICE_AGENT_ID
|
||||
global.fetch = jest.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('工作通知优先使用独立应用凭证获取 token', async () => {
|
||||
process.env.DINGTALK_CLIENT_ID = 'login-client-id'
|
||||
process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret'
|
||||
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID = 'notice-client-id'
|
||||
process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET = 'notice-client-secret'
|
||||
process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '4279191869'
|
||||
|
||||
;(global.fetch as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: 'notice-token', expires_in: 7200 }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, task_id: 123, request_id: 'request-1' }),
|
||||
})
|
||||
|
||||
const { sendDingtalkWorkNotice } = await import('@/lib/dingtalk-client')
|
||||
|
||||
await sendDingtalkWorkNotice({
|
||||
userIds: ['ding-user-1'],
|
||||
title: '测试通知',
|
||||
text: '这是一条测试通知',
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('appkey=notice-client-id'),
|
||||
)
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.not.stringContaining('appkey=login-client-id'),
|
||||
)
|
||||
const [, sendOptions] = (global.fetch as jest.Mock).mock.calls[1]
|
||||
expect(JSON.parse(sendOptions.body)).toEqual(expect.objectContaining({
|
||||
agent_id: 4279191869,
|
||||
userid_list: 'ding-user-1',
|
||||
}))
|
||||
})
|
||||
|
||||
test('工作通知未配置独立凭证时仍可复用登录应用凭证', async () => {
|
||||
process.env.DINGTALK_CLIENT_ID = 'login-client-id'
|
||||
process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret'
|
||||
process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '123'
|
||||
|
||||
const { getDingtalkWorkNoticeConfigStatus } = await import('@/lib/dingtalk-client')
|
||||
|
||||
expect(getDingtalkWorkNoticeConfigStatus()).toEqual(expect.objectContaining({
|
||||
ready: true,
|
||||
appSource: 'login',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -13,9 +13,15 @@ type DingtalkWorkNoticeResult = {
|
||||
raw?: unknown
|
||||
}
|
||||
|
||||
let cachedAppToken: { token: string; expiresAt: number } | null = null
|
||||
type DingtalkAppConfig = {
|
||||
appKey: string
|
||||
appSecret: string
|
||||
source: 'login' | 'workNotice'
|
||||
}
|
||||
|
||||
function getDingtalkAppConfig() {
|
||||
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
|
||||
|
||||
@ -23,26 +29,99 @@ function getDingtalkAppConfig() {
|
||||
throw new Error('钉钉应用未配置 DINGTALK_CLIENT_ID/DINGTALK_CLIENT_SECRET')
|
||||
}
|
||||
|
||||
return { appKey, appSecret }
|
||||
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 || ''
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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 { appKey, appSecret } = getDingtalkAppConfig()
|
||||
const response = await fetch(
|
||||
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}`,
|
||||
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(config.appKey)}&appsecret=${encodeURIComponent(config.appSecret)}`,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
@ -55,12 +134,13 @@ export async function getDingtalkAppAccessToken() {
|
||||
}
|
||||
|
||||
const expiresIn = Number(data.expires_in || 7200)
|
||||
cachedAppToken = {
|
||||
token: String(data.access_token),
|
||||
const nextToken = String(data.access_token)
|
||||
cachedAppTokens.set(cacheKey, {
|
||||
token: nextToken,
|
||||
expiresAt: Date.now() + Math.max(60, expiresIn - 120) * 1000,
|
||||
}
|
||||
})
|
||||
|
||||
return cachedAppToken.token
|
||||
return nextToken
|
||||
}
|
||||
|
||||
export async function sendDingtalkWorkNotice(
|
||||
@ -93,7 +173,7 @@ export async function sendDingtalkWorkNotice(
|
||||
}
|
||||
}
|
||||
|
||||
const token = await getDingtalkAppAccessToken()
|
||||
const token = await getDingtalkWorkNoticeAccessToken()
|
||||
const actionText = input.actionUrl ? `\n\n[打开素材库查看](${input.actionUrl})` : ''
|
||||
const markdownText = truncateText(`${input.text}${actionText}`)
|
||||
|
||||
|
||||
@ -66,6 +66,10 @@ const OPTIONAL_ENV_VARS = [
|
||||
'DINGTALK_APP_SECRET',
|
||||
'DINGTALK_CLIENT_ID',
|
||||
'DINGTALK_CLIENT_SECRET',
|
||||
'DINGTALK_WORK_NOTICE_CLIENT_ID',
|
||||
'DINGTALK_WORK_NOTICE_CLIENT_SECRET',
|
||||
'DINGTALK_WORK_NOTICE_APP_KEY',
|
||||
'DINGTALK_WORK_NOTICE_APP_SECRET',
|
||||
'DINGTALK_WORK_NOTICE_AGENT_ID',
|
||||
'DINGTALK_AGENT_ID',
|
||||
'REPORT_DB_SERVER',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user