|
|
@@ -0,0 +1,118 @@
|
|
|
+'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); });
|