wecom-directory.mjs 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. /** Server only. An aibot sender is a directory UserID only after an explicit mapping. */
  2. export function createWecomDirectory({ config, hash, canonical, fetchImpl = fetch, now = () => Date.now() }) {
  3. const fail = (code) => { throw Object.assign(new Error(code), { code }); };
  4. const text = (v) => typeof v === 'string' && v.trim() && v.length <= 200 ? v.trim() : null;
  5. const list = (v) => Array.isArray(v) ? v : [];
  6. let token = null, tokenUntil = 0;
  7. const requests = new Map();
  8. async function json(url) {
  9. let response;
  10. try { response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(10000) }); }
  11. catch { fail('WECOM_DIRECTORY_UNAVAILABLE'); }
  12. if (!response.ok) fail('WECOM_DIRECTORY_UNAVAILABLE');
  13. let body;
  14. try { body = await response.json(); } catch { fail('WECOM_DIRECTORY_INVALID_RESPONSE'); }
  15. if (body?.errcode !== 0) fail(body?.errcode === 60011 || body?.errcode === 48002 ? 'WECOM_DIRECTORY_PERMISSION_REQUIRED' : 'WECOM_DIRECTORY_API_ERROR');
  16. return body;
  17. }
  18. async function accessToken() {
  19. if (token && tokenUntil > now()) return token;
  20. const body = await json('https://qyapi.weixin.qq.com/cgi-bin/gettoken?' + new URLSearchParams({ corpid: config.corpId, corpsecret: config.secret }));
  21. if (typeof body.access_token !== 'string' || !body.access_token || body.access_token.length > 8192 || !Number.isFinite(body.expires_in) || body.expires_in < 60) fail('WECOM_DIRECTORY_INVALID_RESPONSE');
  22. token = body.access_token; tokenUntil = now() + (body.expires_in - 30) * 1000;
  23. return token;
  24. }
  25. const accountKey = (account) => {
  26. if (account?.platform !== 'wecom' || account.identifierType !== 'wecom_aibot_sender' || !text(account.scopeId) || !text(account.accountId)) return null;
  27. return hash(canonical(['wecom_aibot_sender', account.scopeId, account.accountId]));
  28. };
  29. function mapping(account) {
  30. if (!accountKey(account)) fail('WECOM_SENDER_IDENTITY_REQUIRED');
  31. if (config?.enabled !== true || !text(config.corpId) || !text(config.secret)) fail('WECOM_DIRECTORY_NOT_CONFIGURED');
  32. const bot = config.bots?.[account.scopeId];
  33. if (!bot || bot.mappingVerified !== true) fail('WECOM_SENDER_MAPPING_REQUIRED');
  34. // Never guess an ID type from its prefix or try a bot-scoped identifier against the directory.
  35. const userId = bot.senderIdType === 'directory_userid' ? account.accountId : bot.senderIdType === 'mapped' ? bot.userIds?.[account.accountId] : null;
  36. if (!text(userId)) fail('WECOM_SENDER_MAPPING_REQUIRED');
  37. return userId;
  38. }
  39. async function resolve(account) {
  40. const userId = mapping(account), cacheKey = accountKey(account);
  41. if (requests.has(cacheKey)) return requests.get(cacheKey);
  42. const pending = (async () => {
  43. const access = await accessToken();
  44. const user = await json('https://qyapi.weixin.qq.com/cgi-bin/user/get?' + new URLSearchParams({ access_token: access, userid: userId }));
  45. if (user.userid !== userId || !text(user.name) || user.status !== 1) fail('WECOM_DIRECTORY_MEMBER_UNAVAILABLE');
  46. let departmentNames = [];
  47. if (list(user.department).length) {
  48. try {
  49. const departments = await json('https://qyapi.weixin.qq.com/cgi-bin/department/list?' + new URLSearchParams({ access_token: access }));
  50. const byId = new Map(list(departments.department).map((d) => [d.id, text(d.name)]));
  51. departmentNames = list(user.department).map((id) => byId.get(id)).filter(Boolean);
  52. } catch { /* Optional department access must not hide a verified member name. */ }
  53. }
  54. return {
  55. version: 'ksf.wecom-employee.v1', key: 'employee:' + hash(canonical([config.corpId, userId])),
  56. name: text(user.name), displayName: text(user.alias) || text(user.name),
  57. departmentNames: [...new Set(departmentNames)], position: text(user.position),
  58. organizationName: text(config.organizationName), source: 'wecom_directory', nameVerified: true,
  59. verifiedAt: new Date(now()).toISOString(),
  60. };
  61. })();
  62. requests.set(cacheKey, pending);
  63. try { return await pending; } catch (e) { requests.delete(cacheKey); throw e; }
  64. }
  65. const expectedEmployeeKey = (account) => {
  66. try { return 'employee:' + hash(canonical([config.corpId, mapping(account)])); } catch { return null; }
  67. };
  68. return { accountKey, resolve, expectedEmployeeKey };
  69. }