#!/usr/bin/env node import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const DEFAULT_REPORT_ROOT = "artifacts/qa-flow"; const DEFAULT_TEST_USERID = "HRWDTEST-20260517214548"; const HCLT_LIVE_VERIFIED_MARKER = "HRWDTEST-20260517214548"; const HCLT_LIVE_VERIFIED_REPORT = "artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json"; const WECOM_API_BASE = "https://qyapi.weixin.qq.com"; const CAPABILITY_STATUSES = new Set([ "CAN_REUSE_NOW", "NEEDS_CONFIG", "NEEDS_API_POC", "KEEP_OA_PRIMARY", "NOT_SUPPORTED_BY_API", ]); const FINAL_CONCLUSIONS = new Set([ "GO_TO_HR_MASTER_SYNC", "PAUSE_FOR_CONFIG", "PAUSE_FOR_API_POC", "NO_GO_REVISE_ROADMAP", ]); const ENV_WHITELIST = new Set([ "WECOM_CORP_ID", "WECOM_AGENT_ID", "WECOM_AGENT_SECRET", "WECOM_CHECKIN_SYNC_ENABLED", "WECOM_CHECKIN_SECRET", "WECOM_CONTACT_SYNC_ENABLED", "WECOM_CONTACT_SECRET", "WECOM_CONTACT_DEFAULT_DEPARTMENT_ID", "WECOM_OAUTH_ENABLED", "WECOM_REDIRECT_URI", "WECOM_ALLOWED_RETURN_HOSTS", "WECOM_HR_ASSISTANT_SECRET", "WECOM_APPROVAL_SECRET", "WECOM_APPROVAL_TEMPLATE_ID", "WECOM_APPROVAL_TEMPLATE_IDS", "WECOM_APPROVAL_INSTANCE_ID", "WECOM_APPROVAL_SP_NO", "WECOM_APPROVAL_CALLBACK_URL", "WECOM_CALLBACK_URL", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_AES_KEY", "WECOM_CAPABILITY_POC_LIVE_READ", "WECOM_CAPABILITY_POC_TEST_USERID", "WECOM_CAPABILITY_POC_DAYS", "WECOM_CAPABILITY_POC_JSON", ]); const DOC_REFERENCES = Object.freeze({ contactCreate: "https://developer.work.weixin.qq.com/document/path/90195", checkinOverview: "https://developer.work.weixin.qq.com/document/path/90261", checkinData: "https://developer.work.weixin.qq.com/document/path/90262", checkinRule: "https://developer.work.weixin.qq.com/document/path/90263", approvalOverview: "https://developer.work.weixin.qq.com/document/path/91854", approvalSubmit: "https://developer.work.weixin.qq.com/document/path/91853", approvalTemplate: "https://developer.work.weixin.qq.com/document/path/91982", approvalDetail: "https://developer.work.weixin.qq.com/document/path/91983", hrOverview: "https://developer.work.weixin.qq.com/document/path/99130", hrFields: "https://developer.work.weixin.qq.com/document/path/99131", hrStaffRead: "https://developer.work.weixin.qq.com/document/path/99132", hrStaffUpdate: "https://developer.work.weixin.qq.com/document/path/99133", }); function parseArgs(argv) { const options = { envFile: "", json: process.env.WECOM_CAPABILITY_POC_JSON === "1", liveRead: process.env.WECOM_CAPABILITY_POC_LIVE_READ === "1", days: Number(process.env.WECOM_CAPABILITY_POC_DAYS || 1), testUserId: process.env.WECOM_CAPABILITY_POC_TEST_USERID || DEFAULT_TEST_USERID, issueId: process.env.QA_ISSUE_ID || "WCP1", reportDir: process.env.WECOM_CAPABILITY_POC_REPORT_DIR || "", }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--json") { options.json = true; } else if (arg === "--live-read") { options.liveRead = true; } else if (arg === "--no-live-read") { options.liveRead = false; } else if (arg === "--env-file" || arg === "--poc-env-file") { options.envFile = argv[index + 1] || ""; index += 1; } else if (arg.startsWith("--env-file=")) { options.envFile = arg.slice("--env-file=".length); } else if (arg.startsWith("--poc-env-file=")) { options.envFile = arg.slice("--poc-env-file=".length); } else if (arg === "--test-userid") { options.testUserId = argv[index + 1] || ""; index += 1; } else if (arg.startsWith("--test-userid=")) { options.testUserId = arg.slice("--test-userid=".length); } else if (arg === "--days") { options.days = Number(argv[index + 1] || 1); index += 1; } else if (arg.startsWith("--days=")) { options.days = Number(arg.slice("--days=".length)); } else if (arg.startsWith("--report-dir=")) { options.reportDir = arg.slice("--report-dir=".length); } } options.days = Number.isFinite(options.days) ? Math.max(1, Math.min(7, Math.floor(options.days))) : 1; options.testUserId = String(options.testUserId || "").trim(); if (!options.reportDir) { const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); options.reportDir = join(DEFAULT_REPORT_ROOT, `wecom-capability-poc-${stamp}`); } return options; } function unquoteEnvValue(value) { const trimmed = String(value ?? "").trim(); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed.slice(1, -1); } return trimmed; } function loadEnvFile(filePath) { if (!filePath) return { loaded: false, path: null, keys: [] }; if (!existsSync(filePath)) { throw new Error(`env file not found: ${filePath}`); } const loadedKeys = []; const text = readFileSync(filePath, "utf8"); for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) continue; const [, key, rawValue] = match; if (!ENV_WHITELIST.has(key)) continue; if (process.env[key] == null) { process.env[key] = unquoteEnvValue(rawValue); loadedKeys.push(key); } } return { loaded: true, path: filePath, keys: loadedKeys.sort() }; } function envFlag(name) { return process.env[name] === "true" || process.env[name] === "1"; } function envValue(name) { return String(process.env[name] || "").trim(); } function maskId(value) { const raw = String(value ?? "").trim(); if (!raw) return null; if (raw.length <= 6) return `${raw.slice(0, 2)}***`; return `${raw.slice(0, Math.min(8, raw.length))}***`; } function maskHostLike(value) { const raw = String(value ?? "").trim(); if (!raw) return null; try { const url = new URL(raw); return `${url.protocol}//${url.host}${url.pathname ? "/..." : ""}`; } catch { return raw.length > 32 ? `${raw.slice(0, 16)}...` : raw; } } function safeMessage(value) { return String(value ?? "") .replace(/[A-Za-z0-9_-]{24,}/g, "***") .replace(/access_token=[^&\s]+/gi, "access_token=***") .slice(0, 160); } function sanitizeWecomPayload(payload) { if (!payload || typeof payload !== "object") return null; const result = {}; for (const key of ["errcode", "errmsg", "invaliduser", "invalidparty", "invalidtag"]) { if (payload[key] != null) { result[key] = key === "errmsg" ? safeMessage(payload[key]) : payload[key]; } } if (Array.isArray(payload.department)) result.departmentCount = payload.department.length; if (Array.isArray(payload.checkindata)) result.checkindataCount = payload.checkindata.length; if (Array.isArray(payload.fields)) result.fieldsCount = payload.fields.length; if (Array.isArray(payload.field_info)) result.fieldsCount = payload.field_info.length; if (Array.isArray(payload.info)) result.infoCount = payload.info.length; if (Array.isArray(payload.staff_info)) result.staffInfoCount = payload.staff_info.length; if (Array.isArray(payload.vacation_quota_list)) result.vacationQuotaCount = payload.vacation_quota_list.length; if (payload.userid != null) result.useridPrefix = maskId(payload.userid); if (payload.enable != null) result.enable = payload.enable; return Object.keys(result).length ? result : null; } function capability(input) { if (!CAPABILITY_STATUSES.has(input.status)) { throw new Error(`invalid capability status for ${input.id}: ${input.status}`); } return { id: input.id, domain: input.domain, status: input.status, reason: input.reason, evidence: input.evidence || {}, readOnly: input.readOnly !== false, blockingForHrMasterSync: Boolean(input.blockingForHrMasterSync), docs: input.docs || [], }; } function classifyApiError(apiResult, defaultStatus = "NEEDS_CONFIG") { if (apiResult.networkError) { return { status: "NEEDS_API_POC", reason: apiResult.timeout ? "api_timeout_or_network_unstable" : "api_network_error", }; } const errcode = Number(apiResult.payload?.errcode); if ([48002, 48003, 48004, 60011, 60020, 301002, 40001, 40014, 40029, 60111, 60121, 60203].includes(errcode)) { return { status: "NEEDS_CONFIG", reason: `wecom_errcode_${errcode}` }; } if (apiResult.httpStatus >= 500 || apiResult.httpStatus === 429) { return { status: "NEEDS_API_POC", reason: `wecom_http_${apiResult.httpStatus}` }; } if (Number.isFinite(errcode)) { return { status: defaultStatus, reason: `wecom_errcode_${errcode}` }; } return { status: "NEEDS_API_POC", reason: "missing_or_unexpected_wecom_response" }; } function isWecomApiOk(result) { return Boolean(result?.ok && result.payload && Number(result.payload.errcode ?? 0) === 0); } function isWecomErrcode(result, errcode) { return Number(result?.payload?.errcode) === errcode; } async function fetchJson(url, init = {}) { try { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(18_000), }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = null; } return { ok: response.ok, httpStatus: response.status, payload }; } catch (error) { return { ok: false, httpStatus: null, payload: null, networkError: true, timeout: error?.name === "AbortError", errorMessage: safeMessage(error?.message || error), }; } } async function getWecomToken({ corpId, secret, label }) { if (!corpId || !secret) { return { ok: false, code: "missing_config", reason: `missing_${label}_token_config`, response: null, token: null, }; } const url = new URL(`${WECOM_API_BASE}/cgi-bin/gettoken`); url.searchParams.set("corpid", corpId); url.searchParams.set("corpsecret", secret); const result = await fetchJson(url); const errcode = Number(result.payload?.errcode ?? 0); if (!result.ok || !result.payload || errcode !== 0 || typeof result.payload.access_token !== "string") { const classified = classifyApiError(result); return { ok: false, code: "token_failed", reason: classified.reason, response: sanitizeWecomPayload(result.payload), token: null, }; } return { ok: true, code: "token_fetched", reason: null, response: { errcode: 0, expiresIn: Number(result.payload.expires_in ?? 0) || null }, token: result.payload.access_token, }; } async function wecomGet(pathName, token, query = {}) { const url = new URL(`${WECOM_API_BASE}${pathName}`); url.searchParams.set("access_token", token); for (const [key, value] of Object.entries(query)) { if (value != null && value !== "") url.searchParams.set(key, String(value)); } return fetchJson(url); } async function wecomPost(pathName, token, body = {}) { const url = new URL(`${WECOM_API_BASE}${pathName}`); url.searchParams.set("access_token", token); return fetchJson(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } function toUnixSeconds(date) { return Math.floor(date.getTime() / 1000); } function shanghaiDateAt(dateString, endOfDay = false) { return new Date(`${dateString}T${endOfDay ? "23:59:59" : "00:00:00"}+08:00`); } function shanghaiDateString(date) { return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", }).format(date); } function resolveWindow(days) { const end = new Date(); const start = new Date(end.getTime() - (days - 1) * 24 * 60 * 60 * 1000); return { startDate: shanghaiDateString(start), endDate: shanghaiDateString(end), }; } function firstCsvValue(value) { return String(value || "") .split(",") .map((item) => item.trim()) .find(Boolean) || ""; } function addTokenCapability(capabilities, { id, domain, tokenResult, secretPresent, docs, blockingForHrMasterSync = false }) { if (tokenResult.ok) { capabilities.push(capability({ id, domain, status: "CAN_REUSE_NOW", reason: "access_token_fetch_ok", evidence: tokenResult.response, docs, blockingForHrMasterSync, })); return; } capabilities.push(capability({ id, domain, status: secretPresent ? "NEEDS_CONFIG" : "NEEDS_CONFIG", reason: tokenResult.reason, evidence: tokenResult.response || { missingSecret: !secretPresent }, docs, blockingForHrMasterSync, })); } function addApiCapability(capabilities, input) { const errcode = Number(input.result.payload?.errcode ?? 0); if (input.result.ok && input.result.payload && errcode === 0) { capabilities.push(capability({ id: input.id, domain: input.domain, status: "CAN_REUSE_NOW", reason: input.successReason, evidence: { endpoint: input.endpoint, ...input.successEvidence, response: sanitizeWecomPayload(input.result.payload), }, docs: input.docs, blockingForHrMasterSync: input.blockingForHrMasterSync, })); return; } const classified = classifyApiError(input.result, input.defaultStatus); capabilities.push(capability({ id: input.id, domain: input.domain, status: classified.status, reason: classified.reason, evidence: { endpoint: input.endpoint, response: sanitizeWecomPayload(input.result.payload), httpStatus: input.result.httpStatus, networkError: Boolean(input.result.networkError), }, docs: input.docs, blockingForHrMasterSync: input.blockingForHrMasterSync, })); } function addLiveReadDisabled(capabilities, id, domain, docs = [], blockingForHrMasterSync = false) { capabilities.push(capability({ id, domain, status: "NEEDS_API_POC", reason: "live_read_disabled", evidence: { runWith: "WECOM_CAPABILITY_POC_LIVE_READ=1" }, docs, blockingForHrMasterSync, })); } function addMissingConfig(capabilities, input) { capabilities.push(capability({ id: input.id, domain: input.domain, status: "NEEDS_CONFIG", reason: input.reason, evidence: input.evidence || {}, docs: input.docs || [], blockingForHrMasterSync: Boolean(input.blockingForHrMasterSync), })); } async function probeContact(capabilities, options, config) { const docs = [DOC_REFERENCES.contactCreate]; const hcltPath = HCLT_LIVE_VERIFIED_REPORT; if (existsSync(hcltPath)) { const hclt = JSON.parse(readFileSync(hcltPath, "utf8")); capabilities.push(capability({ id: "contact.hclt_create_update_disable", domain: "contact", status: hclt.status === "PASS" ? "CAN_REUSE_NOW" : "NEEDS_API_POC", reason: hclt.status === "PASS" ? "hclt_live_verified" : "hclt_evidence_not_pass", evidence: { marker: hclt.marker, status: hclt.status, report: hcltPath, finalPolicy: hclt.finalPolicy, }, docs, blockingForHrMasterSync: true, })); } else { capabilities.push(capability({ id: "contact.hclt_create_update_disable", domain: "contact", status: "CAN_REUSE_NOW", reason: "documented_hclt_live_verified", evidence: { marker: HCLT_LIVE_VERIFIED_MARKER, expectedReport: hcltPath, artifactUnavailableInRuntime: true, note: "HCLT evidence is a project fact; local repo keeps the full artifact.", }, docs, blockingForHrMasterSync: true, })); } if (!options.liveRead) { addLiveReadDisabled(capabilities, "contact.department_read", "contact", docs, true); addLiveReadDisabled(capabilities, "contact.member_read", "contact", docs, true); return; } const tokenResult = await getWecomToken({ corpId: config.corpId, secret: config.contactSecret, label: "contact", }); addTokenCapability(capabilities, { id: "contact.token", domain: "contact", tokenResult, secretPresent: Boolean(config.contactSecret), docs, blockingForHrMasterSync: true, }); if (!tokenResult.ok) { addMissingConfig(capabilities, { id: "contact.department_read", domain: "contact", reason: "contact_token_unavailable", evidence: { missingSecret: !config.contactSecret }, docs, blockingForHrMasterSync: true, }); addMissingConfig(capabilities, { id: "contact.member_read", domain: "contact", reason: "contact_token_unavailable", evidence: { missingTestUserId: !options.testUserId }, docs, blockingForHrMasterSync: true, }); return; } let agentFallbackToken = null; async function getAgentFallbackToken() { if (agentFallbackToken) return agentFallbackToken; if (!config.agentSecret) return null; const fallback = await getWecomToken({ corpId: config.corpId, secret: config.agentSecret, label: "contact_agent_fallback", }); agentFallbackToken = fallback.ok ? fallback.token : null; return agentFallbackToken; } let department = await wecomGet("/cgi-bin/department/list", tokenResult.token); let departmentEvidence = {}; if (!isWecomApiOk(department) && isWecomErrcode(department, 48009)) { const fallbackToken = await getAgentFallbackToken(); if (fallbackToken) { const fallbackDepartment = await wecomGet("/cgi-bin/department/list", fallbackToken); if (isWecomApiOk(fallbackDepartment)) { department = fallbackDepartment; departmentEvidence = { tokenSource: "WECOM_AGENT_SECRET_FALLBACK" }; } } } addApiCapability(capabilities, { id: "contact.department_read", domain: "contact", result: department, endpoint: "/cgi-bin/department/list", successReason: "department_list_read_ok", successEvidence: departmentEvidence, docs, blockingForHrMasterSync: true, }); if (!options.testUserId) { addMissingConfig(capabilities, { id: "contact.member_read", domain: "contact", reason: "missing_test_userid", docs, blockingForHrMasterSync: true, }); return; } let member = await wecomGet("/cgi-bin/user/get", tokenResult.token, { userid: options.testUserId }); let memberEvidence = { testUserIdPrefix: maskId(options.testUserId) }; if (!isWecomApiOk(member) && isWecomErrcode(member, 48009)) { const fallbackToken = await getAgentFallbackToken(); if (fallbackToken) { const fallbackMember = await wecomGet("/cgi-bin/user/get", fallbackToken, { userid: options.testUserId }); if (isWecomApiOk(fallbackMember)) { member = fallbackMember; memberEvidence = { ...memberEvidence, tokenSource: "WECOM_AGENT_SECRET_FALLBACK" }; } } } addApiCapability(capabilities, { id: "contact.member_read", domain: "contact", result: member, endpoint: "/cgi-bin/user/get", successReason: "test_member_read_ok", successEvidence: memberEvidence, docs, blockingForHrMasterSync: true, }); } async function probeHrAssistant(capabilities, options, config) { const docs = [DOC_REFERENCES.hrOverview, DOC_REFERENCES.hrFields, DOC_REFERENCES.hrStaffRead, DOC_REFERENCES.hrStaffUpdate]; if (!options.liveRead) { addLiveReadDisabled(capabilities, "hr.fields_read", "hr_assistant", docs, true); addLiveReadDisabled(capabilities, "hr.staff_read", "hr_assistant", docs, true); capabilities.push(capability({ id: "hr.staff_update_classification", domain: "hr_assistant", status: "NEEDS_API_POC", reason: "write_api_requires_separate_approval", evidence: { endpoint: "/cgi-bin/hr/update_staff_info", readOnlyPoc: true }, docs, })); return; } const hrSecret = config.hrAssistantSecret || config.agentSecret; const tokenResult = await getWecomToken({ corpId: config.corpId, secret: hrSecret, label: "hr_assistant", }); addTokenCapability(capabilities, { id: "hr.token", domain: "hr_assistant", tokenResult, secretPresent: Boolean(hrSecret), docs, blockingForHrMasterSync: true, }); if (!tokenResult.ok) { addMissingConfig(capabilities, { id: "hr.fields_read", domain: "hr_assistant", reason: "hr_token_unavailable", evidence: { missingSecret: !hrSecret }, docs, blockingForHrMasterSync: true, }); addMissingConfig(capabilities, { id: "hr.staff_read", domain: "hr_assistant", reason: "hr_token_unavailable", evidence: { missingTestUserId: !options.testUserId }, docs, blockingForHrMasterSync: true, }); } else { const fields = await wecomGet("/cgi-bin/hr/get_fields", tokenResult.token); addApiCapability(capabilities, { id: "hr.fields_read", domain: "hr_assistant", result: fields, endpoint: "/cgi-bin/hr/get_fields", successReason: "hr_fields_read_ok", successEvidence: { tokenSource: config.hrAssistantSecret ? "WECOM_HR_ASSISTANT_SECRET" : "WECOM_AGENT_SECRET" }, docs, blockingForHrMasterSync: true, }); if (!options.testUserId) { addMissingConfig(capabilities, { id: "hr.staff_read", domain: "hr_assistant", reason: "missing_test_userid", docs, blockingForHrMasterSync: true, }); } else { const staff = await wecomPost("/cgi-bin/hr/get_staff_info", tokenResult.token, { userid: options.testUserId, get_all: true, }); addApiCapability(capabilities, { id: "hr.staff_read", domain: "hr_assistant", result: staff, endpoint: "/cgi-bin/hr/get_staff_info", successReason: "hr_staff_read_ok", successEvidence: { testUserIdPrefix: maskId(options.testUserId) }, docs, blockingForHrMasterSync: true, }); } } capabilities.push(capability({ id: "hr.staff_update_classification", domain: "hr_assistant", status: "NEEDS_API_POC", reason: "write_api_requires_separate_approval", evidence: { endpoint: "/cgi-bin/hr/update_staff_info", readOnlyPoc: true }, docs, })); } async function probeCheckin(capabilities, options, config) { const docs = [DOC_REFERENCES.checkinOverview, DOC_REFERENCES.checkinData, DOC_REFERENCES.checkinRule]; if (!options.liveRead) { addLiveReadDisabled(capabilities, "checkin.records_read", "checkin", docs); addLiveReadDisabled(capabilities, "checkin.rules_read", "checkin", docs); return; } if (!config.checkinSyncEnabled) { addMissingConfig(capabilities, { id: "checkin.records_read", domain: "checkin", reason: "wecom_checkin_sync_disabled", evidence: { env: "WECOM_CHECKIN_SYNC_ENABLED" }, docs, }); addMissingConfig(capabilities, { id: "checkin.rules_read", domain: "checkin", reason: "wecom_checkin_sync_disabled", evidence: { env: "WECOM_CHECKIN_SYNC_ENABLED" }, docs, }); return; } const checkinSecret = config.checkinSecret || config.agentSecret; const tokenResult = await getWecomToken({ corpId: config.corpId, secret: checkinSecret, label: "checkin", }); addTokenCapability(capabilities, { id: "checkin.token", domain: "checkin", tokenResult, secretPresent: Boolean(checkinSecret), docs, }); if (!tokenResult.ok || !options.testUserId) { addMissingConfig(capabilities, { id: "checkin.records_read", domain: "checkin", reason: !tokenResult.ok ? "checkin_token_unavailable" : "missing_test_userid", evidence: { missingSecret: !checkinSecret, missingTestUserId: !options.testUserId }, docs, }); addMissingConfig(capabilities, { id: "checkin.rules_read", domain: "checkin", reason: !tokenResult.ok ? "checkin_token_unavailable" : "missing_test_userid", evidence: { missingSecret: !checkinSecret, missingTestUserId: !options.testUserId }, docs, }); return; } const window = resolveWindow(options.days); const records = await wecomPost("/cgi-bin/checkin/getcheckindata", tokenResult.token, { opencheckindatatype: 3, starttime: toUnixSeconds(shanghaiDateAt(window.startDate, false)), endtime: toUnixSeconds(shanghaiDateAt(window.endDate, true)), useridlist: [options.testUserId], }); addApiCapability(capabilities, { id: "checkin.records_read", domain: "checkin", result: records, endpoint: "/cgi-bin/checkin/getcheckindata", successReason: "checkin_short_window_read_ok", successEvidence: { window, testUserIdPrefix: maskId(options.testUserId), emptyDataAllowed: true, }, docs, }); const rules = await wecomPost("/cgi-bin/checkin/getcheckinoption", tokenResult.token, { datetime: toUnixSeconds(shanghaiDateAt(window.endDate, false)), useridlist: [options.testUserId], }); addApiCapability(capabilities, { id: "checkin.rules_read", domain: "checkin", result: rules, endpoint: "/cgi-bin/checkin/getcheckinoption", successReason: "checkin_rule_read_ok", successEvidence: { window, testUserIdPrefix: maskId(options.testUserId), }, docs, }); } async function probeApprovalAndVacation(capabilities, options, config) { const docs = [ DOC_REFERENCES.approvalOverview, DOC_REFERENCES.approvalSubmit, DOC_REFERENCES.approvalTemplate, DOC_REFERENCES.approvalDetail, ]; const approvalSecret = config.approvalSecret || config.agentSecret; if (!options.liveRead) { addLiveReadDisabled(capabilities, "approval.template_detail", "approval", docs); addLiveReadDisabled(capabilities, "approval.instance_detail", "approval", docs); addLiveReadDisabled(capabilities, "approval.vacation_quota", "approval", docs); } else { const tokenResult = await getWecomToken({ corpId: config.corpId, secret: approvalSecret, label: "approval", }); addTokenCapability(capabilities, { id: "approval.token", domain: "approval", tokenResult, secretPresent: Boolean(approvalSecret), docs, }); if (!tokenResult.ok) { for (const id of ["approval.template_detail", "approval.instance_detail", "approval.vacation_quota"]) { addMissingConfig(capabilities, { id, domain: "approval", reason: "approval_token_unavailable", evidence: { missingSecret: !approvalSecret }, docs, }); } } else { const templateId = config.approvalTemplateId; if (!templateId) { addMissingConfig(capabilities, { id: "approval.template_detail", domain: "approval", reason: "missing_approval_template_id", evidence: { env: "WECOM_APPROVAL_TEMPLATE_ID" }, docs, }); } else { const template = await wecomPost("/cgi-bin/oa/gettemplatedetail", tokenResult.token, { template_id: templateId, }); addApiCapability(capabilities, { id: "approval.template_detail", domain: "approval", result: template, endpoint: "/cgi-bin/oa/gettemplatedetail", successReason: "approval_template_read_ok", successEvidence: { templateIdPrefix: maskId(templateId) }, docs, }); } const spNo = config.approvalInstanceId; if (!spNo) { addMissingConfig(capabilities, { id: "approval.instance_detail", domain: "approval", reason: "missing_approval_instance_id", evidence: { env: "WECOM_APPROVAL_INSTANCE_ID or WECOM_APPROVAL_SP_NO" }, docs, }); } else { const detail = await wecomPost("/cgi-bin/oa/getapprovaldetail", tokenResult.token, { sp_no: spNo }); addApiCapability(capabilities, { id: "approval.instance_detail", domain: "approval", result: detail, endpoint: "/cgi-bin/oa/getapprovaldetail", successReason: "approval_instance_read_ok", successEvidence: { spNoPrefix: maskId(spNo) }, docs, }); } if (!options.testUserId) { addMissingConfig(capabilities, { id: "approval.vacation_quota", domain: "approval", reason: "missing_test_userid", docs, }); } else { const vacation = await wecomPost("/cgi-bin/oa/vacation/getuservacationquota", tokenResult.token, { userid: options.testUserId, }); addApiCapability(capabilities, { id: "approval.vacation_quota", domain: "approval", result: vacation, endpoint: "/cgi-bin/oa/vacation/getuservacationquota", successReason: "vacation_quota_read_ok", successEvidence: { testUserIdPrefix: maskId(options.testUserId) }, docs, }); } } } const callbackReady = Boolean(config.callbackUrl && config.callbackToken && config.callbackAesKey); capabilities.push(capability({ id: "callback.config", domain: "callback", status: callbackReady ? "CAN_REUSE_NOW" : "NEEDS_CONFIG", reason: callbackReady ? "callback_config_present" : "callback_config_missing_or_manual_only", evidence: { callbackUrl: maskHostLike(config.callbackUrl), hasCallbackToken: Boolean(config.callbackToken), hasCallbackAesKey: Boolean(config.callbackAesKey), note: "callback endpoint reachability must be verified in later mirror contract", }, docs, })); capabilities.push(capability({ id: "approval.oa_primary_sensitive_domains", domain: "approval", status: "KEEP_OA_PRIMARY", reason: "procurement_contract_reimbursement_finance_remain_oa_primary", evidence: { sensitiveDomains: ["reimbursement", "procurement", "contract", "project", "payment_batch", "ap", "accounting_handoff"], wecomUsage: "dehydrated_message_and_oa_deeplink_only", }, docs, })); } async function probeWorkbenchAndPermissions(capabilities, options, config) { const docs = [DOC_REFERENCES.approvalOverview, DOC_REFERENCES.contactCreate]; if (!options.liveRead) { addLiveReadDisabled(capabilities, "workbench.app_token", "workbench", docs); } else { const tokenResult = await getWecomToken({ corpId: config.corpId, secret: config.agentSecret, label: "workbench", }); addTokenCapability(capabilities, { id: "workbench.app_token", domain: "workbench", tokenResult, secretPresent: Boolean(config.agentSecret), docs, }); } capabilities.push(capability({ id: "permissions.config_presence", domain: "permissions", status: config.corpId && (config.agentSecret || config.contactSecret) ? "CAN_REUSE_NOW" : "NEEDS_CONFIG", reason: config.corpId && (config.agentSecret || config.contactSecret) ? "core_wecom_config_present" : "core_wecom_config_missing", evidence: { hasCorpId: Boolean(config.corpId), hasAgentId: Boolean(config.agentId), hasAgentSecret: Boolean(config.agentSecret), hasContactSecret: Boolean(config.contactSecret), hasCheckinSecretOrAgentFallback: Boolean(config.checkinSecret || config.agentSecret), oauthEnabled: config.oauthEnabled, redirectUri: maskHostLike(config.redirectUri), }, docs, })); } function deriveConclusion(capabilities) { const notSupported = capabilities.filter((item) => item.blockingForHrMasterSync && item.status === "NOT_SUPPORTED_BY_API"); if (notSupported.length) { return "NO_GO_REVISE_ROADMAP"; } const critical = capabilities.filter((item) => item.blockingForHrMasterSync); if (critical.some((item) => item.status === "NEEDS_CONFIG")) { return "PAUSE_FOR_CONFIG"; } if (critical.some((item) => item.status === "NEEDS_API_POC")) { return "PAUSE_FOR_API_POC"; } return "GO_TO_HR_MASTER_SYNC"; } function statusCounts(capabilities) { const counts = {}; for (const status of CAPABILITY_STATUSES) counts[status] = 0; for (const item of capabilities) counts[item.status] = (counts[item.status] || 0) + 1; return counts; } function buildConfig() { return { corpId: envValue("WECOM_CORP_ID"), agentId: envValue("WECOM_AGENT_ID"), agentSecret: envValue("WECOM_AGENT_SECRET"), contactSyncEnabled: envFlag("WECOM_CONTACT_SYNC_ENABLED"), contactSecret: envValue("WECOM_CONTACT_SECRET"), contactDefaultDepartmentId: envValue("WECOM_CONTACT_DEFAULT_DEPARTMENT_ID"), checkinSyncEnabled: envFlag("WECOM_CHECKIN_SYNC_ENABLED"), checkinSecret: envValue("WECOM_CHECKIN_SECRET"), oauthEnabled: envFlag("WECOM_OAUTH_ENABLED"), redirectUri: envValue("WECOM_REDIRECT_URI"), allowedReturnHosts: envValue("WECOM_ALLOWED_RETURN_HOSTS"), hrAssistantSecret: envValue("WECOM_HR_ASSISTANT_SECRET"), approvalSecret: envValue("WECOM_APPROVAL_SECRET"), approvalTemplateId: envValue("WECOM_APPROVAL_TEMPLATE_ID") || firstCsvValue(envValue("WECOM_APPROVAL_TEMPLATE_IDS")), approvalInstanceId: envValue("WECOM_APPROVAL_INSTANCE_ID") || envValue("WECOM_APPROVAL_SP_NO"), callbackUrl: envValue("WECOM_APPROVAL_CALLBACK_URL") || envValue("WECOM_CALLBACK_URL"), callbackToken: envValue("WECOM_CALLBACK_TOKEN"), callbackAesKey: envValue("WECOM_CALLBACK_AES_KEY"), }; } async function buildReport(options, envLoadResult) { const config = buildConfig(); const capabilities = []; capabilities.push(capability({ id: "poc.scope", domain: "poc", status: "CAN_REUSE_NOW", reason: "qa_only_read_only_scope", evidence: { liveRead: options.liveRead, days: options.days, testUserIdPrefix: maskId(options.testUserId), noProductionWrites: true, }, docs: Object.values(DOC_REFERENCES), })); await probeWorkbenchAndPermissions(capabilities, options, config); await probeContact(capabilities, options, config); await probeHrAssistant(capabilities, options, config); await probeCheckin(capabilities, options, config); await probeApprovalAndVacation(capabilities, options, config); const conclusion = deriveConclusion(capabilities); if (!FINAL_CONCLUSIONS.has(conclusion)) { throw new Error(`invalid conclusion: ${conclusion}`); } const blockingCapabilities = capabilities .filter((item) => item.blockingForHrMasterSync && item.status !== "CAN_REUSE_NOW") .map((item) => ({ id: item.id, status: item.status, reason: item.reason })); return { checkedAt: new Date().toISOString(), issueId: options.issueId, status: "PASS", conclusion, liveRead: options.liveRead, readOnly: true, noProductionWrites: true, days: options.days, testUserIdPrefix: maskId(options.testUserId), envFile: envLoadResult.loaded ? { loaded: true, keyCount: envLoadResult.keys.length, // 不输出 env 文件路径,避免暴露部署目录结构以外的信息。 } : { loaded: false }, statusCounts: statusCounts(capabilities), blockingCapabilities, nextStageAllowed: conclusion === "GO_TO_HR_MASTER_SYNC", nonBlockingNote: "Approval, checkin, callback, recovery writeback and RuleOps still require their own later contracts even when HR master sync can proceed.", docReferences: DOC_REFERENCES, capabilities, }; } function printHuman(summary) { console.log(`[wecom-capability-poc] status=${summary.status} conclusion=${summary.conclusion} liveRead=${summary.liveRead}`); console.log(`[wecom-capability-poc] reportDir=${summary.reportDir}`); for (const item of summary.capabilities) { console.log(`[wecom-capability-poc] ${item.id} status=${item.status} reason=${item.reason}`); } } async function main() { const options = parseArgs(process.argv.slice(2)); const envLoadResult = loadEnvFile(options.envFile); mkdirSync(options.reportDir, { recursive: true }); const summary = await buildReport(options, envLoadResult); summary.reportDir = options.reportDir; const summaryPath = join(options.reportDir, "summary.json"); writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); writeFileSync(join(options.reportDir, "capability-matrix.json"), `${JSON.stringify(summary.capabilities, null, 2)}\n`); if (options.json) { console.log(JSON.stringify({ status: summary.status, conclusion: summary.conclusion, reportDir: options.reportDir, statusCounts: summary.statusCounts, blockingCapabilities: summary.blockingCapabilities, nextStageAllowed: summary.nextStageAllowed, }, null, 2)); } else { printHuman(summary); } } main().catch((error) => { console.error(`[wecom-capability-poc][ERROR] ${safeMessage(error instanceof Error ? error.message : String(error))}`); process.exit(1); });