From 7f6c0e098388520dc95233958f4fb92542272d76 Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Mon, 31 Aug 2026 17:10:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20wecom-client=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E6=8E=88=E6=9D=83=E8=83=BD=E5=8A=9B=EF=BC=88?= =?UTF-8?q?=E6=8E=88=E6=9D=83=20URL=20/=20code=20=E6=8D=A2=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=20/=20=E5=A4=96=E9=83=A8=E8=81=94=E7=B3=BB=E4=BA=BA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=90=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/wecom-client.ts | 96 +++++++++++++++++++++ tests/wecom-client.test.mjs | 166 ++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) diff --git a/lib/wecom-client.ts b/lib/wecom-client.ts index 4a0e1e2..f91c63b 100644 --- a/lib/wecom-client.ts +++ b/lib/wecom-client.ts @@ -25,6 +25,7 @@ type WecomEnvelope = { access_token?: string; expires_in?: number; invaliduser?: string; + userid?: string; follow_user?: Array<{ userid: string }>; external_userid?: string[]; external_contact?: { @@ -458,3 +459,98 @@ export async function listExternalContacts( } return contacts; } + +export function buildWecomOAuthUrl(params: { + corpId: string; + agentId: string; + redirectUri: string; + state: string; +}): string { + const query = new URLSearchParams({ + appid: params.corpId, + redirect_uri: params.redirectUri, + response_type: "code", + scope: "snsapi_base", + state: params.state, + agentid: params.agentId, + }); + return `https://open.weixin.qq.com/connect/oauth2/authorize?${query.toString()}#wechat_redirect`; +} + +export type WecomOAuthIdentity = { + userId?: string; + externalUserId?: string; +}; + +export async function exchangeWecomOAuthCode( + config: WecomConfig, + code: string, + fetchImpl: FetchLike = fetch, +): Promise { + if (!hasAppCredentials(config)) { + throw new WecomClientError( + "企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)", + 400, + ); + } + const authorizationCode = bindingValue(code); + if (!authorizationCode) { + throw new WecomClientError("缺少企业微信授权 code", 400); + } + const token = await fetchAccessToken(config, fetchImpl); + const url = `${WECOM_API_ORIGIN}/cgi-bin/auth/getuserinfo?access_token=${encodeURIComponent(token)}`; + const response = await fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ code: authorizationCode }), + signal: AbortSignal.timeout(12_000), + }); + const payload = await readEnvelope(response, "企业微信授权身份换取失败"); + if (!response.ok) { + throw new WecomClientError( + `企业微信授权身份换取失败(HTTP ${response.status})`, + 502, + ); + } + if (Number(payload.errcode ?? 0) === 40029) { + throw new WecomClientError("企业微信授权 code 无效或已过期", 400); + } + ensureOk(payload, "企业微信授权身份换取失败"); + const userId = bindingValue(payload.userid); + const externalUserId = + typeof payload.external_userid === "string" + ? bindingValue(payload.external_userid) + : ""; + if (!userId && !externalUserId) { + throw new WecomClientError("企业微信未返回有效身份", 400); + } + return { + ...(userId ? { userId } : {}), + ...(externalUserId ? { externalUserId } : {}), + }; +} + +export async function fetchWecomExternalContactName( + config: WecomConfig, + externalUserId: string, + fetchImpl: FetchLike = fetch, +): Promise { + if (!hasAppCredentials(config)) return null; + const externalId = bindingValue(externalUserId); + if (!externalId) return null; + try { + const token = await fetchAccessToken(config, fetchImpl); + const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalId)}`; + const response = await fetchImpl(url, { + method: "GET", + signal: AbortSignal.timeout(12_000), + }); + const payload = await readEnvelope(response, "获取外部联系人详情失败"); + if (!response.ok) return null; + if (Number(payload.errcode ?? 0) !== 0) return null; + const name = bindingValue(payload.external_contact?.name); + return name || null; + } catch { + return null; + } +} diff --git a/tests/wecom-client.test.mjs b/tests/wecom-client.test.mjs index 9a99819..93a3c2c 100644 --- a/tests/wecom-client.test.mjs +++ b/tests/wecom-client.test.mjs @@ -1,8 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildWecomOAuthUrl, clearWecomAccessTokenCacheForTests, createGroupMsgTemplate, + exchangeWecomOAuthCode, + fetchWecomExternalContactName, listCustomerGroupChats, listExternalContacts, resolveWecomConfig, @@ -605,3 +608,166 @@ test("createGroupMsgTemplate rejects missing sender or empty chat list", async ( /客户群列表为空/, ); }); + +function fakeWecomOAuth(options = {}) { + const calls = []; + const fetchImpl = async (input, init = {}) => { + const url = new URL(String(input)); + calls.push({ url, init }); + if (url.pathname.endsWith("/cgi-bin/gettoken")) { + return Response.json({ + errcode: 0, + errmsg: "ok", + access_token: "token-test", + expires_in: 7200, + }); + } + if (url.pathname.endsWith("/cgi-bin/auth/getuserinfo")) { + return Response.json({ + errcode: options.oauthErrcode ?? 0, + errmsg: options.oauthErrmsg ?? "ok", + ...(options.userId ? { userid: options.userId } : {}), + ...(options.externalUserId ? { external_userid: options.externalUserId } : {}), + }); + } + if (url.pathname.endsWith("/cgi-bin/externalcontact/get")) { + const eid = url.searchParams.get("external_userid"); + if (options.contactNames && options.contactNames[eid]) { + return Response.json({ + errcode: 0, + errmsg: "ok", + external_contact: { external_userid: eid, name: options.contactNames[eid] }, + }); + } + return Response.json({ errcode: 60111, errmsg: "not found" }); + } + return new Response("not found", { status: 404 }); + }; + return { calls, fetchImpl }; +} + +test("buildWecomOAuthUrl assembles authorize URL with encoded params", () => { + const url = new URL( + buildWecomOAuthUrl({ + corpId: "corp-test", + agentId: "1000001", + redirectUri: "https://portal.example.com/koc/?task=abc", + state: "t=abc", + }), + ); + assert.equal(url.origin, "https://open.weixin.qq.com"); + assert.equal(url.pathname, "/connect/oauth2/authorize"); + assert.equal(url.hash, "#wechat_redirect"); + assert.equal(url.searchParams.get("appid"), "corp-test"); + assert.equal(url.searchParams.get("agentid"), "1000001"); + assert.equal(url.searchParams.get("response_type"), "code"); + assert.equal(url.searchParams.get("scope"), "snsapi_base"); + assert.equal(url.searchParams.get("state"), "t=abc"); + assert.equal( + url.searchParams.get("redirect_uri"), + "https://portal.example.com/koc/?task=abc", + ); +}); + +test("exchangeWecomOAuthCode returns external identity and posts code", async () => { + const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" }); + const identity = await exchangeWecomOAuthCode( + resolveWecomConfig(baseBindings), + "code-abc", + fetchImpl, + ); + assert.deepEqual(identity, { externalUserId: "wmExt123" }); + const call = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/auth/getuserinfo"), + ); + assert.ok(call, "getuserinfo was called"); + assert.equal(call.url.searchParams.get("access_token"), "token-test"); + assert.equal(JSON.parse(String(call.init.body)).code, "code-abc"); +}); + +test("exchangeWecomOAuthCode returns internal member userid", async () => { + const { fetchImpl } = fakeWecomOAuth({ userId: "zhangsan" }); + const identity = await exchangeWecomOAuthCode( + resolveWecomConfig(baseBindings), + "code-abc", + fetchImpl, + ); + assert.deepEqual(identity, { userId: "zhangsan" }); +}); + +test("exchangeWecomOAuthCode throws 400 on invalid code (errcode 40029)", async () => { + const { fetchImpl } = fakeWecomOAuth({ + oauthErrcode: 40029, + oauthErrmsg: "invalid code", + }); + await assert.rejects( + () => + exchangeWecomOAuthCode( + resolveWecomConfig(baseBindings), + "code-expired", + fetchImpl, + ), + (error) => { + assert.ok(error instanceof WecomClientError); + assert.equal(error.status, 400); + return true; + }, + ); +}); + +test("exchangeWecomOAuthCode throws when provider returns no identity", async () => { + const { fetchImpl } = fakeWecomOAuth(); + await assert.rejects( + () => + exchangeWecomOAuthCode( + resolveWecomConfig(baseBindings), + "code-abc", + fetchImpl, + ), + (error) => { + assert.ok(error instanceof WecomClientError); + assert.equal(error.status, 400); + return true; + }, + ); +}); + +test("exchangeWecomOAuthCode reuses cached access_token", async () => { + const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" }); + const config = resolveWecomConfig(baseBindings); + await exchangeWecomOAuthCode(config, "code-1", fetchImpl); + await exchangeWecomOAuthCode(config, "code-2", fetchImpl); + assert.equal( + calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/gettoken")).length, + 1, + ); +}); + +test("fetchWecomExternalContactName resolves name and falls back to null", async () => { + const okImpl = fakeWecomOAuth({ contactNames: { wmExt123: "张三" } }); + assert.equal( + await fetchWecomExternalContactName( + resolveWecomConfig(baseBindings), + "wmExt123", + okImpl.fetchImpl, + ), + "张三", + ); + const failImpl = fakeWecomOAuth(); + assert.equal( + await fetchWecomExternalContactName( + resolveWecomConfig(baseBindings), + "wmMissing", + failImpl.fetchImpl, + ), + null, + ); + assert.equal( + await fetchWecomExternalContactName( + resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }), + "wmExt123", + failImpl.fetchImpl, + ), + null, + ); +});