- removed timeout, use maxIdleTimeMs
- made messages a keyedDB to better utitlize message cache
- possible fix for group ID bug
This commit is contained in:
Adhiraj
2020-09-27 13:51:36 +05:30
parent 18cea74aaf
commit 3a878ae193
16 changed files with 219 additions and 121 deletions

View File

@@ -30,7 +30,7 @@ import { STATUS_CODES, Agent } from 'http'
export class WAConnection extends EventEmitter {
/** The version of WhatsApp Web we're telling the servers we are */
version: [number, number, number] = [2, 2037, 6]
version: [number, number, number] = [2, 2039, 6]
/** The Browser we're telling the WhatsApp Web servers we are */
browserDescription: [string, string, string] = Utils.Browsers.baileys ('Chrome')
/** Metadata like WhatsApp id, name set on WhatsApp etc. */
@@ -41,11 +41,9 @@ export class WAConnection extends EventEmitter {
pendingRequestTimeoutMs: number = null
/** The connection state */
state: WAConnectionState = 'close'
/** New QR generation interval, set to null if you don't want to regenerate */
regenerateQRIntervalMs = 30*1000
connectOptions: WAConnectOptions = {
timeoutMs: 60*1000,
maxIdleTimeMs: 10*1000,
regenerateQRIntervalMs: 30_1000,
maxIdleTimeMs: 15_1000,
waitOnlyForLastMessage: false,
waitForChats: true,
maxRetries: 5,
@@ -56,10 +54,12 @@ export class WAConnection extends EventEmitter {
autoReconnect = ReconnectMode.onConnectionLost
/** Whether the phone is connected */
phoneConnected: boolean = false
/** key to use to order chats */
chatOrderingKey = Utils.WA_CHAT_KEY
maxCachedMessages = 50
chats: KeyedDB<WAChat> = new KeyedDB (Utils.waChatUniqueKey, value => value.jid)
chats: KeyedDB<WAChat> = new KeyedDB (Utils.WA_CHAT_KEY, value => value.jid)
contacts: { [k: string]: WAContact } = {}
/** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */

View File

@@ -1,12 +1,12 @@
import * as Curve from 'curve25519-js'
import * as Utils from './Utils'
import {WAConnection as Base} from './0.Base'
import { MessageLogLevel, WAMetric, WAFlag, BaileysError, Presence, WAUser } from './Constants'
import { MessageLogLevel, WAMetric, WAFlag, BaileysError, Presence, WAUser, DisconnectReason } from './Constants'
export class WAConnection extends Base {
/** Authenticate the connection */
protected async authenticate (onConnectionValidated: () => void, reconnect?: string) {
protected async authenticate (startDebouncedTimeout: () => void, stopDebouncedTimeout: () => void, reconnect?: string) {
// if no auth info is present, that is, a new session has to be established
// generate a client ID
if (!this.authInfo?.clientID) {
@@ -15,6 +15,8 @@ export class WAConnection extends Base {
const canLogin = this.authInfo?.encKey && this.authInfo?.macKey
this.referenceDate = new Date () // refresh reference date
startDebouncedTimeout ()
const initQueries = [
(async () => {
@@ -25,7 +27,9 @@ export class WAConnection extends Base {
longTag: true
})
if (!canLogin) {
stopDebouncedTimeout () // stop the debounced timeout for QR gen
const result = await this.generateKeysForAuth (ref)
startDebouncedTimeout () // restart debounced timeout
return result
}
})()
@@ -58,7 +62,6 @@ export class WAConnection extends Base {
const validationJSON = (await Promise.all (initQueries)).slice(-1)[0] // get the last result
this.user = await this.validateNewConnection(validationJSON[1]) // validate the connection
onConnectionValidated ()
this.log('validated connection successfully', MessageLogLevel.info)
const response = await this.query({ json: ['query', 'ProfilePicThumb', this.user.jid], waitForOpen: false, expect200: false })
@@ -85,7 +88,13 @@ export class WAConnection extends Base {
* @returns the new ref
*/
async generateNewQRCodeRef() {
const response = await this.query({json: ['admin', 'Conn', 'reref'], expect200: true, waitForOpen: false, longTag: true})
const response = await this.query({
json: ['admin', 'Conn', 'reref'],
expect200: true,
waitForOpen: false,
longTag: true,
timeoutMs: this.connectOptions.maxIdleTimeMs
})
return response.ref as string
}
/**
@@ -177,12 +186,17 @@ export class WAConnection extends Base {
.then (newRef => ref = newRef)
.then (emitQR)
.then (regenQR)
.catch (err => this.log (`error in QR gen: ${err}`, MessageLogLevel.info))
}, this.regenerateQRIntervalMs)
.catch (err => {
this.log (`error in QR gen: ${err}`, MessageLogLevel.info)
if (err.status === 429) { // too many QR requests
this.endConnection ()
}
})
}, this.connectOptions.regenerateQRIntervalMs)
}
emitQR ()
if (this.regenerateQRIntervalMs) regenQR ()
if (this.connectOptions.regenerateQRIntervalMs) regenQR ()
const json = await this.waitForMessage('s1', [])
this.qrTimeout && clearTimeout (this.qrTimeout)

View File

@@ -64,22 +64,25 @@ export class WAConnection extends Base {
protected async connectInternal (options: WAConnectOptions, delayMs?: number) {
// actual connect
const connect = () => {
const timeoutMs = options?.timeoutMs || 60*1000
let cancel: () => void
const task = Utils.promiseTimeout(timeoutMs, (resolve, reject) => {
const task = new Promise((resolve, reject) => {
cancel = () => reject (CancelledError())
const checkIdleTime = () => {
this.debounceTimeout && clearTimeout (this.debounceTimeout)
this.debounceTimeout = setTimeout (() => rejectSafe (TimedOutError()), this.connectOptions.maxIdleTimeMs)
}
const debouncedTimeout = () => this.connectOptions.maxIdleTimeMs && this.conn.addEventListener ('message', checkIdleTime)
// determine whether reconnect should be used or not
const shouldUseReconnect = this.lastDisconnectReason !== DisconnectReason.replaced &&
this.lastDisconnectReason !== DisconnectReason.unknown &&
this.lastDisconnectReason !== DisconnectReason.intentional &&
this.user?.jid
const checkIdleTime = () => {
this.debounceTimeout && clearTimeout (this.debounceTimeout)
this.debounceTimeout = setTimeout (() => rejectSafe (TimedOutError()), this.connectOptions.maxIdleTimeMs)
}
const startDebouncedTimeout = () => this.connectOptions.maxIdleTimeMs && this.conn.addEventListener ('message', checkIdleTime)
const stopDebouncedTimeout = () => {
clearTimeout (this.debounceTimeout)
this.conn.removeEventListener ('message', checkIdleTime)
}
const reconnectID = shouldUseReconnect ? this.user.jid.replace ('@s.whatsapp.net', '@c.us') : null
this.conn = new WS(WS_URL, null, {
@@ -110,7 +113,7 @@ export class WAConnection extends Base {
}
}
try {
await this.authenticate (debouncedTimeout, reconnectID)
await this.authenticate (startDebouncedTimeout, stopDebouncedTimeout, reconnectID)
this.conn
.removeAllListeners ('error')
.removeAllListeners ('close')
@@ -124,6 +127,7 @@ export class WAConnection extends Base {
const rejectSafe = error => reject (error)
this.conn.on('error', rejectSafe)
this.conn.on('close', () => rejectSafe(new Error('close')))
}) as Promise<void | { [k: string]: Partial<WAChat> }>
return { promise: task, cancel: cancel }
@@ -163,7 +167,7 @@ export class WAConnection extends Base {
* Must be called immediately after connect
*/
protected receiveChatsAndContacts(waitOnlyForLast: boolean) {
const chats = new KeyedDB<WAChat>(Utils.waChatUniqueKey, c => c.jid)
const chats = new KeyedDB<WAChat>(this.chatOrderingKey, c => c.jid)
const contacts = {}
let receivedContacts = false
@@ -194,7 +198,14 @@ export class WAConnection extends Base {
messages.reverse().forEach (([,, message]: ['message', null, WAMessage]) => {
const jid = message.key.remoteJid
const chat = chats.get(jid)
chat?.messages.unshift (message)
if (chat) {
const fm = chat.messages.all()[0]
const prevEpoch = (fm && fm['epoch']) || 0
message['epoch'] = prevEpoch-1
chat.messages.insert (message)
}
})
}
// if received contacts before messages
@@ -220,7 +231,7 @@ export class WAConnection extends Base {
chat.jid = Utils.whatsappID (chat.jid)
chat.t = +chat.t
chat.count = +chat.count
chat.messages = []
chat.messages = new KeyedDB (Utils.WA_MESSAGE_KEY, Utils.WA_MESSAGE_ID)
// chats data (log json to see what it looks like)
!chats.get (chat.jid) && chats.insert (chat)

View File

@@ -1,7 +1,8 @@
import * as QR from 'qrcode-terminal'
import { WAConnection as Base } from './3.Connect'
import { WAMessageStatusUpdate, WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, MessageLogLevel, PresenceUpdate, BaileysEvent, DisconnectReason, WANode, WAOpenResult, Presence } from './Constants'
import { whatsappID, unixTimestampSeconds, isGroupID, toNumber } from './Utils'
import { whatsappID, unixTimestampSeconds, isGroupID, toNumber, GET_MESSAGE_ID, WA_MESSAGE_ID, WA_MESSAGE_KEY } from './Utils'
import KeyedDB from '@adiwajshing/keyed-db'
export class WAConnection extends Base {
@@ -35,16 +36,15 @@ export class WAConnection extends Base {
this.emit('user-presence-update', update)
})
// If a message has been updated (usually called when a video message gets its upload url)
// If a message has been updated (usually called when a video message gets its upload url, or live locations)
this.registerCallback (['action', 'add:update', 'message'], json => {
const message: WAMessage = json[2][0][2]
const jid = whatsappID(message.key.remoteJid)
const chat = this.chats.get(jid)
if (!chat) return
// reinsert to update
if (chat.messages.delete (message)) chat.messages.insert (message)
const messageIndex = chat.messages.findIndex(m => m.key.id === message.key.id)
if (messageIndex >= 0) chat.messages[messageIndex] = message
this.emit ('message-update', message)
})
// If a user's contact has changed
@@ -183,7 +183,7 @@ export class WAConnection extends Base {
const chat: WAChat = {
jid: jid,
t: unixTimestampSeconds(),
messages: [],
messages: new KeyedDB(WA_MESSAGE_KEY, WA_MESSAGE_ID),
count: 0,
modify_tag: '',
spam: 'false',
@@ -217,14 +217,12 @@ export class WAConnection extends Base {
if (protocolMessage) {
switch (protocolMessage.type) {
case WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE:
const found = chat.messages.find(m => m.key.id === protocolMessage.key.id)
if (found && found.message) {
const found = chat.messages.get (GET_MESSAGE_ID(protocolMessage.key))
if (found?.message) {
this.log ('deleting message: ' + protocolMessage.key.id + ' in chat: ' + protocolMessage.key.remoteJid, MessageLogLevel.info)
found.messageStubType = WA_MESSAGE_STUB_TYPE.REVOKE
found.message = null
delete found.message
this.emit ('message-update', found)
}
break
@@ -233,20 +231,18 @@ export class WAConnection extends Base {
}
} else {
const messages = chat.messages
const messageTimestamp = toNumber (message.messageTimestamp)
let idx = messages.length-1
for (idx; idx >= 0; idx--) {
if (toNumber(messages[idx].messageTimestamp) <= messageTimestamp) {
break
}
}
// if the message is already there
if (messages[idx]?.key.id === message.key.id) return
//this.log (`adding message ID: ${messageTimestamp} to ${JSON.stringify(messages.map(m => toNumber(messageTimestamp)))}`, MessageLogLevel.info)
messages.splice (idx+1, 0, message) // insert
messages.splice(0, messages.length-this.maxCachedMessages)
if (messages.get(WA_MESSAGE_ID(message))) return
const last = messages.all().slice(-1)
const lastEpoch = ((last && last[0]) && last[0]['epoch']) || 0
message['epoch'] = lastEpoch+1
messages.insert (message)
while (messages.length > this.maxCachedMessages) {
messages.delete (messages.all()[0]) // delete oldest messages
}
// only update if it's an actual message
if (message.message) this.chatUpdateTime (chat)
@@ -299,8 +295,9 @@ export class WAConnection extends Base {
}
}
protected chatUpdatedMessage (messageIDs: string[], status: WA_MESSAGE_STATUS_TYPE, chat: WAChat) {
for (let msg of chat.messages) {
if (messageIDs.includes(msg.key.id) && msg.status < status) {
for (let id of messageIDs) {
let msg = chat.messages.get (GET_MESSAGE_ID({ id, fromMe: true })) || chat.messages.get (GET_MESSAGE_ID({ id, fromMe: false }))
if (msg && msg.status < status) {
if (status <= WA_MESSAGE_STATUS_TYPE.PENDING) msg.status = status
else if (isGroupID(chat.jid)) msg.status = status-1
else msg.status = status

View File

@@ -6,7 +6,7 @@ import {
WAMetric,
WAFlag,
} from '../WAConnection/Constants'
import { generateProfilePicture, waChatUniqueKey, whatsappID, unixTimestampSeconds } from './Utils'
import { generateProfilePicture, WA_CHAT_KEY, whatsappID, unixTimestampSeconds } from './Utils'
import { Mutex } from './Mutex'
// All user related functions -- get profile picture, set status etc.
@@ -106,7 +106,7 @@ export class WAConnection extends Base {
chat.imgUrl === undefined && await this.setProfilePicture (chat)
))
)
const cursor = (chats[chats.length-1] && chats.length >= count) ? waChatUniqueKey (chats[chats.length-1]) : null
const cursor = (chats[chats.length-1] && chats.length >= count) ? WA_CHAT_KEY (chats[chats.length-1]) : null
return { chats, cursor }
}
/**

View File

@@ -208,6 +208,8 @@ export class WAConnection extends Base {
const json = ['action', {epoch: this.msgCount.toString(), type: 'relay'}, [['message', null, message]]]
const flag = message.key.remoteJid === this.user.jid ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
await this.query({json, binaryTags: [WAMetric.message, flag], tag: message.key.id, expect200: true})
message.status = WA_MESSAGE_STATUS_TYPE.SERVER_ACK
await this.chatAddMessageAppropriate (message)
}
/**

View File

@@ -1,6 +1,6 @@
import {WAConnection as Base} from './6.MessagesSend'
import { MessageType, WAMessageKey, MessageInfo, WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto } from './Constants'
import { whatsappID, delay, toNumber, unixTimestampSeconds } from './Utils'
import { whatsappID, delay, toNumber, unixTimestampSeconds, GET_MESSAGE_ID, WA_MESSAGE_ID } from './Utils'
import { Mutex } from './Mutex'
export class WAConnection extends Base {
@@ -70,53 +70,71 @@ export class WAConnection extends Base {
const read = await this.setQuery ([['read', attributes, null]])
return read
}
async fetchMessagesFromWA (jid: string, count: number, indexMessage?: { id?: string; fromMe?: boolean }, mostRecentFirst: boolean = true) {
const json = [
'query',
{
epoch: this.msgCount.toString(),
type: 'message',
jid: jid,
kind: mostRecentFirst ? 'before' : 'after',
count: count.toString(),
index: indexMessage?.id,
owner: indexMessage?.fromMe === false ? 'false' : 'true',
},
null,
]
const response = await this.query({json, binaryTags: [WAMetric.queryMessages, WAFlag.ignore], expect200: false})
return (response[2] as WANode[])?.map(item => item[2] as WAMessage) || []
}
/**
* Load the conversation with a group or person
* @param count the number of messages to load
* @param before the data for which message to offset the query by
* @param cursor the data for which message to offset the query by
* @param mostRecentFirst retreive the most recent message first or retreive from the converation start
*/
@Mutex ((jid, _, before, mostRecentFirst) => jid + (before?.id || '') + mostRecentFirst)
async loadMessages (
jid: string,
count: number,
before?: { id?: string; fromMe?: boolean },
cursor?: { id?: string; fromMe?: boolean },
mostRecentFirst: boolean = true
) {
jid = whatsappID(jid)
const retreive = async (count: number, indexMessage: any) => {
const json = [
'query',
{
epoch: this.msgCount.toString(),
type: 'message',
jid: jid,
kind: mostRecentFirst ? 'before' : 'after',
count: count.toString(),
index: indexMessage?.id,
owner: indexMessage?.fromMe === false ? 'false' : 'true',
},
null,
]
const response = await this.query({json, binaryTags: [WAMetric.queryMessages, WAFlag.ignore], expect200: false})
return (response[2] as WANode[])?.map(item => item[2] as WAMessage) || []
}
const retreive = (count: number, indexMessage: any) => this.fetchMessagesFromWA (jid, count, indexMessage, mostRecentFirst)
const chat = this.chats.get (jid)
const hasCursor = cursor?.id && typeof cursor?.fromMe !== 'undefined'
const cursorValue = hasCursor && chat.messages.get (GET_MESSAGE_ID(cursor))
let messages: WAMessage[]
if (!before && chat && mostRecentFirst) {
messages = chat.messages
if (chat && mostRecentFirst && (!hasCursor || cursorValue)) {
messages = chat.messages.paginatedByValue (cursorValue, count, null, 'before')
const diff = count - messages.length
if (diff < 0) {
messages = messages.slice(-count); // get the last X messages
messages = messages.slice(-count) // get the last X messages
} else if (diff > 0) {
let fepoch = (messages[0] && messages[0]['epoch']) || 0
const extra = await retreive (diff, messages[0]?.key)
// add to DB
for (let i = extra.length-1;i >= 0; i--) {
const m = extra[i]
fepoch -= 1
m['epoch'] = fepoch
if(chat.messages.length < this.maxCachedMessages && !chat.messages.get (WA_MESSAGE_ID(m))) {
chat.messages.insert(m)
}
}
messages.unshift (...extra)
}
} else messages = await retreive (count, before)
let cursor
} else messages = await retreive (count, cursor)
if (messages[0]) cursor = { id: messages[0].key.id, fromMe: messages[0].key.fromMe }
else cursor = null
return {messages, cursor}
}
/**
@@ -160,7 +178,7 @@ export class WAConnection extends Base {
*/
async findMessage (jid: string, chunkSize: number, onMessage: (m: WAMessage) => boolean) {
const chat = this.chats.get (whatsappID(jid))
let count = chat?.messages?.length || chunkSize
let count = chat?.messages?.all().length || chunkSize
let offsetID
while (true) {
const {messages, cursor} = await this.loadMessages(jid, count, offsetID, true)
@@ -196,13 +214,24 @@ export class WAConnection extends Base {
return messages
}
/** Load a single message specified by the ID */
async loadMessage (jid: string, messageID: string) {
// load the message before the given message
let messages = (await this.loadMessages (jid, 1, {id: messageID, fromMe: true})).messages
if (!messages[0]) messages = (await this.loadMessages (jid, 1, {id: messageID, fromMe: false})).messages
// the message after the loaded message is the message required
const actual = await this.loadMessages (jid, 1, messages[0] && messages[0].key, false)
return actual.messages[0]
async loadMessage (jid: string, id: string) {
let message: WAMessage
jid = whatsappID (jid)
const chat = this.chats.get (jid)
if (chat) {
// see if message is present in cache
message = chat.messages.get (GET_MESSAGE_ID({ id, fromMe: true })) || chat.messages.get (GET_MESSAGE_ID({ id, fromMe: false }))
}
if (!message) {
// load the message before the given message
let messages = (await this.loadMessages (jid, 1, {id, fromMe: true})).messages
if (!messages[0]) messages = (await this.loadMessages (jid, 1, {id, fromMe: false})).messages
// the message after the loaded message is the message required
const actual = await this.loadMessages (jid, 1, messages[0] && messages[0].key, false)
message = actual.messages[0]
}
return message
}
/**
* Search WhatsApp messages with a given text string
@@ -246,9 +275,9 @@ export class WAConnection extends Base {
const chat = this.chats.get (whatsappID(messageKey.remoteJid))
if (chat) {
chat.messages = chat.messages.filter (m => m.key.id !== messageKey.id)
const value = chat.messages.get (GET_MESSAGE_ID(messageKey))
value && chat.messages.delete (value)
}
return result
}
/**

View File

@@ -1,5 +1,5 @@
import {WAConnection as Base} from './7.MessagesExtra'
import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants'
import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification, MessageLogLevel } from '../WAConnection/Constants'
import { GroupSettingChange } from './Constants'
import { generateMessageID } from '../WAConnection/Utils'
@@ -48,6 +48,18 @@ export class WAConnection extends Base {
*/
groupCreate = async (title: string, participants: string[]) => {
const response = await this.groupQuery('create', null, title, participants) as WAGroupCreateResponse
const gid = response.gid
try {
await this.groupMetadata (gid)
} catch (error) {
this.log (`error in group creation: ${error}, switching gid & checking`, MessageLogLevel.info)
// if metadata is not available
const comps = gid.replace ('@g.us', '').split ('-')
response.gid = `${comps[0]}-${+comps[1] + 1}@g.us`
await this.groupMetadata (gid)
this.log (`group ID switched from ${gid} to ${response.gid}`, MessageLogLevel.info)
}
await this.chatAdd (response.gid, title)
return response
}

View File

@@ -19,6 +19,7 @@ export type WAContextInfo = proto.IContextInfo
export type WAGenericMediaMessage = proto.IVideoMessage | proto.IImageMessage | proto.IAudioMessage | proto.IDocumentMessage | proto.IStickerMessage
export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE
export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS
import KeyedDB from '@adiwajshing/keyed-db'
export interface WALocationMessage {
degreesLatitude: number
@@ -65,8 +66,8 @@ export enum ReconnectMode {
onAllErrors = 2
}
export type WAConnectOptions = {
/** timeout after which the connect attempt will fail, set to null for default timeout value */
timeoutMs?: number
/** New QR generation interval, set to null if you don't want to regenerate */
regenerateQRIntervalMs?: number
/** fails the connection if no data is received for X seconds */
maxIdleTimeMs?: number
/** maximum attempts to connect */
@@ -202,7 +203,7 @@ export interface WAChat {
name?: string
// Baileys added properties
messages: WAMessage[]
messages: KeyedDB<WAMessage>
imgUrl?: string
}
export enum WAMetric {

View File

@@ -9,7 +9,7 @@ import { URL } from 'url'
import { Agent } from 'https'
import Decoder from '../Binary/Decoder'
import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageContent, BaileysError, WAMessageProto, TimedOutError, CancelledError, WAGenericMediaMessage } from './Constants'
import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageContent, BaileysError, WAMessageProto, TimedOutError, CancelledError, WAGenericMediaMessage, WAMessage, WAMessageKey } from './Constants'
const platformMap = {
'aix': 'AIX',
@@ -30,9 +30,16 @@ function hashCode(s: string) {
return h;
}
export const toNumber = (t: Long | number) => (t['low'] || t) as number
export const waChatUniqueKey = (c: WAChat) => ((c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending
export const WA_CHAT_KEY = (c: WAChat) => ((c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending
export const WA_CHAT_KEY_PIN = (c: WAChat) => ((c.pin ? 1 : 0)*1000000 + (c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending
export const WA_MESSAGE_KEY = (m: WAMessage) => toNumber (m.messageTimestamp)*1000 + (m['epoch'] || 0)%1000
export const WA_MESSAGE_ID = (m: WAMessage) => GET_MESSAGE_ID (m.key)
export const GET_MESSAGE_ID = (key: WAMessageKey) => `${key.id}|${key.fromMe ? 1 : 0}`
export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net')
export const isGroupID = (jid: string) => jid?.includes ('@g.us')
export const isGroupID = (jid: string) => jid?.endsWith ('@g.us')
export function shallowChanges <T> (old: T, current: T): Partial<T> {
let changes: Partial<T> = {}