'use strict'; const fs = require('fs'); const path = require('path'); const { readQiweiAuthToken, readQiweiUid, readQiweiGuid, readQiweiApiBase, } = require('../core/credentials'); const { callFmodeWecomGateway } = require('./fmode-wecom-gateway'); class FmodeQiweiClient { constructor(config = {}) { this.config = config; } context() { return { token: readQiweiAuthToken({ authToken: this.config.authToken }), uid: readQiweiUid({ uid: this.config.uid }), guid: readQiweiGuid({ guid: this.config.guid, uid: this.config.uid }), apiBase: readQiweiApiBase({ apiBase: this.config.apiBase }), }; } isConfigured() { const ctx = this.context(); return Boolean(ctx.token && ctx.uid && ctx.apiBase); } requireContext() { const ctx = this.context(); if (!ctx.token) throw new Error('缺少 Fmode 鉴权,请先在 Fmode Studio 中完成登录'); if (!ctx.uid) throw new Error('缺少 Fmode 企微设备 uid,请先完成企微扫码登录'); return ctx; } async call(method, params = {}, options = {}) { const ctx = this.requireContext(); const result = await callFmodeWecomGateway({ gatewayPath: '/doApi', body: { uid: ctx.uid, method, params: ctx.guid ? { ...params, guid: params.guid || ctx.guid } : params, }, token: ctx.token, apiBase: ctx.apiBase, timeoutMs: 30000, networkAttempts: options.networkAttempts, preserveExternalUrls: Boolean(options.preserveExternalUrls), }); return result.data && result.data.data !== undefined ? result.data.data : (result.data || {}); } async checkLogin() { const ctx = this.requireContext(); let result; try { result = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase, timeoutMs: 30000, }); } catch (error) { if (!this.config.trustConfiguredOnStatusError) throw error; return { configured: true, online: true, userOnlineStatus: 2, errorCode: 0, nickname: this.config.nickname || '', corpName: this.config.corpName || '', statusFallback: true, }; } const data = result.data || {}; const detail = data.detail || {}; return { ...detail, configured: Boolean(data.configured), online: Boolean(data.online), userOnlineStatus: data.online ? 2 : (data.statusCode ?? 0), errorCode: data.online ? 0 : (detail.errorCode ?? -1), nickname: detail.nickname || detail.userName || '', corpName: detail.corpName || detail.corpFullName || '', }; } syncMessages(msgSeq, limit) { return this.call('/msg/syncMsg', { msgSeq, limit }, { preserveExternalUrls: true }); } downloadMessageMedia(params) { return this.call('/cloud/wxDownload', params, { preserveExternalUrls: true, networkAttempts: 2 }); } downloadWorkMessageMedia(params) { return this.call('/cloud/wxWorkDownload', params, { preserveExternalUrls: true, networkAttempts: 2 }); } convertCdnMessageMedia(params) { return this.call('/cloud/cdnWxDownload', params, { preserveExternalUrls: true, networkAttempts: 2 }); } async listExternalContacts({ limit = 500, maxPages = 10 } = {}) { const pageSize = Math.max(20, Math.min(1000, Number(limit) || 500)); const pageLimit = Math.max(1, Math.min(20, Number(maxPages) || 10)); const contacts = []; const seen = new Set(); let currentSeq = 0; let pages = 0; let reportedCount = 0; while (pages < pageLimit) { const data = await this.call('/contact/getWxContactList', { currentSeq, limit: pageSize, bizType: 1, }); const list = Array.isArray(data.contactList) ? data.contactList : []; reportedCount = Math.max(reportedCount, Number(data.contactCount) || 0); for (const item of list) { const id = String(item?.userId || item?.externalUserId || item?.id || '').trim(); if (!id || seen.has(id)) continue; seen.add(id); contacts.push(item); } pages += 1; const nextSeq = Number(data.currentSeq); if (!data.hasMore || !list.length || !Number.isFinite(nextSeq) || nextSeq === currentSeq) break; currentSeq = nextSeq; } const missingNames = contacts .filter(item => !String(item.remark || item.nickname || item.nickName || item.userName || item.name || '').trim()) .map(item => String(item.userId || item.externalUserId || item.id || '').trim()) .filter(Boolean); const details = new Map(); for (let index = 0; index < missingNames.length; index += 100) { try { const data = await this.call('/contact/batchGetUserinfo', { userIdList: missingNames.slice(index, index + 100) }); for (const item of Array.isArray(data.contactList) ? data.contactList : []) { const id = String(item?.userId || item?.externalUserId || item?.id || '').trim(); if (id) details.set(id, item); } } catch { break; } } return { contacts: contacts.map(item => { const id = String(item?.userId || item?.externalUserId || item?.id || '').trim(); return { ...item, ...(details.get(id) || {}) }; }), contactCount: Math.max(reportedCount, contacts.length), pages, }; } sendText(toId, content) { return this.call('/msg/sendText', { toId, content, isNoNeedRead: false }); } sendLocation(toId, { title, address, latitude, longitude } = {}) { if (!String(toId || '').trim()) throw new Error('位置消息缺少接收人'); if (!String(title || address || '').trim()) throw new Error('位置消息缺少名称或地址'); if (!Number.isFinite(Number(latitude)) || !Number.isFinite(Number(longitude))) { throw new Error('位置消息需要有效经纬度'); } return this.call('/msg/sendLocation', { toId, title: String(title || ''), address: String(address || ''), latitude: Number(latitude), longitude: Number(longitude), }); } async uploadImageByUrl(fileUrl, filename = 'cover.jpg') { if (!String(fileUrl || '').trim()) throw new Error('待上传的图片 URL 不能为空'); const data = await this.call('/cloud/cdnBigUploadByUrl', { filename: String(filename || 'cover.jpg'), fileUrl: String(fileUrl), fileType: 1, }); if (!data.fileId) throw new Error('企微图片 URL 上传未返回有效 fileId'); return data; } async sendWeapp(toId, { appId, username, title, desc = '', pagePath, coverUrl = '', coverFileId = '', coverFileAesKey = '', coverFileSize = 0, } = {}) { if (!String(toId || '').trim()) throw new Error('小程序卡片缺少接收人'); if (!String(appId || '').trim()) throw new Error('小程序 appId 不能为空'); if (!String(username || '').trim()) throw new Error('小程序 username(原始 id,gh_ 开头)未配置'); if (!String(title || '').trim()) throw new Error('小程序卡片标题不能为空'); const rawUsername = String(username).trim(); const normalizedUsername = /@app$/.test(rawUsername) ? rawUsername : `${rawUsername}@app`; let file = { coverFileId, coverFileAesKey, coverFileSize }; let coverUploadWarning = ''; if (!file.coverFileId && coverUrl) { try { const uploaded = await this.uploadImageByUrl(coverUrl, `cover_${Date.now()}.jpg`); file = { coverFileId: uploaded.fileId, coverFileAesKey: uploaded.fileAesKey || '', coverFileSize: uploaded.fileSize || 0, }; } catch (error) { coverUploadWarning = `封面上传失败:${error.message}`; file = { coverFileId: '', coverFileAesKey: '', coverFileSize: 0 }; } } const result = await this.call('/msg/sendWeapp', { toId, appId: String(appId), username: normalizedUsername, title: String(title), desc: String(desc || ''), pagePath: String(pagePath || ''), thumbUrl: String(coverUrl || ''), coverFileId: file.coverFileId, coverFileAesKey: file.coverFileAesKey, coverFileSize: file.coverFileSize, }); return result && typeof result === 'object' ? { ...result, coverUploadWarning: coverUploadWarning || undefined } : result; } async uploadVoiceFile(filePath) { const ctx = this.requireContext(); const resolved = path.resolve(String(filePath || '')); if (!fs.existsSync(resolved)) throw new Error('待上传的 SILK 文件不存在'); // Retry transient gateway/network failures before falling back to URL upload. let directError = null; for (let attempt = 1; attempt <= 3; attempt += 1) { const form = new FormData(); form.append('uid', ctx.uid); form.append('method', '/cloud/cdnBigUpload'); if (ctx.guid) form.append('guid', ctx.guid); form.append('fileType', '5'); form.append('file', new Blob([fs.readFileSync(resolved)], { type: 'application/octet-stream' }), path.basename(resolved)); try { const response = await fetch(`${ctx.apiBase.replace(/\/$/, '')}/doFileApi`, { method: 'POST', headers: { Authorization: `Bearer ${ctx.token}`, Accept: 'application/json' }, body: form, signal: AbortSignal.timeout(30000), }); const raw = await response.text(); let parsed; try { parsed = JSON.parse(raw); } catch { parsed = {}; } if (response.ok && [0, 200].includes(Number(parsed.code ?? 200))) { const data = parsed.data?.data ?? parsed.data ?? parsed; if (data.fileId && data.fileAesKey) return data; } if (response.status === 404) { directError = new Error('Fmode 网关尚未开放 /doFileApi'); break; } directError = new Error(parsed.mess || parsed.message || parsed.msg || `媒体上传失败(HTTP ${response.status})`); const retryable = response.status === 408 || response.status === 429 || response.status >= 500; if (!retryable) break; } catch (error) { directError = error; } if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 500)); } throw new Error(`Fmode /doFileApi 媒体上传失败:${directError?.message || '未返回有效媒体信息'}`); } sendVoice(toId, media) { return this.call('/msg/sendVoice', { toId, fileAesKey: media.fileAesKey, fileId: media.fileId, fileSize: media.fileSize, voiceTime: media.voiceTime, }, { networkAttempts: 1 }); } } module.exports = { FmodeQiweiClient };