'use strict'; // 钉钉 AI 表格「操作者发现」脚本 // ---------------------------------------------------------------------------- // 背景:AI 表格(notable)所有接口必须在 query 上带 operatorId=操作人 unionId。 // 企业应用的 token 推不出用户身份,unionId 要靠通讯录接口转换。 // 前置:在钉钉开放平台给应用开通这几个权限点(第一次跑报错会给出精确的申请链接, // 点链接 → 开通 → 重跑本脚本即可): // · 通讯录部门信息读权限 qyapi_get_department_list // · 通讯录部门成员读权限 qyapi_get_department_member // · 通讯录个人信息读权限(v2/user/get 取 unionId 用) // 用法:node scripts/aitable-discover.js // 输出:每个成员的 unionId + 谁能读 config.json 里配置的 Base(★ 命中者即为操作者), // 把命中的 unionId 填入 config.json → aitable.operatorId。 const fs = require('fs'); const path = require('path'); const https = require('https'); const ROOT = path.join(__dirname, '..'); const cfg = (() => { const raw = JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'), 'utf8')); const a = raw.aitable || {}; if (!a.appKey || !a.appSecret || !a.baseId) { console.error('✗ config.json 缺 aitable.appKey / appSecret / baseId'); process.exit(1); } return a; })(); function call(url, { method = 'GET', headers = {}, body } = {}) { return new Promise((resolve, reject) => { const data = body ? JSON.stringify(body) : null; const h = { ...headers }; if (data) { h['Content-Type'] = 'application/json'; h['Content-Length'] = Buffer.byteLength(data); } const req = https.request(url, { method, headers: h, timeout: 20000 }, (res) => { let buf = ''; res.on('data', (c) => { buf += c; }); res.on('end', () => { let json = null; try { json = JSON.parse(buf); } catch { /* 保留原文 */ } resolve({ status: res.statusCode, json }); }); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('请求超时'))); if (data) req.write(data); req.end(); }); } function oldApi(pathQ, token, body) { return call(`https://oapi.dingtalk.com${pathQ}${pathQ.includes('?') ? '&' : '?'}access_token=${token}`, { method: 'POST', body }); } function permissionHint(json) { const m = json && typeof json.sub_msg === 'string' ? json.sub_msg : ''; const link = /https:\S+/.exec(m); return link ? `\n → 点这个链接开通权限后重跑:${link[0]}` : ''; } (async () => { console.log('⊙ 前置权限:请先在钉钉开放平台给应用开通以下三个权限点(点击链接 → 申请开通):'); for (const scope of ['qyapi_get_department_list', 'qyapi_get_department_member', 'qyapi_get_member_detail']) { console.log(` · ${scope}`); console.log(` https://open-dev.dingtalk.com/appscope/apply?content=${encodeURIComponent(cfg.appKey)}%23${scope}`); } console.log(''); const t = await call('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', body: { appKey: cfg.appKey, appSecret: cfg.appSecret } }); if (t.status !== 200 || !t.json.accessToken) { console.error('✗ 获取 token 失败', t.json); process.exit(1); } const T = t.json.accessToken; console.log('✓ token OK'); // 1) 部门:根部门 + 子部门 const depts = [1]; const r1 = await oldApi('/topapi/v2/department/listsub', T, { dept_id: 1 }); if (r1.json.errcode !== 0) { console.error(`✗ 拉部门失败 errcode=${r1.json.errcode}${permissionHint(r1.json)}`, r1.json.errmsg || ''); process.exit(1); } for (const d of r1.json.result || []) depts.push(d.dept_id); console.log(`✓ 部门 ${depts.length} 个`); // 2) 成员 const userIds = []; for (const d of depts) { const r = await oldApi('/topapi/v2/user/list', T, { dept_id: d, cursor: 0, size: 50 }); if (r.json.errcode !== 0) { console.error(`✗ 拉成员失败 dept=${d} errcode=${r.json.errcode}${permissionHint(r.json)}`); process.exit(1); } for (const u of (r.json.result && r.json.result.list) || []) if (!userIds.includes(u.userid)) userIds.push(u.userid); } console.log(`✓ 成员 ${userIds.length} 人`); // 3) unionId const cands = []; for (const uid of userIds) { const r = await oldApi('/topapi/v2/user/get', T, { userid: uid }); if (r.json.errcode !== 0) { console.error(`✗ 用户详情失败 ${uid} errcode=${r.json.errcode}${permissionHint(r.json)}`); process.exit(1); } const u = r.json.result || {}; cands.push({ name: u.name, userid: uid, unionid: u.unionid, admin: !!u.admin }); } // 4) 谁能读这个 Base let hit = null; for (const c of cands) { const res = await call(`https://api.dingtalk.com/v1.0/notable/bases/${encodeURIComponent(cfg.baseId)}/sheets?operatorId=${encodeURIComponent(c.unionid)}`, { headers: { 'x-acs-dingtalk-access-token': T } }); const ok = res.status === 200; const sheets = ok && Array.isArray(res.json.value) ? res.json.value.length : (ok ? '?' : ''); console.log(` ${ok ? '★' : '×'} ${c.name}${c.admin ? ' [管理员]' : ''} unionid=${c.unionid} 读Base=${ok}${ok ? ` 数据表数=${sheets}` : ` HTTP ${res.status}`}`); if (ok && !hit) hit = c; } if (hit) { console.log(`\n★ 操作者建议:${hit.name} unionid=${hit.unionid}`); console.log(` 写入方法:config.json → "aitable" 增加/修改 "operatorId": "${hit.unionid}"`); if (hit.admin) console.log(' (该账号是管理员,推荐用管理员身份做系统级推送)'); } else { console.log('\n✗ 没有人能读这个 Base:请确认 baseId 正确、且 AI 表格已分享给上述某个成员'); } })().catch((e) => { console.error('FATAL', e.message); process.exit(1); });