feat: wecom-client 新增网页授权能力(授权 URL / code 换身份 / 外部联系人显示名)
This commit is contained in:
@@ -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<WecomOAuthIdentity> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user