@juzi/wechaty
Version:
Wechaty is a RPA SDK for Chatbot Makers.
747 lines (660 loc) • 28.5 kB
text/typescript
import * as PUPPET from '@juzi/wechaty-puppet'
import { log } from '@juzi/wechaty-puppet'
import {
GError,
timeoutPromise,
TimeoutPromiseGError,
} from 'gerror'
import {
StateSwitch,
BooleanIndicator,
StateSwitchInterface,
} from 'state-switch'
import { config, PUPPET_PAYLOAD_SYNC_GAP, PUPPET_PAYLOAD_SYNC_MAX_RETRY } from '../config.js'
import { timestampToDate } from '../pure-functions/timestamp-to-date.js'
import type {
ContactImpl,
ContactInterface,
MessageImpl,
RoomImpl,
TagGroupInterface,
TagInterface,
} from '../user-modules/mod.js'
import type {
WechatifyUserModuleMixin,
} from './wechatify-user-module-mixin.js'
import type { GErrorMixin } from './gerror-mixin.js'
import type { IoMixin } from './io-mixin.js'
import { checkUntilChanged } from '../pure-functions/retry-policy.js'
import { ScanType } from '@juzi/wechaty-puppet/types'
const PUPPET_MEMORY_NAME = 'puppet'
/**
* Huan(202111): `puppetMixin` must extend `pluginMixin`
* because the `wechaty-redux` plugin need to be installed before
* the puppet started
*
* Huan(20211128): `puppetMixin` must extend `IoMixin`
* because the Io need the puppet instance to be ready when it starts
*/
const puppetMixin = <MixinBase extends WechatifyUserModuleMixin & GErrorMixin & IoMixin> (mixinBase: MixinBase) => {
log.verbose('WechatyPuppetMixin', 'puppetMixin(%s)', mixinBase.name)
abstract class PuppetMixin extends mixinBase {
__puppet?: PUPPET.impls.PuppetInterface
get puppet (): PUPPET.impls.PuppetInterface {
if (!this.__puppet) {
throw new Error('NOPUPPET')
}
return this.__puppet
}
readonly __readyState : StateSwitchInterface
__loginIndicator: BooleanIndicator
__puppetMixinInited = false
constructor (...args: any[]) {
log.verbose('WechatyPuppetMixin', 'construct()')
super(...args)
this.__readyState = new StateSwitch('WechatyReady', { log })
this.__loginIndicator = new BooleanIndicator()
this.on('login', () => {
this.__loginIndicator.value(true)
})
this.on('logout', () => {
this.__loginIndicator.value(false)
})
}
override async start (): Promise<void> {
log.verbose('WechatyPuppetMixin', 'start()')
log.verbose('WechatyPuppetMixin', 'start() super.start() ...')
await super.start()
log.verbose('WechatyPuppetMixin', 'start() super.start() ... done')
try {
/**
* reset the `wechaty.ready()` state
* if it was previous set to `active`
*/
if (this.__readyState.active()) {
this.__readyState.inactive(true)
}
try {
log.verbose('WechatyPuppetMixin', 'start() starting puppet ...')
await timeoutPromise(
this.puppet.start(),
15 * 1000, // 15 seconds timeout
)
log.verbose('WechatyPuppetMixin', 'start() starting puppet ... done')
} catch (e) {
if (e instanceof TimeoutPromiseGError) {
/**
* Huan(202111):
*
* We should throw the Timeout error when the puppet.start() can not be finished in time.
* However, we need to compatible with some buggy puppet implementations which will not resolve the promise.
*
* TODO: throw the Timeout error when the puppet.start() can not be finished in time.
*
* e.g. after resolve @issue https://github.com/padlocal/wechaty-puppet-padlocal/issues/116
*/
log.warn('WechatyPuppetMixin', 'start() starting puppet ... timeout')
log.warn('WechatyPuppetMixin', 'start() puppet info: %s', this.puppet)
} else {
throw e
}
}
} catch (e) {
this.emitError(e)
}
}
override async stop (): Promise<void> {
log.verbose('WechatyPuppetMixin', 'stop()')
try {
log.verbose('WechatyPuppetMixin', 'stop() stopping puppet ...')
await timeoutPromise(
this.puppet.stop(),
15 * 1000, // 15 seconds timeout
)
log.verbose('WechatyPuppetMixin', 'stop() stopping puppet ... done')
} catch (e) {
if (e instanceof TimeoutPromiseGError) {
log.warn('WechatyPuppetMixin', 'stop() stopping puppet ... timeout')
log.warn('WechatyPuppetMixin', 'stop() puppet info: %s', this.puppet)
}
this.emitError(e)
}
log.verbose('WechatyPuppetMixin', 'stop() super.stop() ...')
await super.stop()
log.verbose('WechatyPuppetMixin', 'stop() super.stop() ... done')
}
async ready (): Promise<void> {
log.verbose('WechatyPuppetMixin', 'ready()')
await this.__readyState.stable('active')
log.silly('WechatyPuppetMixin', 'ready() this.readyState.stable(on) resolved')
}
override async init (): Promise<void> {
log.verbose('WechatyPuppetMixin', 'init()')
await super.init()
if (this.__puppetMixinInited) {
log.verbose('WechatyPuppetMixin', 'init() skipped because this puppet has already been inited before.')
return
}
this.__puppetMixinInited = true
log.verbose('WechatyPuppetMixin', 'init() instanciating puppet instance ...')
const puppetInstance = await PUPPET.helpers.resolvePuppet({
puppet: this.__options.puppet || config.systemPuppetName(),
puppetOptions: 'puppetOptions' in this.__options
? this.__options.puppetOptions
: undefined,
})
log.verbose('WechatyPuppetMixin', 'init() instanciating puppet instance ... done')
/**
* Plug the Memory Card to Puppet
*/
log.verbose('WechatyPuppetMixin', 'init() setting memory ...')
const puppetMemory = this.memory.multiplex(PUPPET_MEMORY_NAME)
puppetInstance.setMemory(puppetMemory)
log.verbose('WechatyPuppetMixin', 'init() setting memory ... done')
/**
* Propagate Puppet Events to Wechaty
*/
log.verbose('WechatyPuppetMixin', 'init() setting up events ...')
this.__setupPuppetEvents(puppetInstance)
log.verbose('WechatyPuppetMixin', 'init() setting up events ... done')
/**
* Private Event
* - Huan(202005): emit puppet when set
* - Huan(202110): @see https://github.com/wechaty/redux/blob/16af0ae01f72e37f0ee286b49fa5ccf69850323d/src/wechaty-redux.ts#L82-L98
*/
log.verbose('WechatyPuppetMixin', 'init() emitting "puppet" event ...')
;(this.emit as any)('puppet', puppetInstance)
log.verbose('WechatyPuppetMixin', 'init() emitting "puppet" event ... done')
this.__puppet = puppetInstance
}
__setupPuppetEvents (puppet: PUPPET.impls.PuppetInterface): void {
log.verbose('WechatyPuppetMixin', '__setupPuppetEvents(%s)', puppet)
const eventNameList: PUPPET.types.PuppetEventName[] = Object.keys(PUPPET.types.PUPPET_EVENT_DICT) as PUPPET.types.PuppetEventName[]
for (const eventName of eventNameList) {
log.verbose('PuppetMixin',
'__setupPuppetEvents() puppet.on(%s) (listenerCount:%s) registering...',
eventName,
puppet.listenerCount(eventName),
)
switch (eventName) {
case 'dong':
puppet.on('dong', payload => {
this.emit('dong', payload.data)
})
break
case 'error':
puppet.on('error', payload => {
/**
* Huan(202112):
* 1. remove `payload.data` after it has been sunset (after Dec 31, 2022)
* 2. throw error if `payload.gerror` is not exists (for enforce puppet strict follow the error event schema)
*/
this.emit('error', GError.from(payload.gerror || payload.data || payload))
})
break
case 'heartbeat':
puppet.on('heartbeat', payload => {
/**
* Use `watchdog` event from Puppet to `heartbeat` Wechaty.
*/
// TODO: use a throttle queue to prevent beat too fast.
this.emit('heartbeat', payload.data)
})
break
case 'friendship':
puppet.on('friendship', async payload => {
const friendship = this.Friendship.load(payload.friendshipId)
try {
await friendship.ready()
await friendship.contact().sync()
this.emit('friendship', friendship)
friendship.contact().emit('friendship', friendship)
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'login':
puppet.on('login', async payload => {
try {
const contact = await this.ContactSelf.find({ id: payload.contactId })
if (!contact) {
throw new Error('no contact found for id: ' + payload.contactId)
}
this.emit('login', contact)
const readyTimeout = setTimeout(() => {
if (this.puppet.readyIndicator.value()) {
this.emit('ready')
}
}, 15 * 1000)
puppet.once('ready', () => {
// if we got ready from puppet, we don't have to fire it here.
// it will be fired by ready listener
clearTimeout(readyTimeout)
})
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'logout':
puppet.on('logout', async payload => {
try {
this.__readyState.inactive(true)
const contact = await this.ContactSelf.find({ id: payload.contactId })
if (contact) {
this.emit('logout', contact, payload.data)
} else {
log.verbose('PuppetMixin',
'__setupPuppetEvents() logout event contact self not found for id: %s',
payload.contactId,
)
}
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'message':
puppet.on('message', async payload => {
try {
const msg = await this.Message.find({ id: payload.messageId })
if (!msg) {
this.emit('error', GError.from('message not found for id: ' + payload.messageId))
return
}
this.emit('message', msg)
const room = msg.room()
const listener = msg.listener()
if (room) {
room.emit('message', msg)
} else if (listener) {
listener.emit('message', msg)
} else {
this.emit('error', GError.from('message without room and listener'))
}
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'post':
puppet.on('post', async payload => {
try {
const post = await this.Post.find({ id: payload.postId })
if (!post) {
this.emit('error', GError.from('post not found for id: ' + payload.postId))
return
}
this.emit('post', post)
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'post-comment':
puppet.on('post-comment', async payload => {
try {
const comment = await this.Post.find({ id: payload.commentId })
const post = await this.Post.find({ id: payload.postId })
if (!post) {
this.emit('error', GError.from('post not found for id: ' + payload.postId))
return
}
if (!comment) {
this.emit('error', GError.from('comment not found for id: ' + payload.commentId))
return
}
this.emit('post-comment', comment, post)
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'post-tap':
puppet.on('post-tap', async payload => {
try {
const post = await this.Post.find({ id: payload.postId })
const contact = await this.Contact.find({ id: payload.contactId })
const date = timestampToDate(payload.timestamp)
if (!post) {
this.emit('error', GError.from('post not found for id: ' + payload.postId))
return
}
if (!contact) {
this.emit('error', GError.from('contact not found for id: ' + payload.contactId))
return
}
this.emit('post-tap', post, contact, payload.tapType, payload.tap, date)
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'ready':
puppet.on('ready', async () => {
log.silly('WechatyPuppetMixin', '__setupPuppetEvents() puppet.on(ready)')
// ready event should be emitted 15s after login
let onceLogout: () => void
let timeout: ReturnType<typeof setTimeout> // 'NodeJS' is not defined.
const future = new Promise((resolve, reject) => {
onceLogout = () => {
reject(new Error('puppet logout!'))
}
puppet.once('logout', onceLogout)
timeout = setTimeout(() => {
reject(new Error('waiting for login timeout'))
}, 60 * 1000)
void this.__loginIndicator.ready(true).then(resolve)
}).finally(() => {
puppet.off('logout', onceLogout)
clearTimeout(timeout)
})
try {
await future
await new Promise(resolve => {
setTimeout(resolve, 15 * 1000)
})
if (this.__loginIndicator.value()) {
this.emit('ready')
this.__readyState.active(true)
}
} catch (e) {
log.error(`ready error: ${(e as Error).message}, will emit event anyway if it's logged in now`)
if (this.puppet.isLoggedIn) {
this.emit('ready')
this.__loginIndicator.value(true)
this.__readyState.active(true)
}
}
})
break
case 'room-invite':
puppet.on('room-invite', async payload => {
const roomInvitation = this.RoomInvitation.load(payload.roomInvitationId)
this.emit('room-invite', roomInvitation)
})
break
case 'room-join':
puppet.on('room-join', async payload => {
try {
const room = await this.Room.find({ id: payload.roomId })
if (!room) {
throw new Error('no room found for id: ' + payload.roomId)
}
await room.sync()
const inviteeListAll = await Promise.all(
payload.inviteeIdList.map(id => this.Contact.find({ id })),
)
const inviteeList = inviteeListAll.filter(c => !!c) as ContactInterface[]
const inviter = await this.Contact.find({ id: payload.inviterId })
if (!inviter) {
throw new Error('no inviter found for id: ' + payload.inviterId)
}
const date = timestampToDate(payload.timestamp)
this.emit('room-join', room, inviteeList, inviter, date)
room.emit('join', inviteeList, inviter, date)
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'room-leave':
puppet.on('room-leave', async payload => {
try {
const room = await this.Room.find({ id: payload.roomId })
if (!room) {
throw new Error('no room found for id: ' + payload.roomId)
}
/**
* See: https://github.com/wechaty/wechaty/pull/1833
*/
await room.sync()
const leaverListAll = await Promise.all(
payload.removeeIdList.map(id => this.Contact.find({ id })),
)
const leaverList = leaverListAll.filter(c => !!c) as ContactInterface[]
const remover = await this.Contact.find({ id: payload.removerId })
if (!remover) {
throw new Error('no remover found for id: ' + payload.removerId)
}
const date = timestampToDate(payload.timestamp)
this.emit('room-leave', room, leaverList, remover, date)
room.emit('leave', leaverList, remover, date)
// issue #254
if (payload.removeeIdList.includes(puppet.currentUserId)) {
await puppet.roomPayloadDirty(payload.roomId)
await puppet.roomMemberPayloadDirty(payload.roomId)
}
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'room-topic':
puppet.on('room-topic', async payload => {
try {
const room = await this.Room.find({ id: payload.roomId })
if (!room) {
throw new Error('no room found for id: ' + payload.roomId)
}
await room.sync()
const changer = await this.Contact.find({ id: payload.changerId })
if (!changer) {
throw new Error('no changer found for id: ' + payload.changerId)
}
const date = timestampToDate(payload.timestamp)
this.emit('room-topic', room, payload.newTopic, payload.oldTopic, changer, date)
room.emit('topic', payload.newTopic, payload.oldTopic, changer, date)
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'room-announce':
puppet.on('room-announce', async payload => {
try {
const room = await this.Room.find({ id: payload.roomId })
if (!room) {
throw new Error('no room found for id: ' + payload.roomId)
}
await room.sync()
let changer: ContactInterface | undefined
try {
if (payload.changerId) {
changer = await this.Contact.find({ id: payload.changerId })
}
} catch (e) {
log.warn('room-announce', 'room-announce event error: %s', (e as Error).message)
}
const date = timestampToDate(payload.timestamp)
this.emit('room-announce', room, payload.newAnnounce, changer, payload.oldAnnounce, date)
room.emit('announce', payload.newAnnounce, changer, payload.oldAnnounce, date)
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'scan':
puppet.on('scan', async payload => {
this.__readyState.inactive(true)
const date = timestampToDate(payload.createTimestamp || payload.timestamp || 0)
const expireDate = payload.expireTimestamp ? timestampToDate(payload.expireTimestamp) : undefined
this.emit('scan', payload.qrcode || '', payload.status, payload.data || '', payload.type || ScanType.Unknown, date, expireDate)
})
break
case 'tag':
puppet.on('tag', async payload => {
const date = timestampToDate(payload.timestamp)
switch (payload.type) {
case PUPPET.types.TagEvent.TagCreate: {
const newTagPromises = payload.idList.map(id => this.Tag.find({ id }))
const newTags = await Promise.all(newTagPromises)
this.emit('tag', payload.type, newTags, date)
break
}
case PUPPET.types.TagEvent.TagDelete: {
const deletedTagPromises = payload.idList.map(id => this.Tag.find({ id }))
const deletedTags = await Promise.all(deletedTagPromises)
this.emit('tag', payload.type, deletedTags, date)
// TODO: bind tag-delete to tag instance
break
}
case PUPPET.types.TagEvent.TagRename: {
const renamedTagPromises = payload.idList.map(id => this.Tag.find({ id }))
const renamedTags = (await Promise.all(renamedTagPromises)).filter(tag => !!tag) as TagInterface[]
await Promise.all(renamedTags.map(async tag => {
const oldName = tag.name()
const result = await checkUntilChanged(PUPPET_PAYLOAD_SYNC_GAP, PUPPET_PAYLOAD_SYNC_MAX_RETRY, async () => {
await tag.sync()
return tag.name() === oldName
})
if (!result) {
log.warn('WechatyPuppetMixin', 'tagRenameEvent still get old name after %s retries for tag %s', PUPPET_PAYLOAD_SYNC_MAX_RETRY, tag.id)
}
}))
this.emit('tag', payload.type, renamedTags, date)
// TODO: bind tag-rename to tag instance
break
}
default:
throw new Error('tagEventType ' + payload.type + ' unsupported!')
}
})
break
case 'tag-group':
puppet.on('tag-group', async payload => {
const date = timestampToDate(payload.timestamp)
switch (payload.type) {
case PUPPET.types.TagGroupEvent.TagGroupCreate: {
const newTagGroupPromises = payload.idList.map(id =>
this.TagGroup.find({ id }),
)
const newTagGroups = await Promise.all(newTagGroupPromises)
this.emit('tag-group', payload.type, newTagGroups, date)
break
}
case PUPPET.types.TagGroupEvent.TagGroupDelete: {
const deletedTagGroupPromises = payload.idList.map(id =>
this.TagGroup.find({ id }),
)
const deletedTagGroups = await Promise.all(deletedTagGroupPromises)
this.emit('tag-group', payload.type, deletedTagGroups, date)
break
// TODO: bind tagGroup-delete to tagGroup instance
}
case PUPPET.types.TagGroupEvent.TagGroupRename: {
const renamedTagGroupPromises = payload.idList.map(id =>
this.TagGroup.find({ id }),
)
const renamedTagGroups = (await Promise.all(renamedTagGroupPromises)) as TagGroupInterface[]
await Promise.all(renamedTagGroups.map(async tagGroup => {
const oldName = tagGroup.name()
const result = await checkUntilChanged(PUPPET_PAYLOAD_SYNC_GAP, PUPPET_PAYLOAD_SYNC_MAX_RETRY, async () => {
await tagGroup.sync()
return tagGroup.name() === oldName
})
if (!result) {
log.warn('WechatyPuppetMixin', 'tagGroupRenameEvent still get old name after %s retries for tagGroup %s', PUPPET_PAYLOAD_SYNC_MAX_RETRY, tagGroup.id)
}
}))
this.emit('tag-group', payload.type, renamedTagGroups, date)
// TODO: bind tagGroup-rename to tagGroup instance
break
}
default:
throw new Error('tagGroupEventType ' + payload.type + ' unsupported!')
}
})
break
case 'verify-code':
puppet.on('verify-code', (payload) => {
this.emit('verify-code', payload.id, payload.message || '', payload.scene || PUPPET.types.VerifyCodeScene.UNKNOWN, payload.status || PUPPET.types.VerifyCodeStatus.UNKNOWN)
})
break
case 'reset':
// Do not propagation `reset` event from puppet
break
case 'dirty':
/**
* https://github.com/wechaty/wechaty-puppet-service/issues/43
*/
puppet.on('dirty', async ({ payloadType, payloadId }) => {
try {
switch (payloadType) {
case PUPPET.types.Payload.Contact: {
const contact = await this.Contact.find({ id: payloadId }) as unknown as undefined | ContactImpl
await contact?.ready(true)
break
}
case PUPPET.types.Payload.Room: {
const room = await this.Room.find({ id: payloadId }) as unknown as undefined | RoomImpl
await room?.ready(true)
break
}
case PUPPET.types.Payload.RoomMember: {
if (payloadId.includes(PUPPET.STRING_SPLITTER)) {
break
}
const room = await this.Room.find({ id: payloadId }) as unknown as undefined | RoomImpl
await room?.ready()
break
}
/**
* Huan(202008): noop for the following
*/
case PUPPET.types.Payload.Friendship:
// Friendship has no payload
break
case PUPPET.types.Payload.Message: {
// Message does not need to dirty (?)
const message = await this.Message.find({ id: payloadId }) as unknown as undefined | MessageImpl
await message?.ready(true)
break
}
case PUPPET.types.Payload.Tag:
break
case PUPPET.types.Payload.TagGroup:
break
case PUPPET.types.Payload.Post:
break
case PUPPET.types.Payload.Unspecified:
default:
log.warn('unknown payload type: ' + payloadType)
}
this.emit('dirty', payloadId, payloadType)
} catch (e) {
this.emit('error', GError.from(e))
}
})
break
case 'login-url':
puppet.on('login-url', (payload) => {
this.emit('login-url', payload.url)
})
break
default:
/**
* Check: The eventName here should have the type `never`
*/
throw new Error('eventName ' + eventName + ' unsupported!')
}
}
log.verbose('WechatyPuppetMixin', '__setupPuppetEvents() ... done')
}
}
return PuppetMixin
}
type PuppetMixin = ReturnType<typeof puppetMixin>
type ProtectedPropertyPuppetMixin =
| '__puppet'
| '__readyState'
| '__setupPuppetEvents'
| '__loginIndicator'
export type {
PuppetMixin,
ProtectedPropertyPuppetMixin,
}
export {
puppetMixin,
}