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.
- package/dist/cjs/src/matrix/messages/message-image.d.ts +8 -0
- package/dist/cjs/src/matrix/messages/message-image.d.ts.map +1 -1
- package/dist/cjs/src/matrix/messages/message-image.js +38 -2
- package/dist/cjs/src/matrix/messages/message-image.spec.d.ts +3 -0
- package/dist/cjs/src/matrix/messages/message-image.spec.d.ts.map +1 -0
- package/dist/cjs/src/matrix/messages/message-image.spec.js +66 -0
- package/dist/cjs/src/matrix/messages/message-video.d.ts +6 -0
- package/dist/cjs/src/matrix/messages/message-video.d.ts.map +1 -1
- package/dist/cjs/src/matrix/messages/message-video.js +20 -0
- package/dist/cjs/src/matrix/messages/message-video.spec.d.ts +3 -0
- package/dist/cjs/src/matrix/messages/message-video.spec.d.ts.map +1 -0
- package/dist/cjs/src/matrix/messages/message-video.spec.js +27 -0
- package/dist/cjs/src/matrix/service/request.d.ts +9 -0
- package/dist/cjs/src/matrix/service/request.d.ts.map +1 -1
- package/dist/cjs/src/matrix/service/request.js +76 -42
- package/dist/cjs/src/matrix/utils/file-type.d.ts +4 -0
- package/dist/cjs/src/matrix/utils/file-type.d.ts.map +1 -0
- package/dist/cjs/src/matrix/utils/file-type.js +55 -0
- package/dist/cjs/src/matrix/utils/file-type.spec.d.ts +3 -0
- package/dist/cjs/src/matrix/utils/file-type.spec.d.ts.map +1 -0
- package/dist/cjs/src/matrix/utils/file-type.spec.js +25 -0
- package/dist/cjs/src/puppet-matrix.d.ts +1 -0
- package/dist/cjs/src/puppet-matrix.d.ts.map +1 -1
- package/dist/cjs/src/puppet-matrix.js +17 -2
- package/dist/esm/src/matrix/messages/message-image.d.ts +8 -0
- package/dist/esm/src/matrix/messages/message-image.d.ts.map +1 -1
- package/dist/esm/src/matrix/messages/message-image.js +37 -2
- package/dist/esm/src/matrix/messages/message-image.spec.d.ts +3 -0
- package/dist/esm/src/matrix/messages/message-image.spec.d.ts.map +1 -0
- package/dist/esm/src/matrix/messages/message-image.spec.js +64 -0
- package/dist/esm/src/matrix/messages/message-video.d.ts +6 -0
- package/dist/esm/src/matrix/messages/message-video.d.ts.map +1 -1
- package/dist/esm/src/matrix/messages/message-video.js +19 -0
- package/dist/esm/src/matrix/messages/message-video.spec.d.ts +3 -0
- package/dist/esm/src/matrix/messages/message-video.spec.d.ts.map +1 -0
- package/dist/esm/src/matrix/messages/message-video.spec.js +25 -0
- package/dist/esm/src/matrix/service/request.d.ts +9 -0
- package/dist/esm/src/matrix/service/request.d.ts.map +1 -1
- package/dist/esm/src/matrix/service/request.js +75 -42
- package/dist/esm/src/matrix/utils/file-type.d.ts +4 -0
- package/dist/esm/src/matrix/utils/file-type.d.ts.map +1 -0
- package/dist/esm/src/matrix/utils/file-type.js +47 -0
- package/dist/esm/src/matrix/utils/file-type.spec.d.ts +3 -0
- package/dist/esm/src/matrix/utils/file-type.spec.d.ts.map +1 -0
- package/dist/esm/src/matrix/utils/file-type.spec.js +23 -0
- package/dist/esm/src/puppet-matrix.d.ts +1 -0
- package/dist/esm/src/puppet-matrix.d.ts.map +1 -1
- package/dist/esm/src/puppet-matrix.js +17 -2
- package/package.json +1 -1
- package/src/matrix/messages/message-image.spec.ts +107 -0
- package/src/matrix/messages/message-image.ts +63 -2
- package/src/matrix/messages/message-video.spec.ts +37 -0
- package/src/matrix/messages/message-video.ts +28 -1
- package/src/matrix/service/request.ts +108 -42
- package/src/matrix/utils/file-type.spec.ts +29 -0
- package/src/matrix/utils/file-type.ts +59 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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',
|
|
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
|
|
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
|
+
}
|
package/src/puppet-matrix.ts
CHANGED
|
@@ -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
|
-
//
|
|
1708
|
-
const length = parseInt(audio.
|
|
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> {
|