qiwei-login-run.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. const fs = require('fs');
  2. const path = require('path');
  3. const { spawn } = require('child_process');
  4. const {
  5. readQiweiAuthToken,
  6. ensureQiweiUid,
  7. readQiweiUid,
  8. readQiweiApiBase,
  9. saveQiweiClientConfig,
  10. readQiweiGuid,
  11. clearQiweiClientConfig,
  12. setActiveQiweiContext
  13. } = require('../core/credentials');
  14. const { callFmodeWecomGateway, redactSecret } = require('../providers/fmode-wecom-gateway');
  15. const { okResult, errorResult } = require('../core/result-envelope');
  16. const { latestPath } = require('../core/output-paths');
  17. const { QIWEI_MONTHLY_PRICE } = require('../core/subscribe-page');
  18. const { startLoginFlowServer, stopLoginFlowServer } = require('../core/login-flow-server');
  19. const { startLoginFallbackServer } = require('../core/login-fallback-server');
  20. const { getProductMode } = require('../core/product-mode');
  21. const { recordDeviceGuid } = require('../core/device-broker-mapping');
  22. const { ensureRelayWebhookConfigured, registerRelayTenant } = require('./qiwei-webhook-relay-run');
  23. const QR_STATUS = {
  24. '-1': '未登录,需要扫码登录',
  25. '0': '未登录,可尝试免扫码登录',
  26. '1': '已扫码,等待手机端确认',
  27. '2': '登录成功',
  28. '4': '用户在手机端取消了登录',
  29. '10': '已扫码确认,需要输入手机端 6 位验证码'
  30. };
  31. function authRequiredResult() {
  32. return {
  33. status: 'needs_auth',
  34. assistantMessage: [
  35. '还没有找到 Fmode 鉴权 token,无法调用由 Fmode 网关转发的企业微信接口。',
  36. '优先复用 Claude Code 已配置的 Fmode NewAPI sk- token;也可配置 QIWEI_AUTH_TOKEN、FMODE_API_KEY 或平台 sessionToken。',
  37. '企业微信接口访问凭据和设备上下文由 Fmode 网关管理,技能不会读取或保存。'
  38. ].join('\n'),
  39. summary: { configured: false, errorKind: 'missing_auth_token', recoverable: true },
  40. data: {},
  41. files: [],
  42. nextActions: ['配置 Fmode 鉴权 token 后重试'],
  43. warnings: [],
  44. errors: []
  45. };
  46. }
  47. async function ensureRelayAndCallback({ token, apiBase, uid, guid, input }) {
  48. if (!guid) {
  49. throw new Error('缺少 guid,无法配置 Relay 回调');
  50. }
  51. // 如果还没有租户凭证,先注册
  52. const relayCreds = require('../core/relay-config');
  53. if (!relayCreds.isRelayEnabled()) {
  54. try {
  55. await registerRelayTenant({ ...input, guid, description: '本地 Skill 自动注册' });
  56. } catch (err) {
  57. // 如果已经注册且 force 未开启,可能失败;继续尝试用本地已有凭证配置回调
  58. if (!relayCreds.getTenantId() || !relayCreds.getTenantApiSecret()) {
  59. throw err;
  60. }
  61. }
  62. }
  63. return ensureRelayWebhookConfigured({ ...input, guid, authToken: token, apiBase, uid });
  64. }
  65. function saveQrcodeImage(base64Data) {
  66. try {
  67. const filePath = latestPath('login', 'qiwei-login-qrcode.png');
  68. const data = String(base64Data || '').replace(/^data:image\/\w+;base64,/, '');
  69. fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
  70. return filePath;
  71. } catch {
  72. return '';
  73. }
  74. }
  75. function saveQrcodePreviewHtml(qrcodeImagePath) {
  76. try {
  77. const htmlPath = latestPath('login', 'qiwei-login-qrcode.html');
  78. const html = [
  79. '<!DOCTYPE html>',
  80. '<html lang="zh-CN">',
  81. '<head><meta charset="utf-8"><title>企业微信扫码登录</title>',
  82. '<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>',
  83. '</head>',
  84. '<body>',
  85. '<h1>请用手机企业微信扫码登录</h1>',
  86. `<img src="${path.basename(qrcodeImagePath)}" alt="登录二维码">`,
  87. '<p>扫码后请在手机上确认登录。二维码约 4 分钟内有效,过期请重新生成。</p>',
  88. '</body></html>'
  89. ].join('\n');
  90. fs.writeFileSync(htmlPath, html, 'utf8');
  91. return htmlPath;
  92. } catch {
  93. return '';
  94. }
  95. }
  96. function openInBrowser(target) {
  97. try {
  98. const platform = process.platform;
  99. let cmd;
  100. let args;
  101. if (platform === 'win32') {
  102. // 部分 Windows 环境的 PATH 缺少 System32,用 ComSpec 定位 cmd.exe 而不是依赖 PATH 查找。
  103. cmd = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
  104. args = ['/d', '/s', '/c', 'start', '', target];
  105. } else if (platform === 'darwin') {
  106. cmd = 'open';
  107. args = [target];
  108. } else {
  109. cmd = 'xdg-open';
  110. args = [target];
  111. }
  112. const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
  113. // spawn 失败时 ENOENT 是异步 error 事件,try/catch 接不住;没有监听器会让整个进程崩溃。
  114. child.on('error', () => {});
  115. child.unref();
  116. return true;
  117. } catch {
  118. return false;
  119. }
  120. }
  121. function gatewayErrorResult(error, stage, fallbackMessage) {
  122. const safeMessage = redactSecret(error && (error.bizMessage || error.message));
  123. const kind = String((error && error.kind) || 'upstream');
  124. if (kind === 'auth') {
  125. return {
  126. ...authRequiredResult(),
  127. assistantMessage: `Fmode 鉴权失败(${safeMessage})。请更新鉴权 token 后重试。`,
  128. summary: { stage, errorKind: kind, recoverable: true }
  129. };
  130. }
  131. if (kind === 'billing') {
  132. return {
  133. status: 'needs_subscription',
  134. assistantMessage: `企微订阅未开通、已到期或飞马余额不足(${safeMessage})。请先调用 qiwei_subscription_status,必要时用 qiwei_subscribe 开通或续费。`,
  135. summary: { stage, errorKind: kind, recoverable: true },
  136. data: {},
  137. files: [],
  138. nextActions: ['调用 qiwei_subscription_status', '调用 qiwei_subscribe 开通或续费'],
  139. warnings: [],
  140. errors: []
  141. };
  142. }
  143. if (kind === 'permission') {
  144. return {
  145. status: 'needs_seat',
  146. assistantMessage: `企微席位或权限不足(${safeMessage})。请查看订阅状态并增购席位后重试。`,
  147. summary: { stage, errorKind: kind, recoverable: true },
  148. data: {},
  149. files: [],
  150. nextActions: ['调用 qiwei_subscription_status', '调用 qiwei_subscribe 增购席位'],
  151. warnings: [],
  152. errors: []
  153. };
  154. }
  155. return errorResult(`${fallbackMessage}:${safeMessage}`, {
  156. summary: { stage, errorKind: kind },
  157. nextActions: ['稍后重试', '若持续失败,请检查 Fmode 网关的企业微信接口状态']
  158. });
  159. }
  160. function loginBody(uid, guid, extra = {}) {
  161. return { uid, ...(guid ? { guid } : {}), ...extra };
  162. }
  163. function extractLoginGuid(...values) {
  164. for (const value of values) {
  165. if (!value || typeof value !== 'object') continue;
  166. const guid = String(value.guid || value.deviceGuid || value.qiweiGuid || '').trim();
  167. if (guid) return guid;
  168. }
  169. return '';
  170. }
  171. async function qiweiLoginStatus(input = {}) {
  172. const token = readQiweiAuthToken(input);
  173. if (!token) return authRequiredResult();
  174. const uid = ensureQiweiUid(input);
  175. const guid = readQiweiGuid(input);
  176. const apiBase = readQiweiApiBase(input);
  177. try {
  178. const result = await callFmodeWecomGateway({
  179. gatewayPath: '/login/status',
  180. httpMethod: 'GET',
  181. query: { uid, ...(guid ? { guid } : {}) },
  182. token,
  183. apiBase
  184. });
  185. const data = result.data || {};
  186. const online = Boolean(data.online);
  187. const detail = data.detail || {};
  188. if (online && detail.userId && input.persistConfig !== false) {
  189. const resolvedGuid = extractLoginGuid(data, detail) || guid;
  190. saveQiweiClientConfig({
  191. uid,
  192. guid: resolvedGuid,
  193. apiBase,
  194. userId: detail.userId,
  195. nickname: detail.nickname,
  196. corpName: detail.corpName,
  197. });
  198. if (resolvedGuid) recordDeviceGuid(resolvedGuid, { wecomUserId: detail.userId, nickname: detail.nickname });
  199. }
  200. return okResult({
  201. assistantMessage: data.configured
  202. ? online
  203. ? `企业微信设备在线(uid: ${uid}),可以直接调用业务接口。`
  204. : `企业微信设备当前不在线(状态 ${data.statusCode ?? '未知'}),请调用 qiwei_login_start 重新登录。`
  205. : `当前 uid(${uid})尚未配置企业微信设备,请调用 qiwei_login_start 开始扫码登录。`,
  206. summary: {
  207. configured: Boolean(data.configured),
  208. online,
  209. uid,
  210. statusCode: data.statusCode ?? null
  211. },
  212. data: {
  213. uid,
  214. configured: Boolean(data.configured),
  215. online,
  216. statusCode: data.statusCode ?? null,
  217. detail
  218. },
  219. nextActions: online ? [] : ['调用 qiwei_login_start 开始扫码登录']
  220. });
  221. } catch (error) {
  222. return gatewayErrorResult(error, 'loginStatus', '查询登录状态失败');
  223. }
  224. }
  225. function subscriptionRequiredResult({ uid, detail }) {
  226. return {
  227. status: 'needs_subscription',
  228. assistantMessage: [
  229. `登录前需要先开通企微包月订阅:每个账号 ¥${QIWEI_MONTHLY_PRICE}/月,从飞马余额扣费。`,
  230. '为保护鉴权凭据,不再生成包含请求凭据的静态付费页面。请重新调用 qiwei_login_start 使用默认动态流程页,或直接调用 qiwei_subscribe 开通。',
  231. '开通成功后重新调用 qiwei_login_start 进入扫码登录(第 2 步)。',
  232. detail ? `订阅状态:${detail}` : ''
  233. ].filter(Boolean).join('\n'),
  234. summary: { stage: 'loginStart', errorKind: 'subscription_required', uid, dynamicFlowRequired: true, monthlyPrice: QIWEI_MONTHLY_PRICE },
  235. data: { uid },
  236. files: [],
  237. nextActions: ['重新调用 qiwei_login_start 使用默认动态流程页(或调用 qiwei_subscribe)', '开通后继续扫码登录'],
  238. warnings: [],
  239. errors: []
  240. };
  241. }
  242. async function qiweiLoginStart(input = {}) {
  243. const token = readQiweiAuthToken(input);
  244. if (!token) return authRequiredResult();
  245. let uid = ensureQiweiUid(input);
  246. const guid = readQiweiGuid(input);
  247. const apiBase = readQiweiApiBase(input);
  248. const loginOptions = {};
  249. if (input.forceSwitch === true) loginOptions.forceSwitch = true;
  250. if (input.newAccount === true) loginOptions.newAccount = true;
  251. if (input.areaCode !== undefined && input.areaCode !== null && input.areaCode !== '') {
  252. const areaCode = Number(input.areaCode);
  253. if (!Number.isInteger(areaCode) || areaCode < 100000 || areaCode > 999999) {
  254. return errorResult('areaCode 必须是 6 位行政区划代码。');
  255. }
  256. loginOptions.areaCode = areaCode;
  257. }
  258. if (input.persistConfig !== false) saveQiweiClientConfig({ uid, apiBase });
  259. if (input.flowUi !== false && input.forceSwitch !== true) {
  260. try {
  261. const { url } = await startLoginFlowServer({
  262. token,
  263. apiBase,
  264. uid,
  265. guid,
  266. port: input.flowPort,
  267. onQrcode: (buffer) => {
  268. try {
  269. fs.writeFileSync(latestPath('login', 'qiwei-login-qrcode.png'), buffer);
  270. } catch {}
  271. }
  272. });
  273. const opened = input.openBrowser === false ? false : openInBrowser(url);
  274. return okResult({
  275. assistantMessage: [
  276. `已启动企微登录流程页服务:${url}${opened ? '(已自动打开浏览器)' : ''}。`,
  277. `页面会自动完成两步流程:未开通订阅时先展示套餐页(每号 ¥${QIWEI_MONTHLY_PRICE}/月,飞马余额扣费),开通后自动生成二维码并每 3 秒自动检测扫码状态,需要验证码时页面会提示输入。`,
  278. '也可以用 qiwei_login_check 在对话中轮询登录状态。'
  279. ].join('\n'),
  280. summary: { uid, flowUrl: url, browserOpened: Boolean(opened), monthlyPrice: QIWEI_MONTHLY_PRICE },
  281. data: { uid, flowUrl: url, browserOpened: Boolean(opened) },
  282. nextActions: ['引导用户在流程页完成开通与扫码', '用 qiwei_login_check 轮询登录状态']
  283. });
  284. } catch (error) {
  285. // 流程页服务启动失败时回退到静态二维码流程
  286. }
  287. }
  288. if (input.skipSubscriptionCheck !== true) {
  289. try {
  290. let trialActive = false;
  291. try {
  292. const trialResult = await callFmodeWecomGateway({
  293. gatewayPath: '/trial/status',
  294. httpMethod: 'GET',
  295. token,
  296. apiBase,
  297. cacheBust: true
  298. });
  299. const trial = trialResult.data || {};
  300. trialActive = trial.state === 'active';
  301. } catch {
  302. // Gateways without trial/status continue with the subscription check below.
  303. }
  304. if (trialActive) {
  305. // A live trial seat is equivalent to an active subscription for login gating.
  306. } else {
  307. const statusResult = await callFmodeWecomGateway({
  308. gatewayPath: '/subscribe/status',
  309. httpMethod: 'GET',
  310. token,
  311. apiBase,
  312. cacheBust: true
  313. });
  314. const sub = statusResult.data || {};
  315. if (!sub.subscribed) {
  316. return subscriptionRequiredResult({ uid, detail: '尚未开通包月订阅' });
  317. }
  318. }
  319. } catch (error) {
  320. if (error && (error.kind === 'billing' || error.httpStatus === 402)) {
  321. return subscriptionRequiredResult({ uid, detail: redactSecret(error.message) });
  322. }
  323. if (error && error.kind === 'auth') return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败');
  324. // 订阅状态接口不可用时不阻断登录,由 /login/start 自身订阅校验兑底
  325. }
  326. }
  327. try {
  328. const result = await callFmodeWecomGateway({
  329. gatewayPath: '/login/start',
  330. body: loginBody(uid, guid, loginOptions),
  331. token,
  332. apiBase
  333. });
  334. const data = result.data || {};
  335. uid = String(data.uid || uid).trim();
  336. const startedGuid = extractLoginGuid(data, data.detail, data.account);
  337. if (startedGuid && input.persistConfig !== false) {
  338. // Save the device binding at creation time. Some gateways omit guid
  339. // from the later check response after the QR session is consumed.
  340. saveQiweiClientConfig({ uid, guid: startedGuid, apiBase });
  341. recordDeviceGuid(startedGuid, { wecomUserId: data.detail?.userId, nickname: data.detail?.nickname });
  342. }
  343. const qrcodeBase64 = String(data.loginQrcodeBase64Data || '').replace(/^data:image\/\w+;base64,/, '');
  344. const qrcodeBuffer = qrcodeBase64 ? Buffer.from(qrcodeBase64, 'base64') : null;
  345. // 优先启动可交互的 fallback 本地服务,让用户能在网页里直接输入验证码
  346. if (qrcodeBuffer) {
  347. try {
  348. const { url } = await startLoginFallbackServer({
  349. token,
  350. apiBase,
  351. uid,
  352. // Use the GUID returned by /login/start. The previous value can be
  353. // empty on a first login, which made fallback /flow/check and
  354. // /flow/verify omit the device binding even though it was already
  355. // written to .env.local above.
  356. guid: startedGuid || guid,
  357. qrcodeBuffer,
  358. port: 0,
  359. onQrcode: (buffer) => {
  360. try {
  361. fs.writeFileSync(latestPath('login', 'qiwei-login-qrcode.png'), buffer);
  362. } catch {}
  363. }
  364. });
  365. const opened = input.openBrowser === false ? false : openInBrowser(url);
  366. return okResult({
  367. assistantMessage: [
  368. `企业微信登录二维码已由 Fmode 网关生成(uid: ${uid})。`,
  369. opened
  370. ? `已自动打开扫码登录页:${url}`
  371. : `请打开扫码登录页:${url}`,
  372. '页面会自动检测扫码状态;若手机端需要验证码,可直接在页面输入 6 位数字。'
  373. ].join('\n'),
  374. summary: {
  375. uid,
  376. flowUrl: url,
  377. fallbackServer: true,
  378. browserOpened: opened
  379. },
  380. data: {
  381. uid,
  382. flowUrl: url,
  383. fallbackServer: true,
  384. browserOpened: opened
  385. },
  386. files: [],
  387. nextActions: ['在页面中完成扫码与验证码输入', '调用 qiwei_login_check 确认状态']
  388. });
  389. } catch {
  390. // fallback server 启动失败时继续走纯静态兜底
  391. }
  392. }
  393. const filePath = saveQrcodeImage(data.loginQrcodeBase64Data);
  394. const htmlPath = filePath ? saveQrcodePreviewHtml(filePath) : '';
  395. const opened = input.openBrowser === false ? false : Boolean(htmlPath && openInBrowser(htmlPath));
  396. return okResult({
  397. assistantMessage: [
  398. `企业微信登录二维码已由 Fmode 网关生成(uid: ${uid})。`,
  399. opened
  400. ? `已自动打开二维码预览页:${htmlPath}`
  401. : filePath
  402. ? `请打开或展示二维码图片:${filePath}`
  403. : 'Fmode 网关未返回可保存的二维码图片,请重试。',
  404. '用户扫码并在手机上确认后,调用 qiwei_login_check 轮询状态。',
  405. '如果返回状态 10,请向用户索要 6 位验证码并调用 qiwei_login_verify。'
  406. ].join('\n'),
  407. summary: {
  408. uid,
  409. qrcodeFile: filePath || null,
  410. previewFile: htmlPath || null,
  411. browserOpened: opened,
  412. fallbackServer: false
  413. },
  414. data: {
  415. uid,
  416. qrcodeFile: filePath || null,
  417. previewFile: htmlPath || null,
  418. browserOpened: opened,
  419. loginQrcodeBase64Data: filePath ? undefined : data.loginQrcodeBase64Data
  420. },
  421. files: [filePath, htmlPath].filter(Boolean),
  422. nextActions: ['确认用户看到二维码并扫码', '调用 qiwei_login_check 轮询登录状态']
  423. });
  424. } catch (error) {
  425. if (error && (error.kind === 'billing' || error.httpStatus === 402)) {
  426. return subscriptionRequiredResult({ apiBase, token, uid, detail: redactSecret(error.message) });
  427. }
  428. return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败');
  429. }
  430. }
  431. async function qiweiLoginCheck(input = {}) {
  432. const token = readQiweiAuthToken(input);
  433. if (!token) return authRequiredResult();
  434. const uid = ensureQiweiUid(input);
  435. const guid = readQiweiGuid(input);
  436. const apiBase = readQiweiApiBase(input);
  437. try {
  438. const result = await callFmodeWecomGateway({
  439. gatewayPath: '/login/check',
  440. body: loginBody(uid, guid, { manual: Boolean(input.manual) }),
  441. token,
  442. apiBase
  443. });
  444. const data = result.data || {};
  445. const statusCode = String(data.status);
  446. const statusText = QR_STATUS[statusCode] || `未知状态 ${statusCode}`;
  447. const detail = data.detail || {};
  448. if (statusCode === '2') {
  449. if (data.decisionRequired) {
  450. return {
  451. status: 'needs_seat_decision',
  452. assistantMessage: '新企微账号已完成扫码,当前订阅席位已满。请在登录流程页选择新增购买席位、替换已有账号或取消本次登录。',
  453. summary: {
  454. loggedIn: false,
  455. uid,
  456. statusCode: 2,
  457. decisionRequired: true,
  458. seats: data.seats,
  459. usedSeats: data.usedSeats
  460. },
  461. data: { ...data, uid, status: detail },
  462. files: [],
  463. nextActions: ['在登录流程页完成席位处理'],
  464. warnings: [],
  465. errors: []
  466. };
  467. }
  468. const resolvedGuid = extractLoginGuid(data, detail) || readQiweiGuid(input);
  469. const guid = String(resolvedGuid || '').trim();
  470. if (input.persistConfig !== false) {
  471. saveQiweiClientConfig({
  472. uid,
  473. guid,
  474. apiBase,
  475. userId: detail.userId,
  476. nickname: detail.nickname,
  477. corpName: detail.corpName,
  478. });
  479. if (guid) recordDeviceGuid(guid, { wecomUserId: detail.userId, nickname: detail.nickname });
  480. }
  481. let relaySetup = null;
  482. const product = getProductMode(input);
  483. if (product.mode === 'enterprise') {
  484. try {
  485. relaySetup = await ensureRelayAndCallback({ token, apiBase, uid, guid, input });
  486. } catch (err) {
  487. console.warn('[Login] Relay 自动配置失败:', err.message);
  488. relaySetup = { success: false, error: err.message };
  489. }
  490. }
  491. const assistantMessage = product.mode === 'enterprise'
  492. ? relaySetup?.success
  493. ? `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。企业版 Relay 回调已自动配置:${relaySetup.callbackUrl}。Relay 消费守护进程会由 MCP/Skill 自动保持运行。`
  494. : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为企业版,但 Relay 接入尚未完成,请检查企业 Relay 配置。`
  495. : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为个人版,消息由本地主动监听处理并保存在当前项目。`;
  496. return okResult({
  497. assistantMessage,
  498. summary: {
  499. loggedIn: true,
  500. uid,
  501. statusCode: 2,
  502. userId: detail.userId,
  503. nickname: detail.nickname,
  504. corpId: detail.corpId,
  505. guid,
  506. productMode: product.mode,
  507. relaySetup: relaySetup
  508. ? { success: relaySetup.success, callbackUrl: relaySetup.callbackUrl, error: relaySetup.error }
  509. : null
  510. },
  511. data: { uid, status: detail, guid, product, relaySetup },
  512. nextActions: relaySetup?.success
  513. ? ['保持 npm run relay 运行']
  514. : product.mode === 'enterprise'
  515. ? ['完成企业 Relay 注册和服务端回调连接']
  516. : ['在 4320 工作台配置白名单并启动 AI 监听']
  517. });
  518. }
  519. if (statusCode === '10') {
  520. return {
  521. status: 'needs_verify_code',
  522. assistantMessage: '用户已在手机上确认,但需要输入手机端显示的 6 位验证码。请取得验证码后调用 qiwei_login_verify。',
  523. summary: { loggedIn: false, uid, statusCode: 10 },
  524. data: { uid, status: detail },
  525. files: [],
  526. nextActions: ['向用户索要 6 位验证码', '调用 qiwei_login_verify'],
  527. warnings: [],
  528. errors: []
  529. };
  530. }
  531. return okResult({
  532. assistantMessage: `当前扫码状态:${statusCode}(${statusText})。${
  533. ['-1', '1'].includes(statusCode)
  534. ? '请继续等待用户扫码/确认,3-5 秒后再次调用 qiwei_login_check。'
  535. : statusCode === '4'
  536. ? '用户取消了登录,可重新调用 qiwei_login_start。'
  537. : statusCode === '0'
  538. ? '可再次调用 qiwei_login_check 并传 manual=true 尝试免扫码登录。'
  539. : ''
  540. }`,
  541. summary: { loggedIn: false, uid, statusCode: Number(statusCode) },
  542. data: { uid, status: detail },
  543. nextActions:
  544. statusCode === '4'
  545. ? ['重新调用 qiwei_login_start']
  546. : statusCode === '0'
  547. ? ['调用 qiwei_login_check,传 manual=true']
  548. : ['稍后再次调用 qiwei_login_check']
  549. });
  550. } catch (error) {
  551. return gatewayErrorResult(error, 'loginCheck', '检测扫码状态失败');
  552. }
  553. }
  554. async function qiweiLoginVerify(input = {}) {
  555. const token = readQiweiAuthToken(input);
  556. if (!token) return authRequiredResult();
  557. const uid = ensureQiweiUid(input);
  558. const guid = readQiweiGuid(input);
  559. const code = String(input.code || '').trim();
  560. if (!/^\d{6}$/.test(code)) return errorResult('请提供手机端显示的 6 位数字验证码(入参 code)。');
  561. const apiBase = readQiweiApiBase(input);
  562. try {
  563. await callFmodeWecomGateway({
  564. gatewayPath: '/login/verify',
  565. body: loginBody(uid, guid, { code }),
  566. token,
  567. apiBase
  568. });
  569. return okResult({
  570. assistantMessage: '验证码已提交。请调用 qiwei_login_check 再次确认登录状态。',
  571. summary: { verified: true, uid },
  572. nextActions: ['调用 qiwei_login_check 确认登录状态']
  573. });
  574. } catch (error) {
  575. return gatewayErrorResult(error, 'loginVerify', '验证码校验失败');
  576. }
  577. }
  578. async function releaseQiweiDevice(input = {}) {
  579. const token = readQiweiAuthToken(input);
  580. if (!token) {
  581. const error = new Error('缺少 Fmode 鉴权 token');
  582. error.kind = 'auth';
  583. error.httpStatus = 401;
  584. throw error;
  585. }
  586. const uid = String(input.uid || readQiweiUid(input) || '').trim();
  587. const guid = readQiweiGuid({ ...input, uid });
  588. if (!uid) return { uid: '', guid: '', upstream: { logout: false, stopClient: false, alreadyReleased: true }, skipped: true };
  589. const apiBase = readQiweiApiBase(input);
  590. const upstream = { logout: false, stopClient: false, alreadyReleased: false };
  591. const isAlreadyReleased = error => {
  592. const text = String(error?.bizMessage || error?.message || '');
  593. return error?.kind === 'device' || /未登录|离线|设备不存在|not found|不存在|already/i.test(text);
  594. };
  595. if (guid) {
  596. try {
  597. await callFmodeWecomGateway({
  598. gatewayPath: '/doApi',
  599. httpMethod: 'POST',
  600. body: { uid, method: '/user/logout', params: { guid } },
  601. token,
  602. apiBase
  603. });
  604. upstream.logout = true;
  605. } catch (error) {
  606. if (!isAlreadyReleased(error)) throw error;
  607. upstream.alreadyReleased = true;
  608. }
  609. try {
  610. await callFmodeWecomGateway({
  611. gatewayPath: '/doApi',
  612. httpMethod: 'POST',
  613. body: { uid, method: '/client/stopClient', params: { guid } },
  614. token,
  615. apiBase
  616. });
  617. upstream.stopClient = true;
  618. } catch (error) {
  619. if (!isAlreadyReleased(error)) throw error;
  620. upstream.alreadyReleased = true;
  621. }
  622. } else {
  623. // 没有 GUID 时仍通知网关退出,兼容尚未完成扫码的临时设备。
  624. try {
  625. await callFmodeWecomGateway({
  626. gatewayPath: '/doApi',
  627. httpMethod: 'POST',
  628. body: { uid, method: '/user/logout', params: {} },
  629. token,
  630. apiBase
  631. });
  632. upstream.logout = true;
  633. } catch (error) {
  634. if (!isAlreadyReleased(error)) throw error;
  635. upstream.alreadyReleased = true;
  636. }
  637. }
  638. return { uid, guid, upstream };
  639. }
  640. async function qiweiUserLogout(input = {}) {
  641. const token = readQiweiAuthToken(input);
  642. if (!token) return authRequiredResult();
  643. const uid = String(input.uid || readQiweiUid(input) || '').trim();
  644. const guid = readQiweiGuid({ ...input, uid });
  645. try {
  646. const released = await releaseQiweiDevice({ ...input, uid, guid });
  647. const cleared = clearQiweiClientConfig({ uid, guid, envRoot: process.env.QIWEI_WORKSPACE_ROOT || process.cwd() });
  648. if (!cleared.cleared) {
  649. return errorResult(`上游账号已退出,但本地凭据清理失败:${(cleared.errors || []).map(item => item.message).join(';') || cleared.reason || '未知错误'}`, {
  650. summary: { uid, guid, loggedOut: true, credentialsCleared: false, upstream: released.upstream },
  651. data: released,
  652. nextActions: ['检查工作台 .env.local 后重新打开登录页']
  653. });
  654. }
  655. // Drop process-local account state as well as the persisted UID/GUID. A
  656. // subsequent login must start with a new device context in this process.
  657. setActiveQiweiContext({});
  658. stopLoginFlowServer();
  659. return okResult({
  660. assistantMessage: released.upstream.alreadyReleased
  661. ? '当前企微设备已是离线状态,本地账号凭据已清理,可重新扫码登录。'
  662. : '已退出并释放当前企微设备,本地账号凭据已清理,可重新扫码登录。',
  663. summary: { uid, guid, loggedOut: true, credentialsCleared: true, upstream: released.upstream },
  664. data: released,
  665. nextActions: ['调用 qiwei_login_start 生成新账号登录二维码']
  666. });
  667. } catch (error) {
  668. // 即使网关暂时不可用,也清掉本地设备上下文,避免下一次登录继续复用旧 GUID。
  669. const cleared = clearQiweiClientConfig({ uid, guid, envRoot: process.env.QIWEI_WORKSPACE_ROOT || process.cwd() });
  670. const result = gatewayErrorResult(error, 'userLogout', '退出企微账号失败');
  671. setActiveQiweiContext({});
  672. stopLoginFlowServer();
  673. if (cleared.cleared) {
  674. result.assistantMessage = `${result.assistantMessage} 本地账号凭据已清理,可重新打开登录页。`;
  675. result.summary = { ...(result.summary || {}), uid, guid, credentialsCleared: true };
  676. result.data = { uid, guid, credentialsCleared: true };
  677. }
  678. return result;
  679. }
  680. }
  681. module.exports = {
  682. qiweiLoginStatus,
  683. qiweiLoginStart,
  684. qiweiLoginCheck,
  685. qiweiLoginVerify,
  686. qiweiUserLogout,
  687. releaseQiweiDevice
  688. };