From bbbabdbb72e00902aeb4a38da297327b2da4af9a Mon Sep 17 00:00:00 2001 From: Huan LI Date: Tue, 24 Apr 2018 02:14:44 +0800 Subject: [PATCH] abstract contact & room --- src/contact.ts | 488 ++------- .../web-contact.spec.ts} | 0 src/puppet-web/web-contact.ts | 721 +++++++++++++ ...est.spec.ts => web-friend-request.spec.ts} | 0 ...riend-request.ts => web-friend-request.ts} | 0 .../web-message.spec.ts} | 0 src/{message.ts => puppet-web/web-message.ts} | 0 .../web-room.spec.ts} | 0 src/puppet-web/web-room.ts | 966 ++++++++++++++++++ src/room.ts | 613 ++--------- 10 files changed, 1808 insertions(+), 980 deletions(-) rename src/{contact.spec.ts => puppet-web/web-contact.spec.ts} (100%) create mode 100644 src/puppet-web/web-contact.ts rename src/puppet-web/{friend-request.spec.ts => web-friend-request.spec.ts} (100%) rename src/puppet-web/{friend-request.ts => web-friend-request.ts} (100%) rename src/{message.spec.ts => puppet-web/web-message.spec.ts} (100%) rename src/{message.ts => puppet-web/web-message.ts} (100%) rename src/{room.spec.ts => puppet-web/web-room.spec.ts} (100%) create mode 100644 src/puppet-web/web-room.ts diff --git a/src/contact.ts b/src/contact.ts index 6e87d307..8b4a56e2 100644 --- a/src/contact.ts +++ b/src/contact.ts @@ -18,55 +18,13 @@ * @ignore */ import { - // config, - Raven, - Sayable, log, + Sayable, } from './config' import { - Message, MediaMessage, } from './message' -import Misc from './misc' import PuppetAccessory from './puppet-accessory' -// import Wechaty from './wechaty' - -import PuppetWeb from './puppet-web/' - -export interface ContactObj { - address: string, - city: string, - id: string, - name: string, - province: string, - alias: string|null, - sex: Gender, - signature: string, - star: boolean, - stranger: boolean, - uin: string, - weixin: string, - avatar: string, // XXX URL of HeadImgUrl - official: boolean, - special: boolean, -} - -export interface ContactRawObj { - Alias: string, - City: string, - NickName: string, - Province: string, - RemarkName: string, - Sex: Gender, - Signature: string, - StarFriend: string, - Uin: string, - UserName: string, - HeadImgUrl: string, - - stranger: string, // assign by injectio.js - VerifyFlag: number, -} /** * Enum for Gender values. @@ -85,104 +43,49 @@ export enum Gender { export interface ContactQueryFilter { name?: string | RegExp, alias?: string | RegExp, - // remark is DEPRECATED - remark?: string | RegExp, } -/** - * @see https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3848 - * @ignore - */ -const specialContactList: string[] = [ - 'weibo', 'qqmail', 'fmessage', 'tmessage', 'qmessage', 'qqsync', 'floatbottle', - 'lbsapp', 'shakeapp', 'medianote', 'qqfriend', 'readerapp', 'blogapp', 'facebookapp', - 'masssendapp', 'meishiapp', 'feedsapp', 'voip', 'blogappweixin', 'weixin', 'brandsessionholder', - 'weixinreminder', 'wxid_novlwrv3lqwv11', 'gh_22b87fa7cb3c', 'officialaccounts', 'notification_messages', -] - /** * All wechat contacts(friend) will be encapsulated as a Contact. * * `Contact` is `Sayable`, * [Examples/Contact-Bot]{@link https://github.com/Chatie/wechaty/blob/master/examples/contact-bot.ts} */ -export class Contact extends PuppetAccessory implements Sayable { +export abstract class Contact extends PuppetAccessory implements Sayable { private static pool = new Map() - public obj: ContactObj | null - // private dirtyObj: ContactObj | null - private rawObj: ContactRawObj - /** * @private */ - constructor( - public readonly id: string, - ) { - super() - log.silly('Contact', `constructor(${id})`) - - if (typeof id !== 'string') { - throw new Error('id must be string. found: ' + typeof id) + public static load(id: string): Contact { + if (!id || typeof id !== 'string') { + throw new Error('Contact.load(): id not found') } - } - /** - * @private - */ - public toString(): string { - if (!this.obj) { - return `Contact` + if (!(id in this.pool)) { + // when we call `load()`, `this` should already be extend-ed a child class. + // so we force `this as any` at here to make the call. + Contact.pool[id] = new (this as any)(id) } - const obj = this.obj - const name = obj.alias || obj.name || this.id - return `Contact<${name}>` + return this.pool[id] } /** * @private */ - public toStringEx() { return `Contact(${this.obj && this.obj.name}[${this.id}])` } + constructor( + public readonly id: string, + ) { + super() + log.silly('Contact', `constructor(${id})`) + } /** * @private */ - private parse(rawObj: ContactRawObj): ContactObj | null { - if (!rawObj || !rawObj.UserName) { - log.warn('Contact', 'parse() got empty rawObj!') - // config.puppetInstance().emit('error', e) - return null - } - - return !rawObj ? null : { - id: rawObj.UserName, // MMActualSender??? MMPeerUserName??? `getUserContact(message.MMActualSender,message.MMPeerUserName).HeadImgUrl` - uin: rawObj.Uin, // stable id: 4763975 || getCookie("wxuin") - weixin: rawObj.Alias, // Wechat ID - name: rawObj.NickName, - alias: rawObj.RemarkName, - sex: rawObj.Sex, - province: rawObj.Province, - city: rawObj.City, - signature: rawObj.Signature, - - address: rawObj.Alias, // XXX: need a stable address for user - - star: !!rawObj.StarFriend, - stranger: !!rawObj.stranger, // assign by injectio.js - avatar: rawObj.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 - official: !!rawObj.UserName && !rawObj.UserName.startsWith('@@') && !!(rawObj.VerifyFlag & 8), - /** - * @see 1. https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3246 - * @ignore - */ - special: specialContactList.indexOf(rawObj.UserName) > -1 || /@qqim$/.test(rawObj.UserName), - } + public toString(): string { + const text = this.alias() || this.name() || this.id + return `Contact<${text}>` } /** @@ -237,19 +140,9 @@ export class Contact extends PuppetAccessory implements Sayable { * const contactList = await Contact.findAll({name: 'ruirui'}) // find allof the contacts whose name is 'ruirui' * const contactList = await Contact.findAll({alias: 'lijiarui'}) // find all of the contacts whose alias is 'lijiarui' */ - public static async findAll(queryArg?: ContactQueryFilter): Promise { - let query: ContactQueryFilter - if (queryArg) { - if (queryArg.remark) { - log.warn('Contact', 'Contact.findAll({remark:%s}) DEPRECATED, use Contact.findAll({alias:%s}) instead.', queryArg.remark, queryArg.remark) - query = { alias: queryArg.remark} - } else { - query = queryArg - } - } else { - query = { name: /.*/ } - } - + public static async findAll( + query: ContactQueryFilter = { name: /.*/ }, + ): Promise { // log.verbose('Cotnact', 'findAll({ name: %s })', query.name) log.verbose('Cotnact', 'findAll({ %s })', Object.keys(query) @@ -261,47 +154,14 @@ export class Contact extends PuppetAccessory implements Sayable { throw new Error('query only support one key. multi key support is not availble now.') } - let filterKey = Object.keys(query)[0] - let filterValue: string | RegExp = query[filterKey] - - const keyMap = { - name: 'NickName', - alias: 'RemarkName', - } - - filterKey = keyMap[filterKey] - if (!filterKey) { - throw new Error('unsupport filter key') - } - - if (!filterValue) { - throw new Error('filterValue not found') - } - - /** - * 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.${filterKey}) })` - } else if (typeof filterValue === 'string') { - filterValue = filterValue.replace(/'/g, '\\\'') - filterFunction = `(function (c) { return c.${filterKey} === '${filterValue}' })` - } else { - throw new Error('unsupport name type') - } - try { - const contactList = await this.puppet // config.puppetInstance() - .contactFind(filterFunction) + const contactList: Contact[] = await this.puppet.contactFindAll(query) await Promise.all(contactList.map(c => c.ready())) return contactList } catch (e) { - log.error('Contact', 'findAll() rejected: %s', e.message) + log.error('Contact', 'this.puppet.contactFindAll() rejected: %s', e.message) return [] // fail safe } } @@ -311,7 +171,7 @@ export class Contact extends PuppetAccessory implements Sayable { * * @param {string} text */ - public async say(text: string): Promise + public abstract async say(text: string): Promise /** * Send Media File to Contact @@ -319,44 +179,23 @@ export class Contact extends PuppetAccessory implements Sayable { * @param {MediaMessage} mediaMessage * @memberof Contact */ - public async say(mediaMessage: MediaMessage): Promise + public abstract async say(mediaMessage: MediaMessage): Promise /** * Send Text or Media File to Contact. * * @param {(string | MediaMessage)} textOrMedia - * @returns {Promise} + * @returns {Promise} * @example * const contact = await Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any of your contact name in wechat - * await contact.say('welcome to wechaty!') - * await contact.say(new MediaMessage(__dirname + '/wechaty.png') // put the filePath you want to send here + * try { + * await contact.say('welcome to wechaty!') + * await contact.say(new MediaMessage(__dirname + '/wechaty.png') // put the filePath you want to send here + * } catch (e) { + * console.error(e) + * } */ - public async say(textOrMedia: string | MediaMessage): Promise { - const content = textOrMedia instanceof MediaMessage ? textOrMedia.filename() : textOrMedia - log.verbose('Contact', 'say(%s)', content) - - const user = this.puppet.self() - - if (!user) { - throw new Error('no user') - } - let m - if (typeof textOrMedia === 'string') { - m = new Message() - m.puppet = this.puppet - - m.content(textOrMedia) - } else if (textOrMedia instanceof MediaMessage) { - m = textOrMedia - } else { - throw new Error('not support args') - } - m.from(user) - m.to(this) - log.silly('Contact', 'say() from: %s to: %s content: %s', user.name(), this.name(), content) - - return await this.puppet.send(m) - } + public abstract async say(textOrMedia: string | MediaMessage): Promise /** * Get the name from a contact @@ -365,13 +204,11 @@ export class Contact extends PuppetAccessory implements Sayable { * @example * const name = contact.name() */ - public name() { return Misc.plainText(this.obj && this.obj.name || '') } - - public alias(): string | null - - public alias(newAlias: string): Promise + public abstract name(): string - public alias(empty: null): Promise + public abstract alias(): string | null + public abstract async alias(newAlias: string): Promise + public abstract async alias(empty: null): Promise /** * GET / SET / DELETE the alias for a contact @@ -388,48 +225,23 @@ export class Contact extends PuppetAccessory implements Sayable { * } * * @example SET the alias for a contact - * const ret = await contact.alias('lijiarui') - * if (ret) { + * try { + * await contact.alias('lijiarui') * console.log(`change ${contact.name()}'s alias successfully!`) - * } else { + * } catch (e) { * console.log(`failed to change ${contact.name()} alias!`) * } * * @example DELETE the alias for a contact - * const ret = await contact.alias(null) - * if (ret) { + * try { + * const oldAlias = await contact.alias(null) * console.log(`delete ${contact.name()}'s alias successfully!`) - * } else { + * console.log('old alias is ${oldAlias}`) + * } catch (e) { * console.log(`failed to delete ${contact.name()}'s alias!`) * } */ - public alias(newAlias?: string|null): Promise | string | null { - // log.silly('Contact', 'alias(%s)', newAlias || '') - - if (newAlias === undefined) { - return this.obj && this.obj.alias || null - } - - return this.puppet // config.puppetInstance() - .contactAlias(this, newAlias) - .then(ret => { - if (ret) { - if (this.obj) { - this.obj.alias = newAlias - } else { - log.error('Contact', 'alias() without this.obj?') - } - } else { - log.warn('Contact', 'alias(%s) fail', newAlias) - } - return ret - }) - .catch(e => { - log.error('Contact', 'alias(%s) rejected: %s', newAlias, e.message) - Raven.captureException(e) - return false // fail safe - }) - } + public abstract alias(newAlias?: string|null): Promise | string | null /** * Check if contact is stranger @@ -438,65 +250,36 @@ export class Contact extends PuppetAccessory implements Sayable { * @example * const isStranger = contact.stranger() */ - public stranger(): boolean|null { - if (!this.obj) return null - return this.obj.stranger - } + public abstract stranger(): boolean | null /** * Check if it's a offical account * - * @returns {boolean|null} - True for official account, Flase for contact is not a official account, null for unknown + * @returns {boolean | null} - True for official account, Flase for contact is not a official account, null for unknown * @see {@link https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3243|webwxApp.js#L324} * @see {@link https://github.com/Urinx/WeixinBot/blob/master/README.md|Urinx/WeixinBot/README} * @example * const isOfficial = contact.official() */ - public official(): boolean { - return !!this.obj && this.obj.official - } - - /** - * Check if it's a special contact - * - * The contact who's id in following list will be identify as a special contact - * `weibo`, `qqmail`, `fmessage`, `tmessage`, `qmessage`, `qqsync`, `floatbottle`, - * `lbsapp`, `shakeapp`, `medianote`, `qqfriend`, `readerapp`, `blogapp`, `facebookapp`, - * `masssendapp`, `meishiapp`, `feedsapp`, `voip`, `blogappweixin`, `weixin`, `brandsessionholder`, - * `weixinreminder`, `wxid_novlwrv3lqwv11`, `gh_22b87fa7cb3c`, `officialaccounts`, `notification_messages`, - * - * @see {@link https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3848|webwxApp.js#L3848} - * @see {@link https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3246|webwxApp.js#L3246} - * @returns {boolean|null} True for brand, Flase for contact is not a brand - * @example - * const isSpecial = contact.special() - */ - public special(): boolean { - return !!this.obj && this.obj.special - } + public abstract official(): boolean | null /** * Check if it's a personal account * - * @returns {boolean|null} - True for personal account, Flase for contact is not a personal account + * @returns {boolean | null} - True for personal account, Flase for contact is not a personal account * @example * const isPersonal = contact.personal() */ - public personal(): boolean { - return !this.official() - } + public abstract personal(): boolean | null /** * Check if the contact is star contact. * - * @returns {boolean} - True for star friend, False for no star friend. + * @returns {boolean | null} - True for star friend, False for no star friend. * @example * const isStar = contact.star() */ - public star(): boolean|null { - if (!this.obj) return null - return this.obj.star - } + public abstract star(): boolean | null /** * Contact gender @@ -505,25 +288,25 @@ export class Contact extends PuppetAccessory implements Sayable { * @example * const gender = contact.gender() */ - public gender(): Gender { return this.obj ? this.obj.sex : Gender.Unknown } + public abstract gender(): Gender /** * Get the region 'province' from a contact * - * @returns {string | undefined} + * @returns {string | null} * @example * const province = contact.province() */ - public province() { return this.obj && this.obj.province } + public abstract province(): string | null /** * Get the region 'city' from a contact * - * @returns {string | undefined} + * @returns {string | null} * @example * const city = contact.city() */ - public city() { return this.obj && this.obj.city } + public abstract city(): string | null /** * Get avatar picture file stream @@ -536,113 +319,21 @@ export class Contact extends PuppetAccessory implements Sayable { * avatarReadStream.pipe(avatarWriteStream) * log.info('Bot', 'Contact: %s: %s with avatar file: %s', contact.weixin(), contact.name(), avatarFileName) */ - public async avatar(): Promise { - log.verbose('Contact', 'avatar()') - - if (!this.obj) { - throw new Error('Can not get avatar: no this.obj!') - } else if (!this.obj.avatar) { - throw new Error('Can not get avatar: no this.obj.avatar!') - } - - try { - const hostname = await (/* config.puppetInstance() */ this.puppet as PuppetWeb ).hostname() - const avatarUrl = `http://${hostname}${this.obj.avatar}&type=big` // add '&type=big' to get big image - const cookies = await (/* config.puppetInstance() */ this.puppet as PuppetWeb).cookies() - log.silly('Contact', 'avatar() url: %s', avatarUrl) - - return Misc.urlStream(avatarUrl, cookies) - } catch (err) { - log.warn('Contact', 'avatar() exception: %s', err.stack) - Raven.captureException(err) - throw err - } - } - - /** - * @private - */ - public get(prop) { return this.obj && this.obj[prop] } - - /** - * @private - */ - public isReady(): boolean { - return !!(this.obj && this.obj.id && this.obj.name) - } + public abstract async avatar(): Promise /** - * Force reload data for Contact + * Force reload(re-ready()) data for Contact * * @returns {Promise} * @example * await contact.refresh() */ - public async refresh(): Promise { - // TODO: make sure the contact.* works when we are refreshing the data - // if (this.isReady()) { - // this.dirtyObj = this.obj - // } - this.obj = null - await this.ready() - return this - } - - /** - * @private - */ - public async ready(contactGetter?: (id: string) => Promise): Promise { - // log.silly('Contact', 'ready(' + (contactGetter ? typeof contactGetter : '') + ')') - if (!this.id) { - const e = new Error('ready() call on an un-inited contact') - throw e - } - - if (this.isReady()) { // already ready - return Promise.resolve(this) - } - - if (!contactGetter) { - log.silly('Contact', 'get contact via ' + /* config.puppetInstance() */ this.puppet.constructor.name) - contactGetter = /* config.puppetInstance() */ this.puppet - .getContact.bind(/* config.puppetInstance() */ this.puppet) - } - if (!contactGetter) { - throw new Error('no contatGetter') - } - - try { - const rawObj = await contactGetter(this.id) - log.silly('Contact', `contactGetter(${this.id}) resolved`) - this.rawObj = rawObj - this.obj = this.parse(rawObj) - return this - - } catch (e) { - log.error('Contact', `contactGetter(${this.id}) exception: %s`, e.message) - Raven.captureException(e) - throw e - } - } - - /** - * @private - */ - public dumpRaw() { - console.error('======= dump raw contact =======') - Object.keys(this.rawObj).forEach(k => console.error(`${k}: ${this.rawObj[k]}`)) - } + public abstract async refresh(): Promise /** * @private */ - public dump() { - console.error('======= dump contact =======') - if (!this.obj) { - throw new Error('no this.obj') - } - Object.keys(this.obj).forEach(k => console.error(`${k}: ${this.obj && this.obj[k]}`)) - } + public abstract async ready(): Promise /** * Check if contact is self @@ -651,50 +342,7 @@ export class Contact extends PuppetAccessory implements Sayable { * @example * const isSelf = contact.self() */ - public self(): boolean { - const userId = this.puppet // config.puppetInstance() - .userId - - const selfId = this.id - - if (!userId || !selfId) { - throw new Error('no user or no self id') - } - - return selfId === userId - } - - /** - * @private - */ - // function should be deprecated - public remark(newRemark?: string|null): Promise | string | null { - log.warn('Contact', 'remark(%s) DEPRECATED, use alias(%s) instead.') - log.silly('Contact', 'remark(%s)', newRemark || '') - - switch (newRemark) { - case undefined: - return this.alias() - case null: - return this.alias(null) - default: - return this.alias(newRemark) - } - } - - /** - * @private - */ - public static load(id: string): Contact { - if (!id || typeof id !== 'string') { - throw new Error('Contact.load(): id not found') - } - - if (!(id in Contact.pool)) { - Contact.pool[id] = new Contact(id) - } - return Contact.pool[id] - } + public abstract self(): boolean /** * Get the weixin number from a contact. @@ -706,15 +354,7 @@ export class Contact extends PuppetAccessory implements Sayable { * @example * const weixin = contact.weixin() */ - public weixin(): string | null { - const wxId = this.obj && this.obj.weixin || null - if (!wxId) { - log.verbose('Contact', `weixin() is not able to always work, it's limited by Tencent API`) - log.verbose('Contact', 'weixin() If you want to track a contact between sessions, see FAQ at') - log.verbose('Contact', 'https://github.com/Chatie/wechaty/wiki/FAQ#1-how-to-get-the-permanent-id-for-a-contact') - } - return wxId - } + public abstract weixin(): string | null } diff --git a/src/contact.spec.ts b/src/puppet-web/web-contact.spec.ts similarity index 100% rename from src/contact.spec.ts rename to src/puppet-web/web-contact.spec.ts diff --git a/src/puppet-web/web-contact.ts b/src/puppet-web/web-contact.ts new file mode 100644 index 00000000..6e87d307 --- /dev/null +++ b/src/puppet-web/web-contact.ts @@ -0,0 +1,721 @@ +/** + * Wechaty - https://github.com/chatie/wechaty + * + * @copyright 2016-2018 Huan LI + * + * 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. + * + * @ignore + */ +import { + // config, + Raven, + Sayable, + log, +} from './config' +import { + Message, + MediaMessage, +} from './message' +import Misc from './misc' +import PuppetAccessory from './puppet-accessory' +// import Wechaty from './wechaty' + +import PuppetWeb from './puppet-web/' + +export interface ContactObj { + address: string, + city: string, + id: string, + name: string, + province: string, + alias: string|null, + sex: Gender, + signature: string, + star: boolean, + stranger: boolean, + uin: string, + weixin: string, + avatar: string, // XXX URL of HeadImgUrl + official: boolean, + special: boolean, +} + +export interface ContactRawObj { + Alias: string, + City: string, + NickName: string, + Province: string, + RemarkName: string, + Sex: Gender, + Signature: string, + StarFriend: string, + Uin: string, + UserName: string, + HeadImgUrl: string, + + stranger: string, // assign by injectio.js + VerifyFlag: number, +} + +/** + * Enum for Gender values. + * + * @enum {number} + * @property {number} Unknown - 0 for Unknown + * @property {number} Male - 1 for Male + * @property {number} Female - 2 for Female + */ +export enum Gender { + Unknown = 0, + Male = 1, + Female = 2, +} + +export interface ContactQueryFilter { + name?: string | RegExp, + alias?: string | RegExp, + // remark is DEPRECATED + remark?: string | RegExp, +} + +/** + * @see https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3848 + * @ignore + */ +const specialContactList: string[] = [ + 'weibo', 'qqmail', 'fmessage', 'tmessage', 'qmessage', 'qqsync', 'floatbottle', + 'lbsapp', 'shakeapp', 'medianote', 'qqfriend', 'readerapp', 'blogapp', 'facebookapp', + 'masssendapp', 'meishiapp', 'feedsapp', 'voip', 'blogappweixin', 'weixin', 'brandsessionholder', + 'weixinreminder', 'wxid_novlwrv3lqwv11', 'gh_22b87fa7cb3c', 'officialaccounts', 'notification_messages', +] + +/** + * All wechat contacts(friend) will be encapsulated as a Contact. + * + * `Contact` is `Sayable`, + * [Examples/Contact-Bot]{@link https://github.com/Chatie/wechaty/blob/master/examples/contact-bot.ts} + */ +export class Contact extends PuppetAccessory implements Sayable { + private static pool = new Map() + + public obj: ContactObj | null + // private dirtyObj: ContactObj | null + private rawObj: ContactRawObj + + /** + * @private + */ + constructor( + public readonly id: string, + ) { + super() + log.silly('Contact', `constructor(${id})`) + + if (typeof id !== 'string') { + throw new Error('id must be string. found: ' + typeof id) + } + } + + /** + * @private + */ + public toString(): string { + if (!this.obj) { + return `Contact` + } + const obj = this.obj + const name = obj.alias || obj.name || this.id + return `Contact<${name}>` + } + + /** + * @private + */ + public toStringEx() { return `Contact(${this.obj && this.obj.name}[${this.id}])` } + + /** + * @private + */ + private parse(rawObj: ContactRawObj): ContactObj | null { + if (!rawObj || !rawObj.UserName) { + log.warn('Contact', 'parse() got empty rawObj!') + // config.puppetInstance().emit('error', e) + return null + } + + return !rawObj ? null : { + id: rawObj.UserName, // MMActualSender??? MMPeerUserName??? `getUserContact(message.MMActualSender,message.MMPeerUserName).HeadImgUrl` + uin: rawObj.Uin, // stable id: 4763975 || getCookie("wxuin") + weixin: rawObj.Alias, // Wechat ID + name: rawObj.NickName, + alias: rawObj.RemarkName, + sex: rawObj.Sex, + province: rawObj.Province, + city: rawObj.City, + signature: rawObj.Signature, + + address: rawObj.Alias, // XXX: need a stable address for user + + star: !!rawObj.StarFriend, + stranger: !!rawObj.stranger, // assign by injectio.js + avatar: rawObj.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 + official: !!rawObj.UserName && !rawObj.UserName.startsWith('@@') && !!(rawObj.VerifyFlag & 8), + /** + * @see 1. https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3246 + * @ignore + */ + special: specialContactList.indexOf(rawObj.UserName) > -1 || /@qqim$/.test(rawObj.UserName), + } + } + + /** + * The way to search Contact + * + * @typedef ContactQueryFilter + * @property {string} name - The name-string set by user-self, should be called name + * @property {string} alias - The name-string set by bot for others, should be called alias + * [More Detail]{@link https://github.com/Chatie/wechaty/issues/365} + */ + + /** + * Try to find a contact by filter: {name: string | RegExp} / {alias: string | RegExp} + * + * Find contact by name or alias, if the result more than one, return the first one. + * + * @static + * @param {ContactQueryFilter} query + * @returns {(Promise)} If can find the contact, return Contact, or return null + * @example + * const contactFindByName = await Contact.find({ name:"ruirui"} ) + * const contactFindByAlias = await Contact.find({ alias:"lijiarui"} ) + */ + public static async find(query: ContactQueryFilter): Promise { + log.verbose('Contact', 'find(%s)', JSON.stringify(query)) + + const contactList = await this.findAll(query) + if (!contactList || !contactList.length) { + return null + } + + if (contactList.length > 1) { + log.warn('Contact', 'function find(%s) get %d contacts, use the first one by default', JSON.stringify(query), contactList.length) + } + return contactList[0] + } + + /** + * Find contact by `name` or `alias` + * + * If use Contact.findAll() get the contact list of the bot. + * + * #### definition + * - `name` the name-string set by user-self, should be called name + * - `alias` the name-string set by bot for others, should be called alias + * + * @static + * @param {ContactQueryFilter} [queryArg] + * @returns {Promise} + * @example + * const contactList = await Contact.findAll() // get the contact list of the bot + * const contactList = await Contact.findAll({name: 'ruirui'}) // find allof the contacts whose name is 'ruirui' + * const contactList = await Contact.findAll({alias: 'lijiarui'}) // find all of the contacts whose alias is 'lijiarui' + */ + public static async findAll(queryArg?: ContactQueryFilter): Promise { + let query: ContactQueryFilter + if (queryArg) { + if (queryArg.remark) { + log.warn('Contact', 'Contact.findAll({remark:%s}) DEPRECATED, use Contact.findAll({alias:%s}) instead.', queryArg.remark, queryArg.remark) + query = { alias: queryArg.remark} + } else { + query = queryArg + } + } else { + query = { name: /.*/ } + } + + // log.verbose('Cotnact', 'findAll({ name: %s })', query.name) + log.verbose('Cotnact', 'findAll({ %s })', + Object.keys(query) + .map(k => `${k}: ${query[k]}`) + .join(', '), + ) + + if (Object.keys(query).length !== 1) { + throw new Error('query only support one key. multi key support is not availble now.') + } + + let filterKey = Object.keys(query)[0] + let filterValue: string | RegExp = query[filterKey] + + const keyMap = { + name: 'NickName', + alias: 'RemarkName', + } + + filterKey = keyMap[filterKey] + if (!filterKey) { + throw new Error('unsupport filter key') + } + + if (!filterValue) { + throw new Error('filterValue not found') + } + + /** + * 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.${filterKey}) })` + } else if (typeof filterValue === 'string') { + filterValue = filterValue.replace(/'/g, '\\\'') + filterFunction = `(function (c) { return c.${filterKey} === '${filterValue}' })` + } else { + throw new Error('unsupport name type') + } + + try { + const contactList = await this.puppet // config.puppetInstance() + .contactFind(filterFunction) + + await Promise.all(contactList.map(c => c.ready())) + return contactList + + } catch (e) { + log.error('Contact', 'findAll() rejected: %s', e.message) + return [] // fail safe + } + } + + /** + * Sent Text to contact + * + * @param {string} text + */ + public async say(text: string): Promise + + /** + * Send Media File to Contact + * + * @param {MediaMessage} mediaMessage + * @memberof Contact + */ + public async say(mediaMessage: MediaMessage): Promise + + /** + * Send Text or Media File to Contact. + * + * @param {(string | MediaMessage)} textOrMedia + * @returns {Promise} + * @example + * const contact = await Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any of your contact name in wechat + * await contact.say('welcome to wechaty!') + * await contact.say(new MediaMessage(__dirname + '/wechaty.png') // put the filePath you want to send here + */ + public async say(textOrMedia: string | MediaMessage): Promise { + const content = textOrMedia instanceof MediaMessage ? textOrMedia.filename() : textOrMedia + log.verbose('Contact', 'say(%s)', content) + + const user = this.puppet.self() + + if (!user) { + throw new Error('no user') + } + let m + if (typeof textOrMedia === 'string') { + m = new Message() + m.puppet = this.puppet + + m.content(textOrMedia) + } else if (textOrMedia instanceof MediaMessage) { + m = textOrMedia + } else { + throw new Error('not support args') + } + m.from(user) + m.to(this) + log.silly('Contact', 'say() from: %s to: %s content: %s', user.name(), this.name(), content) + + return await this.puppet.send(m) + } + + /** + * Get the name from a contact + * + * @returns {string} + * @example + * const name = contact.name() + */ + public name() { return Misc.plainText(this.obj && this.obj.name || '') } + + public alias(): string | null + + public alias(newAlias: string): Promise + + public alias(empty: null): Promise + + /** + * GET / SET / DELETE the alias for a contact + * + * Tests show it will failed if set alias too frequently(60 times in one minute). + * @param {(none | string | null)} newAlias + * @returns {(string | null | Promise)} + * @example GET the alias for a contact, return {(string | null)} + * const alias = contact.alias() + * if (alias === null) { + * console.log('You have not yet set any alias for contact ' + contact.name()) + * } else { + * console.log('You have already set an alias for contact ' + contact.name() + ':' + alias) + * } + * + * @example SET the alias for a contact + * const ret = await contact.alias('lijiarui') + * if (ret) { + * console.log(`change ${contact.name()}'s alias successfully!`) + * } else { + * console.log(`failed to change ${contact.name()} alias!`) + * } + * + * @example DELETE the alias for a contact + * const ret = await contact.alias(null) + * if (ret) { + * console.log(`delete ${contact.name()}'s alias successfully!`) + * } else { + * console.log(`failed to delete ${contact.name()}'s alias!`) + * } + */ + public alias(newAlias?: string|null): Promise | string | null { + // log.silly('Contact', 'alias(%s)', newAlias || '') + + if (newAlias === undefined) { + return this.obj && this.obj.alias || null + } + + return this.puppet // config.puppetInstance() + .contactAlias(this, newAlias) + .then(ret => { + if (ret) { + if (this.obj) { + this.obj.alias = newAlias + } else { + log.error('Contact', 'alias() without this.obj?') + } + } else { + log.warn('Contact', 'alias(%s) fail', newAlias) + } + return ret + }) + .catch(e => { + log.error('Contact', 'alias(%s) rejected: %s', newAlias, e.message) + Raven.captureException(e) + return false // fail safe + }) + } + + /** + * Check if contact is stranger + * + * @returns {boolean | null} - True for not friend of the bot, False for friend of the bot, null for unknown. + * @example + * const isStranger = contact.stranger() + */ + public stranger(): boolean|null { + if (!this.obj) return null + return this.obj.stranger + } + + /** + * Check if it's a offical account + * + * @returns {boolean|null} - True for official account, Flase for contact is not a official account, null for unknown + * @see {@link https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3243|webwxApp.js#L324} + * @see {@link https://github.com/Urinx/WeixinBot/blob/master/README.md|Urinx/WeixinBot/README} + * @example + * const isOfficial = contact.official() + */ + public official(): boolean { + return !!this.obj && this.obj.official + } + + /** + * Check if it's a special contact + * + * The contact who's id in following list will be identify as a special contact + * `weibo`, `qqmail`, `fmessage`, `tmessage`, `qmessage`, `qqsync`, `floatbottle`, + * `lbsapp`, `shakeapp`, `medianote`, `qqfriend`, `readerapp`, `blogapp`, `facebookapp`, + * `masssendapp`, `meishiapp`, `feedsapp`, `voip`, `blogappweixin`, `weixin`, `brandsessionholder`, + * `weixinreminder`, `wxid_novlwrv3lqwv11`, `gh_22b87fa7cb3c`, `officialaccounts`, `notification_messages`, + * + * @see {@link https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3848|webwxApp.js#L3848} + * @see {@link https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3246|webwxApp.js#L3246} + * @returns {boolean|null} True for brand, Flase for contact is not a brand + * @example + * const isSpecial = contact.special() + */ + public special(): boolean { + return !!this.obj && this.obj.special + } + + /** + * Check if it's a personal account + * + * @returns {boolean|null} - True for personal account, Flase for contact is not a personal account + * @example + * const isPersonal = contact.personal() + */ + public personal(): boolean { + return !this.official() + } + + /** + * Check if the contact is star contact. + * + * @returns {boolean} - True for star friend, False for no star friend. + * @example + * const isStar = contact.star() + */ + public star(): boolean|null { + if (!this.obj) return null + return this.obj.star + } + + /** + * Contact gender + * + * @returns {Gender.Male(2)|Gender.Female(1)|Gender.Unknown(0)} + * @example + * const gender = contact.gender() + */ + public gender(): Gender { return this.obj ? this.obj.sex : Gender.Unknown } + + /** + * Get the region 'province' from a contact + * + * @returns {string | undefined} + * @example + * const province = contact.province() + */ + public province() { return this.obj && this.obj.province } + + /** + * Get the region 'city' from a contact + * + * @returns {string | undefined} + * @example + * const city = contact.city() + */ + public city() { return this.obj && this.obj.city } + + /** + * Get avatar picture file stream + * + * @returns {Promise} + * @example + * const avatarFileName = contact.name() + `.jpg` + * const avatarReadStream = await contact.avatar() + * const avatarWriteStream = createWriteStream(avatarFileName) + * avatarReadStream.pipe(avatarWriteStream) + * log.info('Bot', 'Contact: %s: %s with avatar file: %s', contact.weixin(), contact.name(), avatarFileName) + */ + public async avatar(): Promise { + log.verbose('Contact', 'avatar()') + + if (!this.obj) { + throw new Error('Can not get avatar: no this.obj!') + } else if (!this.obj.avatar) { + throw new Error('Can not get avatar: no this.obj.avatar!') + } + + try { + const hostname = await (/* config.puppetInstance() */ this.puppet as PuppetWeb ).hostname() + const avatarUrl = `http://${hostname}${this.obj.avatar}&type=big` // add '&type=big' to get big image + const cookies = await (/* config.puppetInstance() */ this.puppet as PuppetWeb).cookies() + log.silly('Contact', 'avatar() url: %s', avatarUrl) + + return Misc.urlStream(avatarUrl, cookies) + } catch (err) { + log.warn('Contact', 'avatar() exception: %s', err.stack) + Raven.captureException(err) + throw err + } + } + + /** + * @private + */ + public get(prop) { return this.obj && this.obj[prop] } + + /** + * @private + */ + public isReady(): boolean { + return !!(this.obj && this.obj.id && this.obj.name) + } + + /** + * Force reload data for Contact + * + * @returns {Promise} + * @example + * await contact.refresh() + */ + public async refresh(): Promise { + // TODO: make sure the contact.* works when we are refreshing the data + // if (this.isReady()) { + // this.dirtyObj = this.obj + // } + this.obj = null + await this.ready() + return this + } + + /** + * @private + */ + public async ready(contactGetter?: (id: string) => Promise): Promise { + // log.silly('Contact', 'ready(' + (contactGetter ? typeof contactGetter : '') + ')') + if (!this.id) { + const e = new Error('ready() call on an un-inited contact') + throw e + } + + if (this.isReady()) { // already ready + return Promise.resolve(this) + } + + if (!contactGetter) { + log.silly('Contact', 'get contact via ' + /* config.puppetInstance() */ this.puppet.constructor.name) + contactGetter = /* config.puppetInstance() */ this.puppet + .getContact.bind(/* config.puppetInstance() */ this.puppet) + } + if (!contactGetter) { + throw new Error('no contatGetter') + } + + try { + const rawObj = await contactGetter(this.id) + log.silly('Contact', `contactGetter(${this.id}) resolved`) + this.rawObj = rawObj + this.obj = this.parse(rawObj) + return this + + } catch (e) { + log.error('Contact', `contactGetter(${this.id}) exception: %s`, e.message) + Raven.captureException(e) + throw e + } + } + + /** + * @private + */ + public dumpRaw() { + console.error('======= dump raw contact =======') + Object.keys(this.rawObj).forEach(k => console.error(`${k}: ${this.rawObj[k]}`)) + } + + /** + * @private + */ + public dump() { + console.error('======= dump contact =======') + if (!this.obj) { + throw new Error('no this.obj') + } + Object.keys(this.obj).forEach(k => console.error(`${k}: ${this.obj && this.obj[k]}`)) + } + + /** + * Check if contact is self + * + * @returns {boolean} True for contact is self, False for contact is others + * @example + * const isSelf = contact.self() + */ + public self(): boolean { + const userId = this.puppet // config.puppetInstance() + .userId + + const selfId = this.id + + if (!userId || !selfId) { + throw new Error('no user or no self id') + } + + return selfId === userId + } + + /** + * @private + */ + // function should be deprecated + public remark(newRemark?: string|null): Promise | string | null { + log.warn('Contact', 'remark(%s) DEPRECATED, use alias(%s) instead.') + log.silly('Contact', 'remark(%s)', newRemark || '') + + switch (newRemark) { + case undefined: + return this.alias() + case null: + return this.alias(null) + default: + return this.alias(newRemark) + } + } + + /** + * @private + */ + public static load(id: string): Contact { + if (!id || typeof id !== 'string') { + throw new Error('Contact.load(): id not found') + } + + if (!(id in Contact.pool)) { + Contact.pool[id] = new Contact(id) + } + return Contact.pool[id] + } + + /** + * Get the weixin number from a contact. + * + * Sometimes cannot get weixin number due to weixin security mechanism, not recommend. + * + * @private + * @returns {string | null} + * @example + * const weixin = contact.weixin() + */ + public weixin(): string | null { + const wxId = this.obj && this.obj.weixin || null + if (!wxId) { + log.verbose('Contact', `weixin() is not able to always work, it's limited by Tencent API`) + log.verbose('Contact', 'weixin() If you want to track a contact between sessions, see FAQ at') + log.verbose('Contact', 'https://github.com/Chatie/wechaty/wiki/FAQ#1-how-to-get-the-permanent-id-for-a-contact') + } + return wxId + } + +} + +export default Contact diff --git a/src/puppet-web/friend-request.spec.ts b/src/puppet-web/web-friend-request.spec.ts similarity index 100% rename from src/puppet-web/friend-request.spec.ts rename to src/puppet-web/web-friend-request.spec.ts diff --git a/src/puppet-web/friend-request.ts b/src/puppet-web/web-friend-request.ts similarity index 100% rename from src/puppet-web/friend-request.ts rename to src/puppet-web/web-friend-request.ts diff --git a/src/message.spec.ts b/src/puppet-web/web-message.spec.ts similarity index 100% rename from src/message.spec.ts rename to src/puppet-web/web-message.spec.ts diff --git a/src/message.ts b/src/puppet-web/web-message.ts similarity index 100% rename from src/message.ts rename to src/puppet-web/web-message.ts diff --git a/src/room.spec.ts b/src/puppet-web/web-room.spec.ts similarity index 100% rename from src/room.spec.ts rename to src/puppet-web/web-room.spec.ts diff --git a/src/puppet-web/web-room.ts b/src/puppet-web/web-room.ts new file mode 100644 index 00000000..70f2dcc3 --- /dev/null +++ b/src/puppet-web/web-room.ts @@ -0,0 +1,966 @@ +/** + * Wechaty - https://github.com/chatie/wechaty + * + * @copyright 2016-2018 Huan LI + * + * 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. + * + * @ignore + */ +import { + // config, + Raven, + Sayable, + log, +} from './config' +import Contact from './contact' +import { + Message, + MediaMessage, +} from './message' +import Misc from './misc' +import PuppetAccessory from './puppet-accessory' + +interface RoomObj { + id: string, + encryId: string, + topic: string, + ownerUin: number, + memberList: Contact[], + nameMap: Map, + roomAliasMap: Map, + contactAliasMap: Map, +} + +type NameType = 'name' | 'alias' | 'roomAlias' | 'contactAlias' + +export interface RoomRawMember { + UserName: string, + NickName: string, + DisplayName: string, +} + +export interface RoomRawObj { + UserName: string, + EncryChatRoomId: string, + NickName: string, + OwnerUin: number, + ChatRoomOwner: string, + MemberList?: RoomRawMember[], +} + +export type RoomEventName = 'join' + | 'leave' + | 'topic' + | 'EVENT_PARAM_ERROR' + +export interface RoomQueryFilter { + topic: string | RegExp, +} + +export interface MemberQueryFilter { + name?: string, + alias?: string, + roomAlias?: string, + contactAlias?: string, +} + +/** + * All wechat rooms(groups) will be encapsulated as a Room. + * + * `Room` is `Sayable`, + * [Examples/Room-Bot]{@link https://github.com/Chatie/wechaty/blob/master/examples/room-bot.ts} + */ +export class Room extends PuppetAccessory implements Sayable { + private static pool = new Map() + + private dirtyObj: RoomObj | null // when refresh, use this to save dirty data for query + private obj: RoomObj | null + private rawObj: RoomRawObj + + /** + * @private + */ + constructor(public id: string) { + super() + log.silly('Room', `constructor(${id})`) + } + + /** + * @private + */ + public toString() { return `@Room<${this.topic()}>` } + + /** + * @private + */ + public toStringEx() { return `Room(${this.obj && this.obj.topic}[${this.id}])` } + + /** + * @private + */ + public isReady(): boolean { + return !!(this.obj && this.obj.memberList && this.obj.memberList.length) + } + + /** + * @private + */ + private async readyAllMembers(memberList: RoomRawMember[]): Promise { + for (const member of memberList) { + const contact = Contact.load(member.UserName) + contact.puppet = this.puppet + await contact.ready() + } + return + } + + /** + * @private + */ + public async ready(contactGetter?: (id: string) => Promise): Promise { + log.silly('Room', 'ready(%s)', contactGetter ? contactGetter.constructor.name : '') + if (!this.id) { + const e = new Error('ready() on a un-inited Room') + log.warn('Room', e.message) + throw e + } else if (this.isReady()) { + return this + } else if (this.obj && this.obj.id) { + log.verbose('Room', 'ready() is not full loaded in room. reloading', this.obj.topic) + } + + if (!contactGetter) { + contactGetter = this.puppet // config.puppetInstance() + .getContact.bind(/* config.puppetInstance() */ this.puppet) + } + if (!contactGetter) { + throw new Error('no contactGetter') + } + + try { + let ttl = 7 + while (ttl--) { + const roomRawObj = await contactGetter(this.id) as RoomRawObj + + const currNum = roomRawObj.MemberList && roomRawObj.MemberList.length || 0 + const prevNum = this.rawObj && this.rawObj.MemberList && this.rawObj.MemberList.length || 0 + + log.silly('Room', `ready() contactGetter(%s) MemberList.length:%d at ttl:%d`, + this.id, + currNum, + ttl, + ) + + if (currNum) { + if (prevNum === currNum) { + log.verbose('Room', `ready() contactGetter(${this.id}) done at ttl:%d`, ttl) + break + } + this.rawObj = roomRawObj + } + + log.silly('Room', `ready() contactGetter(${this.id}) retry at ttl:%d`, ttl) + await new Promise(r => setTimeout(r, 1000)) // wait for 1 second + } + + await this.readyAllMembers(this.rawObj && this.rawObj.MemberList || []) + this.obj = this.parse(this.rawObj) + if (!this.obj) { + throw new Error('no this.obj set after contactGetter') + } + await Promise.all(this.obj.memberList.map(c => c.ready(contactGetter))) + + return this + + } catch (e) { + log.error('Room', 'contactGetter(%s) exception: %s', this.id, e.message) + Raven.captureException(e) + throw e + } + } + + public say(mediaMessage: MediaMessage) + + public say(content: string) + + public say(content: string, replyTo: Contact) + + public say(content: string, replyTo: Contact[]) + + /** + * Send message inside Room, if set [replyTo], wechaty will mention the contact as well. + * + * @param {(string | MediaMessage)} textOrMedia - Send `text` or `media file` inside Room. + * @param {(Contact | Contact[])} [replyTo] - Optional parameter, send content inside Room, and mention @replyTo contact or contactList. + * @returns {Promise} + * If bot send message successfully, it will return true. If the bot failed to send for blocking or any other reason, it will return false + * + * @example Send text inside Room + * const room = await Room.find({name: 'wechaty'}) // change 'wechaty' to any of your room in wechat + * await room.say('Hello world!') + * + * @example Send media file inside Room + * const room = await Room.find({name: 'wechaty'}) // change 'wechaty' to any of your room in wechat + * await room.say(new MediaMessage('/test.jpg')) // put the filePath you want to send here + * + * @example Send text inside Room, and mention @replyTo contact + * const contact = await Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any of the room member + * const room = await Room.find({name: 'wechaty'}) // change 'wechaty' to any of your room in wechat + * await room.say('Hello world!', contact) + */ + public say(textOrMedia: string | MediaMessage, replyTo?: Contact|Contact[]): Promise { + const content = textOrMedia instanceof MediaMessage ? textOrMedia.filename() : textOrMedia + log.verbose('Room', 'say(%s, %s)', + content, + Array.isArray(replyTo) + ? replyTo.map(c => c.name()).join(', ') + : replyTo ? replyTo.name() : '', + ) + + let m + if (typeof textOrMedia === 'string') { + m = new Message() + m.puppet = this.puppet + + const replyToList: Contact[] = [].concat(replyTo as any || []) + + if (replyToList.length > 0) { + const AT_SEPRATOR = String.fromCharCode(8197) + const mentionList = replyToList.map(c => '@' + c.name()).join(AT_SEPRATOR) + m.content(mentionList + ' ' + content) + } else { + m.content(content) + } + // m.to(replyToList[0]) + } else + m = textOrMedia + + m.room(this) + + return this.puppet // config.puppetInstance() + .send(m) + } + + public on(event: 'leave', listener: (this: Room, leaver: Contact) => void): this + + public on(event: 'join' , listener: (this: Room, inviteeList: Contact[] , inviter: Contact) => void): this + + public on(event: 'topic', listener: (this: Room, topic: string, oldTopic: string, changer: Contact) => void): this + + public on(event: 'EVENT_PARAM_ERROR', listener: () => void): this + + /** + * @desc Room Class Event Type + * @typedef RoomEventName + * @property {string} join - Emit when anyone join any room. + * @property {string} topic - Get topic event, emitted when someone change room topic. + * @property {string} leave - Emit when anyone leave the room.
+ * If someone leaves the room by themselves, wechat will not notice other people in the room, so the bot will never get the "leave" event. + */ + + /** + * @desc Room Class Event Function + * @typedef RoomEventFunction + * @property {Function} room-join - (this: Room, inviteeList: Contact[] , inviter: Contact) => void + * @property {Function} room-topic - (this: Room, topic: string, oldTopic: string, changer: Contact) => void + * @property {Function} room-leave - (this: Room, leaver: Contact) => void + */ + + /** + * @listens Room + * @param {RoomEventName} event - Emit WechatyEvent + * @param {RoomEventFunction} listener - Depends on the WechatyEvent + * @return {this} - this for chain + * + * @example Event:join + * const room = await Room.find({topic: 'event-room'}) // change `event-room` to any room topic in your wechat + * if (room) { + * room.on('join', (room: Room, inviteeList: Contact[], inviter: Contact) => { + * const nameList = inviteeList.map(c => c.name()).join(',') + * console.log(`Room ${room.topic()} got new member ${nameList}, invited by ${inviter}`) + * }) + * } + * + * @example Event:leave + * const room = await Room.find({topic: 'event-room'}) // change `event-room` to any room topic in your wechat + * if (room) { + * room.on('leave', (room: Room, leaverList: Contact[]) => { + * const nameList = leaverList.map(c => c.name()).join(',') + * console.log(`Room ${room.topic()} lost member ${nameList}`) + * }) + * } + * + * @example Event:topic + * const room = await Room.find({topic: 'event-room'}) // change `event-room` to any room topic in your wechat + * if (room) { + * room.on('topic', (room: Room, topic: string, oldTopic: string, changer: Contact) => { + * console.log(`Room ${room.topic()} topic changed from ${oldTopic} to ${topic} by ${changer.name()}`) + * }) + * } + * + */ + public on(event: RoomEventName, listener: (...args: any[]) => any): this { + log.verbose('Room', 'on(%s, %s)', event, typeof listener) + + super.on(event, listener) // Room is `Sayable` + return this + } + + /** + * @private + */ + public get(prop): string { return (this.obj && this.obj[prop]) || (this.dirtyObj && this.dirtyObj[prop]) } + + /** + * @private + */ + private parse(rawObj: RoomRawObj): RoomObj | null { + if (!rawObj) { + log.warn('Room', 'parse() on a empty rawObj?') + return null + } + + const memberList = (rawObj.MemberList || []) + .map(m => { + const c = Contact.load(m.UserName) + c.puppet = this.puppet + return c + }) + + const nameMap = this.parseMap('name', rawObj.MemberList) + const roomAliasMap = this.parseMap('roomAlias', rawObj.MemberList) + const contactAliasMap = this.parseMap('contactAlias', rawObj.MemberList) + + return { + id: rawObj.UserName, + encryId: rawObj.EncryChatRoomId, // ??? + topic: rawObj.NickName, + ownerUin: rawObj.OwnerUin, + memberList, + nameMap, + roomAliasMap, + contactAliasMap, + } + } + + /** + * @private + */ + private parseMap(parseContent: NameType, memberList?: RoomRawMember[]): Map { + const mapList: Map = new Map() + if (memberList && memberList.map) { + memberList.forEach(member => { + let tmpName: string + const contact = Contact.load(member.UserName) + contact.puppet = this.puppet + + switch (parseContent) { + case 'name': + tmpName = contact.name() + break + case 'roomAlias': + tmpName = member.DisplayName + break + case 'contactAlias': + tmpName = contact.alias() || '' + break + default: + throw new Error('parseMap failed, member not found') + } + /** + * ISSUE #64 emoji need to be striped + * ISSUE #104 never use remark name because sys group message will never use that + * @rui: Wrong for 'never use remark name because sys group message will never use that', see more in the latest comment in #104 + * @rui: webwx's NickName here return contactAlias, if not set contactAlias, return name + * @rui: 2017-7-2 webwx's NickName just ruturn name, no contactAlias + */ + mapList[member.UserName] = Misc.stripEmoji(tmpName) + }) + } + return mapList + } + + /** + * @private + */ + public dumpRaw() { + console.error('======= dump raw Room =======') + Object.keys(this.rawObj).forEach(k => console.error(`${k}: ${this.rawObj[k]}`)) + } + + /** + * @private + */ + public dump() { + console.error('======= dump Room =======') + if (!this.obj) { + throw new Error('no this.obj') + } + Object.keys(this.obj).forEach(k => console.error(`${k}: ${this.obj && this.obj[k]}`)) + } + + /** + * Add contact in a room + * + * @param {Contact} contact + * @returns {Promise} + * @example + * const contact = await Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any contact in your wechat + * const room = await Room.find({topic: 'wechat'}) // change 'wechat' to any room topic in your wechat + * if (room) { + * const result = await room.add(contact) + * if (result) { + * console.log(`add ${contact.name()} to ${room.topic()} successfully! `) + * } else{ + * console.log(`failed to add ${contact.name()} to ${room.topic()}! `) + * } + * } + */ + public async add(contact: Contact): Promise { + log.verbose('Room', 'add(%s)', contact) + + if (!contact) { + throw new Error('contact not found') + } + + const n = this.puppet // config.puppetInstance() + .roomAdd(this, contact) + return n + } + + /** + * Delete a contact from the room + * It works only when the bot is the owner of the room + * @param {Contact} contact + * @returns {Promise} + * @example + * const room = await Room.find({topic: 'wechat'}) // change 'wechat' to any room topic in your wechat + * const contact = await Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any room member in the room you just set + * if (room) { + * const result = await room.del(contact) + * if (result) { + * console.log(`remove ${contact.name()} from ${room.topic()} successfully! `) + * } else{ + * console.log(`failed to remove ${contact.name()} from ${room.topic()}! `) + * } + * } + */ + public async del(contact: Contact): Promise { + log.verbose('Room', 'del(%s)', contact.name()) + + if (!contact) { + throw new Error('contact not found') + } + const n = await this.puppet // config.puppetInstance() + .roomDel(this, contact) + .then(_ => this.delLocal(contact)) + return n + } + + /** + * @private + */ + private delLocal(contact: Contact): number { + log.verbose('Room', 'delLocal(%s)', contact) + + const memberList = this.obj && this.obj.memberList + if (!memberList || memberList.length === 0) { + return 0 // already in refreshing + } + + let i + for (i = 0; i < memberList.length; i++) { + if (memberList[i].id === contact.id) { + break + } + } + if (i < memberList.length) { + memberList.splice(i, 1) + return 1 + } + return 0 + } + + /** + * @private + */ + public quit() { + throw new Error('wx web not implement yet') + // WechatyBro.glue.chatroomFactory.quit("@@1c066dfcab4ef467cd0a8da8bec90880035aa46526c44f504a83172a9086a5f7" + } + + public topic(): string + + public topic(newTopic: string): void + + /** + * SET/GET topic from the room + * + * @param {string} [newTopic] If set this para, it will change room topic. + * @returns {(string | void)} + * + * @example When you say anything in a room, it will get room topic. + * const bot = Wechaty.instance() + * bot + * .on('message', async m => { + * const room = m.room() + * if (room) { + * const topic = room.topic() + * console.log(`room topic is : ${topic}`) + * } + * }) + * + * @example When you say anything in a room, it will change room topic. + * const bot = Wechaty.instance() + * bot + * .on('message', async m => { + * const room = m.room() + * if (room) { + * const oldTopic = room.topic() + * room.topic('change topic to wechaty!') + * console.log(`room topic change from ${oldTopic} to ${room.topic()}`) + * } + * }) + */ + public topic(newTopic?: string): string | void { + log.verbose('Room', 'topic(%s)', newTopic ? newTopic : '') + if (!this.isReady()) { + log.warn('Room', 'topic() room not ready') + } + + if (typeof newTopic === 'undefined') { + return Misc.plainText(this.obj ? this.obj.topic : '') + } + + this.puppet // config.puppetInstance() + .roomTopic(this, newTopic) + .catch(e => { + log.warn('Room', 'topic(newTopic=%s) exception: %s', + newTopic, e && e.message || e, + ) + Raven.captureException(e) + }) + + if (!this.obj) { + this.obj = {} + } + this.obj['topic'] = newTopic + return + } + + /** + * should be deprecated + * @private + */ + public nick(contact: Contact): string | null { + log.warn('Room', 'nick(Contact) DEPRECATED, use alias(Contact) instead.') + return this.alias(contact) + } + + /** + * Return contact's roomAlias in the room, the same as roomAlias + * @param {Contact} contact + * @returns {string | null} - If a contact has an alias in room, return string, otherwise return null + * @example + * const bot = Wechaty.instance() + * bot + * .on('message', async m => { + * const room = m.room() + * const contact = m.from() + * if (room) { + * const alias = room.alias(contact) + * console.log(`${contact.name()} alias is ${alias}`) + * } + * }) + */ + public alias(contact: Contact): string | null { + return this.roomAlias(contact) + } + + /** + * Same as function alias + * @param {Contact} contact + * @returns {(string | null)} + */ + public roomAlias(contact: Contact): string | null { + if (!this.obj || !this.obj.roomAliasMap) { + return null + } + return this.obj.roomAliasMap[contact.id] || null + } + + /** + * Check if the room has member `contact`. + * + * @param {Contact} contact + * @returns {boolean} Return `true` if has contact, else return `false`. + * @example Check whether 'lijiarui' is in the room 'wechaty' + * const contact = await Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any of contact in your wechat + * const room = await Room.find({topic: 'wechaty'}) // change 'wechaty' to any of the room in your wechat + * if (contact && room) { + * if (room.has(contact)) { + * console.log(`${contact.name()} is in the room ${room.topic()}!`) + * } else { + * console.log(`${contact.name()} is not in the room ${room.topic()} !`) + * } + * } + */ + public has(contact: Contact): boolean { + if (!this.obj || !this.obj.memberList) { + return false + } + return this.obj.memberList + .filter(c => c.id === contact.id) + .length > 0 + } + + public memberAll(filter: MemberQueryFilter): Contact[] + + public memberAll(name: string): Contact[] + + /** + * The way to search member by Room.member() + * + * @typedef MemberQueryFilter + * @property {string} name -Find the contact by wechat name in a room, equal to `Contact.name()`. + * @property {string} alias -Find the contact by alias set by the bot for others in a room, equal to `roomAlias`. + * @property {string} roomAlias -Find the contact by alias set by the bot for others in a room. + * @property {string} contactAlias -Find the contact by alias set by the contact out of a room, equal to `Contact.alias()`. + * [More Detail]{@link https://github.com/Chatie/wechaty/issues/365} + */ + + /** + * Find all contacts in a room + * + * #### definition + * - `name` the name-string set by user-self, should be called name, equal to `Contact.name()` + * - `roomAlias` | `alias` the name-string set by user-self in the room, should be called roomAlias + * - `contactAlias` the name-string set by bot for others, should be called alias, equal to `Contact.alias()` + * @param {(MemberQueryFilter | string)} queryArg -When use memberAll(name:string), return all matched members, including name, roomAlias, contactAlias + * @returns {Contact[]} + * @memberof Room + */ + public memberAll(queryArg: MemberQueryFilter | string): Contact[] { + if (typeof queryArg === 'string') { + // + // use the following `return` statement to do this job. + // + + // const nameList = this.memberAll({name: queryArg}) + // const roomAliasList = this.memberAll({roomAlias: queryArg}) + // const contactAliasList = this.memberAll({contactAlias: queryArg}) + + // if (nameList) { + // contactList = contactList.concat(nameList) + // } + // if (roomAliasList) { + // contactList = contactList.concat(roomAliasList) + // } + // if (contactAliasList) { + // contactList = contactList.concat(contactAliasList) + // } + + return ([] as Contact[]).concat( + this.memberAll({name: queryArg}), + this.memberAll({roomAlias: queryArg}), + this.memberAll({contactAlias: queryArg}), + ) + } + + /** + * We got filter parameter + */ + log.silly('Room', 'memberAll({ %s })', + Object.keys(queryArg) + .map(k => `${k}: ${queryArg[k]}`) + .join(', '), + ) + + if (Object.keys(queryArg).length !== 1) { + throw new Error('Room member find queryArg only support one key. multi key support is not availble now.') + } + + if (!this.obj || !this.obj.memberList) { + log.warn('Room', 'member() not ready') + return [] + } + const filterKey = Object.keys(queryArg)[0] + /** + * ISSUE #64 emoji need to be striped + */ + const filterValue: string = Misc.stripEmoji(Misc.plainText(queryArg[filterKey])) + + const keyMap = { + contactAlias: 'contactAliasMap', + name: 'nameMap', + alias: 'roomAliasMap', + roomAlias: 'roomAliasMap', + } + + const filterMapName = keyMap[filterKey] + if (!filterMapName) { + throw new Error('unsupport filter key: ' + filterKey) + } + + if (!filterValue) { + throw new Error('filterValue not found') + } + + const filterMap = this.obj[filterMapName] + const idList = Object.keys(filterMap) + .filter(id => filterMap[id] === filterValue) + + log.silly('Room', 'memberAll() check %s from %s: %s', filterValue, filterKey, JSON.stringify(filterMap)) + + if (idList.length) { + return idList.map(id => { + const c = Contact.load(id) + c.puppet = this.puppet + return c + }) + } else { + return [] + } + } + + public member(name: string): Contact | null + + public member(filter: MemberQueryFilter): Contact | null + + /** + * Find all contacts in a room, if get many, return the first one. + * + * @param {(MemberQueryFilter | string)} queryArg -When use member(name:string), return all matched members, including name, roomAlias, contactAlias + * @returns {(Contact | null)} + * + * @example Find member by name + * const room = await Room.find({topic: 'wechaty'}) // change 'wechaty' to any room name in your wechat + * if (room) { + * const member = room.member('lijiarui') // change 'lijiarui' to any room member in your wechat + * if (member) { + * console.log(`${room.topic()} got the member: ${member.name()}`) + * } else { + * console.log(`cannot get member in room: ${room.topic()}`) + * } + * } + * + * @example Find member by MemberQueryFilter + * const room = await Room.find({topic: 'wechaty'}) // change 'wechaty' to any room name in your wechat + * if (room) { + * const member = room.member({name: 'lijiarui'}) // change 'lijiarui' to any room member in your wechat + * if (member) { + * console.log(`${room.topic()} got the member: ${member.name()}`) + * } else { + * console.log(`cannot get member in room: ${room.topic()}`) + * } + * } + */ + public member(queryArg: MemberQueryFilter | string): Contact | null { + log.verbose('Room', 'member(%s)', JSON.stringify(queryArg)) + + let memberList: Contact[] + // ISSUE #622 + // error TS2345: Argument of type 'string | MemberQueryFilter' is not assignable to parameter of type 'MemberQueryFilter' #622 + if (typeof queryArg === 'string') { + memberList = this.memberAll(queryArg) + } else { + memberList = this.memberAll(queryArg) + } + + if (!memberList || !memberList.length) { + return null + } + + if (memberList.length > 1) { + log.warn('Room', 'member(%s) get %d contacts, use the first one by default', JSON.stringify(queryArg), memberList.length) + } + return memberList[0] + } + + /** + * Get all room member from the room + * + * @returns {Contact[]} + */ + public memberList(): Contact[] { + log.verbose('Room', 'memberList') + + if (!this.obj || !this.obj.memberList || this.obj.memberList.length < 1) { + log.warn('Room', 'memberList() not ready') + log.verbose('Room', 'memberList() trying call refresh() to update') + this.refresh().then(() => { + log.verbose('Room', 'memberList() refresh() done') + }) + return [] + } + return this.obj.memberList + } + + /** + * Create a new room. + * + * @static + * @param {Contact[]} contactList + * @param {string} [topic] + * @returns {Promise} + * @example Creat a room with 'lijiarui' and 'juxiaomi', the room topic is 'ding - created' + * const helperContactA = await Contact.find({ name: 'lijiarui' }) // change 'lijiarui' to any contact in your wechat + * const helperContactB = await Contact.find({ name: 'juxiaomi' }) // change 'juxiaomi' to any contact in your wechat + * const contactList = [helperContactA, helperContactB] + * console.log('Bot', 'contactList: %s', contactList.join(',')) + * const room = await Room.create(contactList, 'ding') + * console.log('Bot', 'createDingRoom() new ding room created: %s', room) + * await room.topic('ding - created') + * await room.say('ding - created') + */ + public static create(contactList: Contact[], topic?: string): Promise { + log.verbose('Room', 'create(%s, %s)', contactList.join(','), topic) + + if (!contactList || !Array.isArray(contactList)) { + throw new Error('contactList not found') + } + + return this.puppet // config.puppetInstance() + .roomCreate(contactList, topic) + .catch(e => { + log.error('Room', 'create() exception: %s', e && e.stack || e.message || e) + Raven.captureException(e) + throw e + }) + } + + /** + * Find room by topic, return all the matched room + * + * @static + * @param {RoomQueryFilter} [query] + * @returns {Promise} + * @example + * const roomList = await Room.findAll() // get the room list of the bot + * const roomList = await Room.findAll({name: 'wechaty'}) // find all of the rooms with name 'wechaty' + */ + public static async findAll(query?: RoomQueryFilter): Promise { + if (!query) { + query = { topic: /.*/ } + } + log.verbose('Room', 'findAll({ topic: %s })', query.topic) + + let topicFilter = query.topic + + if (!topicFilter) { + throw new Error('topicFilter not found') + } + + let filterFunction: string + + if (topicFilter instanceof RegExp) { + filterFunction = `(function (c) { return ${topicFilter.toString()}.test(c) })` + } else if (typeof topicFilter === 'string') { + topicFilter = topicFilter.replace(/'/g, '\\\'') + filterFunction = `(function (c) { return c === '${topicFilter}' })` + } else { + throw new Error('unsupport topic type') + } + + const roomList = await this.puppet // config.puppetInstance() + .roomFind(filterFunction) + .catch(e => { + log.verbose('Room', 'findAll() rejected: %s', e.message) + Raven.captureException(e) + return [] as Room[] // fail safe + }) + + await Promise.all(roomList.map(room => room.ready())) + // for (let i = 0; i < roomList.length; i++) { + // await roomList[i].ready() + // } + + return roomList + } + + /** + * Try to find a room by filter: {topic: string | RegExp}. If get many, return the first one. + * + * @param {RoomQueryFilter} query + * @returns {Promise} If can find the room, return Room, or return null + */ + public static async find(query: RoomQueryFilter): Promise { + log.verbose('Room', 'find({ topic: %s })', query.topic) + + const roomList = await this.findAll(query) + if (!roomList || roomList.length < 1) { + return null + } else if (roomList.length > 1) { + log.warn('Room', 'find() got more than one result, return the 1st one.') + } + return roomList[0] + } + + /** + * Force reload data for Room + * + * @returns {Promise} + */ + public async refresh(): Promise { + if (this.isReady()) { + this.dirtyObj = this.obj + } + this.obj = null + await this.ready() + return + } + + /** + * @private + * Get room's owner from the room. + * Not recommend, because cannot always get the owner + * @returns {(Contact | null)} + */ + public owner(): Contact | null { + const ownerUin = this.obj && this.obj.ownerUin + + const user = this.puppet // config.puppetInstance() + .user + + if (user && user.get('uin') === ownerUin) { + return user + } + + if (this.rawObj.ChatRoomOwner) { + const c = Contact.load(this.rawObj.ChatRoomOwner) + c.puppet = this.puppet + return c + } + + log.info('Room', 'owner() is limited by Tencent API, sometimes work sometimes not') + return null + } + + /** + * @private + */ + public static load(id: string): Room { + if (!id) { + throw new Error('Room.load() no id') + } + + if (id in this.pool) { + return this.pool[id] + } + return this.pool[id] = new this(id) + } + +} + +export default Room diff --git a/src/room.ts b/src/room.ts index 70f2dcc3..162b1ec7 100644 --- a/src/room.ts +++ b/src/room.ts @@ -25,69 +25,36 @@ import { } from './config' import Contact from './contact' import { - Message, MediaMessage, } from './message' -import Misc from './misc' import PuppetAccessory from './puppet-accessory' -interface RoomObj { - id: string, - encryId: string, - topic: string, - ownerUin: number, - memberList: Contact[], - nameMap: Map, - roomAliasMap: Map, - contactAliasMap: Map, -} - -type NameType = 'name' | 'alias' | 'roomAlias' | 'contactAlias' - -export interface RoomRawMember { - UserName: string, - NickName: string, - DisplayName: string, -} - -export interface RoomRawObj { - UserName: string, - EncryChatRoomId: string, - NickName: string, - OwnerUin: number, - ChatRoomOwner: string, - MemberList?: RoomRawMember[], -} - export type RoomEventName = 'join' | 'leave' | 'topic' - | 'EVENT_PARAM_ERROR' + | never -export interface RoomQueryFilter { - topic: string | RegExp, -} +export type MemberQueryNameType = 'name' | 'roomAlias' | 'contactAlias' export interface MemberQueryFilter { name?: string, - alias?: string, roomAlias?: string, contactAlias?: string, } +export interface RoomQueryFilter { + topic: string | RegExp, +} + /** * All wechat rooms(groups) will be encapsulated as a Room. * * `Room` is `Sayable`, * [Examples/Room-Bot]{@link https://github.com/Chatie/wechaty/blob/master/examples/room-bot.ts} */ -export class Room extends PuppetAccessory implements Sayable { +export abstract class Room extends PuppetAccessory implements Sayable { private static pool = new Map() - private dirtyObj: RoomObj | null // when refresh, use this to save dirty data for query - private obj: RoomObj | null - private rawObj: RoomRawObj - /** * @private */ @@ -104,100 +71,13 @@ export class Room extends PuppetAccessory implements Sayable { /** * @private */ - public toStringEx() { return `Room(${this.obj && this.obj.topic}[${this.id}])` } - - /** - * @private - */ - public isReady(): boolean { - return !!(this.obj && this.obj.memberList && this.obj.memberList.length) - } - - /** - * @private - */ - private async readyAllMembers(memberList: RoomRawMember[]): Promise { - for (const member of memberList) { - const contact = Contact.load(member.UserName) - contact.puppet = this.puppet - await contact.ready() - } - return - } - - /** - * @private - */ - public async ready(contactGetter?: (id: string) => Promise): Promise { - log.silly('Room', 'ready(%s)', contactGetter ? contactGetter.constructor.name : '') - if (!this.id) { - const e = new Error('ready() on a un-inited Room') - log.warn('Room', e.message) - throw e - } else if (this.isReady()) { - return this - } else if (this.obj && this.obj.id) { - log.verbose('Room', 'ready() is not full loaded in room. reloading', this.obj.topic) - } - - if (!contactGetter) { - contactGetter = this.puppet // config.puppetInstance() - .getContact.bind(/* config.puppetInstance() */ this.puppet) - } - if (!contactGetter) { - throw new Error('no contactGetter') - } - - try { - let ttl = 7 - while (ttl--) { - const roomRawObj = await contactGetter(this.id) as RoomRawObj - - const currNum = roomRawObj.MemberList && roomRawObj.MemberList.length || 0 - const prevNum = this.rawObj && this.rawObj.MemberList && this.rawObj.MemberList.length || 0 - - log.silly('Room', `ready() contactGetter(%s) MemberList.length:%d at ttl:%d`, - this.id, - currNum, - ttl, - ) - - if (currNum) { - if (prevNum === currNum) { - log.verbose('Room', `ready() contactGetter(${this.id}) done at ttl:%d`, ttl) - break - } - this.rawObj = roomRawObj - } - - log.silly('Room', `ready() contactGetter(${this.id}) retry at ttl:%d`, ttl) - await new Promise(r => setTimeout(r, 1000)) // wait for 1 second - } - - await this.readyAllMembers(this.rawObj && this.rawObj.MemberList || []) - this.obj = this.parse(this.rawObj) - if (!this.obj) { - throw new Error('no this.obj set after contactGetter') - } - await Promise.all(this.obj.memberList.map(c => c.ready(contactGetter))) - - return this - - } catch (e) { - log.error('Room', 'contactGetter(%s) exception: %s', this.id, e.message) - Raven.captureException(e) - throw e - } - } - - public say(mediaMessage: MediaMessage) - - public say(content: string) - - public say(content: string, replyTo: Contact) - - public say(content: string, replyTo: Contact[]) + public abstract async ready(): Promise + public abstract say(mediaMessage: MediaMessage) : Promise + public abstract say(content: string) : Promise + public abstract say(content: string, replyTo: Contact) : Promise + public abstract say(content: string, replyTo: Contact[]): Promise + public abstract say(content: never, ...args: never[]) : Promise /** * Send message inside Room, if set [replyTo], wechaty will mention the contact as well. * @@ -219,46 +99,12 @@ export class Room extends PuppetAccessory implements Sayable { * const room = await Room.find({name: 'wechaty'}) // change 'wechaty' to any of your room in wechat * await room.say('Hello world!', contact) */ - public say(textOrMedia: string | MediaMessage, replyTo?: Contact|Contact[]): Promise { - const content = textOrMedia instanceof MediaMessage ? textOrMedia.filename() : textOrMedia - log.verbose('Room', 'say(%s, %s)', - content, - Array.isArray(replyTo) - ? replyTo.map(c => c.name()).join(', ') - : replyTo ? replyTo.name() : '', - ) - - let m - if (typeof textOrMedia === 'string') { - m = new Message() - m.puppet = this.puppet - - const replyToList: Contact[] = [].concat(replyTo as any || []) - - if (replyToList.length > 0) { - const AT_SEPRATOR = String.fromCharCode(8197) - const mentionList = replyToList.map(c => '@' + c.name()).join(AT_SEPRATOR) - m.content(mentionList + ' ' + content) - } else { - m.content(content) - } - // m.to(replyToList[0]) - } else - m = textOrMedia - - m.room(this) - - return this.puppet // config.puppetInstance() - .send(m) - } + public abstract say(textOrMedia: string | MediaMessage, replyTo?: Contact|Contact[]): Promise public on(event: 'leave', listener: (this: Room, leaver: Contact) => void): this - public on(event: 'join' , listener: (this: Room, inviteeList: Contact[] , inviter: Contact) => void): this - public on(event: 'topic', listener: (this: Room, topic: string, oldTopic: string, changer: Contact) => void): this - - public on(event: 'EVENT_PARAM_ERROR', listener: () => void): this + public on(event: never, listener: never): never /** * @desc Room Class Event Type @@ -317,99 +163,6 @@ export class Room extends PuppetAccessory implements Sayable { return this } - /** - * @private - */ - public get(prop): string { return (this.obj && this.obj[prop]) || (this.dirtyObj && this.dirtyObj[prop]) } - - /** - * @private - */ - private parse(rawObj: RoomRawObj): RoomObj | null { - if (!rawObj) { - log.warn('Room', 'parse() on a empty rawObj?') - return null - } - - const memberList = (rawObj.MemberList || []) - .map(m => { - const c = Contact.load(m.UserName) - c.puppet = this.puppet - return c - }) - - const nameMap = this.parseMap('name', rawObj.MemberList) - const roomAliasMap = this.parseMap('roomAlias', rawObj.MemberList) - const contactAliasMap = this.parseMap('contactAlias', rawObj.MemberList) - - return { - id: rawObj.UserName, - encryId: rawObj.EncryChatRoomId, // ??? - topic: rawObj.NickName, - ownerUin: rawObj.OwnerUin, - memberList, - nameMap, - roomAliasMap, - contactAliasMap, - } - } - - /** - * @private - */ - private parseMap(parseContent: NameType, memberList?: RoomRawMember[]): Map { - const mapList: Map = new Map() - if (memberList && memberList.map) { - memberList.forEach(member => { - let tmpName: string - const contact = Contact.load(member.UserName) - contact.puppet = this.puppet - - switch (parseContent) { - case 'name': - tmpName = contact.name() - break - case 'roomAlias': - tmpName = member.DisplayName - break - case 'contactAlias': - tmpName = contact.alias() || '' - break - default: - throw new Error('parseMap failed, member not found') - } - /** - * ISSUE #64 emoji need to be striped - * ISSUE #104 never use remark name because sys group message will never use that - * @rui: Wrong for 'never use remark name because sys group message will never use that', see more in the latest comment in #104 - * @rui: webwx's NickName here return contactAlias, if not set contactAlias, return name - * @rui: 2017-7-2 webwx's NickName just ruturn name, no contactAlias - */ - mapList[member.UserName] = Misc.stripEmoji(tmpName) - }) - } - return mapList - } - - /** - * @private - */ - public dumpRaw() { - console.error('======= dump raw Room =======') - Object.keys(this.rawObj).forEach(k => console.error(`${k}: ${this.rawObj[k]}`)) - } - - /** - * @private - */ - public dump() { - console.error('======= dump Room =======') - if (!this.obj) { - throw new Error('no this.obj') - } - Object.keys(this.obj).forEach(k => console.error(`${k}: ${this.obj && this.obj[k]}`)) - } - /** * Add contact in a room * @@ -427,17 +180,7 @@ export class Room extends PuppetAccessory implements Sayable { * } * } */ - public async add(contact: Contact): Promise { - log.verbose('Room', 'add(%s)', contact) - - if (!contact) { - throw new Error('contact not found') - } - - const n = this.puppet // config.puppetInstance() - .roomAdd(this, contact) - return n - } + public abstract async add(contact: Contact): Promise /** * Delete a contact from the room @@ -456,53 +199,15 @@ export class Room extends PuppetAccessory implements Sayable { * } * } */ - public async del(contact: Contact): Promise { - log.verbose('Room', 'del(%s)', contact.name()) - - if (!contact) { - throw new Error('contact not found') - } - const n = await this.puppet // config.puppetInstance() - .roomDel(this, contact) - .then(_ => this.delLocal(contact)) - return n - } + public abstract async del(contact: Contact): Promise /** * @private */ - private delLocal(contact: Contact): number { - log.verbose('Room', 'delLocal(%s)', contact) + public abstract quit(): Promise - const memberList = this.obj && this.obj.memberList - if (!memberList || memberList.length === 0) { - return 0 // already in refreshing - } - - let i - for (i = 0; i < memberList.length; i++) { - if (memberList[i].id === contact.id) { - break - } - } - if (i < memberList.length) { - memberList.splice(i, 1) - return 1 - } - return 0 - } - - /** - * @private - */ - public quit() { - throw new Error('wx web not implement yet') - // WechatyBro.glue.chatroomFactory.quit("@@1c066dfcab4ef467cd0a8da8bec90880035aa46526c44f504a83172a9086a5f7" - } - - public topic(): string - - public topic(newTopic: string): void + public abstract topic(): string + public abstract topic(newTopic: string): Promise /** * SET/GET topic from the room @@ -533,40 +238,7 @@ export class Room extends PuppetAccessory implements Sayable { * } * }) */ - public topic(newTopic?: string): string | void { - log.verbose('Room', 'topic(%s)', newTopic ? newTopic : '') - if (!this.isReady()) { - log.warn('Room', 'topic() room not ready') - } - - if (typeof newTopic === 'undefined') { - return Misc.plainText(this.obj ? this.obj.topic : '') - } - - this.puppet // config.puppetInstance() - .roomTopic(this, newTopic) - .catch(e => { - log.warn('Room', 'topic(newTopic=%s) exception: %s', - newTopic, e && e.message || e, - ) - Raven.captureException(e) - }) - - if (!this.obj) { - this.obj = {} - } - this.obj['topic'] = newTopic - return - } - - /** - * should be deprecated - * @private - */ - public nick(contact: Contact): string | null { - log.warn('Room', 'nick(Contact) DEPRECATED, use alias(Contact) instead.') - return this.alias(contact) - } + public abstract topic(newTopic?: string): string | Promise /** * Return contact's roomAlias in the room, the same as roomAlias @@ -584,21 +256,14 @@ export class Room extends PuppetAccessory implements Sayable { * } * }) */ - public alias(contact: Contact): string | null { - return this.roomAlias(contact) - } + public abstract alias(contact: Contact): string | null /** * Same as function alias * @param {Contact} contact * @returns {(string | null)} */ - public roomAlias(contact: Contact): string | null { - if (!this.obj || !this.obj.roomAliasMap) { - return null - } - return this.obj.roomAliasMap[contact.id] || null - } + public abstract roomAlias(contact: Contact): string | null /** * Check if the room has member `contact`. @@ -616,25 +281,16 @@ export class Room extends PuppetAccessory implements Sayable { * } * } */ - public has(contact: Contact): boolean { - if (!this.obj || !this.obj.memberList) { - return false - } - return this.obj.memberList - .filter(c => c.id === contact.id) - .length > 0 - } - - public memberAll(filter: MemberQueryFilter): Contact[] + public abstract has(contact: Contact): boolean - public memberAll(name: string): Contact[] + public abstract memberAll(filter: MemberQueryFilter): Contact[] + public abstract memberAll(name: string): Contact[] /** * The way to search member by Room.member() * * @typedef MemberQueryFilter * @property {string} name -Find the contact by wechat name in a room, equal to `Contact.name()`. - * @property {string} alias -Find the contact by alias set by the bot for others in a room, equal to `roomAlias`. * @property {string} roomAlias -Find the contact by alias set by the bot for others in a room. * @property {string} contactAlias -Find the contact by alias set by the contact out of a room, equal to `Contact.alias()`. * [More Detail]{@link https://github.com/Chatie/wechaty/issues/365} @@ -645,98 +301,16 @@ export class Room extends PuppetAccessory implements Sayable { * * #### definition * - `name` the name-string set by user-self, should be called name, equal to `Contact.name()` - * - `roomAlias` | `alias` the name-string set by user-self in the room, should be called roomAlias + * - `roomAlias` the name-string set by user-self in the room, should be called roomAlias * - `contactAlias` the name-string set by bot for others, should be called alias, equal to `Contact.alias()` * @param {(MemberQueryFilter | string)} queryArg -When use memberAll(name:string), return all matched members, including name, roomAlias, contactAlias * @returns {Contact[]} * @memberof Room */ - public memberAll(queryArg: MemberQueryFilter | string): Contact[] { - if (typeof queryArg === 'string') { - // - // use the following `return` statement to do this job. - // - - // const nameList = this.memberAll({name: queryArg}) - // const roomAliasList = this.memberAll({roomAlias: queryArg}) - // const contactAliasList = this.memberAll({contactAlias: queryArg}) - - // if (nameList) { - // contactList = contactList.concat(nameList) - // } - // if (roomAliasList) { - // contactList = contactList.concat(roomAliasList) - // } - // if (contactAliasList) { - // contactList = contactList.concat(contactAliasList) - // } - - return ([] as Contact[]).concat( - this.memberAll({name: queryArg}), - this.memberAll({roomAlias: queryArg}), - this.memberAll({contactAlias: queryArg}), - ) - } - - /** - * We got filter parameter - */ - log.silly('Room', 'memberAll({ %s })', - Object.keys(queryArg) - .map(k => `${k}: ${queryArg[k]}`) - .join(', '), - ) - - if (Object.keys(queryArg).length !== 1) { - throw new Error('Room member find queryArg only support one key. multi key support is not availble now.') - } + public abstract memberAll(queryArg: MemberQueryFilter | string): Contact[] - if (!this.obj || !this.obj.memberList) { - log.warn('Room', 'member() not ready') - return [] - } - const filterKey = Object.keys(queryArg)[0] - /** - * ISSUE #64 emoji need to be striped - */ - const filterValue: string = Misc.stripEmoji(Misc.plainText(queryArg[filterKey])) - - const keyMap = { - contactAlias: 'contactAliasMap', - name: 'nameMap', - alias: 'roomAliasMap', - roomAlias: 'roomAliasMap', - } - - const filterMapName = keyMap[filterKey] - if (!filterMapName) { - throw new Error('unsupport filter key: ' + filterKey) - } - - if (!filterValue) { - throw new Error('filterValue not found') - } - - const filterMap = this.obj[filterMapName] - const idList = Object.keys(filterMap) - .filter(id => filterMap[id] === filterValue) - - log.silly('Room', 'memberAll() check %s from %s: %s', filterValue, filterKey, JSON.stringify(filterMap)) - - if (idList.length) { - return idList.map(id => { - const c = Contact.load(id) - c.puppet = this.puppet - return c - }) - } else { - return [] - } - } - - public member(name: string): Contact | null - - public member(filter: MemberQueryFilter): Contact | null + public abstract member(name: string): Contact | null + public abstract member(filter: MemberQueryFilter): Contact | null /** * Find all contacts in a room, if get many, return the first one. @@ -766,46 +340,14 @@ export class Room extends PuppetAccessory implements Sayable { * } * } */ - public member(queryArg: MemberQueryFilter | string): Contact | null { - log.verbose('Room', 'member(%s)', JSON.stringify(queryArg)) - - let memberList: Contact[] - // ISSUE #622 - // error TS2345: Argument of type 'string | MemberQueryFilter' is not assignable to parameter of type 'MemberQueryFilter' #622 - if (typeof queryArg === 'string') { - memberList = this.memberAll(queryArg) - } else { - memberList = this.memberAll(queryArg) - } - - if (!memberList || !memberList.length) { - return null - } - - if (memberList.length > 1) { - log.warn('Room', 'member(%s) get %d contacts, use the first one by default', JSON.stringify(queryArg), memberList.length) - } - return memberList[0] - } + public abstract member(queryArg: MemberQueryFilter | string): Contact | null /** * Get all room member from the room * * @returns {Contact[]} */ - public memberList(): Contact[] { - log.verbose('Room', 'memberList') - - if (!this.obj || !this.obj.memberList || this.obj.memberList.length < 1) { - log.warn('Room', 'memberList() not ready') - log.verbose('Room', 'memberList() trying call refresh() to update') - this.refresh().then(() => { - log.verbose('Room', 'memberList() refresh() done') - }) - return [] - } - return this.obj.memberList - } + public abstract memberList(): Contact[] /** * Create a new room. @@ -824,20 +366,21 @@ export class Room extends PuppetAccessory implements Sayable { * await room.topic('ding - created') * await room.say('ding - created') */ - public static create(contactList: Contact[], topic?: string): Promise { + public static async create(contactList: Contact[], topic?: string): Promise { log.verbose('Room', 'create(%s, %s)', contactList.join(','), topic) if (!contactList || !Array.isArray(contactList)) { throw new Error('contactList not found') } - return this.puppet // config.puppetInstance() - .roomCreate(contactList, topic) - .catch(e => { - log.error('Room', 'create() exception: %s', e && e.stack || e.message || e) - Raven.captureException(e) - throw e - }) + try { + const room = await this.puppet.roomCreate(contactList, topic) + return room + } catch (e) { + log.error('Room', 'create() exception: %s', e && e.stack || e.message || e) + Raven.captureException(e) + throw e + } } /** @@ -850,43 +393,26 @@ export class Room extends PuppetAccessory implements Sayable { * const roomList = await Room.findAll() // get the room list of the bot * const roomList = await Room.findAll({name: 'wechaty'}) // find all of the rooms with name 'wechaty' */ - public static async findAll(query?: RoomQueryFilter): Promise { - if (!query) { - query = { topic: /.*/ } - } + public static async findAll( + query: RoomQueryFilter = { topic: /.*/ }, + ): Promise { log.verbose('Room', 'findAll({ topic: %s })', query.topic) - let topicFilter = query.topic - - if (!topicFilter) { + if (!query.topic) { throw new Error('topicFilter not found') } - let filterFunction: string - - if (topicFilter instanceof RegExp) { - filterFunction = `(function (c) { return ${topicFilter.toString()}.test(c) })` - } else if (typeof topicFilter === 'string') { - topicFilter = topicFilter.replace(/'/g, '\\\'') - filterFunction = `(function (c) { return c === '${topicFilter}' })` - } else { - throw new Error('unsupport topic type') - } - - const roomList = await this.puppet // config.puppetInstance() - .roomFind(filterFunction) - .catch(e => { - log.verbose('Room', 'findAll() rejected: %s', e.message) - Raven.captureException(e) - return [] as Room[] // fail safe - }) + try { + const roomList = await this.puppet.roomFindAll(query) + await Promise.all(roomList.map(room => room.ready())) - await Promise.all(roomList.map(room => room.ready())) - // for (let i = 0; i < roomList.length; i++) { - // await roomList[i].ready() - // } + return roomList - return roomList + } catch (e) { + log.verbose('Room', 'findAll() rejected: %s', e.message) + Raven.captureException(e) + return [] as Room[] // fail safe + } } /** @@ -912,14 +438,7 @@ export class Room extends PuppetAccessory implements Sayable { * * @returns {Promise} */ - public async refresh(): Promise { - if (this.isReady()) { - this.dirtyObj = this.obj - } - this.obj = null - await this.ready() - return - } + public abstract async refresh(): Promise /** * @private @@ -927,25 +446,7 @@ export class Room extends PuppetAccessory implements Sayable { * Not recommend, because cannot always get the owner * @returns {(Contact | null)} */ - public owner(): Contact | null { - const ownerUin = this.obj && this.obj.ownerUin - - const user = this.puppet // config.puppetInstance() - .user - - if (user && user.get('uin') === ownerUin) { - return user - } - - if (this.rawObj.ChatRoomOwner) { - const c = Contact.load(this.rawObj.ChatRoomOwner) - c.puppet = this.puppet - return c - } - - log.info('Room', 'owner() is limited by Tencent API, sometimes work sometimes not') - return null - } + public abstract owner(): Contact | null /** * @private @@ -958,7 +459,7 @@ export class Room extends PuppetAccessory implements Sayable { if (id in this.pool) { return this.pool[id] } - return this.pool[id] = new this(id) + return this.pool[id] = new (this as any)(id) } } -- GitLab