const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const { readQiweiAuthToken, ensureQiweiUid, readQiweiApiBase, saveQiweiClientConfig, readQiweiGuid } = require('../core/credentials'); const { callFmodeWecomGateway, redactSecret } = require('../providers/fmode-wecom-gateway'); const { okResult, errorResult } = require('../core/result-envelope'); const { latestPath } = require('../core/output-paths'); const { QIWEI_MONTHLY_PRICE } = require('../core/subscribe-page'); const { startLoginFlowServer } = require('../core/login-flow-server'); const { startLoginFallbackServer } = require('../core/login-fallback-server'); const { getProductMode } = require('../core/product-mode'); const { recordDeviceGuid } = require('../core/device-broker-mapping'); const { ensureRelayWebhookConfigured, registerRelayTenant } = require('./qiwei-webhook-relay-run'); const QR_STATUS = { '-1': '未登录,需要扫码登录', '0': '未登录,可尝试免扫码登录', '1': '已扫码,等待手机端确认', '2': '登录成功', '4': '用户在手机端取消了登录', '10': '已扫码确认,需要输入手机端 6 位验证码' }; function authRequiredResult() { return { status: 'needs_auth', assistantMessage: [ '还没有找到 Fmode 鉴权 token,无法调用由 Fmode 网关转发的企业微信接口。', '优先复用 Claude Code 已配置的 Fmode NewAPI sk- token;也可配置 QIWEI_AUTH_TOKEN、FMODE_API_KEY 或平台 sessionToken。', '企业微信接口访问凭据和设备上下文由 Fmode 网关管理,技能不会读取或保存。' ].join('\n'), summary: { configured: false, errorKind: 'missing_auth_token', recoverable: true }, data: {}, files: [], nextActions: ['配置 Fmode 鉴权 token 后重试'], warnings: [], errors: [] }; } async function ensureRelayAndCallback({ token, apiBase, uid, guid, input }) { if (!guid) { throw new Error('缺少 guid,无法配置 Relay 回调'); } // 如果还没有租户凭证,先注册 const relayCreds = require('../core/relay-config'); if (!relayCreds.isRelayEnabled()) { try { await registerRelayTenant({ ...input, guid, description: '本地 Skill 自动注册' }); } catch (err) { // 如果已经注册且 force 未开启,可能失败;继续尝试用本地已有凭证配置回调 if (!relayCreds.getTenantId() || !relayCreds.getTenantApiSecret()) { throw err; } } } return ensureRelayWebhookConfigured({ ...input, guid, authToken: token, apiBase, uid }); } function saveQrcodeImage(base64Data) { try { const filePath = latestPath('login', 'qiwei-login-qrcode.png'); const data = String(base64Data || '').replace(/^data:image\/\w+;base64,/, ''); fs.writeFileSync(filePath, Buffer.from(data, 'base64')); return filePath; } catch { return ''; } } function saveQrcodePreviewHtml(qrcodeImagePath) { try { const htmlPath = latestPath('login', 'qiwei-login-qrcode.html'); const html = [ '', '', '企业微信扫码登录', '', '', '', '

请用手机企业微信扫码登录

', `登录二维码`, '

扫码后请在手机上确认登录。二维码约 4 分钟内有效,过期请重新生成。

', '' ].join('\n'); fs.writeFileSync(htmlPath, html, 'utf8'); return htmlPath; } catch { return ''; } } function openInBrowser(target) { try { const platform = process.platform; let cmd; let args; if (platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', target]; } else if (platform === 'darwin') { cmd = 'open'; args = [target]; } else { cmd = 'xdg-open'; args = [target]; } const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }); child.unref(); return true; } catch { return false; } } function gatewayErrorResult(error, stage, fallbackMessage) { const safeMessage = redactSecret(error && (error.bizMessage || error.message)); const kind = String((error && error.kind) || 'upstream'); if (kind === 'auth') { return { ...authRequiredResult(), assistantMessage: `Fmode 鉴权失败(${safeMessage})。请更新鉴权 token 后重试。`, summary: { stage, errorKind: kind, recoverable: true } }; } if (kind === 'billing') { return { status: 'needs_subscription', assistantMessage: `企微订阅未开通、已到期或飞马余额不足(${safeMessage})。请先调用 qiwei_subscription_status,必要时用 qiwei_subscribe 开通或续费。`, summary: { stage, errorKind: kind, recoverable: true }, data: {}, files: [], nextActions: ['调用 qiwei_subscription_status', '调用 qiwei_subscribe 开通或续费'], warnings: [], errors: [] }; } if (kind === 'permission') { return { status: 'needs_seat', assistantMessage: `企微席位或权限不足(${safeMessage})。请查看订阅状态并增购席位后重试。`, summary: { stage, errorKind: kind, recoverable: true }, data: {}, files: [], nextActions: ['调用 qiwei_subscription_status', '调用 qiwei_subscribe 增购席位'], warnings: [], errors: [] }; } return errorResult(`${fallbackMessage}:${safeMessage}`, { summary: { stage, errorKind: kind }, nextActions: ['稍后重试', '若持续失败,请检查 Fmode 网关的企业微信接口状态'] }); } function loginBody(uid, guid, extra = {}) { return { uid, ...(guid ? { guid } : {}), ...extra }; } async function qiweiLoginStatus(input = {}) { const token = readQiweiAuthToken(input); if (!token) return authRequiredResult(); const uid = ensureQiweiUid(input); const guid = readQiweiGuid(input); const apiBase = readQiweiApiBase(input); try { const result = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid, ...(guid ? { guid } : {}) }, token, apiBase }); const data = result.data || {}; const online = Boolean(data.online); const detail = data.detail || {}; if (online && detail.userId && input.persistConfig !== false) { const resolvedGuid = String(detail.guid || guid || '').trim(); saveQiweiClientConfig({ uid, guid: resolvedGuid, apiBase, userId: detail.userId, nickname: detail.nickname, corpName: detail.corpName, }); if (resolvedGuid) recordDeviceGuid(resolvedGuid, { wecomUserId: detail.userId, nickname: detail.nickname }); } return okResult({ assistantMessage: data.configured ? online ? `企业微信设备在线(uid: ${uid}),可以直接调用业务接口。` : `企业微信设备当前不在线(状态 ${data.statusCode ?? '未知'}),请调用 qiwei_login_start 重新登录。` : `当前 uid(${uid})尚未配置企业微信设备,请调用 qiwei_login_start 开始扫码登录。`, summary: { configured: Boolean(data.configured), online, uid, statusCode: data.statusCode ?? null }, data: { uid, configured: Boolean(data.configured), online, statusCode: data.statusCode ?? null, detail }, nextActions: online ? [] : ['调用 qiwei_login_start 开始扫码登录'] }); } catch (error) { return gatewayErrorResult(error, 'loginStatus', '查询登录状态失败'); } } function subscriptionRequiredResult({ uid, detail }) { return { status: 'needs_subscription', assistantMessage: [ `登录前需要先开通企微包月订阅:每个账号 ¥${QIWEI_MONTHLY_PRICE}/月,从飞马余额扣费。`, '为保护鉴权凭据,不再生成包含请求凭据的静态付费页面。请重新调用 qiwei_login_start 使用默认动态流程页,或直接调用 qiwei_subscribe 开通。', '开通成功后重新调用 qiwei_login_start 进入扫码登录(第 2 步)。', detail ? `订阅状态:${detail}` : '' ].filter(Boolean).join('\n'), summary: { stage: 'loginStart', errorKind: 'subscription_required', uid, dynamicFlowRequired: true, monthlyPrice: QIWEI_MONTHLY_PRICE }, data: { uid }, files: [], nextActions: ['重新调用 qiwei_login_start 使用默认动态流程页(或调用 qiwei_subscribe)', '开通后继续扫码登录'], warnings: [], errors: [] }; } async function qiweiLoginStart(input = {}) { const token = readQiweiAuthToken(input); if (!token) return authRequiredResult(); const uid = ensureQiweiUid(input); const guid = readQiweiGuid(input); const apiBase = readQiweiApiBase(input); if (input.persistConfig !== false) saveQiweiClientConfig({ uid, apiBase }); if (input.flowUi !== false) { try { const { url } = await startLoginFlowServer({ token, apiBase, uid, guid, port: input.flowPort, onQrcode: (buffer) => { try { fs.writeFileSync(latestPath('login', 'qiwei-login-qrcode.png'), buffer); } catch {} } }); const opened = input.openBrowser === false ? false : openInBrowser(url); return okResult({ assistantMessage: [ `已启动企微登录流程页服务:${url}${opened ? '(已自动打开浏览器)' : ''}。`, `页面会自动完成两步流程:未开通订阅时先展示套餐页(每号 ¥${QIWEI_MONTHLY_PRICE}/月,飞马余额扣费),开通后自动生成二维码并每 3 秒自动检测扫码状态,需要验证码时页面会提示输入。`, '也可以用 qiwei_login_check 在对话中轮询登录状态。' ].join('\n'), summary: { uid, flowUrl: url, browserOpened: Boolean(opened), monthlyPrice: QIWEI_MONTHLY_PRICE }, data: { uid, flowUrl: url, browserOpened: Boolean(opened) }, nextActions: ['引导用户在流程页完成开通与扫码', '用 qiwei_login_check 轮询登录状态'] }); } catch (error) { // 流程页服务启动失败时回退到静态二维码流程 } } if (input.skipSubscriptionCheck !== true) { try { const statusResult = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token, apiBase, cacheBust: true }); const sub = statusResult.data || {}; if (!sub.subscribed) { return subscriptionRequiredResult({ uid, detail: '尚未开通包月订阅' }); } } catch (error) { if (error && (error.kind === 'billing' || error.httpStatus === 402)) { return subscriptionRequiredResult({ uid, detail: redactSecret(error.message) }); } if (error && error.kind === 'auth') return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败'); // 订阅状态接口不可用时不阻断登录,由 /login/start 自身订阅校验兑底 } } try { const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: loginBody(uid, guid), token, apiBase }); const data = result.data || {}; const qrcodeBase64 = String(data.loginQrcodeBase64Data || '').replace(/^data:image\/\w+;base64,/, ''); const qrcodeBuffer = qrcodeBase64 ? Buffer.from(qrcodeBase64, 'base64') : null; // 优先启动可交互的 fallback 本地服务,让用户能在网页里直接输入验证码 if (qrcodeBuffer) { try { const { url } = await startLoginFallbackServer({ token, apiBase, uid, guid, qrcodeBuffer, port: 0, onQrcode: (buffer) => { try { fs.writeFileSync(latestPath('login', 'qiwei-login-qrcode.png'), buffer); } catch {} } }); const opened = input.openBrowser === false ? false : openInBrowser(url); return okResult({ assistantMessage: [ `企业微信登录二维码已由 Fmode 网关生成(uid: ${uid})。`, opened ? `已自动打开扫码登录页:${url}` : `请打开扫码登录页:${url}`, '页面会自动检测扫码状态;若手机端需要验证码,可直接在页面输入 6 位数字。' ].join('\n'), summary: { uid, flowUrl: url, fallbackServer: true, browserOpened: opened }, data: { uid, flowUrl: url, fallbackServer: true, browserOpened: opened }, files: [], nextActions: ['在页面中完成扫码与验证码输入', '调用 qiwei_login_check 确认状态'] }); } catch { // fallback server 启动失败时继续走纯静态兜底 } } const filePath = saveQrcodeImage(data.loginQrcodeBase64Data); const htmlPath = filePath ? saveQrcodePreviewHtml(filePath) : ''; const opened = input.openBrowser === false ? false : Boolean(htmlPath && openInBrowser(htmlPath)); return okResult({ assistantMessage: [ `企业微信登录二维码已由 Fmode 网关生成(uid: ${uid})。`, opened ? `已自动打开二维码预览页:${htmlPath}` : filePath ? `请打开或展示二维码图片:${filePath}` : 'Fmode 网关未返回可保存的二维码图片,请重试。', '用户扫码并在手机上确认后,调用 qiwei_login_check 轮询状态。', '如果返回状态 10,请向用户索要 6 位验证码并调用 qiwei_login_verify。' ].join('\n'), summary: { uid, qrcodeFile: filePath || null, previewFile: htmlPath || null, browserOpened: opened, fallbackServer: false }, data: { uid, qrcodeFile: filePath || null, previewFile: htmlPath || null, browserOpened: opened, loginQrcodeBase64Data: filePath ? undefined : data.loginQrcodeBase64Data }, files: [filePath, htmlPath].filter(Boolean), nextActions: ['确认用户看到二维码并扫码', '调用 qiwei_login_check 轮询登录状态'] }); } catch (error) { if (error && (error.kind === 'billing' || error.httpStatus === 402)) { return subscriptionRequiredResult({ apiBase, token, uid, detail: redactSecret(error.message) }); } return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败'); } } async function qiweiLoginCheck(input = {}) { const token = readQiweiAuthToken(input); if (!token) return authRequiredResult(); const uid = ensureQiweiUid(input); const guid = readQiweiGuid(input); const apiBase = readQiweiApiBase(input); try { const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: loginBody(uid, guid, { manual: Boolean(input.manual) }), token, apiBase }); const data = result.data || {}; const statusCode = String(data.status); const statusText = QR_STATUS[statusCode] || `未知状态 ${statusCode}`; const detail = data.detail || {}; if (statusCode === '2') { const guid = String(detail.guid || readQiweiGuid(input) || '').trim(); if (input.persistConfig !== false) { saveQiweiClientConfig({ uid, guid, apiBase, userId: detail.userId, nickname: detail.nickname, corpName: detail.corpName, }); if (guid) recordDeviceGuid(guid, { wecomUserId: detail.userId, nickname: detail.nickname }); } let relaySetup = null; const product = getProductMode(input); if (product.mode === 'enterprise') { try { relaySetup = await ensureRelayAndCallback({ token, apiBase, uid, guid, input }); } catch (err) { console.warn('[Login] Relay 自动配置失败:', err.message); relaySetup = { success: false, error: err.message }; } } const assistantMessage = product.mode === 'enterprise' ? relaySetup?.success ? `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。企业版 Relay 回调已自动配置:${relaySetup.callbackUrl}。Relay 消费守护进程会由 MCP/Skill 自动保持运行。` : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为企业版,但 Relay 接入尚未完成,请检查企业 Relay 配置。` : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为个人版,消息由本地主动监听处理并保存在当前项目。`; return okResult({ assistantMessage, summary: { loggedIn: true, uid, statusCode: 2, userId: detail.userId, nickname: detail.nickname, corpId: detail.corpId, guid, productMode: product.mode, relaySetup: relaySetup ? { success: relaySetup.success, callbackUrl: relaySetup.callbackUrl, error: relaySetup.error } : null }, data: { uid, status: detail, guid, product, relaySetup }, nextActions: relaySetup?.success ? ['保持 npm run relay 运行'] : product.mode === 'enterprise' ? ['完成企业 Relay 注册和服务端回调连接'] : ['在 4320 工作台配置白名单并启动 AI 监听'] }); } if (statusCode === '10') { return { status: 'needs_verify_code', assistantMessage: '用户已在手机上确认,但需要输入手机端显示的 6 位验证码。请取得验证码后调用 qiwei_login_verify。', summary: { loggedIn: false, uid, statusCode: 10 }, data: { uid, status: detail }, files: [], nextActions: ['向用户索要 6 位验证码', '调用 qiwei_login_verify'], warnings: [], errors: [] }; } return okResult({ assistantMessage: `当前扫码状态:${statusCode}(${statusText})。${ ['-1', '1'].includes(statusCode) ? '请继续等待用户扫码/确认,3-5 秒后再次调用 qiwei_login_check。' : statusCode === '4' ? '用户取消了登录,可重新调用 qiwei_login_start。' : statusCode === '0' ? '可再次调用 qiwei_login_check 并传 manual=true 尝试免扫码登录。' : '' }`, summary: { loggedIn: false, uid, statusCode: Number(statusCode) }, data: { uid, status: detail }, nextActions: statusCode === '4' ? ['重新调用 qiwei_login_start'] : statusCode === '0' ? ['调用 qiwei_login_check,传 manual=true'] : ['稍后再次调用 qiwei_login_check'] }); } catch (error) { return gatewayErrorResult(error, 'loginCheck', '检测扫码状态失败'); } } async function qiweiLoginVerify(input = {}) { const token = readQiweiAuthToken(input); if (!token) return authRequiredResult(); const uid = ensureQiweiUid(input); const guid = readQiweiGuid(input); const code = String(input.code || '').trim(); if (!/^\d{6}$/.test(code)) return errorResult('请提供手机端显示的 6 位数字验证码(入参 code)。'); const apiBase = readQiweiApiBase(input); try { await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: loginBody(uid, guid, { code }), token, apiBase }); return okResult({ assistantMessage: '验证码已提交。请调用 qiwei_login_check 再次确认登录状态。', summary: { verified: true, uid }, nextActions: ['调用 qiwei_login_check 确认登录状态'] }); } catch (error) { return gatewayErrorResult(error, 'loginVerify', '验证码校验失败'); } } module.exports = { qiweiLoginStatus, qiweiLoginStart, qiweiLoginCheck, qiweiLoginVerify };