wechaty-puppet-matrix 0.0.53 → 0.0.55

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 (57) hide show
  1. package/dist/cjs/src/matrix/messages/message-image.d.ts +8 -0
  2. package/dist/cjs/src/matrix/messages/message-image.d.ts.map +1 -1
  3. package/dist/cjs/src/matrix/messages/message-image.js +38 -2
  4. package/dist/cjs/src/matrix/messages/message-image.spec.d.ts +3 -0
  5. package/dist/cjs/src/matrix/messages/message-image.spec.d.ts.map +1 -0
  6. package/dist/cjs/src/matrix/messages/message-image.spec.js +66 -0
  7. package/dist/cjs/src/matrix/messages/message-video.d.ts +6 -0
  8. package/dist/cjs/src/matrix/messages/message-video.d.ts.map +1 -1
  9. package/dist/cjs/src/matrix/messages/message-video.js +20 -0
  10. package/dist/cjs/src/matrix/messages/message-video.spec.d.ts +3 -0
  11. package/dist/cjs/src/matrix/messages/message-video.spec.d.ts.map +1 -0
  12. package/dist/cjs/src/matrix/messages/message-video.spec.js +27 -0
  13. package/dist/cjs/src/matrix/service/request.d.ts +9 -0
  14. package/dist/cjs/src/matrix/service/request.d.ts.map +1 -1
  15. package/dist/cjs/src/matrix/service/request.js +76 -42
  16. package/dist/cjs/src/matrix/utils/file-type.d.ts +4 -0
  17. package/dist/cjs/src/matrix/utils/file-type.d.ts.map +1 -0
  18. package/dist/cjs/src/matrix/utils/file-type.js +55 -0
  19. package/dist/cjs/src/matrix/utils/file-type.spec.d.ts +3 -0
  20. package/dist/cjs/src/matrix/utils/file-type.spec.d.ts.map +1 -0
  21. package/dist/cjs/src/matrix/utils/file-type.spec.js +25 -0
  22. package/dist/cjs/src/puppet-matrix.d.ts +1 -0
  23. package/dist/cjs/src/puppet-matrix.d.ts.map +1 -1
  24. package/dist/cjs/src/puppet-matrix.js +17 -2
  25. package/dist/esm/src/matrix/messages/message-image.d.ts +8 -0
  26. package/dist/esm/src/matrix/messages/message-image.d.ts.map +1 -1
  27. package/dist/esm/src/matrix/messages/message-image.js +37 -2
  28. package/dist/esm/src/matrix/messages/message-image.spec.d.ts +3 -0
  29. package/dist/esm/src/matrix/messages/message-image.spec.d.ts.map +1 -0
  30. package/dist/esm/src/matrix/messages/message-image.spec.js +64 -0
  31. package/dist/esm/src/matrix/messages/message-video.d.ts +6 -0
  32. package/dist/esm/src/matrix/messages/message-video.d.ts.map +1 -1
  33. package/dist/esm/src/matrix/messages/message-video.js +19 -0
  34. package/dist/esm/src/matrix/messages/message-video.spec.d.ts +3 -0
  35. package/dist/esm/src/matrix/messages/message-video.spec.d.ts.map +1 -0
  36. package/dist/esm/src/matrix/messages/message-video.spec.js +25 -0
  37. package/dist/esm/src/matrix/service/request.d.ts +9 -0
  38. package/dist/esm/src/matrix/service/request.d.ts.map +1 -1
  39. package/dist/esm/src/matrix/service/request.js +75 -42
  40. package/dist/esm/src/matrix/utils/file-type.d.ts +4 -0
  41. package/dist/esm/src/matrix/utils/file-type.d.ts.map +1 -0
  42. package/dist/esm/src/matrix/utils/file-type.js +47 -0
  43. package/dist/esm/src/matrix/utils/file-type.spec.d.ts +3 -0
  44. package/dist/esm/src/matrix/utils/file-type.spec.d.ts.map +1 -0
  45. package/dist/esm/src/matrix/utils/file-type.spec.js +23 -0
  46. package/dist/esm/src/puppet-matrix.d.ts +1 -0
  47. package/dist/esm/src/puppet-matrix.d.ts.map +1 -1
  48. package/dist/esm/src/puppet-matrix.js +17 -2
  49. package/package.json +1 -1
  50. package/src/matrix/messages/message-image.spec.ts +107 -0
  51. package/src/matrix/messages/message-image.ts +63 -2
  52. package/src/matrix/messages/message-video.spec.ts +37 -0
  53. package/src/matrix/messages/message-video.ts +28 -1
  54. package/src/matrix/service/request.ts +108 -42
  55. package/src/matrix/utils/file-type.spec.ts +29 -0
  56. package/src/matrix/utils/file-type.ts +59 -0
  57. package/src/puppet-matrix.ts +22 -3
@@ -50,9 +50,17 @@ export interface VideoMessagePayload {
50
50
  thumb_size: string,
51
51
  thumb_width: string,
52
52
  thumb_height: string,
53
- file_name: ''
53
+ file_name: '',
54
+ /**
55
+ * 按优先级排列的下载候选,第一项即 file_id/file_size。
56
+ * 实测 XML 的 length 可能和 CDN 实际大小不一致 (expected 766838, got 1228708),
57
+ * 有原始视频时回落到 cdnrawvideourl + rawlength
58
+ */
59
+ candidates?: Array<{ file_id: string, file_size: string, aes_key?: string }>,
54
60
  }
55
61
 
62
+ const isPositiveSize = (size?: string): size is string => !!size && Number(size) > 0
63
+
56
64
  export async function parseVideoMessagePayload (rawPayload: MessagePayload): Promise<VideoMessagePayload> {
57
65
  const videoXml: VideoXmlSchema = await xmlToJson(rawPayload.msg)
58
66
  const videoSchema = videoXml.msg.videomsg.$
@@ -70,5 +78,24 @@ export async function parseVideoMessagePayload (rawPayload: MessagePayload): Pro
70
78
  thumb_width: videoSchema.cdnthumbwidth || videoSchema.tpthumbwidth || '',
71
79
  thumb_height: videoSchema.cdnthumbheight || videoSchema.tpthumbheight || '',
72
80
  file_name: '',
81
+ candidates: buildVideoCandidates(videoSchema),
82
+ }
83
+ }
84
+
85
+ export function buildVideoCandidates (schema: VideoXmlSchema['msg']['videomsg']['$']): VideoMessagePayload['candidates'] {
86
+ if (schema.tpvideourl) {
87
+ return undefined
88
+ }
89
+ const candidates: NonNullable<VideoMessagePayload['candidates']> = []
90
+ if (schema.cdnvideourl && isPositiveSize(schema.length)) {
91
+ candidates.push({ file_id: schema.cdnvideourl, file_size: schema.length })
92
+ }
93
+ if (schema.cdnrawvideourl && isPositiveSize(schema.rawlength)) {
94
+ candidates.push({
95
+ file_id: schema.cdnrawvideourl,
96
+ file_size: schema.rawlength,
97
+ aes_key: schema.cdnrawvideoaeskey || schema.aeskey,
98
+ })
73
99
  }
100
+ return candidates.length ? candidates : undefined
74
101
  }
@@ -285,12 +285,29 @@ export interface BaseEvent {
285
285
  robotInfo?: ContactPayload;
286
286
  }
287
287
 
288
+ export interface CdnDownloadCandidate {
289
+ file_id: string;
290
+ file_size: string | number;
291
+ /** 与默认 aes_key 不同时才需要,例如原始视频 cdnrawvideoaeskey */
292
+ aes_key?: string;
293
+ }
294
+
288
295
  export interface ImageMessagePayload {
289
296
  aes_key: string;
290
297
  auth_key: string;
291
298
  file_id: string;
292
299
  file_size?: string | number;
293
300
  file_name?: string;
301
+ /** 图片下载的候选 (file_id, file_size),按顺序回落 */
302
+ candidates?: CdnDownloadCandidate[];
303
+ }
304
+
305
+ /**
306
+ * 解析后端 "download plaintext size differs: expected X, got Y",返回实际大小 Y
307
+ */
308
+ export function parseSizeMismatch (errmsg?: string): string | undefined {
309
+ const match = typeof errmsg === 'string' ? errmsg.match(/size differs: expected \d+, got (\d+)/) : null
310
+ return match && Number(match[1]) > 0 ? match[1] : undefined
294
311
  }
295
312
 
296
313
  export interface SnsImageMessagePayload {
@@ -336,6 +353,7 @@ export interface VideoMessagePayload {
336
353
  thumb_width: string;
337
354
  thumb_height: string;
338
355
  file_name: '';
356
+ candidates?: CdnDownloadCandidate[];
339
357
  }
340
358
 
341
359
  export enum FriendshipType {
@@ -1917,8 +1935,7 @@ class Client extends EventEmitter {
1917
1935
  })
1918
1936
  }
1919
1937
 
1920
- private async retryDownload (fn: () => Promise<any>): Promise<any> {
1921
- const maxRetries = 5
1938
+ private async retryDownload (fn: () => Promise<any>, maxRetries = 5): Promise<any> {
1922
1939
  const delayMs = 5000
1923
1940
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1924
1941
  try {
@@ -1927,6 +1944,10 @@ class Client extends EventEmitter {
1927
1944
  return res
1928
1945
  }
1929
1946
  log.warn(PRE, 'download attempt %d/%d failed (no url in response): %s', attempt, maxRetries, JSON.stringify(res))
1947
+ // size 不匹配是确定性错误,重试同一个 size 没有意义,交给调用方换 size
1948
+ if (parseSizeMismatch(res?.errmsg)) {
1949
+ return res
1950
+ }
1930
1951
  } catch (e) {
1931
1952
  log.warn(PRE, 'download attempt %d/%d error: %s', attempt, maxRetries, e)
1932
1953
  }
@@ -1937,21 +1958,66 @@ class Client extends EventEmitter {
1937
1958
  return null
1938
1959
  }
1939
1960
 
1961
+ /**
1962
+ * 按候选 (file_id, file_size) 依次下载 CDN 文件。
1963
+ * - 后端返回 "plaintext size differs: expected X, got Y" 时立即用 Y 重试同一个 file_id
1964
+ * - 其它失败按 retriesPerCandidate 重试后回落到下一个候选
1965
+ */
1966
+ private async cdnDownloadWithFallback (
1967
+ label: string,
1968
+ baseParams: Record<string, any>,
1969
+ candidates: CdnDownloadCandidate[],
1970
+ ): Promise<string> {
1971
+ const queue = [ ...candidates ]
1972
+ const tried = new Set<string>()
1973
+ // 只有一个候选时保持原来的 5 次重试;有多个候选时每个只试 2 次就回落
1974
+ const retriesPerCandidate = candidates.length > 1 ? 2 : 5
1975
+
1976
+ while (queue.length) {
1977
+ const candidate = queue.shift()!
1978
+ const key = `${candidate.file_id}|${candidate.file_size}|${candidate.aes_key || ''}`
1979
+ if (!candidate.file_id || tried.has(key)) {
1980
+ continue
1981
+ }
1982
+ tried.add(key)
1983
+
1984
+ const params = {
1985
+ ...baseParams,
1986
+ file_id: candidate.file_id,
1987
+ file_size: candidate.file_size,
1988
+ ...(candidate.aes_key ? { aes_key: candidate.aes_key } : {}),
1989
+ }
1990
+ const res = await this.retryDownload(() => this.postData({ path: '/cloud/cdn_download', data: params }), retriesPerCandidate)
1991
+ if (res?.url) {
1992
+ if (tried.size > 1) {
1993
+ log.info(PRE, '%s succeeded with fallback, file_size: %s', label, candidate.file_size)
1994
+ }
1995
+ return encodeURI(res.url) || ''
1996
+ }
1997
+
1998
+ const actualSize = parseSizeMismatch(res?.errmsg)
1999
+ if (actualSize) {
2000
+ log.warn(PRE, '%s size mismatch (xml %s, actual %s), retry with actual size', label, candidate.file_size, actualSize)
2001
+ queue.unshift({ ...candidate, file_size: actualSize })
2002
+ continue
2003
+ }
2004
+ log.warn(PRE, '%s candidate failed, params: %s', label, JSON.stringify(params))
2005
+ }
2006
+
2007
+ log.error(PRE, '%s failed for all candidates: %s', label, JSON.stringify(candidates))
2008
+ return ''
2009
+ }
2010
+
1940
2011
  public async downloadImage (imageInfo: ImageMessagePayload): Promise<any> {
1941
- const params = {
2012
+ const candidates = imageInfo.candidates?.length
2013
+ ? imageInfo.candidates
2014
+ : [ { file_id: imageInfo.file_id, file_size: imageInfo.file_size || '' } ]
2015
+ return this.cdnDownloadWithFallback('downloadImage', {
1942
2016
  log_response: true,
1943
2017
  file_type: 2,
1944
- file_id: imageInfo.file_id,
1945
2018
  aes_key: imageInfo.aes_key,
1946
- file_size: imageInfo.file_size,
1947
2019
  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 ''
2020
+ }, candidates)
1955
2021
  }
1956
2022
 
1957
2023
  public async downloadWWMedia (imageInfo: ImageMessagePayload, type: number): Promise<any> {
@@ -1981,20 +2047,12 @@ class Client extends EventEmitter {
1981
2047
  * @param imageInfo
1982
2048
  */
1983
2049
  public async downloadFile (imageInfo: ImageMessagePayload): Promise<any> {
1984
- const params = {
2050
+ return this.cdnDownloadWithFallback('downloadFile', {
1985
2051
  log_response: true,
1986
2052
  file_type: 5,
1987
- file_id: imageInfo.file_id.replace('@cdn_', ''),
1988
2053
  aes_key: imageInfo.aes_key,
1989
- file_size: imageInfo.file_size,
1990
2054
  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 ''
2055
+ }, [ { file_id: imageInfo.file_id.replace('@cdn_', ''), file_size: imageInfo.file_size || '' } ])
1998
2056
  }
1999
2057
 
2000
2058
  /**
@@ -2045,6 +2103,8 @@ class Client extends EventEmitter {
2045
2103
  * 语音转文字,转写完成(endFlag === 1)返回文字,失败或超时返回空字符串
2046
2104
  */
2047
2105
  public async voiceToText (msgId: string, length: number, timeoutMs = 20000): Promise<string> {
2106
+ // timeSlice.buffer 很长且无需关注,打印时去掉
2107
+ const brief = (res: any) => JSON.stringify(res, (k, v) => (k === 'buffer' ? undefined : v))
2048
2108
  try {
2049
2109
  const idRes = await this.newVoiceTransId()
2050
2110
  const voiceId: string = idRes?.voice_id
@@ -2052,15 +2112,26 @@ class Client extends EventEmitter {
2052
2112
  log.warn(PRE, 'voiceToText: no voice_id: %s', JSON.stringify(idRes))
2053
2113
  return ''
2054
2114
  }
2055
- const checkRes = await this.checkVoiceTrans(voiceId, msgId, length)
2056
- if (checkRes?.baseResponse?.ret !== 0) {
2057
- log.warn(PRE, 'voiceToText: check failed: %s', JSON.stringify(checkRes))
2058
- return ''
2059
- }
2060
- const uploadRes = await this.uploadVoiceTrans(voiceId, msgId, length)
2061
- if (uploadRes?.baseResponse?.ret !== 0) {
2062
- log.warn(PRE, 'voiceToText: upload failed: %s', JSON.stringify(uploadRes))
2063
- return ''
2115
+ // length 必须是语音数据字节数(不是时长)。check 返回 uploadCtx 时需要 upload,
2116
+ // 直到 uploadCtx 为空(后端已拿到全部数据)
2117
+ let checkRes: any
2118
+ for (let round = 0; round < 20; round++) {
2119
+ checkRes = await this.checkVoiceTrans(voiceId, msgId, length)
2120
+ log.verbose(PRE, 'voiceToText check#%d(length=%d): %s', round, length, brief(checkRes))
2121
+ if (checkRes?.baseResponse?.ret !== 0) {
2122
+ log.warn(PRE, 'voiceToText: check failed: %s', brief(checkRes))
2123
+ return ''
2124
+ }
2125
+ const ctx = checkRes?.uploadCtx
2126
+ if (!ctx?.dataLen) {
2127
+ break
2128
+ }
2129
+ const uploadRes = await this.uploadVoiceTrans(voiceId, msgId, length)
2130
+ log.verbose(PRE, 'voiceToText upload#%d: %s', round, brief(uploadRes))
2131
+ if (uploadRes?.baseResponse?.ret !== 0) {
2132
+ log.warn(PRE, 'voiceToText: upload failed: %s', brief(uploadRes))
2133
+ return ''
2134
+ }
2064
2135
  }
2065
2136
  const interval = (checkRes?.queryCtx?.interval || 2) * 1000
2066
2137
  const deadline = Date.now() + timeoutMs
@@ -2068,7 +2139,7 @@ class Client extends EventEmitter {
2068
2139
  await new Promise(resolve => setTimeout(resolve, interval))
2069
2140
  const res = await this.getVoiceTrans(voiceId)
2070
2141
  if (res?.baseResponse?.ret !== 0) {
2071
- log.warn(PRE, 'voiceToText: get result failed: %s', JSON.stringify(res))
2142
+ log.warn(PRE, 'voiceToText: get result failed: %s', brief(res))
2072
2143
  return ''
2073
2144
  }
2074
2145
  if (res?.transRes?.endFlag === 1) {
@@ -2087,19 +2158,14 @@ class Client extends EventEmitter {
2087
2158
  * @param imageInfo
2088
2159
  */
2089
2160
  public async downloadVideo (videoInfo: VideoMessagePayload): Promise<any> {
2090
- const params = {
2161
+ const candidates = videoInfo.candidates?.length
2162
+ ? videoInfo.candidates
2163
+ : [ { file_id: videoInfo.file_id, file_size: videoInfo.file_size } ]
2164
+ return this.cdnDownloadWithFallback('downloadVideo', {
2091
2165
  file_type: 4,
2092
- file_id: videoInfo.file_id,
2093
2166
  aes_key: videoInfo.aes_key,
2094
- file_size: videoInfo.file_size,
2095
2167
  file_name: `/微信视频_${format(new Date(), 'yyyyMMddHHmmss')}.mp4`,
2096
- }
2097
- const res = await this.retryDownload(() => this.postData({ path: '/cloud/cdn_download', data: params }))
2098
- if (res?.url) {
2099
- return encodeURI(res.url) || ''
2100
- }
2101
- log.error(PRE, 'downloadVideo failed after %d retries, params: %s', 5, JSON.stringify(params))
2102
- return ''
2168
+ }, candidates)
2103
2169
  }
2104
2170
 
2105
2171
  /**
@@ -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
+ }
@@ -12,6 +12,7 @@ import Client from './matrix/service/request.js'
12
12
  import { getUnixTime } from 'date-fns'
13
13
 
14
14
  import { mergeWithFallback } from './matrix/utils/index.js'
15
+ import { detectImageExt, fetchRemoteHead, replaceExt } from './matrix/utils/file-type.js'
15
16
  import path from 'path'
16
17
  // 参考
17
18
  import { CacheManager, RoomMemberMap } from './matrix/cache-manager.js'
@@ -1704,8 +1705,8 @@ class PuppetMatrix extends PUPPET.Puppet {
1704
1705
  }
1705
1706
  try {
1706
1707
  const audio = await parseAudioMessagePayload(message)
1707
- // 已验证可用的是 voicelength(语音时长),不是 length(数据字节数)
1708
- const length = parseInt(audio.voicelength, 10)
1708
+ // length 是语音数据字节数,voicelength 是时长(毫秒);接口的 length 参数要的是字节数
1709
+ const length = parseInt(audio.length, 10)
1709
1710
  if (!length) {
1710
1711
  return
1711
1712
  }
@@ -1894,7 +1895,25 @@ class PuppetMatrix extends PUPPET.Puppet {
1894
1895
  log.error(`Can not get file for message: ${messageId}`)
1895
1896
  }
1896
1897
  const url = imageInfo.is_ww ? await this._client?.downloadWWMedia(imageInfo, 1) : await this._client?.downloadImage(imageInfo)
1897
- return this._fileBoxFromUrl(url, messageId)
1898
+ return this._fileBoxFromUrl(url, messageId, url ? await this._imageNameByHead(url) : undefined)
1899
+ }
1900
+
1901
+ /**
1902
+ * 下载接口固定返回 .png 文件名,实际多为 JPEG。读取文件头修正扩展名,识别失败时保持原名
1903
+ */
1904
+ private async _imageNameByHead (url: string): Promise<string | undefined> {
1905
+ const head = await fetchRemoteHead(url)
1906
+ const ext = head && detectImageExt(head)
1907
+ if (!ext) {
1908
+ return undefined
1909
+ }
1910
+ let rawName = 'image'
1911
+ try {
1912
+ rawName = decodeURIComponent(new URL(url).pathname.split('/').pop() || rawName)
1913
+ } catch {
1914
+ // url 解析失败时用默认名
1915
+ }
1916
+ return replaceExt(rawName, ext)
1898
1917
  }
1899
1918
 
1900
1919
  private async _getMessageAudioFileBox (messageId: string, messagePayload: MessagePayload): Promise<FileBoxInterface> {