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.
- package/dist/cjs/src/engine-schema.d.ts +1 -0
- package/dist/cjs/src/engine-schema.d.ts.map +1 -1
- package/dist/cjs/src/matrix/messages/message-contact-card.d.ts +16 -0
- package/dist/cjs/src/matrix/messages/message-contact-card.d.ts.map +1 -0
- package/dist/cjs/src/matrix/messages/message-contact-card.js +25 -0
- package/dist/cjs/src/matrix/messages/message-emotion.d.ts +7 -0
- package/dist/cjs/src/matrix/messages/message-emotion.d.ts.map +1 -1
- package/dist/cjs/src/matrix/messages/message-emotion.js +20 -0
- 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 +21 -0
- package/dist/cjs/src/matrix/service/request.d.ts.map +1 -1
- package/dist/cjs/src/matrix/service/request.js +194 -32
- 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 +3 -0
- package/dist/cjs/src/puppet-matrix.d.ts.map +1 -1
- package/dist/cjs/src/puppet-matrix.js +121 -33
- package/dist/esm/src/engine-schema.d.ts +1 -0
- package/dist/esm/src/engine-schema.d.ts.map +1 -1
- package/dist/esm/src/matrix/messages/message-contact-card.d.ts +16 -0
- package/dist/esm/src/matrix/messages/message-contact-card.d.ts.map +1 -0
- package/dist/esm/src/matrix/messages/message-contact-card.js +22 -0
- package/dist/esm/src/matrix/messages/message-emotion.d.ts +7 -0
- package/dist/esm/src/matrix/messages/message-emotion.d.ts.map +1 -1
- package/dist/esm/src/matrix/messages/message-emotion.js +19 -0
- 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 +21 -0
- package/dist/esm/src/matrix/service/request.d.ts.map +1 -1
- package/dist/esm/src/matrix/service/request.js +193 -32
- 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 +3 -0
- package/dist/esm/src/puppet-matrix.d.ts.map +1 -1
- package/dist/esm/src/puppet-matrix.js +121 -33
- package/package.json +1 -1
- package/src/engine-schema.ts +1 -0
- package/src/matrix/messages/message-contact-card.ts +48 -0
- package/src/matrix/messages/message-emotion.ts +36 -0
- 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 +274 -32
- package/src/matrix/utils/file-type.spec.ts +29 -0
- package/src/matrix/utils/file-type.ts +59 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|