voc-token-preflight.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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. 选择套餐扫码支付(最低 ¥0.01 体验 1 次)',
  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`, { 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. // Step 3: 查询 APIGAuth 余额
  166. const paymentUrl = buildPaymentUrl(user.objectId);
  167. let balance = 0;
  168. let authId = null;
  169. try {
  170. const where = encodeURIComponent(JSON.stringify({
  171. user: { __type: 'Pointer', className: '_User', objectId: user.objectId },
  172. api: { __type: 'Pointer', className: 'APIG', objectId: apigId }
  173. }));
  174. const resp = await fetch(`${PARSE_BASE}/classes/APIGAuth?where=${where}&limit=1`, { headers });
  175. const data = await resp.json();
  176. if (data.results && data.results.length > 0) {
  177. const record = data.results[0];
  178. balance = record.count || 0;
  179. authId = record.objectId;
  180. log(`💰 APIGAuth: ${authId}, 余额: ${balance} 次, 已用: ${record.used || 0} 次`);
  181. } else {
  182. log(`ℹ️ APIGAuth 记录不存在 (用户从未开通此 APIG)`);
  183. }
  184. } catch (e) {
  185. log(`⚠️ APIGAuth 查询失败: ${e.message}`);
  186. }
  187. if (balance <= 0) {
  188. log(`❌ 余额不足,需要充值`);
  189. return emit({
  190. status: 'insufficient_balance',
  191. userId: user.objectId,
  192. username: user.username || null,
  193. authId,
  194. balance,
  195. paymentUrl,
  196. displayMessage: buildDisplayMessage('insufficient_balance', {
  197. userId: user.objectId, balance, paymentUrl
  198. }),
  199. nextAction: 'showPaymentAndWait',
  200. apigId,
  201. timestamp: new Date().toISOString()
  202. });
  203. }
  204. log(`✅ 预飞通过,可进入 Session 1`);
  205. return emit({
  206. status: 'valid',
  207. userId: user.objectId,
  208. username: user.username || null,
  209. authId,
  210. balance,
  211. paymentUrl,
  212. displayMessage: buildDisplayMessage('valid', {
  213. userId: user.objectId, balance, paymentUrl
  214. }),
  215. nextAction: 'proceed',
  216. apigId,
  217. timestamp: new Date().toISOString()
  218. });
  219. })().catch(e => {
  220. log(`💥 未预期错误: ${e.stack || e.message}`);
  221. emit({
  222. status: 'error',
  223. userId: null,
  224. balance: 0,
  225. paymentUrl: buildPaymentUrl(),
  226. displayMessage: buildDisplayMessage('error', {}),
  227. nextAction: 'retry',
  228. apigId,
  229. error: e.message,
  230. timestamp: new Date().toISOString()
  231. });
  232. });