127 lines
5.3 KiB
TypeScript
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 }
|
|
}
|