UNPKG

@juzi/wechaty

Version:

Wechaty is a RPA SDK for Chatbot Makers.

948 lines (837 loc) 29.6 kB
/** * Wechaty Chatbot SDK - https://github.com/wechaty/wechaty * * @copyright 2016 Huan LI (李卓桓) <https://github.com/huan>, and * Wechaty Contributors <https://github.com/wechaty>. * * 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. * */ import * as PUPPET from '@juzi/wechaty-puppet' import type { FileBoxInterface, } from 'file-box' import { concurrencyExecuter, } from 'rx-queue' import type { Constructor, } from 'clone-class' import { log, } from '../config.js' import { ContactEventEmitter } from '../schemas/mod.js' import { poolifyMixin, wechatifyMixin, validationMixin, } from '../user-mixins/mod.js' import { deliverSayableConversationPuppet, } from '../sayable/mod.js' import type { SayableSayer, Sayable, SayOptionsObject, } from '../sayable/mod.js' import { stringifyFilter } from '../helper-functions/stringify-filter.js' import type { MessageInterface } from './message.js' import type { TagInterface } from './tag.js' import type { ContactSelfImpl } from './contact-self.js' const MixinBase = wechatifyMixin( poolifyMixin( ContactEventEmitter, )<ContactImplInterface>(), ) /** * All wechat contacts(friend) will be encapsulated as a Contact. * [Examples/Contact-Bot]{@link https://github.com/wechaty/wechaty/blob/1523c5e02be46ebe2cc172a744b2fbe53351540e/examples/contact-bot.ts} * * @property {string} id - Get Contact id. * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table) */ class ContactMixin extends MixinBase implements SayableSayer { static Type = PUPPET.types.Contact static Gender = PUPPET.types.ContactGender /** * The way to search Contact * * @typedef ContactQueryFilter * @property {string} name - The name-string set by user-self, should be called name * @property {string} alias - The name-string set by bot for others, should be called alias * [More Detail]{@link https://github.com/wechaty/wechaty/issues/365} */ /** * Try to find a contact by filter: {name: string | RegExp} / {alias: string | RegExp} * * Find contact by name or alias, if the result more than one, return the first one. * * @static * @param {string | ContactQueryFilter} query `string` will search `name` & `alias` * @returns {(Promise<undefined | ContactInterface>)} If can find the contact, return Contact, or return null * @example * const bot = new Wechaty() * await bot.start() * const contactFindByName = await bot.Contact.find({ name:"ruirui"} ) * const contactFindByAlias = await bot.Contact.find({ alias:"lijiarui"} ) */ static async find ( query : string | PUPPET.filters.Contact, ): Promise<undefined | ContactInterface> { log.silly('Contact', 'find(%s)', JSON.stringify(query, stringifyFilter)) if (typeof query === 'object' && query.id) { let contact: ContactImpl if (this.wechaty.puppet.currentUserId === query.id) { /** * When the contact id is the currentUserId, return a ContactSelfImpl as the Contact */ contact = (this.wechaty.ContactSelf as any as typeof ContactSelfImpl).load(query.id) } else { contact = (this.wechaty.Contact as any as typeof ContactImpl).load(query.id) } // const contact = (this.wechaty.Contact as any as typeof ContactImpl).load(query.id) try { await contact.ready() } catch (e) { this.wechaty.emitError(e) return undefined } return contact } const contactList = await this.findAll(query) if (contactList.length <= 0) { return } if (contactList.length > 1) { log.warn('Contact', 'find() got more than 1 result: %d total', contactList.length) } for (const [ idx, contact ] of contactList.entries()) { // use puppet.contactValidate() to confirm double confirm that this contactId is valid. // https://github.com/wechaty/wechaty-puppet-padchat/issues/64 // https://github.com/wechaty/wechaty/issues/1345 const valid = await this.wechaty.puppet.contactValidate(contact.id) if (valid) { log.silly('Contact', 'find() contact<id=%s> is valid, return it', idx, contact.id) return contact } else { log.silly('Contact', 'find() contact<id=%s> is invalid, skip it', idx, contact.id) } } log.warn('Contact', 'find() all of %d contacts are invalid', contactList.length) return undefined } /** * Find contact by `name` or `alias` * * If use Contact.findAll() get the contact list of the bot. * * #### definition * - `name` the name-string set by user-self, should be called name * - `alias` the name-string set by bot for others, should be called alias * * @static * @param {string | ContactQueryFilter} [queryArg] `string` will search `name` & `alias` * @returns {Promise<ContactInterface[]>} * @example * const bot = new Wechaty() * await bot.start() * const contactList = await bot.Contact.findAll() // get the contact list of the bot * const contactList = await bot.Contact.findAll({ name: 'ruirui' }) // find all of the contacts whose name is 'ruirui' * const contactList = await bot.Contact.findAll({ alias: 'lijiarui' }) // find all of the contacts whose alias is 'lijiarui' */ static async findAll ( query? : string | PUPPET.filters.Contact, ): Promise<ContactInterface[]> { log.verbose('Contact', 'findAll(%s)', JSON.stringify(query, stringifyFilter) || '') const contactIdList: string[] = await this.wechaty.puppet.contactSearch(query) let continuousErrorCount = 0 let totalErrorCount = 0 const totalErrorThreshold = Math.round(contactIdList.length / 5) const idToContact = async (id: string) => { if (!this.wechaty.isLoggedIn) { throw new Error('wechaty not logged in') } const result = await this.wechaty.Contact.find({ id }).catch(e => { this.wechaty.emitError(e) continuousErrorCount++ totalErrorCount++ if (continuousErrorCount > 5) { throw new Error('5 continuous errors!') } if (totalErrorCount > totalErrorThreshold) { throw new Error(`${totalErrorThreshold} total errors!`) } }) continuousErrorCount = 0 return result } /** * we need to use concurrencyExecuter to reduce the parallel number of the requests */ const CONCURRENCY = 17 const contactIterator = concurrencyExecuter(CONCURRENCY)(idToContact)(contactIdList) const contactList: ContactInterface[] = [] for await (const contact of contactIterator) { if (contact) { contactList.push(contact) } } return contactList } static async batchLoadContacts (contactIdList: string[]) { if (typeof this.wechaty.puppet.batchContactPayload === 'function') { const contactList: ContactInterface[] = contactIdList.map(id => { if (this.wechaty.puppet.currentUserId === id) { return (this.wechaty.ContactSelf as any as typeof ContactSelfImpl).load(id) } else { return (this.wechaty.Contact as any as typeof ContactImpl).load(id) } }) const needPayloadSet: Set<string> = new Set() for (const contact of contactList) { if (!contact.isReady()) { needPayloadSet.add(contact.id) } } if (needPayloadSet.size > 0) { const payloadMap = await this.wechaty.puppet.batchContactPayload(Array.from(needPayloadSet)) for (const contact of contactList) { contact.payload = payloadMap.get(contact.id) } } return contactList } else { let continuousErrorCount = 0 let totalErrorCount = 0 const totalErrorThreshold = Math.round(contactIdList.length / 5) const idToContact = async (id: string) => { if (!this.wechaty.isLoggedIn) { throw new Error('wechaty not logged in') } const result = await this.wechaty.Contact.find({ id }).catch(e => { this.wechaty.emitError(e) continuousErrorCount++ totalErrorCount++ if (continuousErrorCount > 5) { throw new Error('5 continuous errors!') } if (totalErrorCount > totalErrorThreshold) { throw new Error(`${totalErrorThreshold} total errors!`) } }) continuousErrorCount = 0 return result } /** * we need to use concurrencyExecuter to reduce the parallel number of the requests */ const CONCURRENCY = 17 const contactIterator = concurrencyExecuter(CONCURRENCY)(idToContact)(contactIdList) const contactList: ContactInterface[] = [] for await (const contact of contactIterator) { if (contact) { contactList.push(contact) } } return contactList } } // TODO // eslint-disable-next-line no-use-before-define static async delete (contact: ContactInterface): Promise<void> { log.verbose('Contact', 'static delete(%s)', contact.id) await this.wechaty.puppet.contactDelete(contact.id) } /** * * Instance properties * @ignore * */ payload?: PUPPET.payloads.Contact /** * @hideconstructor */ constructor ( public readonly id: string, ) { super() log.silly('Contact', `constructor(${id})`) } /** * @ignore */ override toString (): string { if (!this.payload) { return this.constructor.name } const identity = this.payload.alias || this.payload.name || this.id || 'loading...' return `Contact<${identity}>` } /** * > Tips: * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table) * * @param {(string | ContactInterface | FileBox | UrlLink | MiniProgram | Location)} sayable * send text, Contact, or file to contact. </br> * You can use {@link https://www.npmjs.com/package/file-box|FileBox} to send file * @returns {Promise<void | MessageInterface>} * @example * const bot = new Wechaty() * await bot.start() * const contact = await bot.Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any of your contact name in wechat * * // 1. send text to contact * * await contact.say('welcome to wechaty!') * const msg = await contact.say('welcome to wechaty!') // only supported by puppet-padplus * * // 2. send media file to contact * * import { FileBox } from 'wechaty' * const fileBox1 = FileBox.fromUrl('https://wechaty.github.io/wechaty/images/bot-qr-code.png') * const fileBox2 = FileBox.fromFile('/tmp/text.txt') * await contact.say(fileBox1) * const msg1 = await contact.say(fileBox1) // only supported by puppet-padplus * await contact.say(fileBox2) * const msg2 = await contact.say(fileBox2) // only supported by puppet-padplus * * // 3. send contact card to contact * * const contactCard = bot.Contact.load('contactId') * const msg = await contact.say(contactCard) // only supported by puppet-padplus * * // 4. send url link to contact * * const urlLink = new UrlLink ({ * description : 'WeChat Bot SDK for Individual Account, Powered by TypeScript, Docker, and Love', * thumbnailUrl: 'https://avatars0.githubusercontent.com/u/25162437?s=200&v=4', * title : 'Welcome to Wechaty', * url : 'https://github.com/wechaty/wechaty', * }) * await contact.say(urlLink) * const msg = await contact.say(urlLink) // only supported by puppet-padplus * * // 5. send mini program to contact * * const miniProgram = new MiniProgram ({ * username : 'gh_xxxxxxx', //get from mp.weixin.qq.com * appid : '', //optional, get from mp.weixin.qq.com * title : '', //optional * pagepath : '', //optional * description : '', //optional * thumbnailurl : '', //optional * }) * await contact.say(miniProgram) * const msg = await contact.say(miniProgram) // only supported by puppet-padplus * * // 6. send location to contact * const location = new Location ({ * accuracy : 15, * address : '北京市北京市海淀区45 Chengfu Rd', * latitude : 39.995120999999997, * longitude : 116.334154, * name : '东升乡人民政府(海淀区成府路45号)', * }) * await contact.say(location) * const msg = await contact.say(location) */ async say ( sayable: Sayable, options?: SayOptionsObject, ): Promise<void | MessageInterface> { log.verbose('Contact', 'say(%s)', sayable) if (options?.mentionList) { log.warn('Contact', 'you cannot mention someone in private conversation!') delete options.mentionList } const msgId = await deliverSayableConversationPuppet(this.wechaty.puppet)(this.id)(sayable, options) if (msgId) { const msg = await this.wechaty.Message.find({ id: msgId }) if (msg) { return msg } } } /** * Get the name from a contact * * @returns {string} * @example * const name = contact.name() */ name (): string { return (this.payload && this.payload.name) || '' } aka (): string { return (this.payload && this.payload.aka) || '' } realName (): string { return (this.payload && this.payload.realName) || '' } async alias () : Promise<undefined | string> async alias (newAlias: string) : Promise<void> async alias (empty: null) : Promise<void> /** * GET / SET / DELETE the alias for a contact * * Tests show it will failed if set alias too frequently(60 times in one minute). * @param {(none | string | null)} newAlias * @returns {(Promise<undefined | string | void>)} * @example <caption> GET the alias for a contact, return {(Promise<string | null>)}</caption> * const alias = await contact.alias() * if (alias === null) { * console.log('You have not yet set any alias for contact ' + contact.name()) * } else { * console.log('You have already set an alias for contact ' + contact.name() + ':' + alias) * } * * @example <caption>SET the alias for a contact</caption> * try { * await contact.alias('lijiarui') * console.log(`change ${contact.name()}'s alias successfully!`) * } catch (e) { * console.log(`failed to change ${contact.name()} alias!`) * } * * @example <caption>DELETE the alias for a contact</caption> * try { * const oldAlias = await contact.alias(null) * console.log(`delete ${contact.name()}'s alias successfully!`) * console.log('old alias is ${oldAlias}`) * } catch (e) { * console.log(`failed to delete ${contact.name()}'s alias!`) * } */ async alias (newAlias?: null | string): Promise<void | undefined | string> { log.silly('Contact', 'alias(%s)', newAlias === undefined ? '' : newAlias, ) if (!this.payload) { throw new Error('no payload') } if (typeof newAlias === 'undefined') { return this.payload.alias } try { await this.wechaty.puppet.contactAlias(this.id, newAlias) await this.wechaty.puppet.contactPayloadDirty(this.id) /** * In normal puppet, the dirty event handler, onDirty, is a sync function, so the contactPayload will get the new payload * However for wechaty-puppet-service, it uses flashstore to cache payloads, and deleting a cache is an async function * So there is a chance contactPayload will still get old contact */ let maxCheck = 10 let changed = false while (maxCheck-- > 0 && !changed) { await new Promise(resolve => { setTimeout(resolve, 300) }) this.payload = await this.wechaty.puppet.contactPayload(this.id) const payloadAlias = this.payload.alias || '' changed = newAlias === payloadAlias } if (!changed) { throw new Error('failed to modify clias, still got old alias after 10 tries') } } catch (e) { this.wechaty.emitError(e) log.error('Contact', 'alias(%s) rejected: %s', newAlias, (e as Error).message) } } /** * GET / SET / DELETE the phone list for a contact * * @param {(none | string[])} phoneList * @returns {(Promise<string[] | void>)} * @example <caption> GET the phone list for a contact, return {(Promise<string[]>)}</caption> * const phoneList = await contact.phone() * if (phone.length === 0) { * console.log('You have not yet set any phone number for contact ' + contact.name()) * } else { * console.log('You have already set phone numbers for contact ' + contact.name() + ':' + phoneList.join(',')) * } * * @example <caption>SET the phoneList for a contact</caption> * try { * const phoneList = ['13999999999', '13888888888'] * await contact.alias(phoneList) * console.log(`change ${contact.name()}'s phone successfully!`) * } catch (e) { * console.log(`failed to change ${contact.name()} phone!`) * } */ async phone (): Promise<string[]> async phone (phoneList: string[]): Promise<void> async phone (phoneList?: string[]): Promise<string[] | void> { log.silly('Contact', 'phone(%s)', phoneList === undefined ? '' : JSON.stringify(phoneList)) if (!this.payload) { throw new Error('no payload') } if (typeof phoneList === 'undefined') { return this.payload.phone } try { await this.wechaty.puppet.contactPhone(this.id, phoneList) await this.wechaty.puppet.contactPayloadDirty(this.id) this.payload = await this.wechaty.puppet.contactPayload(this.id) } catch (e) { this.wechaty.emitError(e) log.error('Contact', 'phone(%s) rejected: %s', JSON.stringify(phoneList), (e as Error).message) } } async corporation (): Promise<undefined | string> async corporation (remark: string | null): Promise<void> async corporation (remark?: string | null): Promise<void | undefined | string> { log.silly('Contact', 'corporation(%s)', remark) if (!this.payload) { throw new Error('no payload') } if (typeof remark === 'undefined') { return this.payload.corporation } if (this.payload.type !== PUPPET.types.Contact.Individual) { throw new Error('Can not set corporation remark on non individual contact.') } try { await this.wechaty.puppet.contactCorporationRemark(this.id, remark) await this.wechaty.puppet.contactPayloadDirty(this.id) this.payload = await this.wechaty.puppet.contactPayload(this.id) } catch (e) { this.wechaty.emitError(e) log.error('Contact', 'corporation(%s) rejected: %s', remark, (e as Error).message) } } async description (): Promise<undefined | string> async description (newDescription: string | null): Promise<void> async description (newDescription?: string | null): Promise<void | undefined | string> { log.silly('Contact', 'description(%s)', newDescription) if (!this.payload) { throw new Error('no payload') } if (typeof newDescription === 'undefined') { return this.payload.description } try { await this.wechaty.puppet.contactDescription(this.id, newDescription) await this.wechaty.puppet.contactPayloadDirty(this.id) this.payload = await this.wechaty.puppet.contactPayload(this.id) } catch (e) { this.wechaty.emitError(e) log.error('Contact', 'description(%s) rejected: %s', newDescription, (e as Error).message) } } title (): string | null { if (!this.payload) { throw new Error('no payload') } return this.payload.title || null } coworker (): boolean { if (!this.payload) { throw new Error('no payload') } return !!this.payload.coworker } /** * Check if contact is friend * * > Tips: * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table) * * @returns {boolean | null} * * <br>True for friend of the bot <br> * False for not friend of the bot, null for unknown. * @example * const isFriend = contact.friend() */ friend (): undefined | boolean { log.verbose('Contact', 'friend()') return this.payload?.friend } /** * Enum for ContactType * @enum {number} * @property {number} Unknown - ContactType.Unknown (0) for Unknown * @property {number} Personal - ContactType.Personal (1) for Personal * @property {number} Official - ContactType.Official (2) for Official */ /** * Return the type of the Contact * > Tips: ContactType is enum here.</br> * @returns {ContactType.Unknown | ContactType.Personal | ContactType.Official} * * @example * const bot = new Wechaty() * await bot.start() * const isOfficial = contact.type() === bot.Contact.Type.Official */ type (): PUPPET.types.Contact { if (!this.payload) { throw new Error('no payload') } return this.payload.type } /** * @ignore * TODO: Check if the contact is star contact. * * @returns {boolean | null} - True for star friend, False for no star friend. * @example * const isStar = contact.star() */ star (): undefined | boolean { return this.payload?.star } /** * Contact gender * > Tips: ContactGender is enum here. </br> * * @returns {ContactGender.Unknown | ContactGender.Male | ContactGender.Female} * @example * const gender = contact.gender() === bot.Contact.Gender.Male */ gender (): PUPPET.types.ContactGender { return this.payload ? this.payload.gender : PUPPET.types.ContactGender.Unknown } /** * Get the region 'province' from a contact * * @returns {string | null} * @example * const province = contact.province() */ province (): undefined | string { return this.payload?.province } /** * Get the region 'city' from a contact * * @returns {string | null} * @example * const city = contact.city() */ city (): undefined | string { return this.payload?.city } /** * Get avatar picture file stream * * @returns {Promise<FileBox>} * @example * // Save avatar to local file like `1-name.jpg` * * const file = await contact.avatar() * const name = file.name * await file.toFile(name, true) * console.log(`Contact: ${contact.name()} with avatar file: ${name}`) */ async avatar (): Promise<FileBoxInterface> { log.verbose('Contact', 'avatar()') const fileBox = await this.wechaty.puppet.contactAvatar(this.id) return fileBox } /** * Get all tags of contact * * @returns {Promise<TagInterface[]>} * @example * const tags = await contact.tags() */ async tags (): Promise<TagInterface[]> { log.verbose('Contact', 'tags() for %s', this) try { const tagPayloadList = this.payload?.tags || [] const tagListPromises = tagPayloadList.map(tagId => this.wechaty.Tag.find({ id: tagId })) const tagList = await Promise.all(tagListPromises) return tagList.filter(tag => !!tag) as TagInterface[] } catch (e) { this.wechaty.emitError(e) log.error('Contact', 'tags() exception: %s', (e as Error).message) return [] } } /** * Add a Tag */ async tag (tags: TagInterface | TagInterface[]): Promise<void> { log.verbose('Contact', 'tag(%s) for %s', JSON.stringify(tags), this) if (!Array.isArray(tags)) { tags = [ tags ] } const tagIds = tags.map(tag => tag.id) await this.wechaty.puppet.tagContactTagAdd(tagIds, [ this.id ]) } /** * Remove a Tag */ async tagRemove (tags: TagInterface | TagInterface[]): Promise<void> { log.verbose('Contact', 'tagRemove(%s) for %s', JSON.stringify(tags), this) if (!Array.isArray(tags)) { tags = [ tags ] } const tagIds = tags.map(tag => tag.id) await this.wechaty.puppet.tagContactTagRemove(tagIds, [ this.id ]) } /** * Force reload data for Contact, Sync data from low-level API again. * * @returns {Promise<this>} * @example * await contact.sync() */ async sync (): Promise<void> { await this.wechaty.puppet.contactPayloadDirty(this.id) await this.ready(true) } /** * `ready()` is For FrameWork ONLY! * * Please not to use `ready()` at the user land. * If you want to sync data, use `sync()` instead. * * @ignore */ async ready ( forceSync = false, ): Promise<void> { log.silly('Contact', 'ready() @ %s with id="%s"', this.wechaty.puppet, this.id) if (!forceSync && this.isReady()) { // already ready log.silly('Contact', 'ready() isReady() true') return } try { this.payload = await this.wechaty.puppet.contactPayload(this.id) // log.silly('Contact', `ready() this.wechaty.puppet.contactPayload(%s) resolved`, this) } catch (e) { this.wechaty.emitError(e) log.verbose('Contact', 'ready() this.wechaty.puppet.contactPayload(%s) exception: %s', this.id, (e as Error).message, ) throw e } } async readMark (hasRead: boolean): Promise<void> async readMark (): Promise<boolean> /** * Mark the conversation as read * @param { undefined | boolean } hasRead * * @example * const bot = new Wechaty() * const contact = await bot.Contact.find({name: 'xxx'}) * await contact.readMark() */ async readMark (hasRead?: boolean): Promise<void | boolean> { try { if (typeof hasRead === 'undefined') { return this.wechaty.puppet.conversationReadMark(this.id) } else { await this.wechaty.puppet.conversationReadMark(this.id, hasRead) } } catch (e) { this.wechaty.emitError(e) log.error('Contact', 'readMark() exception: %s', (e as Error).message) } } async endConversation (): Promise<void> { try { await this.wechaty.puppet.endConversation(this.id) } catch (e) { this.wechaty.emitError(e) log.error('Contact', 'endConversation() exception: %s', (e as Error).message) } } /** * @ignore */ isReady (): boolean { return !!(this.payload && this.payload.name) } /** * Check if contact is self * * @returns {boolean} True for contact is self, False for contact is others * @example * const isSelf = contact.self() */ self (): boolean { return this.id === this.wechaty.puppet.currentUserId } /** * Get the handle from a contact. * * > A Twitter handle is the username that appears at the end of your unique Twitter URL. * * Sometimes cannot get handle due to the puppet implementation. * * @ignore * @returns {string | null} * @example * const handle = contact.handle() */ handle (): undefined | string { return this.payload?.handle } /** * Huan(202203): `weixin()` will be removed in v2.0 * @link https://github.com/wechaty/puppet/issues/181 * @deprecated use `handle()` instead */ weixin (): undefined | string { // log.warn('Contact', 'weixin() is deprecated, use `handle()` instead.') // console.error(new Error().stack) return this.payload?.weixin } additionalInfo (): undefined | any { let additionalInfoObj = {} if (this.payload?.additionalInfo) { try { additionalInfoObj = JSON.parse(this.payload.additionalInfo) } catch (e) { log.warn('Contact', 'additionalInfo() parse failed, additionalInfo: %s', this.payload.additionalInfo) } } return additionalInfoObj } async payloadModify (payload: Partial<PUPPET.payloads.Contact>): Promise<void> { return this.wechaty.puppet.contactPayloadModify(this.id, payload) } } class ContactImplBase extends validationMixin(ContactMixin)<ContactImplInterface>() {} interface ContactImplInterface extends ContactImplBase {} type ContactProtectedProperty = | 'ready' type ContactInterface = Omit<ContactImplInterface, ContactProtectedProperty> class ContactImpl extends validationMixin(ContactImplBase)<ContactInterface>() {} type ContactConstructor = Constructor< ContactImplInterface, Omit<typeof ContactImpl, 'load' | 'batchLoadContacts'> > export type { ContactConstructor, ContactProtectedProperty, ContactInterface, } export { ContactImpl, }