Merge pull request #69 from rawars/master

Label support
This commit is contained in:
Rajeh Taher
2023-05-18 23:03:09 +03:00
committed by GitHub
10 changed files with 456 additions and 72 deletions

View File

@@ -88,6 +88,15 @@ const startSock = async() => {
await saveCreds() await saveCreds()
} }
if(events['labels.association']) {
console.log(events['labels.association'])
}
if(events['labels.edit']) {
console.log(events['labels.edit'])
}
if(events.call) { if(events.call) {
console.log('recv call event', events.call) console.log('recv call event', events.call)
} }

View File

@@ -240,6 +240,8 @@ export type BaileysEventMap = {
'chats.update': Partial<Chat>[] 'chats.update': Partial<Chat>[]
/** delete chats with given ID */ /** delete chats with given ID */
'chats.delete': string[] 'chats.delete': string[]
'labels.association': LabelAssociation
'labels.edit': Label
/** presence of contact in a chat updated */ /** presence of contact in a chat updated */
'presence.update': { id: string, presences: { [participant: string]: PresenceData } } 'presence.update': { id: string, presences: { [participant: string]: PresenceData } }

View File

@@ -777,6 +777,52 @@ export const makeChatsSocket = (config: SocketConfig) => {
return appPatch(patch) return appPatch(patch)
} }
/**
* Adds label for the chats
*/
const addChatLabel = (jid: string, labelId: string) => {
return chatModify({
addChatLabel: {
labelId
}
}, jid)
}
/**
* Removes label for the chat
*/
const removeChatLabel = (jid: string, labelId: string) => {
return chatModify({
removeChatLabel: {
labelId
}
}, jid)
}
/**
* Adds label for the message
*/
const addMessageLabel = (jid: string, messageId: string, labelId: string) => {
return chatModify({
addMessageLabel: {
messageId,
labelId
}
}, jid)
}
/**
* Removes label for the message
*/
const removeMessageLabel = (jid: string, messageId: string, labelId: string) => {
return chatModify({
removeMessageLabel: {
messageId,
labelId
}
}, jid)
}
/** /**
* queries need to be fired on connection open * queries need to be fired on connection open
* help ensure parity with WA Web * help ensure parity with WA Web
@@ -945,6 +991,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
updateDefaultDisappearingMode, updateDefaultDisappearingMode,
getBusinessProfile, getBusinessProfile,
resyncAppState, resyncAppState,
chatModify chatModify,
addChatLabel,
removeChatLabel,
addMessageLabel,
removeMessageLabel
} }
} }

View File

@@ -1,13 +1,16 @@
import type KeyedDB from '@adiwajshing/keyed-db' import KeyedDB from '@adiwajshing/keyed-db'
import type { Comparable } from '@adiwajshing/keyed-db/lib/Types' import type { Comparable } from '@adiwajshing/keyed-db/lib/Types'
import type { Logger } from 'pino' import type { Logger } from 'pino'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults' import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import type makeMDSocket from '../Socket' import type makeMDSocket from '../Socket'
import type { BaileysEventEmitter, Chat, ConnectionState, Contact, GroupMetadata, PresenceData, WAMessage, WAMessageCursor, WAMessageKey } from '../Types' import type { BaileysEventEmitter, Chat, ConnectionState, Contact, GroupMetadata, PresenceData, WAMessage, WAMessageCursor, WAMessageKey } from '../Types'
import { Label } from '../Types/Label'
import { LabelAssociation, LabelAssociationType, MessageLabelAssociation } from '../Types/LabelAssociation'
import { toNumber, updateMessageWithReaction, updateMessageWithReceipt } from '../Utils' import { toNumber, updateMessageWithReaction, updateMessageWithReceipt } from '../Utils'
import { jidNormalizedUser } from '../WABinary' import { jidNormalizedUser } from '../WABinary'
import makeOrderedDictionary from './make-ordered-dictionary' import makeOrderedDictionary from './make-ordered-dictionary'
import { ObjectRepository } from './object-repository'
type WASocket = ReturnType<typeof makeMDSocket> type WASocket = ReturnType<typeof makeMDSocket>
@@ -18,26 +21,74 @@ export const waChatKey = (pin: boolean) => ({
export const waMessageID = (m: WAMessage) => m.key.id || '' export const waMessageID = (m: WAMessage) => m.key.id || ''
export const waLabelAssociationKey: Comparable<LabelAssociation, string> = {
key: (la: LabelAssociation) => (la.type === LabelAssociationType.Chat ? la.chatId + la.labelId : la.chatId + la.messageId + la.labelId),
compare: (k1: string, k2: string) => k2.localeCompare(k1)
}
export type BaileysInMemoryStoreConfig = { export type BaileysInMemoryStoreConfig = {
chatKey?: Comparable<Chat, string> chatKey?: Comparable<Chat, string>
labelAssociationKey?: Comparable<LabelAssociation, string>
logger?: Logger logger?: Logger
} }
const makeMessagesDictionary = () => makeOrderedDictionary(waMessageID) const makeMessagesDictionary = () => makeOrderedDictionary(waMessageID)
export default ( const predefinedLabels = Object.freeze<Record<string, Label>>({
{ logger: _logger, chatKey }: BaileysInMemoryStoreConfig '0': {
) => { id: '0',
const logger = _logger || DEFAULT_CONNECTION_CONFIG.logger.child({ stream: 'in-mem-store' }) name: 'New customer',
chatKey = chatKey || waChatKey(true) predefinedId: '0',
const KeyedDB = require('@adiwajshing/keyed-db').default as new (...args: any[]) => KeyedDB<Chat, string> color: 0,
deleted: false
},
'1': {
id: '1',
name: 'New order',
predefinedId: '1',
color: 1,
deleted: false
},
'2': {
id: '2',
name: 'Pending payment',
predefinedId: '2',
color: 2,
deleted: false
},
'3': {
id: '3',
name: 'Paid',
predefinedId: '3',
color: 3,
deleted: false
},
'4': {
id: '4',
name: 'Order completed',
predefinedId: '4',
color: 4,
deleted: false
}
})
const chats = new KeyedDB(chatKey, c => c.id) export default (
{ logger: _logger, chatKey, labelAssociationKey }: BaileysInMemoryStoreConfig
) => {
// const logger = _logger || DEFAULT_CONNECTION_CONFIG.logger.child({ stream: 'in-mem-store' })
chatKey = chatKey || waChatKey(true)
labelAssociationKey = labelAssociationKey || waLabelAssociationKey
const logger = _logger || DEFAULT_CONNECTION_CONFIG.logger.child({ stream: 'in-mem-store' })
// const KeyedDB = require('@adiwajshing/keyed-db').default as new (...args: any[]) => KeyedDB<Chat, string>
const chats = new KeyedDB<Chat, string>(chatKey, c => c.id)
const messages: { [_: string]: ReturnType<typeof makeMessagesDictionary> } = {} const messages: { [_: string]: ReturnType<typeof makeMessagesDictionary> } = {}
const contacts: { [_: string]: Contact } = {} const contacts: { [_: string]: Contact } = {}
const groupMetadata: { [_: string]: GroupMetadata } = {} const groupMetadata: { [_: string]: GroupMetadata } = {}
const presences: { [id: string]: { [participant: string]: PresenceData } } = {} const presences: { [id: string]: { [participant: string]: PresenceData } } = {}
const state: ConnectionState = { connection: 'close' } const state: ConnectionState = { connection: 'close' }
const labels = new ObjectRepository<Label>(predefinedLabels)
const labelAssociations = new KeyedDB<LabelAssociation, string>(labelAssociationKey, labelAssociationKey.key)
const assertMessageList = (jid: string) => { const assertMessageList = (jid: string) => {
if(!messages[jid]) { if(!messages[jid]) {
@@ -60,6 +111,12 @@ export default (
return oldContacts return oldContacts
} }
const labelsUpsert = (newLabels: Label[]) => {
for(const label of newLabels) {
labels.upsertById(label.id, label)
}
}
/** /**
* binds to a BaileysEventEmitter. * binds to a BaileysEventEmitter.
* It listens to all events and constructs a state that you can query accurate data from. * It listens to all events and constructs a state that you can query accurate data from.
@@ -131,6 +188,33 @@ export default (
} }
} }
}) })
ev.on('labels.edit', (label: Label) => {
if(label.deleted) {
return labels.deleteById(label.id)
}
// WhatsApp can store only up to 20 labels
if(labels.count() < 20) {
return labels.upsertById(label.id, label)
}
logger.error('Labels count exceed')
})
ev.on('labels.association', ({ type, association }) => {
switch (type) {
case 'add':
labelAssociations.upsert(association)
break
case 'remove':
labelAssociations.delete(association)
break
default:
console.error(`unknown operation type [${type}]`)
}
})
ev.on('presence.update', ({ id, presences: update }) => { ev.on('presence.update', ({ id, presences: update }) => {
presences[id] = presences[id] || {} presences[id] = presences[id] || {}
Object.assign(presences[id], update) Object.assign(presences[id], update)
@@ -255,12 +339,16 @@ export default (
const toJSON = () => ({ const toJSON = () => ({
chats, chats,
contacts, contacts,
messages messages,
labels,
labelAssociations
}) })
const fromJSON = (json: { chats: Chat[], contacts: { [id: string]: Contact }, messages: { [id: string]: WAMessage[] } }) => { const fromJSON = (json: {chats: Chat[], contacts: { [id: string]: Contact }, messages: { [id: string]: WAMessage[] }, labels: { [labelId: string]: Label }, labelAssociations: LabelAssociation[]}) => {
chats.upsert(...json.chats) chats.upsert(...json.chats)
labelAssociations.upsert(...json.labelAssociations || [])
contactsUpsert(Object.values(json.contacts)) contactsUpsert(Object.values(json.contacts))
labelsUpsert(Object.values(json.labels || {}))
for(const jid in json.messages) { for(const jid in json.messages) {
const list = assertMessageList(jid) const list = assertMessageList(jid)
for(const msg of json.messages[jid]) { for(const msg of json.messages[jid]) {
@@ -277,6 +365,8 @@ export default (
groupMetadata, groupMetadata,
state, state,
presences, presences,
labels,
labelAssociations,
bind, bind,
/** loads messages from the store, if not found -- uses the legacy connection */ /** loads messages from the store, if not found -- uses the legacy connection */
loadMessages: async(jid: string, count: number, cursor: WAMessageCursor) => { loadMessages: async(jid: string, count: number, cursor: WAMessageCursor) => {
@@ -304,6 +394,38 @@ export default (
return messages return messages
}, },
/**
* Get all available labels for profile
*
* Keep in mind that the list is formed from predefined tags and tags
* that were "caught" during their editing.
*/
getLabels: () => {
return labels
},
/**
* Get labels for chat
*
* @returns Label IDs
**/
getChatLabels: (chatId: string) => {
return labelAssociations.filter((la) => la.chatId === chatId).all()
},
/**
* Get labels for message
*
* @returns Label IDs
**/
getMessageLabels: (messageId: string) => {
const associations = labelAssociations
.filter((la: MessageLabelAssociation) => la.messageId === messageId)
.all()
return associations.map(({ labelId }) => labelId)
},
loadMessage: async(jid: string, id: string) => messages[jid]?.get(id), loadMessage: async(jid: string, id: string) => messages[jid]?.get(id),
mostRecentMessage: async(jid: string) => { mostRecentMessage: async(jid: string) => {
const message: WAMessage | undefined = messages[jid]?.array.slice(-1)[0] const message: WAMessage | undefined = messages[jid]?.array.slice(-1)[0]

View File

@@ -0,0 +1,32 @@
export class ObjectRepository<T extends object> {
readonly entityMap: Map<string, T>
constructor(entities: Record<string, T> = {}) {
this.entityMap = new Map(Object.entries(entities))
}
findById(id: string) {
return this.entityMap.get(id)
}
findAll() {
return Array.from(this.entityMap.values())
}
upsertById(id: string, entity: T) {
return this.entityMap.set(id, { ...entity })
}
deleteById(id: string) {
return this.entityMap.delete(id)
}
count() {
return this.entityMap.size
}
toJSON() {
return this.findAll()
}
}

View File

@@ -1,6 +1,8 @@
import type { proto } from '../../WAProto' import type { proto } from '../../WAProto'
import type { AccountSettings } from './Auth' import type { AccountSettings } from './Auth'
import type { BufferedEventData } from './Events' import type { BufferedEventData } from './Events'
import type { ChatLabelAssociationActionBody } from './LabelAssociation'
import type { MessageLabelAssociationActionBody } from './LabelAssociation'
import type { MinimalMessage } from './Message' import type { MinimalMessage } from './Message'
/** privacy settings in WhatsApp Web */ /** privacy settings in WhatsApp Web */
@@ -13,13 +15,7 @@ export type WAReadReceiptsValue = 'all' | 'none'
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ /** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused' export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused'
export const ALL_WA_PATCH_NAMES = [ export const ALL_WA_PATCH_NAMES = ['critical_block', 'critical_unblock_low', 'regular_high', 'regular_low', 'regular'] as const
'critical_block',
'critical_unblock_low',
'regular_high',
'regular_low',
'regular'
] as const
export type WAPatchName = typeof ALL_WA_PATCH_NAMES[number] export type WAPatchName = typeof ALL_WA_PATCH_NAMES[number]
@@ -90,6 +86,11 @@ export type ChatModification =
lastMessages: LastMessageList lastMessages: LastMessageList
} }
| { delete: true, lastMessages: LastMessageList } | { delete: true, lastMessages: LastMessageList }
// Label assosiation
| { addChatLabel: ChatLabelAssociationActionBody }
| { removeChatLabel: ChatLabelAssociationActionBody }
| { addMessageLabel: MessageLabelAssociationActionBody }
| { removeMessageLabel: MessageLabelAssociationActionBody }
export type InitialReceivedChatsState = { export type InitialReceivedChatsState = {
[jid: string]: { [jid: string]: {

View File

@@ -5,6 +5,8 @@ import { WACallEvent } from './Call'
import { Chat, ChatUpdate, PresenceData } from './Chat' import { Chat, ChatUpdate, PresenceData } from './Chat'
import { Contact } from './Contact' import { Contact } from './Contact'
import { GroupMetadata, ParticipantAction } from './GroupMetadata' import { GroupMetadata, ParticipantAction } from './GroupMetadata'
import { Label } from './Label'
import { LabelAssociation } from './LabelAssociation'
import { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message' import { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message'
import { ConnectionState } from './State' import { ConnectionState } from './State'
@@ -54,6 +56,8 @@ export type BaileysEventMap = {
'blocklist.update': { blocklist: string[], type: 'add' | 'remove' } 'blocklist.update': { blocklist: string[], type: 'add' | 'remove' }
/** Receive an update on a call, including when the call was received, rejected, accepted */ /** Receive an update on a call, including when the call was received, rejected, accepted */
'call': WACallEvent[] 'call': WACallEvent[]
'labels.edit': Label
'labels.association': { association: LabelAssociation, type: 'add' | 'remove' }
} }
export type BufferedEventData = { export type BufferedEventData = {

36
src/Types/Label.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface Label {
/** Label uniq ID */
id: string
/** Label name */
name: string
/** Label color ID */
color: number
/** Is label has been deleted */
deleted: boolean
/** WhatsApp has 5 predefined labels (New customer, New order & etc) */
predefinedId?: string
}
/** WhatsApp has 20 predefined colors */
export enum LabelColor {
Color1 = 0,
Color2,
Color3,
Color4,
Color5,
Color6,
Color7,
Color8,
Color9,
Color10,
Color11,
Color12,
Color13,
Color14,
Color15,
Color16,
Color17,
Color18,
Color19,
Color20,
}

View File

@@ -0,0 +1,35 @@
/** Association type */
export enum LabelAssociationType {
Chat = 'label_jid',
Message = 'label_message'
}
export type LabelAssociationTypes = `${LabelAssociationType}`
/** Association for chat */
export interface ChatLabelAssociation {
type: LabelAssociationType.Chat
chatId: string
labelId: string
}
/** Association for message */
export interface MessageLabelAssociation {
type: LabelAssociationType.Message
chatId: string
messageId: string
labelId: string
}
export type LabelAssociation = ChatLabelAssociation | MessageLabelAssociation
/** Body for add/remove chat label association action */
export interface ChatLabelAssociationActionBody {
labelId: string
}
/** body for add/remove message label association action */
export interface MessageLabelAssociationActionBody {
labelId: string
messageId: string
}

View File

@@ -3,6 +3,7 @@ import { AxiosRequestConfig } from 'axios'
import type { Logger } from 'pino' import type { Logger } from 'pino'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { BaileysEventEmitter, Chat, ChatModification, ChatMutation, ChatUpdate, Contact, InitialAppStateSyncOptions, LastMessageList, LTHashState, WAPatchCreate, WAPatchName } from '../Types' import { BaileysEventEmitter, Chat, ChatModification, ChatMutation, ChatUpdate, Contact, InitialAppStateSyncOptions, LastMessageList, LTHashState, WAPatchCreate, WAPatchName } from '../Types'
import { ChatLabelAssociation, LabelAssociationType, MessageLabelAssociation } from '../Types/LabelAssociation'
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidNormalizedUser } from '../WABinary' import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidNormalizedUser } from '../WABinary'
import { aesDecrypt, aesEncrypt, hkdf, hmacSign } from './crypto' import { aesDecrypt, aesEncrypt, hkdf, hmacSign } from './crypto'
import { toNumber } from './generics' import { toNumber } from './generics'
@@ -606,6 +607,68 @@ export const chatModificationToAppPatch = (
apiVersion: 1, apiVersion: 1,
operation: OP.SET, operation: OP.SET,
} }
} else if('addChatLabel' in mod) {
patch = {
syncAction: {
labelAssociationAction: {
labeled: true,
}
},
index: [LabelAssociationType.Chat, mod.addChatLabel.labelId, jid],
type: 'regular',
apiVersion: 3,
operation: OP.SET,
}
} else if('removeChatLabel' in mod) {
patch = {
syncAction: {
labelAssociationAction: {
labeled: false,
}
},
index: [LabelAssociationType.Chat, mod.removeChatLabel.labelId, jid],
type: 'regular',
apiVersion: 3,
operation: OP.SET,
}
} else if('addMessageLabel' in mod) {
patch = {
syncAction: {
labelAssociationAction: {
labeled: true,
}
},
index: [
LabelAssociationType.Message,
mod.addMessageLabel.labelId,
jid,
mod.addMessageLabel.messageId,
'0',
'0'
],
type: 'regular',
apiVersion: 3,
operation: OP.SET,
}
} else if('removeMessageLabel' in mod) {
patch = {
syncAction: {
labelAssociationAction: {
labeled: false,
}
},
index: [
LabelAssociationType.Message,
mod.removeMessageLabel.labelId,
jid,
mod.removeMessageLabel.messageId,
'0',
'0'
],
type: 'regular',
apiVersion: 3,
operation: OP.SET,
}
} else { } else {
throw new Boom('not supported') throw new Boom('not supported')
} }
@@ -687,13 +750,15 @@ export const processSyncAction = (
conditional: getChatUpdateConditional(id, markReadAction?.messageRange) conditional: getChatUpdateConditional(id, markReadAction?.messageRange)
}]) }])
} else if(action?.deleteMessageForMeAction || type === 'deleteMessageForMe') { } else if(action?.deleteMessageForMeAction || type === 'deleteMessageForMe') {
ev.emit('messages.delete', { keys: [ ev.emit('messages.delete', {
keys: [
{ {
remoteJid: id, remoteJid: id,
id: msgId, id: msgId,
fromMe: fromMe === '1' fromMe: fromMe === '1'
} }
] }) ]
})
} else if(action?.contactAction) { } else if(action?.contactAction) {
ev.emit('contacts.upsert', [{ id, name: action.contactAction!.fullName! }]) ev.emit('contacts.upsert', [{ id, name: action.contactAction!.fullName! }])
} else if(action?.pushNameSetting) { } else if(action?.pushNameSetting) {
@@ -731,6 +796,34 @@ export const processSyncAction = (
if(!isInitialSync) { if(!isInitialSync) {
ev.emit('chats.delete', [id]) ev.emit('chats.delete', [id])
} }
} else if(action?.labelEditAction) {
const { name, color, deleted, predefinedId } = action.labelEditAction!
ev.emit('labels.edit', {
id,
name: name!,
color: color!,
deleted: deleted!,
predefinedId: predefinedId ? String(predefinedId) : undefined
})
} else if(action?.labelAssociationAction) {
ev.emit('labels.association', {
type: action.labelAssociationAction.labeled
? 'add'
: 'remove',
association: type === LabelAssociationType.Chat
? {
type: LabelAssociationType.Chat,
chatId: syncAction.index[2],
labelId: syncAction.index[1]
} as ChatLabelAssociation
: {
type: LabelAssociationType.Message,
chatId: syncAction.index[2],
messageId: syncAction.index[3],
labelId: syncAction.index[1]
} as MessageLabelAssociation
})
} else { } else {
logger?.debug({ syncAction, id }, 'unprocessable update') logger?.debug({ syncAction, id }, 'unprocessable update')
} }