| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530 |
- 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 = [
- '<!DOCTYPE html>',
- '<html lang="zh-CN">',
- '<head><meta charset="utf-8"><title>企业微信扫码登录</title>',
- '<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>',
- '</head>',
- '<body>',
- '<h1>请用手机企业微信扫码登录</h1>',
- `<img src="${path.basename(qrcodeImagePath)}" alt="登录二维码">`,
- '<p>扫码后请在手机上确认登录。二维码约 4 分钟内有效,过期请重新生成。</p>',
- '</body></html>'
- ].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
- };
|