2026-07-15 18:06:20 +08:00

127 lines
5.3 KiB
TypeScript

import { getDingTalkConfig } from './config'
type DingTalkPayload = Record<string, any>
export class DingTalkError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly deterministic = false,
) {
super(message)
this.name = 'DingTalkError'
}
}
let tokenCache: { token: string; expiresAt: number } | null = null
let tokenPromise: Promise<string> | null = null
const requestJson = async (url: string, init?: RequestInit): Promise<DingTalkPayload> => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
})
const data = await response.json().catch(() => ({})) as DingTalkPayload
if (!response.ok) {
const code = clean(data.code) || `HTTP_${response.status}`
const message = clean(data.message) || `钉钉接口返回 HTTP ${response.status}`
throw new DingTalkError(message, code, response.status >= 400 && response.status < 500 && response.status !== 429)
}
if (data.errcode !== undefined && Number(data.errcode) !== 0) {
throw new DingTalkError(clean(data.errmsg) || '钉钉接口调用失败', String(data.errcode), isDeterministic(data.errcode, data.errmsg))
}
return data
} catch (error: any) {
if (error instanceof DingTalkError) throw error
if (error?.name === 'AbortError') throw new DingTalkError('钉钉请求超时', 'TIMEOUT')
throw new DingTalkError(clean(error?.message) || '钉钉网络请求失败', 'NETWORK')
} finally {
clearTimeout(timeout)
}
}
const clean = (value: unknown) => String(value || '').trim()
const stringList = (value: unknown) => Array.isArray(value) ? value.map(clean).filter(Boolean) : []
const isDeterministic = (code: unknown, message: unknown) =>
['40014', '40056', '40057', '40078', '41001', '41009', '60111', '60121', '70020'].includes(String(code)) ||
/不存在|不在可见范围|无权限|invalid user|forbidden user|userid/i.test(clean(message))
const getToken = async () => {
if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token
if (tokenPromise) return tokenPromise
tokenPromise = (async () => {
const { appKey, appSecret } = getDingTalkConfig()
const data = await requestJson(
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}`,
)
const token = clean(data.access_token)
if (!token) throw new DingTalkError('钉钉未返回 access_token', 'TOKEN_MISSING')
tokenCache = { token, expiresAt: Date.now() + Math.max(300, Number(data.expires_in) || 7200) * 1000 }
return token
})()
try {
return await tokenPromise
} finally {
tokenPromise = null
}
}
export const sendRobotMessage = async (input: {
recipientUserId: string
title: string
content: string
actionTitle: string
actionUrl: string
}) => {
const { robotCode } = getDingTalkConfig()
const token = await getToken()
const data = await requestJson('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', {
method: 'POST',
headers: { 'x-acs-dingtalk-access-token': token },
body: JSON.stringify({
robotCode,
userIds: [input.recipientUserId],
msgKey: input.actionUrl ? 'sampleActionCard' : 'sampleMarkdown',
msgParam: JSON.stringify(input.actionUrl ? {
title: input.title.slice(0, 120),
text: input.content.slice(0, 4800),
singleTitle: input.actionTitle.slice(0, 20),
singleURL: input.actionUrl,
} : {
title: input.title.slice(0, 120),
text: input.content.slice(0, 4800),
}),
}),
})
const invalid = stringList(data.invalidStaffIdList)
const filtered = stringList(data.filteredStaffIdList)
const flowControlled = stringList(data.flowControlledStaffIdList)
if (invalid.includes(input.recipientUserId)) throw new DingTalkError('收件人钉钉 userId 无效', 'INVALID_USER', true)
if (filtered.includes(input.recipientUserId)) throw new DingTalkError('收件人不在机器人可见范围内', 'FILTERED_USER', true)
if (flowControlled.includes(input.recipientUserId)) throw new DingTalkError('钉钉机器人发送被限流', 'FLOW_CONTROLLED')
const processQueryKey = clean(data.processQueryKey)
if (!processQueryKey) throw new DingTalkError('钉钉未返回 processQueryKey', 'PROCESS_QUERY_KEY_MISSING')
return processQueryKey
}
export const queryRobotReadStatus = async (processQueryKey: string) => {
const { robotCode } = getDingTalkConfig()
const token = await getToken()
const url = new URL('https://api.dingtalk.com/v1.0/robot/oToMessages/readStatus')
url.searchParams.set('robotCode', robotCode)
url.searchParams.set('processQueryKey', processQueryKey)
const data = await requestJson(url.toString(), {
headers: { 'x-acs-dingtalk-access-token': token },
})
const rows = Array.isArray(data.messageReadInfoList) ? data.messageReadInfoList : []
const read = rows.some((row) => clean(row?.readStatus).toLowerCase() === 'read')
const unread = rows.some((row) => clean(row?.readStatus).toLowerCase() === 'unread')
const failed = clean(data.sendStatus).toLowerCase().includes('fail')
return { read, unread, failed }
}