const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const { readQiweiAuthToken, ensureQiweiUid, readQiweiUid, readQiweiApiBase, saveQiweiClientConfig, readQiweiGuid, clearQiweiClientConfig, setActiveQiweiContext } = 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, stopLoginFlowServer } = 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') { // 部分 Windows 环境的 PATH 缺少 System32,用 ComSpec 定位 cmd.exe 而不是依赖 PATH 查找。 cmd = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe'; args = ['/d', '/s', '/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', windowsHide: true }); // spawn 失败时 ENOENT 是异步 error 事件,try/catch 接不住;没有监听器会让整个进程崩溃。 child.on('error', () => {}); 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 }; } function extractLoginGuid(...values) { for (const value of values) { if (!value || typeof value !== 'object') continue; const guid = String(value.guid || value.deviceGuid || value.qiweiGuid || '').trim(); if (guid) return guid; } return ''; } 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 = extractLoginGuid(data, detail) || guid; 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(); let uid = ensureQiweiUid(input); const guid = readQiweiGuid(input); const apiBase = readQiweiApiBase(input); const loginOptions = {}; if (input.forceSwitch === true) loginOptions.forceSwitch = true; if (input.newAccount === true) loginOptions.newAccount = true; if (input.areaCode !== undefined && input.areaCode !== null && input.areaCode !== '') { const areaCode = Number(input.areaCode); if (!Number.isInteger(areaCode) || areaCode < 100000 || areaCode > 999999) { return errorResult('areaCode 必须是 6 位行政区划代码。'); } loginOptions.areaCode = areaCode; } if (input.persistConfig !== false) saveQiweiClientConfig({ uid, apiBase }); if (input.flowUi !== false && input.forceSwitch !== true) { 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 { let trialActive = false; try { const trialResult = await callFmodeWecomGateway({ gatewayPath: '/trial/status', httpMethod: 'GET', token, apiBase, cacheBust: true }); const trial = trialResult.data || {}; trialActive = trial.state === 'active'; } catch { // Gateways without trial/status continue with the subscription check below. } if (trialActive) { // A live trial seat is equivalent to an active subscription for login gating. } else { 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, loginOptions), token, apiBase }); const data = result.data || {}; uid = String(data.uid || uid).trim(); const startedGuid = extractLoginGuid(data, data.detail, data.account); if (startedGuid && input.persistConfig !== false) { // Save the device binding at creation time. Some gateways omit guid // from the later check response after the QR session is consumed. saveQiweiClientConfig({ uid, guid: startedGuid, apiBase }); recordDeviceGuid(startedGuid, { wecomUserId: data.detail?.userId, nickname: data.detail?.nickname }); } 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, // Use the GUID returned by /login/start. The previous value can be // empty on a first login, which made fallback /flow/check and // /flow/verify omit the device binding even though it was already // written to .env.local above. guid: startedGuid || 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') { if (data.decisionRequired) { return { status: 'needs_seat_decision', assistantMessage: '新企微账号已完成扫码,当前订阅席位已满。请在登录流程页选择新增购买席位、替换已有账号或取消本次登录。', summary: { loggedIn: false, uid, statusCode: 2, decisionRequired: true, seats: data.seats, usedSeats: data.usedSeats }, data: { ...data, uid, status: detail }, files: [], nextActions: ['在登录流程页完成席位处理'], warnings: [], errors: [] }; } const resolvedGuid = extractLoginGuid(data, detail) || readQiweiGuid(input); const guid = String(resolvedGuid || '').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', '验证码校验失败'); } } async function releaseQiweiDevice(input = {}) { const token = readQiweiAuthToken(input); if (!token) { const error = new Error('缺少 Fmode 鉴权 token'); error.kind = 'auth'; error.httpStatus = 401; throw error; } const uid = String(input.uid || readQiweiUid(input) || '').trim(); const guid = readQiweiGuid({ ...input, uid }); if (!uid) return { uid: '', guid: '', upstream: { logout: false, stopClient: false, alreadyReleased: true }, skipped: true }; const apiBase = readQiweiApiBase(input); const upstream = { logout: false, stopClient: false, alreadyReleased: false }; const isAlreadyReleased = error => { const text = String(error?.bizMessage || error?.message || ''); return error?.kind === 'device' || /未登录|离线|设备不存在|not found|不存在|already/i.test(text); }; if (guid) { try { await callFmodeWecomGateway({ gatewayPath: '/doApi', httpMethod: 'POST', body: { uid, method: '/user/logout', params: { guid } }, token, apiBase }); upstream.logout = true; } catch (error) { if (!isAlreadyReleased(error)) throw error; upstream.alreadyReleased = true; } try { await callFmodeWecomGateway({ gatewayPath: '/doApi', httpMethod: 'POST', body: { uid, method: '/client/stopClient', params: { guid } }, token, apiBase }); upstream.stopClient = true; } catch (error) { if (!isAlreadyReleased(error)) throw error; upstream.alreadyReleased = true; } } else { // 没有 GUID 时仍通知网关退出,兼容尚未完成扫码的临时设备。 try { await callFmodeWecomGateway({ gatewayPath: '/doApi', httpMethod: 'POST', body: { uid, method: '/user/logout', params: {} }, token, apiBase }); upstream.logout = true; } catch (error) { if (!isAlreadyReleased(error)) throw error; upstream.alreadyReleased = true; } } return { uid, guid, upstream }; } async function qiweiUserLogout(input = {}) { const token = readQiweiAuthToken(input); if (!token) return authRequiredResult(); const uid = String(input.uid || readQiweiUid(input) || '').trim(); const guid = readQiweiGuid({ ...input, uid }); try { const released = await releaseQiweiDevice({ ...input, uid, guid }); const cleared = clearQiweiClientConfig({ uid, guid, envRoot: process.env.QIWEI_WORKSPACE_ROOT || process.cwd() }); if (!cleared.cleared) { return errorResult(`上游账号已退出,但本地凭据清理失败:${(cleared.errors || []).map(item => item.message).join(';') || cleared.reason || '未知错误'}`, { summary: { uid, guid, loggedOut: true, credentialsCleared: false, upstream: released.upstream }, data: released, nextActions: ['检查工作台 .env.local 后重新打开登录页'] }); } // Drop process-local account state as well as the persisted UID/GUID. A // subsequent login must start with a new device context in this process. setActiveQiweiContext({}); stopLoginFlowServer(); return okResult({ assistantMessage: released.upstream.alreadyReleased ? '当前企微设备已是离线状态,本地账号凭据已清理,可重新扫码登录。' : '已退出并释放当前企微设备,本地账号凭据已清理,可重新扫码登录。', summary: { uid, guid, loggedOut: true, credentialsCleared: true, upstream: released.upstream }, data: released, nextActions: ['调用 qiwei_login_start 生成新账号登录二维码'] }); } catch (error) { // 即使网关暂时不可用,也清掉本地设备上下文,避免下一次登录继续复用旧 GUID。 const cleared = clearQiweiClientConfig({ uid, guid, envRoot: process.env.QIWEI_WORKSPACE_ROOT || process.cwd() }); const result = gatewayErrorResult(error, 'userLogout', '退出企微账号失败'); setActiveQiweiContext({}); stopLoginFlowServer(); if (cleared.cleared) { result.assistantMessage = `${result.assistantMessage} 本地账号凭据已清理,可重新打开登录页。`; result.summary = { ...(result.summary || {}), uid, guid, credentialsCleared: true }; result.data = { uid, guid, credentialsCleared: true }; } return result; } } module.exports = { qiweiLoginStatus, qiweiLoginStart, qiweiLoginCheck, qiweiLoginVerify, qiweiUserLogout, releaseQiweiDevice };