| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- '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 };
|