fmode-agent-transport.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. 'use strict';
  2. const fs = require('fs');
  3. const path = require('path');
  4. const {
  5. readQiweiAuthToken,
  6. readQiweiUid,
  7. readQiweiGuid,
  8. readQiweiApiBase,
  9. } = require('../core/credentials');
  10. const { callFmodeWecomGateway } = require('./fmode-wecom-gateway');
  11. class FmodeQiweiClient {
  12. constructor(config = {}) {
  13. this.config = config;
  14. }
  15. context() {
  16. return {
  17. token: readQiweiAuthToken({ authToken: this.config.authToken }),
  18. uid: readQiweiUid({ uid: this.config.uid }),
  19. guid: readQiweiGuid({ guid: this.config.guid, uid: this.config.uid }),
  20. apiBase: readQiweiApiBase({ apiBase: this.config.apiBase }),
  21. };
  22. }
  23. isConfigured() {
  24. const ctx = this.context();
  25. return Boolean(ctx.token && ctx.uid && ctx.apiBase);
  26. }
  27. requireContext() {
  28. const ctx = this.context();
  29. if (!ctx.token) throw new Error('缺少 Fmode 鉴权,请先在 Fmode Studio 中完成登录');
  30. if (!ctx.uid) throw new Error('缺少 Fmode 企微设备 uid,请先完成企微扫码登录');
  31. return ctx;
  32. }
  33. async call(method, params = {}, options = {}) {
  34. const ctx = this.requireContext();
  35. const result = await callFmodeWecomGateway({
  36. gatewayPath: '/doApi',
  37. body: {
  38. uid: ctx.uid,
  39. method,
  40. params: ctx.guid ? { ...params, guid: params.guid || ctx.guid } : params,
  41. },
  42. token: ctx.token,
  43. apiBase: ctx.apiBase,
  44. timeoutMs: 30000,
  45. networkAttempts: options.networkAttempts,
  46. preserveExternalUrls: Boolean(options.preserveExternalUrls),
  47. });
  48. return result.data && result.data.data !== undefined ? result.data.data : (result.data || {});
  49. }
  50. async checkLogin() {
  51. const ctx = this.requireContext();
  52. let result;
  53. try {
  54. result = await callFmodeWecomGateway({
  55. gatewayPath: '/login/status',
  56. httpMethod: 'GET',
  57. query: { uid: ctx.uid },
  58. token: ctx.token,
  59. apiBase: ctx.apiBase,
  60. timeoutMs: 30000,
  61. });
  62. } catch (error) {
  63. if (!this.config.trustConfiguredOnStatusError) throw error;
  64. return {
  65. configured: true,
  66. online: true,
  67. userOnlineStatus: 2,
  68. errorCode: 0,
  69. nickname: this.config.nickname || '',
  70. corpName: this.config.corpName || '',
  71. statusFallback: true,
  72. };
  73. }
  74. const data = result.data || {};
  75. const detail = data.detail || {};
  76. return {
  77. ...detail,
  78. configured: Boolean(data.configured),
  79. online: Boolean(data.online),
  80. userOnlineStatus: data.online ? 2 : (data.statusCode ?? 0),
  81. errorCode: data.online ? 0 : (detail.errorCode ?? -1),
  82. nickname: detail.nickname || detail.userName || '',
  83. corpName: detail.corpName || detail.corpFullName || '',
  84. };
  85. }
  86. syncMessages(msgSeq, limit) {
  87. return this.call('/msg/syncMsg', { msgSeq, limit }, { preserveExternalUrls: true });
  88. }
  89. downloadMessageMedia(params) {
  90. return this.call('/cloud/wxDownload', params, { preserveExternalUrls: true, networkAttempts: 2 });
  91. }
  92. downloadWorkMessageMedia(params) {
  93. return this.call('/cloud/wxWorkDownload', params, { preserveExternalUrls: true, networkAttempts: 2 });
  94. }
  95. convertCdnMessageMedia(params) {
  96. return this.call('/cloud/cdnWxDownload', params, { preserveExternalUrls: true, networkAttempts: 2 });
  97. }
  98. async listExternalContacts({ limit = 500, maxPages = 10 } = {}) {
  99. const pageSize = Math.max(20, Math.min(1000, Number(limit) || 500));
  100. const pageLimit = Math.max(1, Math.min(20, Number(maxPages) || 10));
  101. const contacts = [];
  102. const seen = new Set();
  103. let currentSeq = 0;
  104. let pages = 0;
  105. let reportedCount = 0;
  106. while (pages < pageLimit) {
  107. const data = await this.call('/contact/getWxContactList', {
  108. currentSeq,
  109. limit: pageSize,
  110. bizType: 1,
  111. });
  112. const list = Array.isArray(data.contactList) ? data.contactList : [];
  113. reportedCount = Math.max(reportedCount, Number(data.contactCount) || 0);
  114. for (const item of list) {
  115. const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
  116. if (!id || seen.has(id)) continue;
  117. seen.add(id);
  118. contacts.push(item);
  119. }
  120. pages += 1;
  121. const nextSeq = Number(data.currentSeq);
  122. if (!data.hasMore || !list.length || !Number.isFinite(nextSeq) || nextSeq === currentSeq) break;
  123. currentSeq = nextSeq;
  124. }
  125. const missingNames = contacts
  126. .filter(item => !String(item.remark || item.nickname || item.nickName || item.userName || item.name || '').trim())
  127. .map(item => String(item.userId || item.externalUserId || item.id || '').trim())
  128. .filter(Boolean);
  129. const details = new Map();
  130. for (let index = 0; index < missingNames.length; index += 100) {
  131. try {
  132. const data = await this.call('/contact/batchGetUserinfo', { userIdList: missingNames.slice(index, index + 100) });
  133. for (const item of Array.isArray(data.contactList) ? data.contactList : []) {
  134. const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
  135. if (id) details.set(id, item);
  136. }
  137. } catch {
  138. break;
  139. }
  140. }
  141. return {
  142. contacts: contacts.map(item => {
  143. const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
  144. return { ...item, ...(details.get(id) || {}) };
  145. }),
  146. contactCount: Math.max(reportedCount, contacts.length),
  147. pages,
  148. };
  149. }
  150. sendText(toId, content) {
  151. return this.call('/msg/sendText', { toId, content, isNoNeedRead: false });
  152. }
  153. sendLocation(toId, { title, address, latitude, longitude } = {}) {
  154. if (!String(toId || '').trim()) throw new Error('位置消息缺少接收人');
  155. if (!String(title || address || '').trim()) throw new Error('位置消息缺少名称或地址');
  156. if (!Number.isFinite(Number(latitude)) || !Number.isFinite(Number(longitude))) {
  157. throw new Error('位置消息需要有效经纬度');
  158. }
  159. return this.call('/msg/sendLocation', {
  160. toId,
  161. title: String(title || ''),
  162. address: String(address || ''),
  163. latitude: Number(latitude),
  164. longitude: Number(longitude),
  165. });
  166. }
  167. async uploadImageByUrl(fileUrl, filename = 'cover.jpg') {
  168. if (!String(fileUrl || '').trim()) throw new Error('待上传的图片 URL 不能为空');
  169. const data = await this.call('/cloud/cdnBigUploadByUrl', {
  170. filename: String(filename || 'cover.jpg'),
  171. fileUrl: String(fileUrl),
  172. fileType: 1,
  173. });
  174. if (!data.fileId) throw new Error('企微图片 URL 上传未返回有效 fileId');
  175. return data;
  176. }
  177. async sendWeapp(toId, {
  178. appId,
  179. username,
  180. title,
  181. desc = '',
  182. pagePath,
  183. coverUrl = '',
  184. coverFileId = '',
  185. coverFileAesKey = '',
  186. coverFileSize = 0,
  187. } = {}) {
  188. if (!String(toId || '').trim()) throw new Error('小程序卡片缺少接收人');
  189. if (!String(appId || '').trim()) throw new Error('小程序 appId 不能为空');
  190. if (!String(username || '').trim()) throw new Error('小程序 username(原始 id,gh_ 开头)未配置');
  191. if (!String(title || '').trim()) throw new Error('小程序卡片标题不能为空');
  192. const rawUsername = String(username).trim();
  193. const normalizedUsername = /@app$/.test(rawUsername) ? rawUsername : `${rawUsername}@app`;
  194. let file = { coverFileId, coverFileAesKey, coverFileSize };
  195. let coverUploadWarning = '';
  196. if (!file.coverFileId && coverUrl) {
  197. try {
  198. const uploaded = await this.uploadImageByUrl(coverUrl, `cover_${Date.now()}.jpg`);
  199. file = {
  200. coverFileId: uploaded.fileId,
  201. coverFileAesKey: uploaded.fileAesKey || '',
  202. coverFileSize: uploaded.fileSize || 0,
  203. };
  204. } catch (error) {
  205. coverUploadWarning = `封面上传失败:${error.message}`;
  206. file = { coverFileId: '', coverFileAesKey: '', coverFileSize: 0 };
  207. }
  208. }
  209. const result = await this.call('/msg/sendWeapp', {
  210. toId,
  211. appId: String(appId),
  212. username: normalizedUsername,
  213. title: String(title),
  214. desc: String(desc || ''),
  215. pagePath: String(pagePath || ''),
  216. thumbUrl: String(coverUrl || ''),
  217. coverFileId: file.coverFileId,
  218. coverFileAesKey: file.coverFileAesKey,
  219. coverFileSize: file.coverFileSize,
  220. });
  221. return result && typeof result === 'object'
  222. ? { ...result, coverUploadWarning: coverUploadWarning || undefined }
  223. : result;
  224. }
  225. async uploadVoiceFile(filePath) {
  226. const ctx = this.requireContext();
  227. const resolved = path.resolve(String(filePath || ''));
  228. if (!fs.existsSync(resolved)) throw new Error('待上传的 SILK 文件不存在');
  229. // Retry transient gateway/network failures before falling back to URL upload.
  230. let directError = null;
  231. for (let attempt = 1; attempt <= 3; attempt += 1) {
  232. const form = new FormData();
  233. form.append('uid', ctx.uid);
  234. form.append('method', '/cloud/cdnBigUpload');
  235. if (ctx.guid) form.append('guid', ctx.guid);
  236. form.append('fileType', '5');
  237. form.append('file', new Blob([fs.readFileSync(resolved)], { type: 'application/octet-stream' }), path.basename(resolved));
  238. try {
  239. const response = await fetch(`${ctx.apiBase.replace(/\/$/, '')}/doFileApi`, {
  240. method: 'POST',
  241. headers: { Authorization: `Bearer ${ctx.token}`, Accept: 'application/json' },
  242. body: form,
  243. signal: AbortSignal.timeout(30000),
  244. });
  245. const raw = await response.text();
  246. let parsed;
  247. try { parsed = JSON.parse(raw); } catch { parsed = {}; }
  248. if (response.ok && [0, 200].includes(Number(parsed.code ?? 200))) {
  249. const data = parsed.data?.data ?? parsed.data ?? parsed;
  250. if (data.fileId && data.fileAesKey) return data;
  251. }
  252. if (response.status === 404) {
  253. directError = new Error('Fmode 网关尚未开放 /doFileApi');
  254. break;
  255. }
  256. directError = new Error(parsed.mess || parsed.message || parsed.msg || `媒体上传失败(HTTP ${response.status})`);
  257. const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
  258. if (!retryable) break;
  259. } catch (error) {
  260. directError = error;
  261. }
  262. if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 500));
  263. }
  264. throw new Error(`Fmode /doFileApi 媒体上传失败:${directError?.message || '未返回有效媒体信息'}`);
  265. }
  266. sendVoice(toId, media) {
  267. return this.call('/msg/sendVoice', {
  268. toId,
  269. fileAesKey: media.fileAesKey,
  270. fileId: media.fileId,
  271. fileSize: media.fileSize,
  272. voiceTime: media.voiceTime,
  273. }, { networkAttempts: 1 });
  274. }
  275. }
  276. module.exports = { FmodeQiweiClient };