wechaty-puppet
Version:
Abstract Puppet for Wechaty
706 lines • 28.9 kB
JavaScript
"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