fmode-agent-transport.js 13 KB

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