finalize multi-device

This commit is contained in:
Adhiraj Singh
2021-09-15 13:40:02 +05:30
parent 9cba28e891
commit f267f27ada
82 changed files with 35228 additions and 10644 deletions

430
src/Socket/chats.ts Normal file
View File

@@ -0,0 +1,430 @@
import { decodeSyncdPatch, encodeSyncdPatch } from "../Utils/chat-utils";
import { SocketConfig, WAPresence, PresenceData, Chat, ChatModification, WAMediaUpload } from "../Types";
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidNormalizedUser, S_WHATSAPP_NET } from "../WABinary";
import { makeSocket } from "./socket";
import { proto } from '../../WAProto'
import { toNumber } from "../Utils/generics";
import { compressImage, generateProfilePicture } from "..";
export const makeChatsSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeSocket(config)
const {
ev,
ws,
authState,
generateMessageTag,
sendNode,
query
} = sock
const interactiveQuery = async(userNodes: BinaryNode[], queryNode: BinaryNode) => {
const result = await query({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'get',
xmlns: 'usync',
},
content: [
{
tag: 'usync',
attrs: {
sid: generateMessageTag(),
mode: 'query',
last: 'true',
index: '0',
context: 'interactive',
},
content: [
{
tag: 'query',
attrs: { },
content: [ queryNode ]
},
{
tag: 'list',
attrs: { },
content: userNodes
}
]
}
],
})
const usyncNode = getBinaryNodeChild(result, 'usync')
const listNode = getBinaryNodeChild(usyncNode, 'list')
const users = getBinaryNodeChildren(listNode, 'user')
return users
}
const onWhatsApp = async(...jids: string[]) => {
const results = await interactiveQuery(
[
{
tag: 'user',
attrs: { },
content: jids.map(
jid => ({
tag: 'contact',
attrs: { },
content: `+${jid}`
})
)
}
],
{ tag: 'contact', attrs: { } }
)
return results.map(user => {
const contact = getBinaryNodeChild(user, 'contact')
return { exists: contact.attrs.type === 'in', jid: user.attrs.jid }
}).filter(item => item.exists)
}
const fetchStatus = async(jid: string) => {
const [result] = await interactiveQuery(
[{ tag: 'user', attrs: { jid } }],
{ tag: 'status', attrs: { } }
)
if(result) {
const status = getBinaryNodeChild(result, 'status')
return {
status: status.content!.toString(),
setAt: new Date(+status.attrs.t * 1000)
}
}
}
const updateProfilePicture = async(jid: string, content: WAMediaUpload) => {
const { img } = await generateProfilePicture('url' in content ? content.url.toString() : content)
await query({
tag: 'iq',
attrs: {
to: jidNormalizedUser(jid),
type: 'set',
xmlns: 'w:profile:picture'
},
content: [
{
tag: 'picture',
attrs: { type: 'image' },
content: img
}
]
})
}
const fetchBlocklist = async() => {
const result = await query({
tag: 'iq',
attrs: {
xmlns: 'blocklist',
to: S_WHATSAPP_NET,
type: 'get'
}
})
console.log('blocklist', result)
}
const updateBlockStatus = async(jid: string, action: 'block' | 'unblock') => {
await query({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'set'
},
content: [
{
tag: 'item',
attrs: {
action,
jid
}
}
]
})
}
const fetchPrivacySettings = async() => {
const result = await query({
tag: 'iq',
attrs: {
xmlns: 'privacy',
to: S_WHATSAPP_NET,
type: 'get'
}
})
console.log('privacy', result)
}
const updateAccountSyncTimestamp = async() => {
await sendNode({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'set',
xmlns: 'urn:xmpp:whatsapp:dirty',
id: generateMessageTag(),
},
content: [
{
tag: 'clean',
attrs: { }
}
]
})
}
const collectionSync = async() => {
const COLLECTIONS = ['critical_block', 'critical_unblock_low', 'regular_low', 'regular_high']
await sendNode({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
xmlns: 'w:sync:app:state',
type: 'set',
id: generateMessageTag(),
},
content: [
{
tag: 'sync',
attrs: { },
content: COLLECTIONS.map(
name => ({
tag: 'collection',
attrs: { name, version: '0', return_snapshot: 'true' }
})
)
}
]
})
logger.info('synced collection')
}
const profilePictureUrl = async(jid: string) => {
const result = await query({
tag: 'iq',
attrs: {
to: jid,
type: 'get',
xmlns: 'w:profile:picture'
},
content: [
{ tag: 'picture', attrs: { type: 'preview', query: 'url' } }
]
})
const child = getBinaryNodeChild(result, 'picture')
return child?.attrs?.url
}
const sendPresenceUpdate = async(type: WAPresence, toJid?: string) => {
if(type === 'available' || type === 'unavailable') {
await sendNode({
tag: 'presence',
attrs: {
name: authState.creds.me!.name,
type
}
})
} else {
await sendNode({
tag: 'chatstate',
attrs: {
from: authState.creds.me!.id!,
to: toJid,
},
content: [
{ tag: type, attrs: { } }
]
})
}
}
const presenceSubscribe = (toJid: string) => (
sendNode({
tag: 'presence',
attrs: {
to: toJid,
id: generateMessageTag(),
type: 'subscribe'
}
})
)
const handlePresenceUpdate = ({ tag, attrs, content }: BinaryNode) => {
let presence: PresenceData
const jid = attrs.from
const participant = attrs.participant || attrs.from
if(tag === 'presence') {
presence = {
lastKnownPresence: attrs.type === 'unavailable' ? 'unavailable' : 'available',
lastSeen: attrs.t ? +attrs.t : undefined
}
} else if(Array.isArray(content)) {
const [firstChild] = content
let type = firstChild.tag as WAPresence
if(type === 'paused') {
type = 'available'
}
presence = { lastKnownPresence: type }
} else {
logger.error({ tag, attrs, content }, 'recv invalid presence node')
}
if(presence) {
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } })
}
}
const processSyncActions = (actions: { action: proto.ISyncActionValue, index: [string, string] }[]) => {
const updates: Partial<Chat>[] = []
for(const { action, index: [_, id] } of actions) {
const update: Partial<Chat> = { id }
if(action?.muteAction) {
update.mute = action.muteAction?.muted ?
toNumber(action.muteAction!.muteEndTimestamp!) :
undefined
} else if(action?.archiveChatAction) {
update.archive = !!action.archiveChatAction?.archived
} else if(action?.markChatAsReadAction) {
update.unreadCount = !!action.markChatAsReadAction?.read ? 0 : -1
} else if(action?.clearChatAction) {
console.log(action.clearChatAction)
} else if(action?.contactAction) {
ev.emit('contacts.update', [{ id, name: action.contactAction!.fullName }])
} else if(action?.pushNameSetting) {
authState.creds.me!.name = action?.pushNameSetting?.name!
ev.emit('auth-state.update', authState)
} else {
logger.warn({ action, id }, 'unprocessable update')
}
updates.push(update)
}
ev.emit('chats.update', updates)
}
const patchChat = async(
jid: string,
modification: ChatModification
) => {
const patch = encodeSyncdPatch(modification, { remoteJid: jid }, authState)
const type = 'regular_high'
const ver = authState.creds.appStateVersion![type] || 0
const node: BinaryNode = {
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'set',
xmlns: 'w:sync:app:state'
},
content: [
{
tag: 'patch',
attrs: {
name: type,
version: (ver+1).toString(),
return_snapshot: 'false'
},
content: proto.SyncdPatch.encode(patch).finish()
}
]
}
await query(node)
authState.creds.appStateVersion![type] += 1
ev.emit('auth-state.update', authState)
}
const resyncState = async(name: 'regular_high' | 'regular_low' = 'regular_high') => {
authState.creds.appStateVersion = authState.creds.appStateVersion || {
regular_high: 0,
regular_low: 0,
critical_unblock_low: 0,
critical_block: 0
}
const result = await query({
tag: 'iq',
attrs: {
type: 'set',
xmlns: 'w:sync:app:state',
to: S_WHATSAPP_NET
},
content: [
{
tag: 'sync',
attrs: { },
content: [
{
tag: 'collection',
attrs: {
name,
version: authState.creds.appStateVersion[name].toString(),
return_snapshot: 'false'
}
}
]
}
]
})
const syncNode = getBinaryNodeChild(result, 'sync')
const collectionNode = getBinaryNodeChild(syncNode, 'collection')
const patchesNode = getBinaryNodeChild(collectionNode, 'patches')
const patches = getBinaryNodeChildren(patchesNode, 'patch')
const successfulMutations = patches.flatMap(({ content }) => {
if(content) {
const syncd = proto.SyncdPatch.decode(content! as Uint8Array)
const version = toNumber(syncd.version!.version!)
if(version) {
authState.creds.appStateVersion[name] = Math.max(version, authState.creds.appStateVersion[name])
}
const { mutations, failures } = decodeSyncdPatch(syncd, authState)
if(failures.length) {
logger.info(
{ failures: failures.map(f => ({ trace: f.stack, data: f.data })) },
'failed to decode'
)
}
return mutations
}
return []
})
processSyncActions(successfulMutations)
ev.emit('auth-state.update', authState)
}
ws.on('CB:presence', handlePresenceUpdate)
ws.on('CB:chatstate', handlePresenceUpdate)
ws.on('CB:notification,type:server_sync', (node: BinaryNode) => {
const update = getBinaryNodeChild(node, 'collection')
if(update) {
resyncState(update.attrs.name as any)
}
})
ev.on('connection.update', ({ connection }) => {
if(connection === 'open') {
sendPresenceUpdate('available')
fetchBlocklist()
fetchPrivacySettings()
//collectionSync()
}
})
return {
...sock,
patchChat,
sendPresenceUpdate,
presenceSubscribe,
profilePictureUrl,
onWhatsApp,
fetchBlocklist,
fetchPrivacySettings,
fetchStatus,
updateProfilePicture,
updateBlockStatus
}
}

149
src/Socket/groups.ts Normal file
View File

@@ -0,0 +1,149 @@
import { generateMessageID } from "../Utils";
import { SocketConfig, GroupMetadata, ParticipantAction } from "../Types";
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidDecode, jidEncode } from "../WABinary";
import { makeChatsSocket } from "./chats";
const extractGroupMetadata = (result: BinaryNode) => {
const group = getBinaryNodeChild(result, 'group')
const descChild = getBinaryNodeChild(group, 'description')
let desc: string | undefined
let descId: string | undefined
if(descChild) {
desc = getBinaryNodeChild(descChild, 'body')?.content as string
descId = descChild.attrs.id
}
const metadata: GroupMetadata = {
id: jidEncode(jidDecode(group.attrs.id).user, 'g.us'),
subject: group.attrs.subject,
creation: +group.attrs.creation,
owner: group.attrs.creator,
desc,
descId,
restrict: !!getBinaryNodeChild(result, 'locked') ? 'true' : 'false',
announce: !!getBinaryNodeChild(result, 'announcement') ? 'true' : 'false',
participants: getBinaryNodeChildren(group, 'participant').map(
({ attrs }) => {
return {
id: attrs.jid,
admin: attrs.type || null as any,
}
}
)
}
return metadata
}
export const makeGroupsSocket = (config: SocketConfig) => {
const sock = makeChatsSocket(config)
const { query } = sock
const groupQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => (
query({
tag: 'iq',
attrs: {
type,
xmlns: 'w:g2',
to: jid,
},
content
})
)
const groupMetadata = async(jid: string) => {
const result = await groupQuery(
jid,
'get',
[ { tag: 'query', attrs: { request: 'interactive' } } ]
)
return extractGroupMetadata(result)
}
return {
...sock,
groupMetadata,
groupCreate: async(subject: string, participants: string[]) => {
const key = generateMessageID()
const result = await groupQuery(
'@g.us',
'set',
[
{
tag: 'create',
attrs: {
subject,
key
},
content: participants.map(jid => ({
tag: 'participant',
attrs: { jid }
}))
}
]
)
return extractGroupMetadata(result)
},
groupLeave: async(jid: string) => {
await groupQuery(
'@g.us',
'set',
[
{
tag: 'leave',
attrs: { },
content: [
{ tag: 'group', attrs: { jid } }
]
}
]
)
},
groupUpdateSubject: async(jid: string, subject: string) => {
await groupQuery(
jid,
'set',
[
{
tag: 'subject',
attrs: { },
content: Buffer.from(subject, 'utf-8')
}
]
)
},
groupParticipantsUpdate: async(
jid: string,
participants: string[],
action: ParticipantAction
) => {
const result = await groupQuery(
jid,
'set',
participants.map(
jid => ({
tag: action,
attrs: { },
content: [{ tag: 'participant', attrs: { jid } }]
})
)
)
const node = getBinaryNodeChild(result, action)
const participantsAffected = getBinaryNodeChildren(node!, 'participant')
return participantsAffected.map(p => p.attrs.jid)
},
groupInviteCode: async(jid: string) => {
const result = await groupQuery(jid, 'get', [{ tag: 'invite', attrs: {} }])
const inviteNode = getBinaryNodeChild(result, 'invite')
return inviteNode.attrs.code
},
groupToggleEphemeral: async(jid: string, ephemeralExpiration: number) => {
const content: BinaryNode = ephemeralExpiration ?
{ tag: 'ephemeral', attrs: { ephemeral: ephemeralExpiration.toString() } } :
{ tag: 'not_ephemeral', attrs: { } }
await groupQuery(jid, 'set', [content])
},
groupSettingUpdate: async(jid: string, setting: 'announcement' | 'not_announcement' | 'locked' | 'unlocked') => {
await groupQuery(jid, 'set', [ { tag: setting, attrs: { } } ])
}
}
}

13
src/Socket/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { SocketConfig } from '../Types'
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import { makeMessagesSocket as _makeSocket } from './messages-send'
// export the last socket layer
const makeWASocket = (config: Partial<SocketConfig>) => (
_makeSocket({
...DEFAULT_CONNECTION_CONFIG,
...config
})
)
export default makeWASocket

437
src/Socket/messages-recv.ts Normal file
View File

@@ -0,0 +1,437 @@
import { makeGroupsSocket } from "./groups"
import { SocketConfig, WAMessageStubType, ParticipantAction, Chat, GroupMetadata } from "../Types"
import { decodeMessageStanza, encodeBigEndian, toNumber, whatsappID } from "../Utils"
import { BinaryNode, jidDecode, jidEncode, isJidStatusBroadcast, S_WHATSAPP_NET, areJidsSameUser, getBinaryNodeChildren, getBinaryNodeChild } from '../WABinary'
import { downloadIfHistory } from '../Utils/history'
import { proto } from "../../WAProto"
import { generateSignalPubKey, xmppPreKey, xmppSignedPreKey } from "../Utils/signal"
import { KEY_BUNDLE_TYPE } from "../Defaults"
export const makeMessagesRecvSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeGroupsSocket(config)
const {
ev,
authState,
ws,
assertingPreKeys,
sendNode,
} = sock
const sendMessageAck = async({ attrs }: BinaryNode) => {
const isGroup = !!attrs.participant
const { user: meUser } = jidDecode(authState.creds.me!.id!)
const stanza: BinaryNode = {
tag: 'ack',
attrs: {
class: 'receipt',
id: attrs.id,
to: isGroup ? attrs.from : authState.creds.me!.id,
}
}
if(isGroup) {
stanza.attrs.participant = jidEncode(meUser, 's.whatsapp.net')
}
await sendNode(stanza)
}
const sendRetryRequest = async(node: BinaryNode) => {
const retryCount = +(node.attrs.retryCount || 0) + 1
const isGroup = !!node.attrs.participant
const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
const deviceIdentity = proto.ADVSignedDeviceIdentity.encode(account).finish()
await assertingPreKeys(1, async preKeys => {
const [keyId] = Object.keys(preKeys)
const key = preKeys[+keyId]
const decFrom = node.attrs.from ? jidDecode(node.attrs.from) : undefined
const receipt: BinaryNode = {
tag: 'receipt',
attrs: {
id: node.attrs.id,
type: 'retry',
to: isGroup ? node.attrs.from : jidEncode(decFrom!.user, 's.whatsapp.net', decFrom!.device, 0)
},
content: [
{
tag: 'retry',
attrs: {
count: retryCount.toString(), id: node.attrs.id,
t: node.attrs.t,
v: '1'
}
},
{
tag: 'registration',
attrs: { },
content: encodeBigEndian(authState.creds.registrationId)
}
]
}
if(node.attrs.recipient) {
receipt.attrs.recipient = node.attrs.recipient
}
if(node.attrs.participant) {
receipt.attrs.participant = node.attrs.participant
}
if(retryCount > 1) {
const exec = generateSignalPubKey(Buffer.from(KEY_BUNDLE_TYPE)).slice(0, 1);
(node.content! as BinaryNode[]).push({
tag: 'keys',
attrs: { },
content: [
{ tag: 'type', attrs: { }, content: exec },
{ tag: 'identity', attrs: { }, content: identityKey.public },
xmppPreKey(key, +keyId),
xmppSignedPreKey(signedPreKey),
{ tag: 'device-identity', attrs: { }, content: deviceIdentity }
]
})
}
await sendNode(node)
logger.info({ msgId: node.attrs.id, retryCount }, 'sent retry receipt')
ev.emit('auth-state.update', authState)
})
}
const processMessage = (message: proto.IWebMessageInfo, chatUpdate: Partial<Chat>) => {
const protocolMsg = message.message?.protocolMessage
if(protocolMsg) {
switch(protocolMsg.type) {
case proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_SHARE:
const newKeys = JSON.parse(JSON.stringify(protocolMsg.appStateSyncKeyShare!.keys))
authState.creds.appStateSyncKeys = [
...(authState.creds.appStateSyncKeys || []),
...newKeys
]
ev.emit('auth-state.update', authState)
break
case proto.ProtocolMessage.ProtocolMessageType.REVOKE:
ev.emit('messages.update', [
{
key: protocolMsg.key,
update: { message: null, messageStubType: 1, key: message.key }
}
])
break
case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING:
chatUpdate.ephemeralSettingTimestamp = toNumber(message.messageTimestamp)
chatUpdate.ephemeralExpiration = protocolMsg.ephemeralExpiration
break
}
} else if(message.messageStubType) {
const meJid = authState.creds.me!.id
const jid = message.key!.remoteJid!
//let actor = whatsappID (message.participant)
let participants: string[]
const emitParticipantsUpdate = (action: ParticipantAction) => (
ev.emit('group-participants.update', { id: jid, participants, action })
)
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [ { id: jid, ...update } ])
}
switch (message.messageStubType) {
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
participants = message.messageStubParameters.map(whatsappID)
emitParticipantsUpdate('remove')
// mark the chat read only if you left the group
if (participants.includes(meJid)) {
chatUpdate.readOnly = true
}
break
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
participants = message.messageStubParameters.map(whatsappID)
if (participants.includes(meJid)) {
chatUpdate.readOnly = false
}
emitParticipantsUpdate('add')
break
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
emitGroupUpdate({ announce })
break
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
emitGroupUpdate({ restrict })
break
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
case WAMessageStubType.GROUP_CREATE:
chatUpdate.name = message.messageStubParameters[0]
emitGroupUpdate({ subject: chatUpdate.name })
break
}
}
}
const processHistoryMessage = (item: proto.HistorySync) => {
switch(item.syncType) {
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_BOOTSTRAP:
const messages: proto.IWebMessageInfo[] = []
const chats = item.conversations!.map(
c => {
const chat: Chat = { ...c }
//@ts-expect-error
delete chat.messages
for(const item of c.messages || []) {
messages.push(item.message)
}
return chat
}
)
ev.emit('chats.set', { chats, messages })
break
case proto.HistorySync.HistorySyncHistorySyncType.PUSH_NAME:
const contacts = item.pushnames.map(
p => ({ notify: p.pushname, id: p.id })
)
ev.emit('contacts.upsert', contacts)
break
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_STATUS_V3:
// TODO
break
}
}
const processNotification = (node: BinaryNode): Partial<proto.IWebMessageInfo> => {
const result: Partial<proto.IWebMessageInfo> = { }
const child = (node.content as BinaryNode[])?.[0]
if(node.attrs.type === 'w:gp2') {
switch(child?.tag) {
case 'ephemeral':
case 'not_ephemeral':
result.message = {
protocolMessage: {
type: proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration: +(child.attrs.expiration || 0)
}
}
break
case 'promote':
case 'demote':
case 'remove':
case 'add':
case 'leave':
const stubType = `GROUP_PARTICIPANT_${child.tag!.toUpperCase()}`
result.messageStubType = WAMessageStubType[stubType]
result.messageStubParameters = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid)
break
case 'subject':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_SUBJECT
result.messageStubParameters = [ child.attrs.subject ]
break
case 'announcement':
case 'not_announcement':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_ANNOUNCE
result.messageStubParameters = [ (child.tag === 'announcement').toString() ]
break
case 'locked':
case 'unlocked':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT
result.messageStubParameters = [ (child.tag === 'locked').toString() ]
break
}
} else {
switch(child.tag) {
case 'count':
if(child.attrs.value === '0') {
logger.info('recv all pending notifications')
ev.emit('connection.update', { receivedPendingNotifications: true })
}
break
case 'devices':
const devices = getBinaryNodeChildren(child, 'device')
if(areJidsSameUser(child.attrs.jid, authState.creds!.me!.id)) {
const deviceJids = devices.map(d => d.attrs.jid)
logger.info({ deviceJids }, 'got my own devices')
}
break
}
}
if(Object.keys(result).length) {
return result
}
}
// recv a message
ws.on('CB:message', async(stanza: BinaryNode) => {
const dec = await decodeMessageStanza(stanza, authState)
const fullMessages: proto.IWebMessageInfo[] = []
for(const msg of dec.successes) {
const { attrs } = stanza
const isGroup = !!stanza.attrs.participant
const sender = (attrs.participant || attrs.from)?.toString()
const isMe = areJidsSameUser(sender, authState.creds.me!.id)
await sendMessageAck(stanza)
logger.debug({ msgId: dec.msgId, sender }, 'send message ack')
// send delivery receipt
let recpAttrs: { [_: string]: any }
if(isMe) {
recpAttrs = {
type: 'sender',
id: stanza.attrs.id,
to: stanza.attrs.from,
}
if(isGroup) {
recpAttrs.participant = stanza.attrs.participant
} else {
recpAttrs.recipient = stanza.attrs.recipient
}
} else {
const isStatus = isJidStatusBroadcast(stanza.attrs.from)
recpAttrs = {
//type: 'inactive',
id: stanza.attrs.id,
to: dec.chatId,
}
if(isGroup || isStatus) {
recpAttrs.participant = stanza.attrs.participant
}
}
await sendNode({ tag: 'receipt', attrs: recpAttrs })
logger.debug({ msgId: dec.msgId }, 'send message receipt')
const possibleHistory = downloadIfHistory(msg)
if(possibleHistory) {
const history = await possibleHistory
logger.info({ msgId: dec.msgId, type: history.syncType }, 'recv history')
processHistoryMessage(history)
} else {
const message = msg.deviceSentMessage?.message || msg
fullMessages.push({
key: {
remoteJid: dec.chatId,
fromMe: isMe,
id: dec.msgId,
participant: dec.participant
},
message,
status: isMe ? proto.WebMessageInfo.WebMessageInfoStatus.SERVER_ACK : null,
messageTimestamp: dec.timestamp,
pushName: dec.pushname
})
}
}
if(dec.successes.length) {
ev.emit('auth-state.update', authState)
if(fullMessages.length) {
ev.emit(
'messages.upsert',
{
messages: fullMessages.map(m => proto.WebMessageInfo.fromObject(m)),
type: stanza.attrs.offline ? 'append' : 'notify'
}
)
}
}
for(const { error } of dec.failures) {
logger.error(
{ msgId: dec.msgId, trace: error.stack, data: error.data },
'failure in decrypting message'
)
await sendRetryRequest(stanza)
}
})
ws.on('CB:ack,class:message', async(node: BinaryNode) => {
await sendNode({
tag: 'ack',
attrs: {
class: 'receipt',
id: node.attrs.id,
from: node.attrs.from
}
})
logger.debug({ attrs: node.attrs }, 'sending receipt for ack')
})
const handleReceipt = ({ attrs, content }: BinaryNode) => {
const sender = attrs.participant || attrs.from
const status = attrs.type === 'read' ? proto.WebMessageInfo.WebMessageInfoStatus.READ : proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK
const ids = [attrs.id]
if(Array.isArray(content)) {
const items = getBinaryNodeChildren(content[0], 'item')
ids.push(...items.map(i => i.attrs.id))
}
ev.emit('messages.update', ids.map(id => ({
key: {
remoteJid: attrs.from,
id: id,
fromMe: areJidsSameUser(sender, authState.creds.me!.id!),
participant: attrs.participant
},
update: { status }
})))
}
ws.on('CB:receipt,type:read', handleReceipt)
ws.on('CB:ack,class:receipt', handleReceipt)
ws.on('CB:notification', async(node: BinaryNode) => {
const sendAck = async() => {
await sendNode({
tag: 'ack',
attrs: {
class: 'notification',
id: node.attrs.id,
type: node.attrs.type,
to: node.attrs.from
}
})
logger.debug({ msgId: node.attrs.id }, 'ack notification')
}
await sendAck()
const msg = processNotification(node)
if(msg) {
const fromMe = areJidsSameUser(node.attrs.participant || node.attrs.from, authState.creds.me!.id)
msg.key = {
remoteJid: node.attrs.from,
fromMe,
participant: node.attrs.participant,
id: node.attrs.id
}
msg.messageTimestamp = +node.attrs.t
const fullMsg = proto.WebMessageInfo.fromObject(msg)
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
}
})
ev.on('messages.upsert', ({ messages }) => {
const chat: Partial<Chat> = { id: messages[0].key.remoteJid }
for(const msg of messages) {
processMessage(msg, chat)
if(!!msg.message && !msg.message!.protocolMessage) {
chat.conversationTimestamp = toNumber(msg.messageTimestamp)
if(!msg.key.fromMe) {
chat.unreadCount = (chat.unreadCount || 0) + 1
}
}
}
if(Object.keys(chat).length > 1) {
ev.emit('chats.update', [ chat ])
}
})
return sock
}

392
src/Socket/messages-send.ts Normal file
View File

@@ -0,0 +1,392 @@
import { makeMessagesRecvSocket } from "./messages-recv"
import { SocketConfig, MediaConnInfo, AnyMessageContent, MiscMessageGenerationOptions, WAMediaUploadFunction } from "../Types"
import { encodeWAMessage, generateMessageID, generateWAMessage } from "../Utils"
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, S_WHATSAPP_NET } from '../WABinary'
import { proto } from "../../WAProto"
import { encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, jidToSignalProtocolAddress, parseAndInjectE2ESession } from "../Utils/signal"
import { WA_DEFAULT_EPHEMERAL, DEFAULT_ORIGIN, MEDIA_PATH_MAP } from "../Defaults"
import got from "got"
import { Boom } from "@hapi/boom"
export const makeMessagesSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeMessagesRecvSocket(config)
const {
ev,
authState,
query,
generateMessageTag,
sendNode,
groupMetadata,
groupToggleEphemeral
} = sock
let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async(forceGet = false) => {
let media = await mediaConn
if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) {
mediaConn = (async() => {
const result = await query({
tag: 'iq',
attrs: {
type: 'set',
xmlns: 'w:m',
to: S_WHATSAPP_NET,
},
content: [ { tag: 'media_conn', attrs: { } } ]
})
const mediaConnNode = getBinaryNodeChild(result, 'media_conn')
const node: MediaConnInfo = {
hosts: getBinaryNodeChildren(mediaConnNode, 'host').map(
item => item.attrs as any
),
auth: mediaConnNode.attrs.auth,
ttl: +mediaConnNode.attrs.ttl,
fetchDate: new Date()
}
logger.debug('fetched media conn')
return node
})()
}
return mediaConn
}
const sendReadReceipt = async(jid: string, participant: string | undefined, messageIds: string[]) => {
const node: BinaryNode = {
tag: 'receipt',
attrs: {
id: messageIds[0],
t: Date.now().toString(),
to: jid,
type: 'read'
},
}
if(participant) {
node.attrs.participant = participant
}
messageIds = messageIds.slice(1)
if(messageIds.length) {
node.content = [
{
tag: 'list',
attrs: { },
content: messageIds.map(id => ({
tag: 'item',
attrs: { id }
}))
}
]
}
logger.debug({ jid, messageIds }, 'reading messages')
await sendNode(node)
}
const getUSyncDevices = async(jids: string[], ignoreZeroDevices: boolean) => {
const users = jids.map<BinaryNode>(jid => ({ tag: 'user', attrs: { jid } }))
const iq: BinaryNode = {
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'get',
xmlns: 'usync',
},
content: [
{
tag: 'usync',
attrs: {
sid: generateMessageTag(),
mode: 'query',
last: 'true',
index: '0',
context: 'message',
},
content: [
{
tag: 'query',
attrs: { },
content: [
{
tag: 'devices',
attrs: { version: '2' }
}
]
},
{ tag: 'list', attrs: { }, content: users }
]
},
],
}
const result = await query(iq)
let resultJids = extractDeviceJids(result)
if(ignoreZeroDevices) {
resultJids = resultJids.filter(item => item.device !== 0)
}
return resultJids
}
const assertSession = async(jid: string, force: boolean) => {
const addr = jidToSignalProtocolAddress(jid).toString()
const session = await authState.keys.getSession(addr)
if(!session || force) {
logger.debug({ jid }, `fetching session`)
const identity: BinaryNode = {
tag: 'user',
attrs: { jid, reason: 'identity' },
}
const result = await query({
tag: 'iq',
attrs: {
xmlns: 'encrypt',
type: 'get',
to: S_WHATSAPP_NET,
},
content: [
{
tag: 'key',
attrs: { },
content: [ identity ]
}
]
})
await parseAndInjectE2ESession(result, authState)
return true
}
return false
}
const createParticipantNode = async(jid: string, bytes: Buffer) => {
await assertSession(jid, false)
const { type, ciphertext } = await encryptSignalProto(jid, bytes, authState)
const node: BinaryNode = {
tag: 'to',
attrs: { jid },
content: [{
tag: 'enc',
attrs: { v: '2', type },
content: ciphertext
}]
}
return node
}
const relayMessage = async(jid: string, message: proto.IMessage, msgId?: string) => {
const { user, server } = jidDecode(jid)
const isGroup = server === 'g.us'
msgId = msgId || generateMessageID()
const encodedMsg = encodeWAMessage(message)
const participants: BinaryNode[] = []
let stanza: BinaryNode
const destinationJid = jidEncode(user, isGroup ? 'g.us' : 's.whatsapp.net')
if(isGroup) {
const { ciphertext, senderKeyDistributionMessageKey } = await encryptSenderKeyMsgSignalProto(destinationJid, encodedMsg, authState)
const groupData = await groupMetadata(jid)
const participantsList = groupData.participants.map(p => p.id)
const devices = await getUSyncDevices(participantsList, false)
logger.debug(`got ${devices.length} additional devices`)
const encSenderKeyMsg = encodeWAMessage({
senderKeyDistributionMessage: {
axolotlSenderKeyDistributionMessage: senderKeyDistributionMessageKey,
groupId: destinationJid
}
})
for(const {user, device, agent} of devices) {
const jid = jidEncode(user, 's.whatsapp.net', device, agent)
const participant = await createParticipantNode(jid, encSenderKeyMsg)
participants.push(participant)
}
const binaryNodeContent: BinaryNode[] = []
if( // if there are some participants with whom the session has not been established
// if there are, we overwrite the senderkey
!!participants.find((p) => (
!!(p.content as BinaryNode[]).find(({ attrs }) => attrs.type == 'pkmsg')
))
) {
binaryNodeContent.push({
tag: 'participants',
attrs: { },
content: participants
})
}
binaryNodeContent.push({
tag: 'enc',
attrs: { v: '2', type: 'skmsg' },
content: ciphertext
})
stanza = {
tag: 'message',
attrs: {
id: msgId,
type: 'text',
to: destinationJid
},
content: binaryNodeContent
}
} else {
const { user: meUser } = jidDecode(authState.creds.me!.id!)
const messageToMyself: proto.IMessage = {
deviceSentMessage: {
destinationJid,
message
}
}
const encodedMeMsg = encodeWAMessage(messageToMyself)
participants.push(
await createParticipantNode(jidEncode(user, 's.whatsapp.net'), encodedMsg)
)
participants.push(
await createParticipantNode(jidEncode(meUser, 's.whatsapp.net'), encodedMeMsg)
)
const devices = await getUSyncDevices([ authState.creds.me!.id!, jid ], true)
logger.debug(`got ${devices.length} additional devices`)
for(const { user, device, agent } of devices) {
const isMe = user === meUser
participants.push(
await createParticipantNode(
jidEncode(user, 's.whatsapp.net', device, agent),
isMe ? encodedMeMsg : encodedMsg
)
)
}
stanza = {
tag: 'message',
attrs: {
id: msgId,
type: 'text',
to: destinationJid
},
content: [
{
tag: 'participants',
attrs: { },
content: participants
},
]
}
}
const shouldHaveIdentity = !!participants.find((p) => (
!!(p.content as BinaryNode[]).find(({ attrs }) => attrs.type == 'pkmsg')
))
if(shouldHaveIdentity) {
(stanza.content as BinaryNode[]).push({
tag: 'device-identity',
attrs: { },
content: proto.ADVSignedDeviceIdentity.encode(authState.creds.account).finish()
})
}
logger.debug({ msgId }, 'sending message')
await sendNode(stanza)
ev.emit('auth-state.update', authState)
return msgId
}
const waUploadToServer: WAMediaUploadFunction = async(stream, { mediaType, fileEncSha256B64 }) => {
// send a query JSON to obtain the url & auth token to upload our media
let uploadInfo = await refreshMediaConn(false)
let mediaUrl: string
for (let host of uploadInfo.hosts) {
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
const url = `https://${host.hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
try {
const {body: responseText} = await got.post(
url,
{
headers: {
'Content-Type': 'application/octet-stream',
'Origin': DEFAULT_ORIGIN
},
agent: {
https: config.agent
},
body: stream
}
)
const result = JSON.parse(responseText)
mediaUrl = result?.url
if (mediaUrl) break
else {
uploadInfo = await refreshMediaConn(true)
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
}
} catch (error) {
const isLast = host.hostname === uploadInfo.hosts[uploadInfo.hosts.length-1].hostname
logger.debug(`Error in uploading to ${host.hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
}
}
if (!mediaUrl) {
throw new Boom(
'Media upload failed on all hosts',
{ statusCode: 500 }
)
}
return { mediaUrl }
}
return {
...sock,
assertSession,
relayMessage,
sendReadReceipt,
refreshMediaConn,
sendMessage: async(
jid: string,
content: AnyMessageContent,
options: MiscMessageGenerationOptions = { }
) => {
const userJid = authState.creds.me!.id
if(
typeof content === 'object' &&
'disappearingMessagesInChat' in content &&
typeof content['disappearingMessagesInChat'] !== 'undefined' &&
isJidGroup(jid)
) {
const { disappearingMessagesInChat } = content
const value = typeof disappearingMessagesInChat === 'boolean' ?
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
disappearingMessagesInChat
await groupToggleEphemeral(jid, value)
} else {
const fullMsg = await generateWAMessage(
jid,
content,
{
...options,
logger,
userJid: userJid,
// multi-device does not have this yet
//getUrlInfo: generateUrlInfo,
upload: waUploadToServer
}
)
await relayMessage(jid, fullMsg.message)
process.nextTick(() => {
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
})
return fullMsg
}
}
}
}

469
src/Socket/socket.ts Normal file
View File

@@ -0,0 +1,469 @@
import { Boom } from '@hapi/boom'
import EventEmitter from 'events'
import { promisify } from "util"
import WebSocket from "ws"
import { randomBytes } from 'crypto'
import { proto } from '../../WAProto'
import { DisconnectReason, SocketConfig, BaileysEventEmitter } from "../Types"
import { generateCurveKeyPair, initAuthState, generateRegistrationNode, configureSuccessfulPairing, generateLoginNode, encodeBigEndian, promiseTimeout } from "../Utils"
import { DEFAULT_ORIGIN, DEF_TAG_PREFIX, DEF_CALLBACK_PREFIX, KEY_BUNDLE_TYPE } from "../Defaults"
import { assertNodeErrorFree, BinaryNode, encodeBinaryNode, S_WHATSAPP_NET } from '../WABinary'
import noiseHandler from '../Utils/noise-handler'
import { generateOrGetPreKeys, xmppSignedPreKey, xmppPreKey, getPreKeys } from '../Utils/signal'
/**
* Connects to WA servers and performs:
* - simple queries (no retry mechanism, wait for connection establishment)
* - listen to messages and emit events
* - query phone connection
*/
export const makeSocket = ({
waWebSocketUrl,
connectTimeoutMs,
logger,
agent,
keepAliveIntervalMs,
version,
browser,
auth: initialAuthState
}: SocketConfig) => {
const ws = new WebSocket(waWebSocketUrl, undefined, {
origin: DEFAULT_ORIGIN,
timeout: connectTimeoutMs,
agent,
headers: {
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
'Host': 'web.whatsapp.com',
'Pragma': 'no-cache',
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits'
}
})
ws.setMaxListeners(0)
/** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */
const ephemeralKeyPair = generateCurveKeyPair()
/** WA noise protocol wrapper */
const noise = noiseHandler(ephemeralKeyPair)
const authState = initialAuthState || initAuthState()
const { creds } = authState
const ev = new EventEmitter() as BaileysEventEmitter
let lastDateRecv: Date
let epoch = 0
let keepAliveReq: NodeJS.Timeout
const uqTagId = `${randomBytes(1).toString('hex')[0]}.${randomBytes(1).toString('hex')[0]}-`
const generateMessageTag = () => `${uqTagId}${epoch++}`
const sendPromise = promisify<void>(ws.send)
/** send a raw buffer */
const sendRawMessage = (data: Buffer | Uint8Array) => {
const bytes = noise.encodeFrame(data)
return sendPromise.call(ws, bytes) as Promise<void>
}
/** send a binary node */
const sendNode = (node: BinaryNode) => {
let buff = encodeBinaryNode(node)
return sendRawMessage(buff)
}
/** await the next incoming message */
const awaitNextMessage = async(sendMsg?: Uint8Array) => {
if(ws.readyState !== ws.OPEN) {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
let onOpen: (data: any) => void
let onClose: (err: Error) => void
const result = new Promise<any>((resolve, reject) => {
onOpen = (data: any) => resolve(data)
onClose = reject
ws.on('frame', onOpen)
ws.on('close', onClose)
ws.on('error', onClose)
})
.finally(() => {
ws.off('frame', onOpen)
ws.off('close', onClose)
ws.off('error', onClose)
})
if(sendMsg) {
sendRawMessage(sendMsg).catch(onClose)
}
return result
}
/**
* Wait for a message with a certain tag to be received
* @param tag the message tag to await
* @param json query that was sent
* @param timeoutMs timeout after which the promise will reject
*/
const waitForMessage = async(msgId: string, timeoutMs?: number) => {
let onRecv: (json) => void
let onErr: (err) => void
try {
const result = await promiseTimeout(timeoutMs,
(resolve, reject) => {
onRecv = resolve
onErr = err => {
reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
}
ws.on(`TAG:${msgId}`, onRecv)
ws.on('close', onErr) // if the socket closes, you'll never receive the message
},
)
return result as any
} finally {
ws.off(`TAG:${msgId}`, onRecv)
ws.off('close', onErr) // if the socket closes, you'll never receive the message
}
}
/** send a query, and wait for its response. auto-generates message ID if not provided */
const query = async(node: BinaryNode, timeoutMs?: number) => {
if(!node.attrs.id) node.attrs.id = generateMessageTag()
const msgId = node.attrs.id
const wait = waitForMessage(msgId, timeoutMs)
await sendNode(node)
const result = await (wait as Promise<BinaryNode>)
if('tag' in result) {
assertNodeErrorFree(result)
}
return result
}
/** connection handshake */
const validateConnection = async () => {
logger.info('connected to WA Web')
const init = proto.HandshakeMessage.encode({
clientHello: { ephemeral: ephemeralKeyPair.public }
}).finish()
const result = await awaitNextMessage(init)
const handshake = proto.HandshakeMessage.decode(result)
logger.debug('handshake recv from WA Web')
const keyEnc = noise.processHandshake(handshake, creds.noiseKey)
logger.info('handshake complete')
let node: Uint8Array
if(!creds.me) {
logger.info('not logged in, attempting registration...')
node = generateRegistrationNode(creds, { version, browser })
} else {
logger.info('logging in...')
node = generateLoginNode(creds.me!.id, { version, browser })
}
const payloadEnc = noise.encrypt(node)
await sendRawMessage(
proto.HandshakeMessage.encode({
clientFinish: {
static: new Uint8Array(keyEnc),
payload: new Uint8Array(payloadEnc),
},
}).finish()
)
noise.finishInit()
startKeepAliveRequest()
}
/** get some pre-keys and do something with them */
const assertingPreKeys = async(range: number, execute: (keys: { [_: number]: any }) => Promise<void>) => {
const { newPreKeys, lastPreKeyId, preKeysRange } = generateOrGetPreKeys(authState, range)
const preKeys = await getPreKeys(authState.keys, preKeysRange[0], preKeysRange[1])
await execute(preKeys)
creds.serverHasPreKeys = true
creds.nextPreKeyId = Math.max(lastPreKeyId+1, creds.nextPreKeyId)
creds.firstUnuploadedPreKeyId = Math.max(creds.firstUnuploadedPreKeyId, lastPreKeyId+1)
await Promise.all(
Object.keys(newPreKeys).map(k => authState.keys.setPreKey(+k, newPreKeys[+k]))
)
ev.emit('auth-state.update', authState)
}
/** generates and uploads a set of pre-keys */
const uploadPreKeys = async() => {
await assertingPreKeys(50, async preKeys => {
const node: BinaryNode = {
tag: 'iq',
attrs: {
id: generateMessageTag(),
xmlns: 'encrypt',
type: 'set',
to: S_WHATSAPP_NET,
},
content: [
{ tag: 'registration', attrs: { }, content: encodeBigEndian(creds.registrationId) },
{ tag: 'type', attrs: { }, content: KEY_BUNDLE_TYPE },
{ tag: 'identity', attrs: { }, content: creds.signedIdentityKey.public },
{ tag: 'list', attrs: { }, content: Object.keys(preKeys).map(k => xmppPreKey(preKeys[+k], +k)) },
xmppSignedPreKey(creds.signedPreKey)
]
}
await sendNode(node)
logger.info('uploaded pre-keys')
})
}
const onMessageRecieved = (data: Buffer) => {
noise.decodeFrame(data, frame => {
ws.emit('frame', frame)
// if it's a binary node
if(!(frame instanceof Uint8Array)) {
const msgId = frame.attrs.id
if(logger.level === 'trace') {
logger.trace({ msgId, fromMe: false, frame }, 'communication')
}
let anyTriggered = false
/* Check if this is a response to a message we sent */
anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame)
/* Check if this is a response to a message we are expecting */
const l0 = frame.tag
const l1 = frame.attrs || { }
const l2 = Array.isArray(frame.content) ? frame.content[0]?.tag : ''
Object.keys(l1).forEach(key => {
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, frame) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, frame) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, frame) || anyTriggered
})
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, frame) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, frame) || anyTriggered
anyTriggered = ws.emit('frame', frame) || anyTriggered
if (!anyTriggered && logger.level === 'debug') {
logger.debug({ unhandled: true, msgId, fromMe: false, frame }, 'communication recv')
}
}
})
}
const end = (error: Error | undefined) => {
logger.info({ error }, 'connection closed')
clearInterval(keepAliveReq)
ws.removeAllListeners('close')
ws.removeAllListeners('error')
ws.removeAllListeners('open')
ws.removeAllListeners('message')
if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
try { ws.close() } catch { }
}
ev.emit('connection.update', {
connection: 'close',
lastDisconnect: {
error,
date: new Date()
}
})
ws.removeAllListeners('connection.update')
}
const waitForSocketOpen = async() => {
if(ws.readyState === ws.OPEN) return
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
let onOpen: () => void
let onClose: (err: Error) => void
await new Promise((resolve, reject) => {
onOpen = () => resolve(undefined)
onClose = reject
ws.on('open', onOpen)
ws.on('close', onClose)
ws.on('error', onClose)
})
.finally(() => {
ws.off('open', onOpen)
ws.off('close', onClose)
ws.off('error', onClose)
})
}
const startKeepAliveRequest = () => (
keepAliveReq = setInterval(() => {
if (!lastDateRecv) lastDateRecv = new Date()
const diff = Date.now() - lastDateRecv.getTime()
/*
check if it's been a suspicious amount of time since the server responded with our last seen
it could be that the network is down
*/
if (diff > keepAliveIntervalMs+5000) {
end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
} else if(ws.readyState === ws.OPEN) {
// if its all good, send a keep alive request
query(
{
tag: 'iq',
attrs: {
id: generateMessageTag(),
to: S_WHATSAPP_NET,
type: 'get',
xmlns: 'w:p',
},
content: [{ tag: 'ping', attrs: { } }]
},
keepAliveIntervalMs
)
.then(() => {
lastDateRecv = new Date()
logger.trace('recv keep alive')
})
.catch(err => end(err))
} else {
logger.warn('keep alive called when WS not open')
}
}, keepAliveIntervalMs)
)
/** i have no idea why this exists. pls enlighten me */
const sendPassiveIq = (tag: 'passive' | 'active') => (
sendNode({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
xmlns: 'passive',
type: 'set',
id: generateMessageTag(),
},
content: [
{ tag, attrs: { } }
]
})
)
/** logout & invalidate connection */
const logout = async() => {
await sendNode({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'set',
id: generateMessageTag(),
xmlns: 'md'
},
content: [
{
tag: 'remove-companion-device',
attrs: {
jid: authState.creds.me!.id,
reason: 'user_initiated'
}
}
]
})
end(new Boom('Intentional Logout', { statusCode: DisconnectReason.loggedOut }))
}
ws.on('message', onMessageRecieved)
ws.on('open', validateConnection)
ws.on('error', end)
ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })))
// the server terminated the connection
ws.on('CB:xmlstreamend', () => {
end(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed }))
})
// QR gen
ws.on('CB:iq,type:set,pair-device', async (stanza: BinaryNode) => {
const postQR = async() => {
const QR = await import('qrcode-terminal').catch(err => {
logger.error('add `qrcode-terminal` as a dependency to auto-print QR')
})
QR?.generate(qr, { small: true })
}
const refs = ((stanza.content[0] as BinaryNode).content as BinaryNode[]).map(n => n.content as string)
const iq: BinaryNode = {
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'result',
id: stanza.attrs.id,
}
}
const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64');
const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString('base64')
const advB64 = creds.advSecretKey
const qr = [refs[0], noiseKeyB64, identityKeyB64, advB64].join(',');
ev.emit('connection.update', { qr })
await postQR()
await sendNode(iq)
})
// device paired for the first time
// if device pairs successfully, the server asks to restart the connection
ws.on('CB:iq,,pair-success', async(stanza: BinaryNode) => {
logger.debug('pair success recv')
try {
const { reply, creds: updatedCreds } = configureSuccessfulPairing(stanza, creds)
logger.debug('pairing configured successfully')
const waiting = awaitNextMessage()
await sendNode(reply)
const value = (await waiting) as BinaryNode
if(value.tag === 'stream:error') {
if(value.attrs?.code !== '515') {
throw new Boom('Authentication failed', { statusCode: +(value.attrs.code || 500) })
}
}
Object.assign(creds, updatedCreds)
logger.info({ jid: creds.me!.id }, 'registered connection, restart server')
ev.emit('auth-state.update', authState)
ev.emit('connection.update', { isNewLogin: true, qr: undefined })
end(new Boom('Restart Required', { statusCode: DisconnectReason.restartRequired }))
} catch(error) {
logger.info({ trace: error.stack }, 'error in pairing')
end(error)
}
})
// login complete
ws.on('CB:success', async() => {
if(!creds.serverHasPreKeys) {
await uploadPreKeys()
}
await sendPassiveIq('active')
ev.emit('connection.update', { connection: 'open' })
})
// logged out
ws.on('CB:failure,reason:401', () => {
end(new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut }))
})
process.nextTick(() => {
ev.emit('connection.update', { connection: 'connecting', receivedPendingNotifications: false })
})
return {
ws,
ev,
authState,
get user () {
return authState.creds.me
},
assertingPreKeys,
generateMessageTag,
query,
waitForMessage,
waitForSocketOpen,
sendRawMessage,
sendNode,
logout,
end
}
}
export type Socket = ReturnType<typeof makeSocket>