UNPKG

wechaty-puppet

Version:
706 lines 28.9 kB
"use strict"; /** * Wechaty - https://github.com/chatie/wechaty * * @copyright 2016-2018 Huan LI <zixia@zixia.net> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); // tslint:disable:arrow-parens // tslint:disable:max-line-length // tslint:disable:member-ordering // tslint:disable:unified-signatures const events_1 = require("events"); const lru_cache_1 = __importDefault(require("lru-cache")); const normalize_package_data_1 = __importDefault(require("normalize-package-data")); const read_pkg_up_1 = __importDefault(require("read-pkg-up")); const hot_import_1 = require("hot-import"); const memory_card_1 = require("memory-card"); const rx_queue_1 = require("rx-queue"); const state_switch_1 = require("state-switch"); const watchdog_1 = require("watchdog"); const config_1 = require("./config"); const puppet_1 = require("./schemas/puppet"); const DEFAULT_WATCHDOG_TIMEOUT = 60; let PUPPET_COUNTER = 0; /** * * Puppet Base Class * * See: https://github.com/Chatie/wechaty/wiki/Puppet * */ class Puppet extends events_1.EventEmitter { /** * * * Constructor * * */ constructor(options = {}) { super(); this.options = options; this.counter = PUPPET_COUNTER++; config_1.log.verbose('Puppet', 'constructor(%s) #%d', JSON.stringify(options), this.counter); this.state = new state_switch_1.StateSwitch(this.constructor.name, config_1.log); this.memory = new memory_card_1.MemoryCard(); // dummy memory /** * 1. Setup Watchdog * puppet implementation class only need to do one thing: * feed the watchdog by `this.emit('watchdog', ...)` */ const timeout = this.options.timeout || DEFAULT_WATCHDOG_TIMEOUT; config_1.log.verbose('Puppet', 'constructor() watchdog timeout set to %d seconds', timeout); this.watchdog = new watchdog_1.Watchdog(1000 * timeout, 'Puppet'); this.on('watchdog', food => this.watchdog.feed(food)); this.watchdog.on('reset', lastFood => { const reason = JSON.stringify(lastFood); config_1.log.silly('Puppet', 'constructor() watchdog.on(reset) reason: %s', reason); this.emit('reset', reason); }); /** * 2. Setup `reset` Event via a 1 second Throttle Queue: */ this.resetThrottleQueue = new rx_queue_1.ThrottleQueue(1000); // 2.2. handle all `reset` events via the resetThrottleQueue this.on('reset', reason => { config_1.log.silly('Puppet', 'constructor() this.on(reset) reason: %s', reason); this.resetThrottleQueue.next(reason); }); // 2.3. call reset() and then ignore the following `reset` event for 1 second this.resetThrottleQueue.subscribe(reason => { config_1.log.silly('Puppet', 'constructor() resetThrottleQueue.subscribe() reason: %s', reason); this.reset(reason); }); /** * 3. Setup LRU Caches */ const lruOptions = { dispose(key, val) { config_1.log.silly('Puppet', 'constructor() lruOptions.dispose(%s, %s)', key, JSON.stringify(val).substr(0, 140)); }, // Sometims a wechat account that join too many rooms // will have over 100,000 Contact Payloads after sync max: 100 * 1000, // length: function (n) { return n * 2}, maxAge: 1000 * 60 * 60, }; this.cacheContactPayload = new lru_cache_1.default(lruOptions); this.cacheFriendshipPayload = new lru_cache_1.default(lruOptions); this.cacheMessagePayload = new lru_cache_1.default(lruOptions); this.cacheRoomPayload = new lru_cache_1.default(lruOptions); this.cacheRoomMemberPayload = new lru_cache_1.default(lruOptions); /** * 4. Load the package.json for Puppet Plugin version range matching * * For: dist/src/puppet/puppet.ts * We need to up 3 times: ../../../package.json */ try { const childClassPath = hot_import_1.callerResolve('.', __filename); config_1.log.verbose('Puppet', 'constructor() childClassPath=%s', childClassPath); this.childPkg = read_pkg_up_1.default.sync({ cwd: childClassPath }).pkg; } catch (e) { throw e; } if (!this.childPkg) { throw new Error('Cannot found package.json for Puppet Plugin Module'); } normalize_package_data_1.default(this.childPkg); } toString() { return [ `Puppet#`, this.counter, '<', this.constructor.name, '>', '(', this.memory.name || '', ')', ].join(''); } /** * Unref */ unref() { config_1.log.verbose('Puppet', 'unref()'); this.watchdog.unref(); } /** * @private * * For used by Wechaty internal ONLY. */ setMemory(memory) { config_1.log.verbose('Puppet', 'setMemory()'); if (this.memory.name) { throw new Error('puppet has already had a memory with name set: ' + this.memory.name); } this.memory = memory; } emit(event, ...args) { return super.emit(event, ...args); } on(event, listener) { super.on(event, listener); return this; } /** * reset() Should not be called directly. * `protected` is for testing, not for the child class. * should use `emit('reset', 'reason')` instead. * Huan, July 2018 */ reset(reason) { config_1.log.verbose('Puppet', 'reset(%s)', reason); if (this.state.off()) { config_1.log.verbose('Puppet', 'reset(%s) state is off(), do nothing.', reason); this.watchdog.sleep(); return; } Promise.resolve() .then(() => this.stop()) .then(() => this.start()) .catch(e => { config_1.log.warn('Puppet', 'reset() exception: %s', e); this.emit('error', e); }); } /** * * * Login / Logout * * */ /** * Need to be called internaly when the puppet is logined. * this method will emit a `login` event */ login(userId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'login(%s)', userId); if (this.id) { throw new Error('must logout first before login again!'); } this.id = userId; // console.log('this.id=', this.id) this.emit('login', userId); }); } selfId() { config_1.log.verbose('Puppet', 'selfId()'); if (!this.id) { throw new Error('not logged in, no this.id yet.'); } return this.id; } logonoff() { if (this.id) { return true; } else { return false; } } /** * Get version from the Puppet Implementation */ version() { if (this.childPkg) { return this.childPkg.version; } return '0.0.0'; } /** * will be used by semver.satisfied(version, range) */ wechatyVersionRange(strict = false) { // FIXME: for development, we use `*` if not set if (strict) { return '^0.16.0'; } return '*'; // TODO: test and uncomment the following codes after promote the `wehcaty-puppet` as a solo NPM module // if (this.pkg.dependencies && this.pkg.dependencies.wechaty) { // throw new Error('Wechaty Puppet Implementation should add `wechaty` from `dependencies` to `peerDependencies` in package.json') // } // if (!this.pkg.peerDependencies || !this.pkg.peerDependencies.wechaty) { // throw new Error('Wechaty Puppet Implementation should add `wechaty` to `peerDependencies`') // } // if (!this.pkg.engines || !this.pkg.engines.wechaty) { // throw new Error('Wechaty Puppet Implementation must define `package.engines.wechaty` for a required Version Range') // } // return this.pkg.engines.wechaty } contactRoomList(contactId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'contactRoomList(%s)', contactId); const roomIdList = yield this.roomList(); const roomPayloadList = yield Promise.all(roomIdList.map(roomId => this.roomPayload(roomId))); const resultRoomIdList = roomPayloadList .filter(roomPayload => roomPayload.memberIdList.includes(contactId)) .map(payload => payload.id); return resultRoomIdList; }); } contactPayloadDirty(contactId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'contactPayloadDirty(%s)', contactId); this.cacheContactPayload.del(contactId); }); } contactSearch(query, searchIdList) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'contactSearch(query=%s, %s)', JSON.stringify(query), searchIdList ? `idList.length = ${searchIdList.length}` : ''); if (!searchIdList) { searchIdList = yield this.contactList(); } config_1.log.silly('Puppet', 'contactSearch() searchIdList.length = %d', searchIdList.length); if (!query) { return searchIdList; } if (typeof query === 'string') { const nameIdList = yield this.contactSearch({ name: query }, searchIdList); const aliasIdList = yield this.contactSearch({ alias: query }, searchIdList); return Array.from(new Set([ ...nameIdList, ...aliasIdList, ])); } const filterFuncion = this.contactQueryFilterFactory(query); const BATCH_SIZE = 16; let batchIndex = 0; const resultIdList = []; const matchId = (id) => __awaiter(this, void 0, void 0, function* () { try { /** * Does LRU cache matter at here? */ // const rawPayload = await this.contactRawPayload(id) // const payload = await this.contactRawPayloadParser(rawPayload) const payload = yield this.contactPayload(id); if (filterFuncion(payload)) { return id; } } catch (e) { config_1.log.silly('Puppet', 'contactSearch() contactPayload exception: %s', e.message); yield this.contactPayloadDirty(id); } return undefined; }); while (BATCH_SIZE * batchIndex < searchIdList.length) { const batchSearchIdList = searchIdList.slice(BATCH_SIZE * batchIndex, BATCH_SIZE * (batchIndex + 1)); const matchBatchIdFutureList = batchSearchIdList.map(matchId); const matchBatchIdList = yield Promise.all(matchBatchIdFutureList); const batchSearchIdResultList = matchBatchIdList.filter(id => !!id); resultIdList.push(...batchSearchIdResultList); batchIndex++; } config_1.log.silly('Puppet', 'contactSearch() searchContactPayloadList.length = %d', resultIdList.length); return resultIdList; }); } contactQueryFilterFactory(query) { config_1.log.verbose('Puppet', 'contactQueryFilterFactory(%s)', JSON.stringify(query)); Object.keys(query).forEach(key => { if (query[key] === undefined) { delete query[key]; } }); if (Object.keys(query).length !== 1) { throw new Error('query only support one key. multi key support is not availble now.'); } const filterKey = Object.keys(query)[0]; if (!/^name|alias$/.test(filterKey)) { throw new Error('key not supported: ' + filterKey); } // TypeScript bug: have to set `undefined | string | RegExp` at here, or the later code type check will get error const filterValue = query[filterKey]; if (!filterValue) { throw new Error('filterValue not found for filterKey: ' + filterKey); } let filterFunction; if (typeof filterValue === 'string') { filterFunction = (payload) => filterValue === payload[filterKey]; } else if (filterValue instanceof RegExp) { filterFunction = (payload) => !!payload[filterKey] && filterValue.test(payload[filterKey]); } else { throw new Error('unsupport filterValue type: ' + typeof filterValue); } return filterFunction; } /** * Check a Contact Id if it's still valid. * For example: talk to the server, and see if it should be deleted in the local cache. */ contactValidate(contactId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.silly('Puppet', 'contactValidate(%s) base class just return `true`', contactId); return true; }); } contactPayloadCache(contactId) { // log.silly('Puppet', 'contactPayloadCache(id=%s) @ %s', contactId, this) if (!contactId) { throw new Error('no id'); } const cachedPayload = this.cacheContactPayload.get(contactId); if (cachedPayload) { // log.silly('Puppet', 'contactPayload(%s) cache HIT', contactId) } else { config_1.log.silly('Puppet', 'contactPayload(%s) cache MISS', contactId); } return cachedPayload; } contactPayload(contactId) { return __awaiter(this, void 0, void 0, function* () { // log.silly('Puppet', 'contactPayload(id=%s) @ %s', contactId, this) if (!contactId) { throw new Error('no id'); } /** * 1. Try to get from cache first */ const cachedPayload = this.contactPayloadCache(contactId); if (cachedPayload) { return cachedPayload; } /** * 2. Cache not found */ const rawPayload = yield this.contactRawPayload(contactId); const payload = yield this.contactRawPayloadParser(rawPayload); this.cacheContactPayload.set(contactId, payload); config_1.log.silly('Puppet', 'contactPayload(%s) cache SET', contactId); return payload; }); } friendshipPayloadCache(friendshipId) { // log.silly('Puppet', 'friendshipPayloadCache(id=%s) @ %s', friendshipId, this) if (!friendshipId) { throw new Error('no id'); } const cachedPayload = this.cacheFriendshipPayload.get(friendshipId); if (cachedPayload) { // log.silly('Puppet', 'friendshipPayloadCache(%s) cache HIT', friendshipId) } else { config_1.log.silly('Puppet', 'friendshipPayloadCache(%s) cache MISS', friendshipId); } return cachedPayload; } friendshipPayloadDirty(friendshipId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'friendshipPayloadDirty(%s)', friendshipId); this.cacheFriendshipPayload.del(friendshipId); }); } friendshipPayload(friendshipId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'friendshipPayload(%s)', friendshipId); if (!friendshipId) { throw new Error('no id'); } /** * 1. Try to get from cache first */ const cachedPayload = this.friendshipPayloadCache(friendshipId); if (cachedPayload) { return cachedPayload; } /** * 2. Cache not found */ const rawPayload = yield this.friendshipRawPayload(friendshipId); const payload = yield this.friendshipRawPayloadParser(rawPayload); this.cacheFriendshipPayload.set(friendshipId, payload); return payload; }); } messagePayloadCache(messageId) { // log.silly('Puppet', 'messagePayloadCache(id=%s) @ %s', messageId, this) if (!messageId) { throw new Error('no id'); } const cachedPayload = this.cacheMessagePayload.get(messageId); if (cachedPayload) { // log.silly('Puppet', 'messagePayloadCache(%s) cache HIT', messageId) } else { config_1.log.silly('Puppet', 'messagePayloadCache(%s) cache MISS', messageId); } return cachedPayload; } messagePayloadDirty(messageId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'messagePayloadDirty(%s)', messageId); this.cacheMessagePayload.del(messageId); }); } messagePayload(messageId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'messagePayload(%s)', messageId); if (!messageId) { throw new Error('no id'); } /** * 1. Try to get from cache first */ const cachedPayload = this.messagePayloadCache(messageId); if (cachedPayload) { return cachedPayload; } /** * 2. Cache not found */ const rawPayload = yield this.messageRawPayload(messageId); const payload = yield this.messageRawPayloadParser(rawPayload); this.cacheMessagePayload.set(messageId, payload); config_1.log.silly('Puppet', 'messagePayload(%s) cache SET', messageId); return payload; }); } roomInvitationPayload(roomInvitationId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'roomInvitationPayload(%s)', roomInvitationId); const rawPayload = yield this.roomInvitationRawPayload(roomInvitationId); const payload = yield this.roomInvitationRawPayloadParser(rawPayload); return payload; }); } roomMemberSearch(roomId, query) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'roomMemberSearch(%s, %s)', roomId, JSON.stringify(query)); if (!this.id) { throw new Error('no puppet.id. need puppet to be login-ed for a search'); } if (!query) { throw new Error('no query'); } /** * 0. for YOU: 'You', '你' in sys message */ if (query === puppet_1.YOU) { return [this.id]; } /** * 1. for Text Query */ if (typeof query === 'string') { let contactIdList = []; contactIdList = contactIdList.concat(yield this.roomMemberSearch(roomId, { roomAlias: query }), yield this.roomMemberSearch(roomId, { name: query }), yield this.roomMemberSearch(roomId, { contactAlias: query })); // Keep the unique id only // https://stackoverflow.com/a/14438954/1123955 // return [...new Set(contactIdList)] return Array.from(new Set(contactIdList)); } /** * 2. for RoomMemberQueryFilter */ const memberIdList = yield this.roomMemberList(roomId); let idList = []; if (query.contactAlias || query.name) { /** * We will only have `alias` or `name` set at here. * One is set, the other will be `undefined` */ const contactQueryFilter = { alias: query.contactAlias, name: query.name, }; idList = idList.concat(yield this.contactSearch(contactQueryFilter, memberIdList)); } const memberPayloadList = yield Promise.all(memberIdList.map(contactId => this.roomMemberPayload(roomId, contactId))); if (query.roomAlias) { idList = idList.concat(memberPayloadList.filter(payload => payload.roomAlias === query.roomAlias).map(payload => payload.id)); } return idList; }); } roomSearch(query) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'roomSearch(%s)', JSON.stringify(query)); const allRoomIdList = yield this.roomList(); config_1.log.silly('Puppet', 'roomSearch() allRoomIdList.length=%d', allRoomIdList.length); if (!query || Object.keys(query).length <= 0) { return allRoomIdList; } const roomPayloadList = (yield Promise.all(allRoomIdList.map((id) => __awaiter(this, void 0, void 0, function* () { try { return yield this.roomPayload(id); } catch (e) { // compatible with {} payload config_1.log.silly('Puppet', 'roomSearch() roomPayload exception: %s', e.message); // Remove invalid room id from cache to avoid getting invalid room payload again yield this.roomPayloadDirty(id); yield this.roomMemberPayloadDirty(id); return {}; } })))).filter(payload => Object.keys(payload).length > 0); const filterFunction = this.roomQueryFilterFactory(query); const roomIdList = roomPayloadList .filter(filterFunction) .map(payload => payload.id); config_1.log.silly('Puppet', 'roomSearch() roomIdList filtered. result length=%d', roomIdList.length); return roomIdList; }); } roomQueryFilterFactory(query) { config_1.log.verbose('Puppet', 'roomQueryFilterFactory(%s)', JSON.stringify(query)); if (Object.keys(query).length !== 1) { throw new Error('query only support one key. multi key support is not availble now.'); } // TypeScript bug: have to set `undefined | string | RegExp` at here, or the later code type check will get error const filterKey = Object.keys(query)[0]; if (filterKey !== 'topic') { throw new Error('query key unknown: ' + filterKey); } const filterValue = query[filterKey]; if (!filterValue) { throw new Error('filterValue not found for filterKey: ' + filterKey); } let filterFunction; if (filterValue instanceof RegExp) { filterFunction = (payload) => filterValue.test(payload[filterKey]); } else { // if (typeof filterValue === 'string') { filterFunction = (payload) => filterValue === payload[filterKey]; } return filterFunction; } /** * Check a Room Id if it's still valid. * For example: talk to the server, and see if it should be deleted in the local cache. */ roomValidate(roomId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.silly('Puppet', 'roomValidate(%s) base class just return `true`', roomId); return true; }); } roomPayloadCache(roomId) { // log.silly('Puppet', 'roomPayloadCache(id=%s) @ %s', roomId, this) if (!roomId) { throw new Error('no id'); } const cachedPayload = this.cacheRoomPayload.get(roomId); if (cachedPayload) { // log.silly('Puppet', 'roomPayloadCache(%s) cache HIT', roomId) } else { config_1.log.silly('Puppet', 'roomPayloadCache(%s) cache MISS', roomId); } return cachedPayload; } roomPayloadDirty(roomId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'roomPayloadDirty(%s)', roomId); this.cacheRoomPayload.del(roomId); }); } roomPayload(roomId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'roomPayload(%s)', roomId); if (!roomId) { throw new Error('no id'); } /** * 1. Try to get from cache first */ const cachedPayload = this.roomPayloadCache(roomId); if (cachedPayload) { return cachedPayload; } /** * 2. Cache not found */ const rawPayload = yield this.roomRawPayload(roomId); const payload = yield this.roomRawPayloadParser(rawPayload); this.cacheRoomPayload.set(roomId, payload); config_1.log.silly('Puppet', 'roomPayload(%s) cache SET', roomId); return payload; }); } /** * Concat roomId & contactId to one string */ cacheKeyRoomMember(roomId, contactId) { return contactId + '@@@' + roomId; } roomMemberPayloadDirty(roomId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'roomMemberPayloadDirty(%s)', roomId); const contactIdList = yield this.roomMemberList(roomId); let cacheKey; contactIdList.forEach(contactId => { cacheKey = this.cacheKeyRoomMember(roomId, contactId); this.cacheRoomMemberPayload.del(cacheKey); }); }); } roomMemberPayload(roomId, contactId) { return __awaiter(this, void 0, void 0, function* () { config_1.log.verbose('Puppet', 'roomMemberPayload(roomId=%s, contactId=%s)', roomId, contactId); if (!roomId || !contactId) { throw new Error('no id'); } /** * 1. Try to get from cache */ const CACHE_KEY = this.cacheKeyRoomMember(roomId, contactId); const cachedPayload = this.cacheRoomMemberPayload.get(CACHE_KEY); if (cachedPayload) { return cachedPayload; } /** * 2. Cache not found */ const rawPayload = yield this.roomMemberRawPayload(roomId, contactId); const payload = yield this.roomMemberRawPayloadParser(rawPayload); this.cacheRoomMemberPayload.set(CACHE_KEY, payload); config_1.log.silly('Puppet', 'roomMemberPayload(%s) cache SET', roomId); return payload; }); } } /** * Must overwrite by child class to identify their version */ Puppet.VERSION = '0.0.0'; exports.Puppet = Puppet; exports.default = Puppet; //# sourceMappingURL=puppet.js.map