import * as PUPPET from '@juzi/wechaty-puppet' import { MessageMediaTypeList, log, } from '../../config.js' import { MessageTypes as WhatsAppMessageType, MessageAck, } from '../../schema/whatsapp-interface.js' import WhatsAppBase from '../whatsapp-base.js' import { SpecialSystemType, type WhatsAppMessage, type WhatsAppMessagePayload, } from '../../schema/whatsapp-type.js' import { isContactId, isInviteLink, getInviteCode, sleep, } from '../../helper/miscellaneous.js' import { RequestPool } from '../../request/request-pool.js' import { v4 } from 'uuid' const PRE = 'MessageEventHandler' export default class MessageEventHandler extends WhatsAppBase { public async onMessage (message: WhatsAppMessage | WhatsAppMessagePayload) { log.info(PRE, `onMessage(${JSON.stringify(message)})`) if (!(await this.checkCacheManager())) { log.warn('message ignored because login process is not finished') return } const _data = (message as any)._data // @ts-ignore if ( message.type === 'multi_vcard' || (message.type === 'e2e_notification' && message.body === '' && !message.author && _data.subtype !== 'encrypt') ) { // skip room join notification and multi_vcard message return } const cacheManager = await this.manager.getCacheManager() const messageId = message.id.id const messageInCache = await cacheManager.getMessageRawPayload(messageId) if (messageInCache) { return } if (_data.type === 'notification_template' && _data.subtype === 'contact_info_card') { message.type = WhatsAppMessageType.TEXT message.body = '[客户通过广告或其他渠道发起对话]' } if (_data.type === 'e2e_notification' && _data.subtype === 'encrypt') { message.type = SpecialSystemType message.body = '消息和通话已进行端到端加密。只有此聊天中的成员可以查看、收听或分享。' } await cacheManager.setMessageRawPayload(messageId, message) if ((message as WhatsAppMessagePayload)._data?.caption && (message as WhatsAppMessagePayload)._data?.type === 'image') { // see issue: https://github.com/wechaty/puppet-whatsapp/issues/390 // file message also have captions, but no text message should be generated const genTextMessageFromImageMessage = message as WhatsAppMessagePayload genTextMessageFromImageMessage.type = WhatsAppMessageType.TEXT const textMsgId = `${genTextMessageFromImageMessage.id.id}_TEXT` genTextMessageFromImageMessage.id.id = textMsgId genTextMessageFromImageMessage._data = undefined await this.onMessage(genTextMessageFromImageMessage) } const contactId = message.from if (contactId && isContactId(contactId)) { const contact = await cacheManager.getContactOrRoomRawPayload(contactId) const notFriend = !contact?.isMyContact if (notFriend) { const friendship: PUPPET.payloads.Friendship = { id: v4(), contactId, hello: message.body, timestamp: message.timestamp, type: PUPPET.types.Friendship.Receive, ticket: '', } await cacheManager.setFriendshipRawPayload(friendship.id, friendship) this.emit('friendship', { friendshipId: friendship.id }) } } const needEmitMessage = await this.convertInviteLinkMessageToEvent(message) if (needEmitMessage) { this.emit('message', { messageId }) } } /** * This event only for the message which sent by bot (web / phone) * @param {WhatsAppMessage} message message detail info * @returns */ public async onMessageAck (message: WhatsAppMessage) { log.silly(PRE, `onMessageAck(${JSON.stringify(message)})`) if (!(await this.checkCacheManager())) { log.warn('message ignored because login process is not finished') return } /** * if message ack equal MessageAck.ACK_DEVICE, we could regard it as has already send success. * * FIXME: if the ack is not consecutive, and without MessageAck.ACK_DEVICE, then we could not receive this message. * * After add sync missed message schedule, if the ack of message has not reach MessageAck.ACK_DEVICE, * the schedule will emit these messages with wrong ack (ack = MessageAck.ACK_PENDING or MessageAck.ACK_SERVER), * and will make some mistakes (can not get the media of message). */ if (message.id.fromMe) { if (MessageMediaTypeList.includes(message.type)) { if (message.hasMedia && message.ack === MessageAck.ACK_SERVER) { await this.processMessageFromBot(message) } if (message.ack === MessageAck.ACK_DEVICE || message.ack === MessageAck.ACK_READ) { await this.processMessageFromBot(message) } } else { await this.processMessageFromBot(message) } } } /** * This event only for the message which sent by bot (web / phone) and to the bot self * @param {WhatsAppMessage} message message detail info * @returns */ public async onMessageCreate (message: WhatsAppMessage) { log.silly(PRE, `onMessageCreate(${JSON.stringify(message)})`) if (!(await this.checkCacheManager())) { log.warn('message ignored because login process is not finished') return } if (message.id.fromMe) { const messageId = message.id.id const cacheManager = await this.manager.getCacheManager() await cacheManager.setMessageRawPayload(messageId, message) // void sleep // const requestPool = RequestPool.Instance // const now = Date.now() // while (!requestPool.hasRequest(messageId) && Date.now() - now < 400) { // await sleep(100) // } // requestPool.resolveRequest(messageId) await sleep(1000) // wait for sent message method return to avoid duplicate message // self sent message is not time sensitive, so we can wait for a while this.emit('message', { messageId }) } } public async processMessageFromBot (message: WhatsAppMessage) { const messageId = message.id.id const cacheManager = await this.manager.getCacheManager() const messageInCache = await cacheManager.getMessageRawPayload(messageId) await cacheManager.setMessageRawPayload(messageId, message) // set message with different message ack /** * - Non-Media Message * emit only when no cache * * - Media Message * emit message when no cache or ack of message in cache equal 1 */ if (!messageInCache || (MessageMediaTypeList.includes(message.type) && messageInCache.ack === MessageAck.ACK_SERVER)) { if (!message.author) { // based on experience, not officially conformed // self message from other device contains author // while sent from this puppet it's undefined log.info(PRE, `seems to be self sent message, so skip. id: ${messageId}, base content: ${message.body}`) return } const requestPool = RequestPool.Instance const hasRequest = requestPool.resolveRequest(messageId) if (!hasRequest) { this.emit('message', { messageId }) } } if (messageInCache && message.id.fromMe && message.ack > messageInCache.ack && [MessageAck.ACK_READ, MessageAck.ACK_PLAYED].includes(message.ack)) { await cacheManager.setMessageRawPayload(messageId, message) this.emit('dirty', { payloadId: messageId, payloadType: PUPPET.types.Dirty.Message, }) } } public async convertInviteLinkMessageToEvent (message: WhatsAppMessage | WhatsAppMessagePayload): Promise { const cacheManager = await this.manager.getCacheManager() if (message.type === WhatsAppMessageType.GROUP_INVITE) { const inviteCode = message.inviteV4?.inviteCode if (inviteCode) { const roomInvitationPayload: PUPPET.payloads.EventRoomInvite = { roomInvitationId: inviteCode, } await cacheManager.setRoomInvitationRawPayload(inviteCode, { inviteCode }) this.emit('room-invite', roomInvitationPayload) } else { log.warn(PRE, `convertInviteLinkMessageToEvent can not get invite code: ${JSON.stringify(message)}`) } return false } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (message.type === WhatsAppMessageType.TEXT && message.links && message.links.length === 1 && isInviteLink(message.links[0]!.link)) { const inviteCode = getInviteCode(message.links[0]!.link) if (inviteCode) { const roomInvitationPayload: PUPPET.payloads.EventRoomInvite = { roomInvitationId: inviteCode, } await cacheManager.setRoomInvitationRawPayload(inviteCode, { inviteCode }) this.emit('room-invite', roomInvitationPayload) return false } } return true } public async onIncomingCall (...args: any[]) { // it is a any[] argument log.silly(PRE, `onIncomingCall(${JSON.stringify(args)})`) } public async onMediaUploaded (message: WhatsAppMessage) { log.silly(PRE, `onMediaUploaded(${JSON.stringify(message)})`) await this.createOrUpdateImageMessage(message) if (!message.hasMedia) { log.warn(PRE, `onMediaUploaded failed, message id: ${message.id.id}, type: ${message.type}, detail info: ${JSON.stringify(message)}`) } } public async createOrUpdateImageMessage (message: WhatsAppMessage) { if (message.type === WhatsAppMessageType.IMAGE) { const messageId = message.id.id const cacheManager = await this.manager.getCacheManager() const messageInCache = await cacheManager.getMessageRawPayload(messageId) if (messageInCache) { message.body = messageInCache.body || message.body await cacheManager.setMessageRawPayload(messageId, message) return } await cacheManager.setMessageRawPayload(messageId, message) } } /** * Someone delete message in all devices. Due to they have the same message id so we generate a fake id as flash-store key. * see: https://github.com/pedroslopez/whatsapp-web.js/issues/1178 * @param message revoke message * @param revokedMsg original message, sometimes it will be null */ public async onMessageRevokeEveryone (message: WhatsAppMessage, revokedMsg?: WhatsAppMessage | null | undefined) { log.silly(PRE, `onMessageRevokeEveryone(newMsg: ${JSON.stringify(message)}, originalMsg: ${JSON.stringify(revokedMsg)})`) if (!(await this.checkCacheManager())) { log.warn('message ignored because login process is not finished') return } const cacheManager = await this.manager.getCacheManager() const messageId = message.id.id if (revokedMsg) { const originalMessageId = revokedMsg.id.id const recalledMessageId = this.generateFakeRecallMessageId(originalMessageId) message.body = recalledMessageId await cacheManager.setMessageRawPayload(recalledMessageId, revokedMsg) } await cacheManager.setMessageRawPayload(messageId, message) this.emit('message', { messageId }) } /** * Only delete message in bot phone will trigger this event. But the message type is chat, not revoked any more. */ public async onMessageRevokeMe (message: WhatsAppMessage) { log.silly(PRE, `onMessageRevokeMe(${JSON.stringify(message)})`) if (!(await this.checkCacheManager())) { log.warn('message ignored because login process is not finished') } /* if (message.ack === MessageAck.ACK_PENDING) { // when the bot logout, it will receive onMessageRevokeMe event, but it's ack is MessageAck.ACK_PENDING, so let's ignore this event. return } const cacheManager = await this.manager.getCacheManager() const messageId = message.id.id message.type = WhatsAppMessageType.REVOKED message.body = messageId const recalledMessageId = this.generateFakeRecallMessageId(messageId) await cacheManager.setMessageRawPayload(recalledMessageId, message) this.emit('message', { messageId: recalledMessageId }) */ } public generateFakeRecallMessageId (messageId: string) { return `${messageId}_revoked` } private async checkCacheManager () { let cacheManager try { cacheManager = await this.manager.getCacheManager() } catch (e) {} if (!cacheManager) { log.warn(PRE, 'message comes before login process finished') return false } return true } }