Permalink
Find file Copy path
06bef87 Nov 27, 2018
2 contributors

Users who have contributed to this file

@huan @windmemory
1206 lines (992 sloc) 37.5 KB
/**
* Wechaty - https://github.com/chatie/wechaty
*
* @copyright 2016-2018 Huan LI <zixia@zixia.net>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// tslint:disable:arrow-parens
// tslint:disable:max-line-length
// tslint:disable:member-ordering
// tslint:disable:unified-signatures
import { EventEmitter } from 'events'
// import LRU from 'lru-cache'
import normalize from 'normalize-package-data'
import QuickLru from 'quick-lru'
import readPkgUp from 'read-pkg-up'
import {
Constructor,
} from 'clone-class'
import {
FileBox,
} from 'file-box'
import {
callerResolve,
} from 'hot-import'
import {
MemoryCard,
} from 'memory-card'
import {
ThrottleQueue,
} from 'rx-queue'
import {
StateSwitch,
} from 'state-switch'
import {
Watchdog,
WatchdogFood,
} from 'watchdog'
import {
log,
} from './config'
import {
ContactPayload,
ContactPayloadFilterFunction,
ContactQueryFilter,
} from './schemas/contact'
import {
FriendshipPayload,
} from './schemas/friendship'
import {
MessagePayload,
MessagePayloadFilterFunction,
MessageQueryFilter,
MessageType,
} from './schemas/message'
import {
RoomMemberPayload,
RoomMemberQueryFilter,
RoomPayload,
RoomPayloadFilterFunction,
RoomQueryFilter,
} from './schemas/room'
import {
RoomInvitationPayload,
} from './schemas/room-invitation'
import {
UrlLinkPayload,
} from './schemas/url-link'
import {
PuppetEventName,
PuppetOptions,
Receiver,
YOU,
} from './schemas/puppet'
const DEFAULT_WATCHDOG_TIMEOUT = 60
let PUPPET_COUNTER = 0
/**
*
* Puppet Base Class
*
* See: https://github.com/Chatie/wechaty/wiki/Puppet
*
*/
export abstract class Puppet extends EventEmitter {
/**
* Must overwrite by child class to identify their version
*/
public static readonly VERSION: string = '0.0.0'
protected readonly cacheContactPayload : QuickLru<string, ContactPayload>
protected readonly cacheFriendshipPayload : QuickLru<string, FriendshipPayload>
protected readonly cacheMessagePayload : QuickLru<string, MessagePayload>
protected readonly cacheRoomPayload : QuickLru<string, RoomPayload>
protected readonly cacheRoomMemberPayload : QuickLru<string, RoomMemberPayload>
protected readonly state : StateSwitch
protected readonly counter : number
protected memory : MemoryCard
/**
* Login-ed User ID
*/
protected id?: string
private readonly watchdog : Watchdog
/**
* childPkg stores the `package.json` that the NPM module who extends the `Puppet`
*/
private readonly childPkg: undefined | normalize.Package
/**
* Throttle Reset Events
*/
private resetThrottleQueue : ThrottleQueue<string>
/**
*
*
* Constructor
*
*
*/
constructor (
protected options: PuppetOptions = {},
) {
super()
this.counter = PUPPET_COUNTER++
log.verbose('Puppet', 'constructor(%s) #%d', JSON.stringify(options), this.counter)
this.state = new StateSwitch(this.constructor.name, log)
this.memory = new MemoryCard() // dummy memory
this.memory.load() // load here is for testing only
.then(() => log.verbose('Puppet', 'constructor() memory.load() done'))
.catch(e => log.warn('Puppet', 'constructor() memory.load() rejection: %s', e))
/**
* 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
log.verbose('Puppet', 'constructor() watchdog timeout set to %d seconds', timeout)
this.watchdog = new Watchdog(1000 * timeout, 'Puppet')
this.on('watchdog', food => this.watchdog.feed(food))
this.watchdog.on('reset', lastFood => {
const reason = JSON.stringify(lastFood)
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 ThrottleQueue<string>(1000)
// 2.2. handle all `reset` events via the resetThrottleQueue
this.on('reset', reason => {
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 => {
log.silly('Puppet', 'constructor() resetThrottleQueue.subscribe() reason: %s', reason)
this.reset(reason)
})
/**
* 3. Setup LRU Caches
*/
const lruOptions: QuickLru.Options = {
maxSize: 10 * 1000
}
this.cacheContactPayload = new QuickLru<string, ContactPayload>(lruOptions)
this.cacheFriendshipPayload = new QuickLru<string, FriendshipPayload>(lruOptions)
this.cacheMessagePayload = new QuickLru<string, MessagePayload>(lruOptions)
this.cacheRoomPayload = new QuickLru<string, RoomPayload>(lruOptions)
this.cacheRoomMemberPayload = new QuickLru<string, RoomMemberPayload>(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 = callerResolve('.', __filename)
log.verbose('Puppet', 'constructor() childClassPath=%s', childClassPath)
this.childPkg = readPkgUp.sync({ cwd: childClassPath }).pkg
} catch (e) {
throw e
}
if (!this.childPkg) {
throw new Error('Cannot found package.json for Puppet Plugin Module')
}
normalize(this.childPkg)
}
public toString () {
return [
`Puppet#`,
this.counter,
'<',
this.constructor.name,
'>',
'(',
this.memory.name || '',
')',
].join('')
}
/**
* Unref
*/
public unref (): void {
log.verbose('Puppet', 'unref()')
this.watchdog.unref()
}
/**
* @private
*
* For used by Wechaty internal ONLY.
*/
public setMemory (memory: MemoryCard): void {
log.verbose('Puppet', 'setMemory()')
if (this.memory.name) {
throw new Error('puppet has already had a named memory: ' + this.memory.name)
}
this.memory = memory
}
/**
*
*
* Events
*
*
*/
public emit (event: 'dong', data?: string) : boolean
public emit (event: 'error', error: Error) : boolean
public emit (event: 'friendship', friendshipId: string) : boolean
public emit (event: 'login', contactId: string) : boolean
public emit (event: 'logout', contactId: string) : boolean
public emit (event: 'message', messageId: string) : boolean
public emit (event: 'reset', reason: string) : boolean
public emit (event: 'room-join', roomId: string, inviteeIdList: string[], inviterId: string) : boolean
public emit (event: 'room-leave', roomId: string, leaverIdList: string[], remover?: string) : boolean
public emit (event: 'room-topic', roomId: string, newTopic: string, oldTopic: string, changerId: string) : boolean
public emit (event: 'room-invite', roomInvitationId: string) : boolean
public emit (event: 'scan', qrcode: string, status: number, data?: string) : boolean
public emit (event: 'ready') : boolean
// Internal Usage: watchdog
public emit (event: 'watchdog', food: WatchdogFood) : boolean
public emit (event: never, ...args: never[]): never
public emit (
event: PuppetEventName,
...args: any[]
): boolean {
return super.emit(event, ...args)
}
/**
*
*
* Listeners
*
*
*/
public on (event: 'dong', listener: (data?: string) => void) : this
public on (event: 'error', listener: (error: string) => void) : this
public on (event: 'friendship', listener: (friendshipId: string) => void) : this
public on (event: 'login', listener: (contactId: string) => void) : this
public on (event: 'logout', listener: (contactId: string) => void) : this
public on (event: 'message', listener: (messageId: string) => void) : this
public on (event: 'reset', listener: (reason: string) => void) : this
public on (event: 'room-join', listener: (roomId: string, inviteeIdList: string[], inviterId: string) => void) : this
public on (event: 'room-leave', listener: (roomId: string, leaverIdList: string[], removerId?: string) => void) : this
public on (event: 'room-topic', listener: (roomId: string, newTopic: string, oldTopic: string, changerId: string) => void) : this
public on (event: 'room-invite', listener: (roomInvitationId: string) => void) : this
public on (event: 'scan', listener: (qrcode: string, status: number, data?: string) => void) : this
public on (event: 'ready', listener: () => void) : this
// Internal Usage: watchdog
public on (event: 'watchdog', listener: (data: WatchdogFood) => void) : this
public on (event: never, listener: never): never
public on (
event : PuppetEventName,
listener : (...args: any[]) => void,
): this {
super.on(event, listener)
return this
}
/**
*
*
* Start / Stop
*
*
*/
public abstract async start () : Promise<void>
public abstract async stop () : Promise<void>
/**
* reset() Should not be called directly.
* `protected` is for testing, not for the child class.
* should use `emit('reset', 'reason')` instead.
* Huan, July 2018
*/
protected reset (reason: string): void {
log.verbose('Puppet', 'reset(%s)', reason)
if (this.state.off()) {
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 => {
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
*/
protected async login (userId: string): Promise<void> {
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)
}
/**
* Need to be called internaly/externaly when the puppet need to be logouted
* this method will emit a `logout` event,
*
* Note: must set `this.id = undefined` in this function.
*/
public abstract async logout (): Promise<void>
public selfId (): string {
log.verbose('Puppet', 'selfId()')
if (!this.id) {
throw new Error('not logged in, no this.id yet.')
}
return this.id
}
public logonoff (): boolean {
if (this.id) {
return true
} else {
return false
}
}
/**
*
*
* Misc
*
*
*/
/**
* Check whether the puppet is work property.
* @returns `false` if something went wrong
* 'dong' if everything is OK
*/
public abstract ding (data?: string) : void
/**
* Get version from the Puppet Implementation
*/
public version (): string {
if (this.childPkg) {
return this.childPkg.version
}
return '0.0.0'
}
/**
* will be used by semver.satisfied(version, range)
*/
public wechatyVersionRange (strict = false): string {
// 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
}
/**
*
* ContactSelf
*
*/
public abstract async contactSelfQrcode () : Promise<string /*QR Code Value*/>
public abstract async contactSelfName (name: string) : Promise<void>
public abstract async contactSelfSignature (signature: string) : Promise<void>
/**
*
* Contact
*
*/
public abstract async contactAlias (contactId: string) : Promise<string>
public abstract async contactAlias (contactId: string, alias: string | null) : Promise<void>
public abstract async contactAvatar (contactId: string) : Promise<FileBox>
public abstract async contactAvatar (contactId: string, file: FileBox) : Promise<void>
public abstract async contactList () : Promise<string[]>
protected abstract async contactRawPayload (contactId: string) : Promise<any>
protected abstract async contactRawPayloadParser (rawPayload: any) : Promise<ContactPayload>
public async contactRoomList (
contactId: string,
): Promise<string[] /* roomId */> {
log.verbose('Puppet', 'contactRoomList(%s)', contactId)
const roomIdList = await this.roomList()
const roomPayloadList = await Promise.all(
roomIdList.map(
roomId => this.roomPayload(roomId)
)
)
const resultRoomIdList = roomPayloadList
.filter(roomPayload => roomPayload.memberIdList.includes(contactId))
.map(payload => payload.id)
return resultRoomIdList
}
public async contactPayloadDirty (contactId: string): Promise<void> {
log.verbose('Puppet', 'contactPayloadDirty(%s)', contactId)
this.cacheContactPayload.delete(contactId)
}
public async contactSearch (
query? : string | ContactQueryFilter,
searchIdList? : string[],
): Promise<string[]> {
log.verbose('Puppet', 'contactSearch(query=%s, %s)',
JSON.stringify(query),
searchIdList
? `idList.length = ${searchIdList.length}`
: '',
)
if (!searchIdList) {
searchIdList = await this.contactList()
}
log.silly('Puppet', 'contactSearch() searchIdList.length = %d', searchIdList.length)
if (!query) {
return searchIdList
}
if (typeof query === 'string') {
const nameIdList = await this.contactSearch({ name: query } , searchIdList)
const aliasIdList = await this.contactSearch({ alias: query } , searchIdList)
return Array.from(
new Set([
...nameIdList,
...aliasIdList,
])
)
}
const filterFuncion: ContactPayloadFilterFunction = this.contactQueryFilterFactory(query)
const BATCH_SIZE = 16
let batchIndex = 0
const resultIdList: string[] = []
const matchId = async (id: string) => {
try {
/**
* Does LRU cache matter at here?
*/
// const rawPayload = await this.contactRawPayload(id)
// const payload = await this.contactRawPayloadParser(rawPayload)
const payload = await this.contactPayload(id)
if (filterFuncion(payload)) {
return id
}
} catch (e) {
log.silly('Puppet', 'contactSearch() contactPayload exception: %s', e.message)
await 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 = await Promise.all(matchBatchIdFutureList)
const batchSearchIdResultList: string[] = matchBatchIdList.filter(id => !!id) as string[]
resultIdList.push(...batchSearchIdResultList)
batchIndex ++
}
log.silly('Puppet', 'contactSearch() searchContactPayloadList.length = %d', resultIdList.length)
return resultIdList
}
protected contactQueryFilterFactory (
query: ContactQueryFilter,
): ContactPayloadFilterFunction {
log.verbose('Puppet', 'contactQueryFilterFactory(%s)',
JSON.stringify(query),
)
Object.keys(query).forEach(key => {
if (query[key as keyof ContactQueryFilter] === undefined) {
delete query[key as keyof ContactQueryFilter]
}
})
if (Object.keys(query).length !== 1) {
throw new Error('query only support one key. multi key support is not availble now.')
}
const filterKey = Object.keys(query)[0] as keyof ContactQueryFilter
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: undefined | string | RegExp = query[filterKey]
if (!filterValue) {
throw new Error('filterValue not found for filterKey: ' + filterKey)
}
let filterFunction
if (typeof filterValue === 'string') {
filterFunction = (payload: ContactPayload) => filterValue === payload[filterKey]
} else if (filterValue instanceof RegExp) {
filterFunction = (payload: ContactPayload) => !!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.
*/
public async contactValidate (contactId: string) : Promise<boolean> {
log.silly('Puppet', 'contactValidate(%s) base class just return `true`', contactId)
return true
}
protected contactPayloadCache (contactId: string): undefined | ContactPayload {
// 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 {
log.silly('Puppet', 'contactPayload(%s) cache MISS', contactId)
}
return cachedPayload
}
public async contactPayload (
contactId: string,
): Promise<ContactPayload> {
// 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 = await this.contactRawPayload(contactId)
const payload = await this.contactRawPayloadParser(rawPayload)
this.cacheContactPayload.set(contactId, payload)
log.silly('Puppet', 'contactPayload(%s) cache SET', contactId)
return payload
}
/**
*
* Friendship
*
*/
public abstract async friendshipAdd (contactId: string, hello?: string) : Promise<void>
public abstract async friendshipAccept (friendshipId: string) : Promise<void>
protected abstract async friendshipRawPayload (friendshipId: string) : Promise<any>
protected abstract async friendshipRawPayloadParser (rawPayload: any) : Promise<FriendshipPayload>
protected friendshipPayloadCache (friendshipId: string): undefined | FriendshipPayload {
// 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 {
log.silly('Puppet', 'friendshipPayloadCache(%s) cache MISS', friendshipId)
}
return cachedPayload
}
protected async friendshipPayloadDirty (friendshipId: string): Promise<void> {
log.verbose('Puppet', 'friendshipPayloadDirty(%s)', friendshipId)
this.cacheFriendshipPayload.delete(friendshipId)
}
public async friendshipPayload (
friendshipId: string,
): Promise<FriendshipPayload> {
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 = await this.friendshipRawPayload(friendshipId)
const payload = await this.friendshipRawPayloadParser(rawPayload)
this.cacheFriendshipPayload.set(friendshipId, payload)
return payload
}
/**
*
* Message
*
*/
public abstract async messageFile (messageId: string) : Promise<FileBox>
public abstract async messageUrl (messageId: string) : Promise<UrlLinkPayload>
public abstract async messageForward (receiver: Receiver, messageId: string) : Promise<void>
public abstract async messageSendText (receiver: Receiver, text: string, mentionIdList?: string[]) : Promise<void>
public abstract async messageSendContact (receiver: Receiver, contactId: string) : Promise<void>
public abstract async messageSendFile (receiver: Receiver, file: FileBox) : Promise<void>
public abstract async messageSendUrl (receiver: Receiver, urlLinkPayload: UrlLinkPayload) : Promise<void>
protected abstract async messageRawPayload (messageId: string) : Promise<any>
protected abstract async messageRawPayloadParser (rawPayload: any) : Promise<MessagePayload>
protected messagePayloadCache (messageId: string): undefined | MessagePayload {
// 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 {
log.silly('Puppet', 'messagePayloadCache(%s) cache MISS', messageId)
}
return cachedPayload
}
protected async messagePayloadDirty (messageId: string): Promise<void> {
log.verbose('Puppet', 'messagePayloadDirty(%s)', messageId)
this.cacheMessagePayload.delete(messageId)
}
public async messagePayload (
messageId: string,
): Promise<MessagePayload> {
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 = await this.messageRawPayload(messageId)
const payload = await this.messageRawPayloadParser(rawPayload)
this.cacheMessagePayload.set(messageId, payload)
log.silly('Puppet', 'messagePayload(%s) cache SET', messageId)
return payload
}
public messageList (): string[] {
log.verbose('Puppet', 'messageList()')
return [...this.cacheMessagePayload.keys()]
}
public async messageSearch (
query?: MessageQueryFilter,
): Promise<string[] /* Message Id List*/> {
log.verbose('Puppet', 'messageSearch(%s)', JSON.stringify(query))
const allMessageIdList: string[] = this.messageList()
log.silly('Puppet', 'messageSearch() allMessageIdList.length=%d', allMessageIdList.length)
if (!query || Object.keys(query).length <= 0) {
return allMessageIdList
}
const messagePayloadList: MessagePayload[] = await Promise.all(
allMessageIdList.map(
id => this.messagePayload(id)
),
)
const filterFunction = this.messageQueryFilterFactory(query)
const messageIdList = messagePayloadList
.filter(filterFunction)
.map(payload => payload.id)
log.silly('Puppet', 'messageSearch() messageIdList filtered. result length=%d', messageIdList.length)
return messageIdList
}
protected messageQueryFilterFactory (
query: MessageQueryFilter,
): MessagePayloadFilterFunction {
log.verbose('Puppet', 'messageQueryFilterFactory(%s)',
JSON.stringify(query),
)
if (Object.keys(query).length < 1) {
throw new Error('query empty')
}
const filterFunctionList: MessagePayloadFilterFunction[] = []
// TypeScript bug: have to set `undefined | string | RegExp` at here, or the later code type check will get error
const filterKeyList = Object.keys(query) as Array<keyof MessageQueryFilter>
for (const filterKey of filterKeyList) {
const filterValue: undefined | string | MessageType | RegExp = query[filterKey]
if (!filterValue) {
throw new Error('filterValue not found for filterKey: ' + filterKey)
}
let filterFunction: MessagePayloadFilterFunction
if (filterValue instanceof RegExp) {
filterFunction = (payload: MessagePayload) => filterValue.test(payload[filterKey] as string)
} else { // if (typeof filterValue === 'string') {
filterFunction = (payload: MessagePayload) => filterValue === payload[filterKey]
}
filterFunctionList.push(filterFunction)
}
const allFilterFunction: MessagePayloadFilterFunction = payload => filterFunctionList.every(func => func(payload))
return allFilterFunction
}
/**
*
* Room Invitation
*
*/
public abstract async roomInvitationAccept (roomInvitationId: string): Promise<void>
protected abstract async roomInvitationRawPayload (roomInvitationId: string) : Promise<any>
protected abstract async roomInvitationRawPayloadParser (rawPayload: any) : Promise<RoomInvitationPayload>
public async roomInvitationPayload (roomInvitationId: string): Promise<RoomInvitationPayload> {
log.verbose('Puppet', 'roomInvitationPayload(%s)', roomInvitationId)
const rawPayload = await this.roomInvitationRawPayload(roomInvitationId)
const payload = await this.roomInvitationRawPayloadParser(rawPayload)
return payload
}
/**
*
* Room
*
*/
public abstract async roomAdd (roomId: string, contactId: string) : Promise<void>
public abstract async roomAvatar (roomId: string) : Promise<FileBox>
public abstract async roomCreate (contactIdList: string[], topic?: string) : Promise<string>
public abstract async roomDel (roomId: string, contactId: string) : Promise<void>
public abstract async roomQuit (roomId: string) : Promise<void>
public abstract async roomTopic (roomId: string) : Promise<string>
public abstract async roomTopic (roomId: string, topic: string) : Promise<void>
public abstract async roomTopic (roomId: string, topic?: string) : Promise<string | void>
public abstract async roomQrcode (roomId: string) : Promise<string>
public abstract async roomList () : Promise<string[]>
public abstract async roomMemberList (roomId: string) : Promise<string[]>
protected abstract async roomRawPayload (roomId: string) : Promise<any>
protected abstract async roomRawPayloadParser (rawPayload: any) : Promise<RoomPayload>
protected abstract async roomMemberRawPayload (roomId: string, contactId: string) : Promise<any>
protected abstract async roomMemberRawPayloadParser (rawPayload: any) : Promise<RoomMemberPayload>
public abstract async roomAnnounce (roomId: string) : Promise<string>
public abstract async roomAnnounce (roomId: string, text: string) : Promise<void>
public async roomMemberSearch (
roomId : string,
query : (YOU | string) | RoomMemberQueryFilter,
): Promise<string[]> {
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 === YOU) {
return [this.id]
}
/**
* 1. for Text Query
*/
if (typeof query === 'string') {
let contactIdList: string[] = []
contactIdList = contactIdList.concat(
await this.roomMemberSearch(roomId, { roomAlias: query }),
await this.roomMemberSearch(roomId, { name: query }),
await 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 = await this.roomMemberList(roomId)
let idList: string[] = []
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: ContactQueryFilter = {
alias : query.contactAlias,
name : query.name,
}
idList = idList.concat(
await this.contactSearch(
contactQueryFilter,
memberIdList,
),
)
}
const memberPayloadList = await 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
}
public async roomSearch (
query?: RoomQueryFilter,
): Promise<string[] /* Room Id List*/> {
log.verbose('Puppet', 'roomSearch(%s)', JSON.stringify(query))
const allRoomIdList: string[] = await this.roomList()
log.silly('Puppet', 'roomSearch() allRoomIdList.length=%d', allRoomIdList.length)
if (!query || Object.keys(query).length <= 0) {
return allRoomIdList
}
const roomPayloadList: RoomPayload[] = (await Promise.all(
allRoomIdList.map(
async id => {
try {
return await this.roomPayload(id)
} catch (e) {
// compatible with {} payload
log.silly('Puppet', 'roomSearch() roomPayload exception: %s', e.message)
// Remove invalid room id from cache to avoid getting invalid room payload again
await this.roomPayloadDirty(id)
await this.roomMemberPayloadDirty(id)
return {} as any
}
}
),
)).filter(payload => Object.keys(payload).length > 0)
const filterFunction = this.roomQueryFilterFactory(query)
const roomIdList = roomPayloadList
.filter(filterFunction)
.map(payload => payload.id)
log.silly('Puppet', 'roomSearch() roomIdList filtered. result length=%d', roomIdList.length)
return roomIdList
}
protected roomQueryFilterFactory (
query: RoomQueryFilter,
): RoomPayloadFilterFunction {
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] as keyof RoomQueryFilter
if (filterKey !== 'topic') {
throw new Error('query key unknown: ' + filterKey)
}
const filterValue: undefined | string | RegExp = query[filterKey]
if (!filterValue) {
throw new Error('filterValue not found for filterKey: ' + filterKey)
}
let filterFunction: RoomPayloadFilterFunction
if (filterValue instanceof RegExp) {
filterFunction = (payload: RoomPayload) => filterValue.test(payload[filterKey])
} else { // if (typeof filterValue === 'string') {
filterFunction = (payload: RoomPayload) => 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.
*/
public async roomValidate (roomId: string): Promise<boolean> {
log.silly('Puppet', 'roomValidate(%s) base class just return `true`', roomId)
return true
}
protected roomPayloadCache (roomId: string): undefined | RoomPayload {
// 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 {
log.silly('Puppet', 'roomPayloadCache(%s) cache MISS', roomId)
}
return cachedPayload
}
public async roomPayloadDirty (roomId: string): Promise<void> {
log.verbose('Puppet', 'roomPayloadDirty(%s)', roomId)
this.cacheRoomPayload.delete(roomId)
}
public async roomPayload (
roomId: string,
): Promise<RoomPayload> {
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 = await this.roomRawPayload(roomId)
const payload = await this.roomRawPayloadParser(rawPayload)
this.cacheRoomPayload.set(roomId, payload)
log.silly('Puppet', 'roomPayload(%s) cache SET', roomId)
return payload
}
/**
* Concat roomId & contactId to one string
*/
private cacheKeyRoomMember (
roomId : string,
contactId : string,
): string {
return contactId + '@@@' + roomId
}
public async roomMemberPayloadDirty (roomId: string): Promise<void> {
log.verbose('Puppet', 'roomMemberPayloadDirty(%s)', roomId)
const contactIdList = await this.roomMemberList(roomId)
let cacheKey
contactIdList.forEach(contactId => {
cacheKey = this.cacheKeyRoomMember(roomId, contactId)
this.cacheRoomMemberPayload.delete(cacheKey)
})
}
public async roomMemberPayload (
roomId : string,
contactId : string,
): Promise<RoomMemberPayload> {
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 = await this.roomMemberRawPayload(roomId, contactId)
const payload = await this.roomMemberRawPayloadParser(rawPayload)
this.cacheRoomMemberPayload.set(CACHE_KEY, payload)
log.silly('Puppet', 'roomMemberPayload(%s) cache SET', roomId)
return payload
}
}
export type PuppetImplementation = typeof Puppet & Constructor<Puppet>
export default Puppet