wechaty-puppet-matrix 0.0.54 → 0.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/cjs/src/engine-schema.d.ts +1 -0
  2. package/dist/cjs/src/engine-schema.d.ts.map +1 -1
  3. package/dist/cjs/src/matrix/messages/message-contact-card.d.ts +16 -0
  4. package/dist/cjs/src/matrix/messages/message-contact-card.d.ts.map +1 -0
  5. package/dist/cjs/src/matrix/messages/message-contact-card.js +25 -0
  6. package/dist/cjs/src/matrix/messages/message-emotion.d.ts +7 -0
  7. package/dist/cjs/src/matrix/messages/message-emotion.d.ts.map +1 -1
  8. package/dist/cjs/src/matrix/messages/message-emotion.js +20 -0
  9. package/dist/cjs/src/matrix/messages/message-image.d.ts +8 -0
  10. package/dist/cjs/src/matrix/messages/message-image.d.ts.map +1 -1
  11. package/dist/cjs/src/matrix/messages/message-image.js +38 -2
  12. package/dist/cjs/src/matrix/messages/message-image.spec.d.ts +3 -0
  13. package/dist/cjs/src/matrix/messages/message-image.spec.d.ts.map +1 -0
  14. package/dist/cjs/src/matrix/messages/message-image.spec.js +66 -0
  15. package/dist/cjs/src/matrix/messages/message-video.d.ts +6 -0
  16. package/dist/cjs/src/matrix/messages/message-video.d.ts.map +1 -1
  17. package/dist/cjs/src/matrix/messages/message-video.js +20 -0
  18. package/dist/cjs/src/matrix/messages/message-video.spec.d.ts +3 -0
  19. package/dist/cjs/src/matrix/messages/message-video.spec.d.ts.map +1 -0
  20. package/dist/cjs/src/matrix/messages/message-video.spec.js +27 -0
  21. package/dist/cjs/src/matrix/service/request.d.ts +21 -0
  22. package/dist/cjs/src/matrix/service/request.d.ts.map +1 -1
  23. package/dist/cjs/src/matrix/service/request.js +194 -32
  24. package/dist/cjs/src/matrix/utils/file-type.d.ts +4 -0
  25. package/dist/cjs/src/matrix/utils/file-type.d.ts.map +1 -0
  26. package/dist/cjs/src/matrix/utils/file-type.js +55 -0
  27. package/dist/cjs/src/matrix/utils/file-type.spec.d.ts +3 -0
  28. package/dist/cjs/src/matrix/utils/file-type.spec.d.ts.map +1 -0
  29. package/dist/cjs/src/matrix/utils/file-type.spec.js +25 -0
  30. package/dist/cjs/src/puppet-matrix.d.ts +3 -0
  31. package/dist/cjs/src/puppet-matrix.d.ts.map +1 -1
  32. package/dist/cjs/src/puppet-matrix.js +121 -33
  33. package/dist/esm/src/engine-schema.d.ts +1 -0
  34. package/dist/esm/src/engine-schema.d.ts.map +1 -1
  35. package/dist/esm/src/matrix/messages/message-contact-card.d.ts +16 -0
  36. package/dist/esm/src/matrix/messages/message-contact-card.d.ts.map +1 -0
  37. package/dist/esm/src/matrix/messages/message-contact-card.js +22 -0
  38. package/dist/esm/src/matrix/messages/message-emotion.d.ts +7 -0
  39. package/dist/esm/src/matrix/messages/message-emotion.d.ts.map +1 -1
  40. package/dist/esm/src/matrix/messages/message-emotion.js +19 -0
  41. package/dist/esm/src/matrix/messages/message-image.d.ts +8 -0
  42. package/dist/esm/src/matrix/messages/message-image.d.ts.map +1 -1
  43. package/dist/esm/src/matrix/messages/message-image.js +37 -2
  44. package/dist/esm/src/matrix/messages/message-image.spec.d.ts +3 -0
  45. package/dist/esm/src/matrix/messages/message-image.spec.d.ts.map +1 -0
  46. package/dist/esm/src/matrix/messages/message-image.spec.js +64 -0
  47. package/dist/esm/src/matrix/messages/message-video.d.ts +6 -0
  48. package/dist/esm/src/matrix/messages/message-video.d.ts.map +1 -1
  49. package/dist/esm/src/matrix/messages/message-video.js +19 -0
  50. package/dist/esm/src/matrix/messages/message-video.spec.d.ts +3 -0
  51. package/dist/esm/src/matrix/messages/message-video.spec.d.ts.map +1 -0
  52. package/dist/esm/src/matrix/messages/message-video.spec.js +25 -0
  53. package/dist/esm/src/matrix/service/request.d.ts +21 -0
  54. package/dist/esm/src/matrix/service/request.d.ts.map +1 -1
  55. package/dist/esm/src/matrix/service/request.js +193 -32
  56. package/dist/esm/src/matrix/utils/file-type.d.ts +4 -0
  57. package/dist/esm/src/matrix/utils/file-type.d.ts.map +1 -0
  58. package/dist/esm/src/matrix/utils/file-type.js +47 -0
  59. package/dist/esm/src/matrix/utils/file-type.spec.d.ts +3 -0
  60. package/dist/esm/src/matrix/utils/file-type.spec.d.ts.map +1 -0
  61. package/dist/esm/src/matrix/utils/file-type.spec.js +23 -0
  62. package/dist/esm/src/puppet-matrix.d.ts +3 -0
  63. package/dist/esm/src/puppet-matrix.d.ts.map +1 -1
  64. package/dist/esm/src/puppet-matrix.js +121 -33
  65. package/package.json +1 -1
  66. package/src/engine-schema.ts +1 -0
  67. package/src/matrix/messages/message-contact-card.ts +48 -0
  68. package/src/matrix/messages/message-emotion.ts +36 -0
  69. package/src/matrix/messages/message-image.spec.ts +107 -0
  70. package/src/matrix/messages/message-image.ts +63 -2
  71. package/src/matrix/messages/message-video.spec.ts +37 -0
  72. package/src/matrix/messages/message-video.ts +28 -1
  73. package/src/matrix/service/request.ts +274 -32
  74. package/src/matrix/utils/file-type.spec.ts +29 -0
  75. package/src/matrix/utils/file-type.ts +59 -0
  76. package/src/puppet-matrix.ts +150 -35
@@ -275,6 +275,7 @@ export interface MessagePayload {
275
275
  atWxidList?: string[]; // 消息中艾特人wxid列表
276
276
  msg: string; // 消息内容
277
277
  listenerId: string; //
278
+ clientMsgId?: number; // 发送接口返回的 client_msg_id,撤回时需要
278
279
  }
279
280
 
280
281
  export interface BaseEvent {
@@ -285,12 +286,29 @@ export interface BaseEvent {
285
286
  robotInfo?: ContactPayload;
286
287
  }
287
288
 
289
+ export interface CdnDownloadCandidate {
290
+ file_id: string;
291
+ file_size: string | number;
292
+ /** 与默认 aes_key 不同时才需要,例如原始视频 cdnrawvideoaeskey */
293
+ aes_key?: string;
294
+ }
295
+
288
296
  export interface ImageMessagePayload {
289
297
  aes_key: string;
290
298
  auth_key: string;
291
299
  file_id: string;
292
300
  file_size?: string | number;
293
301
  file_name?: string;
302
+ /** 图片下载的候选 (file_id, file_size),按顺序回落 */
303
+ candidates?: CdnDownloadCandidate[];
304
+ }
305
+
306
+ /**
307
+ * 解析后端 "download plaintext size differs: expected X, got Y",返回实际大小 Y
308
+ */
309
+ export function parseSizeMismatch (errmsg?: string): string | undefined {
310
+ const match = typeof errmsg === 'string' ? errmsg.match(/size differs: expected \d+, got (\d+)/) : null
311
+ return match && Number(match[1]) > 0 ? match[1] : undefined
294
312
  }
295
313
 
296
314
  export interface SnsImageMessagePayload {
@@ -336,6 +354,7 @@ export interface VideoMessagePayload {
336
354
  thumb_width: string;
337
355
  thumb_height: string;
338
356
  file_name: '';
357
+ candidates?: CdnDownloadCandidate[];
339
358
  }
340
359
 
341
360
  export enum FriendshipType {
@@ -669,6 +688,14 @@ export interface MomentInfo {
669
688
  parentId: string
670
689
  }
671
690
 
691
+ /** 从发送接口响应中取 client_msg_id(撤回消息需要) */
692
+ function pickClientMsgId (res: any): number | undefined {
693
+ const item = res?.list?.[0] || res
694
+ const v = item?.clientMsgId ?? item?.client_msg_id ?? item?.clientMsgid
695
+ const n = typeof v === 'string' ? parseInt(v, 10) : v
696
+ return typeof n === 'number' && !isNaN(n) ? n : undefined
697
+ }
698
+
672
699
  class Client extends EventEmitter {
673
700
 
674
701
  private readonly options: PuppetMatrixOptions
@@ -1849,6 +1876,7 @@ class Client extends EventEmitter {
1849
1876
 
1850
1877
  return {
1851
1878
  id: res?.list?.[0].newMsgId || '',
1879
+ clientMsgId: pickClientMsgId(res),
1852
1880
  timeStamp: res?.list?.[0].createTime || '',
1853
1881
  msgType: res?.list?.[0].type || '',
1854
1882
  talkerId: selfId || '',
@@ -1889,6 +1917,7 @@ class Client extends EventEmitter {
1889
1917
 
1890
1918
  return {
1891
1919
  id: res?.list?.[0].newMsgId || '',
1920
+ clientMsgId: pickClientMsgId(res),
1892
1921
  timeStamp: res?.list?.[0].createTime || '',
1893
1922
  msgType: res?.list?.[0].type || '',
1894
1923
  talkerId: selfId || '',
@@ -1917,8 +1946,7 @@ class Client extends EventEmitter {
1917
1946
  })
1918
1947
  }
1919
1948
 
1920
- private async retryDownload (fn: () => Promise<any>): Promise<any> {
1921
- const maxRetries = 5
1949
+ private async retryDownload (fn: () => Promise<any>, maxRetries = 5): Promise<any> {
1922
1950
  const delayMs = 5000
1923
1951
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1924
1952
  try {
@@ -1927,6 +1955,10 @@ class Client extends EventEmitter {
1927
1955
  return res
1928
1956
  }
1929
1957
  log.warn(PRE, 'download attempt %d/%d failed (no url in response): %s', attempt, maxRetries, JSON.stringify(res))
1958
+ // size 不匹配是确定性错误,重试同一个 size 没有意义,交给调用方换 size
1959
+ if (parseSizeMismatch(res?.errmsg)) {
1960
+ return res
1961
+ }
1930
1962
  } catch (e) {
1931
1963
  log.warn(PRE, 'download attempt %d/%d error: %s', attempt, maxRetries, e)
1932
1964
  }
@@ -1937,21 +1969,66 @@ class Client extends EventEmitter {
1937
1969
  return null
1938
1970
  }
1939
1971
 
1972
+ /**
1973
+ * 按候选 (file_id, file_size) 依次下载 CDN 文件。
1974
+ * - 后端返回 "plaintext size differs: expected X, got Y" 时立即用 Y 重试同一个 file_id
1975
+ * - 其它失败按 retriesPerCandidate 重试后回落到下一个候选
1976
+ */
1977
+ private async cdnDownloadWithFallback (
1978
+ label: string,
1979
+ baseParams: Record<string, any>,
1980
+ candidates: CdnDownloadCandidate[],
1981
+ ): Promise<string> {
1982
+ const queue = [ ...candidates ]
1983
+ const tried = new Set<string>()
1984
+ // 只有一个候选时保持原来的 5 次重试;有多个候选时每个只试 2 次就回落
1985
+ const retriesPerCandidate = candidates.length > 1 ? 2 : 5
1986
+
1987
+ while (queue.length) {
1988
+ const candidate = queue.shift()!
1989
+ const key = `${candidate.file_id}|${candidate.file_size}|${candidate.aes_key || ''}`
1990
+ if (!candidate.file_id || tried.has(key)) {
1991
+ continue
1992
+ }
1993
+ tried.add(key)
1994
+
1995
+ const params = {
1996
+ ...baseParams,
1997
+ file_id: candidate.file_id,
1998
+ file_size: candidate.file_size,
1999
+ ...(candidate.aes_key ? { aes_key: candidate.aes_key } : {}),
2000
+ }
2001
+ const res = await this.retryDownload(() => this.postData({ path: '/cloud/cdn_download', data: params }), retriesPerCandidate)
2002
+ if (res?.url) {
2003
+ if (tried.size > 1) {
2004
+ log.info(PRE, '%s succeeded with fallback, file_size: %s', label, candidate.file_size)
2005
+ }
2006
+ return encodeURI(res.url) || ''
2007
+ }
2008
+
2009
+ const actualSize = parseSizeMismatch(res?.errmsg)
2010
+ if (actualSize) {
2011
+ log.warn(PRE, '%s size mismatch (xml %s, actual %s), retry with actual size', label, candidate.file_size, actualSize)
2012
+ queue.unshift({ ...candidate, file_size: actualSize })
2013
+ continue
2014
+ }
2015
+ log.warn(PRE, '%s candidate failed, params: %s', label, JSON.stringify(params))
2016
+ }
2017
+
2018
+ log.error(PRE, '%s failed for all candidates: %s', label, JSON.stringify(candidates))
2019
+ return ''
2020
+ }
2021
+
1940
2022
  public async downloadImage (imageInfo: ImageMessagePayload): Promise<any> {
1941
- const params = {
2023
+ const candidates = imageInfo.candidates?.length
2024
+ ? imageInfo.candidates
2025
+ : [ { file_id: imageInfo.file_id, file_size: imageInfo.file_size || '' } ]
2026
+ return this.cdnDownloadWithFallback('downloadImage', {
1942
2027
  log_response: true,
1943
2028
  file_type: 2,
1944
- file_id: imageInfo.file_id,
1945
2029
  aes_key: imageInfo.aes_key,
1946
- file_size: imageInfo.file_size,
1947
2030
  file_name: `/微信图片_${format(new Date(), 'yyyyMMddHHmmss')}.png`,
1948
- }
1949
- const res = await this.retryDownload(() => this.postData({ path: '/cloud/cdn_download', data: params }))
1950
- if (res?.url) {
1951
- return encodeURI(res.url) || ''
1952
- }
1953
- log.error(PRE, 'downloadImage failed after %d retries, params: %s', 5, JSON.stringify(params))
1954
- return ''
2031
+ }, candidates)
1955
2032
  }
1956
2033
 
1957
2034
  public async downloadWWMedia (imageInfo: ImageMessagePayload, type: number): Promise<any> {
@@ -1981,20 +2058,12 @@ class Client extends EventEmitter {
1981
2058
  * @param imageInfo
1982
2059
  */
1983
2060
  public async downloadFile (imageInfo: ImageMessagePayload): Promise<any> {
1984
- const params = {
2061
+ return this.cdnDownloadWithFallback('downloadFile', {
1985
2062
  log_response: true,
1986
2063
  file_type: 5,
1987
- file_id: imageInfo.file_id.replace('@cdn_', ''),
1988
2064
  aes_key: imageInfo.aes_key,
1989
- file_size: imageInfo.file_size,
1990
2065
  file_name: '/' + sanitizeFilename(imageInfo.file_name),
1991
- }
1992
- const res = await this.retryDownload(() => this.postData({ path: '/cloud/cdn_download', data: params }))
1993
- if (res?.url) {
1994
- return encodeURI(res.url) || ''
1995
- }
1996
- log.error(PRE, 'downloadFile failed after %d retries, params: %s', 5, JSON.stringify(params))
1997
- return ''
2066
+ }, [ { file_id: imageInfo.file_id.replace('@cdn_', ''), file_size: imageInfo.file_size || '' } ])
1998
2067
  }
1999
2068
 
2000
2069
  /**
@@ -2100,19 +2169,14 @@ class Client extends EventEmitter {
2100
2169
  * @param imageInfo
2101
2170
  */
2102
2171
  public async downloadVideo (videoInfo: VideoMessagePayload): Promise<any> {
2103
- const params = {
2172
+ const candidates = videoInfo.candidates?.length
2173
+ ? videoInfo.candidates
2174
+ : [ { file_id: videoInfo.file_id, file_size: videoInfo.file_size } ]
2175
+ return this.cdnDownloadWithFallback('downloadVideo', {
2104
2176
  file_type: 4,
2105
- file_id: videoInfo.file_id,
2106
2177
  aes_key: videoInfo.aes_key,
2107
- file_size: videoInfo.file_size,
2108
2178
  file_name: `/微信视频_${format(new Date(), 'yyyyMMddHHmmss')}.mp4`,
2109
- }
2110
- const res = await this.retryDownload(() => this.postData({ path: '/cloud/cdn_download', data: params }))
2111
- if (res?.url) {
2112
- return encodeURI(res.url) || ''
2113
- }
2114
- log.error(PRE, 'downloadVideo failed after %d retries, params: %s', 5, JSON.stringify(params))
2115
- return ''
2179
+ }, candidates)
2116
2180
  }
2117
2181
 
2118
2182
  /**
@@ -2144,6 +2208,7 @@ class Client extends EventEmitter {
2144
2208
  })
2145
2209
  return {
2146
2210
  id: res?.newMsgId || '',
2211
+ clientMsgId: pickClientMsgId(res),
2147
2212
  timeStamp: res?.createTime || '',
2148
2213
  msgType: WechatMessageType.Image,
2149
2214
  talkerId: selfId || '',
@@ -2187,6 +2252,7 @@ class Client extends EventEmitter {
2187
2252
 
2188
2253
  return {
2189
2254
  id: res?.newMsgId || '',
2255
+ clientMsgId: pickClientMsgId(res),
2190
2256
  timeStamp: res?.createTime || getUnixTime(new Date()),
2191
2257
  msgType: WechatMessageType.Video,
2192
2258
  talkerId: selfId || '',
@@ -2227,6 +2293,7 @@ class Client extends EventEmitter {
2227
2293
  })
2228
2294
  return {
2229
2295
  id: res?.newMsgId || '',
2296
+ clientMsgId: pickClientMsgId(res),
2230
2297
  timeStamp: res?.createTime || '',
2231
2298
  msgType: WechatMessageType.File,
2232
2299
  talkerId: selfId || '',
@@ -2277,6 +2344,7 @@ class Client extends EventEmitter {
2277
2344
  })
2278
2345
  return {
2279
2346
  id: res?.newMsgId || '',
2347
+ clientMsgId: pickClientMsgId(res),
2280
2348
  timeStamp: res?.createTime || '',
2281
2349
  msgType: WechatMessageType.Text,
2282
2350
  talkerId: selfId || '',
@@ -2336,6 +2404,7 @@ class Client extends EventEmitter {
2336
2404
 
2337
2405
  return {
2338
2406
  id: res?.newMsgId || '',
2407
+ clientMsgId: pickClientMsgId(res),
2339
2408
  timeStamp: res?.createTime || '',
2340
2409
  msgType: WechatMessageType.MiniProgram,
2341
2410
  talkerId: selfId || '',
@@ -2375,6 +2444,7 @@ class Client extends EventEmitter {
2375
2444
 
2376
2445
  return {
2377
2446
  id: res?.newMsgId || '',
2447
+ clientMsgId: pickClientMsgId(res),
2378
2448
  timeStamp: res?.createTime || '',
2379
2449
  msgType: WechatMessageType.App,
2380
2450
  talkerId: selfId || '',
@@ -2616,6 +2686,7 @@ class Client extends EventEmitter {
2616
2686
  })
2617
2687
  return {
2618
2688
  id: res?.newMsgId || '',
2689
+ clientMsgId: pickClientMsgId(res),
2619
2690
  timeStamp: res?.createTime || '',
2620
2691
  msgType: WechatMessageType.App,
2621
2692
  talkerId: selfId || '',
@@ -2629,6 +2700,177 @@ class Client extends EventEmitter {
2629
2700
  }
2630
2701
  }
2631
2702
 
2703
+ /**
2704
+ * 发送名片(无需 xml)
2705
+ * @param toId 接收人/群 id
2706
+ * @param shareId 被分享的联系人 wxid
2707
+ * @param shareNickname 被分享人昵称
2708
+ * @param shareHeadImg 被分享人头像
2709
+ */
2710
+ public async sendContact (
2711
+ {
2712
+ toId,
2713
+ shareId,
2714
+ shareNickname,
2715
+ shareHeadImg,
2716
+ }: {
2717
+ toId: string;
2718
+ shareId: string;
2719
+ shareNickname?: string;
2720
+ shareHeadImg?: string;
2721
+ },
2722
+ selfId: string | undefined,
2723
+ ): Promise<MessagePayload | void> {
2724
+ try {
2725
+ const res = await this.postData({
2726
+ path: '/msg/send_share_card',
2727
+ data: {
2728
+ to_username: toId,
2729
+ share_username: shareId,
2730
+ share_nickname: shareNickname || '',
2731
+ share_head_img: shareHeadImg || '',
2732
+ },
2733
+ })
2734
+ return {
2735
+ id: res?.newMsgId || '',
2736
+ clientMsgId: pickClientMsgId(res),
2737
+ timeStamp: res?.createTime || '',
2738
+ msgType: WechatMessageType.ShareCard,
2739
+ talkerId: selfId || '',
2740
+ text: shareId,
2741
+ msg: shareId,
2742
+ fromWxid: isRoomId(toId) ? toId : '',
2743
+ listenerId: toId,
2744
+ }
2745
+ } catch (error) {
2746
+ log.error(PRE, 'sendContact(%s, %s): %s', toId, shareId, error)
2747
+ }
2748
+ }
2749
+
2750
+ /**
2751
+ * 通过 md5 发送表情(微信服务器已有该表情时可直接发送)
2752
+ */
2753
+ public async sendEmojiMd5 (
2754
+ toId: string,
2755
+ md5: string,
2756
+ size: number,
2757
+ selfId: string | undefined,
2758
+ ): Promise<MessagePayload | void> {
2759
+ try {
2760
+ const res = await this.postData({
2761
+ path: '/msg/send_emoji_md5',
2762
+ data: {
2763
+ to_username: toId,
2764
+ file_md5: md5,
2765
+ file_size: size,
2766
+ },
2767
+ })
2768
+ log.verbose(PRE, 'sendEmojiMd5 res: %s', JSON.stringify(res))
2769
+ return this.emojiResult(res, toId, md5, selfId)
2770
+ } catch (error) {
2771
+ log.error(PRE, 'sendEmojiMd5(%s, %s): %s', toId, md5, error)
2772
+ }
2773
+ }
2774
+
2775
+ /**
2776
+ * 通过图片 url 发送表情
2777
+ */
2778
+ public async sendEmojiUrl (
2779
+ toId: string,
2780
+ url: string,
2781
+ selfId: string | undefined,
2782
+ ): Promise<MessagePayload | void> {
2783
+ try {
2784
+ const res = await this.postData({
2785
+ path: '/msg/send_emoji_url',
2786
+ data: {
2787
+ to_username: toId,
2788
+ url,
2789
+ },
2790
+ })
2791
+ log.verbose(PRE, 'sendEmojiUrl res: %s', JSON.stringify(res))
2792
+ return this.emojiResult(res, toId, url, selfId)
2793
+ } catch (error) {
2794
+ log.error(PRE, 'sendEmojiUrl(%s, %s): %s', toId, url, error)
2795
+ }
2796
+ }
2797
+
2798
+ private emojiResult (res: any, toId: string, text: string, selfId: string | undefined): MessagePayload | void {
2799
+ // 返回格式: { baseResponse, emojiItem: [{ ret, msgId, newMsgId, md5, totalLen }] }
2800
+ const item = res?.emojiItem?.[0] || res?.list?.[0] || res
2801
+ const id = item?.newMsgId || item?.msgId
2802
+ if (!id || (item?.ret !== undefined && item.ret !== 0)) {
2803
+ log.warn(PRE, 'send emoji failed: %s', JSON.stringify(res))
2804
+ return
2805
+ }
2806
+ return {
2807
+ clientMsgId: pickClientMsgId(item),
2808
+ fromWxid: isRoomId(toId) ? toId : '',
2809
+ id: String(id),
2810
+ listenerId: toId,
2811
+ msg: text,
2812
+ msgType: WechatMessageType.Emoticon,
2813
+ talkerId: selfId || '',
2814
+ text,
2815
+ timeStamp: item?.createTime || '',
2816
+ }
2817
+ }
2818
+
2819
+ /**
2820
+ * 拍一拍
2821
+ * @param toId 会话 id(私聊为好友 wxid,群聊为群 id)
2822
+ * @param pattedId 被拍的人 wxid
2823
+ */
2824
+ public async sendPat (toId: string, pattedId: string): Promise<boolean> {
2825
+ try {
2826
+ const res = await this.postData({
2827
+ path: '/msg/send_pat',
2828
+ data: {
2829
+ to_username: toId,
2830
+ patted_username: pattedId,
2831
+ },
2832
+ })
2833
+ log.verbose(PRE, 'sendPat res: %s', JSON.stringify(res))
2834
+ if (!res || res.errcode || (res.baseResponse?.ret !== undefined && res.baseResponse.ret !== 0)) {
2835
+ log.warn(PRE, 'sendPat(%s, %s) failed: %s', toId, pattedId, JSON.stringify(res))
2836
+ return false
2837
+ }
2838
+ return true
2839
+ } catch (error) {
2840
+ log.error(PRE, 'sendPat(%s, %s): %s', toId, pattedId, error)
2841
+ return false
2842
+ }
2843
+ }
2844
+
2845
+ /**
2846
+ * 撤回消息
2847
+ * @param toId 消息所在会话(好友 wxid / 群 id)
2848
+ * @param msgId 发送返回的 newMsgId
2849
+ * @param clientMsgId 发送返回的 client_msg_id
2850
+ */
2851
+ public async revokeMsg (toId: string, msgId: string, clientMsgId: number | undefined): Promise<boolean> {
2852
+ try {
2853
+ const res = await this.postData({
2854
+ path: '/msg/revoke_msg',
2855
+ data: {
2856
+ to_username: toId,
2857
+ msg_id: msgId,
2858
+ client_msg_id: clientMsgId || 0,
2859
+ },
2860
+ })
2861
+ log.verbose(PRE, 'revokeMsg res: %s', JSON.stringify(res))
2862
+ const ret = res?.baseResponse?.ret
2863
+ if (ret !== undefined && ret !== 0) {
2864
+ log.warn(PRE, 'revokeMsg(%s, %s) failed: %s', toId, msgId, JSON.stringify(res?.baseResponse))
2865
+ return false
2866
+ }
2867
+ return !!res
2868
+ } catch (error) {
2869
+ log.error(PRE, 'revokeMsg(%s, %s): %s', toId, msgId, error)
2870
+ return false
2871
+ }
2872
+ }
2873
+
2632
2874
  /**
2633
2875
  * 发送群公告
2634
2876
  * @param roomId 群id
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env -S node -r ts-node/register
2
+ import { test } from 'tstest'
3
+
4
+ import { detectImageExt, replaceExt } from './file-type.js'
5
+
6
+ const hex = (s: string) => Buffer.from(s, 'hex')
7
+
8
+ test('detectImageExt recognizes common image headers', async t => {
9
+ // 实测微信图片下载结果的文件头
10
+ t.equal(detectImageExt(hex('ffd8fffe00104c61766335392e33372e')), 'jpg', 'jpeg')
11
+ t.equal(detectImageExt(hex('89504e470d0a1a0a0000000d49484452')), 'png', 'png')
12
+ t.equal(detectImageExt(Buffer.from('GIF89a......')), 'gif', 'gif')
13
+ t.equal(detectImageExt(Buffer.concat([ Buffer.from('RIFF'), hex('00000000'), Buffer.from('WEBPVP8 ') ])), 'webp', 'webp')
14
+ t.equal(detectImageExt(hex('424d000000000000')), 'bmp', 'bmp')
15
+ t.equal(detectImageExt(Buffer.concat([ hex('00000018'), Buffer.from('ftypheic'), hex('00000000') ])), 'heic', 'heic')
16
+ t.equal(detectImageExt(Buffer.concat([ hex('0000001c'), Buffer.from('ftypavif'), hex('00000000') ])), 'avif', 'avif')
17
+ })
18
+
19
+ test('detectImageExt returns undefined for unknown or short data', async t => {
20
+ t.equal(detectImageExt(hex('0000001c667479706d703432')), undefined, 'mp4 is not an image')
21
+ t.equal(detectImageExt(Buffer.from('<html>')), undefined, 'html')
22
+ t.equal(detectImageExt(Buffer.alloc(0)), undefined, 'empty')
23
+ })
24
+
25
+ test('replaceExt replaces or appends extension', async t => {
26
+ t.equal(replaceExt('微信图片_20261105234022.png', 'jpg'), '微信图片_20261105234022.jpg', 'replace')
27
+ t.equal(replaceExt('image', 'gif'), 'image.gif', 'append')
28
+ t.equal(replaceExt('.hidden', 'jpg'), '.hidden.jpg', 'dotfile')
29
+ })
@@ -0,0 +1,59 @@
1
+ import axios from 'axios'
2
+
3
+ /**
4
+ * 根据文件头 (magic bytes) 判断图片扩展名,无法识别时返回 undefined
5
+ */
6
+ export function detectImageExt (head: Buffer): string | undefined {
7
+ if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
8
+ return 'jpg'
9
+ }
10
+ if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]))) {
11
+ return 'png'
12
+ }
13
+ if (head.length >= 6 && [ 'GIF87a', 'GIF89a' ].includes(head.subarray(0, 6).toString('ascii'))) {
14
+ return 'gif'
15
+ }
16
+ if (head.length >= 12 && head.subarray(0, 4).toString('ascii') === 'RIFF' && head.subarray(8, 12).toString('ascii') === 'WEBP') {
17
+ return 'webp'
18
+ }
19
+ if (head.length >= 2 && head[0] === 0x42 && head[1] === 0x4d) {
20
+ return 'bmp'
21
+ }
22
+ if (head.length >= 12 && head.subarray(4, 8).toString('ascii') === 'ftyp') {
23
+ const brand = head.subarray(8, 12).toString('ascii')
24
+ if ([ 'heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1' ].includes(brand)) {
25
+ return 'heic'
26
+ }
27
+ if ([ 'avif', 'avis' ].includes(brand)) {
28
+ return 'avif'
29
+ }
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ /**
35
+ * 用 Range 请求读取远程文件开头若干字节,失败返回 undefined
36
+ */
37
+ export async function fetchRemoteHead (url: string, bytes = 32, timeoutMs = 5000): Promise<Buffer | undefined> {
38
+ try {
39
+ const res = await axios.get<ArrayBuffer>(url, {
40
+ headers: { Range: `bytes=0-${bytes - 1}` },
41
+ responseType: 'arraybuffer',
42
+ timeout: timeoutMs,
43
+ // 服务端不支持 Range 时会返回整个文件,限制最大读取量
44
+ maxContentLength: 50 * 1024 * 1024,
45
+ })
46
+ return Buffer.from(res.data).subarray(0, bytes)
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 把文件名的扩展名替换成 ext
54
+ */
55
+ export function replaceExt (fileName: string, ext: string): string {
56
+ const dot = fileName.lastIndexOf('.')
57
+ const base = dot > 0 ? fileName.slice(0, dot) : fileName
58
+ return `${base}.${ext}`
59
+ }