voc-token-preflight.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. #!/usr/bin/env node
  2. /**
  3. * VOC Token Preflight v1
  4. *
  5. * 检测 VOC-AI token 状态,供 OpenClaw Workshop 在进入 Session 1 之前
  6. * 强制调用,确保 token 有效、余额充足,否则返回可直接展示给用户的
  7. * Markdown 支付引导文案 + 专属充值链接。
  8. *
  9. * 用法:
  10. * node voc-token-preflight.js [apigId] [--json]
  11. *
  12. * 参数:
  13. * apigId 要检测的 APIG objectId。缺省 7HwdQZk55B(voc-ecom,覆盖 Session 1 的 category-landscape 等付费 skill)。
  14. * --json 只输出 JSON 到 stdout(静默 stderr 日志)。适合被其他脚本 pipe 解析。
  15. *
  16. * 输出(stdout 最后一行):
  17. * PREFLIGHT_RESULT={"status":"valid|missing|expired|insufficient_balance|error", ...}
  18. *
  19. * 退出码:
  20. * 0 - valid
  21. * 1 - missing / expired / insufficient_balance(预期内的业务状态)
  22. * 2 - error(网络或未预期错误)
  23. */
  24. const fs = require('fs');
  25. const os = require('os');
  26. const path = require('path');
  27. const PARSE_BASE = 'https://server.fmode.cn/parse';
  28. const APP_ID = 'ncloudmaster';
  29. const APIG_PAY_BASE = 'https://app.fmode.cn/dev/apig-pay/';
  30. const FUN_ID = 'HOkkX72PMF';
  31. const args = process.argv.slice(2);
  32. const apigId = args.find(a => !a.startsWith('--')) || '7HwdQZk55B';
  33. const jsonOnly = args.includes('--json');
  34. function log(...msg) {
  35. if (!jsonOnly) console.error(...msg);
  36. }
  37. function buildPaymentUrl(userId) {
  38. const params = new URLSearchParams();
  39. if (userId) params.set('user', userId);
  40. params.set('apigid', apigId);
  41. params.set('fun_id', FUN_ID);
  42. return `${APIG_PAY_BASE}?${params.toString()}`;
  43. }
  44. function buildDisplayMessage(status, ctx) {
  45. const { userId, balance, paymentUrl } = ctx;
  46. switch (status) {
  47. case 'valid':
  48. return [
  49. '🔐 VOC-AI 服务已就绪',
  50. ` 👤 账号 ${userId}`,
  51. ` 💰 余额 ${balance} 次`
  52. ].join('\n');
  53. case 'missing':
  54. case 'expired':
  55. return [
  56. '🔐 需要开通 VOC-AI 数据服务',
  57. '━━━━━━━━━━━━━━━━━━━━',
  58. `👉 点击开通:${paymentUrl}`,
  59. '',
  60. '📋 操作步骤:',
  61. '1. 打开上方链接完成手机号登录',
  62. '2. 选择套餐扫码支付',
  63. '3. 支付成功后,点击页面顶部"📋 复制 Token"',
  64. '4. 将 token 粘贴到对话框发给我',
  65. '',
  66. '⏸️ 我会等你,粘过来就能继续。'
  67. ].join('\n');
  68. case 'insufficient_balance':
  69. return [
  70. `💰 VOC-AI 余额不足(当前 ${balance} 次)`,
  71. '━━━━━━━━━━━━━━━━━━━━',
  72. `👉 专属续费:${paymentUrl}`,
  73. '',
  74. '扫码支付后自动激活,无需重新登录。'
  75. ].join('\n');
  76. case 'error':
  77. return `⚠️ Token 预飞遇到网络/系统错误,请稍后重试。`;
  78. default:
  79. return `⚠️ 未知状态: ${status}`;
  80. }
  81. }
  82. function emit(result) {
  83. if (jsonOnly) {
  84. console.log(JSON.stringify(result, null, 2));
  85. } else {
  86. console.log(`PREFLIGHT_RESULT=${JSON.stringify(result)}`);
  87. }
  88. if (result.status === 'valid') process.exit(0);
  89. if (result.status === 'error') process.exit(2);
  90. process.exit(1);
  91. }
  92. (async () => {
  93. log('══════════════════════════════════════════════');
  94. log(' VOC Token Preflight v1');
  95. log(` APIG: ${apigId}`);
  96. log('══════════════════════════════════════════════');
  97. // Step 1: 读取本地 token
  98. const configPath = path.join(os.homedir(), '.openclaw', 'voc-credentials.json');
  99. let token = null;
  100. if (fs.existsSync(configPath)) {
  101. try {
  102. const raw = fs.readFileSync(configPath, 'utf-8').trim();
  103. const config = raw ? JSON.parse(raw) : {};
  104. token = config.vocToken;
  105. } catch (e) {
  106. log(`⚠️ 读取 voc-credentials.json 失败: ${e.message}`);
  107. }
  108. }
  109. if (!token) {
  110. log('❌ 未找到 vocToken (voc-credentials.json 不存在或为空)');
  111. const paymentUrl = buildPaymentUrl();
  112. return emit({
  113. status: 'missing',
  114. userId: null,
  115. balance: 0,
  116. paymentUrl,
  117. displayMessage: buildDisplayMessage('missing', { paymentUrl }),
  118. nextAction: 'showPaymentAndWait',
  119. apigId,
  120. timestamp: new Date().toISOString()
  121. });
  122. }
  123. log(`✅ Token: ${token.slice(0, 6)}...${token.slice(-4)}`);
  124. // Step 2: /users/me 校验
  125. const headers = {
  126. 'Content-Type': 'application/json',
  127. 'X-Parse-Application-Id': APP_ID,
  128. 'X-Parse-Session-Token': token
  129. };
  130. let user;
  131. try {
  132. const resp = await fetch(`${PARSE_BASE}/users/me?include=company`, { headers });
  133. user = await resp.json();
  134. if (!resp.ok || !user.objectId) {
  135. log(`❌ Token 失效: ${user.error || user.message || JSON.stringify(user)}`);
  136. const paymentUrl = buildPaymentUrl();
  137. return emit({
  138. status: 'expired',
  139. userId: null,
  140. balance: 0,
  141. paymentUrl,
  142. displayMessage: buildDisplayMessage('expired', { paymentUrl }),
  143. nextAction: 'showPaymentAndWait',
  144. apigId,
  145. parseError: user.error || user.message || null,
  146. timestamp: new Date().toISOString()
  147. });
  148. }
  149. } catch (e) {
  150. log(`⚠️ 网络错误: ${e.message}`);
  151. return emit({
  152. status: 'error',
  153. userId: null,
  154. balance: 0,
  155. paymentUrl: buildPaymentUrl(),
  156. displayMessage: buildDisplayMessage('error', {}),
  157. nextAction: 'retry',
  158. apigId,
  159. error: e.message,
  160. timestamp: new Date().toISOString()
  161. });
  162. }
  163. log(`✅ Token 有效`);
  164. log(`👤 用户: ${user.username || user.objectId} (${user.objectId})`);
  165. const userCompanyId = user.company?.objectId || null;
  166. if (userCompanyId) log(`🏢 Company: ${userCompanyId}`);
  167. // Step 3: 查询 APIGAuth 余额
  168. const paymentUrl = buildPaymentUrl(user.objectId);
  169. let balance = 0;
  170. let authId = null;
  171. let authCompanyId = null;
  172. try {
  173. const where = encodeURIComponent(JSON.stringify({
  174. user: { __type: 'Pointer', className: '_User', objectId: user.objectId },
  175. api: { __type: 'Pointer', className: 'APIG', objectId: apigId }
  176. }));
  177. const resp = await fetch(`${PARSE_BASE}/classes/APIGAuth?where=${where}&limit=1`, { headers });
  178. const data = await resp.json();
  179. if (data.results && data.results.length > 0) {
  180. const record = data.results[0];
  181. balance = record.count || 0;
  182. authId = record.objectId;
  183. authCompanyId = record.company?.objectId || null;
  184. log(`💰 APIGAuth: ${authId}, 余额: ${balance} 次, 已用: ${record.used || 0} 次`);
  185. if (userCompanyId && authCompanyId !== userCompanyId) {
  186. log(`⚠️ APIGAuth Company 不匹配: ${authCompanyId || '(未绑定)'} !== ${userCompanyId}`);
  187. }
  188. } else {
  189. log(`ℹ️ APIGAuth 记录不存在 (用户从未开通此 APIG)`);
  190. }
  191. } catch (e) {
  192. log(`⚠️ APIGAuth 查询失败: ${e.message}`);
  193. }
  194. if (balance <= 0) {
  195. log(`❌ 余额不足,需要充值`);
  196. return emit({
  197. status: 'insufficient_balance',
  198. userId: user.objectId,
  199. username: user.username || null,
  200. authId,
  201. balance,
  202. paymentUrl,
  203. displayMessage: buildDisplayMessage('insufficient_balance', {
  204. userId: user.objectId, balance, paymentUrl
  205. }),
  206. nextAction: 'showPaymentAndWait',
  207. apigId,
  208. timestamp: new Date().toISOString()
  209. });
  210. }
  211. if (userCompanyId && authCompanyId !== userCompanyId) {
  212. const currentAuthCompany = authCompanyId || null;
  213. return emit({
  214. status: 'auth_company_mismatch',
  215. userId: user.objectId,
  216. username: user.username || null,
  217. authId,
  218. balance,
  219. userCompanyId,
  220. authCompanyId: currentAuthCompany,
  221. paymentUrl,
  222. displayMessage: [
  223. '⚠️ VOC-AI 服务余额存在,但社媒网关可能仍会 403。',
  224. ` 用户 Company: ${userCompanyId}`,
  225. ` APIGAuth Company: ${currentAuthCompany || '(未绑定)'}`,
  226. ' 下一步:修复 APIGAuth.company 绑定,或部署后端 user/company OR 查询后再重试。'
  227. ].join('\n'),
  228. nextAction: 'repairApigAuthCompanyBinding',
  229. apigId,
  230. timestamp: new Date().toISOString()
  231. });
  232. }
  233. log(`✅ 预飞通过,可进入 Session 1`);
  234. return emit({
  235. status: 'valid',
  236. userId: user.objectId,
  237. username: user.username || null,
  238. authId,
  239. balance,
  240. paymentUrl,
  241. displayMessage: buildDisplayMessage('valid', {
  242. userId: user.objectId, balance, paymentUrl
  243. }),
  244. nextAction: 'proceed',
  245. apigId,
  246. timestamp: new Date().toISOString()
  247. });
  248. })().catch(e => {
  249. log(`💥 未预期错误: ${e.stack || e.message}`);
  250. emit({
  251. status: 'error',
  252. userId: null,
  253. balance: 0,
  254. paymentUrl: buildPaymentUrl(),
  255. displayMessage: buildDisplayMessage('error', {}),
  256. nextAction: 'retry',
  257. apigId,
  258. error: e.message,
  259. timestamp: new Date().toISOString()
  260. });
  261. });