const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken"); const WECOM_API_BASE = "https://qyapi.weixin.qq.com"; const APPROVAL_GET_INFO_ENDPOINT = "/cgi-bin/oa/getapprovalinfo"; const APPROVAL_CREATE_TEMPLATE_ENDPOINT = "/cgi-bin/oa/approval/create_template"; const APPROVAL_APPLY_EVENT_ENDPOINT = "/cgi-bin/oa/applyevent"; const APPROVAL_POC_TARGETS = Object.freeze([ { key: "leave", label: "请假" }, { key: "missed_punch", label: "补卡" }, ]); const APPROVAL_HR_ATTENDANCE_TARGETS = Object.freeze([ ...APPROVAL_POC_TARGETS, { key: "outing", label: "外出" }, { key: "overtime", label: "加班" }, ]); class WecomApprovalPocError extends Error { constructor(code, message, details = {}) { super(message); this.name = "WecomApprovalPocError"; this.code = code; this.details = details; } } function toSafeString(value, maxLength = 500) { if (value == null) return ""; return String(value).trim().slice(0, maxLength); } function sanitizeErrorMessage(value) { return toSafeString(value, 500) .replace(/access_token=[^&\s]+/gi, "access_token=***") .replace(/[A-Za-z0-9_-]{24,}/g, "***") .slice(0, 240); } function maskApprovalIdentifier(value) { const raw = toSafeString(value, 160); if (!raw) return null; if (raw.length <= 6) return `${raw.slice(0, 2)}***`; return `${raw.slice(0, Math.min(8, raw.length))}***`; } function countBy(items, selector) { const counts = {}; for (const item of items) { const key = toSafeString(selector(item), 80) || "unknown"; counts[key] = (counts[key] || 0) + 1; } return counts; } function normalizeControls(value) { if (!value) return []; if (Array.isArray(value)) return value.filter((item) => item && typeof item === "object"); if (typeof value === "object") { for (const key of ["controls", "contents", "items", "children"]) { if (Array.isArray(value[key])) return normalizeControls(value[key]); } } return []; } function controlType(control) { if (!control || typeof control !== "object") return "unknown"; return ( control.control || control.control_type || control.controlType || control.type || control.property?.control || control.property?.control_type || control.property?.type || "unknown" ); } function summarizeTemplatePayload(payload) { const templateId = payload?.template_id ?? payload?.templateId; const controls = normalizeControls(payload?.template_content?.controls) .concat(normalizeControls(payload?.template_content?.contents)) .concat(normalizeControls(payload?.controls)); const dedupedControls = controls.length ? controls : normalizeControls(payload?.template_content); return { templateIdPrefix: maskApprovalIdentifier(templateId), fieldCount: dedupedControls.length, fieldTypeCounts: countBy(dedupedControls, controlType), hasTemplateNames: Array.isArray(payload?.template_names) && payload.template_names.length > 0, }; } function summarizeApprovalPayload(payload) { const info = payload?.info && typeof payload.info === "object" ? payload.info : {}; const applyData = info.apply_data && typeof info.apply_data === "object" ? info.apply_data : {}; const contents = Array.isArray(applyData.contents) ? applyData.contents : []; const records = Array.isArray(info.sp_record) ? info.sp_record : []; const notifyers = Array.isArray(info.notifyer) ? info.notifyer : []; const comments = Array.isArray(info.comments) ? info.comments : []; return { spNoPrefix: maskApprovalIdentifier(info.sp_no ?? payload?.sp_no), templateIdPrefix: maskApprovalIdentifier(info.template_id), spName: toSafeString(info.sp_name ?? info.spName, 120) || null, spStatus: info.sp_status == null ? null : toSafeString(info.sp_status, 40), applyFieldCount: contents.length, applyFieldTypeCounts: countBy(contents, controlType), approvalNodeCount: records.length, notifyerCount: notifyers.length, commentCount: comments.length, }; } function extractApprovalInfoSpNos(payload) { if (!payload || typeof payload !== "object") return []; const candidates = [ payload.sp_no_list, payload.spNoList, payload.info?.sp_no_list, ]; const list = candidates.find((item) => Array.isArray(item)) || []; return list.map((item) => toSafeString(item, 160)).filter(Boolean); } function extractApprovalInfoNextCursor(payload) { if (!payload || typeof payload !== "object") return ""; return toSafeString(payload.next_cursor ?? payload.nextCursor ?? payload.info?.next_cursor, 160); } function summarizeApprovalInfoPayload(payload) { const spNos = extractApprovalInfoSpNos(payload); const nextCursor = extractApprovalInfoNextCursor(payload); return { spNoCount: spNos.length, spNoPrefixes: spNos.map(maskApprovalIdentifier), nextCursorPresent: Boolean(nextCursor), }; } function sanitizeApprovalApiResponse(payload) { if (!payload || typeof payload !== "object") return null; const result = sanitizeWecomResponse(payload) ?? {}; if (payload.template_id || payload.templateId) { result.templateIdPrefix = maskApprovalIdentifier(payload.template_id ?? payload.templateId); } if (payload.sp_no || payload.spNo) { result.spNoPrefix = maskApprovalIdentifier(payload.sp_no ?? payload.spNo); } if (payload.template_content) { const summary = summarizeTemplatePayload(payload); result.fieldCount = summary.fieldCount; result.fieldTypeCounts = summary.fieldTypeCounts; } if (payload.info) { const summary = summarizeApprovalPayload(payload); result.spStatus = summary.spStatus; result.applyFieldCount = summary.applyFieldCount; result.approvalNodeCount = summary.approvalNodeCount; result.notifyerCount = summary.notifyerCount; result.commentCount = summary.commentCount; } if (Array.isArray(payload.sp_no_list) || Array.isArray(payload.spNoList)) { const summary = summarizeApprovalInfoPayload(payload); result.spNoCount = summary.spNoCount; result.spNoPrefixes = summary.spNoPrefixes; result.nextCursorPresent = summary.nextCursorPresent; } return Object.keys(result).length ? result : null; } function classifyWecomApprovalResult(result, fallbackCode = "wecom_approval_api_error") { if (result?.networkError) { return { code: result.errorMessage === "timeout" ? "wecom_approval_timeout" : "wecom_approval_network_error", message: result.errorMessage || "WeCom approval API network error", response: null, }; } const errcode = Number(result?.payload?.errcode); if (Number.isFinite(errcode) && errcode !== 0) { return { code: "wecom_approval_api_error", message: `WeCom approval API errcode ${errcode}`, response: sanitizeApprovalApiResponse(result.payload), }; } return { code: result?.httpStatus ? `wecom_approval_http_${result.httpStatus}` : fallbackCode, message: "WeCom approval API failed", response: sanitizeApprovalApiResponse(result?.payload), }; } async function fetchWecomApprovalJson(pathName, accessToken, body, options = {}) { const url = new URL(`${WECOM_API_BASE}${pathName}`); url.searchParams.set("access_token", accessToken); const timeoutMs = Math.max(500, Number(options.timeoutMs ?? 10000)); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await (options.fetchImpl ?? fetch)(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}), signal: controller.signal, }); const payload = await response.json().catch(() => null); const errcode = payload && typeof payload === "object" ? Number(payload.errcode) : NaN; return { ok: response.ok && Number.isFinite(errcode) && errcode === 0, httpStatus: response.status, payload, }; } catch (error) { return { ok: false, httpStatus: null, payload: null, networkError: true, errorMessage: error?.name === "AbortError" ? "timeout" : sanitizeErrorMessage(error?.message ?? error), }; } finally { clearTimeout(timer); } } async function getApprovalAccessToken({ config, fetchImpl, timeoutMs }) { const tokenResult = await getWecomAppAccessToken({ config: { ...config, wecomRequireAgentId: false, }, timeoutMs, fetchImpl, }); if (!tokenResult.ok) { throw new WecomApprovalPocError(tokenResult.code, tokenResult.message || "WeCom app token unavailable", { response: tokenResult.response, }); } return { accessToken: tokenResult.accessToken, tokenSource: "WECOM_AGENT_SECRET", response: tokenResult.response, }; } async function fetchWecomApprovalTemplateDetail({ config, templateId, fetchImpl = fetch, timeoutMs = config?.wecomRequestTimeoutMs ?? 10000, }) { const resolvedTemplateId = toSafeString(templateId, 160); if (!resolvedTemplateId) { return { ok: false, skipped: true, retryable: false, code: "approval_template_id_missing", message: "Missing approval template id", tokenSource: "WECOM_AGENT_SECRET", response: null, summary: null, }; } try { const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs }); const result = await fetchWecomApprovalJson("/cgi-bin/oa/gettemplatedetail", token.accessToken, { template_id: resolvedTemplateId, }, { fetchImpl, timeoutMs }); if (!result.ok) { const error = classifyWecomApprovalResult(result, "wecom_approval_template_detail_failed"); return { ok: false, skipped: false, retryable: false, code: error.code, message: error.message, tokenSource: token.tokenSource, response: error.response, summary: null, }; } return { ok: true, skipped: false, retryable: false, code: "wecom_approval_template_detail_fetched", message: null, tokenSource: token.tokenSource, response: sanitizeApprovalApiResponse(result.payload), summary: summarizeTemplatePayload(result.payload), }; } catch (error) { return { ok: false, skipped: false, retryable: Boolean(error?.details?.retryable), code: error?.code || "wecom_approval_template_detail_failed", message: sanitizeErrorMessage(error?.message || error), tokenSource: "WECOM_AGENT_SECRET", response: error?.details?.response || null, summary: null, }; } } async function fetchWecomApprovalDetail({ config, spNo, fetchImpl = fetch, timeoutMs = config?.wecomRequestTimeoutMs ?? 10000, includeRawPayload = false, }) { const resolvedSpNo = toSafeString(spNo, 160); if (!resolvedSpNo) { return { ok: false, skipped: true, retryable: false, code: "approval_sp_no_missing", message: "Missing approval sp_no", tokenSource: "WECOM_AGENT_SECRET", response: null, summary: null, }; } try { const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs }); const result = await fetchWecomApprovalJson("/cgi-bin/oa/getapprovaldetail", token.accessToken, { sp_no: resolvedSpNo, }, { fetchImpl, timeoutMs }); if (!result.ok) { const error = classifyWecomApprovalResult(result, "wecom_approval_detail_failed"); return { ok: false, skipped: false, retryable: false, code: error.code, message: error.message, tokenSource: token.tokenSource, response: error.response, summary: null, }; } return { ok: true, skipped: false, retryable: false, code: "wecom_approval_detail_fetched", message: null, tokenSource: token.tokenSource, response: sanitizeApprovalApiResponse(result.payload), rawPayload: includeRawPayload ? result.payload : undefined, templateId: toSafeString(result.payload?.info?.template_id ?? result.payload?.info?.templateId, 160), spName: toSafeString(result.payload?.info?.sp_name ?? result.payload?.info?.spName, 120), summary: summarizeApprovalPayload(result.payload), }; } catch (error) { return { ok: false, skipped: false, retryable: Boolean(error?.details?.retryable), code: error?.code || "wecom_approval_detail_failed", message: sanitizeErrorMessage(error?.message || error), tokenSource: "WECOM_AGENT_SECRET", response: error?.details?.response || null, summary: null, }; } } function normalizeApprovalInfoFilter(filter) { if (!filter || typeof filter !== "object") return null; const key = toSafeString(filter.key, 80); const value = toSafeString(filter.value, 200); if (!key || !value) return null; return { key, value }; } function normalizeApprovalInfoRequest({ startTime, endTime, size, nextCursor, filters, }) { const normalizedStart = Math.floor(Number(startTime)); const normalizedEnd = Math.floor(Number(endTime)); const normalizedSize = Math.max(1, Math.min(100, Math.floor(Number(size || 100)))); const body = { starttime: normalizedStart, endtime: normalizedEnd, size: normalizedSize, }; const cursor = toSafeString(nextCursor, 160); if (cursor) body.next_cursor = cursor; const normalizedFilters = Array.isArray(filters) ? filters.map(normalizeApprovalInfoFilter).filter(Boolean) : []; if (normalizedFilters.length) { body.filters = normalizedFilters; } return body; } async function fetchWecomApprovalInfoList({ config, startTime, endTime, size = 100, nextCursor = "", filters = [], fetchImpl = fetch, timeoutMs = config?.wecomRequestTimeoutMs ?? 10000, }) { const normalizedStart = Math.floor(Number(startTime)); const normalizedEnd = Math.floor(Number(endTime)); if (!Number.isFinite(normalizedStart) || !Number.isFinite(normalizedEnd) || normalizedStart <= 0 || normalizedEnd <= 0) { return { ok: false, skipped: true, retryable: false, code: "approval_compensation_window_invalid", message: "Missing or invalid approval compensation time window", tokenSource: "WECOM_AGENT_SECRET", endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, response: null, spNos: [], nextCursor: "", summary: null, }; } if (normalizedStart >= normalizedEnd) { return { ok: false, skipped: true, retryable: false, code: "approval_compensation_window_invalid", message: "Approval compensation startTime must be before endTime", tokenSource: "WECOM_AGENT_SECRET", endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, response: null, spNos: [], nextCursor: "", summary: null, }; } try { const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs }); const requestBody = normalizeApprovalInfoRequest({ startTime: normalizedStart, endTime: normalizedEnd, size, nextCursor, filters, }); const result = await fetchWecomApprovalJson(APPROVAL_GET_INFO_ENDPOINT, token.accessToken, requestBody, { fetchImpl, timeoutMs, }); if (!result.ok) { const error = classifyWecomApprovalResult(result, "wecom_approval_info_failed"); return { ok: false, skipped: false, retryable: false, code: error.code, message: error.message, tokenSource: token.tokenSource, endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, response: error.response, spNos: [], nextCursor: "", summary: null, }; } const spNos = extractApprovalInfoSpNos(result.payload); const nextCursorValue = extractApprovalInfoNextCursor(result.payload); return { ok: true, skipped: false, retryable: false, code: "wecom_approval_info_fetched", message: null, tokenSource: token.tokenSource, endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, response: sanitizeApprovalApiResponse(result.payload), spNos, nextCursor: nextCursorValue, summary: summarizeApprovalInfoPayload(result.payload), }; } catch (error) { return { ok: false, skipped: false, retryable: Boolean(error?.details?.retryable), code: error?.code || "wecom_approval_info_failed", message: sanitizeErrorMessage(error?.message || error), tokenSource: "WECOM_AGENT_SECRET", endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, response: error?.details?.response || null, spNos: [], nextCursor: "", summary: null, }; } } function extractTemplateId(payload) { return toSafeString(payload?.template_id ?? payload?.templateId, 160); } function extractSpNo(payload) { return toSafeString(payload?.sp_no ?? payload?.spNo, 160); } async function fetchWecomApprovalTemplateCreate({ config, payload, fetchImpl = fetch, timeoutMs = config?.wecomRequestTimeoutMs ?? 10000, }) { try { const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs }); const result = await fetchWecomApprovalJson(APPROVAL_CREATE_TEMPLATE_ENDPOINT, token.accessToken, payload, { fetchImpl, timeoutMs, }); if (!result.ok) { const error = classifyWecomApprovalResult(result, "wecom_approval_create_template_failed"); return { ok: false, skipped: false, retryable: false, code: error.code, message: error.message, tokenSource: token.tokenSource, endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`, response: error.response, templateId: null, summary: null, }; } const templateId = extractTemplateId(result.payload); return { ok: true, skipped: false, retryable: false, code: "wecom_approval_template_created", message: null, tokenSource: token.tokenSource, endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`, response: sanitizeApprovalApiResponse(result.payload), templateId, summary: { templateIdPrefix: maskApprovalIdentifier(templateId), }, }; } catch (error) { return { ok: false, skipped: false, retryable: Boolean(error?.details?.retryable), code: error?.code || "wecom_approval_create_template_failed", message: sanitizeErrorMessage(error?.message || error), tokenSource: "WECOM_AGENT_SECRET", endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`, response: error?.details?.response || null, templateId: null, summary: null, }; } } async function fetchWecomApprovalApplyEvent({ config, payload, fetchImpl = fetch, timeoutMs = config?.wecomRequestTimeoutMs ?? 10000, }) { try { const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs }); const result = await fetchWecomApprovalJson(APPROVAL_APPLY_EVENT_ENDPOINT, token.accessToken, payload, { fetchImpl, timeoutMs, }); if (!result.ok) { const error = classifyWecomApprovalResult(result, "wecom_approval_apply_event_failed"); return { ok: false, skipped: false, retryable: false, code: error.code, message: error.message, tokenSource: token.tokenSource, endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`, response: error.response, spNo: null, summary: null, }; } const spNo = extractSpNo(result.payload); return { ok: true, skipped: false, retryable: false, code: "wecom_approval_apply_event_submitted", message: null, tokenSource: token.tokenSource, endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`, response: sanitizeApprovalApiResponse(result.payload), spNo, summary: { spNoPrefix: maskApprovalIdentifier(spNo), }, }; } catch (error) { return { ok: false, skipped: false, retryable: Boolean(error?.details?.retryable), code: error?.code || "wecom_approval_apply_event_failed", message: sanitizeErrorMessage(error?.message || error), tokenSource: "WECOM_AGENT_SECRET", endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`, response: error?.details?.response || null, spNo: null, summary: null, }; } } module.exports = { APPROVAL_HR_ATTENDANCE_TARGETS, APPROVAL_POC_TARGETS, APPROVAL_APPLY_EVENT_ENDPOINT, APPROVAL_CREATE_TEMPLATE_ENDPOINT, APPROVAL_GET_INFO_ENDPOINT, WecomApprovalPocError, fetchWecomApprovalApplyEvent, fetchWecomApprovalDetail, fetchWecomApprovalInfoList, fetchWecomApprovalTemplateCreate, fetchWecomApprovalTemplateDetail, maskApprovalIdentifier, sanitizeApprovalApiResponse, summarizeApprovalInfoPayload, summarizeApprovalPayload, summarizeTemplatePayload, };