UNPKG

11.2 kBPlain TextView Raw
1import fs from 'fs'
2import path from 'path'
3import makeDeviceId from './FacebookDeviceId'
4import HttpApi from './http/HttpApi'
5import MqttApi from './mqtt/MqttApi'
6import Session from './types/Session'
7import Thread, { parseThread } from './types/Thread'
8import User, { parseUser } from './types/User'
9import debug from 'debug'
10import { Readable } from 'stream'
11import { PublishPacket } from './mqtt/messages/Publish'
12import Message, { MessageOptions, parseThreadMessage, parseDeltaMessage } from './types/Message'
13import parseDeltaEvent from './types/events/parseDeltaEvent'
14import EventEmitter from 'events'
15import { AttachmentNotFoundError, AttachmentURLMissingError } from './types/Errors'
16import StrictEventEmitter from 'strict-event-emitter-types'
17import ClientEvents from './ClientEvents'
18import * as Payloads from './mqtt/payloads'
19import DeviceId from './types/DeviceId'
20
21const debugLog = debug('fblib')
22
23export interface ClientOptions {
24 selfListen?: boolean
25 session?: Session
26 deviceId?: DeviceId
27}
28
29type ClientEmitter = StrictEventEmitter<EventEmitter, ClientEvents>
30
31// 🥖
32/**
33 * Main client class
34 */
35export default class Client extends (EventEmitter as { new(): ClientEmitter }) {
36 private mqttApi: MqttApi
37 private httpApi: HttpApi
38 private readonly session: Session | null
39 private seqId = ''
40 loggedIn: boolean = false
41 private options: ClientOptions
42
43 constructor (options: ClientOptions = { selfListen: false, session: null }) {
44 super()
45
46 this.options = options
47
48 this.mqttApi = new MqttApi()
49 this.httpApi = new HttpApi()
50
51 let session = options.session
52 if (!session) {
53 session = { tokens: null, deviceId: null }
54 }
55
56 if (options.deviceId) {
57 session.deviceId = options.deviceId
58 }
59
60 if (!session.deviceId) {
61 const deviceId = makeDeviceId()
62 session.deviceId = deviceId
63 this.httpApi.deviceId = deviceId.deviceId
64 }
65
66 if (session.tokens) {
67 this.httpApi.token = session.tokens.access_token
68 }
69
70 this.session = session
71 }
72
73 async login (email: string, password: string) {
74 // trim to check for spaces (which are truthy)
75 if (this.loggedIn) throw new Error('Already logged in!')
76 if (
77 !email || typeof email !== 'string' || !email.trim() ||
78 !password || typeof password !== 'string' || !password.trim()
79 ) throw new Error('Wrong username/password!')
80 await this.doLogin(email, password)
81 this.loggedIn = true
82 }
83
84 private doLogin (login: string, password: string) {
85 return new Promise(async (resolve, reject) => {
86 if (!this.session.tokens) {
87 let tokens
88 try {
89 tokens = await this.httpApi.auth(login, password)
90 } catch (err) {
91 return reject(err)
92 }
93 this.httpApi.token = tokens.access_token
94 this.session.tokens = tokens
95 }
96
97 this.mqttApi.on('publish', async (publish: PublishPacket) => {
98 debugLog(publish.topic)
99 if (publish.topic === '/send_message_response') {
100 const response = JSON.parse(publish.data.toString('utf8'))
101 debugLog(response)
102 this.mqttApi.emit('sentMessage:' + response.msgid, response)
103 }
104 if (publish.topic === '/t_ms') this.handleMS(publish.data.toString('utf8'))
105 })
106
107 this.mqttApi.on('connected', async () => {
108 let viewer
109 try {
110 ({ viewer } = await this.httpApi.querySeqId())
111 } catch (err) {
112 return reject(err)
113 }
114 const seqId = viewer.message_threads.sync_sequence_id
115 this.seqId = seqId
116 resolve()
117 if (!this.session.tokens.syncToken) {
118 await this.createQueue(seqId)
119 return
120 }
121
122 await this.createQueue(seqId)
123 })
124
125 try {
126 await this.mqttApi.connect(
127 this.session.tokens,
128 this.session.deviceId
129 )
130 } catch (err) {
131 return reject(err)
132 }
133 })
134 }
135
136 getSession () {
137 return this.session
138 }
139
140 sendMessage (threadId: string, message: string, options?: MessageOptions) {
141 return this.mqttApi.sendMessage(threadId, message, options)
142 }
143
144 /**
145 * Indicate that the user is currently present in the conversation.
146 * Only relevant for non-group conversations
147 */
148 async sendPresenceState (recipientUserId: string, present: boolean) {
149 const payload = new Payloads.PresenceState(recipientUserId, present)
150 return this.mqttApi.sendPublish(payload.getTopic(), await Payloads.encodePayload(payload))
151 }
152
153 /**
154 * Send "User is typing" message.
155 * In a non-group conversation, sendPresenceState() must be called first.
156 */
157 async sendTypingState (threadOrRecipientUserId: string, present: boolean) {
158 const payload = new Payloads.TypingState(this.session.tokens.uid, present, threadOrRecipientUserId)
159 return this.mqttApi.sendPublish(payload.getTopic(), await Payloads.encodePayload(payload))
160 }
161
162 /**
163 * Mark a message as read.
164 */
165 async sendReadReceipt (message: Message) {
166 const payload = new Payloads.ReadReceipt(message)
167 return this.mqttApi.sendPublish(payload.getTopic(), await Payloads.encodePayload(payload))
168 }
169
170 async getThreadList (count: number): Promise<Thread[]> {
171 const threads = await this.httpApi.threadListQuery(count)
172 return threads.viewer.message_threads.nodes.map(parseThread)
173 }
174
175 sendAttachmentFile (threadId: string, attachmentPath: string, extension?: string) {
176 if (!fs.existsSync(attachmentPath)) throw new AttachmentNotFoundError(attachmentPath)
177 const stream = fs.createReadStream(attachmentPath)
178 if (!extension) extension = path.parse(attachmentPath).ext
179 const length = fs.statSync(attachmentPath).size.toString()
180 return this.httpApi.sendImage(stream, extension, this.session.tokens.uid, threadId, length)
181 }
182
183 sendAttachmentStream (threadId: string, extension: string, attachment: Readable) {
184 return this.httpApi.sendImage(attachment, extension, this.session.tokens.uid, threadId)
185 }
186
187 async getAttachmentURL (messageId: string, attachmentId: string): Promise<string> {
188 const attachment = await this.httpApi.getAttachment(messageId, attachmentId)
189 if (!attachment.redirect_uri) throw new AttachmentURLMissingError(attachment)
190 return attachment.redirect_uri
191 }
192
193 getAttachmentInfo (messageId: string, attachmentId: string) {
194 return this.httpApi.getAttachment(messageId, attachmentId)
195 }
196
197 async getStickerURL (stickerId: number): Promise<string> {
198 const sticker = await this.httpApi.getSticker(stickerId)
199 return sticker[stickerId.toString()].thread_image.uri
200 }
201
202 async getThreadInfo (threadId: string): Promise<Thread> {
203 const res = await this.httpApi.threadQuery(threadId)
204 const thread = res[threadId]
205 if (!thread) return null
206 return parseThread(thread)
207 }
208
209 async getUserInfo (userId: string): Promise<User> {
210 const res = await this.httpApi.userQuery(userId)
211 const user = res[userId]
212 if (!user) return null
213 return parseUser(user)
214 }
215
216 async getMessages (threadId: string, count: number): Promise<Message> {
217 const res = await this.httpApi.threadMessagesQuery(threadId, count)
218 const thread = res[threadId]
219 if (!thread) return null
220 return thread.messages.nodes.map(message => parseThreadMessage(threadId, message))
221 }
222
223 private async createQueue (seqId: number) {
224
225 // sync_api_version 3: You receive /t_ms payloads as json
226 // sync_api_version 10: You receiove /t_ms payloads as thrift,
227 // and connectQueue() does not have to be called.
228 // Note that connectQueue() should always use 10 instead.
229
230 const obj = (
231 {
232 initial_titan_sequence_id: seqId,
233 delta_batch_size: 125,
234 device_params: {
235 image_sizes: {
236 0: '4096x4096',
237 4: '312x312',
238 1: '768x768',
239 2: '420x420',
240 3: '312x312'
241 },
242 animated_image_format: 'WEBP,GIF',
243 animated_image_sizes: {
244 0: '4096x4096',
245 4: '312x312',
246 1: '768x768',
247 2: '420x420',
248 3: '312x312'
249 }
250 },
251 entity_fbid: this.session.tokens.uid,
252 sync_api_version: 3, // Must be 3 instead of 10 to receive json payloads
253 encoding: 'JSON', // Must be removed if using sync_api_version 10
254 queue_params: {
255 // Array of numbers -> Some bitwise encoding scheme -> base64. Numbers range from 0 to 67
256 // Decides what type of /t_ms delta messages you get. Flags unknown, copy-pasted from app.
257 client_delta_sync_bitmask: 'Amvr2dBlf7PNgA',
258 graphql_query_hashes: {
259 xma_query_id: '306810703252313'
260 },
261 graphql_query_params: {
262 306810703252313: {
263 xma_id: '<ID>',
264 small_preview_width: 624,
265 small_preview_height: 312,
266 large_preview_width: 1536,
267 large_preview_height: 768,
268 full_screen_width: 4096,
269 full_screen_height: 4096,
270 blur: 0.0,
271 nt_context: {
272 styles_id: 'fe1fd5357bb40c81777dc915dfbd6aa4',
273 pixel_ratio: 3.0
274 }
275 }
276 }
277 }
278 }
279 )
280
281 await this.mqttApi.sendPublish(
282 '/messenger_sync_create_queue',
283 JSON.stringify(obj)
284 )
285 }
286
287 private async connectQueue (seqId) {
288
289 // If createQueue() uses sync_api_version 10, this does not need to be called, and you will not receive json payloads.
290 // If this does not use sync_api_version 10, you will not receive all messages (e.g. reactions )
291 // Send the thrift-equivalent payload to /t_ms_gd and you will receive mostly thrift-encoded payloads instead.
292
293 const obj = {
294 delta_batch_size: 125,
295 max_deltas_able_to_process: 1250,
296 sync_api_version: 10, // Must be 10 to receive some messages
297 encoding: 'JSON',
298
299 last_seq_id: seqId,
300 sync_token: this.session.tokens.syncToken
301 }
302
303 await this.mqttApi.sendPublish(
304 '/messenger_sync_get_diffs',
305 JSON.stringify(obj)
306 )
307 }
308
309 private async handleMS (ms: string) {
310 let data
311 try {
312 data = JSON.parse(ms.replace('\u0000', ''))
313 } catch (err) {
314 console.error('Error while parsing the following message:')
315 console.error(ms)
316 return
317 }
318
319 // Handled on queue creation
320 if (data.syncToken) {
321 this.session.tokens.syncToken = data.syncToken
322 await this.connectQueue(this.seqId)
323 return
324 }
325
326 if (!data.deltas || !Array.isArray(data.deltas)) return
327
328 data.deltas.forEach(delta => {
329 debugLog(delta)
330 this.handleMessage(delta)
331 })
332 }
333
334 private handleMessage (event: any) {
335 if (event.deltaNewMessage) {
336 const message = parseDeltaMessage(event.deltaNewMessage)
337 if (!message || message.authorId === this.session.tokens.uid && !this.options.selfListen) return
338 this.emit('message', message)
339 }
340
341 const deltaEvent = parseDeltaEvent(event)
342 if (!deltaEvent) return
343 this.emit('event', deltaEvent)
344 // @ts-ignore TypeScript somehow doesn't recognize that EventType is compatible with the properties defined in ClientEvents
345 this.emit(deltaEvent.type, deltaEvent.event)
346 }
347}