aitable-discover.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. 'use strict';
  2. // 钉钉 AI 表格「操作者发现」脚本
  3. // ----------------------------------------------------------------------------
  4. // 背景:AI 表格(notable)所有接口必须在 query 上带 operatorId=操作人 unionId。
  5. // 企业应用的 token 推不出用户身份,unionId 要靠通讯录接口转换。
  6. // 前置:在钉钉开放平台给应用开通这几个权限点(第一次跑报错会给出精确的申请链接,
  7. // 点链接 → 开通 → 重跑本脚本即可):
  8. // · 通讯录部门信息读权限 qyapi_get_department_list
  9. // · 通讯录部门成员读权限 qyapi_get_department_member
  10. // · 通讯录个人信息读权限(v2/user/get 取 unionId 用)
  11. // 用法:node scripts/aitable-discover.js
  12. // 输出:每个成员的 unionId + 谁能读 config.json 里配置的 Base(★ 命中者即为操作者),
  13. // 把命中的 unionId 填入 config.json → aitable.operatorId。
  14. const fs = require('fs');
  15. const path = require('path');
  16. const https = require('https');
  17. const ROOT = path.join(__dirname, '..');
  18. const cfg = (() => {
  19. const raw = JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'), 'utf8'));
  20. const a = raw.aitable || {};
  21. if (!a.appKey || !a.appSecret || !a.baseId) {
  22. console.error('✗ config.json 缺 aitable.appKey / appSecret / baseId');
  23. process.exit(1);
  24. }
  25. return a;
  26. })();
  27. function call(url, { method = 'GET', headers = {}, body } = {}) {
  28. return new Promise((resolve, reject) => {
  29. const data = body ? JSON.stringify(body) : null;
  30. const h = { ...headers };
  31. if (data) { h['Content-Type'] = 'application/json'; h['Content-Length'] = Buffer.byteLength(data); }
  32. const req = https.request(url, { method, headers: h, timeout: 20000 }, (res) => {
  33. let buf = '';
  34. res.on('data', (c) => { buf += c; });
  35. res.on('end', () => {
  36. let json = null;
  37. try { json = JSON.parse(buf); } catch { /* 保留原文 */ }
  38. resolve({ status: res.statusCode, json });
  39. });
  40. });
  41. req.on('error', reject);
  42. req.on('timeout', () => req.destroy(new Error('请求超时')));
  43. if (data) req.write(data);
  44. req.end();
  45. });
  46. }
  47. function oldApi(pathQ, token, body) {
  48. return call(`https://oapi.dingtalk.com${pathQ}${pathQ.includes('?') ? '&' : '?'}access_token=${token}`, { method: 'POST', body });
  49. }
  50. function permissionHint(json) {
  51. const m = json && typeof json.sub_msg === 'string' ? json.sub_msg : '';
  52. const link = /https:\S+/.exec(m);
  53. return link ? `\n → 点这个链接开通权限后重跑:${link[0]}` : '';
  54. }
  55. (async () => {
  56. console.log('⊙ 前置权限:请先在钉钉开放平台给应用开通以下三个权限点(点击链接 → 申请开通):');
  57. for (const scope of ['qyapi_get_department_list', 'qyapi_get_department_member', 'qyapi_get_member_detail']) {
  58. console.log(` · ${scope}`);
  59. console.log(` https://open-dev.dingtalk.com/appscope/apply?content=${encodeURIComponent(cfg.appKey)}%23${scope}`);
  60. }
  61. console.log('');
  62. const t = await call('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', body: { appKey: cfg.appKey, appSecret: cfg.appSecret } });
  63. if (t.status !== 200 || !t.json.accessToken) { console.error('✗ 获取 token 失败', t.json); process.exit(1); }
  64. const T = t.json.accessToken;
  65. console.log('✓ token OK');
  66. // 1) 部门:根部门 + 子部门
  67. const depts = [1];
  68. const r1 = await oldApi('/topapi/v2/department/listsub', T, { dept_id: 1 });
  69. if (r1.json.errcode !== 0) { console.error(`✗ 拉部门失败 errcode=${r1.json.errcode}${permissionHint(r1.json)}`, r1.json.errmsg || ''); process.exit(1); }
  70. for (const d of r1.json.result || []) depts.push(d.dept_id);
  71. console.log(`✓ 部门 ${depts.length} 个`);
  72. // 2) 成员
  73. const userIds = [];
  74. for (const d of depts) {
  75. const r = await oldApi('/topapi/v2/user/list', T, { dept_id: d, cursor: 0, size: 50 });
  76. if (r.json.errcode !== 0) { console.error(`✗ 拉成员失败 dept=${d} errcode=${r.json.errcode}${permissionHint(r.json)}`); process.exit(1); }
  77. for (const u of (r.json.result && r.json.result.list) || []) if (!userIds.includes(u.userid)) userIds.push(u.userid);
  78. }
  79. console.log(`✓ 成员 ${userIds.length} 人`);
  80. // 3) unionId
  81. const cands = [];
  82. for (const uid of userIds) {
  83. const r = await oldApi('/topapi/v2/user/get', T, { userid: uid });
  84. if (r.json.errcode !== 0) { console.error(`✗ 用户详情失败 ${uid} errcode=${r.json.errcode}${permissionHint(r.json)}`); process.exit(1); }
  85. const u = r.json.result || {};
  86. cands.push({ name: u.name, userid: uid, unionid: u.unionid, admin: !!u.admin });
  87. }
  88. // 4) 谁能读这个 Base
  89. let hit = null;
  90. for (const c of cands) {
  91. const res = await call(`https://api.dingtalk.com/v1.0/notable/bases/${encodeURIComponent(cfg.baseId)}/sheets?operatorId=${encodeURIComponent(c.unionid)}`,
  92. { headers: { 'x-acs-dingtalk-access-token': T } });
  93. const ok = res.status === 200;
  94. const sheets = ok && Array.isArray(res.json.value) ? res.json.value.length : (ok ? '?' : '');
  95. console.log(` ${ok ? '★' : '×'} ${c.name}${c.admin ? ' [管理员]' : ''} unionid=${c.unionid} 读Base=${ok}${ok ? ` 数据表数=${sheets}` : ` HTTP ${res.status}`}`);
  96. if (ok && !hit) hit = c;
  97. }
  98. if (hit) {
  99. console.log(`\n★ 操作者建议:${hit.name} unionid=${hit.unionid}`);
  100. console.log(` 写入方法:config.json → "aitable" 增加/修改 "operatorId": "${hit.unionid}"`);
  101. if (hit.admin) console.log(' (该账号是管理员,推荐用管理员身份做系统级推送)');
  102. } else {
  103. console.log('\n✗ 没有人能读这个 Base:请确认 baseId 正确、且 AI 表格已分享给上述某个成员');
  104. }
  105. })().catch((e) => { console.error('FATAL', e.message); process.exit(1); });