提交 c82c167d 编写于 作者: Huan (李卓桓)'s avatar Huan (李卓桓)

Split PuppetPuppeteer Out (#1371)

上级 7cc0a006
......@@ -4,7 +4,3 @@
/docs/ @lijiarui @hczhcz @TingYinHelen @ax4
/examples/ @Gcaufy @hczhcz
/src/puppet-puppeteer/bridge.*.ts @mukaiu @xjchengo
/src/puppet-puppeteer/event.ts @binsee
/src/puppet-puppeteer/firer.*.ts @xinbenlv
/src/puppet-puppeteer/wechaty-bro.js @binsee @mukaiu @hczhcz @cherry-geqi @zhenyong
......@@ -7,7 +7,7 @@
"wechaty": {
"DEFAULT_HEAD": 0,
"DEFAULT_PORT": 8080,
"DEFAULT_PUPPET": "puppeteer",
"DEFAULT_PUPPET": "wechat4u",
"DEFAULT_PROFILE": "default",
"DEFAULT_PROTOCOL": "io|0.0.1",
"DEFAULT_TOKEN": "WECHATY_IO_TOKEN",
......@@ -15,7 +15,7 @@
},
"scripts": {
"clean": "shx rm -fr dist/*",
"dist": "npm run clean && tsc && shx cp src/puppet-puppeteer/*.js dist/src/puppet-puppeteer/",
"dist": "npm run clean && tsc",
"pack": "npm pack",
"doc": "bash -x scripts/generate-docs.sh",
"coverage": "nyc report --reporter=text-lcov | coveralls",
......@@ -124,7 +124,7 @@
"watchdog": "^0.8.1",
"wechat4u": "^0.7.6",
"wechaty-puppet": "^0.2.3",
"wechaty-puppet-wechat4u": "^0.2.2",
"wechaty-puppet-wechat4u": "^0.2.6",
"ws": "^5.2.0",
"xml2json": "^0.11.2"
},
......@@ -184,7 +184,8 @@
"tuling123-client": "^0.0.2",
"typedoc": "^0.11.1",
"typescript": "^2.9.2",
"wechaty-puppet-mock": "^0.2.1"
"wechaty-puppet-mock": "^0.2.1",
"wechaty-puppet-puppeteer": "^0.2.1"
},
"files_comment__whitelist_npm_publish": "http://stackoverflow.com/a/8617868/1123955",
"files": [
......
......@@ -34,10 +34,6 @@ export {
export { IoClient } from './io-client'
export { Misc } from './misc'
export {
PuppetPuppeteer,
} from './puppet-puppeteer/'
export {
Wechaty,
}
......
// import { PuppetMock } from 'wechaty-puppet-mock'
import { PuppetWechat4u } from 'wechaty-puppet-wechat4u'
// import { PuppetPuppeteer } from 'wechaty-puppet-puppeteer'
import { PuppetPuppeteer } from './puppet-puppeteer/'
import { PuppetPadchat } from './puppet-padchat'
/**
......@@ -12,7 +12,7 @@ export const PUPPET_DICT = {
//////////////////////////
// mock: PuppetMock,
padchat: PuppetPadchat,
puppeteer: PuppetPuppeteer,
// puppeteer: PuppetPuppeteer,
wechat4u: PuppetWechat4u,
}
......
#!/usr/bin/env ts-node
/**
* 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 test from 'blue-tape'
// tslint:disable:no-shadowed-variable
// import sinon from 'sinon'
// const sinonTest = require('sinon-test')(sinon)
import {
launch,
} from 'puppeteer'
// import { spy } from 'sinon'
import {
MemoryCard,
} from 'memory-card'
import Bridge from './bridge'
const PUPPETEER_LAUNCH_OPTIONS = {
headless: true,
args: [
'--disable-gpu',
'--disable-setuid-sandbox',
'--no-sandbox',
],
}
test('PuppetPuppeteerBridge', async t => {
const profile = new MemoryCard()
const bridge = new Bridge({ profile })
try {
await bridge.init()
await bridge.quit()
t.pass('Bridge instnace')
} catch (e) {
t.fail('Bridge instance: ' + e)
}
})
test('preHtmlToXml()', async t => {
const BLOCKED_HTML_ZH = [
'<pre style="word-wrap: break-word; white-space: pre-wrap;">',
'&lt;error&gt;',
'&lt;ret&gt;1203&lt;/ret&gt;',
'&lt;message&gt;当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过Windows微信、Mac微信或者手机客户端微信登录。&lt;/message&gt;',
'&lt;/error&gt;',
'</pre>',
].join('')
const BLOCKED_XML_ZH = [
'<error>',
'<ret>1203</ret>',
'<message>当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过Windows微信、Mac微信或者手机客户端微信登录。</message>',
'</error>',
].join('')
const profile = new MemoryCard()
const bridge = new Bridge({ profile })
const xml = bridge.preHtmlToXml(BLOCKED_HTML_ZH)
t.equal(xml, BLOCKED_XML_ZH, 'should parse html to xml')
})
test('testBlockedMessage()', async t => {
const BLOCKED_HTML_ZH = [
'<pre style="word-wrap: break-word; white-space: pre-wrap;">',
'&lt;error&gt;',
'&lt;ret&gt;1203&lt;/ret&gt;',
'&lt;message&gt;当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过手机客户端或者windows微信登录。&lt;/message&gt;',
'&lt;/error&gt;',
'</pre>',
].join('')
const BLOCKED_XML_ZH = `
<error>
<ret>1203</ret>
<message>当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过手机客户端或者windows微信登录。</message>
</error>
`
const BLOCKED_TEXT_ZH = [
'当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。',
'你可以通过手机客户端或者windows微信登录。',
].join('')
// tslint:disable:max-line-length
const BLOCKED_XML_EN = `
<error>
<ret>1203</ret>
<message>For account security, newly registered WeChat accounts are unable to log in to Web WeChat. To use WeChat on a computer, use Windows WeChat or Mac WeChat at http://wechat.com</message>
</error>
`
const BLOCKED_TEXT_EN = [
'For account security, newly registered WeChat accounts are unable to log in to Web WeChat.',
' To use WeChat on a computer, use Windows WeChat or Mac WeChat at http://wechat.com',
].join('')
t.test('not blocked', async t => {
const profile = new MemoryCard()
const bridge = new Bridge({ profile })
const msg = await bridge.testBlockedMessage('this is not xml')
t.equal(msg, false, 'should return false when no block message')
})
t.test('html', async t => {
const profile = new MemoryCard()
const bridge = new Bridge({ profile })
const msg = await bridge.testBlockedMessage(BLOCKED_HTML_ZH)
t.equal(msg, BLOCKED_TEXT_ZH, 'should get zh blocked message')
})
t.test('zh', async t => {
const profile = new MemoryCard()
const bridge = new Bridge({ profile })
const msg = await bridge.testBlockedMessage(BLOCKED_XML_ZH)
t.equal(msg, BLOCKED_TEXT_ZH, 'should get zh blocked message')
})
test('en', async t => {
const profile = new MemoryCard()
const bridge = new Bridge({ profile })
const msg = await bridge.testBlockedMessage(BLOCKED_XML_EN)
t.equal(msg, BLOCKED_TEXT_EN, 'should get en blocked message')
})
})
test('clickSwitchAccount()', async t => {
const SWITCH_ACCOUNT_HTML = `
<div class="association show" ng-class="{show: isAssociationLogin &amp;&amp; !isBrokenNetwork}">
<img class="img" mm-src="" alt="" src="//res.wx.qq.com/a/wx_fed/webwx/res/static/img/2KriyDK.png">
<p ng-show="isWaitingAsConfirm" class="waiting_confirm ng-hide">Confirm login on mobile WeChat</p>
<a href="javascript:;" ng-show="!isWaitingAsConfirm" ng-click="associationLogin()" class="button button_primary">Log in</a>
<a href="javascript:;" ng-click="qrcodeLogin()" class="button button_default">Switch Account</a>
</div>
`
const profile = new MemoryCard()
const bridge = new Bridge({ profile} )
t.test('switch account needed', async t => {
const browser = await launch(PUPPETEER_LAUNCH_OPTIONS)
const page = await browser.newPage()
await page.setContent(SWITCH_ACCOUNT_HTML)
const clicked = await bridge.clickSwitchAccount(page)
await page.close()
await browser.close()
t.equal(clicked, true, 'should click the switch account button')
})
t.test('switch account not needed', async t => {
const browser = await launch(PUPPETEER_LAUNCH_OPTIONS)
const page = await browser.newPage()
await page.setContent('<h1>ok</h1>')
const clicked = await bridge.clickSwitchAccount(page)
await page.close()
await browser.close()
t.equal(clicked, false, 'should no button found')
})
})
test('WechatyBro.ding()', async t => {
const profile = new MemoryCard(Math.random().toString(36).substr(2, 5))
const bridge = new Bridge({
profile,
})
t.ok(bridge, 'should instanciated a bridge')
try {
await bridge.init()
t.pass('should init Bridge')
const retDing = await bridge.evaluate(() => {
return WechatyBro.ding()
}) as any as string
t.is(retDing, 'dong', 'should got dong after execute WechatyBro.ding()')
const retCode = await bridge.proxyWechaty('loginState')
t.is(typeof retCode, 'boolean', 'should got a boolean after call proxyWechaty(loginState)')
await bridge.quit()
t.pass('b.quit()')
} catch (err) {
t.fail('exception: ' + err.message)
} finally {
profile.destroy()
}
})
/**
* 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 { EventEmitter } from 'events'
import fs from 'fs'
import path from 'path'
import {
Browser,
Cookie,
Dialog,
launch,
Page,
} from 'puppeteer'
import StateSwitch from 'state-switch'
// import { parseString } from 'xml2js'
import { toJson } from 'xml2json'
import {
MemoryCard,
} from 'memory-card'
/* tslint:disable:no-var-requires */
// const retryPromise = require('retry-promise').default
import { log } from '../config'
import Misc from '../misc'
import {
WebMessageMediaPayload,
WebMessageRawPayload,
WebContactRawPayload,
} from '../puppet-puppeteer/web-schemas'
import {
WebRoomRawPayload,
} from './web-schemas'
export interface InjectResult {
code: number,
message: string,
}
export interface BridgeOptions {
head? : boolean,
profile : MemoryCard,
}
export class Bridge extends EventEmitter {
private browser : undefined | Browser
private page : undefined | Page
private state : StateSwitch
constructor(
public options: BridgeOptions,
) {
super()
log.verbose('PuppetPuppeteerBridge', 'constructor()')
this.state = new StateSwitch('PuppetPuppeteerBridge', log)
}
public async init(): Promise<void> {
log.verbose('PuppetPuppeteerBridge', 'init()')
this.state.on('pending')
try {
this.browser = await this.initBrowser()
log.verbose('PuppetPuppeteerBridge', 'init() initBrowser() done')
this.on('load', this.onLoad.bind(this))
const ready = new Promise(resolve => this.once('ready', resolve))
this.page = await this.initPage(this.browser)
await ready
this.state.on(true)
log.verbose('PuppetPuppeteerBridge', 'init() initPage() done')
} catch (e) {
log.error('PuppetPuppeteerBridge', 'init() exception: %s', e)
this.state.off(true)
try {
if (this.page) {
await this.page.close()
}
if (this.browser) {
await this.browser.close()
}
} catch (e2) {
log.error('PuppetPuppeteerBridge', 'init() exception %s, close page/browser exception %s', e, e2)
}
this.emit('error', e)
throw e
}
}
public async initBrowser(): Promise<Browser> {
log.verbose('PuppetPuppeteerBridge', 'initBrowser()')
const headless = this.options.head ? false : true
const browser = await launch({
headless,
args: [
'--audio-output-channels=0',
'--disable-default-apps',
'--disable-extensions',
'--disable-translate',
'--disable-gpu',
'--disable-setuid-sandbox',
'--disable-sync',
'--hide-scrollbars',
'--mute-audio',
'--no-sandbox',
],
})
const version = await browser.version()
log.verbose('PuppetPuppeteerBridge', 'initBrowser() version: %s', version)
return browser
}
public async onDialog(dialog: Dialog) {
log.warn('PuppetPuppeteerBridge', 'init() page.on(dialog) type:%s message:%s',
dialog.type, dialog.message())
try {
// XXX: Which ONE is better?
await dialog.accept()
// await dialog.dismiss()
} catch (e) {
log.error('PuppetPuppeteerBridge', 'init() dialog.dismiss() reject: %s', e)
}
this.emit('error', new Error(`${dialog.type}(${dialog.message()})`))
}
public async onLoad(page: Page): Promise<void> {
log.verbose('PuppetPuppeteerBridge', 'initPage() on(load) %s', page.url())
if (this.state.off()) {
log.verbose('PuppetPuppeteerBridge', 'initPage() onLoad() OFF state detected. NOP')
return // reject(new Error('onLoad() OFF state detected'))
}
try {
const emitExist = await page.evaluate(() => {
return typeof window['emit'] === 'function'
})
if (!emitExist) {
await page.exposeFunction('emit', this.emit.bind(this))
}
await this.readyAngular(page)
await this.inject(page)
await this.clickSwitchAccount(page)
this.emit('ready')
} catch (e) {
log.error('PuppetPuppeteerBridge', 'init() initPage() onLoad() exception: %s', e)
await page.close()
this.emit('error', e)
}
}
public async initPage(browser: Browser): Promise<Page> {
log.verbose('PuppetPuppeteerBridge', 'initPage()')
// set this in time because the following callbacks
// might be called before initPage() return.
const page = this.page = await browser.newPage()
page.on('error', e => this.emit('error', e))
page.on('dialog', this.onDialog.bind(this))
const cookieList = (await this.options.profile.get('cookies')) as Cookie[]
const url = this.entryUrl(cookieList)
log.verbose('PuppetPuppeteerBridge', 'initPage() before page.goto(url)')
await page.goto(url) // Does this related to(?) the CI Error: exception: Navigation Timeout Exceeded: 30000ms exceeded
log.verbose('PuppetPuppeteerBridge', 'initPage() after page.goto(url)')
if (cookieList && cookieList.length) {
await page.setCookie(...cookieList)
log.silly('PuppetPuppeteerBridge', 'initPage() page.setCookie() %s cookies set back', cookieList.length)
}
page.on('load', () => this.emit('load', page))
await page.reload() // reload page to make effect of the new cookie.
return page
}
public async readyAngular(page: Page): Promise<void> {
log.verbose('PuppetPuppeteerBridge', 'readyAngular()')
try {
await page.waitForFunction(`typeof window.angular !== 'undefined'`)
} catch (e) {
log.verbose('PuppetPuppeteerBridge', 'readyAngular() exception: %s', e)
const blockedMessage = await this.testBlockedMessage()
if (blockedMessage) { // Wechat Account Blocked
// TODO: advertise for puppet-padchat
log.info('PuppetPuppeteerBridge', `
Please see: Account Login Issue <https://github.com/Chatie/wechaty/issues/872>
`)
throw new Error(blockedMessage)
} else {
throw e
}
}
}
public async inject(page: Page): Promise<void> {
log.verbose('PuppetPuppeteerBridge', 'inject()')
const WECHATY_BRO_JS_FILE = path.join(
__dirname,
'wechaty-bro.js',
)
try {
const sourceCode = fs.readFileSync(WECHATY_BRO_JS_FILE)
.toString()
let retObj = await page.evaluate(sourceCode) as any as InjectResult
if (retObj && /^(2|3)/.test(retObj.code.toString())) {
// HTTP Code 2XX & 3XX
log.silly('PuppetPuppeteerBridge', 'inject() eval(Wechaty) return code[%d] message[%s]',
retObj.code, retObj.message)
} else { // HTTP Code 4XX & 5XX
throw new Error('execute injectio error: ' + retObj.code + ', ' + retObj.message)
}
retObj = await this.proxyWechaty('init')
if (retObj && /^(2|3)/.test(retObj.code.toString())) {
// HTTP Code 2XX & 3XX
log.silly('PuppetPuppeteerBridge', 'inject() Wechaty.init() return code[%d] message[%s]',
retObj.code, retObj.message)
} else { // HTTP Code 4XX & 5XX
throw new Error('execute proxyWechaty(init) error: ' + retObj.code + ', ' + retObj.message)
}
const SUCCESS_CIPHER = 'ding() OK!'
const future = new Promise(resolve => this.once('dong', resolve))
this.ding(SUCCESS_CIPHER)
const r = await future
if (r !== SUCCESS_CIPHER) {
throw new Error('fail to get right return from call ding()')
}
log.silly('PuppetPuppeteerBridge', 'inject() ding success')
} catch (e) {
log.verbose('PuppetPuppeteerBridge', 'inject() exception: %s. stack: %s', e.message, e.stack)
throw e
}
}
public async logout(): Promise<any> {
log.verbose('PuppetPuppeteerBridge', 'logout()')
try {
return await this.proxyWechaty('logout')
} catch (e) {
log.error('PuppetPuppeteerBridge', 'logout() exception: %s', e.message)
throw e
}
}
public async quit(): Promise<void> {
log.verbose('PuppetPuppeteerBridge', 'quit()')
if (!this.page) {
throw new Error('no page')
}
if (!this.browser) {
throw new Error('no browser')
}
this.state.off('pending')
try {
await this.page.close()
log.silly('PuppetPuppeteerBridge', 'quit() page.close()-ed')
} catch (e) {
log.warn('PuppetPuppeteerBridge', 'quit() page.close() exception: %s', e)
}
try {
await this.browser.close()
log.silly('PuppetPuppeteerBridge', 'quit() browser.close()-ed')
} catch (e) {
log.warn('PuppetPuppeteerBridge', 'quit() browser.close() exception: %s', e)
}
this.state.off(true)
}
public async getUserName(): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getUserName()')
try {
const userName = await this.proxyWechaty('getUserName')
return userName
} catch (e) {
log.error('PuppetPuppeteerBridge', 'getUserName() exception: %s', e.message)
throw e
}
}
public async contactAlias(contactId: string, alias: string|null): Promise<boolean> {
try {
return await this.proxyWechaty('contactRemark', contactId, alias)
} catch (e) {
log.verbose('PuppetPuppeteerBridge', 'contactRemark() exception: %s', e.message)
// Issue #509 return false instead of throw when contact is not a friend.
// throw e
log.warn('PuppetPuppeteerBridge', 'contactRemark() does not work on contact is not a friend')
return false
}
}
public async contactList(): Promise<string[]> {
try {
return await this.proxyWechaty('contactList')
} catch (e) {
log.error('PuppetPuppeteerBridge', 'contactList() exception: %s', e.message)
throw e
}
}
public async roomList(): Promise<string[]> {
try {
return await this.proxyWechaty('roomList')
} catch (e) {
log.error('PuppetPuppeteerBridge', 'roomList() exception: %s', e.message)
throw e
}
}
public async roomDelMember(
roomId: string,
contactId: string,
): Promise<number> {
if (!roomId || !contactId) {
throw new Error('no roomId or contactId')
}
try {
return await this.proxyWechaty('roomDelMember', roomId, contactId)
} catch (e) {
log.error('PuppetPuppeteerBridge', 'roomDelMember(%s, %s) exception: %s', roomId, contactId, e.message)
throw e
}
}
public async roomAddMember(
roomId: string,
contactId: string,
): Promise<number> {
log.verbose('PuppetPuppeteerBridge', 'roomAddMember(%s, %s)', roomId, contactId)
if (!roomId || !contactId) {
throw new Error('no roomId or contactId')
}
try {
return await this.proxyWechaty('roomAddMember', roomId, contactId)
} catch (e) {
log.error('PuppetPuppeteerBridge', 'roomAddMember(%s, %s) exception: %s', roomId, contactId, e.message)
throw e
}
}
public async roomModTopic(
roomId: string,
topic: string,
): Promise<string> {
if (!roomId) {
throw new Error('no roomId')
}
try {
await this.proxyWechaty('roomModTopic', roomId, topic)
return topic
} catch (e) {
log.error('PuppetPuppeteerBridge', 'roomModTopic(%s, %s) exception: %s', roomId, topic, e.message)
throw e
}
}
public async roomCreate(contactIdList: string[], topic?: string): Promise<string> {
if (!contactIdList || !Array.isArray(contactIdList)) {
throw new Error('no valid contactIdList')
}
try {
const roomId = await this.proxyWechaty('roomCreate', contactIdList, topic)
if (typeof roomId === 'object') {
// It is a Error Object send back by callback in browser(WechatyBro)
throw roomId
}
return roomId
} catch (e) {
log.error('PuppetPuppeteerBridge', 'roomCreate(%s) exception: %s', contactIdList, e.message)
throw e
}
}
public async verifyUserRequest(
contactId: string,
hello: string,
): Promise<boolean> {
log.verbose('PuppetPuppeteerBridge', 'verifyUserRequest(%s, %s)', contactId, hello)
if (!contactId) {
throw new Error('no valid contactId')
}
try {
return await this.proxyWechaty('verifyUserRequest', contactId, hello)
} catch (e) {
log.error('PuppetPuppeteerBridge', 'verifyUserRequest(%s, %s) exception: %s', contactId, hello, e.message)
throw e
}
}
public async verifyUserOk(
contactId: string,
ticket: string,
): Promise<boolean> {
log.verbose('PuppetPuppeteerBridge', 'verifyUserOk(%s, %s)', contactId, ticket)
if (!contactId || !ticket) {
throw new Error('no valid contactId or ticket')
}
try {
return await this.proxyWechaty('verifyUserOk', contactId, ticket)
} catch (e) {
log.error('PuppetPuppeteerBridge', 'verifyUserOk(%s, %s) exception: %s', contactId, ticket, e.message)
throw e
}
}
public async send(
toUserName: string,
text: string,
): Promise<void> {
log.verbose('PuppetPuppeteerBridge', 'send(%s, %s)', toUserName, text)
if (!toUserName) {
throw new Error('UserName not found')
}
if (!text) {
throw new Error('cannot say nothing')
}
try {
const ret = await this.proxyWechaty('send', toUserName, text)
if (!ret) {
throw new Error('send fail')
}
} catch (e) {
log.error('PuppetPuppeteerBridge', 'send() exception: %s', e.message)
throw e
}
}
public async getMsgImg(id: string): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getMsgImg(%s)', id)
try {
return await this.proxyWechaty('getMsgImg', id)
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getMsgImg, %d) exception: %s', id, e.message)
throw e
}
}
public async getMsgEmoticon(id: string): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getMsgEmoticon(%s)', id)
try {
return await this.proxyWechaty('getMsgEmoticon', id)
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getMsgEmoticon, %d) exception: %s', id, e.message)
throw e
}
}
public async getMsgVideo(id: string): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getMsgVideo(%s)', id)
try {
return await this.proxyWechaty('getMsgVideo', id)
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getMsgVideo, %d) exception: %s', id, e.message)
throw e
}
}
public async getMsgVoice(id: string): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getMsgVoice(%s)', id)
try {
return await this.proxyWechaty('getMsgVoice', id)
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getMsgVoice, %d) exception: %s', id, e.message)
throw e
}
}
public async getMsgPublicLinkImg(id: string): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getMsgPublicLinkImg(%s)', id)
try {
return await this.proxyWechaty('getMsgPublicLinkImg', id)
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getMsgPublicLinkImg, %d) exception: %s', id, e.message)
throw e
}
}
public async getMessage(id: string): Promise<WebMessageRawPayload> {
try {
return await Misc.retry(async (retry, attempt) => {
log.silly('PuppetPuppeteerBridge', 'getMessage(%s) retry attempt %d',
id,
attempt,
)
try {
const rawPayload = await this.proxyWechaty('getMessage', id)
if (rawPayload && Object.keys(rawPayload).length > 0) {
return rawPayload
}
throw new Error('got empty return value at attempt: ' + attempt)
} catch (e) {
log.verbose('PuppetPuppeteerBridge', 'getMessage() proxyWechaty(getMessage, %s) exception: %s', id, e.message)
retry(e)
}
})
} catch (e) {
log.error('PuppetPuppeteerBridge', 'promiseRetry() getContact() finally FAIL: %s', e.message)
throw e
}
}
public async getContact(id: string): Promise<WebContactRawPayload | WebRoomRawPayload> {
try {
return await Misc.retry(async (retry, attempt) => {
log.silly('PuppetPuppeteerBridge', 'getContact(%s) retry attempt %d',
id,
attempt,
)
try {
const rawPayload = await this.proxyWechaty('getContact', id)
if (rawPayload && Object.keys(rawPayload).length > 0) {
return rawPayload
}
throw new Error('got empty return value at attempt: ' + attempt)
} catch (e) {
log.verbose('PuppetPuppeteerBridge', 'getContact() proxyWechaty(getContact, %s) exception: %s', id, e.message)
retry(e)
}
})
} catch (e) {
log.error('PuppetPuppeteerBridge', 'promiseRetry() getContact() finally FAIL: %s', e.message)
throw e
}
/////////////////////////////////
}
public async getBaseRequest(): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getBaseRequest()')
try {
return await this.proxyWechaty('getBaseRequest')
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getBaseRequest) exception: %s', e.message)
throw e
}
}
public async getPassticket(): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getPassticket()')
try {
return await this.proxyWechaty('getPassticket')
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getPassticket) exception: %s', e.message)
throw e
}
}
public async getCheckUploadUrl(): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getCheckUploadUrl()')
try {
return await this.proxyWechaty('getCheckUploadUrl')
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getCheckUploadUrl) exception: %s', e.message)
throw e
}
}
public async getUploadMediaUrl(): Promise<string> {
log.verbose('PuppetPuppeteerBridge', 'getUploadMediaUrl()')
try {
return await this.proxyWechaty('getUploadMediaUrl')
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(getUploadMediaUrl) exception: %s', e.message)
throw e
}
}
public async sendMedia(mediaData: WebMessageMediaPayload): Promise<boolean> {
log.verbose('PuppetPuppeteerBridge', 'sendMedia(mediaData)')
if (!mediaData.ToUserName) {
throw new Error('UserName not found')
}
if (!mediaData.MediaId) {
throw new Error('cannot say nothing')
}
try {
return await this.proxyWechaty('sendMedia', mediaData)
} catch (e) {
log.error('PuppetPuppeteerBridge', 'sendMedia() exception: %s', e.message)
throw e
}
}
public async forward(baseData: WebMessageRawPayload, patchData: WebMessageRawPayload): Promise<boolean> {
log.verbose('PuppetPuppeteerBridge', 'forward()')
if (!baseData.ToUserName) {
throw new Error('UserName not found')
}
if (!patchData.MMActualContent && !patchData.MMSendContent && !patchData.Content) {
throw new Error('cannot say nothing')
}
try {
return await this.proxyWechaty('forward', baseData, patchData)
} catch (e) {
log.error('PuppetPuppeteerBridge', 'forward() exception: %s', e.message)
throw e
}
}
/**
* Proxy Call to Wechaty in Bridge
*/
public async proxyWechaty(
wechatyFunc : string,
...args : any[]
): Promise<any> {
log.silly('PuppetPuppeteerBridge', 'proxyWechaty(%s%s)',
wechatyFunc,
args.length === 0
? ''
: ', ' + args.join(', '),
)
if (!this.page) {
throw new Error('no page')
}
try {
const noWechaty = await this.page.evaluate(() => {
return typeof WechatyBro === 'undefined'
})
if (noWechaty) {
const e = new Error('there is no WechatyBro in browser(yet)')
throw e
}
} catch (e) {
log.warn('PuppetPuppeteerBridge', 'proxyWechaty() noWechaty exception: %s', e)
throw e
}
const argsEncoded = Buffer.from(
encodeURIComponent(
JSON.stringify(args),
),
).toString('base64')
// see: http://blog.sqrtthree.com/2015/08/29/utf8-to-b64/
const argsDecoded = `JSON.parse(decodeURIComponent(window.atob('${argsEncoded}')))`
const wechatyScript = `
WechatyBro
.${wechatyFunc}
.apply(
undefined,
${argsDecoded},
)
`.replace(/[\n\s]+/, ' ')
// log.silly('PuppetPuppeteerBridge', 'proxyWechaty(%s, ...args) %s', wechatyFunc, wechatyScript)
// console.log('proxyWechaty wechatyFunc args[0]: ')
// console.log(args[0])
try {
const ret = await this.page.evaluate(wechatyScript)
return ret
} catch (e) {
log.verbose('PuppetPuppeteerBridge', 'proxyWechaty(%s, %s) ', wechatyFunc, args.join(', '))
log.warn('PuppetPuppeteerBridge', 'proxyWechaty() exception: %s', e.message)
throw e
}
}
public ding(data: any): void {
log.verbose('PuppetPuppeteerBridge', 'ding(%s)', data || '')
this.proxyWechaty('ding', data)
.then(dongData => {
this.emit('dong', dongData)
})
.catch(e => {
log.error('PuppetPuppeteerBridge', 'ding(%s) exception: %s', data, e.message)
this.emit('error', e)
})
}
public preHtmlToXml(text: string): string {
log.verbose('PuppetPuppeteerBridge', 'preHtmlToXml()')
const preRegex = /^<pre[^>]*>([^<]+)<\/pre>$/i
const matches = text.match(preRegex)
if (!matches) {
return text
}
return Misc.unescapeHtml(matches[1])
}
public async innerHTML(): Promise<string> {
const html = await this.evaluate(() => {
return window.document.body.innerHTML
})
return html
}
/**
* Throw if there's a blocked message
*/
public async testBlockedMessage(text?: string): Promise<string | false> {
if (!text) {
text = await this.innerHTML()
}
if (!text) {
throw new Error('testBlockedMessage() no text found!')
}
const textSnip = text.substr(0, 50).replace(/\n/, '')
log.verbose('PuppetPuppeteerBridge', 'testBlockedMessage(%s)',
textSnip)
interface BlockedMessage {
error?: {
ret : number,
message : string,
}
}
let obj: BlockedMessage
try {
// see unit test for detail
const tryXmlText = this.preHtmlToXml(text)
obj = JSON.parse(toJson(tryXmlText))
} catch (e) {
log.warn('PuppetPuppeteerBridge', 'testBlockedMessage() toJson() exception: %s', e)
return false
}
if (!obj) {
// FIXME: when will this happen?
log.warn('PuppetPuppeteerBridge', 'testBlockedMessage() toJson(%s) return empty obj', textSnip)
return false
}
if (!obj.error) {
return false
}
const ret = +obj.error.ret
const message = obj.error.message
log.warn('PuppetPuppeteerBridge', 'testBlockedMessage() error.ret=%s', ret)
if (ret === 1203) {
// <error>
// <ret>1203</ret>
// <message>当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过手机客户端或者windows微信登录。</message>
// </error>
return message
}
return message // other error message
// return new Promise<string | false>(resolve => {
// parseString(tryXmlText, { explicitArray: false }, (err, obj: BlockedMessage) => {
// if (err) { // HTML can not be parsed to JSON
// return resolve(false)
// }
// if (!obj) {
// // FIXME: when will this happen?
// log.warn('PuppetPuppeteerBridge', 'testBlockedMessage() parseString(%s) return empty obj', textSnip)
// return resolve(false)
// }
// if (!obj.error) {
// return resolve(false)
// }
// const ret = +obj.error.ret
// const message = obj.error.message
// log.warn('PuppetPuppeteerBridge', 'testBlockedMessage() error.ret=%s', ret)
// if (ret === 1203) {
// // <error>
// // <ret>1203</ret>
// // <message>当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过手机客户端或者windows微信登录。</message>
// // </error>
// return resolve(message)
// }
// return resolve(message) // other error message
// })
// })
}
public async clickSwitchAccount(page: Page): Promise<boolean> {
log.verbose('PuppetPuppeteerBridge', 'clickSwitchAccount()')
// https://github.com/GoogleChrome/puppeteer/issues/537#issuecomment-334918553
// async function listXpath(thePage: Page, xpath: string): Promise<ElementHandle[]> {
// log.verbose('PuppetPuppeteerBridge', 'clickSwitchAccount() listXpath()')
// try {
// const nodeHandleList = await (thePage as any).evaluateHandle(xpathInner => {
// const nodeList: Node[] = []
// const query = document.evaluate(xpathInner, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
// for (let i = 0, length = query.snapshotLength; i < length; ++i) {
// nodeList.push(query.snapshotItem(i))
// }
// return nodeList
// }, xpath)
// const properties = await nodeHandleList.getProperties()
// const elementHandleList: ElementHandle[] = []
// const releasePromises: Promise<void>[] = []
// for (const property of properties.values()) {
// const element = property.asElement()
// if (element)
// elementHandleList.push(element)
// else
// releasePromises.push(property.dispose())
// }
// await Promise.all(releasePromises)
// return elementHandleList
// } catch (e) {
// log.verbose('PuppetPuppeteerBridge', 'clickSwitchAccount() listXpath() exception: %s', e)
// return []
// }
// }
// TODO: use page.$x() (with puppeteer v1.1 or above) to replace DIY version of listXpath() instead.
// See: https://github.com/GoogleChrome/puppeteer/blob/v1.1.0/docs/api.md#pagexexpression
const XPATH_SELECTOR = `//div[contains(@class,'association') and contains(@class,'show')]/a[@ng-click='qrcodeLogin()']`
try {
// const [button] = await listXpath(page, XPATH_SELECTOR)
const [button] = await page.$x(XPATH_SELECTOR)
if (button) {
await button.click()
log.silly('PuppetPuppeteerBridge', 'clickSwitchAccount() clicked!')
return true
} else {
log.silly('PuppetPuppeteerBridge', 'clickSwitchAccount() button not found')
return false
}
} catch (e) {
log.silly('PuppetPuppeteerBridge', 'clickSwitchAccount() exception: %s', e)
throw e
}
}
public async hostname(): Promise<string | null> {
log.verbose('PuppetPuppeteerBridge', 'hostname()')
if (!this.page) {
throw new Error('no page')
}
try {
const hostname = await this.page.evaluate(() => window.location.hostname) as string
log.silly('PuppetPuppeteerBridge', 'hostname() got %s', hostname)
return hostname
} catch (e) {
log.error('PuppetPuppeteerBridge', 'hostname() exception: %s', e)
this.emit('error', e)
return null
}
}
public async cookies(cookieList: Cookie[]): Promise<void>
public async cookies(): Promise<Cookie[]>
public async cookies(cookieList?: Cookie[]): Promise<void | Cookie[]> {
if (!this.page) {
throw new Error('no page')
}
if (cookieList) {
try {
await this.page.setCookie(...cookieList)
} catch (e) {
log.error('PuppetPuppeteerBridge', 'cookies(%s) reject: %s', cookieList, e)
this.emit('error', e)
}
return
} else {
// FIXME: puppeteer typing bug
cookieList = await this.page.cookies() as any as Cookie[]
return cookieList
}
}
/**
* name
*/
public entryUrl(cookieList?: Cookie[]): string {
log.verbose('PuppetPuppeteerBridge', 'cookieDomain(%s)', cookieList)
const DEFAULT_URL = 'https://wx.qq.com'
if (!cookieList || cookieList.length === 0) {
log.silly('PuppetPuppeteerBridge', 'cookieDomain() no cookie, return default %s', DEFAULT_URL)
return DEFAULT_URL
}
const wxCookieList = cookieList.filter(c => /^webwx_auth_ticket|webwxuvid$/.test(c.name))
if (!wxCookieList.length) {
log.silly('PuppetPuppeteerBridge', 'cookieDomain() no valid cookie, return default hostname')
return DEFAULT_URL
}
let domain = wxCookieList[0].domain
if (!domain) {
log.silly('PuppetPuppeteerBridge', 'cookieDomain() no valid domain in cookies, return default hostname')
return DEFAULT_URL
}
domain = domain.slice(1)
if (domain === 'wechat.com') {
domain = 'web.wechat.com'
}
let url
if (/^http/.test(domain)) {
url = domain
} else {
// Protocol error (Page.navigate): Cannot navigate to invalid URL undefined
url = `https://${domain}`
}
log.silly('PuppetPuppeteerBridge', 'cookieDomain() got %s', url)
return url
}
public async reload(): Promise<void> {
log.verbose('PuppetPuppeteerBridge', 'reload()')
if (!this.page) {
throw new Error('no page')
}
await this.page.reload()
return
}
public async evaluate(fn: () => any, ...args: any[]): Promise<any> {
log.silly('PuppetPuppeteerBridge', 'evaluate()')
if (!this.page) {
throw new Error('no page')
}
try {
return await this.page.evaluate(fn, ...args)
} catch (e) {
log.error('PuppetPuppeteerBridge', 'evaluate() exception: %s', e)
this.emit('error', e)
return null
}
}
}
export {
Cookie,
}
export default Bridge
#!/usr/bin/env ts-node
/**
* 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 test from 'blue-tape'
// tslint:disable:no-shadowed-variable
// import sinon from 'sinon'
import {
MemoryCard,
} from 'memory-card'
// import Wechaty from '../wechaty'
import {
// Event,
PuppetPuppeteer,
} from './puppet-puppeteer'
test('Puppet Puppeteer Event smoke testing', async t => {
const puppet = new PuppetPuppeteer({
memory: new MemoryCard(),
})
try {
await puppet.start()
t.pass('should be inited')
await puppet.stop()
t.pass('should be quited')
} catch (e) {
t.fail('exception: ' + e.message)
}
})
/**
* 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 {
WatchdogFood,
} from 'watchdog'
import {
log,
} from '../config'
// import {
// PuppetScanEvent,
// } from 'wechaty-puppet'
import {
Firer,
} from './firer'
import {
PuppetPuppeteer,
} from './puppet-puppeteer'
import {
WebMessageType,
WebMessageRawPayload,
} from './web-schemas'
/* tslint:disable:variable-name */
export const Event = {
onDing,
onLog,
onLogin,
onLogout,
onMessage,
onScan,
onUnload,
}
function onDing(
this: PuppetPuppeteer,
data: any,
): void {
log.silly('PuppetPuppeteerEvent', 'onDing(%s)', data)
this.emit('watchdog', { data })
}
async function onScan(
this : PuppetPuppeteer,
// Do not use PuppetScanPayload at here, use { code: number, url: string } instead,
// because this is related with Browser Hook Code:
// wechaty-bro.js
payloadFromBrowser : { code: number, url: string },
): Promise<void> {
log.verbose('PuppetPuppeteerEvent', 'onScan({code: %d, url: %s})', payloadFromBrowser.code, payloadFromBrowser.url)
// if (this.state.off()) {
// log.verbose('PuppetPuppeteerEvent', 'onScan(%s) state.off()=%s, NOOP',
// payload, this.state.off())
// return
// }
this.scanPayload = {
qrcode: payloadFromBrowser.url,
status: payloadFromBrowser.code,
}
/**
* When wx.qq.com push a new QRCode to Scan, there will be cookie updates(?)
*/
await this.saveCookie()
if (this.logonoff()) {
log.verbose('PuppetPuppeteerEvent', 'onScan() there has user when got a scan event. emit logout and set it to null')
await this.logout()
}
// feed watchDog a `scan` type of food
const food: WatchdogFood = {
data: payloadFromBrowser,
type: 'scan',
}
this.emit('watchdog', food)
const qrcode = payloadFromBrowser.url.replace(/\/qrcode\//, '/l/')
const status = payloadFromBrowser.code
this.emit('scan', qrcode, status)
}
function onLog(data: any): void {
log.silly('PuppetPuppeteerEvent', 'onLog(%s)', data)
}
async function onLogin(
this: PuppetPuppeteer,
note: string,
ttl = 30,
): Promise<void> {
log.verbose('PuppetPuppeteerEvent', 'onLogin(%s, %d)', note, ttl)
const TTL_WAIT_MILLISECONDS = 1 * 1000
if (ttl <= 0) {
log.verbose('PuppetPuppeteerEvent', 'onLogin(%s) TTL expired')
this.emit('error', new Error('onLogin() TTL expired.'))
return
}
// if (this.state.off()) {
// log.verbose('PuppetPuppeteerEvent', 'onLogin(%s, %d) state.off()=%s, NOOP',
// note, ttl, this.state.off())
// return
// }
if (this.logonoff()) {
throw new Error('onLogin() user had already logined: ' + this.selfId())
// await this.logout()
}
this.scanPayload = undefined
try {
/**
* save login user id to this.userId
*
* issue #772: this.bridge might not inited if the 'login' event fired too fast(because of auto login)
*/
const userId = await this.bridge.getUserName()
if (!userId) {
log.verbose('PuppetPuppeteerEvent', 'onLogin() browser not fully loaded(ttl=%d), retry later', ttl)
const html = await this.bridge.innerHTML()
log.silly('PuppetPuppeteerEvent', 'onLogin() innerHTML: %s', html.substr(0, 500))
setTimeout(onLogin.bind(this, note, ttl - 1), TTL_WAIT_MILLISECONDS)
return
}
log.silly('PuppetPuppeteerEvent', 'bridge.getUserName: %s', userId)
// const user = this.Contact.load(userId)
// await user.ready()
log.silly('PuppetPuppeteerEvent', `onLogin() user ${userId} logined`)
// if (this.state.on() === true) {
await this.saveCookie()
// }
// fix issue #668
await this.waitStable()
await this.login(userId)
} catch (e) {
log.error('PuppetPuppeteerEvent', 'onLogin() exception: %s', e)
throw e
}
return
}
async function onLogout(
this: PuppetPuppeteer,
data: any,
): Promise<void> {
log.verbose('PuppetPuppeteerEvent', 'onLogout(%s)', data)
if (this.logonoff()) {
await this.logout()
} else {
// not logged-in???
log.error('PuppetPuppeteerEvent', 'onLogout() without self-user')
}
}
async function onMessage(
this : PuppetPuppeteer,
rawPayload : WebMessageRawPayload,
): Promise<void> {
// const msg = this.Message.create(
// rawPayload.MsgId,
// await this.messagePayload(rawPayload.MsgId),
// )
const firer = new Firer(this)
/**
* Fire Events if match message type & content
*/
switch (rawPayload.MsgType) {
case WebMessageType.VERIFYMSG:
this.emit('friendship', rawPayload.MsgId)
// firer.checkFriendRequest(rawPayload)
break
case WebMessageType.SYS:
/**
* /^@@/.test() return true means it's a room
*/
if (/^@@/.test(rawPayload.FromUserName)) {
const joinResult = await firer.checkRoomJoin(rawPayload)
const leaveResult = await firer.checkRoomLeave(rawPayload)
const topicRestul = await firer.checkRoomTopic(rawPayload)
if (!joinResult && !leaveResult && !topicRestul) {
log.warn('PuppetPuppeteerEvent', `checkRoomSystem message: <${rawPayload.Content}> not found`)
}
} else {
await firer.checkFriendConfirm(rawPayload)
}
break
}
this.emit('message', rawPayload.MsgId)
}
async function onUnload(this: PuppetPuppeteer): Promise<void> {
log.silly('PuppetPuppeteerEvent', 'onUnload()')
/*
try {
await this.quit()
await this.init()
} catch (e) {
log.error('PuppetPuppeteerEvent', 'onUnload() exception: %s', e)
this.emit('error', e)
throw e
}
*/
}
export default Event
#!/usr/bin/env ts-node
/**
* 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.
*
*/
/**
* Process the Message to find which event to FIRE
*/
// tslint:disable:no-shadowed-variable
import test from 'blue-tape'
// import sinon from 'sinon'
// const sinonTest = require('sinon-test')(sinon)
import { PuppetPuppeteer } from './puppet-puppeteer'
import { Firer } from './firer'
const SELF_ID = 'self-id'
const mockPuppetPuppeteer = {
selfId: () => SELF_ID,
} as any as PuppetPuppeteer
test('parseFriendConfirm()', async t => {
const contentList = [
[
'You have added 李卓桓 as your WeChat contact. Start chatting!',
'李卓桓',
],
[
'你已添加了李卓桓,现在可以开始聊天了。',
'李卓桓',
],
[
`johnbassserver@gmail.com just added you to his/her contacts list. Send a message to him/her now!`,
`johnbassserver@gmail.com`,
],
[
`johnbassserver@gmail.com刚刚把你添加到通讯录,现在可以开始聊天了。`,
`johnbassserver@gmail.com`,
],
]
let result: boolean
const firer = new Firer(mockPuppetPuppeteer)
contentList.forEach(([content]) => {
result = (firer as any).parseFriendConfirm(content)
t.true(result, 'should be truthy for confirm msg: ' + content)
})
result = (firer as any).parseFriendConfirm('fsdfsdfasdfasdfadsa')
t.false(result, 'should be falsy for other msg')
})
test('parseRoomJoin()', async t => {
const contentList: [string, string, string[]][] = [
[
`You invited 管理员 to the group chat. `,
`You`,
[`管理员`],
],
[
`You invited 李卓桓.PreAngel、Bruce LEE to the group chat. `,
`You`,
[`李卓桓.PreAngel`, `Bruce LEE`],
],
[
`管理员 invited 小桔建群助手 to the group chat`,
`管理员`,
[`小桔建群助手`],
],
[
`管理员 invited 庆次、小桔妹 to the group chat`,
`管理员`,
['庆次', '小桔妹'],
],
[
`你邀请"管理员"加入了群聊 `,
`你`,
['管理员'],
],
[
`"管理员"邀请"宁锐锋"加入了群聊`,
`管理员`,
['宁锐锋'],
],
[
`"管理员"通过扫描你分享的二维码加入群聊 `,
`你`,
['管理员'],
],
[
`" 桔小秘"通过扫描"李佳芮"分享的二维码加入群聊`,
`李佳芮`,
['桔小秘'],
],
[
`"管理员" joined group chat via the QR code you shared. `,
`you`,
['管理员'],
],
[
`"宁锐锋" joined the group chat via the QR Code shared by "管理员".`,
`管理员`,
['宁锐锋'],
],
]
const firer = new Firer(mockPuppetPuppeteer)
let result
contentList.forEach(([content, inviter, inviteeList]) => {
result = (firer as any).parseRoomJoin(content)
t.ok(result, 'should check room join message right for ' + content)
t.deepEqual(result[0], inviteeList, 'should get inviteeList right')
t.is(result[1], inviter, 'should get inviter right')
})
t.throws(() => {
(firer as any).parseRoomJoin('fsadfsadfsdfsdfs')
}, Error, 'should throws if message is not expected')
})
test('parseRoomLeave()', async t => {
const contentLeaverList = [
[
`You removed "Bruce LEE" from the group chat`,
`Bruce LEE`,
],
[
'你将"李佳芮"移出了群聊',
'李佳芮',
],
]
const contentRemoverList = [
[
`You were removed from the group chat by "桔小秘"`,
`桔小秘`,
],
[
'你被"李佳芮"移出群聊',
'李佳芮',
],
]
const firer = new Firer(mockPuppetPuppeteer)
contentLeaverList.forEach(([content, leaver]) => {
const resultLeaver = (firer as any).parseRoomLeave(content)[0]
t.ok(resultLeaver, 'should get leaver for leave message: ' + content)
t.is(resultLeaver, leaver, 'should get leaver name right')
})
contentRemoverList.forEach(([content, remover]) => {
const resultRemover = (firer as any).parseRoomLeave(content)[1]
t.ok(resultRemover, 'should get remover for leave message: ' + content)
t.is(resultRemover, remover, 'should get leaver name right')
})
t.throws(() => {
(firer as any).parseRoomLeave('fafdsfsdfafa')
}, Error, 'should throw if message is not expected')
})
test('parseRoomTopic()', async t => {
const contentList = [
[
`"李卓桓.PreAngel" changed the group name to "ding"`,
`李卓桓.PreAngel`,
`ding`,
],
[
'"李佳芮"修改群名为“dong”',
'李佳芮',
'dong',
],
]
const firer = new Firer(mockPuppetPuppeteer)
let result
contentList.forEach(([content, changer, topic]) => {
result = (firer as any).parseRoomTopic(content)
t.ok(result, 'should check topic right for content: ' + content)
t.is(topic , result[0], 'should get right topic')
t.is(changer, result[1], 'should get right changer')
})
t.throws(() => {
(firer as any).parseRoomTopic('fafdsfsdfafa')
}, Error, 'should throw if message is not expected')
})
/**
* 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.
*
*/
/* tslint:disable:no-var-requires */
// const retryPromise = require('retry-promise').default
// import cuid from 'cuid'
import {
log,
} from '../config'
import {
// WebRecomendInfo,
WebMessageRawPayload,
} from './web-schemas'
import PuppetPuppeteer from './puppet-puppeteer'
// import {
// // FriendRequestPayload,
// FriendRequestType,
// FriendRequestPayloadReceive,
// FriendRequestPayloadConfirm,
// } from 'wechaty-puppet'
const REGEX_CONFIG = {
friendConfirm: [
/^You have added (.+) as your WeChat contact. Start chatting!$/,
/^你已添加了(.+),现在可以开始聊天了。$/,
/^(.+) just added you to his\/her contacts list. Send a message to him\/her now!$/,
/^(.+)刚刚把你添加到通讯录,现在可以开始聊天了。$/,
],
roomJoinInvite: [
// There are 3 blank(charCode is 32) here. eg: You invited 管理员 to the group chat.
/^(.+?) invited (.+) to the group chat.\s+$/,
// There no no blank or punctuation here. eg: 管理员 invited 小桔建群助手 to the group chat
/^(.+?) invited (.+) to the group chat$/,
// There are 2 blank(charCode is 32) here. eg: 你邀请"管理员"加入了群聊
/^(.+?)邀请"(.+)"加入了群聊\s+$/,
// There no no blank or punctuation here. eg: "管理员"邀请"宁锐锋"加入了群聊
/^"(.+?)"邀请"(.+)"加入了群聊$/,
],
roomJoinQrcode: [
// Wechat change this, should desperate. See more in pr#651
// /^" (.+)" joined the group chat via the QR Code shared by "?(.+?)".$/,
// There are 2 blank(charCode is 32) here. Qrcode is shared by bot. eg: "管理员" joined group chat via the QR code you shared.
/^"(.+)" joined group chat via the QR code "?(.+?)"? shared.\s+$/,
// There are no blank(charCode is 32) here. Qrcode isn't shared by bot. eg: "宁锐锋" joined the group chat via the QR Code shared by "管理员".
/^"(.+)" joined the group chat via the QR Code shared by "?(.+?)".$/,
// There are 2 blank(charCode is 32) here. Qrcode is shared by bot. eg: "管理员"通过扫描你分享的二维码加入群聊
/^"(.+)"通过扫描(.+?)分享的二维码加入群聊\s+$/,
// There are 1 blank(charCode is 32) here. Qrode isn't shared by bot. eg: " 苏轼"通过扫描"管理员"分享的二维码加入群聊
/^"\s+(.+)"通过扫描"(.+?)"分享的二维码加入群聊$/,
],
// no list
roomLeaveIKickOther: [
/^(You) removed "(.+)" from the group chat$/,
/^()将"(.+)"移出了群聊$/,
],
roomLeaveOtherKickMe: [
/^(You) were removed from the group chat by "(.+)"$/,
/^()被"(.+)"移出群聊$/,
],
roomTopic: [
/^"?(.+?)"? changed the group name to "(.+)"$/,
/^"?(.+?)"?修改群名为“(.+)”$/,
],
}
export class Firer {
constructor(
public puppet: PuppetPuppeteer,
) {
//
}
// public async checkFriendRequest(
// rawPayload : WebMessageRawPayload,
// ): Promise<void> {
// if (!rawPayload.RecommendInfo) {
// throw new Error('no RecommendInfo')
// }
// const recommendInfo: WebRecomendInfo = rawPayload.RecommendInfo
// log.verbose('PuppetPuppeteerFirer', 'fireFriendRequest(%s)', recommendInfo)
// if (!recommendInfo) {
// throw new Error('no recommendInfo')
// }
// const contactId = recommendInfo.UserName
// const hello = recommendInfo.Content
// const ticket = recommendInfo.Ticket
// const type = FriendRequestType.Receive
// const id = cuid()
// const payloadReceive: FriendRequestPayloadReceive = {
// id,
// contactId,
// hello,
// ticket,
// type,
// }
// this.puppet.cacheFriendRequestPayload.set(id, payloadReceive)
// this.puppet.emit('friend', id)
// }
public async checkFriendConfirm(
rawPayload : WebMessageRawPayload,
) {
const content = rawPayload.Content
log.silly('PuppetPuppeteerFirer', 'fireFriendConfirm(%s)', content)
if (!this.parseFriendConfirm(content)) {
return
}
// const contactId = rawPayload.FromUserName
// const type = FriendRequestType.Confirm
// const id = cuid()
// const payloadConfirm: FriendRequestPayloadConfirm = {
// id,
// contactId,
// type,
// }
// this.puppet.cacheFriendRequestPayload.set(id, payloadConfirm)
this.puppet.emit('friendship', rawPayload.MsgId)
}
public async checkRoomJoin(
rawPayload : WebMessageRawPayload,
): Promise<boolean> {
const text = rawPayload.Content
const roomId = rawPayload.FromUserName
/**
* Get the display names of invitee & inviter
*/
let inviteeNameList : string[]
let inviterName : string
try {
[inviteeNameList, inviterName] = this.parseRoomJoin(text)
} catch (e) {
log.silly('PuppetPuppeteerFirer', 'checkRoomJoin() "%s" is not a join message', text)
return false // not a room join message
}
log.silly('PuppetPuppeteerFirer', 'checkRoomJoin() inviteeList: %s, inviter: %s',
inviteeNameList.join(','),
inviterName,
)
/**
* Convert the display name to Contact ID
*/
let inviterContactId: undefined | string = undefined
const inviteeContactIdList: string[] = []
if (/^You|你$/i.test(inviterName)) { // === 'You' || inviter === '你' || inviter === 'you'
inviterContactId = this.puppet.selfId()
}
const sleep = 1000
const timeout = 60 * 1000
let ttl = timeout / sleep
let ready = true
while (ttl-- > 0) {
log.silly('PuppetPuppeteerFirer', 'fireRoomJoin() retry() ttl %d', ttl)
if (!ready) {
await new Promise(r => setTimeout(r, timeout))
ready = true
}
/**
* loop inviteeNameList
* set inviteeContactIdList
*/
for (const i in inviteeNameList) {
const inviteeName = inviteeNameList[i]
const inviteeContactId = inviteeContactIdList[i]
if (inviteeContactId) {
/**
* had already got ContactId for Room Member
* try to resolve the ContactPayload
*/
try {
await this.puppet.contactPayload(inviteeContactId)
} catch (e) {
log.warn('PuppetPuppeteerFirer', 'fireRoomJoin() contactPayload(%s) exception: %s', inviteeContactId, e.message)
ready = false
}
} else {
/**
* only had Name of RoomMember
* try to resolve the ContactId & ContactPayload
*/
const memberIdList = await this.puppet.roomMemberSearch(roomId, inviteeName)
if (memberIdList.length <= 0) {
ready = false
}
const contactId = memberIdList[0]
// XXX: Take out the first one if we have matched many contact.
inviteeContactIdList[i] = contactId
try {
await this.puppet.contactPayload(contactId)
} catch (e) {
ready = false
}
}
}
if (!inviterContactId) {
const contactIdList = await this.puppet.roomMemberSearch(roomId, inviterName)
if (contactIdList.length > 0) {
inviterContactId = contactIdList[0]
} else {
ready = false
}
}
if (ready) {
log.silly('PuppetPuppeteerFirer', 'fireRoomJoin() resolve() inviteeContactIdList: %s, inviterContactId: %s',
inviteeContactIdList.join(','),
inviterContactId,
)
/**
* Resolve All Payload again to make sure the data is ready.
*/
await Promise.all(
inviteeContactIdList.map(
id => this.puppet.contactPayload(id),
),
)
if (!inviterContactId) {
throw new Error('no inviterContactId')
}
await this.puppet.contactPayload(inviterContactId)
await this.puppet.roomPayload(roomId)
this.puppet.emit('room-join', roomId, inviteeContactIdList, inviterContactId)
return true
}
}
log.warn('PuppetPuppeteerFier', 'fireRoomJoin() resolve payload fail.')
return false
}
/**
* You removed "Bruce LEE" from the group chat
*/
public async checkRoomLeave(
rawPayload : WebMessageRawPayload,
): Promise<boolean> {
log.verbose('PuppetPuppeteerFirer', 'fireRoomLeave(%s)', rawPayload.Content)
const roomId = rawPayload.FromUserName
let leaverName : string
let removerName : string
try {
[leaverName, removerName] = this.parseRoomLeave(rawPayload.Content)
} catch (e) {
log.silly('PuppetPuppeteerFirer', 'fireRoomLeave() %s', e.message)
return false
}
log.silly('PuppetPuppeteerFirer', 'fireRoomLeave() got leaverName: %s', leaverName)
/**
* FIXME: leaver maybe is a list
* @lijiarui: I have checked, leaver will never be a list. If the bot remove 2 leavers at the same time, it will be 2 sys message, instead of 1 sys message contains 2 leavers.
*/
let leaverContactId : undefined | string
let removerContactId : undefined | string
if (/^(You|你)$/i.test(leaverName)) {
leaverContactId = this.puppet.selfId()
} else if (/^(You|你)$/i.test(removerName)) {
removerContactId = this.puppet.selfId()
}
if (!leaverContactId) {
const idList = await this.puppet.roomMemberSearch(roomId, leaverName)
leaverContactId = idList[0]
}
if (!removerContactId) {
const idList = await this.puppet.roomMemberSearch(roomId, removerName)
removerContactId = idList[0]
}
if (!leaverContactId || !removerContactId) {
throw new Error('no id')
}
/**
* FIXME: leaver maybe is a list
* @lijiarui 2017: I have checked, leaver will never be a list. If the bot remove 2 leavers at the same time,
* it will be 2 sys message, instead of 1 sys message contains 2 leavers.
* @huan 2018 May: we need to generilize the pattern for future usage.
*/
this.puppet.emit('room-leave', roomId , [leaverContactId], removerContactId)
setTimeout(async _ => {
await this.puppet.roomPayloadDirty(roomId)
await this.puppet.roomPayload(roomId)
}, 10 * 1000) // reload the room data, especially for memberList
return true
}
public async checkRoomTopic(
rawPayload : WebMessageRawPayload,
): Promise<boolean> {
let topic : string
let changer : string
try {
[topic, changer] = this.parseRoomTopic(rawPayload.Content)
} catch (e) { // not found
return false
}
const roomId = rawPayload.ToUserName
const roomPayload = await this.puppet.roomPayload(roomId)
const oldTopic = roomPayload.topic
let changerContactId: undefined | string
if (/^(You|你)$/.test(changer)) {
changerContactId = this.puppet.selfId()
} else {
changerContactId = (await this.puppet.roomMemberSearch(roomId, changer))[0]
}
if (!changerContactId) {
log.error('PuppetPuppeteerFirer', 'fireRoomTopic() changer contact not found for %s', changer)
return false
}
try {
this.puppet.emit('room-topic', roomId , topic, oldTopic, changerContactId)
return true
} catch (e) {
log.error('PuppetPuppeteerFirer', 'fireRoomTopic() co exception: %s', e.stack)
return false
}
}
/**
* try to find FriendRequest Confirmation Message
*/
private parseFriendConfirm(
content: string,
): boolean {
const reList = REGEX_CONFIG.friendConfirm
let found = false
reList.some(re => !!(found = re.test(content)))
if (found) {
return true
} else {
return false
}
}
/**
* try to find 'join' event for Room
*
* 1.
* You invited 管理员 to the group chat.
* You invited 李卓桓.PreAngel、Bruce LEE to the group chat.
* 2.
* 管理员 invited 小桔建群助手 to the group chat
* 管理员 invited 庆次、小桔妹 to the group chat
*/
private parseRoomJoin(
content: string,
): [string[], string] {
log.verbose('PuppetPuppeteerFirer', 'parseRoomJoin(%s)', content)
const reListInvite = REGEX_CONFIG.roomJoinInvite
const reListQrcode = REGEX_CONFIG.roomJoinQrcode
let foundInvite: string[]|null = []
reListInvite.some(re => !!(foundInvite = content.match(re)))
let foundQrcode: string[]|null = []
reListQrcode.some(re => !!(foundQrcode = content.match(re)))
if ((!foundInvite || !foundInvite.length) && (!foundQrcode || !foundQrcode.length)) {
throw new Error('parseRoomJoin() not found matched re of ' + content)
}
/**
* 管理员 invited 庆次、小桔妹 to the group chat
* "管理员"通过扫描你分享的二维码加入群聊
*/
const [inviter, inviteeStr] = foundInvite ? [ foundInvite[1], foundInvite[2] ] : [ foundQrcode[2], foundQrcode[1] ]
const inviteeList = inviteeStr.split(/、/)
return [inviteeList, inviter] // put invitee at first place
}
private parseRoomLeave(
content: string,
): [string, string] {
let matchIKickOther: null | string[] = []
REGEX_CONFIG.roomLeaveIKickOther.some(
regex => !!(
matchIKickOther = content.match(regex)
),
)
let matchOtherKickMe: null | string[] = []
REGEX_CONFIG.roomLeaveOtherKickMe.some(
re => !!(
matchOtherKickMe = content.match(re)
),
)
let leaverName : undefined | string
let removerName : undefined | string
if (matchIKickOther && matchIKickOther.length) {
leaverName = matchIKickOther[2]
removerName = matchIKickOther[1]
} else if (matchOtherKickMe && matchOtherKickMe.length) {
leaverName = matchOtherKickMe[1]
removerName = matchOtherKickMe[2]
} else {
throw new Error('no match')
}
return [leaverName, removerName]
}
private parseRoomTopic(
content: string,
): [string, string] {
const reList = REGEX_CONFIG.roomTopic
let found: string[]|null = []
reList.some(re => !!(found = content.match(re)))
if (!found || !found.length) {
throw new Error('checkRoomTopic() not found')
}
const [, changer, topic] = found
return [topic, changer]
}
}
export default Firer
#!/usr/bin/env ts-node
/**
* 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.
*
*/
// tslint:disable:no-shadowed-variable
import test from 'blue-tape'
// import sinon from 'sinon'
import {
PuppetPuppeteer,
} from './'
test('PuppetPuppeteer Module Exports', async t => {
t.ok(PuppetPuppeteer , 'should export PuppetPuppeteer')
})
/**
* 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 { PuppetPuppeteer } from './puppet-puppeteer'
export {
PuppetPuppeteer,
}
export default PuppetPuppeteer
#!/usr/bin/env ts-node
/**
* 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.
*
*/
// tslint:disable:no-shadowed-variable
import test from 'blue-tape'
import sinon from 'sinon'
const sinonTest = require('sinon-test')(sinon, {
useFakeTimers: { // https://github.com/sinonjs/lolex
shouldAdvanceTime : true,
advanceTimeDelta : 10,
},
})
// import { log } from '../../src/config'
// log.level('silly')
import { MemoryCard } from 'memory-card'
import { Wechaty } from '../wechaty'
import { PuppetPuppeteer } from './puppet-puppeteer'
import { Bridge } from './bridge'
import { Event } from './event'
class WechatyTest extends Wechaty {
public initPuppetAccessory(puppet: PuppetPuppeteer) {
super.initPuppetAccessory(puppet)
}
public initPuppetEventBridge(puppet: PuppetPuppeteer) {
super.initPuppetEventBridge(puppet)
}
}
class PuppetTest extends PuppetPuppeteer {
public contactRawPayload(id: string) {
return super.contactRawPayload(id)
}
public roomRawPayload(id: string) {
return super.roomRawPayload(id)
}
public messageRawPayload(id: string) {
return super.messageRawPayload(id)
}
}
// test('Puppet smoke testing', async t => {
// const puppet = new PuppetTest({ memory: new MemoryCard() })
// const wechaty = new WechatyTest({ puppet })
// wechaty.initPuppetAccessory(puppet)
// t.ok(puppet.state.off(), 'should be OFF state after instanciate')
// puppet.state.on('pending')
// t.ok(puppet.state.on(), 'should be ON state after set')
// t.ok(puppet.state.pending(), 'should be pending state after set')
// })
test('login/logout events', sinonTest(async function (t: test.Test) {
const sandbox = sinon.createSandbox()
try {
const puppet = new PuppetTest({ memory: new MemoryCard() })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
wechaty.initPuppetEventBridge(puppet)
sandbox.stub(Event, 'onScan') // block the scan event to prevent reset logined user
sandbox.stub(Bridge.prototype, 'getUserName').resolves('mockedUserName')
sandbox.stub(Bridge.prototype, 'contactList')
.onFirstCall().resolves([])
.onSecondCall().resolves([1])
.resolves([1, 2])
sandbox.stub(puppet, 'contactRawPayload').resolves({
NickName: 'mockedNickName',
UserName: 'mockedUserName',
})
// sandbox.stub(puppet, 'waitStable').resolves()
await puppet.start()
t.pass('should be inited')
t.is(puppet.logonoff() , false , 'should be not logined')
const future = new Promise(r => wechaty.once('login', r))
.catch(e => t.fail(e))
puppet.bridge.emit('login', 'TestPuppetPuppeteer')
await future
t.is(puppet.logonoff(), true, 'should be logined')
t.ok((puppet.bridge.getUserName as any).called, 'bridge.getUserName should be called')
t.ok((puppet.contactRawPayload as any).called, 'puppet.contactRawPayload should be called')
t.ok((Bridge.prototype.contactList as any).called, 'contactList stub should be called')
t.is((Bridge.prototype.contactList as any).callCount, 4, 'should call stubContacList 4 times')
const logoutPromise = new Promise(resolve => puppet.once('logout', _ => resolve('logoutFired')))
puppet.bridge.emit('logout')
t.is(await logoutPromise, 'logoutFired', 'should fire logout event')
t.is(puppet.logonoff(), false, 'should be logouted')
await puppet.stop()
} catch (e) {
t.fail(e)
} finally {
sandbox.restore()
}
}))
/**
* 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 http from 'http'
import path from 'path'
import nodeUrl from 'url'
import bl from 'bl'
import mime from 'mime'
import request from 'request'
import md5 from 'md5'
import {
FileBox,
} from 'file-box'
import {
ThrottleQueue,
} from 'rx-queue'
import {
Watchdog,
WatchdogFood,
} from 'watchdog'
import {
Puppet,
PuppetOptions,
Receiver,
} from 'wechaty-puppet'
import {
config,
log,
Raven,
qrCodeForChatie,
} from '../config'
import Misc from '../misc'
import {
Bridge,
Cookie,
} from './bridge'
import Event from './event'
import {
WebAppMsgType,
WebContactRawPayload,
WebMessageMediaPayload,
WebMessageRawPayload,
WebMediaType,
WebMessageType,
WebRecomendInfo,
// WebRoomRawMember,
WebRoomRawPayload,
WebRoomRawMember,
} from './web-schemas'
import {
ContactPayload,
// ContactQueryFilter,
ContactType,
FriendshipPayload,
FriendshipPayloadReceive,
FriendshipPayloadConfirm,
FriendshipType,
MessagePayload,
MessageType,
// RoomMemberQueryFilter,
RoomPayload,
RoomMemberPayload,
// RoomQueryFilter,
PuppetQrcodeScanEvent,
} from 'wechaty-puppet'
import {
messageRawPayloadParser,
messageFilename,
} from './pure-function-helpers'
export type PuppetFoodType = 'scan' | 'ding'
export type ScanFoodType = 'scan' | 'login' | 'logout'
export class PuppetPuppeteer extends Puppet {
public bridge : Bridge
public scanPayload? : PuppetQrcodeScanEvent
public scanWatchdog: Watchdog<ScanFoodType>
private fileId: number
constructor(
public options: PuppetOptions,
) {
super(options)
this.fileId = 0
this.bridge = new Bridge({
head : config.head,
profile : this.options.memory,
})
const SCAN_TIMEOUT = 2 * 60 * 1000 // 2 minutes
this.scanWatchdog = new Watchdog<ScanFoodType>(SCAN_TIMEOUT, 'Scan')
}
public async start(): Promise<void> {
log.verbose('PuppetPuppeteer', `start() with ${this.options.memory.name}`)
this.state.on('pending')
try {
this.initWatchdog()
this.initWatchdogForScan()
this.bridge = await this.initBridge()
log.verbose('PuppetPuppeteer', 'initBridge() done')
/**
* state must set to `live`
* before feed Watchdog
*/
this.state.on(true)
/**
* Feed the dog and start watch
*/
const food: WatchdogFood = {
data: 'inited',
timeout: 2 * 60 * 1000, // 2 mins for first login
}
this.emit('watchdog', food)
/**
* Save cookie for every 5 minutes
*/
const throttleQueue = new ThrottleQueue(5 * 60 * 1000)
this.on('watchdog', data => throttleQueue.next(data))
throttleQueue.subscribe(async data => {
log.verbose('Wechaty', 'start() throttleQueue.subscribe() new item: %s', data)
await this.saveCookie()
})
log.verbose('PuppetPuppeteer', 'start() done')
this.emit('start')
return
} catch (e) {
log.error('PuppetPuppeteer', 'start() exception: %s', e)
this.state.off(true)
this.emit('error', e)
await this.stop()
Raven.captureException(e)
throw e
}
}
private initWatchdog(): void {
log.verbose('PuppetPuppeteer', 'initWatchdogForPuppet()')
const puppet = this
// clean the dog because this could be re-inited
this.watchdog.removeAllListeners()
// fix issue #981
puppet.removeAllListeners('watchdog')
puppet.on('watchdog', food => this.watchdog.feed(food))
this.watchdog.on('feed', food => {
log.silly('PuppetPuppeteer', 'initWatchdogForPuppet() dog.on(feed, food={type=%s, data=%s})', food.type, food.data)
// feed the dog, heartbeat the puppet.
// 201805 puppet no need to `heartbeat`?
// puppet.emit('heartbeat', food.data)
})
this.watchdog.on('reset', async (food, timeout) => {
log.warn('PuppetPuppeteer', 'initWatchdogForPuppet() dog.on(reset) last food:%s, timeout:%s',
food.data, timeout)
try {
await this.stop()
await this.start()
} catch (e) {
puppet.emit('error', e)
}
})
}
/**
* 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
*/
private initWatchdogForScan(): void {
log.verbose('PuppetPuppeteer', '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', async (food, timePast) => {
log.warn('PuppetPuppeteer', 'initScanWatchdog() on(reset) lastFood: %s, timePast: %s',
food.data, timePast)
try {
await this.bridge.reload()
} catch (e) {
log.error('PuppetPuppeteer', 'initScanWatchdog() on(reset) exception: %s', e)
try {
log.error('PuppetPuppeteer', 'initScanWatchdog() on(reset) try to recover by bridge.{quit,init}()', e)
await this.bridge.quit()
await this.bridge.init()
log.error('PuppetPuppeteer', 'initScanWatchdog() on(reset) recover successful')
} catch (e) {
log.error('PuppetPuppeteer', 'initScanWatchdog() on(reset) recover FAIL: %s', e)
this.emit('error', e)
}
}
})
}
public async stop(): Promise<void> {
log.verbose('PuppetPuppeteer', 'quit()')
if (this.state.off()) {
log.warn('PuppetPuppeteer', 'quit() is called on a OFF puppet. await ready(off) and return.')
await this.state.ready('off')
return
}
this.state.off('pending')
log.verbose('PuppetPuppeteer', 'quit() make watchdog sleep before do quit')
/**
* Clean listeners for `watchdog`
*/
this.watchdog.sleep()
this.scanWatchdog.sleep()
this.watchdog.removeAllListeners()
this.scanWatchdog.removeAllListeners()
this.removeAllListeners('watchdog')
try {
await this.bridge.quit()
// register the removeListeners micro task at then end of the task queue
setImmediate(() => this.bridge.removeAllListeners())
} catch (e) {
log.error('PuppetPuppeteer', 'quit() exception: %s', e.message)
Raven.captureException(e)
throw e
} finally {
this.state.off(true)
this.emit('stop')
}
}
private async initBridge(): Promise<Bridge> {
log.verbose('PuppetPuppeteer', 'initBridge()')
if (this.state.off()) {
const e = new Error('initBridge() found targetState != live, no init anymore')
log.warn('PuppetPuppeteer', e.message)
throw e
}
this.bridge.on('dong' , data => this.emit('dong', data))
// this.bridge.on('ding' , Event.onDing.bind(this))
this.bridge.on('error' , e => this.emit('error', e))
this.bridge.on('log' , Event.onLog.bind(this))
this.bridge.on('login' , Event.onLogin.bind(this))
this.bridge.on('logout' , Event.onLogout.bind(this))
this.bridge.on('message' , Event.onMessage.bind(this))
this.bridge.on('scan' , Event.onScan.bind(this))
this.bridge.on('unload' , Event.onUnload.bind(this))
try {
await this.bridge.init()
} catch (e) {
log.error('PuppetPuppeteer', 'initBridge() exception: %s', e.message)
await this.bridge.quit().catch(console.error)
this.emit('error', e)
Raven.captureException(e)
throw e
}
return this.bridge
}
public async messageRawPayload(id: string): Promise <WebMessageRawPayload> {
const rawPayload = await this.bridge.getMessage(id)
return rawPayload
}
public async messageRawPayloadParser(
rawPayload: WebMessageRawPayload,
): Promise<MessagePayload> {
log.verbose('PuppetPuppeteer', 'messageRawPayloadParser(%s) @ %s', rawPayload, this)
const payload = messageRawPayloadParser(rawPayload)
return payload
}
public async messageFile(messageId: string): Promise<FileBox> {
const rawPayload = await this.messageRawPayload(messageId)
const fileBox = await this.messageRawPayloadToFile(rawPayload)
return fileBox
}
private async messageRawPayloadToFile(
rawPayload: WebMessageRawPayload,
): Promise<FileBox> {
let url = await this.messageRawPayloadToUrl(rawPayload)
if (!url) {
throw new Error('no url for type ' + MessageType[rawPayload.MsgType])
}
url = url.replace(/^https/i, 'http') // use http instead of https, because https will only success on the very first request!
const parsedUrl = nodeUrl.parse(url)
const msgFileName = messageFilename(rawPayload)
if (!msgFileName) {
throw new Error('no filename')
}
const cookies = await this.cookies()
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36',
// 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
Accept: '*/*',
Host: parsedUrl.hostname!, // 'wx.qq.com', // MsgType.VIDEO | IMAGE
// Referer: protocol + '//wx.qq.com/',
Referer: url,
// 'Upgrade-Insecure-Requests': 1, // MsgType.VIDEO | IMAGE
Range: 'bytes=0-',
// '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', // MsgType.IMAGE | VIDEO
// 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.6,en-US;q=0.4,en;q=0.2',
/**
* pgv_pvi=6639183872; pgv_si=s8359147520; webwx_data_ticket=gSeBbuhX+0kFdkXbgeQwr6Ck
*/
Cookie: cookies.map(c => `${c['name']}=${c['value']}`).join('; '),
}
const fileBox = FileBox.fromUrl(url, msgFileName, headers)
return fileBox
}
/**
* TODO: Test this function if it could work...
*/
// public async forward(baseData: MsgRawObj, patchData: MsgRawObj): Promise<boolean> {
public async messageForward(
receiver : Receiver,
messageId : string,
): Promise<void> {
log.silly('PuppetPuppeteer', 'forward(receiver=%s, messageId=%s)',
receiver,
messageId,
)
let rawPayload = await this.messageRawPayload(messageId)
// rawPayload = Object.assign({}, rawPayload)
const newMsg = <WebMessageRawPayload>{}
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.id || ''
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 = Misc.unescapeHtml(rawPayload.Content.replace(/^@\w+:<br\/>/, '')).replace(/^[\w\-]+:<br\/>/, '')
newMsg.MMIsChatRoom = receiver.roomId ? true : false
// 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 = receiver.contactId || receiver.roomId as string
// 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('PuppetPuppeteer', 'forward() exception: %s', e.message)
Raven.captureException(e)
throw e
}
}
public async messageSendText(
receiver : Receiver,
text : string,
): Promise<void> {
log.verbose('PuppetPuppeteer', 'messageSendText(receiver=%s, text=%s)', JSON.stringify(receiver), text)
let destinationId
if (receiver.roomId) {
destinationId = receiver.roomId
} else if (receiver.contactId) {
destinationId = receiver.contactId
} else {
throw new Error('PuppetPuppeteer.messageSendText(): message with neither room nor to?')
}
log.silly('PuppetPuppeteer', 'messageSendText() destination: %s, text: %s)',
destinationId,
text,
)
try {
await this.bridge.send(destinationId, text)
} catch (e) {
log.error('PuppetPuppeteer', 'messageSendText() exception: %s', e.message)
Raven.captureException(e)
throw e
}
}
public async login(userId: string): Promise<void> {
return super.login(userId)
}
/**
* logout from browser, then server will emit `logout` event
*/
public async logout(): Promise<void> {
log.verbose('PuppetPuppeteer', 'logout()')
const user = this.selfId()
if (!user) {
log.warn('PuppetPuppeteer', 'logout() without self()')
return
}
try {
await this.bridge.logout()
} catch (e) {
log.error('PuppetPuppeteer', 'logout() exception: %s', e.message)
Raven.captureException(e)
throw e
} finally {
this.id = undefined
this.emit('logout', user)
}
}
/**
*
* Contact
*
*/
public async contactRawPayload(id: string): Promise<WebContactRawPayload> {
log.silly('PuppetPuppeteer', 'contactRawPayload(%s) @ %s', id, this)
try {
const rawPayload = await this.bridge.getContact(id) as WebContactRawPayload
return rawPayload
} catch (e) {
log.error('PuppetPuppeteer', 'contactRawPayload(%s) exception: %s', id, e.message)
Raven.captureException(e)
throw e
}
}
public async contactRawPayloadParser(
rawPayload: WebContactRawPayload,
): Promise<ContactPayload> {
log.silly('PuppetPuppeteer', 'contactParseRawPayload(Object.keys(payload).length=%d)',
Object.keys(rawPayload).length,
)
if (!Object.keys(rawPayload).length) {
log.error('PuppetPuppeteer', 'contactParseRawPayload(Object.keys(payload).length=%d)',
Object.keys(rawPayload).length,
)
log.error('PuppetPuppeteer', 'contactParseRawPayload() got empty rawPayload!')
throw new Error('empty raw payload')
// return {
// gender: Gender.Unknown,
// type: Contact.Type.Unknown,
// }
}
// this.id = rawPayload.UserName // MMActualSender??? MMPeerUserName??? `getUserContact(message.MMActualSender,message.MMPeerUserName).HeadImgUrl`
// uin: rawPayload.Uin, // stable id: 4763975 || getCookie("wxuin")
return {
id: rawPayload.UserName,
weixin: rawPayload.Alias, // Wechat ID
name: Misc.plainText(rawPayload.NickName || ''),
alias: rawPayload.RemarkName,
gender: rawPayload.Sex,
province: rawPayload.Province,
city: rawPayload.City,
signature: rawPayload.Signature,
address: rawPayload.Alias, // XXX: need a stable address for user
star: !!rawPayload.StarFriend,
friend: rawPayload.stranger === undefined
? undefined
: !rawPayload.stranger, // assign by injectio.js
avatar: rawPayload.HeadImgUrl,
/**
* @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
*/
// tslint:disable-next-line
type: (!!rawPayload.UserName && !rawPayload.UserName.startsWith('@@') && !!(rawPayload.VerifyFlag & 8))
? ContactType.Official
: ContactType.Personal,
/**
* @see 1. https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3246
* @ignore
*/
// special: specialContactList.indexOf(rawPayload.UserName) > -1 || /@qqim$/.test(rawPayload.UserName),
}
}
public ding(data?: string): void {
log.verbose('PuppetPuppeteer', 'ding(%s)', data || '')
this.bridge.ding(data)
}
public async contactAvatar(contactId: string) : Promise<FileBox>
public async contactAvatar(contactId: string, file: FileBox) : Promise<void>
public async contactAvatar(contactId: string, file?: FileBox): Promise<void | FileBox> {
log.verbose('PuppetPuppeteer', '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 = `http://${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('; '),
}
// return Misc.urlStream(avatarUrl, cookies)
// const contact = this.Contact.load(contactId)
// await contact.ready()
const fileName = (payload.name || 'unknown') + '-avatar.jpg'
return FileBox.fromUrl(
avatarUrl,
fileName,
headers,
)
} catch (err) {
log.warn('PuppeteerContact', 'avatar() exception: %s', err.stack)
Raven.captureException(err)
throw err
}
}
public async contactQrcode(contactId: string): Promise<string> {
if (contactId !== this.selfId()) {
throw new Error('can not set avatar for others')
}
throw new Error('not supported')
// return await this.bridge.WXqr
}
public contactAlias(contactId: string) : Promise<string>
public contactAlias(contactId: string, alias: string | null): Promise<void>
public async contactAlias(
contactId : string,
alias? : string | null,
): Promise<string | void> {
if (typeof alias === 'undefined') {
throw new Error('to be implement')
}
try {
const ret = await this.bridge.contactAlias(contactId, alias)
if (!ret) {
log.warn('PuppetPuppeteer', 'contactRemark(%s, %s) bridge.contactAlias() return false',
contactId, alias,
)
throw new Error('bridge.contactAlias fail')
}
} catch (e) {
log.warn('PuppetPuppeteer', 'contactRemark(%s, %s) rejected: %s', contactId, alias, e.message)
Raven.captureException(e)
throw e
}
}
// private contactQueryFilterToFunctionString(
// query: ContactQueryFilter,
// ): string {
// log.verbose('PuppetPuppeteer', 'contactQueryFilterToFunctionString({ %s })',
// Object.keys(query)
// .map(k => `${k}: ${query[k as keyof ContactQueryFilter]}`)
// .join(', '),
// )
// if (Object.keys(query).length !== 1) {
// throw new Error('query only support one key. multi key support is not availble now.')
// }
// const filterKey = Object.keys(query)[0] as keyof ContactQueryFilter
// let filterValue: string | RegExp | undefined = query[filterKey]
// if (!filterValue) {
// throw new Error('filterValue not found')
// }
// const protocolKeyMap = {
// name: 'NickName',
// alias: 'RemarkName',
// }
// const protocolFilterKey = protocolKeyMap[filterKey]
// if (!protocolFilterKey) {
// throw new Error('unsupport protocol filter key')
// }
// /**
// * must be string because we need inject variable value
// * into code as variable namespecialContactList
// */
// let filterFunction: string
// if (filterValue instanceof RegExp) {
// filterFunction = `(function (c) { return ${filterValue.toString()}.test(c.${protocolFilterKey}) })`
// } else if (typeof filterValue === 'string') {
// filterValue = filterValue.replace(/'/g, '\\\'')
// filterFunction = `(function (c) { return c.${protocolFilterKey} === '${filterValue}' })`
// } else {
// throw new Error('unsupport name type')
// }
// return filterFunction
// }
public async contactList(): Promise<string[]> {
const idList = await this.bridge.contactList()
return idList
}
// public async contactFindAll(
// query: ContactQueryFilter = { name: /.*/ },
// ): Promise<string[]> {
// const filterFunc = this.contactQueryFilterToFunctionString(query)
// try {
// const idList = await this.bridge.contactFind(filterFunc)
// return idList
// } catch (e) {
// log.warn('PuppetPuppeteer', 'contactFind(%s) rejected: %s', filterFunc, e.message)
// Raven.captureException(e)
// throw e
// }
// }
/**
*
* Room
*
*/
public async roomRawPayload(id: string): Promise<WebRoomRawPayload> {
log.verbose('PuppetPuppeteer', 'roomRawPayload(%s)', id)
try {
let rawPayload: WebRoomRawPayload | undefined // = 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) as undefined | WebRoomRawPayload
if (rawPayload) {
const currLength = rawPayload.MemberList && rawPayload.MemberList.length || -1
log.silly('PuppetPuppeteer', `roomPayload() this.bridge.getContact(%s) MemberList.length:(prev:%d, curr:%d) at ttl:%d`,
id,
prevLength,
currLength,
ttl,
)
if (prevLength === currLength) {
log.silly('PuppetPuppeteer', `roomPayload() puppet.getContact(%s) done at ttl:%d with length:%d`, this.id, ttl, currLength)
return rawPayload
}
if (currLength >= prevLength) {
prevLength = currLength
} else {
log.warn('PuppetPuppeteer', 'roomRawPayload() currLength(%d) <= prevLength(%d) ???',
currLength,
prevLength,
)
}
}
log.silly('PuppetPuppeteer', `roomPayload() puppet.getContact(${id}) retry at ttl:%d`, ttl)
await new Promise(r => setTimeout(r, 1000)) // wait for 1 second
}
throw new Error('no payload')
} catch (e) {
log.error('PuppetPuppeteer', 'roomRawPayload(%s) exception: %s', id, e.message)
Raven.captureException(e)
throw e
}
}
public async roomRawPayloadParser(
rawPayload: WebRoomRawPayload,
): Promise<RoomPayload> {
log.verbose('PuppetPuppeteer', '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: RoomPayload = {
id,
topic: Misc.plainText(rawPayload.NickName || ''),
memberIdList,
// aliasDict,
// nameMap,
// roomAliasMap,
// contactAliasMap,
}
// console.log(roomPayload)
return roomPayload
}
public async roomList(): Promise<string[]> {
log.verbose('PuppetPupppeteer', 'roomList()')
const idList = await this.bridge.roomList()
return idList
}
public async roomDel(
roomId : string,
contactId : string,
): Promise<void> {
try {
await this.bridge.roomDelMember(roomId, contactId)
} catch (e) {
log.warn('PuppetPuppeteer', 'roomDelMember(%s, %d) rejected: %s', roomId, contactId, e.message)
Raven.captureException(e)
throw e
}
}
public async roomAvatar(roomId: string): Promise<FileBox> {
log.verbose('PuppetPuppeteer', 'roomAvatar(%s)', roomId)
const payload = await this.roomPayload(roomId)
if (payload.avatar) {
return FileBox.fromUrl(payload.avatar)
}
log.warn('PuppetPuppeteer', 'roomAvatar() avatar not found, use the chatie default.')
return qrCodeForChatie()
}
public async roomAdd(
roomId : string,
contactId : string,
): Promise<void> {
try {
await this.bridge.roomAddMember(roomId, contactId)
} catch (e) {
log.warn('PuppetPuppeteer', 'roomAddMember(%s) rejected: %s', contactId, e.message)
Raven.captureException(e)
throw e
}
}
public async roomTopic(roomId: string) : Promise<string>
public async roomTopic(roomId: string, topic: string) : Promise<void>
public async roomTopic(
roomId : string,
topic? : string,
): Promise<void | string> {
if (!topic) {
const payload = await this.roomPayload(roomId)
return payload.topic
}
try {
await this.bridge.roomModTopic(roomId, topic)
} catch (e) {
log.warn('PuppetPuppeteer', 'roomTopic(%s) rejected: %s', topic, e.message)
Raven.captureException(e)
throw e
}
}
public async roomCreate(
contactIdList : string[],
topic : string,
): Promise<string> {
try {
const roomId = await this.bridge.roomCreate(contactIdList, topic)
if (!roomId) {
throw new Error('PuppetPuppeteer.roomCreate() roomId "' + roomId + '" not found')
}
return roomId
} catch (e) {
log.warn('PuppetPuppeteer', 'roomCreate(%s, %s) rejected: %s', contactIdList.join(','), topic, e.message)
Raven.captureException(e)
throw e
}
}
public async roomAnnounce(roomId: string) : Promise<string>
public async roomAnnounce(roomId: string, text: string) : Promise<void>
public async roomAnnounce(roomId: string, text?: string) : Promise<void | string> {
log.warn('PuppetPuppeteer', 'roomAnnounce(%s, %s) not supported', roomId, text ? text : '')
if (text) {
return
}
return ''
}
public async roomQuit(roomId: string): Promise<void> {
log.warn('PuppetPuppeteer', 'roomQuit(%s) not supported by Web API', roomId)
}
public async roomQrcode(roomId: string): Promise<string> {
throw new Error('not support ' + roomId)
}
public async roomMemberList(roomId: string) : Promise<string[]> {
log.verbose('PuppetPuppeteer', 'roommemberList(%s)', roomId)
const rawPayload = await this.roomRawPayload(roomId)
const memberIdList = (rawPayload.MemberList || [])
.map(member => member.UserName)
return memberIdList
}
public async roomMemberRawPayload(roomId: string, contactId: string): Promise<WebRoomRawMember> {
log.verbose('PuppetPuppeteer', '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')
}
}
public async roomMemberRawPayloadParser(rawPayload: WebRoomRawMember): Promise<RoomMemberPayload> {
log.verbose('PuppetPuppeteer', 'roomMemberRawPayloadParser(%s)', rawPayload)
const payload: RoomMemberPayload = {
id : rawPayload.UserName,
roomAlias : rawPayload.DisplayName,
name : rawPayload.NickName,
avatar : rawPayload.HeadImgUrl,
}
return payload
}
/**
*
* Friendship
*
*/
public async friendshipRawPayload(id: string): Promise<WebMessageRawPayload> {
log.warn('PuppetPuppeteer', 'friendshipRawPayload(%s)', id)
const rawPayload = await this.bridge.getMessage(id)
if (!rawPayload) {
throw new Error('no rawPayload')
}
return rawPayload
}
public async friendshipRawPayloadParser(rawPayload: WebMessageRawPayload): Promise<FriendshipPayload> {
log.warn('PuppetPuppeteer', 'friendshipRawPayloadParser(%s)', rawPayload)
switch (rawPayload.MsgType) {
case WebMessageType.VERIFYMSG:
if (!rawPayload.RecommendInfo) {
throw new Error('no RecommendInfo')
}
const recommendInfo: WebRecomendInfo = rawPayload.RecommendInfo
if (!recommendInfo) {
throw new Error('no recommendInfo')
}
const payloadReceive: FriendshipPayloadReceive = {
id : rawPayload.MsgId,
contactId : recommendInfo.UserName,
hello : recommendInfo.Content,
ticket : recommendInfo.Ticket,
type : FriendshipType.Receive,
}
return payloadReceive
case WebMessageType.SYS:
const payloadConfirm: FriendshipPayloadConfirm = {
id : rawPayload.MsgId,
contactId : rawPayload.FromUserName,
type : FriendshipType.Confirm,
}
return payloadConfirm
default:
throw new Error('not supported friend request message raw payload')
}
}
public async friendshipAdd(
contactId : string,
hello : string,
): Promise<void> {
try {
await this.bridge.verifyUserRequest(contactId, hello)
} catch (e) {
log.warn('PuppetPuppeteer', 'friendshipAdd() bridge.verifyUserRequest(%s, %s) rejected: %s', contactId, hello, e.message)
Raven.captureException(e)
throw e
}
}
public async friendshipAccept(
friendshipId : string,
): Promise<void> {
const payload = await this.friendshipPayload(friendshipId) as FriendshipPayloadReceive
try {
await this.bridge.verifyUserOk(payload.contactId, payload.ticket)
} catch (e) {
log.warn('PuppetPuppeteer', 'bridge.verifyUserOk(%s, %s) rejected: %s', payload.contactId, payload.ticket, e.message)
Raven.captureException(e)
throw e
}
}
/**
* @private
* For issue #668
*/
public async waitStable(): Promise<void> {
log.verbose('PuppetPuppeteer', 'readyStable()')
let prevLength = -1
let ttl = 60
const sleepTime = 60 * 1000 / ttl
while (ttl-- > 0) {
const contactIdList = await this.contactList()
if (prevLength === contactIdList.length) {
log.verbose('PuppetPuppeteer', 'readyStable() stable() READY length=%d', prevLength)
return
}
prevLength = contactIdList.length
await new Promise(r => setTimeout(r, sleepTime))
}
log.warn('PuppetPuppeteer', 'readyStable() TTL expired. Final length=%d', prevLength)
}
/**
* https://www.chatie.io:8080/api
* location.hostname = www.chatie.io
* location.host = www.chatie.io:8080
* See: https://stackoverflow.com/a/11379802/1123955
*/
private async hostname(): Promise<string> {
try {
const name = await this.bridge.hostname()
if (!name) {
throw new Error('no hostname found')
}
return name
} catch (e) {
log.error('PuppetPuppeteer', 'hostname() exception:%s', e)
this.emit('error', e)
throw e
}
}
private async cookies(): Promise<Cookie[]> {
return await this.bridge.cookies()
}
public async saveCookie(): Promise<void> {
const cookieList = await this.bridge.cookies()
this.options.memory.set('cookies', cookieList)
this.options.memory.save()
}
private extToType(ext: string): WebMessageType {
switch (ext) {
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> {
private async messageRawPayloadToUrl(
rawPayload: WebMessageRawPayload,
): Promise<null | string> {
log.silly('PuppetPuppeteer', 'readyMedia()')
// let type = MessageType.Unknown
let url: undefined | string
try {
switch (rawPayload.MsgType) {
case WebMessageType.EMOTICON:
// type = MessageType.Emoticon
url = await this.bridge.getMsgEmoticon(rawPayload.MsgId)
break
case WebMessageType.IMAGE:
// type = MessageType.Image
url = await this.bridge.getMsgImg(rawPayload.MsgId)
break
case WebMessageType.VIDEO:
case WebMessageType.MICROVIDEO:
// type = MessageType.Video
url = await this.bridge.getMsgVideo(rawPayload.MsgId)
break
case WebMessageType.VOICE:
// type = MessageType.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 = MessageType.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 = MessageType.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 = MessageType.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: MessageType.Unknown,
// }
return null
}
} catch (e) {
log.warn('PuppetPuppeteer', 'ready() exception: %s', e.message)
Raven.captureException(e)
throw e
}
return url
}
private async uploadMedia(
file : FileBox,
toUserName : string,
): Promise<WebMessageMediaPayload> {
const filename = file.name
const ext = path.extname(filename) // message.ext()
// const contentType = Misc.mime(ext)
const contentType = mime.getType(ext)
// const contentType = message.mimeType()
if (!contentType) {
throw new Error('no MIME Type found on mediaMessage: ' + file.name)
}
let mediatype: WebMediaType
switch (ext) {
case '.bmp':
case '.jpeg':
case '.jpg':
case '.png':
case '.gif':
mediatype = WebMediaType.Image
break
case '.mp4':
mediatype = WebMediaType.Video
break
default:
mediatype = WebMediaType.Attachment
}
const buffer = await new Promise<Buffer>((resolve, reject) => {
file.pipe(new bl((err: Error, data: Buffer) => {
if (err) reject(err)
else resolve(data)
}))
})
// 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 (mediatype === WebMediaType.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.selfId()
const id = 'WU_FILE_' + this.fileId
this.fileId++
const hostname = await this.bridge.hostname()
const headers = {
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',
Cookie: cookie.map(c => c.name + '=' + c.value).join('; '),
}
log.silly('PuppetPuppeteer', 'uploadMedia() headers:%s', JSON.stringify(headers))
const uploadMediaRequest = {
BaseRequest: baseRequest,
FileMd5: fileMd5,
FromUserName: fromUserName,
ToUserName: toUserName,
UploadType: 2,
ClientMediaId: +new Date,
MediaType: WebMediaType.Attachment,
StartPos: 0,
DataLen: size,
TotalLen: size,
Signature: '',
AESKey: '',
}
const checkData = {
BaseRequest: baseRequest,
FromUserName: fromUserName,
ToUserName: toUserName,
FileName: filename,
FileSize: size,
FileMd5: fileMd5,
FileType: 7, // If do not have this parameter, the api will fail
}
const mediaData = {
ToUserName: toUserName,
MediaId: '',
FileName: filename,
FileSize: size,
FileMd5: fileMd5,
MMFileExt: ext,
} as WebMessageMediaPayload
// 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 = <any> await new Promise((resolve, reject) => {
const r = {
url: `https://${hostname}${checkUploadUrl}`,
headers,
json: checkData,
}
request.post(r, (err, _ /* res */, body) => {
try {
if (err) {
reject(err)
} else {
let obj = body
if (typeof body !== 'object') {
log.silly('PuppetPuppeteer', 'updateMedia() typeof body = %s', typeof body)
try {
obj = JSON.parse(body)
} catch (e) {
log.error('PuppetPuppeteer', 'updateMedia() body = %s', body)
log.error('PuppetPuppeteer', '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('PuppetPuppeteer', 'uploadMedia() checkUpload err:%s \nreq:%s\nret:%s', JSON.stringify(errMsg), JSON.stringify(r), body)
reject(new Error('chackUpload err:' + JSON.stringify(errMsg)))
}
resolve({
Signature : obj.Signature,
AESKey : obj.AESKey,
})
}
} catch (e) {
reject(e)
}
})
})
} catch (e) {
log.error('PuppetPuppeteer', 'uploadMedia() checkUpload exception: %s', e.message)
throw e
}
if (!ret.Signature) {
log.error('PuppetPuppeteer', '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('PuppetPuppeteer', 'uploadMedia() webwx_data_ticket: %s', webwxDataTicket)
log.verbose('PuppetPuppeteer', 'uploadMedia() pass_ticket: %s', passTicket)
const formData = {
id,
name: filename,
type: contentType,
lastModifiedDate: Date().toString(),
size,
mediatype,
uploadmediarequest: JSON.stringify(uploadMediaRequest),
webwx_data_ticket: webwxDataTicket,
pass_ticket: passTicket || '',
filename: {
value: buffer,
options: {
filename,
contentType,
size,
},
},
}
let mediaId: string
try {
mediaId = <string>await new Promise((resolve, reject) => {
try {
request.post({
url: uploadMediaUrl + '?f=json',
headers,
formData,
}, function (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('PuppetPuppeteer', 'uploadMedia() uploadMedia exception: %s', e.message)
throw new Error('uploadMedia err: ' + e.message)
}
if (!mediaId) {
log.error('PuppetPuppeteer', 'uploadMedia(): upload fail')
throw new Error('PuppetPuppeteer.uploadMedia(): upload fail')
}
return Object.assign(mediaData, { MediaId: mediaId })
}
public async messageSendFile(
receiver : Receiver,
file : FileBox,
): Promise<void> {
log.verbose('PuppetPuppeteer', 'messageSendFile(receiver=%s, file=%s)',
JSON.stringify(receiver),
file.toString(),
)
let destinationId
if (receiver.roomId) {
destinationId = receiver.roomId
} else if (receiver.contactId) {
destinationId = receiver.contactId
} else {
throw new Error('PuppetPuppeteer.messageSendFile(): message with neither room nor to?')
}
let mediaData: WebMessageMediaPayload
let rawPayload = {} as WebMessageRawPayload
if (!rawPayload || !rawPayload.MediaId) {
try {
mediaData = await this.uploadMedia(file, destinationId)
rawPayload = Object.assign(rawPayload, mediaData)
log.silly('PuppetPuppeteer', 'Upload completed, new rawObj:%s', JSON.stringify(rawPayload))
} catch (e) {
log.error('PuppetPuppeteer', 'sendMedia() exception: %s', e.message)
throw e
}
} else {
// To support forward file
log.silly('PuppetPuppeteer', 'skip upload file, rawObj:%s', JSON.stringify(rawPayload))
mediaData = {
ToUserName : destinationId,
MediaId : rawPayload.MediaId,
MsgType : rawPayload.MsgType,
FileName : rawPayload.FileName,
FileSize : rawPayload.FileSize,
MMFileExt : rawPayload.MMFileExt,
}
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.extToType(path.extname(file.name))
log.silly('PuppetPuppeteer', 'sendMedia() destination: %s, mediaId: %s, MsgType; %s)',
destinationId,
mediaData.MediaId,
mediaData.MsgType,
)
let ret = false
try {
ret = await this.bridge.sendMedia(mediaData)
} catch (e) {
log.error('PuppetPuppeteer', 'sendMedia() exception: %s', e.message)
Raven.captureException(e)
throw e
}
if (!ret) {
throw new Error('sendMedia fail')
}
}
public async messageSendContact(
receiver : Receiver,
contactId : string,
): Promise<void> {
log.verbose('PuppetPuppeteer', 'messageSend("%s", %s)', JSON.stringify(receiver), contactId)
throw new Error('not support')
}
private async getBaseRequest(): Promise<any> {
try {
const json = await this.bridge.getBaseRequest()
const obj = JSON.parse(json)
return obj.BaseRequest
} catch (e) {
log.error('PuppetPuppeteer', 'send() exception: %s', e.message)
Raven.captureException(e)
throw e
}
}
}
export default PuppetPuppeteer
#!/usr/bin/env ts-node
/**
* 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.
*
*/
// tslint:disable:no-shadowed-variable
import test from 'blue-tape'
import sinon from 'sinon'
import {
cloneClass,
} from 'clone-class'
import {
MemoryCard,
} from 'memory-card'
import {
log,
} from '../config'
// import Wechaty from '../wechaty'
import { Contact } from '../user'
import PuppetPuppeteer from './puppet-puppeteer'
test('Contact smoke testing', async t => {
/* tslint:disable:variable-name */
const UserName = '@0bb3e4dd746fdbd4a80546aef66f4085'
const NickName = 'NickNameTest'
const RemarkName = 'AliasTest'
const sandbox = sinon.createSandbox()
function mockContactPayload(id: string) {
log.verbose('PuppeteerContactTest', 'mockContactPayload(%s)', id)
return new Promise<any>(resolve => {
if (id !== UserName) return resolve({})
setImmediate(() => resolve({
UserName: UserName,
NickName: NickName,
RemarkName: RemarkName,
}))
})
}
const puppet = new PuppetPuppeteer({
memory: new MemoryCard(),
// wechaty: new Wechaty(),
})
sandbox.stub(puppet as any, 'contactRawPayload').callsFake(mockContactPayload)
// tslint:disable-next-line:variable-name
const MyContact = cloneClass(Contact)
MyContact.puppet = puppet
const c = new MyContact(UserName)
t.is(c.id, UserName, 'id/UserName right')
await c.ready()
t.is(c.name(), NickName, 'NickName set')
t.is(c.alias(), RemarkName, 'should get the right alias from Contact')
sandbox.restore()
// const contact1 = await Contact.find({name: 'NickNameTest'})
// t.is(contact1.id, UserName, 'should find contact by name')
// const contact2 = await Contact.find({alias: 'AliasTest'})
// t.is(contact2.id, UserName, 'should find contact by alias')
})
#!/usr/bin/env ts-node
/**
* 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.
*
*/
// tslint:disable:no-shadowed-variable
import test from 'blue-tape'
import sinon from 'sinon'
// const sinonTest = require('sinon-test')(sinon)
import {
MemoryCard,
} from 'memory-card'
import {
Contact,
} from '../user'
import {
Wechaty,
} from '../wechaty'
import {
FriendshipPayload,
FriendshipType,
} from 'wechaty-puppet'
import {
PuppetPuppeteer,
} from './puppet-puppeteer'
import {
WebMessageRawPayload,
} from './web-schemas'
class WechatyTest extends Wechaty {
public initPuppetAccessory(puppet: PuppetPuppeteer) {
super.initPuppetAccessory(puppet)
}
}
class PuppetTest extends PuppetPuppeteer {
public contactRawPayload(id: string) {
return super.contactRawPayload(id)
}
public roomRawPayload(id: string) {
return super.roomRawPayload(id)
}
public messageRawPayload(id: string) {
return super.messageRawPayload(id)
}
}
test('PuppetPuppeteerFriendship.receive smoke testing', async t => {
const puppet = new PuppetTest({ memory: new MemoryCard() })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
/* tslint:disable:max-line-length */
const rawMessagePayload: WebMessageRawPayload = JSON.parse(`
{"MsgId":"3225371967511173931","FromUserName":"fmessage","ToUserName":"@f7321198e0349f1b38c9f2ef158f70eb","MsgType":37,"Content":"&lt;msg fromusername=\\"wxid_a8d806dzznm822\\" encryptusername=\\"v1_c1e03a32c60dd9a9e14f1092132808a2de0ad363f79b303693654282954fbe4d3e12481166f4b841f28de3dd58b0bd54@stranger\\" fromnickname=\\"李卓桓.PreAngel\\" content=\\"我是群聊&amp;quot;Wechaty&amp;quot;的李卓桓.PreAngel\\" shortpy=\\"LZHPREANGEL\\" imagestatus=\\"3\\" scene=\\"14\\" country=\\"CN\\" province=\\"Beijing\\" city=\\"Haidian\\" sign=\\"投资人中最会飞的程序员。好友请加 918999 ,因为本号好友已满。\\" percard=\\"1\\" sex=\\"1\\" alias=\\"zixia008\\" weibo=\\"\\" weibonickname=\\"\\" albumflag=\\"0\\" albumstyle=\\"0\\" albumbgimgid=\\"911623988445184_911623988445184\\" snsflag=\\"49\\" snsbgimgid=\\"http://mmsns.qpic.cn/mmsns/zZSYtpeVianSQYekFNbuiajROicLficBzzeGuvQjnWdGDZ4budZovamibQnoKWba7D2LeuQRPffS8aeE/0\\" snsbgobjectid=\\"12183966160653848744\\" mhash=\\"\\" mfullhash=\\"\\" bigheadimgurl=\\"http://wx.qlogo.cn/mmhead/ver_1/xct7OPTbuU6iaS8gTaK2VibhRs3rATwnU1rCUwWu8ic89EGOynaic2Y4MUdKr66khhAplcfFlm7xbXhum5reania3fXDXH6CI9c3Bb4BODmYAh04/0\\" smallheadimgurl=\\"http://wx.qlogo.cn/mmhead/ver_1/xct7OPTbuU6iaS8gTaK2VibhRs3rATwnU1rCUwWu8ic89EGOynaic2Y4MUdKr66khhAplcfFlm7xbXhum5reania3fXDXH6CI9c3Bb4BODmYAh04/132\\" ticket=\\"v2_ba70dfbdb1b10168d61c1ab491be19e219db11ed5c28701f605efb4dccbf132f664d8a4c9ef6e852b2a4e8d8638be81d125c2e641f01903669539c53f1e582b2@stranger\\" opcode=\\"2\\" googlecontact=\\"\\" qrticket=\\"\\" chatroomusername=\\"2332413729@chatroom\\" sourceusername=\\"\\" sourcenickname=\\"\\"&gt;&lt;brandlist count=\\"0\\" ver=\\"670564024\\"&gt;&lt;/brandlist&gt;&lt;/msg&gt;","Status":3,"ImgStatus":1,"CreateTime":1475567560,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49","NickName":"李卓桓.PreAngel","Province":"北京","City":"海淀","Content":"我是群聊\\"Wechaty\\"的李卓桓.PreAngel","Signature":"投资人中最会飞的程序员。好友请加 918999 ,因为本号好友已满。","Alias":"zixia008","Scene":14,"AttrStatus":233251,"Sex":1,"Ticket":"v2_ba70dfbdb1b10168d61c1ab491be19e219db11ed5c28701f605efb4dccbf132f664d8a4c9ef6e852b2a4e8d8638be81d125c2e641f01903669539c53f1e582b2@stranger","OpCode":2,"HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49&skey=@crypt_f9cec94b_5b073dca472bd5e41771d309bb8c37bd&msgid=3225371967511173931","MMFromVerifyMsg":true},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3225371967511174000,"MMPeerUserName":"fmessage","MMDigest":"李卓桓.PreAngel想要将你加为朋友","MMIsSend":false,"MMIsChatRoom":false,"MMUnread":true,"LocalID":"3225371967511173931","ClientMsgId":"3225371967511173931","MMActualContent":"&lt;msg fromusername=\\"wxid_a8d806dzznm822\\" encryptusername=\\"v1_c1e03a32c60dd9a9e14f1092132808a2de0ad363f79b303693654282954fbe4d3e12481166f4b841f28de3dd58b0bd54@stranger\\" fromnickname=\\"李卓桓.PreAngel\\" content=\\"我是群聊&amp;quot;Wechaty&amp;quot;的李卓桓.PreAngel\\" shortpy=\\"LZHPREANGEL\\" imagestatus=\\"3\\" scene=\\"14\\" country=\\"CN\\" province=\\"Beijing\\" city=\\"Haidian\\" sign=\\"投资人中最会飞的程序员。好友请加 918999 ,因为本号好友已满。\\" percard=\\"1\\" sex=\\"1\\" alias=\\"zixia008\\" weibo=\\"\\" weibonickname=\\"\\" albumflag=\\"0\\" albumstyle=\\"0\\" albumbgimgid=\\"911623988445184_911623988445184\\" snsflag=\\"49\\" snsbgimgid=\\"http://mmsns.qpic.cn/mmsns/zZSYtpeVianSQYekFNbuiajROicLficBzzeGuvQjnWdGDZ4budZovamibQnoKWba7D2LeuQRPffS8aeE/0\\" snsbgobjectid=\\"12183966160653848744\\" mhash=\\"\\" mfullhash=\\"\\" bigheadimgurl=\\"http://wx.qlogo.cn/mmhead/ver_1/xct7OPTbuU6iaS8gTaK2VibhRs3rATwnU1rCUwWu8ic89EGOynaic2Y4MUdKr66khhAplcfFlm7xbXhum5reania3fXDXH6CI9c3Bb4BODmYAh04/0\\" smallheadimgurl=\\"http://wx.qlogo.cn/mmhead/ver_1/xct7OPTbuU6iaS8gTaK2VibhRs3rATwnU1rCUwWu8ic89EGOynaic2Y4MUdKr66khhAplcfFlm7xbXhum5reania3fXDXH6CI9c3Bb4BODmYAh04/132\\" ticket=\\"v2_ba70dfbdb1b10168d61c1ab491be19e219db11ed5c28701f605efb4dccbf132f664d8a4c9ef6e852b2a4e8d8638be81d125c2e641f01903669539c53f1e582b2@stranger\\" opcode=\\"2\\" googlecontact=\\"\\" qrticket=\\"\\" chatroomusername=\\"2332413729@chatroom\\" sourceusername=\\"\\" sourcenickname=\\"\\"&gt;&lt;brandlist count=\\"0\\" ver=\\"670564024\\"&gt;&lt;/brandlist&gt;&lt;/msg&gt;","MMActualSender":"fmessage","MMDigestTime":"15:52","MMDisplayTime":1475567560,"MMTime":"15:52"}
`)
const info = rawMessagePayload.RecommendInfo!
const contact = wechaty.Contact.load(info.UserName)
const hello = info.Content
const ticket = info.Ticket
const id = 'id'
const type = FriendshipType.Receive
const payload: FriendshipPayload = {
id,
type,
contactId: contact.id,
hello,
ticket,
}
const sandbox = sinon.createSandbox()
sandbox.stub(puppet, 'friendshipPayload').resolves(payload)
sandbox.stub(puppet, 'friendshipPayloadCache').returns(payload)
const fr = wechaty.Friendship.load(id)
await fr.ready()
t.is(fr.hello(), '我是群聊"Wechaty"的李卓桓.PreAngel', 'should has right request message')
t.true(fr.contact() instanceof Contact, 'should have a Contact instance')
t.is(fr.type(), wechaty.Friendship.Type.Receive, 'should be receive type')
sandbox.restore()
})
test('PuppetPuppeteerFriendship.confirm smoke testing', async t => {
const puppet = new PuppetTest({ memory: new MemoryCard() })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
/* tslint:disable:max-line-length */
const rawMessagePayload: WebMessageRawPayload = JSON.parse(`
{"MsgId":"3382012679535022763","FromUserName":"@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49","ToUserName":"@f7321198e0349f1b38c9f2ef158f70eb","MsgType":10000,"Content":"You have added 李卓桓.PreAngel as your WeChat contact. Start chatting!","Status":4,"ImgStatus":1,"CreateTime":1475569920,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3382012679535022600,"MMPeerUserName":"@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49","MMDigest":"You have added 李卓桓.PreAngel as your WeChat contact. Start chatting!","MMIsSend":false,"MMIsChatRoom":false,"LocalID":"3382012679535022763","ClientMsgId":"3382012679535022763","MMActualContent":"You have added 李卓桓.PreAngel as your WeChat contact. Start chatting!","MMActualSender":"@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49","MMDigestTime":"16:32","MMDisplayTime":1475569920,"MMTime":"16:32"}
`)
const friendshipPayload: FriendshipPayload = {
id : 'id',
type : FriendshipType.Confirm,
contactId : 'xxx',
}
const sandbox = sinon.createSandbox()
sandbox.stub(puppet, 'messageRawPayload') .resolves(rawMessagePayload)
sandbox.stub(puppet, 'contactPayload') .resolves({})
sandbox.stub(puppet, 'contactPayloadCache') .returns({})
sandbox.stub(puppet, 'friendshipPayload') .resolves(friendshipPayload)
sandbox.stub(puppet, 'friendshipPayloadCache') .returns(friendshipPayload)
const msg = wechaty.Message.create(rawMessagePayload.MsgId)
await msg.ready()
t.true(/^You have added (.+) as your WeChat contact. Start chatting!$/.test(msg.text()), 'should match confirm message')
const fr = wechaty.Friendship.load('xx')
await fr.ready()
t.true(fr.contact() instanceof Contact, 'should have a Contact instance')
t.is(fr.type(), wechaty.Friendship.Type.Confirm, 'should be confirm type')
sandbox.restore()
})
#!/usr/bin/env ts-node
/**
* 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.
*
*/
// tslint:disable:no-shadowed-variable
import test from 'blue-tape'
import sinon from 'sinon'
// const sinonTest = require('sinon-test')(sinon)
import {
MemoryCard,
} from 'memory-card'
import {
// config,
log,
} from '../config'
import Wechaty from '../wechaty'
import {
MessagePayload,
} from 'wechaty-puppet'
import {
Message,
} from '../user'
import {
PuppetPuppeteer,
} from './puppet-puppeteer'
import {
WebMessageRawPayload,
WebRoomRawPayload,
} from './web-schemas'
class WechatyTest extends Wechaty {
public initPuppetAccessory(puppet: PuppetPuppeteer) {
super.initPuppetAccessory(puppet)
}
}
class PuppetTest extends PuppetPuppeteer {
public contactRawPayload(id: string) {
return super.contactRawPayload(id)
}
public roomRawPayload(id: string) {
return super.roomRawPayload(id)
}
public messageRawPayload(id: string) {
return super.messageRawPayload(id)
}
}
class PuppetPuppeteerTest extends PuppetPuppeteer {
public id?: string = undefined
}
test('constructor()', async t => {
const puppet = new PuppetTest({ memory: new MemoryCard() })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
const MOCK_USER_ID = 'TEST-USER-ID'
/* tslint:disable:max-line-length */
const rawPayload: WebMessageRawPayload = JSON.parse('{"MsgId":"179242112323992762","FromUserName":"@0bb3e4dd746fdbd4a80546aef66f4085","ToUserName":"@16d20edf23a3bf3bc71bb4140e91619f3ff33b4e33f7fcd25e65c1b02c7861ab","MsgType":1,"Content":"test123","Status":3,"ImgStatus":1,"CreateTime":1461652670,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":179242112323992770,"MMPeerUserName":"@0bb3e4dd746fdbd4a80546aef66f4085","MMDigest":"test123","MMIsSend":false,"MMIsChatRoom":false,"MMUnread":true,"LocalID":"179242112323992762","ClientMsgId":"179242112323992762","MMActualContent":"test123","MMActualSender":"@0bb3e4dd746fdbd4a80546aef66f4085","MMDigestTime":"14:37","MMDisplayTime":1461652670,"MMTime":"14:37"}')
const EXPECTED = {
id: '179242112323992762',
from: '@0bb3e4dd746fdbd4a80546aef66f4085',
}
const msg = wechaty.Message.create(rawPayload.MsgId)
const sandbox = sinon.createSandbox()
const mockMessagePayload = (_: string) => {
const payload: MessagePayload = {
id : 'id',
type : Message.Type.Text,
fromId : EXPECTED.from,
timestamp : Date.now(),
toId : 'toId',
}
return payload
}
sandbox.stub(puppet, 'contactPayload').returns({})
sandbox.stub(puppet, 'contactPayloadCache').returns({})
sandbox.stub(puppet, 'roomPayload').returns({})
sandbox.stub(puppet, 'roomPayloadCache').returns({})
sandbox.stub(puppet, 'messagePayload').callsFake(mockMessagePayload)
sandbox.stub(puppet, 'messagePayloadCache').callsFake(mockMessagePayload)
await puppet.login(MOCK_USER_ID)
await msg.ready()
t.is(msg.id , EXPECTED.id , 'id right')
t.is(msg.from()!.id , EXPECTED.from , 'from right')
const s = msg.toString()
t.is(typeof s, 'string', 'toString()')
sandbox.restore()
})
// Issue #445
// XXX have to use test.serial() because mockGetContact can not be parallel
test('ready()', async t => {
// must different with other rawData, because Contact class with load() will cache the result. or use Contact.resetPool()
/* tslint:disable:max-line-length */
const rawPayload: WebMessageRawPayload = JSON.parse('{"MsgId":"3009511950433684462","FromUserName":"@0748ee480711bf20af91c298a0d7dcc77c30a680c1004157386b81cf13474823","ToUserName":"@b58f91e0c5c9e841e290d862ddb63c14","MsgType":1,"Content":"哈哈","Status":3,"ImgStatus":1,"CreateTime":1462887888,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3009511950433684500,"MMPeerUserName":"@0748ee480711bf20af91c298a0d7dcc77c30a680c1004157386b81cf13474823","MMDigest":"哈哈","MMIsSend":false,"MMIsChatRoom":false,"MMUnread":false,"LocalID":"3009511950433684462","ClientMsgId":"3009511950433684462","MMActualContent":"哈哈","MMActualSender":"@0748ee480711bf20af91c298a0d7dcc77c30a680c1004157386b81cf13474823","MMDigestTime":"21:44","MMDisplayTime":1462887888,"MMTime":"21:44","_h":104,"_index":0,"_offsetTop":0,"$$hashKey":"098"}')
const expectedFromUserName = '@0748ee480711bf20af91c298a0d7dcc77c30a680c1004157386b81cf13474823'
const expectedToUserName = '@b58f91e0c5c9e841e290d862ddb63c14'
const expectedFromNickName = 'From Nick Name@Test'
const expectedToNickName = 'To Nick Name@Test'
const expectedMsgId = '3009511950433684462'
// Mock
function mockContactRawPayload(id: string) {
log.silly('TestMessage', `mocked getContact(%s)`, id)
return new Promise(resolve => {
let obj = {}
switch (id) {
case expectedFromUserName:
obj = {
UserName: expectedFromUserName,
NickName: expectedFromNickName,
}
break
case expectedToUserName:
obj = {
UserName: expectedToUserName,
NickName: expectedToNickName,
}
break
default:
log.error('TestMessage', `mocked getContact(%s) unknown`, id)
t.fail('mocked getContact(${id}) unknown')
break
}
log.silly('TestMessage', 'setTimeout mocked getContact')
setTimeout(_ => {
log.silly('TestMessage', 'mocked getContact resolved')
return resolve(obj)
}, 100)
})
}
function mockMessageRawPayload(id: string) {
if (id === rawPayload.MsgId) {
return rawPayload
}
return {}
}
const sandbox = sinon.createSandbox()
const puppet = new PuppetTest({ memory: new MemoryCard })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
sandbox.stub(puppet, 'contactRawPayload').callsFake(mockContactRawPayload)
sandbox.stub(puppet, 'messageRawPayload').callsFake(mockMessageRawPayload)
const m = wechaty.Message.create(rawPayload.MsgId)
t.is(m.id, expectedMsgId, 'id/MsgId right')
await m.ready()
const fc = m.from()
const tc = m.to()
if (!fc || !tc) {
throw new Error('no fc or no tc')
}
t.is(fc.id , expectedFromUserName, 'contact ready for FromUserName')
t.is(fc.name() , expectedFromNickName, 'contact ready for FromNickName')
t.is(tc.id , expectedToUserName , 'contact ready for ToUserName')
t.is(tc.name() , expectedToNickName , 'contact ready for ToNickName')
sandbox.restore()
})
test('find()', async t => {
const puppet = new PuppetPuppeteer({ memory: new MemoryCard() })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
const sandbox = sinon.createSandbox()
sandbox.stub(puppet, 'contactPayload').resolves({})
sandbox.stub(puppet, 'contactPayloadCache').returns({})
const MOCK_USER_ID = 'TEST-USER-ID'
await puppet.login(MOCK_USER_ID)
const msg = await wechaty.Message.find({
id: 'xxx',
})
t.ok(msg, 'Message found')
t.ok(msg!.id, 'Message.id is ok')
sandbox.restore()
})
test('findAll()', async t => {
const puppet = new PuppetTest({ memory: new MemoryCard() })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
const sandbox = sinon.createSandbox()
sandbox.stub(puppet, 'contactPayload').resolves({})
sandbox.stub(puppet, 'contactPayloadCache').returns({})
const MOCK_USER_ID = 'TEST-USER-ID'
await puppet.login(MOCK_USER_ID)
const msgList = await wechaty.Message.findAll({
from: 'yyy',
})
t.is(msgList.length, 2, 'Message.findAll with limit 2')
sandbox.restore()
})
test('self()', async t => {
const puppet = new PuppetPuppeteer({ memory: new MemoryCard() })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
const MOCK_USER_ID = 'TEST-USER-ID'
const sandbox = sinon.createSandbox()
const MOCK_CONTACT = wechaty.Contact.load(MOCK_USER_ID)
function mockMessagePayload() {
const payload: MessagePayload = {
id : 'id',
fromId : MOCK_CONTACT.id,
toId : 'to_id',
type : wechaty.Message.Type.Text,
timestamp : Date.now(),
}
return payload
}
sandbox.stub(puppet, 'messagePayload').callsFake(mockMessagePayload)
sandbox.stub(puppet, 'messagePayloadCache').callsFake(mockMessagePayload)
sandbox.stub(puppet, 'messageRawPayload').resolves({})
sandbox.stub(puppet, 'roomPayload').resolves({})
sandbox.stub(puppet, 'roomPayloadCache').returns({})
sandbox.stub(puppet, 'contactPayload').resolves({})
sandbox.stub(puppet, 'contactPayloadCache').returns({})
const selfIdStub = sandbox.stub(puppet, 'selfId').returns(MOCK_CONTACT.id)
await puppet.login(MOCK_USER_ID)
const selfMsg = wechaty.Message.create('xxx')
await selfMsg.ready()
t.true(selfMsg.self(), 'should identify self message true where message from MOCK_CONTACT')
selfIdStub.restore()
sandbox.stub(puppet, 'selfId').returns('fadsfasdfasdfasfas')
const otherMsg = wechaty.Message.create('xxx')
await otherMsg.ready()
t.false(otherMsg.self(), 'should identify self message false when from a different fromId')
sandbox.restore()
})
test('mentioned()', async t => {
/* tslint:disable:max-line-length */
const rawPayload11: WebMessageRawPayload = JSON.parse(`{"MsgId":"6475340302153501409","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:<br/>@_@","Status":3,"ImgStatus":1,"CreateTime":1489823176,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":6475340302153502000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@_@","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"6475340302153501409","ClientMsgId":"6475340302153501409","MMActualContent":"@_@","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:46","MMDisplayTime":1489823176,"MMTime":"15:46"}`)
const rawPayload12: WebMessageRawPayload = JSON.parse(`{"MsgId":"3670467504370401673","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:<br/>user@email.com","Status":3,"ImgStatus":1,"CreateTime":1489823281,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3670467504370402000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:user@email.com","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"3670467504370401673","ClientMsgId":"3670467504370401673","MMActualContent":"<a target=\'_blank\' href=\'/cgi-bin/mmwebwx-bin/webwxcheckurl?requrl=http%3A%2F%2Fuser%40email.com&skey=%40crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7&deviceid=e982718209172853&pass_ticket=undefined&opcode=2&scene=1&username=@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2\'>user@email.com</a>","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:48","MMDisplayTime":1489823176,"MMTime":""}`)
const rawPayload13: WebMessageRawPayload = JSON.parse(`{"MsgId":"6796857876930585020","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:<br/>@_@ wow! my email is ruiruibupt@gmail.com","Status":3,"ImgStatus":1,"CreateTime":1489823387,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":6796857876930585000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@_@ wow! my email is ruiruibupt@gmail.com","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"6796857876930585020","ClientMsgId":"6796857876930585020","MMActualContent":"@_@ wow! my email is<a target=\'_blank\' href=\'/cgi-bin/mmwebwx-bin/webwxcheckurl?requrl=http%3A%2F%2Fruiruibupt%40gmail.com&skey=%40crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7&deviceid=e982718209172853&pass_ticket=undefined&opcode=2&scene=1&username=@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2\'> ruiruibupt@gmail.com</a>","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:49","MMDisplayTime":1489823387,"MMTime":"15:49"}`)
const rawPayload21: WebMessageRawPayload = JSON.parse(`{"MsgId":"2661793617819734017","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:<br/>@小桔同学 你好啊","Status":3,"ImgStatus":1,"CreateTime":1489823541,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":2661793617819734000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@小桔同学 你好啊","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"2661793617819734017","ClientMsgId":"2661793617819734017","MMActualContent":"@小桔同学 你好啊","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:52","MMDisplayTime":1489823387,"MMTime":""}`)
const rawPayload22: WebMessageRawPayload = JSON.parse(`{"MsgId":"5278536998175085820","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:<br/>@wuli舞哩客服 和@小桔同学 是好朋友","Status":3,"ImgStatus":1,"CreateTime":1489823598,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":5278536998175086000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@wuli舞哩客服 和@小桔同学 是好朋友","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"5278536998175085820","ClientMsgId":"5278536998175085820","MMActualContent":"@wuli舞哩客服 和@小桔同学 是好朋友","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:53","MMDisplayTime":1489823598,"MMTime":"15:53"}`)
const rawPayload31: WebMessageRawPayload = JSON.parse(`{"MsgId":"7410792097315403535","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:<br/>@wuli舞哩客服 我的邮箱是 ruiruibupt@gmail.com","Status":3,"ImgStatus":1,"CreateTime":1489823684,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":7410792097315404000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@wuli舞哩客服 我的邮箱是 ruiruibupt@gmail.com","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"7410792097315403535","ClientMsgId":"7410792097315403535","MMActualContent":"@wuli舞哩客服 我的邮箱是<a target=\'_blank\' href=\'/cgi-bin/mmwebwx-bin/webwxcheckurl?requrl=http%3A%2F%2Fruiruibupt%40gmail.com&skey=%40crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7&deviceid=e982718209172853&pass_ticket=undefined&opcode=2&scene=1&username=@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2\'> ruiruibupt@gmail.com</a>","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:54","MMDisplayTime":1489823598,"MMTime":""}`)
const rawPayload32: WebMessageRawPayload = JSON.parse(`{"MsgId":"3807714644369652210","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:<br/>@_@ wow,@wuli舞哩客服 和@小桔同学 看起来很有默契","Status":3,"ImgStatus":1,"CreateTime":1489823764,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3807714644369652000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@_@ wow,@wuli舞哩客服 和@小桔同学 看起来很有默契","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"3807714644369652210","ClientMsgId":"3807714644369652210","MMActualContent":"@_@ wow,@wuli舞哩客服 和@小桔同学 看起来很有默契","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:56","MMDisplayTime":1489823598,"MMTime":""}`)
const ROOM_RAW_PAYLOAD: WebRoomRawPayload = JSON.parse(`{"RemarkPYQuanPin":"22acb030ff0911e68a73cff62d9268c5","RemarkPYInitial":"22ACB030FF0911E68A73CFF62D9268C5","PYInitial":"","PYQuanPin":"","Uin":0,"UserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","NickName":"付费入群","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=670397504&username=@@e2355db381dc46a77c0b95516d05e7486135cb6370d8a6af66925d89d50ec278&skey=","ContactFlag":2,"MemberCount":146,"MemberList":[{"Alias":"ruirui_0914","AppAccountFlag":0,"AttrStatus":2147584103,"ChatRoomId":0,"City":"海淀","ContactFlag":3,"DisplayName":"","EncryChatRoomId":"","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=648035215&username=@4c32c97337cbb325442c304d6a44e374&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"IsOwner":0,"KeyWord":"qq5","MMOrderSymbol":"~","MemberCount":0,"MemberList":[],"NickName":"李佳芮","OwnerUin":0,"PYInitial":"LJR","PYQuanPin":"lijiarui","Province":"北京","RemarkName":"22acb030-ff09-11e6-8a73-cff62d9268c5","RemarkPYInitial":"22ACB030FF0911E68A73CFF62D9268C5","RemarkPYQuanPin":"22acb030ff0911e68a73cff62d9268c5","Sex":2,"Signature":"出洞计划 | 向前一步","SnsFlag":49,"StarFriend":0,"Statues":0,"Uin":0,"UniFriend":0,"UserName":"@4c32c97337cbb325442c304d6a44e374","VerifyFlag":0,"_h":50,"_index":16,"_offsetTop":696,"stranger":false},{"AppAccountFlag":0,"ContactFlag":0,"HeadImgFlag":1,"HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=665886775&username=@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"MMOrderSymbol":"~","NickName":"小桔同学","PYInitial":"","PYQuanPin":"","RemarkName":"","RemarkPYInitial":"","RemarkPYQuanPin":"","Sex":0,"Signature":"我是一个性感的机器人","SnsFlag":1,"StarFriend":0,"Uin":244009576,"UserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","VerifyFlag":0,"WebWxPluginSwitch":0,"stranger":false},{"$$hashKey":"01J","Alias":"dancewuli","AppAccountFlag":0,"AttrStatus":233509,"ChatRoomId":0,"City":"","ContactFlag":3,"DisplayName":"","EncryChatRoomId":"","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=635310858&username=@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"IsOwner":0,"KeyWord":"","MMOrderSymbol":"~","MemberCount":0,"MemberList":[],"NickName":"wuli舞哩客服","OwnerUin":0,"PYInitial":"WULIWLKF","PYQuanPin":"wuliwulikefu","Province":"","RemarkName":"50fb16c0-ff09-11e6-9fe5-dda97284d25b","RemarkPYInitial":"50FB16C0FF0911E69FE5DDA97284D25B","RemarkPYQuanPin":"50fb16c0ff0911e69fe5dda97284d25b","Sex":0,"Signature":"","SnsFlag":1,"StarFriend":0,"Statues":0,"Uin":0,"UniFriend":0,"UserName":"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855","VerifyFlag":0,"_h":50,"_index":10,"_offsetTop":396,"stranger":false}],"RemarkName":"","HideInputBarFlag":0,"Sex":0,"Signature":"","VerifyFlag":0,"OwnerUin":2351423900,"StarFriend":0,"AppAccountFlag":0,"Statues":0,"AttrStatus":0,"Province":"","City":"","Alias":"","SnsFlag":0,"UniFriend":0,"DisplayName":"","ChatRoomId":0,"KeyWord":"","MMFromBatchGet":true,"MMFromBatchget":true,"MMInChatroom":true}`)
const CONTACT_RAW_PAYLOAD_DICT = JSON.parse(`{"@4c32c97337cbb325442c304d6a44e374":{"Alias":"ruirui_0914","AppAccountFlag":0,"AttrStatus":2147584103,"ChatRoomId":0,"City":"海淀","ContactFlag":3,"DisplayName":"","EncryChatRoomId":"","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=648035215&username=@4c32c97337cbb325442c304d6a44e374&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"IsOwner":0,"KeyWord":"qq5","MMOrderSymbol":"~","MemberCount":0,"MemberList":[],"NickName":"李佳芮","OwnerUin":0,"PYInitial":"LJR","PYQuanPin":"lijiarui","Province":"北京","RemarkName":"22acb030-ff09-11e6-8a73-cff62d9268c5","RemarkPYInitial":"22ACB030FF0911E68A73CFF62D9268C5","RemarkPYQuanPin":"22acb030ff0911e68a73cff62d9268c5","Sex":2,"Signature":"出洞计划 | 向前一步","SnsFlag":49,"StarFriend":0,"Statues":0,"Uin":0,"UniFriend":0,"UserName":"@4c32c97337cbb325442c304d6a44e374","VerifyFlag":0,"_h":50,"_index":16,"_offsetTop":696,"stranger":false},"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2":{"AppAccountFlag":0,"ContactFlag":0,"HeadImgFlag":1,"HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=665886775&username=@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"MMOrderSymbol":"~","NickName":"小桔同学","PYInitial":"","PYQuanPin":"","RemarkName":"","RemarkPYInitial":"","RemarkPYQuanPin":"","Sex":0,"Signature":"我是一个性感的机器人","SnsFlag":1,"StarFriend":0,"Uin":244009576,"UserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","VerifyFlag":0,"WebWxPluginSwitch":0,"stranger":false},"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855":{"$$hashKey":"01J","Alias":"dancewuli","AppAccountFlag":0,"AttrStatus":233509,"ChatRoomId":0,"City":"","ContactFlag":3,"DisplayName":"","EncryChatRoomId":"","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=635310858&username=@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"IsOwner":0,"KeyWord":"","MMOrderSymbol":"~","MemberCount":0,"MemberList":[],"NickName":"wuli舞哩客服","OwnerUin":0,"PYInitial":"WULIWLKF","PYQuanPin":"wuliwulikefu","Province":"","RemarkName":"50fb16c0-ff09-11e6-9fe5-dda97284d25b","RemarkPYInitial":"50FB16C0FF0911E69FE5DDA97284D25B","RemarkPYQuanPin":"50fb16c0ff0911e69fe5dda97284d25b","Sex":0,"Signature":"","SnsFlag":1,"StarFriend":0,"Statues":0,"Uin":0,"UniFriend":0,"UserName":"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855","VerifyFlag":0,"_h":50,"_index":10,"_offsetTop":396,"stranger":false}}`)
const ROOM_ID = '@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4'
// Mock
const mockContactRawPayload = function (id: string) {
log.silly('PuppeteerMessageTest', 'mockContactRawPayload(%s)', id)
return new Promise((resolve, reject) => {
if (id in CONTACT_RAW_PAYLOAD_DICT) {
const contactRawPayload = CONTACT_RAW_PAYLOAD_DICT[id]
setImmediate(() => resolve(contactRawPayload))
} else {
reject()
}
})
}
const mockRoomRawPayload = function (id: string) {
log.silly('PuppeteerMessageTest', 'mockRoomRawPayload(%s)', id)
return new Promise((resolve, reject) => {
if (id === ROOM_ID) {
resolve(ROOM_RAW_PAYLOAD)
} else {
reject()
}
})
}
const mockMessageRawPayload = function (id: string) {
log.silly('PuppeteerMessageTest', 'mockMessageRawPayload(%s)', id)
return new Promise((resolve, reject) => {
switch (id) {
case rawPayload11.MsgId:
return resolve(rawPayload11)
case rawPayload12.MsgId:
return resolve(rawPayload12)
case rawPayload13.MsgId:
return resolve(rawPayload13)
case rawPayload21.MsgId:
return resolve(rawPayload21)
case rawPayload22.MsgId:
return resolve(rawPayload22)
case rawPayload31.MsgId:
return resolve(rawPayload31)
case rawPayload32.MsgId:
return resolve(rawPayload32)
default:
return reject()
}
})
}
const sandbox = sinon.createSandbox()
const puppet = new PuppetPuppeteerTest({ memory: new MemoryCard() })
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
sandbox.stub(puppet, 'contactRawPayload').callsFake(mockContactRawPayload)
sandbox.stub(puppet, 'roomRawPayload') .callsFake(mockRoomRawPayload)
sandbox.stub(puppet, 'messageRawPayload').callsFake(mockMessageRawPayload)
sandbox.stub(puppet, 'id').value('pretend-to-be-logined')
const msg11 = wechaty.Message.create(rawPayload11.MsgId)
await msg11.ready()
const room11 = msg11.room()
if (room11) {
await room11.ready()
const mentionContactList11 = await msg11.mentioned()
t.is(mentionContactList11.length, 0, '@_@ in message should not be treat as contact')
}
const msg12 = wechaty.Message.create(rawPayload12.MsgId)
await msg12.ready()
const room12 = msg12.room()
if (room12) {
await room12.ready()
const mentionContactList12 = await msg12.mentioned()
t.is(mentionContactList12.length, 0, 'user@email.com in message should not be treat as contact')
}
const msg13 = wechaty.Message.create(rawPayload13.MsgId)
await msg13.ready()
const room13 = msg13.room()
if (room13) {
await room13.ready()
// setTimeout(function () {
const mentionContactList13 = await msg13.mentioned()
t.is(mentionContactList13.length, 0, '@_@ wow! my email is ruiruibupt@gmail.com in message should not be treat as contact')
// }, 1 * 1000)
}
const msg21 = wechaty.Message.create(rawPayload21.MsgId)
await msg21.ready()
const room21 = msg21.room()
if (room21) {
await room21.ready()
const mentionContactList21 = await msg21.mentioned()
t.is(mentionContactList21.length, 1, '@小桔同学 is a contact')
t.is(mentionContactList21[0].id, '@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2', 'should get 小桔同学 id right in rawPayload21')
}
const msg22 = wechaty.Message.create(rawPayload22.MsgId)
await msg22.ready()
const room22 = msg22.room()
if (room22) {
await room22.ready()
const mentionContactList22 = await msg22.mentioned()
t.is(mentionContactList22.length, 2, '@小桔同学 and @wuli舞哩客服 is a contact')
// not sure the rela serial
t.is(mentionContactList22[0].id, '@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855', 'should get 小桔同学 id right in rawPayload22')
t.is(mentionContactList22[1].id, '@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2', 'should get wuli舞哩客服 id right in rawPayload22')
}
const msg31 = wechaty.Message.create(rawPayload31.MsgId)
await msg31.ready()
const room31 = msg31.room()
if (room31) {
await room31.ready()
const mentionContactList31 = await msg31.mentioned()
t.is(mentionContactList31.length, 1, '@wuli舞哩客服 is a contact')
t.is(mentionContactList31[0].id, '@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855', 'should get wuli舞哩客服 id right in rawPayload31')
}
const msg32 = wechaty.Message.create(rawPayload32.MsgId)
await msg32.ready()
const room32 = msg32.room()
if (room32) {
await room32.ready()
const mentionContactList32 = await msg32.mentioned()
t.is(mentionContactList32.length, 2, '@小桔同学 and @wuli舞哩客服 is a contact')
t.is(mentionContactList32[0].id, '@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855', 'should get wuli舞哩客服 id right in rawPayload32')
t.is(mentionContactList32[1].id, '@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2', 'should get 小桔同学 id right in rawPayload32')
}
sandbox.restore()
})
#!/usr/bin/env ts-node
/**
* 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.
*
*/
// tslint:disable:no-shadowed-variable
import test from 'blue-tape'
import sinon from 'sinon'
// import cloneClass from 'clone-class'
import {
MemoryCard,
} from 'memory-card'
import {
log,
} from '../config'
import {
Wechaty,
} from '../wechaty'
import {
PuppetPuppeteer,
} from './puppet-puppeteer'
import {
WebRoomRawPayload,
} from './web-schemas'
class WechatyTest extends Wechaty {
public initPuppetAccessory(puppet: PuppetPuppeteer) {
super.initPuppetAccessory(puppet)
}
}
class PuppetPuppeteerTest extends PuppetPuppeteer {
public id?: string = undefined
}
// tslint:disable:max-line-length
const ROOM_RAW_PAYLOAD: WebRoomRawPayload = JSON.parse(`{"RemarkPYQuanPin":"","RemarkPYInitial":"","PYInitial":"TZZGQNTSHGFJ","PYQuanPin":"tongzhizhongguoqingniantianshihuiguanfangjia","Uin":0,"UserName":"@@e2355db381dc46a77c0b95516d05e7486135cb6370d8a6af66925d89d50ec278","NickName":"(通知)中国青年天使会官方家","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=670397504&username=@@e2355db381dc46a77c0b95516d05e7486135cb6370d8a6af66925d89d50ec278&skey=","ContactFlag":2,"MemberCount":146,"MemberList":[{"Uin":0,"UserName":"@ecff4a7a86f23455dc42317269aa36ab","NickName":"童玮亮","AttrStatus":103423,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"dap","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@ecff4a7a86f23455dc42317269aa36ab&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@eac4377ecfd59e4321262f892177169f","NickName":"麦刚","AttrStatus":33674247,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"mai","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@eac4377ecfd59e4321262f892177169f&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@ad85207730aa94e006ddce28f74e6878","NickName":"田美坤Maggie","AttrStatus":112679,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"田美坤","KeyWord":"tia","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@ad85207730aa94e006ddce28f74e6878&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":2351423900,"UserName":"@33cc239d22b20d56395bbbd0967b28b9","NickName":"周宏光","AttrStatus":327869,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"周宏光","KeyWord":"acc","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@33cc239d22b20d56395bbbd0967b28b9&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@5e77381e1e3b5641ddcee44670b6e83a","NickName":"牛文文","AttrStatus":100349,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"niu","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@5e77381e1e3b5641ddcee44670b6e83a&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@56941ef97f3e9c70af88667fdd613b44","NickName":"羊东 东方红酒窖","AttrStatus":33675367,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"Yan","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@56941ef97f3e9c70af88667fdd613b44&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@72c4767ce32db488871fdd1c27173b81","NickName":"李竹~英诺天使(此号已满)","AttrStatus":235261,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"liz","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@72c4767ce32db488871fdd1c27173b81&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@0b0e2eb9501ab2d84f9f800f6a0b4216","NickName":"周静彤 杨宁助理","AttrStatus":230885,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"zlo","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@0b0e2eb9501ab2d84f9f800f6a0b4216&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@4bfa767be0cd3fb78409b9735d1dcc57","NickName":"周哲 Jeremy","AttrStatus":33791995,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"zho","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@4bfa767be0cd3fb78409b9735d1dcc57&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@ad954bf2159a572b7743a5bc134739f4","NickName":"vicky张","AttrStatus":100477,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"hua","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@ad954bf2159a572b7743a5bc134739f4&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"}],"RemarkName":"","HideInputBarFlag":0,"Sex":0,"Signature":"","VerifyFlag":0,"OwnerUin":2351423900,"StarFriend":0,"AppAccountFlag":0,"Statues":0,"AttrStatus":0,"Province":"","City":"","Alias":"","SnsFlag":0,"UniFriend":0,"DisplayName":"","ChatRoomId":0,"KeyWord":"","EncryChatRoomId":"@4b8baa99bdfc354443711412126d2aaf","MMFromBatchGet":true,"MMOrderSymbol":"TONGZHIZHONGGUOQINGNIANTIANSHIHUIGUANFANGJIA","MMFromBatchget":true,"MMInChatroom":true}`)
const CONTACT_RAW_PAYLOAD_DICT = JSON.parse(`{"@ad85207730aa94e006ddce28f74e6878":{ "UserName": "@ad85207730aa94e006ddce28f74e6878","NickName": "田美坤Maggie","RemarkName": "" },"@72c4767ce32db488871fdd1c27173b81":{ "UserName": "@72c4767ce32db488871fdd1c27173b81","NickName": "李竹~英诺天使(此号已满)","RemarkName": "" },"@ecff4a7a86f23455dc42317269aa36ab":{ "UserName": "@ecff4a7a86f23455dc42317269aa36ab","NickName": "童玮亮","RemarkName": "童玮亮备注" }}`)
const ROOM_EXPECTED = {
id: '@@e2355db381dc46a77c0b95516d05e7486135cb6370d8a6af66925d89d50ec278',
topic: '(通知)中国青年天使会官方家',
encryId: '@4b8baa99bdfc354443711412126d2aaf',
memberId1: '@ad85207730aa94e006ddce28f74e6878',
memberNick1: '田美坤',
memberId2: '@72c4767ce32db488871fdd1c27173b81',
memberNick2: '李竹~英诺天使(此号已满)',
memberId3: '@ecff4a7a86f23455dc42317269aa36ab',
memberNick3: '童玮亮备注',
ownerId: '@33cc239d22b20d56395bbbd0967b28b9',
}
test('Room smok testing', async t => {
// Mock
const mockContactRoomRawPayload = function (id: string) {
log.verbose('PuppeteerRoomTest', 'mockContactRawPayload(%s)', id)
return new Promise(resolve => {
if (id === ROOM_EXPECTED.id) {
setImmediate(() => resolve(ROOM_RAW_PAYLOAD))
} else if (id in CONTACT_RAW_PAYLOAD_DICT) {
setImmediate(() => resolve(CONTACT_RAW_PAYLOAD_DICT[id]))
} else {
// ignore other ids
setImmediate(() => resolve({id}))
}
})
}
const sandbox = sinon.createSandbox()
const puppet = new PuppetPuppeteerTest({
memory: new MemoryCard(),
})
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
sandbox.stub(puppet, 'contactRawPayload').callsFake(mockContactRoomRawPayload)
sandbox.stub(puppet, 'roomRawPayload').callsFake(mockContactRoomRawPayload)
sandbox.stub(puppet, 'id').value('pretend-to-be-logined')
const room = wechaty.Room.load(ROOM_EXPECTED.id)
await room.ready()
t.is(room.id, ROOM_EXPECTED.id, 'should set id/UserName right')
// t.is((r as any).payload[.('encryId') , EXPECTED.encryId, 'should set EncryChatRoomId')
t.is(await room.topic(), ROOM_EXPECTED.topic, 'should set topic/NickName')
const contact1 = new wechaty.Contact(ROOM_EXPECTED.memberId1)
const alias1 = await room.alias(contact1)
t.is(alias1, ROOM_EXPECTED.memberNick1, 'should get roomAlias')
// const name1 = r.alias(contact1)
// t.is(name1, EXPECTED.memberNick1, 'should get roomAlias')
const contact2 = wechaty.Contact.load(ROOM_EXPECTED.memberId2)
const alias2 = await room.alias(contact2)
t.is(alias2, null, 'should return null if not set roomAlias')
// const name2 = r.alias(contact2)
// t.is(name2, null, 'should return null if not set roomAlias')
t.equal(await room.has(contact1), true, 'should has contact1')
const noSuchContact = wechaty.Contact.load('not exist id')
t.equal(await room.has(noSuchContact), false, 'should has no this member')
const owner = room.owner()
t.true(owner === null || owner instanceof wechaty.Contact, 'should get Contact instance for owner, or null')
// wxApp hide uin for all contacts.
// t.is(r.owner().id, EXPECTED.ownerId, 'should get owner right by OwnerUin & Uin')
const contactA = await room.member(ROOM_EXPECTED.memberNick1)
if (!contactA) {
throw new Error(`member(${ROOM_EXPECTED.memberNick1}) should get member by roomAlias by default`)
}
const contactB = await room.member(ROOM_EXPECTED.memberNick2)
const contactC = await room.member(ROOM_EXPECTED.memberNick3)
const contactD = await room.member({roomAlias: ROOM_EXPECTED.memberNick1})
if (!contactB) {
throw new Error(`member(${ROOM_EXPECTED.memberNick2}) should get member by name by default`)
}
if (!contactC) {
throw new Error(`member(${ROOM_EXPECTED.memberNick3}) should get member by name by default`)
}
if (!contactD) {
throw new Error(`member({alias: ${ROOM_EXPECTED.memberNick3}}) should get member by roomAlias`)
}
t.is(contactA.id, ROOM_EXPECTED.memberId1, `should get the right id from ${ROOM_EXPECTED.memberId1}, find member by default`)
t.is(contactB.id, ROOM_EXPECTED.memberId2, `should get the right id from ${ROOM_EXPECTED.memberId2}, find member by default`)
t.is(contactC.id, ROOM_EXPECTED.memberId3, `should get the right id from ${ROOM_EXPECTED.memberId3}, find member by default`)
t.is(contactD.id, ROOM_EXPECTED.memberId1, `should get the right id from ${ROOM_EXPECTED.memberId1}, find member by roomAlias`)
const s = room.toString()
t.is(typeof s, 'string', 'toString()')
sandbox.restore()
})
// test('Room static method', async t => {
// const puppet = new PuppetPuppeteer({
// memory: new MemoryCard(),
// })
// const wechaty = new WechatyTest({ puppet })
// wechaty.initPuppetAccessory(puppet)
// try {
// const result = await wechaty.Room.find({ topic: 'xxx' })
// t.is(result, null, `should return null if cannot find the room`)
// } catch (e) {
// t.pass('should throw before login or not found')
// }
// const roomList = await wechaty.Room.findAll({
// topic: 'yyy',
// })
// t.is(roomList.length, 0, 'should return empty array before login')
// })
test('Room iterator for contact in it', async t => {
// Mock
const mockContactRoomRawPayload = function (id: string) {
log.verbose('PuppeteerRoomTest', 'mockContactRawPayload(%s)', id)
return new Promise(resolve => {
if (id === ROOM_EXPECTED.id) {
setImmediate(() => resolve(ROOM_RAW_PAYLOAD))
} else if (id in CONTACT_RAW_PAYLOAD_DICT) {
setImmediate(() => resolve(CONTACT_RAW_PAYLOAD_DICT[id]))
} else {
// ignore other ids
setImmediate(() => resolve({id}))
}
})
}
const sandbox = sinon.createSandbox()
const puppet = new PuppetPuppeteer({
memory: new MemoryCard(),
})
const wechaty = new WechatyTest({ puppet })
wechaty.initPuppetAccessory(puppet)
sandbox.stub(puppet, 'contactRawPayload').callsFake(mockContactRoomRawPayload)
sandbox.stub(puppet, 'roomRawPayload').callsFake(mockContactRoomRawPayload)
const room = wechaty.Room.load(ROOM_EXPECTED.id)
await room.ready()
const MEMBER_CONTACT_ID_LIST = ROOM_RAW_PAYLOAD.MemberList!.map(rawMember => rawMember.UserName)
let n = 0
for await (const memberContact of room) {
t.ok(MEMBER_CONTACT_ID_LIST.includes(memberContact.id), 'should get one of the room member: ' + memberContact.id)
n++
}
const memberList = await room.memberList()
t.equal(n, memberList.length, 'should iterate all the members of the room')
})
export * from './message-extname'
export * from './message-filename'
export * from './is-type'
export * from './message-raw-payload-parser'
export * from './web-message-type'
export function isRoomId(id: string): boolean {
return /^@@/.test(id)
}
export function isContactId(id: string): boolean {
return !isRoomId(id)
}
import {
WebMessageRawPayload,
WebMessageType,
WebAppMsgType,
} from '../web-schemas'
export function messageExtname(
rawPayload: WebMessageRawPayload,
): string {
let ext: string
// const type = this.type()
switch (rawPayload.MsgType) {
case WebMessageType.EMOTICON:
ext = '.gif'
break
case WebMessageType.IMAGE:
ext = '.jpg'
break
case WebMessageType.VIDEO:
case WebMessageType.MICROVIDEO:
ext = '.mp4'
break
case WebMessageType.VOICE:
ext = '.mp3'
break
case WebMessageType.APP:
switch (rawPayload.AppMsgType) {
case WebAppMsgType.URL:
ext = '.url' // XXX
break
default:
ext = '.' + rawPayload.MsgType
break
}
break
case WebMessageType.TEXT:
if (rawPayload.SubMsgType === WebMessageType.LOCATION) {
ext = '.jpg'
}
ext = '.' + rawPayload.MsgType
break
default:
ext = '.' + rawPayload.MsgType
}
return ext
}
import {
WebMessageRawPayload,
} from '../web-schemas'
import {
messageExtname,
} from './message-extname'
export function messageFilename(
rawPayload: WebMessageRawPayload,
): string {
let guessFilename = rawPayload.FileName || rawPayload.MediaId || rawPayload.MsgId
const re = /\.[a-z0-9]{1,7}$/i
if (!re.test(guessFilename)) {
if (rawPayload.MMAppMsgFileExt) {
guessFilename += '.' + rawPayload.MMAppMsgFileExt
} else {
guessFilename += messageExtname(rawPayload)
}
}
return guessFilename
}
import {
WebMessageRawPayload,
} from '../web-schemas'
import {
MessageType,
} from 'wechaty-puppet'
import {
isRoomId,
} from './is-type'
import {
messageFilename,
} from './message-filename'
import {
webMessageType,
} from './web-message-type'
import {
MessagePayload,
} from 'wechaty-puppet'
export function messageRawPayloadParser(
rawPayload: WebMessageRawPayload,
): MessagePayload {
const id = rawPayload.MsgId
const fromId = rawPayload.MMActualSender // MMPeerUserName
const text: string = rawPayload.MMActualContent // Content has @id prefix added by wx
const timestamp: number = rawPayload.MMDisplayTime // Javascript timestamp of milliseconds
const msgFileName: undefined | string = messageFilename(rawPayload) || undefined
let roomId : undefined | string
let toId : undefined | string
// FIXME: has there any better method to know the room ID?
if (rawPayload.MMIsChatRoom) {
if (isRoomId(rawPayload.FromUserName)) {
roomId = rawPayload.FromUserName // MMPeerUserName always eq FromUserName ?
} else if (isRoomId(rawPayload.ToUserName)) {
roomId = rawPayload.ToUserName
} else {
throw new Error('parse found a room message, but neither FromUserName nor ToUserName is a room(/^@@/)')
}
// console.log('rawPayload.FromUserName: ', rawPayload.FromUserName)
// console.log('rawPayload.ToUserName: ', rawPayload.ToUserName)
// console.log('rawPayload.MMPeerUserName: ', rawPayload.MMPeerUserName)
}
if (rawPayload.ToUserName) {
if (!isRoomId(rawPayload.ToUserName)) { // if a message in room without any specific receiver, then it will set to be `undefined`
toId = rawPayload.ToUserName
}
}
const type: MessageType = webMessageType(rawPayload.MsgType)
const payloadBase = {
id,
type,
fromId,
filename: msgFileName,
text,
timestamp,
}
let payload: MessagePayload
if (toId) {
payload = {
...payloadBase,
toId,
roomId,
}
} else if (roomId) {
payload = {
...payloadBase,
toId,
roomId,
}
} else {
throw new Error('neither roomId nor toId')
}
return payload
}
import {
WebMessageType,
} from '../web-schemas'
import {
MessageType,
} from 'wechaty-puppet'
export function webMessageType(webMsgType: WebMessageType): MessageType {
switch (webMsgType) {
case WebMessageType.TEXT:
return MessageType.Text
case WebMessageType.EMOTICON:
case WebMessageType.IMAGE:
return MessageType.Image
case WebMessageType.VOICE:
return MessageType.Audio
case WebMessageType.MICROVIDEO:
case WebMessageType.VIDEO:
return MessageType.Video
case WebMessageType.TEXT:
return MessageType.Text
/**
* Treat those Types as TEXT
*
* Friendship is a SYS message
* FIXME: should we use better message type at here???
*/
case WebMessageType.SYS:
case WebMessageType.APP:
return MessageType.Text
// VERIFYMSG = 37,
// POSSIBLEFRIEND_MSG = 40,
// SHARECARD = 42,
// LOCATION = 48,
// VOIPMSG = 50,
// STATUSNOTIFY = 51,
// VOIPNOTIFY = 52,
// VOIPINVITE = 53,
// SYSNOTICE = 9999,
// RECALLED = 10002,
default:
return MessageType.Text
}
}
/**
* 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.
*
*/
export interface WebContactRawPayload {
Alias: string,
City: string,
NickName: string,
Province: string,
RemarkName: string,
Sex: number,
Signature: string,
StarFriend: string,
Uin: string,
UserName: string,
HeadImgUrl: string,
stranger: string, // assign by injectio.js
VerifyFlag: number,
}
export interface WebMessageMediaPayload {
ToUserName: string,
MsgType: number,
MediaId: string,
FileName: string,
FileSize: number,
FileMd5?: string,
FileType?: number,
MMFileExt?: string,
Signature?: string,
}
export interface WebMessageRawPayload {
MsgId: string,
MMActualSender: string, // getUserContact(message.MMActualSender,message.MMPeerUserName).isContact()
MMPeerUserName: string, // message.MsgType == CONF.MSGTYPE_TEXT && message.MMPeerUserName == 'newsapp'
ToUserName: string,
FromUserName: string,
MMActualContent: string, // Content has @id prefix added by wx
Content: string,
MMDigest: string,
MMDisplayTime: number, // Javascript timestamp of milliseconds
CreateTime: number,
/**
* MsgType == MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_URL
* class="cover" mm-src="{{getMsgImg(message.MsgId,'slave')}}"
*/
Url: string,
MMAppMsgDesc: string, // class="desc" ng-bind="message.MMAppMsgDesc"
/**
* Attachment
*
* MsgType == MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_ATTACH
*/
FileName: string, // FileName: '钢甲互联项目BP1108.pdf',
FileSize: number, // FileSize: '2845701',
MediaId: string, // MediaId: '@crypt_b1a45e3f_c21dceb3ac01349...
MMFileExt: string, // doc, docx ... 'undefined'?
Signature: string, // checkUpload return the signature used to upload large files
MMAppMsgFileExt: string, // doc, docx ... 'undefined'?
MMAppMsgFileSize: string, // '2.7MB',
MMAppMsgDownloadUrl: string, // 'https://file.wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetmedia?sender=@4f549c2dafd5ad731afa4d857bf03c10&mediaid=@crypt_b1a45e3f
// <a download ng-if="message.MMFileStatus == CONF.MM_SEND_FILE_STATUS_SUCCESS
// && (massage.MMStatus == CONF.MSG_SEND_STATUS_SUCC || massage.MMStatus === undefined)
// " href="{{message.MMAppMsgDownloadUrl}}">下载</a>
MMUploadProgress: number, // < 100
/**
* 模板消息
* MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_READER_TYPE
* item.url
* item.title
* item.pub_time
* item.cover
* item.digest
*/
MMCategory: any[], // item in message.MMCategory
/**
* Type
*
* MsgType == CONF.MSGTYPE_VOICE : ng-style="{'width':40 + 7*message.VoiceLength/1000}
*/
MsgType: number,
AppMsgType: WebAppMsgType, // message.MsgType == CONF.MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_URL
// message.MsgType == CONF.MSGTYPE_TEXT && message.SubMsgType != CONF.MSGTYPE_LOCATION
SubMsgType: WebMessageType, // "msgType":"{{message.MsgType}}","subType":{{message.SubMsgType||0}},"msgId":"{{message.MsgId}}"
/**
* Status-es
*/
Status: string,
MMStatus: number, // img ng-show="message.MMStatus == 1" class="ico_loading"
// ng-click="resendMsg(message)" ng-show="message.MMStatus == 5" title="重新发送"
MMFileStatus: number, // <p class="loading" ng-show="message.MMStatus == 1 || message.MMFileStatus == CONF.MM_SEND_FILE_STATUS_FAIL">
// CONF.MM_SEND_FILE_STATUS_QUEUED, MM_SEND_FILE_STATUS_SENDING
/**
* Location
*/
MMLocationUrl: string, // ng-if="message.MsgType == CONF.MSGTYPE_TEXT && message.SubMsgType == CONF.MSGTYPE_LOCATION"
// <a href="{{message.MMLocationUrl}}" target="_blank">
// 'http://apis.map.qq.com/uri/v1/geocoder?coord=40.075041,116.338994'
MMLocationDesc: string, // MMLocationDesc: '北京市昌平区回龙观龙腾苑(五区)内(龙腾街南)',
/**
* MsgType == CONF.MSGTYPE_EMOTICON
*
* getMsgImg(message.MsgId,'big',message)
*/
/**
* Image
*
* getMsgImg(message.MsgId,'slave')
*/
MMImgStyle: string, // ng-style="message.MMImgStyle"
MMPreviewSrc: string, // message.MMPreviewSrc || message.MMThumbSrc || getMsgImg(message.MsgId,'slave')
MMThumbSrc: string,
/**
* Friend Request & ShareCard ?
*
* MsgType == CONF.MSGTYPE_SHARECARD" ng-click="showProfile($event,message.RecommendInfo.UserName)
* MsgType == CONF.MSGTYPE_VERIFYMSG
*/
RecommendInfo? : WebRecomendInfo,
/**
* Transpond Message
*/
MsgIdBeforeTranspond? : string, // oldMsg.MsgIdBeforeTranspond || oldMsg.MsgId,
isTranspond? : boolean,
MMSourceMsgId? : string,
MMSendContent? : string,
MMIsChatRoom? : boolean,
}
// export type MessageTypeName = 'TEXT' | 'IMAGE' | 'VOICE' | 'VERIFYMSG' | 'POSSIBLEFRIEND_MSG'
// | 'SHARECARD' | 'VIDEO' | 'EMOTICON' | 'LOCATION' | 'APP' | 'VOIPMSG' | 'STATUSNOTIFY'
// | 'VOIPNOTIFY' | 'VOIPINVITE' | 'MICROVIDEO' | 'SYSNOTICE' | 'SYS' | 'RECALLED'
// export type MessageTypeValue = 1 | 3 | 34 | 37 | 40 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 9999 | 10000 | 10002
// export interface WebMsgTypeDict {
// [index: string]: string|number,
// // MessageTypeName: MessageTypeValue
// // , MessageTypeValue: MessageTypeName
// }
/**
*
* Enum for AppMsgType values.
*
* @enum {number}
* @property {number} TEXT - AppMsgType.TEXT (1) for TEXT
* @property {number} IMG - AppMsgType.IMG (2) for IMG
* @property {number} AUDIO - AppMsgType.AUDIO (3) for AUDIO
* @property {number} VIDEO - AppMsgType.VIDEO (4) for VIDEO
* @property {number} URL - AppMsgType.URL (5) for URL
* @property {number} ATTACH - AppMsgType.ATTACH (6) for ATTACH
* @property {number} OPEN - AppMsgType.OPEN (7) for OPEN
* @property {number} EMOJI - AppMsgType.EMOJI (8) for EMOJI
* @property {number} VOICE_REMIND - AppMsgType.VOICE_REMIND (9) for VOICE_REMIND
* @property {number} SCAN_GOOD - AppMsgType.SCAN_GOOD (10) for SCAN_GOOD
* @property {number} GOOD - AppMsgType.GOOD (13) for GOOD
* @property {number} EMOTION - AppMsgType.EMOTION (15) for EMOTION
* @property {number} CARD_TICKET - AppMsgType.CARD_TICKET (16) for CARD_TICKET
* @property {number} REALTIME_SHARE_LOCATION - AppMsgType.REALTIME_SHARE_LOCATION (17) for REALTIME_SHARE_LOCATION
* @property {number} TRANSFERS - AppMsgType.TRANSFERS (2e3) for TRANSFERS
* @property {number} RED_ENVELOPES - AppMsgType.RED_ENVELOPES (2001) for RED_ENVELOPES
* @property {number} READER_TYPE - AppMsgType.READER_TYPE (100001) for READER_TYPE
*/
export enum WebAppMsgType {
TEXT = 1,
IMG = 2,
AUDIO = 3,
VIDEO = 4,
URL = 5,
ATTACH = 6,
OPEN = 7,
EMOJI = 8,
VOICE_REMIND = 9,
SCAN_GOOD = 10,
GOOD = 13,
EMOTION = 15,
CARD_TICKET = 16,
REALTIME_SHARE_LOCATION = 17,
TRANSFERS = 2e3,
RED_ENVELOPES = 2001,
READER_TYPE = 100001,
}
/**
*
* Enum for MsgType values.
* @enum {number}
* @property {number} TEXT - MsgType.TEXT (1) for TEXT
* @property {number} IMAGE - MsgType.IMAGE (3) for IMAGE
* @property {number} VOICE - MsgType.VOICE (34) for VOICE
* @property {number} VERIFYMSG - MsgType.VERIFYMSG (37) for VERIFYMSG
* @property {number} POSSIBLEFRIEND_MSG - MsgType.POSSIBLEFRIEND_MSG (40) for POSSIBLEFRIEND_MSG
* @property {number} SHARECARD - MsgType.SHARECARD (42) for SHARECARD
* @property {number} VIDEO - MsgType.VIDEO (43) for VIDEO
* @property {number} EMOTICON - MsgType.EMOTICON (47) for EMOTICON
* @property {number} LOCATION - MsgType.LOCATION (48) for LOCATION
* @property {number} APP - MsgType.APP (49) for APP
* @property {number} VOIPMSG - MsgType.VOIPMSG (50) for VOIPMSG
* @property {number} STATUSNOTIFY - MsgType.STATUSNOTIFY (51) for STATUSNOTIFY
* @property {number} VOIPNOTIFY - MsgType.VOIPNOTIFY (52) for VOIPNOTIFY
* @property {number} VOIPINVITE - MsgType.VOIPINVITE (53) for VOIPINVITE
* @property {number} MICROVIDEO - MsgType.MICROVIDEO (62) for MICROVIDEO
* @property {number} SYSNOTICE - MsgType.SYSNOTICE (9999) for SYSNOTICE
* @property {number} SYS - MsgType.SYS (10000) for SYS
* @property {number} RECALLED - MsgType.RECALLED (10002) for RECALLED
*/
export enum WebMessageType {
TEXT = 1,
IMAGE = 3,
VOICE = 34,
VERIFYMSG = 37,
POSSIBLEFRIEND_MSG = 40,
SHARECARD = 42,
VIDEO = 43,
EMOTICON = 47,
LOCATION = 48,
APP = 49,
VOIPMSG = 50,
STATUSNOTIFY = 51,
VOIPNOTIFY = 52,
VOIPINVITE = 53,
MICROVIDEO = 62,
SYSNOTICE = 9999,
SYS = 10000,
RECALLED = 10002,
}
/**
* from Message
*/
export interface WebRecomendInfo {
UserName : string,
NickName : string, // display_name
Content : string, // request message
HeadImgUrl : string, // message.RecommendInfo.HeadImgUrl
Ticket : string, // a pass token
VerifyFlag : number,
}
export const enum WebMediaType {
Image = 1,
Video = 2,
Audio = 3,
Attachment = 4,
}
export interface WebRoomRawMember {
UserName : string,
NickName : string,
DisplayName : string,
HeadImgUrl : string,
}
export interface WebRoomRawPayload {
UserName: string,
EncryChatRoomId: string,
NickName: string,
OwnerUin: number,
ChatRoomOwner: string,
MemberList?: WebRoomRawMember[],
}
/**
* 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.
*
*/
/**
* Wechaty - Wechat for Bot, and human who talk to bot.
*
* PuppetPuppeteer: WechatyBro
*
* Inject this js code to browser,
* in order to interactive with wechat web program.
*
* ATTENTION:
*
* JavaScript in this file will be ran inside:
*
* BROWSER
*
* instead of
*
* NODE.js
*
* read more about this in puppet-web-bridge.ts
*/
(function() {
function init() {
if (!angularIsReady()) {
retObj.code = 503 // 503 SERVICE UNAVAILABLE https://httpstatuses.com/503
retObj.message = 'init() without a ready angular env'
return retObj
}
if (WechatyBro.vars.initState === true) {
log('WechatyBro.init() called twice: already inited')
retObj.code = 304 // 304 Not Modified https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
retObj.message = 'init() already inited before. returned with do nothing'
return retObj
}
if (MMCgiLogined()) {
login('page refresh')
}
glueToAngular()
hookEvents()
hookRecalledMsgProcess()
log('init() scanCode: ' + WechatyBro.vars.scanCode)
setTimeout(() => checkScan(), 1000)
heartBeat(true)
log('inited!. ;-D')
WechatyBro.vars.initState = true
retObj.code = 200
retObj.message = 'WechatyBro Init Succ'
return retObj
}
function log(text) {
WechatyBro.emit('log', text)
}
/**
*
* Functions that Glued with AngularJS
*
*/
function MMCgiLogined() {
return !!(window.MMCgi && window.MMCgi.isLogin)
}
function angularIsReady() {
// don't log inside, because we has not yet init clog here.
return !!(
(typeof angular) !== 'undefined'
&& angular.element
&& angular.element('body')
&& angular.element(document).injector()
)
}
function heartBeat(firstTime) {
var TIMEOUT = 15000 // 15s
if (firstTime && WechatyBro.vars.heartBeatTimmer) {
log('heartBeat timer exist when 1st time is true? return for do nothing')
return
}
WechatyBro.emit('ding', 'heartbeat@browser')
WechatyBro.vars.heartBeatTimmer = setTimeout(heartBeat, TIMEOUT)
return TIMEOUT
}
function glueToAngular() {
var injector = angular.element(document).injector()
if (!injector) {
throw new Error('glueToAngular cant get injector(right now)')
}
var accountFactory = injector.get('accountFactory')
var appFactory = injector.get('appFactory')
var chatroomFactory = injector.get('chatroomFactory')
var chatFactory = injector.get('chatFactory')
var contactFactory = injector.get('contactFactory')
var confFactory = injector.get('confFactory')
var emojiFactory = injector.get('emojiFactory')
var loginFactory = injector.get('loginFactory')
var utilFactory = injector.get('utilFactory')
var http = injector.get('$http')
var state = injector.get('$state')
var mmHttp = injector.get('mmHttp')
var appScope = angular.element('[ng-controller="appController"]').scope()
var rootScope = injector.get('$rootScope')
var loginScope = angular.element('[ng-controller="loginController"]').scope()
/*
// method 1
appFactory.syncOrig = appFactory.sync
appFactory.syncCheckOrig = appFactory.syncCheck
appFactory.sync = function() { WechatyBro.log('appFactory.sync() !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); return appFactory.syncOrig(arguments) }
appFactory.syncCheck = function() { WechatyBro.log('appFactory.syncCheck() !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); return appFactory.syncCheckOrig(arguments) }
// method 2
$.ajaxOrig = $.ajax
$.ajax = function() { WechatyBro.log('$.ajax() !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); return $.ajaxOrig(arguments) }
$.ajax({
url: "https://wx.qq.com/zh_CN/htmledition/v2/images/webwxgeticon.jpg"
, type: "GET"
}).done(function (response) {
alert("success");
})
// method 3 - mmHttp
mmHttp.getOrig = mmHttp.get
mmHttp.get = function() { WechatyBro.log('mmHttp.get() !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); return mmHttp.getOrig(arguments) }
*/
/**
* generate $scope with a contoller (as it is not assigned in html staticly)
* https://github.com/angular/angular.js/blob/a4e60cb6970d8b6fa9e0af4b9f881ee3ba7fdc99/test/ng/controllerSpec.js#L24
*/
var contentChatScope = rootScope.$new()
injector.get('$controller')('contentChatController', {$scope: contentChatScope })
// get all we need from wx in browser(angularjs)
WechatyBro.glue = {
injector,
http,
mmHttp,
state,
accountFactory,
chatroomFactory,
chatFactory,
confFactory,
contactFactory,
emojiFactory,
loginFactory,
utilFactory,
rootScope,
appScope,
loginScope,
contentChatScope,
}
return true
}
function checkScan() {
// log('checkScan()')
if (loginState()) {
log('checkScan() - already login, no more check, and return(only)') //but I will emit a login event')
// login('checkScan found already login')
return
}
const loginScope = WechatyBro.glue.loginScope
if (!loginScope) {
log('checkScan() - loginScope disappeared, TODO: find out the reason why this happen')
// login('loginScope disappeared')
// return
return setTimeout(checkScan, 1000)
}
// loginScope.code:
// 0: 显示二维码
// 408: 未确认(显示二维码后30秒触发)
// 201: 扫描,未确认
// 200: 登录成功
var code = +loginScope.code
var url = loginScope.qrcodeUrl
log('checkScan() code:' + code + ' url:' + url + ' scanCode:' + WechatyBro.vars.scanCode)
if (url && code !== WechatyBro.vars.scanCode) {
log('checkScan() - code change detected: from '
+ WechatyBro.vars.scanCode
+ ' to '
+ code
)
WechatyBro.emit('scan', {
code : code,
url : url,
})
WechatyBro.vars.scanCode = code
}
if (code !== 200) {
return setTimeout(checkScan, 1000)
}
WechatyBro.vars.scanCode = null
loginScope.code = null
// wait a while because the account maybe blocked by tencent,
// then there will be a dialog should appear
setTimeout(() => login('scan code 200'), 1000)
return
}
function loginState(state) {
if (typeof state === 'undefined') {
return !!WechatyBro.vars.loginState
}
WechatyBro.vars.loginState = state
return
}
function login(data) {
log('login(' + data + ')')
loginState(true)
WechatyBro.emit('login', data)
}
function logout(data) {
log('logout(' + data + ')')
loginState(false)
// WechatyBro.emit('logout', data)
if (WechatyBro.glue.loginFactory) {
WechatyBro.glue.loginFactory.loginout(0)
} else {
log('logout() WechatyBro.glue.loginFactory NOT found')
}
setTimeout(() => checkScan(), 1000)
}
function ding(data) {
log('recv ding')
return data || 'dong'
}
function hookEvents() {
var rootScope = WechatyBro.glue.rootScope
var appScope = WechatyBro.glue.appScope
if (!rootScope || !appScope) {
log('hookEvents() no rootScope')
return false
}
rootScope.$on('message:add:success', function(event, data) {
if (!loginState()) { // in case of we missed the pageInit event
login('by event[message:add:success]')
}
WechatyBro.emit('message', data)
})
rootScope.$on('root:pageInit:success'), function (event, data) {
login('by event[root:pageInit:success]')
}
// newLoginPage seems not stand for a user login action
// appScope.$on("newLoginPage", function(event, data) {
// login('by event[newLoginPage]')
// })
window.addEventListener('unload', function(e) {
// XXX only 1 event can be emitted here???
WechatyBro.emit('unload', String(e))
log('event unload')
})
return true
}
function hookRecalledMsgProcess() {
var chatFactory = WechatyBro.glue.chatFactory
var utilFactory = WechatyBro.glue.utilFactory
var confFactory = WechatyBro.glue.confFactory
// hook chatFactory._recalledMsgProcess, resolve emit RECALLED type msg
oldRecalledMsgProcess = chatFactory._recalledMsgProcess
chatFactory._recalledMsgProcess = function(msg) {
oldRecalledMsgProcess(msg)
var m = Object.assign({}, msg)
var content = utilFactory.htmlDecode(m.MMActualContent)
content = utilFactory.encodeEmoji(content)
var revokemsg = utilFactory.xml2json(content).revokemsg
if (revokemsg.msgid) {
var chatMsgs = chatFactory.getChatMessage(m.MMPeerUserName)
var i = chatFactory._findMessageByMsgId(chatMsgs, revokemsg.msgid)
if (i > -1) {
m = chatMsgs[i]
m.MsgType = confFactory.MSGTYPE_RECALLED
} else {
m.MsgId = revokemsg.msgid
m.MMActualContent = m.Content = revokemsg.replacemsg.replace(/"/g,"")
}
WechatyBro.emit('message', m)
}
}
}
/**
*
* Help Functions which Proxy to WXAPP AngularJS Scope & Factory
* getMsgImg(message.MsgId,'slave')
* getMsgImg(message.MsgId,'big',message)
*/
function getMsgImg(id, type, message) {
var contentChatScope = WechatyBro.glue.contentChatScope
if (!contentChatScope) {
throw new Error('getMsgImg() contentChatScope not found')
}
var path = contentChatScope.getMsgImg(id, type, message)
return window.location.origin + path
// https://wx.qq.com/?&lang=en_US/cgi-bin/mmwebwx-bin/webwxgetmsgimg?&MsgID=4520385745174034093&skey=%40crypt_f9cec94b_a3aa5c868466d81bc518293eb292926e
// https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetmsgimg?&MsgID=8454987316459381112&skey=%40crypt_f9cec94b_bd210b2224f217afeab8d462af70cf53
}
function getMsgEmoticon(id) {
var chatFactory = WechatyBro.glue.chatFactory
var message = chatFactory.getMsg(id)
return message.MMPreviewSrc || getMsgImg(message.MsgId,'big',message) || message.MMThumbSrc
}
function getMsgVideo(id) {
var contentChatScope = WechatyBro.glue.contentChatScope
if (!contentChatScope) {
throw new Error('getMsgVideo() contentChatScope not found')
}
var path = contentChatScope.getMsgVideo(id)
return window.location.origin + path
}
/**
* from playVoice()
*/
function getMsgVoice(id) {
var confFactory = WechatyBro.glue.confFactory
var accountFactory = WechatyBro.glue.accountFactory
var path = confFactory.API_webwxgetvoice + "?msgid=" + id + "&skey=" + accountFactory.getSkey()
return window.location.origin + path
}
function getMsgPublicLinkImg(id) {
var path = '/cgi-bin/mmwebwx-bin/webwxgetpubliclinkimg?url=xxx&msgid=' + id + '&pictype=location'
return window.location.origin + path
}
function getBaseRequest() {
var accountFactory = WechatyBro.glue.accountFactory
var BaseRequest = accountFactory.getBaseRequest()
return JSON.stringify(BaseRequest)
}
function getPassticket() {
var accountFactory = WechatyBro.glue.accountFactory
return accountFactory.getPassticket()
}
function getCheckUploadUrl() {
var confFactory = WechatyBro.glue.confFactory
return confFactory.API_checkupload
}
function getUploadMediaUrl() {
var confFactory = WechatyBro.glue.confFactory
return confFactory.API_webwxuploadmedia
}
function sendMedia(data) {
var chatFactory = WechatyBro.glue.chatFactory
var confFactory = WechatyBro.glue.confFactory
if (!chatFactory || !confFactory) {
log('sendMedia() chatFactory or confFactory not exist.')
return false
}
try {
var d = {
ToUserName: data.ToUserName,
MediaId: data.MediaId,
MsgType: data.MsgType,
FileName: data.FileName,
FileSize: data.FileSize,
MMFileExt: data.MMFileExt,
}
if (data.Signature) {
d.Signature = data.Signature
}
var m = chatFactory.createMessage(d)
m.MMFileStatus = confFactory.MM_SEND_FILE_STATUS_SUCCESS
m.MMStatus = confFactory.MSG_SEND_STATUS_SUCC
m.sendByLocal = false
chatFactory.appendMessage(m)
chatFactory.sendMessage(m)
} catch (e) {
log('sendMedia() exception: ' + e.message)
return false
}
return true
}
function forward(baseData, patchData) {
var chatFactory = WechatyBro.glue.chatFactory
var confFactory = WechatyBro.glue.confFactory
if (!chatFactory || !confFactory) {
log('forward() chatFactory or confFactory not exist.')
return false
}
try {
var m = chatFactory.createMessage(baseData)
// Need to override the parametes after called createMessage()
m = Object.assign(m, patchData)
chatFactory.appendMessage(m)
chatFactory.sendMessage(m)
} catch (e) {
log('forward() exception: ' + e.message)
return false
}
return true
}
function send(ToUserName, Content) {
var chatFactory = WechatyBro.glue.chatFactory
var confFactory = WechatyBro.glue.confFactory
if (!chatFactory || !confFactory) {
log('send() chatFactory or confFactory not exist.')
return false
}
try {
var m = chatFactory.createMessage({
ToUserName: ToUserName,
Content: Content,
MsgType: confFactory.MSGTYPE_TEXT,
})
chatFactory.appendMessage(m)
chatFactory.sendMessage(m)
} catch (e) {
log('send() exception: ' + e.message)
return false
}
return true
}
function getMessage(id) {
var chatFactory = WechatyBro.glue.chatFactory
if (!chatFactory) {
log('chatFactory not inited')
return null
}
var msg = chatFactory.getMsg(id)
if (!msg) {
return null
}
var msgWithoutFunction = {}
Object.keys(msg).forEach(function(k) {
if (typeof msg[k] !== 'function') {
msgWithoutFunction[k] = msg[k]
}
})
return msgWithoutFunction
}
function getContact(id) {
var contactFactory = WechatyBro.glue.contactFactory
if (!contactFactory) {
log('contactFactory not inited')
return null
}
var contact = contactFactory.getContact(id)
var contactWithoutFunction = {}
if (contact) {
if (contact.isContact) {
// extend rawObj to identify `stranger`
contact.stranger = !(contact.isContact())
}
Object.keys(contact).forEach(function(k) {
if (typeof contact[k] !== 'function') {
contactWithoutFunction[k] = contact[k]
}
})
} else {
/**
* when `id` does not exist in _contact Array, maybe it is belongs to a stranger in a room.
* try to find in room's member list for this `id`, and return the contact info, if any.
*/
contact = Object.keys(_contacts)
.filter(id => id.match(/^@@/)) // only search in room
.map(id => _contacts[id]) // map to room array
.filter(r => r.MemberList.length) // get rid of room without member list
.filter(r => r.MemberList
.filter(m => m.UserName === id)
.length
)
.map(c => c.MemberList
.filter(m => m.UserName === id)
[0]
)
[0]
if (contact) {
contact.stranger = true
Object.keys(contact).forEach(k => {
if (typeof contact[k] !== 'function') {
contactWithoutFunction[k] = contact[k]
}
})
} else {
// Not found contact id
contactWithoutFunction = null
}
}
return contactWithoutFunction
}
function getUserName() {
if (!WechatyBro.loginState()) {
return null
}
var accountFactory = WechatyBro.glue.accountFactory
return accountFactory
? accountFactory.getUserName()
: null
}
function contactList() {
var contactFactory = WechatyBro.glue.contactFactory
return new Promise(resolve => retryFind(0, resolve))
// return
// retry 3 times, sleep 300ms between each time
function retryFind(attempt, callback) {
attempt = attempt || 0
var contactIdList = contactFactory
.getAllFriendContact()
.map(c => c.UserName)
if (contactIdList && contactIdList.length) {
callback(contactIdList)
} else if (attempt > 3) {
callback([])
} else {
attempt++
setTimeout(() => retryFind(attempt, callback), 1000)
}
}
}
function contactRemark(UserName, remark) {
if (remark === null || remark === undefined) {
remark = ''
}
var contact = _contacts[UserName]
if (!contact) {
throw new Error('contactRemark() can not found UserName ' + UserName)
}
var accountFactory = WechatyBro.glue.accountFactory
var confFactory = WechatyBro.glue.confFactory
var emojiFactory = WechatyBro.glue.emojiFactory
var mmHttp = WechatyBro.glue.mmHttp
return new Promise(resolve => {
mmHttp({
method: "POST",
url: confFactory.API_webwxoplog,
data: angular.extend({
UserName: UserName,
CmdId: confFactory.oplogCmdId.MODREMARKNAME,
RemarkName: emojiFactory.formatHTMLToSend(remark),
}, accountFactory.getBaseRequest()),
MMRetry: {
count: 3,
timeout: 1e4,
serial: !0,
}
})
.success(() => {
contact.RemarkName = remark
return resolve(true)
})
.error(() => {
return resolve(false) // TODO: use reject???
})
})
}
// function roomFind(filterFunction) {
function roomList() {
var contactFactory = WechatyBro.glue.contactFactory
// var match
// if (!filterFunction) {
// match = () => true
// } else {
// match = eval(filterFunction)
// }
// log(match.toString())
return contactFactory.getAllChatroomContact()
// .filter(r => match(r.NickName))
.map(r => r.UserName)
}
function roomDelMember(ChatRoomName, UserName) {
var chatroomFactory = WechatyBro.glue.chatroomFactory
return chatroomFactory.delMember(ChatRoomName, UserName)
}
function roomAddMember(ChatRoomName, UserName) {
var chatroomFactory = WechatyBro.glue.chatroomFactory
// log(ChatRoomName)
// log(UserName)
return new Promise(resolve => {
// There's no return value of addMember :(
// https://github.com/wechaty/webwx-app-tracker/blob/f22cb043ff4201ee841990dbeb59e22643092f92/formatted/webwxApp.js#L2404-L2413
var timer = setTimeout(() => {
log('roomAddMember() timeout')
// TODO change to reject here. (BREAKING CHANGES)
return resolve(0)
}, 10 * 1000)
chatroomFactory.addMember(ChatRoomName, UserName, function(result) {
clearTimeout(timer)
return resolve(1)
})
})
}
function roomModTopic(ChatRoomName, topic) {
var chatroomFactory = WechatyBro.glue.chatroomFactory
return chatroomFactory.modTopic(ChatRoomName, topic)
}
function roomCreate(UserNameList, topic) {
var UserNameListArg = UserNameList.map(function(n) { return { UserName: n } })
var chatroomFactory = WechatyBro.glue.chatroomFactory
var state = WechatyBro.glue.state
return new Promise(resolve => {
chatroomFactory.create(UserNameListArg)
.then(function(r) {
if (r.BaseResponse && 0 == r.BaseResponse.Ret || -2013 == r.BaseResponse.Ret) {
state.go('chat', { userName: r.ChatRoomName }) // BE CAREFUL: key name is userName, not UserName! 20161001
// if (topic) {
// setTimeout(_ => roomModTopic(r.ChatRoomName, topic), 3000)
// }
if (!r.ChatRoomName) {
throw new Error('chatroomFactory.create() got empty r.ChatRoomName')
}
resolve(r.ChatRoomName)
} else {
throw new Error('chatroomFactory.create() error with Ret: '
+ r && r.BaseResponse.Ret
+ 'with ErrMsg: '
+ r && r.BaseResponse.ErrMsg
)
}
})
.catch(function(e) {
// TODO change to reject (BREAKIKNG CHANGES)
resolve(
JSON.parse(
JSON.stringify(
e
, Object.getOwnPropertyNames(e)
)
)
)
})
})
}
function verifyUserRequest(UserName, VerifyContent) {
VerifyContent = VerifyContent || '';
var contactFactory = WechatyBro.glue.contactFactory
var confFactory = WechatyBro.glue.confFactory
var Ticket = '' // what's this?
return new Promise(resolve => {
contactFactory.verifyUser({
Opcode: confFactory.VERIFYUSER_OPCODE_SENDREQUEST,
Scene: confFactory.ADDSCENE_PF_WEB,
UserName,
Ticket,
VerifyContent,
})
.then(() => { // succ
// alert('ok')
// log('friendAdd(' + UserName + ', ' + VerifyContent + ') done')
resolve(true)
}, (err) => { // fail
// alert('not ok')
log('friendAdd(' + UserName + ', ' + VerifyContent + ') fail: ' + err)
resolve(false)
})
})
}
function verifyUserOk(UserName, Ticket) {
var contactFactory = WechatyBro.glue.contactFactory
var confFactory = WechatyBro.glue.confFactory
return new Promise(resolve => {
contactFactory.verifyUser({
UserName: UserName,
Opcode: confFactory.VERIFYUSER_OPCODE_VERIFYOK,
Scene: confFactory.ADDSCENE_PF_WEB,
Ticket: Ticket,
}).then(() => { // succ
// alert('ok')
log('friendVerify(' + UserName + ', ' + Ticket + ') done')
return resolve(true)
}, err => { // fail
// alert('err')
log('friendVerify(' + UserName + ', ' + Ticket + ') fail')
return resolve(false)
})
})
}
/////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////
/*
* WechatyBro injectio must return this object.
* PuppetPuppeteerBridge need this to decide if injection is successful.
*/
var retObj = {
code: 200, // 2XX ok, 4XX/5XX error. HTTP like
message: 'any message',
}
if (typeof this.WechatyBro !== 'undefined') {
retObj.code = 201
retObj.message = 'WechatyBro already injected?'
return retObj
}
var WechatyBro = {
glue: {
// will be initialized by glueToAngular() function
},
// glue funcs
// , getLoginStatusCode: function() { return WechatyBro.glue.loginScope.code }
// , getLoginQrImgUrl: function() { return WechatyBro.glue.loginScope.qrcodeUrl }
angularIsReady,
// variable
vars: {
loginState : false,
initState : false,
scanCode : null,
heartBeatTimmer : null,
},
// funcs
ding, // simple return 'dong'
emit: window.emit, // send event to Node.js
init, // initialize WechatyBro @ Browser
log, // log to Node.js
logout, // logout current logined user
send, // send message to wechat user
getContact,
getMessage,
getUserName,
getMsgImg,
getMsgEmoticon,
getMsgVideo,
getMsgVoice,
getMsgPublicLinkImg,
getBaseRequest,
getPassticket,
getUploadMediaUrl,
sendMedia,
forward,
getCheckUploadUrl,
// for Wechaty Contact Class
contactList,
contactRemark,
// for Wechaty Room Class
roomCreate,
roomAddMember,
roomList,
roomDelMember,
roomModTopic,
// for Friend Request
verifyUserRequest,
verifyUserOk,
// test purpose
isLogin: () => {
log('isLogin() DEPRECATED. use loginState() instead');
return loginState()
},
loginState,
}
this.WechatyBro = WechatyBro
retObj.code = 200
retObj.message = 'WechatyBro Inject Done'
return retObj
}.apply(window))
......@@ -2,12 +2,3 @@ declare module 'bl'
declare module 'blessed-contrib'
declare module 'qrcode-terminal'
declare module 'json-rpc-peer'
declare var window
// Extend the `Window` from Browser
interface Window {
emit: Function, // from puppeteer
}
declare const WechatyBro: any
(function() {
const retObj = {
code: 42,
message: 'meaning of the life',
}
return retObj
}())
#!/usr/bin/env ts-node
/**
* 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 fs from 'fs'
import path from 'path'
// tslint:disable:no-shadowed-variable
import test from 'blue-tape'
import sinon from 'sinon'
import {
Cookie,
launch,
} from 'puppeteer'
const PUPPETEER_LAUNCH_OPTIONS = {
headless: true,
args: [
'--disable-gpu',
'--disable-setuid-sandbox',
'--no-sandbox',
],
}
test('Puppeteer smoke testing', async t => {
let browser, page
try {
browser = await launch(PUPPETEER_LAUNCH_OPTIONS)
t.ok(browser, 'Browser instnace')
const version = await browser.version()
t.ok(version, 'should get version')
page = await browser.newPage()
t.pass('should create newPage for browser')
await page.goto('https://wx.qq.com/')
t.pass('should open wx.qq.com')
const result = await page.evaluate(() => 42)
t.is(result as any, 42, 'should get 42')
} catch (e) {
t.fail(e && e.message || e)
} finally {
if (page) {
await page.close()
}
if (browser) {
await browser.close()
}
}
})
test('evaluate() a function that returns a Promise', async t => {
try {
const browser = await launch(PUPPETEER_LAUNCH_OPTIONS)
const page = await browser.newPage()
const result = await page.evaluate(() => Promise.resolve(42))
t.equal(result, 42, 'should get resolved value of promise inside browser')
await page.close()
await browser.close()
} catch (e) {
t.fail(e && e.message || e)
}
})
test('evaluate() a file and get the returns value', async t => {
const EXPECTED_OBJ = {
code: 42,
message: 'meaning of the life',
}
try {
const browser = await launch(PUPPETEER_LAUNCH_OPTIONS)
const page = await browser.newPage()
const file = path.join(
__dirname,
'fixtures/inject-file.js',
)
const source = fs.readFileSync(file).toString()
const result = await page.evaluate(source)
t.deepEqual(result, EXPECTED_OBJ, 'should inject file inside browser and return the value')
const noWechaty = await page.evaluate('typeof WechatyBro === "undefined"')
t.equal(noWechaty, true, 'should no wechaty by default')
const hasWindow = await page.evaluate('typeof window === "object"')
t.equal(hasWindow, true, 'should has window by default')
await page.close()
await browser.close()
} catch (e) {
t.fail(e && e.message || e)
}
})
test('page.on(console)', async t => {
const EXPECTED_ARG1 = 'arg1'
const EXPECTED_ARG2 = 2
// const EXPECTED_ARG3 = { arg3: 3 }
const browser = await launch(PUPPETEER_LAUNCH_OPTIONS)
const page = await browser.newPage()
const spy = sinon.spy()
page.on('console', spy)
await page.evaluate((...args) => {
console.log.apply(console, args)
}, EXPECTED_ARG1, EXPECTED_ARG2) // , EXPECTED_ARG3)
// wait a while to let chrome fire the event
await new Promise(r => setTimeout(r, 3))
t.ok(spy.calledOnce, 'should be called once')
const consoleMessage = spy.firstCall.args[0]
t.equal(consoleMessage.type(), 'log', 'should get log type')
t.equal(consoleMessage.text(), EXPECTED_ARG1 + ' ' + EXPECTED_ARG2, 'should get console.log 1st/2nd arg')
await page.close()
await browser.close()
})
test('page.exposeFunction()', async t => {
const browser = await launch(PUPPETEER_LAUNCH_OPTIONS)
const page = await browser.newPage()
const spy = sinon.spy()
await page.exposeFunction('nodeFunc', spy)
await page.evaluate(`nodeFunc(42)`)
t.ok(spy.calledOnce, 'should be called once inside browser')
t.equal(spy.firstCall.args[0], 42, 'should be called with 42')
await page.close()
await browser.close()
})
test('other demos', async t => {
const EXPECTED_URL = 'https://github.com/'
try {
const browser = await launch(PUPPETEER_LAUNCH_OPTIONS)
const version = await browser.version()
t.ok(version, 'should get version')
const page = await browser.newPage()
await page.goto(EXPECTED_URL)
// await page.goto('https://www.chromestatus.com/features', {waitUntil: 'networkidle'});
// await page.waitForSelector('h3 a');
// await page.click('input[type="submit"]');
// not the same with the document of ConsoleMessage???
page.on('dialog', async dialog => {
console.log(dialog)
console.log('dialog:', dialog.type, dialog.message())
await dialog.accept('ok')
})
page.on('error', (e, ...args) => {
console.error('error', e)
console.error('error:args:', args)
})
page.on('pageerror', (e, ...args) => {
console.error('pageerror', e)
console.error('pageerror:args:', args)
})
page.on('load', (e, ...args) => {
console.log('load:e:', e)
console.log('load:args:', args)
})
// await page.setRequestInterception(true)
page.on('request', async interceptedRequest => {
if (interceptedRequest.url().endsWith('.png')
|| interceptedRequest.url().endsWith('.jpg')
) {
await interceptedRequest.abort()
} else {
await interceptedRequest.continue()
}
})
page.on('requestfailed', (...args: any[]) => {
console.log('requestfailed:args:', args)
})
page.on('response', (/*res, ...args*/) => {
// console.log('response:res:', res)
// console.log('response:args:', args)
})
// page.click(selector[, options])
// await page.injectFile(path.join(__dirname, 'wechaty-bro.js'))
const cookieList = await page.cookies() as any as Cookie[]
t.ok(cookieList.length, 'should get cookies')
t.ok(cookieList[0].name, 'should get cookies with name')
const cookie: Cookie = {
name : 'test-name',
value : 'test-value',
domain : 'qq.com',
path : '/',
expires : 1234324132,
httpOnly : false,
secure : false,
sameSite : 'Strict',
session : true,
}
await page.setCookie(cookie)
const result = await page.evaluate(() => 8 * 7)
t.equal(result, 56, 'should evaluated function for () => 8 * 7 = 56')
t.equal(await page.evaluate('1 + 2'), 3, 'should evaluated 1 + 2 = 3')
const url = await page.url()
t.equal(url, EXPECTED_URL, 'should get the url right')
// await new Promise(r => setTimeout(r, 3000))
await page.close()
await browser.close()
} catch (e) {
t.fail(e)
}
})
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册