qiwei-login-run.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. const fs = require('fs');
  2. const path = require('path');
  3. const { spawn } = require('child_process');
  4. const {
  5. readQiweiAuthToken,
  6. ensureQiweiUid,
  7. readQiweiApiBase,
  8. saveQiweiClientConfig,
  9. readQiweiGuid
  10. } = require('../core/credentials');
  11. const { callFmodeWecomGateway, redactSecret } = require('../providers/fmode-wecom-gateway');
  12. const { okResult, errorResult } = require('../core/result-envelope');
  13. const { latestPath } = require('../core/output-paths');
  14. const { QIWEI_MONTHLY_PRICE } = require('../core/subscribe-page');
  15. const { startLoginFlowServer } = require('../core/login-flow-server');
  16. const { startLoginFallbackServer } = require('../core/login-fallback-server');
  17. const { getProductMode } = require('../core/product-mode');
  18. const { recordDeviceGuid } = require('../core/device-broker-mapping');
  19. const { ensureRelayWebhookConfigured, registerRelayTenant } = require('./qiwei-webhook-relay-run');
  20. const QR_STATUS = {
  21. '-1': '未登录,需要扫码登录',
  22. '0': '未登录,可尝试免扫码登录',
  23. '1': '已扫码,等待手机端确认',
  24. '2': '登录成功',
  25. '4': '用户在手机端取消了登录',
  26. '10': '已扫码确认,需要输入手机端 6 位验证码'
  27. };
  28. function authRequiredResult() {
  29. return {
  30. status: 'needs_auth',
  31. assistantMessage: [
  32. '还没有找到 Fmode 鉴权 token,无法调用由 Fmode 网关转发的企业微信接口。',
  33. '优先复用 Claude Code 已配置的 Fmode NewAPI sk- token;也可配置 QIWEI_AUTH_TOKEN、FMODE_API_KEY 或平台 sessionToken。',
  34. '企业微信接口访问凭据和设备上下文由 Fmode 网关管理,技能不会读取或保存。'
  35. ].join('\n'),
  36. summary: { configured: false, errorKind: 'missing_auth_token', recoverable: true },
  37. data: {},
  38. files: [],
  39. nextActions: ['配置 Fmode 鉴权 token 后重试'],
  40. warnings: [],
  41. errors: []
  42. };
  43. }
  44. async function ensureRelayAndCallback({ token, apiBase, uid, guid, input }) {
  45. if (!guid) {
  46. throw new Error('缺少 guid,无法配置 Relay 回调');
  47. }
  48. // 如果还没有租户凭证,先注册
  49. const relayCreds = require('../core/relay-config');
  50. if (!relayCreds.isRelayEnabled()) {
  51. try {
  52. await registerRelayTenant({ ...input, guid, description: '本地 Skill 自动注册' });
  53. } catch (err) {
  54. // 如果已经注册且 force 未开启,可能失败;继续尝试用本地已有凭证配置回调
  55. if (!relayCreds.getTenantId() || !relayCreds.getTenantApiSecret()) {
  56. throw err;
  57. }
  58. }
  59. }
  60. return ensureRelayWebhookConfigured({ ...input, guid, authToken: token, apiBase, uid });
  61. }
  62. function saveQrcodeImage(base64Data) {
  63. try {
  64. const filePath = latestPath('login', 'qiwei-login-qrcode.png');
  65. const data = String(base64Data || '').replace(/^data:image\/\w+;base64,/, '');
  66. fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
  67. return filePath;
  68. } catch {
  69. return '';
  70. }
  71. }
  72. function saveQrcodePreviewHtml(qrcodeImagePath) {
  73. try {
  74. const htmlPath = latestPath('login', 'qiwei-login-qrcode.html');
  75. const html = [
  76. '<!DOCTYPE html>',
  77. '<html lang="zh-CN">',
  78. '<head><meta charset="utf-8"><title>企业微信扫码登录</title>',
  79. '<style>body{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f6f7}img{width:280px;height:280px;border:1px solid #e0e0e0;border-radius:8px;background:#fff;padding:12px}h1{font-size:20px;color:#333}p{color:#666;font-size:14px}</style>',
  80. '</head>',
  81. '<body>',
  82. '<h1>请用手机企业微信扫码登录</h1>',
  83. `<img src="${path.basename(qrcodeImagePath)}" alt="登录二维码">`,
  84. '<p>扫码后请在手机上确认登录。二维码约 4 分钟内有效,过期请重新生成。</p>',
  85. '</body></html>'
  86. ].join('\n');
  87. fs.writeFileSync(htmlPath, html, 'utf8');
  88. return htmlPath;
  89. } catch {
  90. return '';
  91. }
  92. }
  93. function openInBrowser(target) {
  94. try {
  95. const platform = process.platform;
  96. let cmd;
  97. let args;
  98. if (platform === 'win32') {
  99. cmd = 'cmd';
  100. args = ['/c', 'start', '', target];
  101. } else if (platform === 'darwin') {
  102. cmd = 'open';
  103. args = [target];
  104. } else {
  105. cmd = 'xdg-open';
  106. args = [target];
  107. }
  108. const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
  109. child.unref();
  110. return true;
  111. } catch {
  112. return false;
  113. }
  114. }
  115. function gatewayErrorResult(error, stage, fallbackMessage) {
  116. const safeMessage = redactSecret(error && (error.bizMessage || error.message));
  117. const kind = String((error && error.kind) || 'upstream');
  118. if (kind === 'auth') {
  119. return {
  120. ...authRequiredResult(),
  121. assistantMessage: `Fmode 鉴权失败(${safeMessage})。请更新鉴权 token 后重试。`,
  122. summary: { stage, errorKind: kind, recoverable: true }
  123. };
  124. }
  125. if (kind === 'billing') {
  126. return {
  127. status: 'needs_subscription',
  128. assistantMessage: `企微订阅未开通、已到期或飞马余额不足(${safeMessage})。请先调用 qiwei_subscription_status,必要时用 qiwei_subscribe 开通或续费。`,
  129. summary: { stage, errorKind: kind, recoverable: true },
  130. data: {},
  131. files: [],
  132. nextActions: ['调用 qiwei_subscription_status', '调用 qiwei_subscribe 开通或续费'],
  133. warnings: [],
  134. errors: []
  135. };
  136. }
  137. if (kind === 'permission') {
  138. return {
  139. status: 'needs_seat',
  140. assistantMessage: `企微席位或权限不足(${safeMessage})。请查看订阅状态并增购席位后重试。`,
  141. summary: { stage, errorKind: kind, recoverable: true },
  142. data: {},
  143. files: [],
  144. nextActions: ['调用 qiwei_subscription_status', '调用 qiwei_subscribe 增购席位'],
  145. warnings: [],
  146. errors: []
  147. };
  148. }
  149. return errorResult(`${fallbackMessage}:${safeMessage}`, {
  150. summary: { stage, errorKind: kind },
  151. nextActions: ['稍后重试', '若持续失败,请检查 Fmode 网关的企业微信接口状态']
  152. });
  153. }
  154. function loginBody(uid, guid, extra = {}) {
  155. return { uid, ...(guid ? { guid } : {}), ...extra };
  156. }
  157. async function qiweiLoginStatus(input = {}) {
  158. const token = readQiweiAuthToken(input);
  159. if (!token) return authRequiredResult();
  160. const uid = ensureQiweiUid(input);
  161. const guid = readQiweiGuid(input);
  162. const apiBase = readQiweiApiBase(input);
  163. try {
  164. const result = await callFmodeWecomGateway({
  165. gatewayPath: '/login/status',
  166. httpMethod: 'GET',
  167. query: { uid, ...(guid ? { guid } : {}) },
  168. token,
  169. apiBase
  170. });
  171. const data = result.data || {};
  172. const online = Boolean(data.online);
  173. const detail = data.detail || {};
  174. if (online && detail.userId && input.persistConfig !== false) {
  175. const resolvedGuid = String(detail.guid || guid || '').trim();
  176. saveQiweiClientConfig({
  177. uid,
  178. guid: resolvedGuid,
  179. apiBase,
  180. userId: detail.userId,
  181. nickname: detail.nickname,
  182. corpName: detail.corpName,
  183. });
  184. if (resolvedGuid) recordDeviceGuid(resolvedGuid, { wecomUserId: detail.userId, nickname: detail.nickname });
  185. }
  186. return okResult({
  187. assistantMessage: data.configured
  188. ? online
  189. ? `企业微信设备在线(uid: ${uid}),可以直接调用业务接口。`
  190. : `企业微信设备当前不在线(状态 ${data.statusCode ?? '未知'}),请调用 qiwei_login_start 重新登录。`
  191. : `当前 uid(${uid})尚未配置企业微信设备,请调用 qiwei_login_start 开始扫码登录。`,
  192. summary: {
  193. configured: Boolean(data.configured),
  194. online,
  195. uid,
  196. statusCode: data.statusCode ?? null
  197. },
  198. data: {
  199. uid,
  200. configured: Boolean(data.configured),
  201. online,
  202. statusCode: data.statusCode ?? null,
  203. detail
  204. },
  205. nextActions: online ? [] : ['调用 qiwei_login_start 开始扫码登录']
  206. });
  207. } catch (error) {
  208. return gatewayErrorResult(error, 'loginStatus', '查询登录状态失败');
  209. }
  210. }
  211. function subscriptionRequiredResult({ uid, detail }) {
  212. return {
  213. status: 'needs_subscription',
  214. assistantMessage: [
  215. `登录前需要先开通企微包月订阅:每个账号 ¥${QIWEI_MONTHLY_PRICE}/月,从飞马余额扣费。`,
  216. '为保护鉴权凭据,不再生成包含请求凭据的静态付费页面。请重新调用 qiwei_login_start 使用默认动态流程页,或直接调用 qiwei_subscribe 开通。',
  217. '开通成功后重新调用 qiwei_login_start 进入扫码登录(第 2 步)。',
  218. detail ? `订阅状态:${detail}` : ''
  219. ].filter(Boolean).join('\n'),
  220. summary: { stage: 'loginStart', errorKind: 'subscription_required', uid, dynamicFlowRequired: true, monthlyPrice: QIWEI_MONTHLY_PRICE },
  221. data: { uid },
  222. files: [],
  223. nextActions: ['重新调用 qiwei_login_start 使用默认动态流程页(或调用 qiwei_subscribe)', '开通后继续扫码登录'],
  224. warnings: [],
  225. errors: []
  226. };
  227. }
  228. async function qiweiLoginStart(input = {}) {
  229. const token = readQiweiAuthToken(input);
  230. if (!token) return authRequiredResult();
  231. const uid = ensureQiweiUid(input);
  232. const guid = readQiweiGuid(input);
  233. const apiBase = readQiweiApiBase(input);
  234. if (input.persistConfig !== false) saveQiweiClientConfig({ uid, apiBase });
  235. if (input.flowUi !== false) {
  236. try {
  237. const { url } = await startLoginFlowServer({
  238. token,
  239. apiBase,
  240. uid,
  241. guid,
  242. port: input.flowPort,
  243. onQrcode: (buffer) => {
  244. try {
  245. fs.writeFileSync(latestPath('login', 'qiwei-login-qrcode.png'), buffer);
  246. } catch {}
  247. }
  248. });
  249. const opened = input.openBrowser === false ? false : openInBrowser(url);
  250. return okResult({
  251. assistantMessage: [
  252. `已启动企微登录流程页服务:${url}${opened ? '(已自动打开浏览器)' : ''}。`,
  253. `页面会自动完成两步流程:未开通订阅时先展示套餐页(每号 ¥${QIWEI_MONTHLY_PRICE}/月,飞马余额扣费),开通后自动生成二维码并每 3 秒自动检测扫码状态,需要验证码时页面会提示输入。`,
  254. '也可以用 qiwei_login_check 在对话中轮询登录状态。'
  255. ].join('\n'),
  256. summary: { uid, flowUrl: url, browserOpened: Boolean(opened), monthlyPrice: QIWEI_MONTHLY_PRICE },
  257. data: { uid, flowUrl: url, browserOpened: Boolean(opened) },
  258. nextActions: ['引导用户在流程页完成开通与扫码', '用 qiwei_login_check 轮询登录状态']
  259. });
  260. } catch (error) {
  261. // 流程页服务启动失败时回退到静态二维码流程
  262. }
  263. }
  264. if (input.skipSubscriptionCheck !== true) {
  265. try {
  266. const statusResult = await callFmodeWecomGateway({
  267. gatewayPath: '/subscribe/status',
  268. httpMethod: 'GET',
  269. token,
  270. apiBase,
  271. cacheBust: true
  272. });
  273. const sub = statusResult.data || {};
  274. if (!sub.subscribed) {
  275. return subscriptionRequiredResult({ uid, detail: '尚未开通包月订阅' });
  276. }
  277. } catch (error) {
  278. if (error && (error.kind === 'billing' || error.httpStatus === 402)) {
  279. return subscriptionRequiredResult({ uid, detail: redactSecret(error.message) });
  280. }
  281. if (error && error.kind === 'auth') return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败');
  282. // 订阅状态接口不可用时不阻断登录,由 /login/start 自身订阅校验兑底
  283. }
  284. }
  285. try {
  286. const result = await callFmodeWecomGateway({
  287. gatewayPath: '/login/start',
  288. body: loginBody(uid, guid),
  289. token,
  290. apiBase
  291. });
  292. const data = result.data || {};
  293. const qrcodeBase64 = String(data.loginQrcodeBase64Data || '').replace(/^data:image\/\w+;base64,/, '');
  294. const qrcodeBuffer = qrcodeBase64 ? Buffer.from(qrcodeBase64, 'base64') : null;
  295. // 优先启动可交互的 fallback 本地服务,让用户能在网页里直接输入验证码
  296. if (qrcodeBuffer) {
  297. try {
  298. const { url } = await startLoginFallbackServer({
  299. token,
  300. apiBase,
  301. uid,
  302. guid,
  303. qrcodeBuffer,
  304. port: 0,
  305. onQrcode: (buffer) => {
  306. try {
  307. fs.writeFileSync(latestPath('login', 'qiwei-login-qrcode.png'), buffer);
  308. } catch {}
  309. }
  310. });
  311. const opened = input.openBrowser === false ? false : openInBrowser(url);
  312. return okResult({
  313. assistantMessage: [
  314. `企业微信登录二维码已由 Fmode 网关生成(uid: ${uid})。`,
  315. opened
  316. ? `已自动打开扫码登录页:${url}`
  317. : `请打开扫码登录页:${url}`,
  318. '页面会自动检测扫码状态;若手机端需要验证码,可直接在页面输入 6 位数字。'
  319. ].join('\n'),
  320. summary: {
  321. uid,
  322. flowUrl: url,
  323. fallbackServer: true,
  324. browserOpened: opened
  325. },
  326. data: {
  327. uid,
  328. flowUrl: url,
  329. fallbackServer: true,
  330. browserOpened: opened
  331. },
  332. files: [],
  333. nextActions: ['在页面中完成扫码与验证码输入', '调用 qiwei_login_check 确认状态']
  334. });
  335. } catch {
  336. // fallback server 启动失败时继续走纯静态兜底
  337. }
  338. }
  339. const filePath = saveQrcodeImage(data.loginQrcodeBase64Data);
  340. const htmlPath = filePath ? saveQrcodePreviewHtml(filePath) : '';
  341. const opened = input.openBrowser === false ? false : Boolean(htmlPath && openInBrowser(htmlPath));
  342. return okResult({
  343. assistantMessage: [
  344. `企业微信登录二维码已由 Fmode 网关生成(uid: ${uid})。`,
  345. opened
  346. ? `已自动打开二维码预览页:${htmlPath}`
  347. : filePath
  348. ? `请打开或展示二维码图片:${filePath}`
  349. : 'Fmode 网关未返回可保存的二维码图片,请重试。',
  350. '用户扫码并在手机上确认后,调用 qiwei_login_check 轮询状态。',
  351. '如果返回状态 10,请向用户索要 6 位验证码并调用 qiwei_login_verify。'
  352. ].join('\n'),
  353. summary: {
  354. uid,
  355. qrcodeFile: filePath || null,
  356. previewFile: htmlPath || null,
  357. browserOpened: opened,
  358. fallbackServer: false
  359. },
  360. data: {
  361. uid,
  362. qrcodeFile: filePath || null,
  363. previewFile: htmlPath || null,
  364. browserOpened: opened,
  365. loginQrcodeBase64Data: filePath ? undefined : data.loginQrcodeBase64Data
  366. },
  367. files: [filePath, htmlPath].filter(Boolean),
  368. nextActions: ['确认用户看到二维码并扫码', '调用 qiwei_login_check 轮询登录状态']
  369. });
  370. } catch (error) {
  371. if (error && (error.kind === 'billing' || error.httpStatus === 402)) {
  372. return subscriptionRequiredResult({ apiBase, token, uid, detail: redactSecret(error.message) });
  373. }
  374. return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败');
  375. }
  376. }
  377. async function qiweiLoginCheck(input = {}) {
  378. const token = readQiweiAuthToken(input);
  379. if (!token) return authRequiredResult();
  380. const uid = ensureQiweiUid(input);
  381. const guid = readQiweiGuid(input);
  382. const apiBase = readQiweiApiBase(input);
  383. try {
  384. const result = await callFmodeWecomGateway({
  385. gatewayPath: '/login/check',
  386. body: loginBody(uid, guid, { manual: Boolean(input.manual) }),
  387. token,
  388. apiBase
  389. });
  390. const data = result.data || {};
  391. const statusCode = String(data.status);
  392. const statusText = QR_STATUS[statusCode] || `未知状态 ${statusCode}`;
  393. const detail = data.detail || {};
  394. if (statusCode === '2') {
  395. const guid = String(detail.guid || readQiweiGuid(input) || '').trim();
  396. if (input.persistConfig !== false) {
  397. saveQiweiClientConfig({
  398. uid,
  399. guid,
  400. apiBase,
  401. userId: detail.userId,
  402. nickname: detail.nickname,
  403. corpName: detail.corpName,
  404. });
  405. if (guid) recordDeviceGuid(guid, { wecomUserId: detail.userId, nickname: detail.nickname });
  406. }
  407. let relaySetup = null;
  408. const product = getProductMode(input);
  409. if (product.mode === 'enterprise') {
  410. try {
  411. relaySetup = await ensureRelayAndCallback({ token, apiBase, uid, guid, input });
  412. } catch (err) {
  413. console.warn('[Login] Relay 自动配置失败:', err.message);
  414. relaySetup = { success: false, error: err.message };
  415. }
  416. }
  417. const assistantMessage = product.mode === 'enterprise'
  418. ? relaySetup?.success
  419. ? `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。企业版 Relay 回调已自动配置:${relaySetup.callbackUrl}。Relay 消费守护进程会由 MCP/Skill 自动保持运行。`
  420. : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为企业版,但 Relay 接入尚未完成,请检查企业 Relay 配置。`
  421. : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为个人版,消息由本地主动监听处理并保存在当前项目。`;
  422. return okResult({
  423. assistantMessage,
  424. summary: {
  425. loggedIn: true,
  426. uid,
  427. statusCode: 2,
  428. userId: detail.userId,
  429. nickname: detail.nickname,
  430. corpId: detail.corpId,
  431. guid,
  432. productMode: product.mode,
  433. relaySetup: relaySetup
  434. ? { success: relaySetup.success, callbackUrl: relaySetup.callbackUrl, error: relaySetup.error }
  435. : null
  436. },
  437. data: { uid, status: detail, guid, product, relaySetup },
  438. nextActions: relaySetup?.success
  439. ? ['保持 npm run relay 运行']
  440. : product.mode === 'enterprise'
  441. ? ['完成企业 Relay 注册和服务端回调连接']
  442. : ['在 4320 工作台配置白名单并启动 AI 监听']
  443. });
  444. }
  445. if (statusCode === '10') {
  446. return {
  447. status: 'needs_verify_code',
  448. assistantMessage: '用户已在手机上确认,但需要输入手机端显示的 6 位验证码。请取得验证码后调用 qiwei_login_verify。',
  449. summary: { loggedIn: false, uid, statusCode: 10 },
  450. data: { uid, status: detail },
  451. files: [],
  452. nextActions: ['向用户索要 6 位验证码', '调用 qiwei_login_verify'],
  453. warnings: [],
  454. errors: []
  455. };
  456. }
  457. return okResult({
  458. assistantMessage: `当前扫码状态:${statusCode}(${statusText})。${
  459. ['-1', '1'].includes(statusCode)
  460. ? '请继续等待用户扫码/确认,3-5 秒后再次调用 qiwei_login_check。'
  461. : statusCode === '4'
  462. ? '用户取消了登录,可重新调用 qiwei_login_start。'
  463. : statusCode === '0'
  464. ? '可再次调用 qiwei_login_check 并传 manual=true 尝试免扫码登录。'
  465. : ''
  466. }`,
  467. summary: { loggedIn: false, uid, statusCode: Number(statusCode) },
  468. data: { uid, status: detail },
  469. nextActions:
  470. statusCode === '4'
  471. ? ['重新调用 qiwei_login_start']
  472. : statusCode === '0'
  473. ? ['调用 qiwei_login_check,传 manual=true']
  474. : ['稍后再次调用 qiwei_login_check']
  475. });
  476. } catch (error) {
  477. return gatewayErrorResult(error, 'loginCheck', '检测扫码状态失败');
  478. }
  479. }
  480. async function qiweiLoginVerify(input = {}) {
  481. const token = readQiweiAuthToken(input);
  482. if (!token) return authRequiredResult();
  483. const uid = ensureQiweiUid(input);
  484. const guid = readQiweiGuid(input);
  485. const code = String(input.code || '').trim();
  486. if (!/^\d{6}$/.test(code)) return errorResult('请提供手机端显示的 6 位数字验证码(入参 code)。');
  487. const apiBase = readQiweiApiBase(input);
  488. try {
  489. await callFmodeWecomGateway({
  490. gatewayPath: '/login/verify',
  491. body: loginBody(uid, guid, { code }),
  492. token,
  493. apiBase
  494. });
  495. return okResult({
  496. assistantMessage: '验证码已提交。请调用 qiwei_login_check 再次确认登录状态。',
  497. summary: { verified: true, uid },
  498. nextActions: ['调用 qiwei_login_check 确认登录状态']
  499. });
  500. } catch (error) {
  501. return gatewayErrorResult(error, 'loginVerify', '验证码校验失败');
  502. }
  503. }
  504. module.exports = {
  505. qiweiLoginStatus,
  506. qiweiLoginStart,
  507. qiweiLoginCheck,
  508. qiweiLoginVerify
  509. };