UNPKG

wechaty-puppet-wechat

Version:
1,225 lines 51 kB
/** * Wechaty - https://github.com/chatie/wechaty * * @copyright 2016-2018 Huan LI <zixia@zixia.net> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import path from 'path'; import nodeUrl from 'url'; import md5 from 'md5'; import mime from 'mime'; import request from 'request'; import { ThrottleQueue, } from 'rx-queue'; import { Watchdog, } from 'watchdog'; import * as PUPPET from 'wechaty-puppet'; import { log } from 'wechaty-puppet'; import { FileBox } from 'file-box'; import { MEMORY_SLOT, qrCodeForChatie, VERSION, } from './config.js'; import { messageFilename, messageRawPayloadParser, plainText, unescapeHtml, isRoomId, } from './pure-function-helpers/mod.js'; import { Bridge, } from './bridge.js'; import { Event, } from './event.js'; import * as envVars from './env-vars.js'; import { WebAppMsgType, UploadMediaType, WebMessageType, } from './web-schemas.js'; import { parseMentionIdList } from './pure-function-helpers/parse-mention-id-list.js'; export class PuppetWeChat extends PUPPET.Puppet { options; static VERSION = VERSION; bridge; scanPayload; scanWatchdog; fileId; constructor(options = {}) { super(options); this.options = options; this.fileId = 0; this.bridge = new Bridge({ endpoint: envVars.WECHATY_PUPPET_WECHAT_ENDPOINT(options.endpoint), head: envVars.WECHATY_PUPPET_WECHAT_PUPPETEER_HEAD(options.head), launchOptions: options.launchOptions, memory: this.memory, stealthless: envVars.WECHATY_PUPPET_WECHAT_PUPPETEER_STEALTHLESS(options.stealthless), uos: envVars.WECHATY_PUPPET_WECHAT_PUPPETEER_UOS(options.uos), uosExtSpam: envVars.WECHATY_PUPPET_WECHAT_TOKEN(options.token), }); const SCAN_TIMEOUT = 2 * 60 * 1000; // 2 minutes this.scanWatchdog = new Watchdog(SCAN_TIMEOUT, 'Scan'); this.initWatchdogForScan(); } async onStart() { log.verbose('PuppetWeChat', `onStart() with ${this.memory.name}`); /** * Overwrite the memory in bridge * because it could be changed between constructor() and start() */ this.bridge.options.memory = this.memory; // this.initWatchdog() // this.initWatchdogForScan() this.bridge = await this.initBridge(); log.verbose('PuppetWeChat', 'onStart() initBridge() done'); /** * Feed the dog and start watch */ const food = { data: 'inited', timeout: 2 * 60 * 1000, // 2 mins for first login }; this.emit('heartbeat', food); /** * Save cookie for every 5 minutes */ const throttleQueue = new ThrottleQueue(5 * 60 * 1000); this.on('heartbeat', data => throttleQueue.next(data)); throttleQueue.subscribe((data) => { log.verbose('PuppetWeChat', 'onStart() throttleQueue.subscribe() new item: %s', data); this.wrapAsync(this.saveCookie()); }); } async onStop() { log.verbose('PuppetWeChat', 'onStop()'); /** * Clean listeners for `watchdog` */ // this.watchdog.sleep() this.scanWatchdog.sleep(); // this.watchdog.removeAllListeners() this.scanWatchdog.removeAllListeners(); this.removeAllListeners('watchdog'); await this.bridge.stop(); // register the removeListeners micro task at then end of the task queue setImmediate(() => this.bridge.removeAllListeners()); } /** * Deal with SCAN events * * if web browser stay at login qrcode page long time, * sometimes the qrcode will not refresh, leave there expired. * so we need to refresh the page after a while */ initWatchdogForScan() { log.verbose('PuppetWeChat', 'initWatchdogForScan()'); const puppet = this; const dog = this.scanWatchdog; // clean the dog because this could be re-inited // dog.removeAllListeners() puppet.on('scan', info => { dog.feed({ data: info, type: 'scan', }); }); puppet.on('login', ( /* user */) => { // dog.feed({ // data: user, // type: 'login', // }) // do not monitor `scan` event anymore // after user login dog.sleep(); }); // active monitor again for `scan` event puppet.on('logout', user => { dog.feed({ data: user, type: 'logout', }); }); dog.on('reset', this.wrapAsync(async (food, timePast) => { log.warn('PuppetWeChat', 'initScanWatchdog() on(reset) lastFood: %s, timePast: %s', food.data, timePast); try { await this.bridge.reload(); } catch (e) { log.error('PuppetWeChat', 'initScanWatchdog() on(reset) exception: %s', e); try { log.error('PuppetWeChat', 'initScanWatchdog() on(reset) try to recover by bridge.{quit,init}()', e); await this.bridge.stop(); await this.bridge.start(); log.error('PuppetWeChat', 'initScanWatchdog() on(reset) recover successful'); } catch (e) { log.error('PuppetWeChat', 'initScanWatchdog() on(reset) recover FAIL: %s', e); this.emit('error', e); } } })); } async initBridge() { log.verbose('PuppetWeChat', 'initBridge()'); if (this.state.inactive()) { const e = new Error('initBridge() found targetState != live, no init anymore'); log.warn('PuppetWeChat', e.message); throw e; } this.bridge.on('dong', (data) => this.emit('dong', { data })); // this.bridge.on('ding' , Event.onDing.bind(this)) this.bridge.on('heartbeat', (data) => this.emit('heartbeat', { data: data + 'bridge ding' })); this.bridge.on('error', (e) => this.emit('error', e)); this.bridge.on('log', Event.onLog.bind(this)); this.bridge.on('login', this.wrapAsync(Event.onLogin.bind(this))); this.bridge.on('logout', this.wrapAsync(Event.onLogout.bind(this))); this.bridge.on('message', this.wrapAsync(Event.onMessage.bind(this))); this.bridge.on('scan', this.wrapAsync(Event.onScan.bind(this))); this.bridge.on('unload', this.wrapAsync(Event.onUnload.bind(this))); try { await this.bridge.start(); } catch (e) { log.error('PuppetWeChat', 'initBridge() exception: %s', e.message); await this.bridge.stop().catch(e => { log.error('PuppetWeChat', 'initBridge() this.bridge.stop() rejection: %s', e); }); this.emit('error', e); throw e; } return this.bridge; } async getBaseRequest() { try { const json = await this.bridge.getBaseRequest(); const obj = JSON.parse(json); return obj.BaseRequest; } catch (e) { log.error('PuppetWeChat', 'send() exception: %s', e.message); throw e; } } /** * * Message * */ async messageRawPayload(id) { const rawPayload = await this.bridge.getMessage(id); return rawPayload; } async messageRawPayloadParser(rawPayload) { log.verbose('PuppetWeChat', 'messageRawPayloadParser(%s) @ %s', rawPayload, this); const payload = messageRawPayloadParser(rawPayload); /** * Huan(202109): generate mention id list * https://github.com/wechaty/wechaty-puppet-wechat/issues/141 */ if (payload.roomId && payload.text) { payload.mentionIdList = await parseMentionIdList(this, payload.roomId, payload.text); } return payload; } async messageRecall(messageId) { return PUPPET.throwUnsupportedError(messageId); } async messageFile(messageId) { const rawPayload = await this.messageRawPayload(messageId); const fileBox = await this.messageRawPayloadToFile(rawPayload); return fileBox; } async messageUrl(messageId) { return PUPPET.throwUnsupportedError(messageId); } async messageMiniProgram(messageId) { log.verbose('PuppetWeChat', 'messageMiniProgram(%s)', messageId); return PUPPET.throwUnsupportedError(messageId); } async messageRawPayloadToFile(rawPayload) { const url = await this.messageRawPayloadToUrl(rawPayload); if (!url) { throw new Error('no url for type ' + PUPPET.types.Message[rawPayload.MsgType]); } const parsedUrl = new nodeUrl.URL(url); const msgFileName = messageFilename(rawPayload); if (!msgFileName) { throw new Error('no filename'); } const cookies = await this.cookies(); const headers = { Accept: '*/*', // 'Accept-Encoding': 'gzip, deflate, sdch', // 'Accept-Encoding': 'gzip, deflate, sdch, br', // MsgType.IMAGE | VIDEO 'Accept-Encoding': 'identity;q=1, *;q=0', 'Accept-Language': 'zh-CN,zh;q=0.8', // 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.6,en-US;q=0.4,en;q=0.2', Cookie: cookies.map(c => `${c.name}=${c.value}`).join('; '), // Accept: 'image/webp,image/*,*/*;q=0.8', // Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', // MsgType.IMAGE | VIDEO Host: parsedUrl.hostname, Range: 'bytes=0-', // Referer: protocol + '//wx.qq.com/', Referer: url, // 'Upgrade-Insecure-Requests': 1, // MsgType.VIDEO | IMAGE /** * pgv_pvi=6639183872; pgv_si=s8359147520; webwx_data_ticket=gSeBbuhX+0kFdkXbgeQwr6Ck */ 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36', }; const fileBox = FileBox.fromUrl(url, msgFileName, headers); return fileBox; } async messageSendUrl(conversationId, urlLinkPayload) { PUPPET.throwUnsupportedError(conversationId, urlLinkPayload); } async messageSendMiniProgram(conversationId, miniProgramPayload) { log.verbose('PuppetWeChat', 'messageSendMiniProgram("%s", %s)', conversationId, JSON.stringify(miniProgramPayload)); PUPPET.throwUnsupportedError(conversationId, miniProgramPayload); } /** * TODO: Test this function if it could work... */ // public async forward(baseData: MsgRawObj, patchData: MsgRawObj): Promise<boolean> { async messageForward(conversationId, messageId) { log.silly('PuppetWeChat', 'forward(receiver=%s, messageId=%s)', conversationId, messageId); let rawPayload = await this.messageRawPayload(messageId); // rawPayload = Object.assign({}, rawPayload) const newMsg = {}; const largeFileSize = 25 * 1024 * 1024; // let ret = false // if you know roomId or userId, you can use `Room.load(roomId)` or `Contact.load(userId)` // let sendToList: Contact[] = [].concat(sendTo as any || []) // sendToList = sendToList.filter(s => { // if ((s instanceof Room || s instanceof Contact) && s.id) { // return true // } // return false // }) as Contact[] // if (sendToList.length < 1) { // throw new Error('param must be Room or Contact and array') // } if (rawPayload.FileSize >= largeFileSize && !rawPayload.Signature) { // if has RawObj.Signature, can forward the 25Mb+ file log.warn('MediaMessage', 'forward() Due to webWx restrictions, ' + 'more than 25MB of files can not be downloaded and can not be forwarded.'); throw new Error('forward() Due to webWx restrictions, ' + 'more than 25MB of files can not be downloaded and can not be forwarded.'); } newMsg.FromUserName = this.currentUserId; newMsg.isTranspond = true; newMsg.MsgIdBeforeTranspond = rawPayload.MsgIdBeforeTranspond || rawPayload.MsgId; newMsg.MMSourceMsgId = rawPayload.MsgId; // In room msg, the content prefix sender:, need to be removed, // otherwise the forwarded sender will display the source message sender, // causing self () to determine the error newMsg.Content = unescapeHtml(rawPayload.Content.replace(/^@\w+:<br\/>/, '')).replace(/^[\w-]+:<br\/>/, ''); newMsg.MMIsChatRoom = isRoomId(conversationId); // The following parameters need to be overridden after calling createMessage() rawPayload = Object.assign(rawPayload, newMsg); // for (let i = 0; i < sendToList.length; i++) { // newMsg.ToUserName = sendToList[i].id // // all call success return true // ret = (i === 0 ? true : ret) && await config.puppetInstance().forward(m, newMsg) // } newMsg.ToUserName = conversationId; // ret = await config.puppetInstance().forward(m, newMsg) // return ret const baseData = rawPayload; const patchData = newMsg; try { const ret = await this.bridge.forward(baseData, patchData); if (!ret) { throw new Error('forward failed'); } } catch (e) { log.error('PuppetWeChat', 'forward() exception: %s', e.message); throw e; } } async messageSendText(conversationId, text) { log.verbose('PuppetWeChat', 'messageSendText(%s, %s)', conversationId, text); try { await this.bridge.send(conversationId, text); } catch (e) { log.error('PuppetWeChat', 'messageSendText() exception: %s', e.message); throw e; } } /** * logout from browser, then server will emit `logout` event */ async logout(reason) { log.verbose('PuppetWeChat', 'logout(%s)', reason); if (!this.isLoggedIn) { log.warn('PuppetWeChat', 'logout() without self()'); return; } try { await this.bridge.logout(); } catch (e) { log.error('PuppetWeChat', 'logout() exception: %s', e.message); throw e; } finally { await super.logout(reason); } } /** * * ContactSelf * * */ async contactSelfQRCode() { return PUPPET.throwUnsupportedError(); } async contactSelfName(name) { return PUPPET.throwUnsupportedError(name); } async contactSelfSignature(signature) { return PUPPET.throwUnsupportedError(signature); } /** * * Contact * */ async contactRawPayload(id) { log.silly('PuppetWeChat', 'contactRawPayload(%s) @ %s', id, this); try { const rawPayload = await this.bridge.getContact(id); return rawPayload; } catch (e) { log.error('PuppetWeChat', 'contactRawPayload(%s) exception: %s', id, e.message); throw e; } } async contactRawPayloadParser(rawPayload) { log.silly('PuppetWeChat', 'contactParseRawPayload(Object.keys(payload).length=%d)', Object.keys(rawPayload).length); if (!Object.keys(rawPayload).length) { log.error('PuppetWeChat', 'contactParseRawPayload(Object.keys(payload).length=%d)', Object.keys(rawPayload).length); log.error('PuppetWeChat', 'contactParseRawPayload() got empty rawPayload!'); throw new Error('empty raw payload'); // return { // gender: Gender.Unknown, // type: Contact.Type.Unknown, // } } // this._currentUserId = rawPayload.UserName // MMActualSender??? MMPeerUserName??? `getUserContact(message.MMActualSender,message.MMPeerUserName).HeadImgUrl` // uin: rawPayload.Uin, // stable id: 4763975 || getCookie("wxuin") return { address: rawPayload.Alias, alias: rawPayload.RemarkName, avatar: rawPayload.HeadImgUrl, city: rawPayload.City, friend: rawPayload.stranger === undefined ? undefined : !rawPayload.stranger, gender: rawPayload.Sex, id: rawPayload.UserName, name: plainText(rawPayload.NickName || ''), phone: [], province: rawPayload.Province, signature: rawPayload.Signature, star: !!rawPayload.StarFriend, /** * @see 1. https://github.com/Chatie/webwx-app-tracker/blob/ * 7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3243 * @see 2. https://github.com/Urinx/WeixinBot/blob/master/README.md * @ignore */ type: (!!rawPayload.UserName && !rawPayload.UserName.startsWith('@@') && !!(rawPayload.VerifyFlag & 8)) ? PUPPET.types.Contact.Official : PUPPET.types.Contact.Individual, weixin: rawPayload.Alias, // Wechat ID }; } ding(data) { log.verbose('PuppetWeChat', 'ding(%s)', data || ''); this.bridge.ding(data); } async contactAvatar(contactId, file) { log.verbose('PuppetWeChat', 'contactAvatar(%s)', contactId); if (file) { throw new Error('not support'); } const payload = await this.contactPayload(contactId); if (!payload.avatar) { throw new Error('Can not get avatar: no payload.avatar!'); } try { const hostname = await this.hostname(); const avatarUrl = `https://${hostname}${payload.avatar}&type=big`; // add '&type=big' to get big image const cookieList = await this.cookies(); log.silly('PuppeteerContact', 'avatar() url: %s', avatarUrl); /** * FileBox headers (will be used in NodeJS.http.get param options) */ const headers = { cookie: cookieList.map(c => `${c.name}=${c.value}`).join('; '), }; const fileName = (payload.name || 'unknown') + '-avatar.jpg'; return FileBox.fromUrl(avatarUrl, fileName, headers); } catch (err) { log.warn('PuppeteerContact', 'avatar() exception: %s', err.stack); throw err; } } async contactAlias(contactId, alias) { if (typeof alias === 'undefined') { throw new Error('to be implement'); } try { const ret = await this.bridge.contactAlias(contactId, alias); if (!ret) { log.warn('PuppetWeChat', 'contactRemark(%s, %s) bridge.contactAlias() return false', contactId, alias); throw new Error('bridge.contactAlias fail'); } } catch (e) { log.warn('PuppetWeChat', 'contactRemark(%s, %s) rejected: %s', contactId, alias, e.message); throw e; } } async contactList() { const idList = await this.bridge.contactList(); return idList; } /** * * Room * */ async roomRawPayload(id) { log.verbose('PuppetWeChat', 'roomRawPayload(%s)', id); try { let rawPayload; // = await this.bridge.getContact(room.id) as PuppeteerRoomRawPayload // let currNum = rawPayload.MemberList && rawPayload.MemberList.length || 0 // let prevNum = room.memberList().length // rawPayload && rawPayload.MemberList && this.rawObj.MemberList.length || 0 let prevLength = 0; /** * @todo use Misc.retry() to replace the following loop */ let ttl = 7; while (ttl-- /* && currNum !== prevNum */) { rawPayload = await this.bridge.getContact(id); if (rawPayload) { const currLength = (rawPayload.MemberList && rawPayload.MemberList.length) || 0; log.silly('PuppetWeChat', 'roomPayload() this.bridge.getContact(%s) ' + 'MemberList.length:(prev:%d, curr:%d) at ttl:%d', id, prevLength, currLength, ttl); if (prevLength === currLength) { log.silly('PuppetWeChat', 'roomPayload() puppet.getContact(%s) done at ttl:%d with length:%d', this.currentUserId, ttl, currLength); return rawPayload; } if (currLength >= prevLength) { prevLength = currLength; } else { log.warn('PuppetWeChat', 'roomRawPayload() currLength(%d) <= prevLength(%d) ???', currLength, prevLength); } } log.silly('PuppetWeChat', `roomPayload() puppet.getContact(${id}) retry at ttl:%d`, ttl); await new Promise(resolve => setTimeout(resolve, 1000)); // wait for 1 second } throw new Error('no payload'); } catch (e) { log.error('PuppetWeChat', 'roomRawPayload(%s) exception: %s', id, e.message); throw e; } } async roomRawPayloadParser(rawPayload) { log.verbose('PuppetWeChat', 'roomRawPayloadParser(%s)', rawPayload); // const payload = await this.roomPayload(rawPayload.UserName) // console.log(rawPayload) // const memberList = (rawPayload.MemberList || []) // .map(m => this.Contact.load(m.UserName)) // await Promise.all(memberList.map(c => c.ready())) const id = rawPayload.UserName; // const rawMemberList = rawPayload.MemberList || [] // const memberIdList = rawMemberList.map(rawMember => rawMember.UserName) // const nameMap = await this.roomParseMap('name' , rawPayload.MemberList) // const roomAliasMap = await this.roomParseMap('roomAlias' , rawPayload.MemberList) // const contactAliasMap = await this.roomParseMap('contactAlias', rawPayload.MemberList) // const aliasDict = {} as { [id: string]: string | undefined } // if (Array.isArray(rawPayload.MemberList)) { // rawPayload.MemberList.forEach(rawMember => { // aliasDict[rawMember.UserName] = rawMember.DisplayName // }) // // const memberListPayload = await Promise.all( // // rawPayload.MemberList // // .map(rawMember => rawMember.UserName) // // .map(contactId => this.contactPayload(contactId)), // // ) // // console.log(memberListPayload) // // memberListPayload.forEach(payload => aliasDict[payload.id] = payload.alias) // // console.log(aliasDict) // } const memberIdList = rawPayload.MemberList ? rawPayload.MemberList.map(m => m.UserName) : []; const roomPayload = { adminIdList: [], id, memberIdList, topic: plainText(rawPayload.NickName || ''), // aliasDict, // nameMap, // roomAliasMap, // contactAliasMap, }; // console.log(roomPayload) return roomPayload; } async roomList() { log.verbose('PuppetPupppeteer', 'roomList()'); const idList = await this.bridge.roomList(); return idList; } async roomDel(roomId, contactId) { try { await this.bridge.roomDelMember(roomId, contactId); } catch (e) { log.warn('PuppetWeChat', 'roomDelMember(%s, %d) rejected: %s', roomId, contactId, e.message); throw e; } } async roomAvatar(roomId) { log.verbose('PuppetWeChat', 'roomAvatar(%s)', roomId); const payload = await this.roomPayload(roomId); if (payload.avatar) { return FileBox.fromUrl(payload.avatar); } log.warn('PuppetWeChat', 'roomAvatar() avatar not found, use the chatie default.'); return qrCodeForChatie(); } async roomAdd(roomId, contactId) { try { await this.bridge.roomAddMember(roomId, contactId); } catch (e) { log.warn('PuppetWeChat', 'roomAddMember(%s) rejected: %s', contactId, e.message); throw e; } } async roomTopic(roomId, topic) { if (!topic) { const payload = await this.roomPayload(roomId); return payload.topic; } try { await this.bridge.roomModTopic(roomId, topic); } catch (e) { log.warn('PuppetWeChat', 'roomTopic(%s) rejected: %s', topic, e.message); throw e; } } async roomCreate(contactIdList, topic) { try { const roomId = await this.bridge.roomCreate(contactIdList, topic); if (!roomId) { throw new Error('PuppetWeChat.roomCreate() roomId "' + roomId + '" not found'); } return roomId; } catch (e) { log.warn('PuppetWeChat', 'roomCreate(%s, %s) rejected: %s', contactIdList.join(','), topic, e.message); throw e; } } async roomAnnounce(roomId, text) { log.warn('PuppetWeChat', 'roomAnnounce(%s, %s) not supported', roomId, text || ''); if (text) { return; } return ''; } async roomQuit(roomId) { log.warn('PuppetWeChat', 'roomQuit(%s) not supported by Web API', roomId); } async roomQRCode(roomId) { return PUPPET.throwUnsupportedError(roomId); } async roomMemberList(roomId) { log.verbose('PuppetWeChat', 'roommemberList(%s)', roomId); const rawPayload = await this.roomRawPayload(roomId); const memberIdList = (rawPayload.MemberList || []) .map(member => member.UserName); return memberIdList; } async roomMemberRawPayload(roomId, contactId) { log.verbose('PuppetWeChat', 'roomMemberRawPayload(%s, %s)', roomId, contactId); const rawPayload = await this.roomRawPayload(roomId); const memberPayloadList = rawPayload.MemberList || []; const memberPayloadResult = memberPayloadList.filter(payload => payload.UserName === contactId); if (memberPayloadResult.length > 0) { return memberPayloadResult[0]; } else { throw new Error('not found'); } } async roomMemberRawPayloadParser(rawPayload) { log.verbose('PuppetWeChat', 'roomMemberRawPayloadParser(%s)', rawPayload); const payload = { avatar: rawPayload.HeadImgUrl, id: rawPayload.UserName, name: rawPayload.NickName, roomAlias: rawPayload.DisplayName, }; return payload; } /** * * Room Invitation * */ async roomInvitationAccept(roomInvitationId) { return PUPPET.throwUnsupportedError(roomInvitationId); } async roomInvitationRawPayload(roomInvitationId) { return PUPPET.throwUnsupportedError(roomInvitationId); } async roomInvitationRawPayloadParser(rawPayload) { return PUPPET.throwUnsupportedError(rawPayload); } /** * * Friendship * */ async friendshipRawPayload(id) { log.warn('PuppetWeChat', 'friendshipRawPayload(%s)', id); const rawPayload = await this.bridge.getMessage(id); return rawPayload; } async friendshipRawPayloadParser(rawPayload) { log.warn('PuppetWeChat', 'friendshipRawPayloadParser(%s)', rawPayload); const timestamp = Math.floor(Date.now() / 1000); // in seconds switch (rawPayload.MsgType) { case WebMessageType.VERIFYMSG: { const recommendInfo = rawPayload.RecommendInfo; if (!recommendInfo) { throw new Error('no RecommendInfo'); } const payloadReceive = { contactId: recommendInfo.UserName, hello: recommendInfo.Content, id: rawPayload.MsgId, ticket: recommendInfo.Ticket, timestamp, type: PUPPET.types.Friendship.Receive, }; return payloadReceive; } case WebMessageType.SYS: { const payloadConfirm = { contactId: rawPayload.FromUserName, id: rawPayload.MsgId, timestamp, type: PUPPET.types.Friendship.Confirm, }; return payloadConfirm; } default: throw new Error('not supported friend request message raw payload'); } } async friendshipSearchPhone(phone) { throw PUPPET.throwUnsupportedError(phone); } async friendshipSearchWeixin(weixin) { throw PUPPET.throwUnsupportedError(weixin); } async friendshipAdd(contactId, hello) { try { await this.bridge.verifyUserRequest(contactId, hello); } catch (e) { log.warn('PuppetWeChat', 'friendshipAdd() bridge.verifyUserRequest(%s, %s) rejected: %s', contactId, hello, e.message); throw e; } } async friendshipAccept(friendshipId) { const payload = await this.friendshipPayload(friendshipId); try { await this.bridge.verifyUserOk(payload.contactId, payload.ticket); } catch (e) { log.warn('PuppetWeChat', 'bridge.verifyUserOk(%s, %s) rejected: %s', payload.contactId, payload.ticket, e.message); throw e; } } /** * @private * For issue #668 */ async waitStable() { log.verbose('PuppetWeChat', 'waitStable()'); let maxNum = 0; let curNum = 0; let unchangedNum = 0; const SLEEP_SECOND = 1; const STABLE_CHECK_NUM = 3; while (unchangedNum < STABLE_CHECK_NUM) { // wait 1 second await new Promise(resolve => setTimeout(resolve, SLEEP_SECOND * 1000)); const contactList = await this.contactList(); curNum = contactList.length; if (curNum > 0 && curNum === maxNum) { unchangedNum++; } else /* curNum < maxNum */ { unchangedNum = 0; } if (curNum > maxNum) { maxNum = curNum; } log.silly('PuppetWeChat', 'readyStable() while() curNum=%s, maxNum=%s, unchangedNum=%s', curNum, maxNum, unchangedNum); } log.verbose('PuppetWeChat', 'readyStable() emit(ready)'); this.emit('ready', { data: 'stable' }); } /** * https://www.chatie.io:8080/api * location.hostname = www.chatie.io * location.host = www.chatie.io:8080 * See: https://stackoverflow.com/a/11379802/1123955 */ async hostname() { try { const name = await this.bridge.hostname(); if (!name) { throw new Error('no hostname found'); } return name; } catch (e) { log.error('PuppetWeChat', 'hostname() exception:%s', e); this.emit('error', e); throw e; } } async cookies() { return this.bridge.cookies(); } async saveCookie() { if (this.state.inactive() === true) { log.warn('PuppetWeChat', 'saveCookie() found state inactive, skipped.'); return; } const cookieList = await this.bridge.cookies(); await this.memory.set(MEMORY_SLOT, cookieList); await this.memory.save(); } /** * `isImg()` @see https://github.com/wechaty/webwx-app-tracker/blob/a12c78fb8bd7186c0f3bb0e18dd611151e6b8aac/formatted/webwxApp.js#L3441-L3450 * `getMsgType()` @see https://github.com/wechaty/webwx-app-tracker/blob/a12c78fb8bd7186c0f3bb0e18dd611151e6b8aac/formatted/webwxApp.js#L3452-L3463 */ getMsgType(ext) { switch (ext.toLowerCase()) { case 'bmp': case 'jpeg': case 'jpg': case 'png': return WebMessageType.IMAGE; case 'gif': return WebMessageType.EMOTICON; case 'mp4': return WebMessageType.VIDEO; default: return WebMessageType.APP; } } // public async readyMedia(): Promise<this> { async messageRawPayloadToUrl(rawPayload) { log.silly('PuppetWeChat', 'readyMedia()'); // let type = PUPPET.types.Message.Unknown let url; try { switch (rawPayload.MsgType) { case WebMessageType.EMOTICON: // type = PUPPET.types.Message.Emoticon url = await this.bridge.getMsgEmoticon(rawPayload.MsgId); break; case WebMessageType.IMAGE: // type = PUPPET.types.Message.Image url = await this.bridge.getMsgImg(rawPayload.MsgId); break; case WebMessageType.VIDEO: case WebMessageType.MICROVIDEO: // type = PUPPET.types.Message.Video url = await this.bridge.getMsgVideo(rawPayload.MsgId); break; case WebMessageType.VOICE: // type = PUPPET.types.Message.Audio url = await this.bridge.getMsgVoice(rawPayload.MsgId); break; case WebMessageType.APP: switch (rawPayload.AppMsgType) { case WebAppMsgType.ATTACH: if (!rawPayload.MMAppMsgDownloadUrl) { throw new Error('no MMAppMsgDownloadUrl'); } // had set in Message // type = PUPPET.types.Message.Attachment url = rawPayload.MMAppMsgDownloadUrl; break; case WebAppMsgType.URL: case WebAppMsgType.READER_TYPE: if (!rawPayload.Url) { throw new Error('no Url'); } // had set in Message // type = PUPPET.types.Message.Attachment url = rawPayload.Url; break; default: { const e = new Error('ready() unsupported typeApp(): ' + rawPayload.AppMsgType); log.warn('PuppeteerMessage', e.message); throw e; } } break; case WebMessageType.TEXT: if (rawPayload.SubMsgType === WebMessageType.LOCATION) { // type = PUPPET.types.Message.Image url = await this.bridge.getMsgPublicLinkImg(rawPayload.MsgId); } break; default: /** * not a support media message, do nothing. */ return null; // return this } if (!url) { // if (!this.payload.url) { // /** // * not a support media message, do nothing. // */ // return this // } // url = this.payload.url // return { // type: PUPPET.types.Message.Unknown, // } return null; } } catch (e) { log.warn('PuppetWeChat', 'ready() exception: %s', e.message); throw e; } return url; } getExtName(filename) { return path.extname(filename).slice(1); } async uploadMedia(file, toUserName) { const filename = file.name; const ext = this.getExtName(filename); const msgType = this.getMsgType(ext); const contentType = mime.getType(ext) || file.mediaType || undefined; if (!contentType) { throw new Error('no MIME Type found on mediaMessage: ' + file.name); } let mediatype; switch (msgType) { // case WebMessageType.EMOTICON: //gif cannot be "pic", it will cause sending wrong picture. #178 case WebMessageType.IMAGE: mediatype = 'pic'; break; case WebMessageType.VIDEO: mediatype = 'video'; break; default: mediatype = 'doc'; } // const buffer = await new Promise<Buffer>((resolve, reject) => { // const bl = new BufferList((err: undefined | Error, data: Buffer) => { // if (err) reject(err) // else resolve(data) // }) // file.pipe(bl) // }) // Huan(202201): fix bl not a standard Writable problem const buffer = await file.toBuffer(); // Sending video files is not allowed to exceed 20MB // https://github.com/Chatie/webwx-app-tracker/blob/ // 7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L1115 const MAX_FILE_SIZE = 100 * 1024 * 1024; const LARGE_FILE_SIZE = 25 * 1024 * 1024; const MAX_VIDEO_SIZE = 20 * 1024 * 1024; if (msgType === WebMessageType.VIDEO && buffer.length > MAX_VIDEO_SIZE) { throw new Error(`Sending video files is not allowed to exceed ${MAX_VIDEO_SIZE / 1024 / 1024}MB`); } if (buffer.length > MAX_FILE_SIZE) { throw new Error(`Sending files is not allowed to exceed ${MAX_FILE_SIZE / 1024 / 1024}MB`); } const fileMd5 = md5(buffer); const baseRequest = await this.getBaseRequest(); const passTicket = await this.bridge.getPassticket(); const uploadMediaUrl = await this.bridge.getUploadMediaUrl(); const checkUploadUrl = await this.bridge.getCheckUploadUrl(); const cookie = await this.bridge.cookies(); const first = cookie.find(c => c.name === 'webwx_data_ticket'); const webwxDataTicket = first && first.value; const size = buffer.length; const fromUserName = this.currentUserId; const id = 'WU_FILE_' + this.fileId; this.fileId++; const hostname = await this.bridge.hostname(); const headers = { Cookie: cookie.map(c => c.name + '=' + c.value).join('; '), Referer: `https://${hostname}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36', }; log.silly('PuppetWeChat', 'uploadMedia() headers:%s', JSON.stringify(headers)); const uploadMediaRequest = { AESKey: '', BaseRequest: baseRequest, ClientMediaId: +new Date(), DataLen: size, FileMd5: fileMd5, FromUserName: fromUserName, MediaType: UploadMediaType.Attachment, Signature: '', StartPos: 0, ToUserName: toUserName, TotalLen: size, UploadType: 2, }; const checkData = { BaseRequest: baseRequest, FileMd5: fileMd5, FileName: filename, FileSize: size, FileType: 7, FromUserName: fromUserName, ToUserName: toUserName, }; const mediaData = { FileMd5: fileMd5, FileName: filename, FileSize: size, MMFileExt: ext, MediaId: '', ToUserName: toUserName, }; // If file size > 25M, must first call checkUpload to get Signature and AESKey, otherwise it will fail to upload // https://github.com/Chatie/webwx-app-tracker/blob/ // 7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L1132 #1182 if (size > LARGE_FILE_SIZE) { let ret; try { ret = await new Promise((resolve, reject) => { const r = { headers, json: checkData, url: `https://${hostname}${checkUploadUrl}`, }; request.post(r, (err, _ /* res */, body) => { try { if (err) { reject(err); } else { let obj = body; if (typeof body !== 'object') { log.silly('PuppetWeChat', 'updateMedia() typeof body = %s', typeof body); try { obj = JSON.parse(body); } catch (e) { log.error('PuppetWeChat', 'updateMedia() body = %s', body); log.error('PuppetWeChat', 'updateMedia() exception: %s', e); this.emit('error', e); } } if (typeof obj !== 'object' || obj.BaseResponse.Ret !== 0) { const errMsg = obj.BaseResponse || 'api return err'; log.silly('PuppetWeChat', 'uploadMedia() checkUpload err:%s \nreq:%s\nret:%s', JSON.stringify(errMsg), JSON.stringify(r), body); reject(new Error('chackUpload err:' + JSON.stringify(errMsg))); } resolve({ AESKey: obj.AESKey, Signature: obj.Signature, }); } } catch (e) { reject(e); } }); }); } catch (e) { log.error('PuppetWeChat', 'uploadMedia() checkUpload exception: %s', e.message); throw e; } if (!ret.Signature) { log.error('PuppetWeChat', 'uploadMedia(): chackUpload failed to get Signature'); throw new Error('chackUpload failed to get Signature'); } uploadMediaRequest.Signature = ret.Signature; uploadMediaRequest.AESKey = ret.AESKey; mediaData.Signature = ret.Signature; } else { delete uploadMediaRequest.Signature; delete uploadMediaRequest.AESKey; } log.verbose('PuppetWeChat', 'uploadMedia() webwx_data_ticket: %s', webwxDataTicket); log.verbose('PuppetWeChat', 'uploadMedia() pass_ticket: %s', passTicket); /** * If FILE.SIZE > 1M, file buffer need to split for upload. * Split strategy: * BASE_LENGTH: 512 * 1024 * chunks: split number * chunk: the index of chunks */ const BASE_LENGTH = 512 * 1024; const chunks = Math.ceil(buffer.length / BASE_LENGTH); const bufferData = []; for (let i = 0; i < chunks; i++) { const tempBuffer = buffer.slice(i * BASE_LENGTH, (i + 1) * BASE_LENGTH); bufferData.push(tempBuffer); } async function getMediaId(buffer, index) { const formData = { chunk: index, chunks, filename: { options: { contentType, filename, size, }, value: buffer, }, id, lastModifiedDate: Date().toString(), mediatype, name: filename, pass_ticket: passTicket || '', size, type: contentType, uploadmediarequest: JSON.stringify(uploadMediaRequest), webwx_data_ticket: webwxDataTicket, }; try { return await new Promise((resolve, reject) => { try { request.post({ formData, headers, url: uploadMediaUrl + '?f=json', }, (err, _, body) => { if (err) { reject(err); } else { let obj = body; if (typeof body !== 'object') { obj = JSON.parse(body); } resolve(obj.MediaId || ''); } }); } catch (e) { reject(e); } }); } catch (e) { log.error('PuppetWeChat', 'uploadMedia() uploadMedia exception: %s', e.message); throw new Error('uploadMedia err: ' + e.message); } } let mediaId = ''; for (let i = 0; i < bufferData.length; i++) { mediaId = await getMediaId(bufferData[i], i); } if (!mediaId) { log.error('PuppetWeChat', 'uploadMedia(): upload fail'); throw new Error('PuppetWeChat.uploadMedia(): upload fail'); } return Object.assign(mediaData, { MediaId: mediaId }); } async messageSendFile(conversationId, file) { log.verbose('PuppetWeChat', 'messageSendFile(%s, file=%s)', conversationId, file.toString()); let mediaData; let rawPayload = {}; if (!rawPayload.MediaId) { try { mediaData = await this.uploadMedia(file, conversationId); rawPayload = Object.assign(rawPayload, mediaData); log.silly('PuppetWeChat', 'Upload completed, new rawObj:%s', JSON.stringify(rawPayload)); } catch (e) { log.error('PuppetWeChat', 'sendMedia() exception: %s', e.message); throw e; } } else { // To support forward file log.silly('PuppetWeChat', 'skip upload file, rawObj:%s', JSON.stringify(rawPayload)); mediaData = { FileName: rawPayload.FileName, FileSize: rawPayload.FileSize, MMFileExt: rawPayload.MMFileExt, MediaId: rawPayload.MediaId, MsgType: rawPayload.MsgType, ToUserName: conversationId, }; if (rawPayload.Signature) { mediaData.Signature = rawPayload.Signature; } } // console.log('mediaData.MsgType', mediaData.MsgType) // console.log('rawObj.MsgType', message.rawObj && message.rawObj.MsgType) mediaData.MsgType = this.getMsgType(this.getExtName(file.name)); log.silly('PuppetWeChat', 'sendMedia() destination: %s, mediaId: %s, MsgType; %s)', conversationId, mediaData.MediaId, mediaData.MsgType); let ret = false; try { ret = await this.bridge.sendMedia(mediaData); } catch (e) { log.error('PuppetWeChat', 'sendMedia() exception: %s', e.message); throw e; } if (!ret) { throw new Error('sendMedia fail'); } } async messageSendContact(conversationId, contactId) { log.verbose('PuppetWeChat', 'messageSend("%s", %s)', conversationId, contactId); return PUPPET.throwUnsupportedError(); } async messageImage(messageId, imageType) { log.verbose('PuppetWeChat', 'messageImage(%s, %s)', messageId, imageType); return this.messageFile(messageId); } async messageContact(messageId) { log.verbose('PuppetWeChat', 'messageContact(%s)', messageId); return PUPPET.throwUnsupportedError(messageId); } /** * * Tag * */ async tagContactAdd(tagId, contactId) { return PUPPET.throwUns