├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package-lock.json ├── package.json ├── src ├── auth.coffee ├── channel.coffee ├── chatreq.coffee ├── client.coffee ├── init.coffee ├── initdataparser.coffee ├── messagebuilder.coffee ├── messageparser.coffee ├── pblite.coffee ├── pushdataparser.coffee ├── schema.coffee └── util.coffee └── test ├── body.html ├── convstate.json ├── sidgsid.bin ├── syncall.bin ├── test-initparse.coffee ├── test-initparsebody.coffee ├── test-messagebuilder.coffee ├── test-messageparser.coffee ├── test-pushdataparser.coffee └── test-syncallnewevents.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | node_modules 15 | npm-debug.log 16 | public 17 | .DS_Store 18 | .#* 19 | \#* 20 | bower_components 21 | doc 22 | 23 | BrowserStackLocal 24 | cookies.json 25 | login.coffee 26 | jsdom 27 | refreshtoken.txt 28 | 29 | lib 30 | 31 | Cookies-journal 32 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | node_modules 15 | npm-debug.log 16 | public 17 | .DS_Store 18 | .#* 19 | \#* 20 | bower_components 21 | doc 22 | 23 | BrowserStackLocal 24 | cookies.json 25 | login.coffee 26 | jsdom 27 | test 28 | refreshtoken.txt 29 | 30 | #src 31 | .travis.yml 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "lts/*" 5 | before_script: 6 | coffee -c -o lib src 7 | notifications: 8 | email: false 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | hangupsjs 2 | ========= 3 | 4 | [![Greenkeeper badge](https://badges.greenkeeper.io/yakyak/hangupsjs.svg)](https://greenkeeper.io/) 5 | 6 | ### 2021-04-15 v1.4.0-beta 7 | 8 | * Fix create conversation not working, and names showing up as Unknown 9 | 10 | ### 2021-04-15 v1.3.11 11 | 12 | * Fix bad crash when we don't have any active conversations. 13 | 14 | ### 2020-07-07 v1.3.10 15 | 16 | * Adds `VERSION` property to client 17 | 18 | ### 2020-06-16 v1.3.9 19 | 20 | * Fix connection issues due to server changing data to an array ([#117](https://github.com/yakyak/hangupsjs/pull/117)) 21 | 22 | ### 2018-11-08 v1.3.8 23 | 24 | * Use the name instead of the key in AF_initDataChunkQueue. ([#107](https://github.com/yakyak/hangupsjs/pull/107)) 25 | 26 | ### 2018-10-26 v1.3.7 27 | 28 | * Fix email and self_entity changing ids server-side. And initial conv list ([#104](https://github.com/yakyak/hangupsjs/pull/104)) 29 | * update versions of modules 30 | 31 | ### 2017-11-16 v1.3.6 32 | 33 | * Adds ability to modify OTR status 34 | * Add client delivery medium type 35 | * Update node version 36 | 37 | ### 2017-09-15 v1.3.5 38 | 39 | * Adds additional fields to ENTITY schema 40 | * Updates the subscribe method to only `babel` and `babel_presence_last_seen` 41 | 42 | 43 | ### 2016-12-05 v1.3.4 44 | 45 | Add support for conversation metadata fetching 46 | 47 | ### 2016-12-01 v1.3.3 48 | 49 | Fixes a breaking change of the google apis. 50 | 51 | ### 2016-01-15 v1.3.2 52 | 53 | This is a minor release. It does not solve login problems that are related to 54 | recent Google API changes. They have been solved in yakyak/yakyak client because 55 | auth method there is different. That solution involves user interaction 56 | therefore it can't be implemented hangupsjs library. 57 | 58 | We are still looking for a solution. 59 | 60 | ### 2016-01-15 v1.3.0 breaking change 61 | 62 | It seems the entities information that previously was available in the 63 | init data is no longer there. Relying on these entities would now 64 | break. 65 | 66 | [tdryer](https://github.com/algesten/hangupsjs/issues/39#issuecomment-171853264) 67 | pointed out that hangups have stopped doing this init data request, 68 | since it's not necessary. hangupsjs should follow (soon) and remove 69 | everything around pvt/init. this will be a major release. 70 | 71 | ## Summary 72 | 73 | Client library for Google Hangouts in nodejs. 74 | 75 | ## Disclaimer 76 | 77 | This library is in no way affiliated with or endorsed by Google. Use 78 | at your own risk. 79 | 80 | ## Origins 81 | 82 | Port of https://github.com/tdryer/hangups to node js. 83 | 84 | I take no credit for the excellent work of Tom Dryer putting together 85 | the original python client library for Google Hangouts. This port is 86 | simply taking his work and porting it to coffeescript step by step. 87 | 88 | The library is rather new and needs more tests, error handling etc. 89 | 90 | ## Usage 91 | 92 | ```bash 93 | $ npm install hangupsjs 94 | ``` 95 | 96 | The client is started with `connect()` passing callback function for a 97 | promise for a login object containing the credentials. 98 | 99 | Example usage (javascript below): 100 | 101 | ```coffee 102 | Client = require 'hangupsjs' 103 | Q = require 'q' 104 | 105 | # callback to get promise for creds using stdin. this in turn 106 | # means the user must fire up their browser and get the 107 | # requested token. 108 | creds = -> auth:Client.authStdin 109 | 110 | client = new Client() 111 | 112 | # set more verbose logging 113 | client.loglevel 'debug' 114 | 115 | # receive chat message events 116 | client.on 'chat_message', (ev) -> 117 | console.log ev 118 | 119 | # connect and post a message. 120 | # the id is a conversation id. 121 | client.connect(creds).then -> 122 | client.sendchatmessage('UgzJilj2Tg_oqkAaABAQ', [ 123 | [0, 'Hello World'] 124 | ]) 125 | .done() 126 | ``` 127 | 128 | The same example code in javascript: 129 | 130 | ```javascript 131 | var Client = require('hangupsjs'); 132 | var Q = require('q'); 133 | 134 | // callback to get promise for creds using stdin. this in turn 135 | // means the user must fire up their browser and get the 136 | // requested token. 137 | var creds = function() { 138 | return { 139 | auth: Client.authStdin 140 | }; 141 | }; 142 | 143 | var client = new Client(); 144 | 145 | // set more verbose logging 146 | client.loglevel('debug'); 147 | 148 | // receive chat message events 149 | client.on('chat_message', function(ev) { 150 | return console.log(ev); 151 | }); 152 | 153 | // connect and post a message. 154 | // the id is a conversation id. 155 | client.connect(creds).then(function() { 156 | return client.sendchatmessage('UgzJilj2Tg_oqkAaABAQ', 157 | [[0, 'Hello World']]); 158 | }).done(); 159 | ``` 160 | 161 | ## Long running sessions / reconnect 162 | 163 | hangupsjs will not try to keep the connection open endlessly. the push 164 | channel has some reconnect logic, but it will eventually back off with 165 | a `connect_failed` event. 166 | 167 | additionally the client also monitors activity. the push channel 168 | receives events at least every 20-30 seconds, if there are no chat 169 | events, we get a `noop`. 170 | 171 | after a successful `connect()`, the client monitors the channel to 172 | ensure we receive any event at least every 45 seconds. if 45 seconds 173 | passes and the push channel got nothing, the client stops with a 174 | `connect_failed` event. 175 | 176 | ### Example 177 | 178 | To construct a client that just doesn't give up we do: 179 | 180 | ```javascript 181 | var reconnect = function() { 182 | client.connect(creds).then(function() { 183 | // we are now connected. a `connected` 184 | // event was emitted. 185 | }); 186 | }; 187 | 188 | // whenever it fails, we try again 189 | client.on('connect_failed', function() { 190 | Q.Promise(function(rs) { 191 | // backoff for 3 seconds 192 | setTimeout(rs,3000); 193 | }).then(reconnect); 194 | }); 195 | 196 | // start connection 197 | reconnect(); 198 | ``` 199 | 200 | ## API 201 | 202 | ### High Level API 203 | 204 | High level API calls that are not doing direct hangouts calls. 205 | 206 | #### `Client()` 207 | 208 | `Client(opts)` 209 | 210 | `opts.jarstore` (optional) instance of 211 | [`Store`](https://github.com/SalesforceEng/tough-cookie) to use 212 | instead of default file persistence for cookies. 213 | 214 | `opts.cookiespath` (optional) path to file in which to store cached 215 | login cookies. Defaults to `cookies.json` in module dir. not used 216 | if `opts.jarstore` is passed. 217 | 218 | `opts.rtokenpath` (optional) path to file in which to store the 219 | oauth refresh token. Defaults to `refreshtoken.txt` in module dir. 220 | 221 | `opts.proxy` (optional) proxy URL that gets passed to 222 | request. Documentation is 223 | [here](https://github.com/request/request#proxies) 224 | 225 | #### `connect` 226 | 227 | `connect: (creds) ->` 228 | 229 | Attempts to connect the client to hangouts. See 230 | [`isInited`](#isinited) for the steps that connects the client. 231 | Returns a promise for connection. The promise only resolves when init 232 | is completed. On the [`connected`](#connected) event. 233 | 234 | `creds`: is callback that returns a promise for login creds. The creds 235 | are either `{creds:->}` or 236 | `{cookies:}` 237 | 238 | ##### email/pass 239 | 240 | To login using an email/password combo, you need to login using OAuth 241 | and provide the access token to the API. Furthermore it uses a google 242 | white listed OAuth CLIENT\_ID and CLIENT\_SECRET that shows up as 243 | "iOS Device" in your accounts page. 244 | 245 | This is the login URL, also available as `Client.OAUTH2_LOGIN_URL`. 246 | 247 | https://accounts.google.com/o/oauth2/auth?&client_id=936475272427.apps.googleusercontent.com&scope=https%3A%2F%2Fwww.google.com%2Faccounts%2FOAuthLogin&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&response_type=code 248 | 249 | The library provides a stdin-method that requests the token. 250 | 251 | ```coffee 252 | creds = -> auth:Client.authStdin 253 | 254 | client.connect(creds).then -> # and so on... 255 | ``` 256 | 257 | ##### cookies 258 | 259 | The other way to log in is to provide a string array of cookies for 260 | the `google.com` domain that are set up as part of a successful login. 261 | 262 | Typically these cookies are called: `NID`, `SID`, `HSID`, `SSID`, 263 | `APISID`, `SAPISID` 264 | 265 | Example: 266 | 267 | ```coffee 268 | creds = -> Q {cookies:[ 269 | 'NID=67=QI6go9WMWDFxv; Expires=Wed, 04 Nov 2015 06:10:24 GMT; Domain=google.com; Path=/; HttpOnly' 270 | 'SID=DASDPgAAAAKJASKJD; Expires=Thu, 04 May 2017 06:10:24 GMT; Domain=google.com; Path=/' 271 | 'HSID=ARQX_; Expires=Thu, 04 May 2017 06:10:24 GMT; Domain=google.com; Path=/; HttpOnly; Priority=HIGH' 272 | 'SSID=AkD; Expires=Thu, 04 May 2017 06:10:24 GMT; Domain=google.com; Path=/; Secure; HttpOnly; Priority=HIGH' 273 | 'APISID=kMseXb; Expires=Thu, 04 May 2017 06:10:24 GMT; Domain=google.com; Path=/; Priority=HIGH' 274 | 'SAPISID=clOd; Expires=Thu, 04 May 2017 06:10:24 GMT; Domain=google.com; Path=/; Secure; Priority=HIGH' 275 | ]} 276 | 277 | client.connect(creds).then -> # and so on... 278 | ``` 279 | 280 | 281 | #### `disconnect` 282 | 283 | `disconnect: ->` 284 | 285 | Disconnects the client. 286 | 287 | 288 | #### `isInited` 289 | 290 | `isInited` 291 | 292 | For Client to be fully inited the following must happen on 293 | [`connect`](#connect) 294 | 295 | 1. Get login cookies against https://accounts.google.com/ServiceLogin 296 | or reuse cached cookies. 297 | 298 | 2. Using the cookies, fetch a PVT token (whatever that is) against 299 | https://talkgadget.google.com/talkgadget/_/extension-start 300 | 301 | 3. Load the chat widget HTML + javascript using the PVT token from 302 | https://talkgadget.google.com/u/0/talkgadget/_/chat 303 | 304 | 4. From the returned javascript get an `apikey` and some other headers 305 | used in each api call later. 306 | 307 | 5. Fetch channel `sid`/`gsid` from 308 | https://0.client-channel.google.com/client-channel/channel-bind 309 | 310 | 6. Using the `sid`/`gsid` open a long poll request against the same 311 | URL as in 5. This is the push data channel. 312 | 313 | 7. From first data coming through the push data channel, extract a 314 | `clientid` which also is used in each api call later. 315 | 316 | 8. Post a subscribe request against same URL as in 5 to make push data 317 | channel receive chat events. 318 | 319 | Only after all these steps are completed will `isInited` return true. 320 | 321 | 322 | 323 | #### `loglevel` 324 | 325 | `loglevel: (level) ->` 326 | 327 | Sets the log level one of `debug`, `info`, `warn` or `error`. 328 | 329 | 330 | #### `logout` 331 | 332 | `logout: () ->` 333 | 334 | Logs the current client out by removing refresh token and cached cookies. 335 | 336 | Example: 337 | 338 | ```coffee 339 | # force cleared state 340 | client.logout().then -> 341 | # will now require new credentials 342 | client.connect(creds) 343 | .then -> 344 | ... 345 | ``` 346 | 347 | #### `MessageBuilder` 348 | 349 | Helper to compose message `segments` that goes into 350 | [`sendchatmessage`](#sendchatmessage). The builder has these methods. 351 | 352 | Example: 353 | 354 | ```coffee 355 | bld = new Client.MessageBuilder() 356 | segments = bld.text('Hello ').bold('World').text('!!!').toSegments() 357 | client.sendchatmessage('UgzfaJwj2Tg_oqk5EhEp5faABAQ', segments) 358 | ``` 359 | 360 | ##### `builder.text(txt)` 361 | 362 | `(txt, bold=false, italic=false, strikethrough=false, underline=false, href=null) ->` 363 | 364 | Adds a text segment. 365 | 366 | ```coffee 367 | builder.text('Hello') 368 | ``` 369 | 370 | ##### `builder.bold(txt)` 371 | 372 | Adds a text segment in bold. 373 | 374 | ##### `builder.italic(txt)` 375 | 376 | Adds a text segment in italic. 377 | 378 | ##### `builder.strikethrough(txt)` 379 | 380 | Adds a text segment strikethroughed. 381 | 382 | ##### `builder.underline(txt)` 383 | 384 | Adds an underlined text segment. 385 | 386 | ##### `build.linebreak()` 387 | 388 | Adds a new line. 389 | 390 | ##### `builder.link(txt, href)` 391 | 392 | Adds a text that is a link. 393 | 394 | ##### `builder.toSegments` 395 | 396 | Turns the builder into an array of segments usable for [`sendchatmessage`](#sendchatmessage). 397 | 398 | 399 | 400 | ### Low Level API 401 | 402 | Each API call does a direct operation against hangouts. Each call 403 | returns a promise for the result. 404 | 405 | #### `sendchatmessage` 406 | 407 | `sendchatmessage: (conversation_id, 408 | segments, 409 | image_id = None, 410 | otr_status = OffTheRecordStatus.ON_THE_RECORD, 411 | client_generated_id = null, 412 | delivery_medium = [ClientDeliveryMediumType.BABEL], 413 | message_action_type = [[MessageActionType.NONE, ""]]) ->` 414 | 415 | Send a chat message to a conversation. 416 | 417 | `conversation_id`: the conversation to send a message to. 418 | 419 | `segments`: array of segments to send. See 420 | [`messagebuilder`](#messagebuilder) for help. 421 | 422 | `image_id`: is an optional ID of an image retrieved from 423 | [`uploadimage`](#uploadimage). If provided, the image will be 424 | attached to the # message. 425 | 426 | `otr_status`: determines whether the message will be saved in the 427 | server's chat history. Note that the OTR status of the conversation is 428 | irrelevant, clients may send messages with whatever OTR status they 429 | like. One of `Client.OffTheRecordStatus.OFF_THE_RECORD` or 430 | `Client.OffTheRecordStatus.ON_THE_RECORD`. 431 | 432 | `client_generated_id` is an identifier that is kept in the event both 433 | in the result of this call and the following chat_event. it can be 434 | used to tie together a client send with the update from the 435 | server. The default is `null` which makes the client generate a random 436 | id. 437 | 438 | `delivery_medium`: determines via which medium the message will be 439 | delivered. If caller does not specify value we pick the value BABEL to 440 | ensure the message is delivered via default medium. In fact the caller 441 | should retrieve current conversation's default delivery medium from 442 | self_conversation_state.delivery_medium_option when calling to ensure 443 | the message is delivered back to the conversation on same medium always. 444 | 445 | `message_action_type`: determines if the message is a simple text message 446 | or if the message is an action like `/me`. One of `Client.MessageActionType.NONE` 447 | or `Client.MessageActionType.ME_ACTION` 448 | 449 | #### `setactiveclient` 450 | 451 | `setactiveclient: (active, timeoutsecs) ->` 452 | 453 | The active client receives notifications. This marks the client as active. 454 | 455 | `active`: boolean indicating active state 456 | 457 | `timeoutsecs`: the length of active in seconds. 458 | 459 | 460 | 461 | #### `syncallnewevents` 462 | 463 | `syncallnewevents: (timestamp) ->` 464 | 465 | List all events occuring at or after timestamp. Timestamp can be a 466 | date or long millis. 467 | 468 | `timestamp`: date instance specifying the time after which to return 469 | all events occuring in. 470 | 471 | 472 | 473 | #### `getselfinfo` 474 | 475 | `getselfinfo: ->` 476 | 477 | Return information about your account. 478 | 479 | 480 | 481 | #### `setconversationnotificationlevel` 482 | 483 | `setconversationnotificationlevel: (conversation_id, level) ->` 484 | 485 | Set the notification level of a conversation. 486 | 487 | Pass `Client.NotificationLevel.QUIET` to disable notifications, or 488 | `Client.NotificationLevel.RING` to enable them. 489 | 490 | 491 | 492 | #### `setfocus` 493 | 494 | `setfocus: (conversation_id, focus=FocusStatus.FOCUSED, timeoutsecs=20) ->` 495 | 496 | Set focus (occurs whenever you give focus to a client). 497 | 498 | `conversation_id`: the conversation you are focusing. 499 | 500 | `typing`: constant indicating focus status. One of 501 | `Client.FocusStatus.FOCUSED` or `Client.FocusStatus.UNFOCUSED` 502 | 503 | `timeoutsecs`: the length of focus in seconds. 504 | 505 | 506 | 507 | #### `settyping` 508 | 509 | `settyping: (conversation_id, typing=TypingStatus.TYPING) ->` 510 | 511 | Send typing notification. 512 | 513 | `conversation_id`: the conversation you want to send typing 514 | notification for. 515 | 516 | `typing`: constant indicating typing status. One of 517 | `Client.TypingStatus.TYPING`, `Client.TypingStatus.PAUSED` or 518 | `Client.TypingStatus.STOPPED` 519 | 520 | 521 | 522 | #### `setpresence` 523 | 524 | `setpresence: (online, mood=None) ->` 525 | 526 | Set the presence or mood of this client. 527 | 528 | `online`: boolean indicating whether client is online. 529 | 530 | `mood`: emoticon UTF-8 smiley like 0x1f603 531 | 532 | 533 | 534 | #### `querypresence` 535 | 536 | `querypresence: (chat_id) ->` 537 | 538 | Check someone's presence status. 539 | 540 | `chat_id`: the identifer of the user to check. 541 | 542 | 543 | 544 | #### `removeuser` 545 | 546 | `removeuser: (conversation_id) ->` 547 | 548 | Remove self from chat. 549 | 550 | `conversation_id`: the conversation to remove self from. 551 | 552 | 553 | 554 | #### `deleteconversation` 555 | 556 | `deleteconversation: (conversation_id) ->` 557 | 558 | Delete one-to-one conversation. 559 | 560 | `conversation_id`: the conversation to delete. 561 | 562 | 563 | 564 | #### `updatewatermark` 565 | 566 | `updatewatermark: (conversation_id, timestamp) ->` 567 | 568 | Update the watermark (read timestamp) for a conversation. 569 | 570 | `conversation_id`: the conversation to update the read timestamp for. 571 | 572 | `timestamp`: the date or long millis to set as read timestamp. 573 | 574 | 575 | 576 | #### `adduser` 577 | 578 | `adduser: (conversation_id, chat_ids) ->` 579 | 580 | Add user(s) to existing conversation. 581 | 582 | `conversation_id`: the conversation to add user(s) to. 583 | 584 | `chat_ids`: array of user chat_ids to add. 585 | 586 | 587 | 588 | #### `renameconversation` 589 | 590 | `renameconversation: (conversation_id, name) ->` 591 | 592 | Set the name of a conversation. 593 | 594 | `conversation_id`: the conversation to change. 595 | 596 | `name`: the name to change to. 597 | 598 | 599 | 600 | #### `createconversation` 601 | 602 | `createconversation: (chat_ids, force_group=false) ->` 603 | 604 | Create a new conversation. 605 | 606 | `chat_ids`: is an array of chat_id which should be invited to 607 | conversation (except yourself). 608 | 609 | `force_group`: set to true if you invite just one chat_id, but still 610 | want a group. 611 | 612 | The new conversation ID is returned as `res.conversation.id.id` 613 | 614 | 615 | 616 | #### `getconversation` 617 | 618 | `getconversation: (conversation_id, timestamp, max_events=50) ->` 619 | 620 | Return conversation events. 621 | 622 | This is mainly used for retrieving conversation scrollback. Events 623 | occurring before timestamp are returned, in order from oldest to 624 | newest. 625 | 626 | `conversation_id`: the conversation to get events in. 627 | 628 | `timestamp`: the timestamp as long millis or date to get events 629 | before. 630 | 631 | `max_events`: number of events to retrieve. 632 | 633 | 634 | 635 | #### `syncrecentconversations` 636 | 637 | `syncrecentconversations: (timestamp_since=null) ->` 638 | 639 | List the contents of recent conversations, including messages. 640 | Similar to syncallnewevents, but returns a limited number of 641 | conversations (20) rather than all conversations in a given 642 | date range. 643 | 644 | To get older conversations, use the timestamp_since parameter. 645 | 646 | 647 | 648 | #### `searchentities` 649 | 650 | `searchentities: (search_string, max_results=10) ->` 651 | 652 | Search for people. 653 | 654 | `search_string`: string to look for. 655 | 656 | `max_results`: number of results to return. 657 | 658 | 659 | 660 | #### `getentitybyid` 661 | 662 | `getentitybyid: (chat_ids) ->` 663 | 664 | Return information about a list of chat_ids. 665 | 666 | `chat_ids`: array of user chat ids to get information for. 667 | 668 | 669 | 670 | #### `sendeasteregg` 671 | 672 | `sendeasteregg: (conversation_id, easteregg) ->` 673 | 674 | Send an easteregg to a conversation. 675 | 676 | `conversation_id`: conversation to bother. 677 | 678 | `easteregg`: may not be empty. could be one of 'ponies', 'pitchforks', 679 | 'bikeshed', 'shydino' 680 | 681 | 682 | 683 | #### `uploadimage` 684 | 685 | `uploadimage: (path, filename=null, timeout=30000) ->` 686 | 687 | Uploads an image that can be later attached to a chat message. 688 | 689 | `imagefile` is a string path 690 | 691 | `filename` can optionally be provided otherwise the path name is used. 692 | 693 | `timeout` can be used to upload larger images, that may need more than 30 sec to be sent 694 | 695 | returns an `image_id` that can be used in [`sendchatmessage`](#sendchatmessage). 696 | 697 | 698 | 699 | ## Events 700 | 701 | The following events are available on the `Client` object. Example: 702 | 703 | ```coffee 704 | client.on 'chat_message', (msg) -> 705 | # ... do something 706 | ``` 707 | 708 | ### State events 709 | 710 | #### `connecting` 711 | 712 | When someone calls `client.connect()` and it indicates we are trying 713 | to connect the client. 714 | 715 | #### `connected` 716 | 717 | When the client is fully inited and connected. 718 | 719 | #### `connect_failed (err)` 720 | 721 | Indicates that the client connection either didn't start or was 722 | interrupted. Either way, the client will not try to connect again by 723 | itself. Another `client.connect` is required. 724 | 725 | Emitted in three cases. 726 | 727 | 1. After `connecting` (in `client.connect()`) indicating that the 728 | client could not connect at all. 729 | 730 | 2. After `connected` when running the polling (server push channel) 731 | successfully, but is interrupted (such as lost network connection). 732 | 733 | 3. If the server push channel receives no events after 45 seconds 734 | (server emits at least `noop` every 20-30 seconds). 735 | 736 | ### Chat events 737 | 738 | #### `chat_message` 739 | 740 | On a received chat message. 741 | 742 | #### `client_conversation` 743 | 744 | Whenever an update about the conversation itself is needed. Like when 745 | a new conversation is created, this event comes first with the 746 | metadata about it. 747 | 748 | The conversation state is stored in self_conversation_state of the event. 749 | The self_conversation_state.delivery_medium_option contains an array of the 750 | delivery medium options which indicate all possible medium. The array element 751 | with current_default == true should be the one used to send message via by 752 | default. Currently there are 3 types of known medium, BABEL, Google Voice and 753 | SMS. BABEL is the Google Hangouts codename BTW. 754 | 755 | #### `membership_change` 756 | 757 | Member joining/leaving conversation. 758 | 759 | #### `conversation_rename` 760 | 761 | On a renamed conversation. 762 | 763 | #### `focus` 764 | 765 | When a user focuses a conversation. 766 | 767 | #### `hangout_event` 768 | 769 | On changes to video/audio calls. A "hangout" is in google API talk 770 | strictly a video/audio event. `START_HANGOUT` and `END_HANGOUT` would 771 | indicate attempts to start/end audio/video events. 772 | 773 | #### `typing` 774 | 775 | When a user is typing. 776 | 777 | #### `watermark` 778 | 779 | When a user updates their read timestamp. 780 | 781 | #### `notification_level` 782 | 783 | When user changes the notification level of his own 784 | conversation. I.e. [setconversationnotificationlevel](#setconversationnotificationlevel). 785 | 786 | [See #10](https://github.com/algesten/hangupsjs/issues/10) 787 | 788 | #### `easter_egg` 789 | 790 | When anyone in the conversation triggers an easter 791 | egg. 792 | 793 | [See #10](https://github.com/algesten/hangupsjs/issues/10) 794 | 795 | #### `delete` 796 | 797 | When a conversation is deleted by the user. As a response 798 | to `deleteconversation`. 799 | 800 | ### To be investigated 801 | 802 | The following events are possible and not investigated. Please tell me 803 | in an [issue](https://github.com/algesten/hangupsjs/issues) if you figure one out. 804 | 805 | * `conversation_notification` 806 | * `reply_to_invite` 807 | * `settings` 808 | * `self_presence` [See #10](https://github.com/algesten/hangupsjs/issues/10) 809 | * `presence` [See #10](https://github.com/algesten/hangupsjs/issues/10) 810 | * `block` 811 | * `invitation_watermark` 812 | 813 | 814 | 815 | ## License 816 | 817 | Copyright © 2015 Martin Algesten 818 | 819 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 820 | 821 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 822 | 823 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 824 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hangupsjs", 3 | "version": "1.4.0-beta", 4 | "description": "google hangouts client library for nodejs", 5 | "main": "lib/client.js", 6 | "scripts": { 7 | "postinstall": "npm run prepublishOnly", 8 | "test": "mocha", 9 | "prepublishOnly": "coffee -o lib src" 10 | }, 11 | "greenkeeper": { 12 | "ignore": [ 13 | "fnuc" 14 | ] 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/yakyak/hangupsjs" 19 | }, 20 | "keywords": [ 21 | "hangouts", 22 | "library" 23 | ], 24 | "mocha": { 25 | "reporter": "list", 26 | "timeout": 500, 27 | "spec": "test/**/*.coffee", 28 | "require": [ 29 | "coffeescript/register" 30 | ] 31 | }, 32 | "author": "Martin Algesten ", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/yakyak/hangupsjs/issues" 36 | }, 37 | "homepage": "https://github.com/yakyak/hangupsjs", 38 | "dependencies": { 39 | "bog": "^1.0.0", 40 | "fnuc": "0.0.15", 41 | "q": "^1.2.0", 42 | "request": "^2.88.0", 43 | "tough-cookie": "^3.0.1", 44 | "tough-cookie-file-store": "1.2.0" 45 | }, 46 | "devDependencies": { 47 | "chai": "^4.1.0", 48 | "coffeescript": "^2.5.1", 49 | "hexy": "^0.3.0", 50 | "mocha": "^7.0.1", 51 | "request-debug": "^0.2.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/auth.coffee: -------------------------------------------------------------------------------- 1 | Cookie = require('tough-cookie').Cookie 2 | request = require 'request' 3 | log = require 'bog' 4 | Q = require 'q' 5 | fs = require 'fs' 6 | 7 | {plug, req, NetworkError} = require './util' 8 | 9 | # this CLIENT_ID and CLIENT_SECRET are whitelisted at google and 10 | # turns up as "iOS device". access can be revoked a this page: 11 | # https://security.google.com/settings/security/permissions 12 | OAUTH2_CLIENT_ID = '936475272427.apps.googleusercontent.com' 13 | OAUTH2_CLIENT_SECRET = 'KWsJlkaMn1jGLxQpWxMnOox-' 14 | OAUTH2_SCOPE = 'https://www.google.com/accounts/OAuthLogin__my_sep__https://www.googleapis.com/auth/userinfo.email' 15 | OAUTH2_DELEGATED = '183697946088-m3jnlsqshjhh5lbvg05k46q1k4qqtrgn.apps.googleusercontent.com' 16 | 17 | OAUTH2_PARAMS = 18 | client_id: OAUTH2_CLIENT_ID 19 | scope: OAUTH2_SCOPE 20 | access_type: 'offline' 21 | delegated_client_id: OAUTH2_DELEGATED 22 | top_level_cookie: '1' 23 | hl: 'en' 24 | 25 | OAUTH2_QUERY = ("&#{k}=#{encodeURIComponent(v)}" for k, v of OAUTH2_PARAMS).join('').replace('__my_sep__', '+') 26 | OAUTH2_LOGIN_URL = "https://accounts.google.com/o/oauth2/programmatic_auth?#{OAUTH2_QUERY}" 27 | OAUTH2_TOKEN_REQUEST_URL = 'https://accounts.google.com/o/oauth2/token' 28 | 29 | UBERAUTH = 'https://accounts.google.com/accounts/OAuthLogin?source=hangups&issueuberauth=1' 30 | MERGE_SESSION = 'https://accounts.google.com/MergeSession' 31 | MERGE_SESSION_MAIL = "https://accounts.google.com/MergeSession?service=mail" + 32 | "&continue=http://www.google.com&uberauth=" 33 | 34 | class AuthError extends Error then constructor: -> super() 35 | 36 | setCookie = (jar) -> (cookie) -> Q.Promise (rs, rj) -> 37 | jar.setCookie cookie, OAUTH2_LOGIN_URL, plug(rs,rj) 38 | 39 | cookieStrToJar = (jar, str) -> setCookie(jar)(Cookie.parse(str)) 40 | 41 | clone = (o) -> JSON.parse JSON.stringify o 42 | 43 | module.exports = class Auth 44 | 45 | constructor: (@jar, @jarstore, @creds, @opts) -> 46 | 47 | # get authentication cookies on the form [{key:, value:}, {...}, ...] 48 | # first checks the database if we already have cookies, or else proceeds with login 49 | getAuth: => 50 | log.debug 'getting auth...' 51 | self = @ 52 | Q().then => 53 | Q.Promise (rs, rj) => self.jar.getCookies OAUTH2_LOGIN_URL, plug(rs, rj) 54 | .then (cookies) => 55 | if cookies.length 56 | log.debug 'using cached cookies' 57 | Q() 58 | else 59 | log.debug 'proceeding to login' 60 | self.login() 61 | .then -> 62 | # result 63 | log.debug 'getAuth done' 64 | .fail (err) -> 65 | log.error 'getAuth failed', err 66 | Q.reject err 67 | 68 | login: -> 69 | self = @ 70 | Q().then => 71 | # fetch creds to inspect what we got to work with 72 | self.creds() 73 | .then (creds) => 74 | if creds.auth 75 | self.oauthLogin(creds) 76 | else if creds.cookies 77 | self.providedCookies(creds) 78 | else 79 | throw new Error("No acceptable creds provided") 80 | 81 | 82 | # An array of cookie strings to put into the jar 83 | providedCookies: ({cookies}) => 84 | proms = for cookie in cookies 85 | cookieStrToJar @jar, cookie 86 | Q.all proms 87 | 88 | 89 | oauthLogin: ({auth}) => 90 | self = @ 91 | Q().then => 92 | # load the refresh-token from disk, and if found 93 | # use to get an authentication token. 94 | self.loadRefreshToken().then (rtoken) => 95 | self.authWithRefreshToken(rtoken) if rtoken 96 | .then (atoken) => 97 | if atoken 98 | # token from refresh-token. just use it. 99 | atoken 100 | else 101 | # no loaded refresh-token. request auth code. 102 | self.requestAuthCode auth 103 | .then (atoken) => 104 | # one way or another we have an atoken now 105 | self.getSessionCookies atoken 106 | 107 | 108 | loadRefreshToken: => 109 | path = @opts.rtokenpath 110 | tokenPersistence = @opts.tokenPersistence 111 | Q().then -> 112 | Q.Promise (rs, rj) -> 113 | if tokenPersistence 114 | tokenPersistence.load().then rs, rj 115 | else 116 | fs.readFile path, 'utf-8', plug(rs,rj) 117 | .fail (err) -> 118 | # ENOTFOUND is ok, we just return null and deal with 119 | # oauthLogin() 120 | return null if err.code == 'ENOENT' 121 | Q.reject err 122 | 123 | 124 | saveRefreshToken: (rtoken) => 125 | path = @opts.rtokenpath 126 | tokenPersistence = @opts.tokenPersistence 127 | Q().then -> 128 | Q.Promise (rs, rj) -> 129 | if tokenPersistence 130 | tokenPersistence.save(rtoken).then rs, rj 131 | else 132 | fs.writeFile path, rtoken, plug(rs,rj) 133 | 134 | authWithRefreshToken: (rtoken) => 135 | log.debug 'auth with refresh token' 136 | Q().then -> 137 | opts = 138 | method: 'POST' 139 | uri: OAUTH2_TOKEN_REQUEST_URL 140 | form: 141 | client_id: OAUTH2_CLIENT_ID 142 | client_secret: OAUTH2_CLIENT_SECRET 143 | grant_type: 'refresh_token' 144 | refresh_token: rtoken 145 | req(opts) 146 | .then (res) -> 147 | if res.statusCode == 200 148 | log.debug 'refresh token success' 149 | body = JSON.parse(res.body) 150 | body.access_token 151 | else 152 | Q.reject NetworkError.forRes(res) 153 | 154 | 155 | requestAuthCode: (auth) => 156 | log.debug 'request auth code from user' 157 | self = @ 158 | Q().then -> 159 | auth() 160 | .then (code) -> 161 | log.debug 'requesting refresh token' 162 | opts = 163 | method: 'POST' 164 | uri: OAUTH2_TOKEN_REQUEST_URL 165 | form: 166 | client_id: OAUTH2_CLIENT_ID 167 | client_secret: OAUTH2_CLIENT_SECRET 168 | code: code 169 | grant_type: 'authorization_code' 170 | redirect_uri: 'urn:ietf:wg:oauth:2.0:oob' 171 | req(opts) 172 | .then (res) => 173 | if res.statusCode == 200 174 | log.debug 'auth with code success' 175 | body = JSON.parse(res.body) 176 | # save it and then return the access token 177 | self.saveRefreshToken(body.refresh_token).then -> 178 | body.access_token 179 | else 180 | 181 | 182 | 183 | getSessionCookies: (atoken) => 184 | log.debug 'attempt to get session cookies', atoken 185 | uberauth = null 186 | headers = Authorization: "Bearer #{atoken}" 187 | jarstore = @jarstore 188 | opts = @opts 189 | Q().then -> 190 | log.debug 'requesting uberauth' 191 | req 192 | method: 'GET' 193 | uri: UBERAUTH 194 | jar: request.jar jarstore 195 | proxy: opts.proxy 196 | headers: headers 197 | withCredentials: true 198 | .then (res) -> 199 | return Q.reject NetworkError.forRes(res) unless res.statusCode == 200 200 | log.debug 'got uberauth' 201 | uberauth = res.body 202 | .then -> 203 | # not sure what this is. some kind of cookie warmup call? 204 | log.debug 'request merge session 1/2' 205 | req 206 | method: 'GET' 207 | uri: MERGE_SESSION 208 | jar: request.jar jarstore 209 | proxy: opts.proxy 210 | withCredentials: true 211 | .then (res) -> 212 | return Q.reject NetworkError.forRes(res) unless res.statusCode == 200 213 | log.debug 'request merge session 2/2' 214 | req 215 | method: 'GET' 216 | uri: MERGE_SESSION_MAIL + uberauth 217 | jar: request.jar jarstore 218 | proxy: opts.proxy 219 | headers: headers 220 | withCredentials: true 221 | .then (res) -> 222 | return Q.reject NetworkError.forRes(res) unless res.statusCode == 200 223 | log.debug 'got session cookies' 224 | 225 | 226 | authStdin: -> 227 | process.stdout.write """\nTo log in: 228 | \t1. Open the link below in a browser 229 | \t2. Open the developer tools 230 | \t3. Open the network tab 231 | \t4. Insert google credentials in main browser window 232 | \t5. Filter the network tab for \'programmatic_auth\' and on the response headers search for \'set-cookie\' 233 | \t6. Copy the auth_code to the console below:\n\n""" 234 | process.stdout.write OAUTH2_LOGIN_URL 235 | Q().then -> 236 | process.stdout.write "\n\nAuthorization Token: " 237 | process.stdin.setEncoding 'utf8' 238 | Q.Promise (rs) -> process.stdin.on 'readable', fn = -> 239 | chunk = process.stdin.read() 240 | if chunk != null 241 | rs chunk 242 | process.stdin.removeListener 'on', fn 243 | 244 | # Expose this to Client 245 | Auth.OAUTH2_LOGIN_URL = OAUTH2_LOGIN_URL 246 | -------------------------------------------------------------------------------- /src/channel.coffee: -------------------------------------------------------------------------------- 1 | require('fnuc').expose global 2 | {CookieJar} = require 'tough-cookie' 3 | request = require 'request' 4 | crypto = require 'crypto' 5 | log = require 'bog' 6 | Q = require 'q' 7 | 8 | {req, find, wait, NetworkError, fmterr} = require './util' 9 | InitDataParser = require './initdataparser' 10 | PushDataParser = require './pushdataparser' 11 | 12 | ORIGIN_URL = 'https://hangouts.google.com' 13 | CHANNEL_URL_NUMBER = Math.floor(Math.random() * 30).toString() 14 | CHANNEL_URL_PREFIX = "https://#{CHANNEL_URL_NUMBER}.client-channel.google.com/client-channel" 15 | 16 | UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 17 | (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36' 18 | 19 | op = (o) -> "#{CHANNEL_URL_PREFIX}/#{o}" 20 | 21 | isUnknownSID = (res) -> res.statusCode == 400 and res.statusMessage == 'Unknown SID' 22 | 23 | # error token 24 | ABORT = {} 25 | 26 | # typical long poll 27 | # 28 | # 2015-05-02 14:44:19 DEBUG found sid/gsid 5ECBB7A224ED4276 XOqP3EYTfy6z0eGEr9OD5A 29 | # 2015-05-02 14:44:19 DEBUG long poll req 30 | # 2015-05-02 14:44:19 DEBUG long poll response 200 OK 31 | # 2015-05-02 14:44:19 DEBUG got msg [[[2,["noop"]]]] 32 | # 2015-05-02 14:44:19 DEBUG got msg [[[3,[{"p":"{\"1\":{\"1\":{\"1\":{\"1\":1,\"2\":1}},\"4\":\"1430570659159\",\"5\":\"S1\"},\"3\":{\"1\":{\"1\":1},\"2\":\"lcsw_hangouts881CED94\"}}"}]]]] 33 | # 2015-05-02 14:44:49 DEBUG got msg [[[4,["noop"]]]] 34 | # 2015-05-02 14:45:14 DEBUG got msg [[[5,["noop"]]]] 35 | # ... 36 | # 2015-05-02 14:47:56 DEBUG got msg [[[11,["noop"]]]] 37 | # 2015-05-02 14:48:21 DEBUG got msg [[[12,["noop"]]]] 38 | # 2015-05-02 14:48:21 DEBUG long poll end 39 | # 2015-05-02 14:48:21 DEBUG long poll req 40 | # 2015-05-02 14:48:21 DEBUG long poll response 200 OK 41 | # 2015-05-02 14:48:21 DEBUG got msg [[[13,["noop"]]]] 42 | # ... 43 | # 2015-05-02 15:31:39 DEBUG long poll error { [Error: ESOCKETTIMEDOUT] code: 'ESOCKETTIMEDOUT' } 44 | # 2015-05-02 15:31:39 DEBUG poll error { [Error: ESOCKETTIMEDOUT] code: 'ESOCKETTIMEDOUT' } 45 | # 2015-05-02 15:31:39 DEBUG backing off for 2 ms 46 | # 2015-05-02 15:31:39 DEBUG long poll end 47 | # 2015-05-02 15:31:39 DEBUG long poll req 48 | # 2015-05-02 15:31:39 DEBUG long poll response 200 OK 49 | # 2015-05-02 15:31:39 DEBUG got msg [[[121,["noop"]]]] 50 | 51 | authhead = (sapisid, msec, origin) -> 52 | auth_string = "#{msec} #{sapisid} #{origin}" 53 | auth_hash = crypto.createHash('sha1').update(auth_string).digest 'hex' 54 | return { 55 | authorization: "SAPISIDHASH #{msec}_#{auth_hash}" 56 | 'x-origin': origin 57 | 'x-goog-authuser': '0' 58 | } 59 | 60 | sapisidof = (jarstore) -> 61 | jar = new CookieJar jarstore 62 | cookies = jar.getCookiesSync ORIGIN_URL 63 | cookie = find cookies, (cookie) -> cookie.key == 'SAPISID' 64 | return cookie?.value 65 | 66 | MAX_RETRIES = 5 67 | 68 | 69 | module.exports = class Channel 70 | 71 | constructor: (@jarstore, @proxy) -> 72 | @pushParser = new PushDataParser() 73 | 74 | fetchPvt: => 75 | log.debug 'fetching pvt' 76 | opts = 77 | method: 'GET' 78 | uri: "#{ORIGIN_URL}/webchat/start" 79 | jar: request.jar @jarstore 80 | withCredentials: true 81 | req(opts).then (res) => 82 | DICT = 83 | pvtToken: { key:'ds:0', fn: (d) -> d[1] } 84 | 85 | result = [] 86 | await InitDataParser.parse res.body, DICT, result 87 | log.debug 'found pvt token', result.pvtToken 88 | result.pvtToken 89 | .fail (err) -> 90 | log.info 'fetchPvt failed', fmterr(err) 91 | Q.reject err 92 | 93 | authHeaders: -> 94 | sapisid = sapisidof @jarstore 95 | unless sapisid 96 | log.warn 'no SAPISID cookie' 97 | return null 98 | authhead sapisid, Date.now(), ORIGIN_URL 99 | 100 | fetchSid: => 101 | auth = @authHeaders() 102 | return Q.reject new Error("No auth headers") unless auth 103 | self = @ 104 | Q().then => 105 | opts = 106 | method: 'POST' 107 | uri: op 'channel/bind' 108 | jar: request.jar self.jarstore 109 | qs: 110 | VER: 8 111 | RID: 81187 112 | ctype: 'hangouts' 113 | form: 114 | count: 0 115 | headers: auth 116 | encoding: null # get body as buffer 117 | withCredentials: true 118 | 119 | req(opts).then (res) -> 120 | log.debug opts.uri, 'res', res.statusCode 121 | # Example format (after parsing JS): 122 | # [ [0,["c","SID_HERE","",8]], 123 | # [1,[{"gsid":"GSESSIONID_HERE"}]]] 124 | if res.statusCode == 200 125 | p = new PushDataParser(res.body) 126 | line = p.pop() 127 | [_,[_,sid]] = line[0] 128 | [_,[{gsid}]] = line[1] 129 | log.debug 'found sid/gsid', sid, gsid 130 | return {sid,gsid} 131 | else 132 | log.error 'failed to get sid', res.statusCode, res.body.toString() 133 | .fail (err) -> 134 | log.info 'fetchSid failed', fmterr(err) 135 | Q.reject err 136 | 137 | 138 | # get next messages from channel 139 | getLines: => 140 | @start() unless @running 141 | @pushParser.allLines() 142 | 143 | 144 | # start polling 145 | start: => 146 | retries = MAX_RETRIES 147 | @running = true 148 | @sid = null # ensures we get a new sid 149 | @gsid = null 150 | @subscribed = false 151 | self = @ 152 | run = => 153 | # graceful stop of polling 154 | return unless self.running 155 | self.poll(retries).then -> 156 | # XXX we only reset to MAX_RETRIES after a full ended 157 | # poll. this means in bad network conditions we get an 158 | # edge case where retries never reset despite getting 159 | # (interrupted) good polls. perhaps move retries to 160 | # instance var? 161 | retries = MAX_RETRIES # reset on success 162 | run() 163 | .fail (err) => 164 | # abort token is not an error 165 | return if err == ABORT 166 | retries-- 167 | log.debug 'poll error', err 168 | if retries > 0 169 | run() 170 | else 171 | self.running = false 172 | # resetting with error makes pushParser.allLines() 173 | # resolve with that error, which in turn makes 174 | # self.getLines() propagate the error out. 175 | self.pushParser.reset(err) 176 | run() 177 | return null 178 | 179 | 180 | # gracefully stop polling 181 | stop: => 182 | log.debug 'channel stop' 183 | # stop looping 184 | @running = false 185 | # this releases the @getLines() promise 186 | @pushParser?.reset?() 187 | # abort current request 188 | @currentReq?.abort?() 189 | 190 | 191 | poll: (retries) => 192 | self = @ 193 | Q().then -> 194 | backoffTime = 2 * (MAX_RETRIES - retries) * 1000 195 | log.debug 'backing off for', backoffTime, 'ms' if backoffTime 196 | wait backoffTime 197 | .then => 198 | Q.reject ABORT unless self.running 199 | .then => 200 | unless self.sid 201 | self.fetchSid().then (o) => 202 | merge this, o # set on this 203 | self.pushParser.reset() # ensure no half data 204 | .then => 205 | self.reqpoll() 206 | 207 | 208 | # long polling 209 | reqpoll: => 210 | self = @ 211 | Q.Promise (rs, rj) => 212 | log.debug 'long poll req' 213 | opts = 214 | method: 'GET' 215 | uri: op 'channel/bind' 216 | jar: request.jar self.jarstore 217 | qs: 218 | VER: 8 219 | gsessionid: self.gsid 220 | RID: 'rpc' 221 | t: 1 222 | SID: self.sid 223 | CI: 0 224 | ctype: 'hangouts' 225 | TYPE: 'xmlhttp' 226 | headers: self.authHeaders() 227 | encoding: null # get body as buffer 228 | timeout: 30000 # 30 seconds timeout in connect attempt 229 | withCredentials: true 230 | ok = false 231 | self.currentReq = request(opts).on 'response', (res) => 232 | log.debug 'long poll response', res.statusCode, res.statusMessage 233 | if res.statusCode == 200 234 | return ok = true 235 | else if isUnknownSID(res) 236 | ok = false 237 | log.debug 'sid became invalid' 238 | self.sid = null 239 | self.gsid = null 240 | self.subscribed = false 241 | rj NetworkError.forRes(res) 242 | .on 'data', (chunk) => 243 | if ok 244 | # log.debug 'long poll chunk\n' + require('hexy').hexy(chunk) 245 | self.pushParser.parse chunk 246 | # subscribe on first data received 247 | self.subscribe() unless self.subscribed 248 | .on 'error', (err) => 249 | log.debug 'long poll error', err 250 | rj err 251 | .on 'end', -> 252 | log.debug 'long poll end' 253 | rs() 254 | 255 | 256 | # Subscribes the channel to receive relevant events. Only needs to 257 | # be called when a new channel (SID/gsessionid) is opened. 258 | subscribe: => 259 | return if @subscribed 260 | @subscribed = true 261 | self = @ 262 | Q().then -> 263 | wait(1000) # https://github.com/tdryer/hangups/issues/58 264 | .then => 265 | timestamp = Date.now() * 1000 266 | services = ['babel', 'babel_presence_last_seen'] 267 | mapList = for service in services 268 | JSON.stringify({"3": {"1": {"1": service}}}) 269 | formMap = {count: mapList.length, ofs: 0} 270 | for el, ix in mapList 271 | formMap["req#{ix}_p"] = el 272 | opts = 273 | method: 'POST' 274 | uri: op 'channel/bind' 275 | jar: request.jar self.jarstore 276 | proxy: self.proxy 277 | qs: 278 | VER: 8 279 | RID: 81188 280 | ctype: 'hangouts' 281 | gsessionid: self.gsid 282 | SID: self.sid 283 | headers: self.authHeaders() 284 | timeout: 30000 # 30 seconds timeout in connect attempt 285 | form: formMap 286 | withCredentials: true 287 | req(opts) 288 | .then (res) -> 289 | if res.statusCode == 200 290 | return log.debug 'subscribed channel' 291 | else if isUnknownSID(res) 292 | ok = false 293 | log.debug 'sid became invalid' 294 | self.sid = null 295 | self.gsid = null 296 | self.subscribed = false 297 | Q.reject NetworkError.forRes(res) 298 | .fail (err) => 299 | log.info 'subscribe failed', fmterr(err) 300 | self.subscribed = false 301 | Q.reject err 302 | -------------------------------------------------------------------------------- /src/chatreq.coffee: -------------------------------------------------------------------------------- 1 | log = require 'bog' 2 | request = require 'request' 3 | Q = require 'q' 4 | 5 | {req, NetworkError, tryparse} = require './util' 6 | 7 | #require('request-debug') request 8 | 9 | module.exports = class ChatReq 10 | 11 | constructor: (@jarstore, @init, @channel, @proxy) -> 12 | 13 | # does a request against url. 14 | # contentype is request Content-Type. 15 | # body is the body which will be JSON.stringify() 16 | # json is whether we want a result that is json or protojson 17 | # 18 | # These cookies are typically submitted: 19 | # NID, SID, HSID, SSID, APISID, SAPISID 20 | baseReq: (url, contenttype, body, params={}, json=true, timeout=30000) -> 21 | headers = @channel.authHeaders() 22 | return Q.reject new Error("No auth headers") unless headers 23 | headers['Content-Type'] = contenttype 24 | params.key = @init.apikey 25 | params.alt = if json then 'json' else 'protojson' 26 | opts = 27 | method: if body? then 'POST' else 'GET' 28 | uri: url 29 | jar: request.jar @jarstore 30 | proxy: @proxy 31 | qs: params 32 | headers: headers 33 | 34 | encoding: null # get body as buffer 35 | timeout: timeout # timeout in connect attempt (default 30 sec) 36 | withCredentials: true 37 | 38 | if body? 39 | opts.body = if Buffer.isBuffer body then body else JSON.stringify(body) 40 | 41 | req(opts).fail (err) -> 42 | log.warn 'request failed', err 43 | Q.reject err 44 | .then (res) -> 45 | showBody = if res.statusCode == 200 then '' else res.body?.toString?() 46 | log.debug 'request for', url, 'result:', res.statusCode, showBody 47 | if res.statusCode == 200 48 | if json 49 | tryparse res.body.toString() 50 | else 51 | res.body # protojson, return as Buffer 52 | else 53 | log.debug 'request for 2', url, 'result:', res.statusCode, res.body?.toString?() 54 | Q.reject NetworkError.forRes(res) 55 | 56 | 57 | # request endpoint by submitting body. json toggles whether we want 58 | # the result as json or protojson 59 | req: (endpoint, body, json=true) -> 60 | url = "https://clients6.google.com/chat/v1/#{endpoint}" 61 | @baseReq url, 'application/json+protobuf', body, {}, json 62 | 63 | userMediaReq: (endpoint, params, json=true) -> 64 | url = "https://hangoutsusermedia-pa.clients6.google.com/v1/usermediaservice/#{endpoint}" 65 | @baseReq url, 'application/json+protobuf', null, params, json 66 | -------------------------------------------------------------------------------- /src/client.coffee: -------------------------------------------------------------------------------- 1 | require('fnuc').expose global 2 | FileCookieStore = require 'tough-cookie-file-store' 3 | {CookieJar} = require 'tough-cookie' 4 | {EventEmitter} = require 'events' 5 | syspath = require 'path' 6 | log = require 'bog' 7 | fs = require 'fs' 8 | Q = require 'q' 9 | moment = require('moment') 10 | 11 | {plug, fmterr, wait} = require './util' 12 | 13 | MessageBuilder = require './messagebuilder' 14 | MessageParser = require './messageparser' 15 | ChatReq = require './chatreq' 16 | Channel = require './channel' 17 | Auth = require './auth' 18 | Init = require './init' 19 | 20 | {OffTheRecordStatus, 21 | FocusStatus, 22 | TypingStatus, 23 | MessageActionType, 24 | ClientDeliveryMediumType, 25 | ClientNotificationLevel, 26 | CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE, 27 | CLIENT_GET_CONVERSATION_RESPONSE, 28 | CLIENT_GET_ENTITY_BY_ID_RESPONSE, 29 | CLIENT_CREATE_CONVERSATION_RESPONSE, 30 | CLIENT_SEARCH_ENTITIES_RESPONSE} = require './schema' 31 | 32 | IMAGE_UPLOAD_URL = 'https://docs.google.com/upload/photos/resumable' 33 | 34 | DEFAULTS = 35 | rtokenpath: syspath.normalize syspath.join __dirname, '../refreshtoken.txt' 36 | cookiespath: syspath.normalize syspath.join __dirname, '../cookies.json' 37 | 38 | # the max amount of time we will wait between seeing some sort of 39 | # activity from the server. 40 | ALIVE_WAIT = 45000 41 | 42 | # ensure path exists 43 | touch = (path) -> 44 | try 45 | fs.statSync(path) 46 | catch err 47 | if err.code == 'ENOENT' 48 | fs.writeFileSync(path, '') 49 | 50 | rm = (path) -> Q.Promise((rs, rj) -> fs.unlink(path, plug(rs, rj))).fail (err) -> 51 | if err.code == 'ENOENT' then null else Q.reject(err) 52 | 53 | None = undefined 54 | 55 | randomid = -> Math.round Math.random() * Math.pow(2,32) 56 | datetolong = (d) -> if typeis d, 'date' then d.getTime() else d 57 | togoogtime = sequence datetolong, mul(1000) 58 | 59 | # token indicating abort in connect-loop 60 | ABORT = {abort:true} 61 | 62 | module.exports = class Client extends EventEmitter 63 | 64 | constructor: (opts) -> 65 | super() 66 | @opts = mixin DEFAULTS, opts 67 | @doInit() 68 | @messageParser = new MessageParser(this) 69 | 70 | # clientid comes as part of pushdata 71 | self = @ 72 | @on 'clientid', (clientid) => self.init.clientid = clientid 73 | 74 | loglevel: (lvl) -> log.level lvl 75 | 76 | connect: (creds) -> 77 | # tell the world what we're doing 78 | @emit 'connecting' 79 | # create a new auth instance 80 | @auth = new Auth @jar, @jarstore, creds, @opts 81 | # getAuth does a login and stores the cookies 82 | # of the login into the db. the cookies are 83 | # cached. 84 | self = @ 85 | @auth.getAuth().then => 86 | # fetch the 'pvt' token, which is required for the 87 | # initialization request (otherwise it will return 400) 88 | self.channel.fetchPvt() 89 | .then (pvt) => 90 | # see https://github.com/algesten/hangupsjs/issues/6 91 | unless pvt 92 | # clear state and start reconnecting 93 | log.debug 'no pvt token, logout and then reconnect' 94 | self.logout().then => self.connect(creds) 95 | return Q.reject ABORT 96 | # now intialize the chat using the pvt 97 | self.init.initChat self.jarstore, pvt 98 | .then => 99 | log.debug 'initializing recent conversations' 100 | self.initrecentconversations self.init 101 | .then => 102 | self.running = true 103 | self.connected = false 104 | # ensure we have a fresh timestamp 105 | self.lastActive = Date.now() 106 | self.ensureConnected() 107 | do poller = => 108 | return unless self.running 109 | self.channel.getLines().then (lines) => 110 | # wait until we receive first data to emit a 111 | # 'connected' event. 112 | if not self.connected and self.running 113 | self.connected = true 114 | self.emit 'connected' 115 | # when disconnecting, no more lines to parse. 116 | if self.running 117 | self.messageParser.parsePushLines lines 118 | poller() 119 | .fail (err) => 120 | log.debug err.stack if err.stack 121 | log.debug err 122 | log.info 'poller stopped', fmterr(err) 123 | self.running = false 124 | self.connected = false 125 | self.emit 'connect_failed', err 126 | # wait for connected event to release promise 127 | Q.Promise (rs) => self.once 'connected', -> rs() 128 | .fail (err) => 129 | self.running = false 130 | self.connected = false 131 | if err == ABORT 132 | return null 133 | else 134 | # tell everyone we didn't connect 135 | self.emit 'connect_failed', err 136 | return Q.reject(err) 137 | 138 | 139 | doInit: -> 140 | touch @opts.cookiespath unless @opts.jarstore 141 | @jarstore = @opts.jarstore 142 | unless @jarstore? 143 | try 144 | @jarstore = new FileCookieStore(@opts.cookiespath) 145 | catch error 146 | if !fs.existsSync @opts.cookiespath 147 | throw error 148 | log.error 'Error while reading cookie store, clearing cookie file' 149 | fs.unlinkSync @opts.cookiespath 150 | @jarstore = new FileCookieStore(@opts.cookiespath) 151 | @jar = new CookieJar @jarstore 152 | @channel = new Channel @jarstore, @opts.proxy 153 | @init = new Init @opts.proxy 154 | @chatreq = new ChatReq @jarstore, @init, @channel, @opts.proxy 155 | 156 | 157 | # clears entire auth state, removing cached cookies and refresh 158 | # token. 159 | logout: => 160 | # stop client 161 | @disconnect() 162 | # remove saved state 163 | rpath = @opts.rtokenpath 164 | cpath = @opts.cookiespath 165 | self = @ 166 | Q().then -> 167 | log.info 'removing refresh token' 168 | rm rpath 169 | .then -> 170 | log.info 'removing cookie store' 171 | rm cpath 172 | .then => 173 | self.doInit() 174 | 175 | emit: (ev, data) -> 176 | # record when we last emitted 177 | @lastActive = Date.now() unless ev is 'connect_failed' 178 | # debug it 179 | log.debug 'emit', ev, (data ? '') 180 | # and do it 181 | super ev, data 182 | 183 | 184 | # we get at least a "noop" event every 20-30 secs, if we have no 185 | # event after 45 secs, we must suspect a network interruption 186 | ensureConnected: => 187 | # if there's a running timeout, stop it 188 | clearTimeout @ensureTimer if @ensureTimer 189 | # and no ensuring unless we're connected 190 | return unless @running 191 | # check whether we got an event within the threshold we see 192 | # noop 20-30 secs, so 45 should be ok 193 | self = @ 194 | Q().then => 195 | if (Date.now() - self.lastActive) > ALIVE_WAIT 196 | log.debug 'activity wait timeout after 45 secs' 197 | self.disconnect() # this also sets self.connected to false 198 | self.emit 'connect_failed', new Error("Connection timeout") 199 | .then => 200 | return unless self.running # it may have changed 201 | waitFor = self.lastActive + ALIVE_WAIT - Date.now() 202 | self.ensureTimer = setTimeout self.ensureConnected, waitFor 203 | 204 | 205 | disconnect: -> 206 | log.debug 'disconnect' 207 | @running = false 208 | @connected = false 209 | clearTimeout @ensureTimer if @ensureTimer 210 | @channel?.stop?() 211 | 212 | 213 | isInited: => 214 | # checks that we have all init stuff 215 | !!(@init.apikey and @init.email and @init.headerdate and @init.headerversion and 216 | @init.headerid and @init.clientid) 217 | 218 | syncCookies: (sess) -> 219 | @jarstore.getAllCookies (e, cookies) -> 220 | cookies.forEach (cookie) -> 221 | schema = if cookie.secure then "https" else "http" 222 | host = if cookie?.domain?[0] is "." then cookie.domain.substr(1) else cookie.domain 223 | p = {url: schema + '://' + host, name: cookie.key, value: cookie.value, domain: host, path: cookie.path, secure: cookie.secure, httpOnly: cookie.httpOnly, expirationDate: moment(cookie.expires).unix(), sameSite: cookie.sameSite} 224 | sess.cookies.set(p) 225 | .catch (e) -> 226 | return 227 | 228 | # makes the header required at the start of each api call body. 229 | _requestBodyHeader: -> 230 | [ 231 | [6, 3, @init.headerversion, @init.headerdate], 232 | [@init.clientid, @init.headerid], 233 | None, 234 | "en" 235 | ] 236 | 237 | 238 | # The active client receives notifications. This marks the client as active. 239 | # 240 | # 241 | # api: clients/setactiveclient 242 | setactiveclient: (active, timeoutsecs) -> 243 | @chatreq.req 'clients/setactiveclient', [ 244 | @_requestBodyHeader() 245 | active 246 | "#{@init.email}/#{@init.clientid}" 247 | timeoutsecs 248 | ] 249 | 250 | 251 | # List all events occuring at or after timestamp. Timestamp can be 252 | # a date or long millis. 253 | # 254 | # This method requests protojson rather than json so we have one 255 | # chat message parser rather than two. 256 | # 257 | # timestamp: date instance specifying the time after which to 258 | # return all events occuring in. 259 | # 260 | # returns a parsed CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE 261 | syncallnewevents: (timestamp) -> 262 | @chatreq.req('conversations/syncallnewevents', [ 263 | @_requestBodyHeader() 264 | togoogtime(timestamp) 265 | [], None, [], false, [] 266 | 1048576 # max_response_size_bytes 267 | ], false).then (body) -> # receive as protojson 268 | CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE.parse body 269 | 270 | 271 | # Send a chat message to a conversation. 272 | # 273 | # conversation_id must be a valid conversation ID. segments must be a 274 | # list of message segments to send, in pblite format. 275 | # 276 | # image_id is an optional ID of an image retrieved from 277 | # @uploadimage(). If provided, the image will be attached to the 278 | # message. 279 | # 280 | # otr_status determines whether the message will be saved in the server's 281 | # chat history. Note that the OTR status of the conversation is 282 | # irrelevant, clients may send messages with whatever OTR status they 283 | # like. 284 | # 285 | # client_generated_id is an identifier that is kept in the event 286 | # both in the result of this call and the following chat_event. 287 | # it can be used to tie together a client send with the update 288 | # from the server. The default is `null` which makes 289 | # the client generate a random id. 290 | # 291 | # message_action_type determines if the message is a simple text message 292 | # or if the message is an action like `/me`. 293 | sendchatmessage: (conversation_id, 294 | segments, 295 | image_id = None, 296 | otr_status = OffTheRecordStatus.ON_THE_RECORD, 297 | client_generated_id = null, 298 | delivery_medium = [ClientDeliveryMediumType.BABEL], 299 | message_action_type = [[MessageActionType.NONE, ""]]) -> 300 | client_generated_id = randomid() unless client_generated_id 301 | @chatreq.req 'conversations/sendchatmessage', [ 302 | @_requestBodyHeader(), 303 | None, None, None, message_action_type 304 | [ 305 | segments, [] 306 | ], 307 | (if image_id then [[image_id, false]] else None) 308 | [ 309 | [conversation_id] 310 | client_generated_id 311 | otr_status 312 | delivery_medium 313 | ], 314 | None, None, None, [] 315 | ] 316 | 317 | # Return information about your account. 318 | getselfinfo: -> 319 | @chatreq.req 'contacts/getselfinfo', [ 320 | @_requestBodyHeader() 321 | [], [] 322 | ] 323 | 324 | 325 | # Set focus (occurs whenever you give focus to a client). 326 | # 327 | # focus must be a FocusStatus enum. 328 | setfocus: (conversation_id, focus=FocusStatus.FOCUSED, timeoutsecs=20) -> 329 | @chatreq.req 'conversations/setfocus', [ 330 | @_requestBodyHeader() 331 | [conversation_id] 332 | focus 333 | timeoutsecs 334 | ] 335 | 336 | 337 | # Send typing notification. 338 | # 339 | # conversation_id must be a valid conversation ID. typing must be 340 | # a TypingStatus enum. 341 | settyping: (conversation_id, typing=TypingStatus.TYPING) -> 342 | @chatreq.req 'conversations/settyping', [ 343 | @_requestBodyHeader() 344 | [conversation_id] 345 | typing 346 | ] 347 | 348 | 349 | # Set the presence or mood of this client. 350 | setpresence: (online, mood=None) -> 351 | @chatreq.req 'presence/setpresence', [ 352 | @_requestBodyHeader() 353 | [ 354 | # timeout_secs timeout in seconds for this presence 355 | 720 356 | # client_presence_state: 357 | # 40 => DESKTOP_ACTIVE 358 | # 30 => DESKTOP_IDLE 359 | # 1 => NONE 360 | if online then 1 else 40 361 | ] 362 | None 363 | None 364 | # true if going offline, false if coming online 365 | [not online] 366 | # UTF-8 smiley like 0x1f603 367 | [mood] 368 | ] 369 | 370 | # Check someone's presence status. 371 | querypresence: (chat_ids) -> 372 | if not Array.isArray chat_ids 373 | chat_ids = [chat_ids] 374 | opts = [1, 2, 3, 5, 7, 8, 10] 375 | else 376 | opts = [2, 3, 10] 377 | 378 | @chatreq.req 'presence/querypresence', [ 379 | @_requestBodyHeader() 380 | [chat_id] for chat_id in chat_ids, 381 | opts 382 | ] 383 | 384 | # Leave group conversation. 385 | # 386 | # conversation_id must be a valid conversation ID. 387 | removeuser: (conversation_id) -> 388 | client_generated_id = randomid() 389 | @chatreq.req 'conversations/removeuser', [ 390 | @_requestBodyHeader() 391 | None, None, None, 392 | [ 393 | [conversation_id], client_generated_id, 2 394 | ], 395 | ] 396 | 397 | 398 | # Delete one-to-one conversation. 399 | # 400 | # conversation_id must be a valid conversation ID. 401 | deleteconversation: (conversation_id) -> 402 | @chatreq.req 'conversations/deleteconversation', [ 403 | @_requestBodyHeader() 404 | [conversation_id], 405 | # Not sure what timestamp should be there, last time I have tried it 406 | # Hangouts client in GMail sent something like now() - 5 hours 407 | Date.now() * 1000 408 | None, [], 409 | ] 410 | 411 | 412 | # Update the watermark (read timestamp) for a conversation. 413 | # 414 | # conversation_id must be a valid conversation ID. 415 | # 416 | # timestamp is a date or long millis 417 | updatewatermark: (conversation_id, timestamp) -> 418 | @chatreq.req 'conversations/updatewatermark', [ 419 | @_requestBodyHeader() 420 | # conversation_id 421 | [conversation_id], 422 | # latest_read_timestamp 423 | togoogtime(timestamp) 424 | ] 425 | 426 | # Mark event observed for a conversation. 427 | # 428 | # conversation_id must be a valid conversation ID. 429 | # event_id must be a valid event ID. 430 | # 431 | # timestamp is a date or long millis 432 | markeventobserved: (conversation_id, event_id) -> 433 | @chatreq.req 'conversations/markeventobserved', [ 434 | @_requestBodyHeader() 435 | # conversation_id 436 | [[[conversation_id], [event_id]]] 437 | ] 438 | 439 | 440 | # Add user to existing conversation. 441 | # 442 | # conversation_id must be a valid conversation ID. 443 | # 444 | # chat_ids is an array of chat_id which should be invited to 445 | # conversation. 446 | adduser: (conversation_id, chat_ids) -> 447 | client_generated_id = randomid() 448 | @chatreq.req 'conversations/adduser', [ 449 | @_requestBodyHeader() 450 | None, 451 | [chat_id, None, None, "unknown", None, []] for chat_id in chat_ids, 452 | None, 453 | [ 454 | [conversation_id], client_generated_id, 2, None, 4 455 | ] 456 | ] 457 | 458 | 459 | # Set the name of a conversation. 460 | renameconversation: (conversation_id, name) -> 461 | client_generated_id = randomid() 462 | @chatreq.req 'conversations/renameconversation', [ 463 | @_requestBodyHeader() 464 | None, 465 | name, 466 | None, 467 | [[conversation_id], client_generated_id, 1] 468 | ] 469 | 470 | 471 | # Create a new conversation. 472 | # 473 | # chat_ids is an array of chat_id which should be invited to 474 | # conversation (except yourself). 475 | # 476 | # force_group set to true if you invite just one chat_id, but 477 | # still want a group. 478 | # 479 | # New conversation ID is returned as res['conversation']['conversation_id']['id'] 480 | createconversation: (chat_ids, force_group=false) -> 481 | client_generated_id = randomid() 482 | @chatreq.req('conversations/createconversation', [ 483 | @_requestBodyHeader() 484 | if chat_ids.length == 1 and not force_group then 1 else 2 485 | client_generated_id 486 | None 487 | [chat_id, None, None, "unknown", None, []] for chat_id in chat_ids 488 | ], false).then (body) -> 489 | CLIENT_CREATE_CONVERSATION_RESPONSE.parse body 490 | 491 | 492 | # Return conversation events. 493 | # 494 | # This is mainly used for retrieving conversation 495 | # scrollback. Events occurring before timestamp are returned, in 496 | # order from oldest to newest. 497 | getconversation: (conversation_id, timestamp, max_events=50, include_metadata = false) -> 498 | @chatreq.req('conversations/getconversation', [ 499 | @_requestBodyHeader() 500 | [[conversation_id], [], []], # conversationSpec 501 | include_metadata, # includeConversationMetadata 502 | true, # includeEvents 503 | None, # ??? 504 | max_events, # maxEventsPerConversation 505 | # eventContinuationToken (specifying timestamp is sufficient) 506 | [ 507 | None, # eventId 508 | None, # storageContinuationToken 509 | togoogtime(timestamp), # eventTimestamp 510 | ] 511 | ], false).then (body) -> # as protojson 512 | CLIENT_GET_CONVERSATION_RESPONSE.parse body 513 | 514 | 515 | # List the contents of recent conversations, including messages. 516 | # Similar to syncallnewevents, but returns a limited 517 | # number of conversations (20) rather than all conversations in a 518 | # given date range. 519 | # 520 | # To get older conversations, use the timestamp_since parameter. 521 | # 522 | # returns a parsed CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE (same structure) 523 | syncrecentconversations: (timestamp_since=null) -> 524 | @chatreq.req('conversations/syncrecentconversations', [ 525 | @_requestBodyHeader(), 526 | timestamp_since # timestamp that controls pagination 527 | ], false).then (body) -> # receive as protojson 528 | CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE.parse body 529 | 530 | # Initializes the recent conversations. 531 | initrecentconversations: (init) -> 532 | @chatreq.req('conversations/syncrecentconversations', [ 533 | @_requestBodyHeader(), 534 | null 535 | ], false).then (body) -> # receive as protojson 536 | data = CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE.parse body 537 | init.conv_states = data.conversation_state if Array.isArray data.conversation_state 538 | 539 | # Search for people. 540 | searchentities: (search_string, max_results=10) -> 541 | @chatreq.req('contacts/searchentities', [ 542 | @_requestBodyHeader() 543 | [] 544 | search_string 545 | max_results 546 | ], false).then (body) -> 547 | CLIENT_SEARCH_ENTITIES_RESPONSE.parse body 548 | 549 | 550 | # Return information about a list of chat_ids 551 | getentitybyid: (chat_ids) -> 552 | @chatreq.req('contacts/getentitybyid', [ 553 | @_requestBodyHeader() 554 | None 555 | [String(chat_id)] for chat_id in chat_ids 556 | ], false).then (body) -> 557 | CLIENT_GET_ENTITY_BY_ID_RESPONSE.parse body 558 | 559 | 560 | # Send a easteregg to a conversation. 561 | # 562 | # easteregg may not be empty. should be one of 563 | # 'ponies', 'pitchforks', 'bikeshed', 'shydino' 564 | sendeasteregg: (conversation_id, easteregg) -> 565 | @chatreq.req 'conversations/easteregg', [ 566 | @_requestBodyHeader() 567 | [conversation_id] 568 | [easteregg, None, 1] 569 | ] 570 | 571 | 572 | # Set the notification level of a conversation. 573 | # 574 | # Pass Client.NotificationLevel.QUIET to disable notifications, 575 | # or Client.NotificationLevel.RING to enable them. 576 | setconversationnotificationlevel: (conversation_id, level) -> 577 | @chatreq.req 'conversations/setconversationnotificationlevel', [ 578 | @_requestBodyHeader() 579 | [conversation_id], 580 | level 581 | ] 582 | 583 | # Set the OTR status of a conversation 584 | # 585 | # Pass Client.OffTheRecordStatus.OFF_THE_RECORD to disable history 586 | # or Client.OffTheRecordStatus.ON_THE_RECORD to turn it on 587 | modifyotrstatus: (conversation_id, otr=OffTheRecordStatus.ON_THE_RECORD) -> 588 | client_generated_id = randomid() 589 | @chatreq.req 'conversations/modifyotrstatus', [ 590 | @_requestBodyHeader(), 591 | None, 592 | otr, 593 | None, 594 | [ 595 | [conversation_id], client_generated_id, otr, None, 9 596 | ] 597 | ] 598 | 599 | # Uploads an image that can be later attached to a chat message. 600 | # 601 | # imagefile is a string path 602 | # 603 | # filename can optionally be provided otherwise the path name is 604 | # used. 605 | # 606 | # returns an image_id that can be used in sendchatmessage 607 | uploadimage: (imagefile, filename=null, timeout=30000) => 608 | # either use provided or from path 609 | filename = filename ? (if Buffer.isBuffer imagefile then "image.jpg" else syspath.basename(imagefile)) 610 | size = null 611 | puturl = null 612 | chatreq = @chatreq 613 | Q().then -> Q.Promise (rs, rj) -> 614 | # figure out file size 615 | if Buffer.isBuffer(imagefile) then rs({ size: imagefile.length }) else fs.stat imagefile, plug(rs, rj) 616 | .then (st) -> 617 | size = st.size 618 | .then -> 619 | log.debug 'image resume upload prepare' 620 | chatreq.baseReq IMAGE_UPLOAD_URL, 'application/x-www-form-urlencoded;charset=UTF-8' 621 | , { 622 | protocolVersion: "0.8" 623 | createSessionRequest: 624 | fields: [{ 625 | external: { 626 | filename, 627 | size, 628 | put: {}, 629 | name: 'file' 630 | } 631 | }] 632 | } 633 | .then (body) -> 634 | puturl = body?.sessionStatus?.externalFieldTransfers?[0]?.putInfo?.url 635 | log.debug 'image resume upload to:', puturl 636 | .then -> Q.Promise (rs, rj) -> 637 | if Buffer.isBuffer(imagefile) then rs(imagefile) else fs.readFile imagefile, plug(rs, rj) 638 | .then (buf) -> 639 | log.debug 'image resume uploading' 640 | chatreq.baseReq puturl, 'application/octet-stream', buf, {}, true, timeout 641 | .then (body) -> 642 | log.debug 'image resume upload finished' 643 | body?.sessionStatus?.additionalInfo?['uploader_service.GoogleRupioAdditionalInfo']?.completionInfo?.customerSpecificInfo?.photoid 644 | 645 | getvideoinformation: (user_id, photo_id) -> 646 | @chatreq.userMediaReq 'videoinformation', { 647 | 'mediaItemId.legacyPhotoId.obfuscatedUserId': user_id, 648 | 'mediaItemId.legacyPhotoId.photoId': photo_id 649 | } 650 | 651 | # aliases list 652 | aliases = [ 653 | 'logLevel', 654 | 'sendChatMessage', 655 | 'setActiveClient', 656 | 'syncAllNewEvents', 657 | 'getSelfInfo', 658 | 'setConversationNotificationLevel', 659 | 'modifyOtrStatus', 660 | 'setFocus', 661 | 'setTyping', 662 | 'setPresence', 663 | 'queryPresence', 664 | 'removeUser', 665 | 'deleteConversation', 666 | 'updateWatermark', 667 | 'addUser', 668 | 'renameConversation', 669 | 'createConversation', 670 | 'getConversation', 671 | 'syncRecentConversations', 672 | 'searchEntities', 673 | 'getEntityById', 674 | 'sendEasteregg', 675 | 'uploadImage' 676 | ] 677 | 678 | # set aliases 679 | aliases.forEach((alias) -> 680 | Client.prototype[alias] = Client.prototype[alias.toLowerCase()]) 681 | 682 | 683 | 684 | 685 | # Expose these as part of publich API 686 | Client.OffTheRecordStatus = OffTheRecordStatus 687 | Client.ClientDeliveryMediumType = ClientDeliveryMediumType 688 | Client.FocusStatus = FocusStatus 689 | Client.TypingStatus = TypingStatus 690 | Client.MessageActionType = MessageActionType 691 | Client.MessageBuilder = MessageBuilder 692 | Client.authStdin = Auth::authStdin 693 | Client.NotificationLevel = ClientNotificationLevel 694 | Client.OAUTH2_LOGIN_URL = Auth.OAUTH2_LOGIN_URL 695 | Client.VERSION = JSON.parse(fs.readFileSync(syspath.normalize syspath.join(__dirname, '..', 'package.json'))).version 696 | -------------------------------------------------------------------------------- /src/init.coffee: -------------------------------------------------------------------------------- 1 | request = require 'request' 2 | log = require 'bog' 3 | Q = require 'q' 4 | fs = require 'fs' 5 | syspath = require 'path' 6 | 7 | InitDataParser = require './initdataparser' 8 | 9 | {req, find, uniqfn, NetworkError} = require './util' 10 | 11 | {CLIENT_GET_SELF_INFO_RESPONSE, 12 | CLIENT_CONVERSATION_STATE_LIST, 13 | INITIAL_CLIENT_ENTITIES} = require './schema' 14 | 15 | CHAT_INIT_URL = 'https://hangouts.google.com/webchat/u/0/load' 16 | CHAT_INIT_PARAMS = 17 | fid: 'gtn-roster-iframe-id', 18 | ec: '["ci:ec",true,true,false]', 19 | pvt: null, # Populated later 20 | 21 | module.exports = class Init 22 | 23 | constructor: (@proxy) -> 24 | @self_entity = [] 25 | @conv_states = [] 26 | 27 | initChat: (jarstore, pvt) -> 28 | log.debug 'initChat()' 29 | params = clone CHAT_INIT_PARAMS 30 | params.pvt = pvt 31 | opts = 32 | method: 'GET' 33 | uri: CHAT_INIT_URL 34 | qs: params 35 | jar: request.jar jarstore 36 | proxy: @proxy 37 | withCredentials: true 38 | self = @ 39 | req(opts).then (res) => 40 | if res.statusCode == 200 41 | @parseBody res.body 42 | else 43 | log.warn 'init failed', res.statusCode, res.statusMessage 44 | Q.reject NetworkError.forRes(res) 45 | 46 | parseBody: (body) -> 47 | DICT = 48 | apikey: { name:'cin:cac', fn: (d) -> d[0][2] } 49 | email: { name:'cic:vd', fn: (d) -> d[0][2] } 50 | headerdate: { name:'cin:acc', fn: (d) -> d[0][4] } 51 | headerversion: { name:'cin:acc', fn: (d) -> d[0][6] } 52 | headerid: { name:'cin:bcsc', fn: (d) -> d[0][7] } 53 | timestamp: { name:'cgsirp', fn: (d) -> new Date (d[0][1][4] / 1000) } 54 | self_entity: { name:'cgsirp', fn: (d) -> 55 | CLIENT_GET_SELF_INFO_RESPONSE.parse(d[0]).self_entity 56 | } 57 | conv_states: { name:'cgsirp', fn: (d) -> 58 | # Removed in server-side update 59 | CLIENT_CONVERSATION_STATE_LIST.parse(d[0][3]) 60 | } 61 | 62 | await InitDataParser.parse body, DICT, this 63 | 64 | # massage the entities 65 | this.entgroups = [] 66 | this.entities = undefined -------------------------------------------------------------------------------- /src/initdataparser.coffee: -------------------------------------------------------------------------------- 1 | log = require 'bog' 2 | Q = require 'q' 3 | 4 | {find} = require './util' 5 | 6 | module.exports = class InitDataParser 7 | 8 | @parse: (body, dict, result) -> 9 | Q().then -> 10 | # the structure of the html body is (bizarelly): 11 | # 12 | # 13 | # 14 | # ... 15 | # 16 | # 17 | 18 | # first remove the part 19 | html = body.replace /*(.|\n)*<\/html>/gm, '' 20 | 21 | # and then the 1665 | -------------------------------------------------------------------------------- /test/convstate.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "conversation_id": { 4 | "id": "UgxjmN5ygMnYI6ZRPZp4AaABAQ" 5 | }, 6 | "conversation": { 7 | "conversation_id": { 8 | "id": "UgxjmN5ygMnYI6ZRPZp4AaABAQ" 9 | }, 10 | "type": "STICKY_ONE_TO_ONE", 11 | "name": null, 12 | "self_conversation_state": { 13 | "self_read_state": { 14 | "participant_id": { 15 | "gaia_id": "102224360723365489932", 16 | "chat_id": "102224360723365489932" 17 | }, 18 | "latest_read_timestamp": 1430497808139701 19 | }, 20 | "status": "ACTIVE", 21 | "notification_level": "RING", 22 | "view": [ 23 | "INBOX_VIEW" 24 | ], 25 | "inviter_id": { 26 | "gaia_id": "102224360723365489932", 27 | "chat_id": "102224360723365489932" 28 | }, 29 | "invite_timestamp": 1430497801138000, 30 | "sort_timestamp": 1430497808139701, 31 | "active_timestamp": 1430497801138000, 32 | "delivery_medium_option": [ { 33 | "current_default": false, 34 | "delivery_medium": { 35 | "delivery_medium_type": "GOOGLE_VOICE", 36 | "phone_number": { 37 | "e164": "+14011234567", 38 | "i18n_data" : { 39 | "national_number": "(401) 123-4567", 40 | "international_number": "+1 401-123-4567", 41 | "country_code": 1, 42 | "region_code": "US", 43 | "is_valid": false, 44 | "validation_result": "IS_POSSIBLE" 45 | } 46 | } 47 | } 48 | } ] 49 | }, 50 | "read_state": [ 51 | { 52 | "participant_id": { 53 | "gaia_id": "105510613398923491294", 54 | "chat_id": "105510613398923491294" 55 | }, 56 | "last_read_timestamp": 0 57 | }, 58 | { 59 | "participant_id": { 60 | "gaia_id": "102224360723365489932", 61 | "chat_id": "102224360723365489932" 62 | }, 63 | "last_read_timestamp": 1430497808139701 64 | } 65 | ], 66 | "otr_status": "ON_THE_RECORD", 67 | "current_participant": [ 68 | { 69 | "gaia_id": "102224360723365489932", 70 | "chat_id": "102224360723365489932" 71 | }, 72 | { 73 | "gaia_id": "105510613398923491294", 74 | "chat_id": "105510613398923491294" 75 | } 76 | ], 77 | "participant_data": [ 78 | { 79 | "id": { 80 | "gaia_id": "105510613398923491294", 81 | "chat_id": "105510613398923491294" 82 | }, 83 | "fallback_name": "Bo Tenström", 84 | "invitation_status": "PENDING", 85 | "phone_number": null, 86 | "participant_type": "GAIA", 87 | "new_invitation_status": "PENDING" 88 | }, 89 | { 90 | "id": { 91 | "gaia_id": "102224360723365489932", 92 | "chat_id": "102224360723365489932" 93 | }, 94 | "fallback_name": "Bo Tenström", 95 | "invitation_status": "ACCEPTED", 96 | "phone_number": { 97 | "e164": "+14017654321", 98 | "i18n_data": { 99 | "national_number": "(401) 765-4321", 100 | "international_number": "+1 401-765-4321", 101 | "country_code": 1, 102 | "region_code": "US", 103 | "is_valid": true, 104 | "validation_result": "IS_POSSIBLE" 105 | } 106 | }, 107 | "participant_type": "GOOGLE_VOICE", 108 | "new_invitation_status": "ACCEPTED" 109 | } 110 | ] 111 | }, 112 | "event": [ 113 | { 114 | "conversation_id": { 115 | "id": "UgxjmN5ygMnYI6ZRPZp4AaABAQ" 116 | }, 117 | "sender_id": { 118 | "gaia_id": "102224360723365489932", 119 | "chat_id": "102224360723365489932" 120 | }, 121 | "timestamp": 1430497808139701, 122 | "self_event_state": { 123 | "user_id": { 124 | "gaia_id": "102224360723365489932", 125 | "chat_id": "102224360723365489932" 126 | }, 127 | "client_generated_id": "1289503288794", 128 | "notification_level": {} 129 | }, 130 | "chat_message": { 131 | "annotation": [], 132 | "message_content": { 133 | "segment": [ 134 | { 135 | "type": "TEXT", 136 | "text": "hey", 137 | "formatting": { 138 | "bold": 0, 139 | "italic": 0, 140 | "strikethrough": 0, 141 | "underline": 0 142 | }, 143 | "link_data": null 144 | } 145 | ], 146 | "attachment": [] 147 | } 148 | }, 149 | "membership_change": null, 150 | "conversation_rename": null, 151 | "hangout_event": null, 152 | "event_id": "7zbr7sUt_No7zbr8i4WRTa", 153 | "advances_sort_timestamp": null, 154 | "otr_modification": null, 155 | "event_otr": "ON_THE_RECORD" 156 | } 157 | ], 158 | "event_continuation_token": null 159 | }, 160 | { 161 | "conversation_id": { 162 | "id": "UgzJilj2Tg_oqk5EhEp4AaABAQ" 163 | }, 164 | "conversation": { 165 | "conversation_id": { 166 | "id": "UgzJilj2Tg_oqk5EhEp4AaABAQ" 167 | }, 168 | "type": "STICKY_ONE_TO_ONE", 169 | "name": null, 170 | "self_conversation_state": { 171 | "self_read_state": { 172 | "participant_id": { 173 | "gaia_id": "102224360723365489932", 174 | "chat_id": "102224360723365489932" 175 | }, 176 | "latest_read_timestamp": 1430497844252252 177 | }, 178 | "status": "ACTIVE", 179 | "notification_level": "RING", 180 | "view": [ 181 | "INBOX_VIEW" 182 | ], 183 | "inviter_id": { 184 | "gaia_id": "110994664963851875523", 185 | "chat_id": "110994664963851875523" 186 | }, 187 | "invite_timestamp": 1430497826216000, 188 | "sort_timestamp": 1430497904083513, 189 | "active_timestamp": 1430497840649000, 190 | "delivery_medium_option": [ { 191 | "current_default": true, 192 | "delivery_medium": { 193 | "delivery_medium_type": "BABEL", 194 | "phone_number": null 195 | } 196 | } ] 197 | }, 198 | "read_state": [ 199 | { 200 | "participant_id": { 201 | "gaia_id": "102224360723365489932", 202 | "chat_id": "102224360723365489932" 203 | }, 204 | "last_read_timestamp": 1430497844252252 205 | }, 206 | { 207 | "participant_id": { 208 | "gaia_id": "110994664963851875523", 209 | "chat_id": "110994664963851875523" 210 | }, 211 | "last_read_timestamp": 0 212 | } 213 | ], 214 | "otr_status": "ON_THE_RECORD", 215 | "current_participant": [ 216 | { 217 | "gaia_id": "110994664963851875523", 218 | "chat_id": "110994664963851875523" 219 | }, 220 | { 221 | "gaia_id": "102224360723365489932", 222 | "chat_id": "102224360723365489932" 223 | } 224 | ], 225 | "participant_data": [ 226 | { 227 | "id": { 228 | "gaia_id": "102224360723365489932", 229 | "chat_id": "102224360723365489932" 230 | }, 231 | "fallback_name": "Bo Tenström", 232 | "invitation_status": "ACCEPTED", 233 | "phone_number": null, 234 | "participant_type": "GAIA", 235 | "new_invitation_status": "ACCEPTED" 236 | }, 237 | { 238 | "id": { 239 | "gaia_id": "110994664963851875523", 240 | "chat_id": "110994664963851875523" 241 | }, 242 | "fallback_name": "Martin Algesten", 243 | "invitation_status": "ACCEPTED", 244 | "phone_number": null, 245 | "participant_type": "GAIA", 246 | "new_invitation_status": "ACCEPTED" 247 | } 248 | ] 249 | }, 250 | "event": [ 251 | { 252 | "conversation_id": { 253 | "id": "UgzJilj2Tg_oqk5EhEp4AaABAQ" 254 | }, 255 | "sender_id": { 256 | "gaia_id": "110994664963851875523", 257 | "chat_id": "110994664963851875523" 258 | }, 259 | "timestamp": 1430497831165769, 260 | "self_event_state": { 261 | "user_id": { 262 | "gaia_id": "102224360723365489932", 263 | "chat_id": "102224360723365489932" 264 | }, 265 | "client_generated_id": null, 266 | "notification_level": {} 267 | }, 268 | "chat_message": { 269 | "annotation": [], 270 | "message_content": { 271 | "segment": [ 272 | { 273 | "type": "TEXT", 274 | "text": "the second", 275 | "formatting": { 276 | "bold": 0, 277 | "italic": 0, 278 | "strikethrough": 0, 279 | "underline": 0 280 | }, 281 | "link_data": null 282 | } 283 | ], 284 | "attachment": [] 285 | } 286 | }, 287 | "membership_change": null, 288 | "conversation_rename": null, 289 | "hangout_event": null, 290 | "event_id": "7zbrAwdAT-g7zbrBWycNlt", 291 | "advances_sort_timestamp": null, 292 | "otr_modification": null, 293 | "event_otr": "ON_THE_RECORD" 294 | }, 295 | { 296 | "conversation_id": { 297 | "id": "UgzJilj2Tg_oqk5EhEp4AaABAQ" 298 | }, 299 | "sender_id": { 300 | "gaia_id": "102224360723365489932", 301 | "chat_id": "102224360723365489932" 302 | }, 303 | "timestamp": 1430497844252252, 304 | "self_event_state": { 305 | "user_id": { 306 | "gaia_id": "102224360723365489932", 307 | "chat_id": "102224360723365489932" 308 | }, 309 | "client_generated_id": "260429275674", 310 | "notification_level": {} 311 | }, 312 | "chat_message": { 313 | "annotation": [], 314 | "message_content": { 315 | "segment": [ 316 | { 317 | "type": "TEXT", 318 | "text": "test123", 319 | "formatting": { 320 | "bold": 0, 321 | "italic": 0, 322 | "strikethrough": 0, 323 | "underline": 0 324 | }, 325 | "link_data": null 326 | } 327 | ], 328 | "attachment": [] 329 | } 330 | }, 331 | "membership_change": null, 332 | "conversation_rename": null, 333 | "hangout_event": null, 334 | "event_id": "7zbrAwdAT-g7zbrD7DR5bM", 335 | "advances_sort_timestamp": null, 336 | "otr_modification": null, 337 | "event_otr": "ON_THE_RECORD" 338 | }, 339 | { 340 | "conversation_id": { 341 | "id": "UgzJilj2Tg_oqk5EhEp4AaABAQ" 342 | }, 343 | "sender_id": { 344 | "gaia_id": "110994664963851875523", 345 | "chat_id": "110994664963851875523" 346 | }, 347 | "timestamp": 1430497904083513, 348 | "self_event_state": { 349 | "user_id": { 350 | "gaia_id": "102224360723365489932", 351 | "chat_id": "102224360723365489932" 352 | }, 353 | "client_generated_id": null, 354 | "notification_level": {} 355 | }, 356 | "chat_message": { 357 | "annotation": [], 358 | "message_content": { 359 | "segment": [ 360 | { 361 | "type": "TEXT", 362 | "text": "the second one", 363 | "formatting": { 364 | "bold": 0, 365 | "italic": 0, 366 | "strikethrough": 0, 367 | "underline": 0 368 | }, 369 | "link_data": null 370 | } 371 | ], 372 | "attachment": [] 373 | } 374 | }, 375 | "membership_change": null, 376 | "conversation_rename": null, 377 | "hangout_event": null, 378 | "event_id": "7zbrAwdAT-g7zbrKQdW4ZV", 379 | "advances_sort_timestamp": null, 380 | "otr_modification": null, 381 | "event_otr": "ON_THE_RECORD" 382 | } 383 | ], 384 | "event_continuation_token": null 385 | } 386 | ] 387 | -------------------------------------------------------------------------------- /test/sidgsid.bin: -------------------------------------------------------------------------------- 1 | 79 2 | [[0,["c","9EB0A0FABFF8FB97","",8] 3 | ] 4 | ,[1,[{"gsid":"iMyLjHNOp8jTdYnYP4ophA"}]] 5 | ] 6 | -------------------------------------------------------------------------------- /test/syncall.bin: -------------------------------------------------------------------------------- 1 | ["csanerp",[1,,"","-6693534691558475312",1430641400746000] 2 | ,1430641100747000,[[["UgzJilj2Tg_oqk5EhEp4AaABAQ"] 3 | ,[["UgzJilj2Tg_oqk5EhEp4AaABAQ"] 4 | ,1,,[,,,,,,[["102224360723365489932","102224360723365489932"] 5 | ,1430575955297187] 6 | ,2,30,[1] 7 | ,["110994664963851875523","110994664963851875523"] 8 | ,1430497826216000,1430575955297187,1430497840649000,,,[[[1] 9 | ,1] 10 | ] 11 | ,0] 12 | ,[] 13 | ,[] 14 | ,,[[["102224360723365489932","102224360723365489932"] 15 | ,1430575955297187] 16 | ,[["110994664963851875523","110994664963851875523"] 17 | ,0] 18 | ] 19 | ,0,2,1,1,[["110994664963851875523","110994664963851875523"] 20 | ,["102224360723365489932","102224360723365489932"] 21 | ] 22 | ,[[["102224360723365489932","102224360723365489932"] 23 | ,"Bo Tenström",2,,2,2] 24 | ,[["110994664963851875523","110994664963851875523"] 25 | ,"Martin Algesten",2,,2,2] 26 | ] 27 | ,,0,,[1] 28 | ,1] 29 | ,[[["UgzJilj2Tg_oqk5EhEp4AaABAQ"] 30 | ,["110994664963851875523","110994664963851875523"] 31 | ,1430497831165769,[["102224360723365489932","102224360723365489932"] 32 | ] 33 | ,,0,[,[] 34 | ,[[[0,"the second",[0,0,0,0] 35 | ] 36 | ] 37 | ,[] 38 | ] 39 | ] 40 | ,,,,,"7zbrAwdAT-g7zbrBWycNlt",,,1,2,1,,,[1] 41 | ,,,1,1430497831165769] 42 | ,[["UgzJilj2Tg_oqk5EhEp4AaABAQ"] 43 | ,["102224360723365489932","102224360723365489932"] 44 | ,1430497844252252,[["102224360723365489932","102224360723365489932"] 45 | ,"260429275674"] 46 | ,,0,[,[] 47 | ,[[[0,"test123",[0,0,0,0] 48 | ] 49 | ] 50 | ,[] 51 | ] 52 | ] 53 | ,,,,,"7zbrAwdAT-g7zbrD7DR5bM",,,1,2,1,,,[1] 54 | ,,,1,1430497844252252] 55 | ,[["UgzJilj2Tg_oqk5EhEp4AaABAQ"] 56 | ,["110994664963851875523","110994664963851875523"] 57 | ,1430497904083513,[["102224360723365489932","102224360723365489932"] 58 | ] 59 | ,,0,[,[] 60 | ,[[[0,"the second one",[0,0,0,0] 61 | ] 62 | ] 63 | ,[] 64 | ] 65 | ] 66 | ,,,,,"7zbrAwdAT-g7zbrKQdW4ZV",,,1,2,1,,,[1] 67 | ,,,1,1430497904083513] 68 | ,[["UgzJilj2Tg_oqk5EhEp4AaABAQ"] 69 | ,["110994664963851875523","110994664963851875523"] 70 | ,1430571336328963,[["102224360723365489932","102224360723365489932"] 71 | ] 72 | ,,0,[,[] 73 | ,[[[0,"vi testar 123",[] 74 | ] 75 | ] 76 | ,[] 77 | ] 78 | ] 79 | ,,,,,"7zbrAwdAT-g7ze2OK3N6LB",,,1,2,1,,,[1] 80 | ,,,1,1430571336328963] 81 | ,[["UgzJilj2Tg_oqk5EhEp4AaABAQ"] 82 | ,["110994664963851875523","110994664963851875523"] 83 | ,1430575955297187,[["102224360723365489932","102224360723365489932"] 84 | ] 85 | ,,0,[,[] 86 | ,[[[0,"tja bosse",[0,0,0,0] 87 | ] 88 | ] 89 | ,[] 90 | ] 91 | ] 92 | ,,,,,"7zbrAwdAT-g7zeBC9kcDsL",,,1,2,1,,,[1] 93 | ,,,1,1430575955297187] 94 | ] 95 | ,,,,[] 96 | ] 97 | ,[["UgxjmN5ygMnYI6ZRPZp4AaABAQ"] 98 | ,[["UgxjmN5ygMnYI6ZRPZp4AaABAQ"] 99 | ,1,,[,,,,,,[["102224360723365489932","102224360723365489932"] 100 | ,1430497808139701] 101 | ,2,30,[1] 102 | ,["102224360723365489932","102224360723365489932"] 103 | ,1430497801138000,1430497808139701,1430497801138000,,,[[[1] 104 | ,1] 105 | ] 106 | ,0] 107 | ,[] 108 | ,[] 109 | ,,[[["105510613398923491294","105510613398923491294"] 110 | ,0] 111 | ,[["102224360723365489932","102224360723365489932"] 112 | ,1430497808139701] 113 | ] 114 | ,0,2,1,1,[["102224360723365489932","102224360723365489932"] 115 | ,["105510613398923491294","105510613398923491294"] 116 | ] 117 | ,[[["105510613398923491294","105510613398923491294"] 118 | ,"Bo Tenström",1,,2,1] 119 | ,[["102224360723365489932","102224360723365489932"] 120 | ,"Bo Tenström",2,,2,2] 121 | ] 122 | ,,0,,[1] 123 | ,1] 124 | ,[[["UgxjmN5ygMnYI6ZRPZp4AaABAQ"] 125 | ,["102224360723365489932","102224360723365489932"] 126 | ,1430497808139701,[["102224360723365489932","102224360723365489932"] 127 | ,"1289503288794"] 128 | ,,0,[,[] 129 | ,[[[0,"hey",[0,0,0,0] 130 | ] 131 | ] 132 | ,[] 133 | ] 134 | ] 135 | ,,,,,"7zbr7sUt_No7zbr8i4WRTa",,,1,2,1,,,[1] 136 | ,,,1,1430497808139701] 137 | ] 138 | ,,,,[] 139 | ] 140 | ] 141 | ,,,,[] 142 | ] 143 | -------------------------------------------------------------------------------- /test/test-initparse.coffee: -------------------------------------------------------------------------------- 1 | {assert} = require('chai') 2 | deql = assert.deepEqual 3 | 4 | {CLIENT_GET_SELF_INFO_RESPONSE 5 | INITIAL_CLIENT_ENTITIES, 6 | CLIENT_CONVERSATION_STATE_LIST, 7 | EMBED_ITEM} = require '../src/schema' 8 | 9 | msg1 = ["cgsirp",[1,null,"","1950326504872917925",1430493729941000],[null,null,null,null,null,null,null,[1,0,[],null,null,null,null,null,[[]]],["102224360723365489932","102224360723365489932"],[1,"Bo Tenström","Bo","//lh5.googleusercontent.com/-99B0CMsSo68/AAAAAAAAAAI/AAAAAAAAABI/v8oOeHFwNSI/photo.jpg",["botenstrom2@gmail.com"],[],null,null,null,null,null,2,[],[]],null,null,2,null,0,0,0],0,[],[0,null,0],[0],[[],[],2],[[8,0],[9,1],[22,0],[19,1],[10,1],[11,1],[14,0],[20,0],[17,0],[16,0],[23,0],[24,0],[27,0],[5,1],[6,1],[1,0],[2,1],[7,1],[3,1],[4,1],[29,1],[13,0],[12,0],[15,0],[28,0]],[1],1,[1,1],[null,[],[[5,0],[4,0],[2,0],[6,1],[1,0],[3,1]]],1,1,0,2,[],1,["SE",46],[],null,[1]] 10 | 11 | cmp1 = { 12 | "self_entity": { 13 | "id": { 14 | "gaia_id": "102224360723365489932", 15 | "chat_id": "102224360723365489932" 16 | }, 17 | "properties": { 18 | "type": 1, 19 | "display_name": "Bo Tenström", 20 | "first_name": "Bo", 21 | "photo_url": "//lh5.googleusercontent.com/-99B0CMsSo68/AAAAAAAAAAI/AAAAAAAAABI/v8oOeHFwNSI/photo.jpg", 22 | "canonical_email": null, 23 | "in_users_domain": null, 24 | "gender": null, 25 | "phones": [], 26 | "photo_url_status": 2, 27 | "emails": [ 28 | "botenstrom2@gmail.com" 29 | ] 30 | } 31 | } 32 | } 33 | 34 | describe 'CLIENT_GET_SELF_INFO_RESPONSE', -> 35 | 36 | it 'parses', -> 37 | deql CLIENT_GET_SELF_INFO_RESPONSE.parse(msg1), cmp1 38 | 39 | 40 | msg2 = ["cgserp",[1,null,"","-7303892207317438164",1430552593677000],[],null,[0,"XrPb1g==",[[[null,null,null,null,null,null,null,null,["110994664963851875523","110994664963851875523"],[1,"Martin Algesten","Martin","//lh5.googleusercontent.com/-R7AuYVncPys/AAAAAAAAAAI/AAAAAAAAAIw/incUIqFokok/photo.jpg",[],[],null,null,null,0,null,2,[],[]],null,null,2,null,1,1,0],0],[[null,null,null,null,null,null,null,null,["105510613398923491294","105510613398923491294"],[1,"Bo Tenström","Bo","//lh6.googleusercontent.com/-Xg2kTTvP-1o/AAAAAAAAAAI/AAAAAAAAABY/buSUZUepxPY/photo.jpg",[],[],null,null,null,0,null,2,[],[]],null,null,2,null,1,1,0],0]]],[0,"KCC4Ng==",[[[null,null,null,null,null,null,null,null,["105510613398923491294","105510613398923491294"],[1,"Bo Tenström","Bo","//lh6.googleusercontent.com/-Xg2kTTvP-1o/AAAAAAAAAAI/AAAAAAAAABY/buSUZUepxPY/photo.jpg",[],[],null,null,null,0,null,2,[],[]],null,null,2,null,1,1,0],1],[[null,null,null,null,null,null,null,null,["110994664963851875523","110994664963851875523"],[1,"Martin Algesten","Martin","//lh5.googleusercontent.com/-R7AuYVncPys/AAAAAAAAAAI/AAAAAAAAAIw/incUIqFokok/photo.jpg",[],[],null,null,null,0,null,2,[],[]],null,null,2,null,1,1,0],2]]],[0,"AAAAAA==",[]],[0,"AAAAAA==",[]],[0,"AAAAAA==",[]],[0,"AAAAAA==",[]]] 41 | 42 | cmp2 = { 43 | "entities": [], 44 | "group1": { 45 | "entities": [ 46 | { 47 | "entity": { 48 | "id": { 49 | "gaia_id": "110994664963851875523", 50 | "chat_id": "110994664963851875523" 51 | }, 52 | "properties": { 53 | "type": 1, 54 | "display_name": "Martin Algesten", 55 | "first_name": "Martin", 56 | "photo_url": "//lh5.googleusercontent.com/-R7AuYVncPys/AAAAAAAAAAI/AAAAAAAAAIw/incUIqFokok/photo.jpg", 57 | "canonical_email": null, 58 | "in_users_domain": 0, 59 | "gender": null, 60 | "phones": [], 61 | "photo_url_status": 2, 62 | "emails": [] 63 | } 64 | } 65 | }, 66 | { 67 | "entity": { 68 | "id": { 69 | "gaia_id": "105510613398923491294", 70 | "chat_id": "105510613398923491294" 71 | }, 72 | "properties": { 73 | "type": 1, 74 | "display_name": "Bo Tenström", 75 | "first_name": "Bo", 76 | "photo_url": "//lh6.googleusercontent.com/-Xg2kTTvP-1o/AAAAAAAAAAI/AAAAAAAAABY/buSUZUepxPY/photo.jpg", 77 | "canonical_email": null, 78 | "in_users_domain": 0, 79 | "gender": null, 80 | "phones": [], 81 | "photo_url_status": 2, 82 | "emails": [] 83 | } 84 | } 85 | } 86 | ] 87 | }, 88 | "group2": { 89 | "entities": [ 90 | { 91 | "entity": { 92 | "id": { 93 | "gaia_id": "105510613398923491294", 94 | "chat_id": "105510613398923491294" 95 | }, 96 | "properties": { 97 | "type": 1, 98 | "display_name": "Bo Tenström", 99 | "first_name": "Bo", 100 | "photo_url": "//lh6.googleusercontent.com/-Xg2kTTvP-1o/AAAAAAAAAAI/AAAAAAAAABY/buSUZUepxPY/photo.jpg", 101 | "canonical_email": null, 102 | "in_users_domain": 0, 103 | "gender": null, 104 | "phones": [], 105 | "photo_url_status": 2, 106 | "emails": [] 107 | } 108 | } 109 | }, 110 | { 111 | "entity": { 112 | "id": { 113 | "gaia_id": "110994664963851875523", 114 | "chat_id": "110994664963851875523" 115 | }, 116 | "properties": { 117 | "type": 1, 118 | "display_name": "Martin Algesten", 119 | "first_name": "Martin", 120 | "photo_url": "//lh5.googleusercontent.com/-R7AuYVncPys/AAAAAAAAAAI/AAAAAAAAAIw/incUIqFokok/photo.jpg", 121 | "canonical_email": null, 122 | "in_users_domain": 0, 123 | "gender": null, 124 | "phones": [], 125 | "photo_url_status": 2, 126 | "emails": [] 127 | } 128 | } 129 | } 130 | ] 131 | }, 132 | "group3": { 133 | "entities": [] 134 | }, 135 | "group4": { 136 | "entities": [] 137 | }, 138 | "group5": { 139 | "entities": [] 140 | } 141 | } 142 | 143 | describe 'INITIAL_CLIENT_ENTITIES', -> 144 | 145 | it 'parses', -> 146 | deql INITIAL_CLIENT_ENTITIES.parse(msg2), cmp2 147 | 148 | 149 | msg3 = [[["UgxjmN5ygMnYI6ZRPZp4AaABAQ"],[["UgxjmN5ygMnYI6ZRPZp4AaABAQ"],1,null,[null,null,null,null,null,null,[["102224360723365489932","102224360723365489932"],1430497808139701],2,30,[1],["102224360723365489932","102224360723365489932"],1430497801138000,1430497808139701,1430497801138000,null,null,[[[2,["+14011234567",["(401) 123-4567","+1 401-123-4567",1,"US",0,0]]],0,1]],0],[],[],null,[[["105510613398923491294","105510613398923491294"],0],[["102224360723365489932","102224360723365489932"],1430497808139701]],0,2,1,1,[["102224360723365489932","102224360723365489932"],["105510613398923491294","105510613398923491294"]],[[["105510613398923491294","105510613398923491294"],"Bo Tenström",1,null,2,1],[["102224360723365489932","102224360723365489932"],"Bo Tenström",2,["+14017654321",["(401) 765-4321","+1 401-765-4321",1,"US",1,0] ],3,2]],null,0,null,[1],1],[[["UgxjmN5ygMnYI6ZRPZp4AaABAQ"],["102224360723365489932","102224360723365489932"],1430497808139701,[["102224360723365489932","102224360723365489932"],"1289503288794"],null,0,[null,[],[[[0,"hey",[0,0,0,0]]],[]]],null,null,null,null,"7zbr7sUt_No7zbr8i4WRTa",null,null,1,2,1,null,null,[1],null,null,1,1430497808139701]],null,null,null,[]],[["UgzJilj2Tg_oqk5EhEp4AaABAQ"],[["UgzJilj2Tg_oqk5EhEp4AaABAQ"],1,null,[null,null,null,null,null,null,[["102224360723365489932","102224360723365489932"],1430497844252252],2,30,[1],["110994664963851875523","110994664963851875523"],1430497826216000,1430497904083513,1430497840649000,null,null,[[[1],1]],0],[],[],null,[[["102224360723365489932","102224360723365489932"],1430497844252252],[["110994664963851875523","110994664963851875523"],0]],0,2,1,1,[["110994664963851875523","110994664963851875523"],["102224360723365489932","102224360723365489932"]],[[["102224360723365489932","102224360723365489932"],"Bo Tenström",2,null,2,2],[["110994664963851875523","110994664963851875523"],"Martin Algesten",2,null,2,2]],null,0,null,[1],1],[[["UgzJilj2Tg_oqk5EhEp4AaABAQ"],["110994664963851875523","110994664963851875523"],1430497831165769,[["102224360723365489932","102224360723365489932"]],null,0,[null,[],[[[0,"the second",[0,0,0,0]]],[]]],null,null,null,null,"7zbrAwdAT-g7zbrBWycNlt",null,null,1,2,1,null,null,[1],null,null,1,1430497831165769],[["UgzJilj2Tg_oqk5EhEp4AaABAQ"],["102224360723365489932","102224360723365489932"],1430497844252252,[["102224360723365489932","102224360723365489932"],"260429275674"],null,0,[null,[],[[[0,"test123",[0,0,0,0]]],[]]],null,null,null,null,"7zbrAwdAT-g7zbrD7DR5bM",null,null,1,2,1,null,null,[1],null,null,1,1430497844252252],[["UgzJilj2Tg_oqk5EhEp4AaABAQ"],["110994664963851875523","110994664963851875523"],1430497904083513,[["102224360723365489932","102224360723365489932"]],null,0,[null,[],[[[0,"the second one",[0,0,0,0]]],[]]],null,null,null,null,"7zbrAwdAT-g7zbrKQdW4ZV",null,null,1,2,1,null,null,[1],null,null,1,1430497904083513]],null,null,null,[]]] 150 | 151 | cmp3 = require './convstate.json' 152 | 153 | describe 'CLIENT_CONVERSATION_STATE_LIST', -> 154 | 155 | it 'parses', -> 156 | deql CLIENT_CONVERSATION_STATE_LIST.parse(msg3), cmp3 157 | 158 | msg4 = [['249'], '' , { "27639957": [["https://plus.google.com/photos/albums/p16geqve3h5t3tqdn4odhtha2j5lqkale?pid=6275042227379600450&oid=103730981268153889186", null, null, "https://lh3.googleusercontent.com/-QUwpEWamKew/VxVtqMGJfEI/AAAAAAAAAFM/jeRZI6e_DUIZkUVdhXoNbQNiY8UxBGvwwCK8B/s0/2016-04-18.jpg", null, null, null, null, null, 768, 401], "103730981268153889186", "6272415246136908337", "6275042227379600450", null, "https://lh3.googleusercontent.com/-QUwpEWamKew/VxVtqMGJfEI/AAAAAAAAAFM/jeRZI6e_DUIZkUVdhXoNbQNiY8UxBGvwwCK8B/s0/2016-04-18.jpg", null, null, null, "https://lh3.googleusercontent.com/nUIH-qp7Cgeei1PAAdirnxrtS2Ryc6A2Tai2gzOdR0oIAPxhIj9BtSkTkYQWxalPvr4", null, null, 1, ["shared_group_6275042227379600450", "BABEL_STREAM_ID", "BABEL_UNIQUE_ID_1e30efc4-8f46-4f58-ab52-c2b8ec77a3a7"]] }, ''] 159 | 160 | cmp4 = { 161 | 'type_': ['249'], 162 | 'data': "", 163 | 'places': null, 164 | 'plus_photo': {"data":{"album_id":"6272415246136908337","media_type":"MEDIA_TYPE_PHOTO","original_content_url":"https://lh3.googleusercontent.com/nUIH-qp7Cgeei1PAAdirnxrtS2Ryc6A2Tai2gzOdR0oIAPxhIj9BtSkTkYQWxalPvr4","owner_obfuscated_id":"103730981268153889186","photo_id":"6275042227379600450","stream_id":["shared_group_6275042227379600450","BABEL_STREAM_ID","BABEL_UNIQUE_ID_1e30efc4-8f46-4f58-ab52-c2b8ec77a3a7"],"thumbnail":{"height_px":401,"image_url":"https://lh3.googleusercontent.com/-QUwpEWamKew/VxVtqMGJfEI/AAAAAAAAAFM/jeRZI6e_DUIZkUVdhXoNbQNiY8UxBGvwwCK8B/s0/2016-04-18.jpg","url":"https://plus.google.com/photos/albums/p16geqve3h5t3tqdn4odhtha2j5lqkale?pid=6275042227379600450&oid=103730981268153889186","width_px":768},"url":"https://lh3.googleusercontent.com/-QUwpEWamKew/VxVtqMGJfEI/AAAAAAAAAFM/jeRZI6e_DUIZkUVdhXoNbQNiY8UxBGvwwCK8B/s0/2016-04-18.jpg"}}} 165 | 166 | describe 'EMBED_ITEM', -> 167 | 168 | it 'parses', -> 169 | deql EMBED_ITEM.parse(msg4), cmp4 170 | -------------------------------------------------------------------------------- /test/test-initparsebody.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | {assert} = require('chai') 3 | deql = assert.deepEqual 4 | 5 | Init = require '../src/init' 6 | 7 | describe 'Init', -> 8 | 9 | init = null 10 | beforeEach -> 11 | init = new Init() 12 | 13 | describe 'parseBody', -> 14 | 15 | it 'takes an html body, evals it and extracts data', -> 16 | body = fs.readFileSync './test/body.html', 'utf-8' 17 | init.parseBody(body).then -> 18 | deql init.apikey, 'AIzaSyAfFJCeph-euFSwtmqFZi0kaKk-cZ5wufM' 19 | deql init.email, 'joao.marques.antunes@gmail.com' 20 | deql init.headerdate, '1480451609' 21 | deql init.headerversion, 'chat_frontend_20161129.11_p0' 22 | deql init.headerid, '820F7C523A7BC3A6' 23 | deql init.timestamp, new Date('2016-12-04T22:40:25.772Z') 24 | assert.isNotNull init.self_entity 25 | assert.isNotNull init.entities 26 | -------------------------------------------------------------------------------- /test/test-messagebuilder.coffee: -------------------------------------------------------------------------------- 1 | {assert} = require('chai') 2 | deql = assert.deepEqual 3 | 4 | MessageBuilder = require '../src/messagebuilder' 5 | 6 | describe 'MessageBuilder', -> 7 | 8 | mb = null 9 | beforeEach -> 10 | mb = new MessageBuilder() 11 | 12 | it 'adds a simple text segment', -> 13 | deql mb.text('Hello World!').toSegments(), [[0,'Hello World!']] 14 | deql mb.toSegsjson(), [{text:'Hello World!', type:'TEXT'}] 15 | 16 | it 'adds a bold text segment', -> 17 | deql mb.bold('Hello World!').toSegments(), [[0,'Hello World!',[1,null,null,null]]] 18 | deql mb.toSegsjson(), [{formatting:{bold:1}, text:'Hello World!', type:'TEXT'}] 19 | 20 | it 'adds a italic text segment', -> 21 | deql mb.italic('Hello World!').toSegments(), [[0,'Hello World!',[null,1,null,null]]] 22 | deql mb.toSegsjson(), [{formatting:{italic:1}, text:'Hello World!', type:'TEXT'}] 23 | 24 | it 'adds a strikethrough text segment', -> 25 | deql mb.strikethrough('Hello World!').toSegments(), [[0,'Hello World!',[null,null,1,null]]] 26 | deql mb.toSegsjson(), [{formatting:{strikethrough:1}, text:'Hello World!', type:'TEXT'}] 27 | 28 | it 'adds an underline text segment', -> 29 | deql mb.underline('Hello World!').toSegments(), [[0,'Hello World!',[null,null,null,1]]] 30 | deql mb.toSegsjson(), [{formatting:{underline:1}, text:'Hello World!', type:'TEXT'}] 31 | 32 | it 'adds a link', -> 33 | deql mb.link('linktext', 'http://foo/bar').toSegments(), 34 | [[0, 'linktext ('], [2,'http://foo/bar',null,['http://foo/bar']], [0, ')']] 35 | deql mb.toSegsjson(), 36 | [{type:'TEXT', text:'linktext ('}, {link_data:{link_target:'http://foo/bar'},text:'http://foo/bar', type:'LINK'}, {type:'TEXT', text:')'}] 37 | 38 | it 'adds a plain link', -> 39 | deql mb.link('http://foo/bar', 'http://foo/bar').toSegments(), 40 | [[2,'http://foo/bar',null,['http://foo/bar']]] 41 | deql mb.toSegsjson(), 42 | [{link_data:{link_target:'http://foo/bar'},text:'http://foo/bar', type:'LINK'}] 43 | 44 | it 'adds a linebreak', -> 45 | deql mb.linebreak().toSegments(), [[1,'\n']] 46 | deql mb.toSegsjson(), [{text:'\n', type:'LINE_BREAK'}] 47 | 48 | it 'recognizes the given message_action_type', -> 49 | mb = new MessageBuilder 4 50 | deql mb.toMessageActionType(), [[4, ""]] 51 | -------------------------------------------------------------------------------- /test/test-messageparser.coffee: -------------------------------------------------------------------------------- 1 | {assert} = require('chai') 2 | deql = assert.deepEqual 3 | 4 | MessageParser = require '../src/messageparser' 5 | 6 | describe 'MessagePaser', -> 7 | 8 | m = null 9 | beforeEach -> 10 | m = new MessageParser() 11 | 12 | describe 'parsePushLines', -> 13 | 14 | it 'handles noop', (done) -> 15 | m.emit = (ev) -> 16 | deql 'noop', ev 17 | done() 18 | m.parsePushLines [[[2,["noop"]]]] 19 | 20 | it 'handles {p:{ with clientid', (done) -> 21 | m.emit = (ev, data) -> 22 | deql ev, 'clientid' 23 | deql data, 'lcsw_hangouts881CED94' 24 | done() 25 | m.parsePushLines [[[3,[{"p":"{\"1\":{\"1\":{\"1\":{\"1\":1,\"2\":1}},\"4\":\"1430570659159\",\"5\":\"S1\"},\"3\":{\"1\":{\"1\":1},\"2\":\"lcsw_hangouts881CED94\"}}"}]]]] 26 | -------------------------------------------------------------------------------- /test/test-pushdataparser.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | {assert} = require('chai') 3 | deql = assert.deepEqual 4 | 5 | PushDataParser = require '../src/pushdataparser' 6 | 7 | describe 'PushDataParser', -> 8 | 9 | p = null 10 | beforeEach -> 11 | p = new PushDataParser() 12 | 13 | it 'parses sid/gsid', -> 14 | msg = fs.readFileSync './test/sidgsid.bin', null 15 | lines = p.parse msg 16 | deql lines, 1 17 | s = p.pop() 18 | [_,[_,sid]] = s[0] 19 | [_,[{gsid}]] = s[1] 20 | deql sid, '9EB0A0FABFF8FB97' 21 | deql gsid, 'iMyLjHNOp8jTdYnYP4ophA' 22 | 23 | it 'handles chopped off len specifications', -> 24 | msg1 = new Buffer.from('1') 25 | lines = p.parse msg1 26 | deql lines, 0 27 | deql p.leftover, new Buffer.from('1') 28 | msg2 = new Buffer.from('0\n1234567890') 29 | lines = p.parse msg2 30 | deql lines, 1 31 | deql p.leftover, null 32 | 33 | it 'handles chopped off data', -> 34 | msg1 = new Buffer.from('10\n1234') 35 | lines = p.parse msg1 36 | deql lines, 0 37 | deql p.leftover, new Buffer.from('10\n1234') 38 | msg2 = new Buffer.from('567890') 39 | lines = p.parse msg2 40 | deql lines, 1 41 | deql p.leftover, null 42 | 43 | describe 'allLines', -> 44 | 45 | it 'is a promise for all the lines read', (done) -> 46 | p.allLines().then (lines) -> 47 | deql lines, ['abc', 'def'] 48 | done() 49 | .done() 50 | p.parse new Buffer.from('5\n"abc"5\n"def"') 51 | -------------------------------------------------------------------------------- /test/test-syncallnewevents.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | {assert} = require('chai') 3 | deql = assert.deepEqual 4 | 5 | {CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE} = require '../src/schema' 6 | 7 | describe 'CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE', -> 8 | 9 | it 'parses', -> 10 | msg = fs.readFileSync './test/syncall.bin' 11 | x = CLIENT_SYNC_ALL_NEW_EVENTS_RESPONSE.parse msg 12 | deql x.response_header, 13 | current_server_time: 1430641400746000 14 | request_trace_id: "-6693534691558475312" 15 | status: 1 16 | deql x.sync_timestamp, 1430641100747000 17 | deql x.conversation_state[0].event[4].chat_message.message_content.segment[0].text, 18 | 'tja bosse' 19 | --------------------------------------------------------------------------------