Browse Source

Initial commit

master
Jason Chu 6 years ago
commit
01147297cd
  1. 21
      LICENSE
  2. 8
      README.md
  3. 211
      connect.go
  4. 37
      consts.go
  5. 54
      dump.go
  6. 11
      errors.go
  7. 307
      listen.go
  8. 132
      login.go
  9. 28
      mark_read.go
  10. 82
      meta.go
  11. 47
      parse.go
  12. 133
      presence.go
  13. 57
      pull_form.go
  14. 146
      send.go
  15. 35
      session.go
  16. 8
      thread.go
  17. 40
      typing.go

21
LICENSE

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Jason Chu (1lann)
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.

8
README.md

@ -0,0 +1,8 @@
# messenger
[![Go Report Card](https://goreportcard.com/badge/github.com/1lann/messenger)](https://goreportcard.com/report/github.com/1lann/messenger)
[![GoDoc](https://godoc.org/github.com/1lann/messenger?status.svg)](https://godoc.org/github.com/1lann/messenger)
A Go (golang) package that allows you to interact with Facebook chat/Messenger using
an unofficial API ported from [github.com/Schmavery/facebook-chat-api](github.com/Schmavery/facebook-chat-api).
## License
messenger is licensed under the MIT license which can be found [here](/LICENSE).

211
connect.go

@ -0,0 +1,211 @@
package messenger
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
)
func generateClientID() string {
data := make([]byte, 4)
_, err := io.ReadFull(rand.Reader, data)
if err != nil {
panic(err)
}
return hex.EncodeToString(data)
}
type accessibilityStruct struct {
Sr int64 `json:"sr"`
SrTs int64 `json:"sr-ts"`
Jk int64 `json:"jk"`
JkTs int64 `json:"jk-ts"`
Kb int64 `json:"kb"`
KbTs int64 `json:"kb-ts"`
Hcm int64 `json:"hcm"`
HcmTs int64 `json:"hcm-ts"`
}
func generateAccessibilityCookie() string {
now := time.Now().UnixNano() / 1000000
access := accessibilityStruct{
Sr: 0,
SrTs: now,
Jk: 0,
JkTs: now,
Kb: 0,
KbTs: now,
Hcm: 0,
HcmTs: now,
}
res, err := json.Marshal(access)
if err != nil {
panic(err)
}
return url.QueryEscape(string(res))
}
// ConnectToChat connects the session to chat after you've successfully
// logged in.
func (s *Session) ConnectToChat() error {
err := s.populateMeta()
if err != nil {
return err
}
s.l.form = s.newPullForm()
err = s.requestReconnect()
if err != nil {
return err
}
err = s.connectToStage1()
if err != nil {
return err
}
err = s.connectToStage2()
if err != nil {
return err
}
err = s.connectToStage3()
if err != nil {
return err
}
return nil
}
func (s *Session) requestReconnect() error {
req, _ := http.NewRequest(http.MethodGet, reconnectURL, nil)
req.Header = defaultHeader()
resp, err := s.client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func (s *Session) connectToStage1() error {
req, err := s.createStage1Request()
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respInfo, err := parseResponse(resp.Body)
if err != nil {
return err
}
if respInfo.Type != "lb" {
return ParseError{"non t: \"lb\" response from chat server"}
}
s.l.form.stickyPool = respInfo.Sticky.Pool
s.l.form.stickyToken = respInfo.Sticky.Token
return nil
}
func (s *Session) createStage1Request() (*http.Request, error) {
cookies := s.client.Jar.Cookies(fbURL)
for _, cookie := range cookies {
if cookie.Name == "c_user" {
s.userID = cookie.Value
break
}
}
if s.userID == "" {
return nil, ParseError{"missing required c_user user ID"}
}
s.clientID = generateClientID()
presence := s.generatePresence()
cookies = append(cookies, []*http.Cookie{
{
Name: "presence",
Value: presence,
Domain: ".facebook.com",
Secure: true,
},
{
Name: "locale",
Value: "en_US",
Domain: ".facebook.com",
Secure: true,
},
{
Name: "a11y",
Value: generateAccessibilityCookie(),
Domain: ".facebook.com",
Secure: true,
},
}...)
s.client.Jar.SetCookies(fbURL, cookies)
form := s.newPullForm()
req, _ := http.NewRequest(http.MethodGet, chatURL+form.encode(), nil)
req.Header = defaultHeader()
return req, nil
}
func (s *Session) connectToStage2() error {
req, _ := http.NewRequest(http.MethodGet, chatURL+s.l.form.encode(), nil)
req.Header = defaultHeader()
resp, err := s.client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func (s *Session) connectToStage3() error {
form := make(url.Values)
form.Set("client", "mercury")
form.Set("folders[0]", "inbox")
form.Set("last_action_timestamp", "0")
form = s.addFormMeta(form)
req, _ := http.NewRequest(http.MethodPost, threadSyncURL,
strings.NewReader(form.Encode()))
req.Header = defaultHeader()
resp, err := s.client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
}

37
consts.go

@ -0,0 +1,37 @@
package messenger
import (
"errors"
"net/http"
"net/url"
)
const (
facebookURL = "https://www.facebook.com/"
facebookOrigin = "https://www.facebook.com"
loginURL = "https://www.facebook.com/login.php?login_attempt=1&lwv=110"
chatURL = "https://0-edge-chat.facebook.com/pull?"
threadSyncURL = "https://www.facebook.com/ajax/mercury/thread_sync.php"
reconnectURL = "https://www.facebook.com/ajax/presence/reconnect.php?reason=6"
readStatusURL = "https://www.facebook.com/ajax/mercury/change_read_status.php"
sendMessageURL = "https://www.facebook.com/ajax/mercury/send_messages.php"
typingURL = "https://www.facebook.com/ajax/messaging/typ.php"
syncURL = "https://www.facebook.com/notifications/sync/"
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36"
formURLEncoded = "application/x-www-form-urlencoded"
loggedOutError = 1357001
)
var (
errNoRedirects = errors.New("no redirects")
fbURL, _ = url.Parse("https://www.facebook.com")
edgeURL, _ = url.Parse("https://0-edge-chat.facebook.com")
)
func defaultHeader() http.Header {
header := make(http.Header)
header.Set("User-Agent", userAgent)
header.Set("Origin", facebookOrigin)
header.Set("Referer", facebookURL)
return header
}

54
dump.go

@ -0,0 +1,54 @@
package messenger
import (
"bytes"
"encoding/gob"
"net/http"
)
type sessionDump struct {
FBCookies []*http.Cookie
EdgeCookies []*http.Cookie
}
// DumpSession dumps the session (i.e. cookies) and returns it as a []byte.
// Note that if you restore the session, you may not need to login, but you
// must reconnect to chat.
func (s *Session) DumpSession() ([]byte, error) {
fbCookies := s.client.Jar.Cookies(fbURL)
edgeCookies := s.client.Jar.Cookies(edgeURL)
buf := new(bytes.Buffer)
enc := gob.NewEncoder(buf)
err := enc.Encode(sessionDump{
FBCookies: fbCookies,
EdgeCookies: edgeCookies,
})
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// RestoreSession restores the session (i.e. cookies) stored as a []byte
// back into the session. Note that you may not need to login again, but you
// must reconnect to chat.
func (s *Session) RestoreSession(data []byte) error {
buf := bytes.NewReader(data)
dec := gob.NewDecoder(buf)
restoredSession := sessionDump{}
err := dec.Decode(&restoredSession)
if err != nil {
return err
}
s.client.Jar.SetCookies(fbURL, restoredSession.FBCookies)
s.client.Jar.SetCookies(edgeURL, restoredSession.EdgeCookies)
return nil
}
func init() {
gob.Register(sessionDump{})
}

11
errors.go

@ -0,0 +1,11 @@
package messenger
// ParseError is returned if an error due to parsing occurs.
type ParseError struct {
message string
}
// Error returns the detailed parse error message.
func (p ParseError) Error() string {
return "messenger: " + p.message
}

307
listen.go

@ -0,0 +1,307 @@
package messenger
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// A subset of possible errors returned by OnError
var (
ErrLoggedOut = errors.New("messenger: (probably) logged out")
ErrUnknown = errors.New("messenger: unknown error from server")
)
type listener struct {
form pullForm
lastMessage time.Time
activeRequest *http.Request
lastSync time.Time
// TODO: Close functions are hackily thread safe.
shouldClose bool
closed chan bool
closeMutex *sync.Mutex
onMessage func(msg *Message)
onRead func(thread Thread, userID string)
onError func(err error)
processedThreadMessages map[string][]string
processedMutex *sync.Mutex
}
// Listen starts listening for events and messages from Facebook's chat
// servers and blocks.
func (s *Session) Listen() {
s.l.closeMutex = new(sync.Mutex)
s.checkListeners()
s.l.lastMessage = time.Now()
s.l.lastSync = time.Now()
go func() {
for !s.l.shouldClose {
s.listenRequest()
}
}()
s.l.closeMutex.Lock()
<-s.l.closed
s.l.shouldClose = true
s.l.closeMutex.Unlock()
}
func (s *Session) checkListeners() {
if s.l.onError == nil {
s.l.onError = func(err error) { fmt.Println(err) }
}
if s.l.onMessage == nil {
s.l.onMessage = func(msg *Message) {}
}
if s.l.onRead == nil {
s.l.onRead = func(thread Thread, userID string) {}
}
}
// OnMessage sets the handler for when a message is received.
func (s *Session) OnMessage(handler func(msg *Message)) {
s.l.onMessage = handler
}
// OnRead sets the handler for when a message is read.
func (s *Session) OnRead(handler func(thread Thread, userID string)) {
s.l.onRead = handler
}
// OnError sets the handler for when an error during listening occurs.
func (s *Session) OnError(handler func(err error)) {
s.l.onError = handler
}
// Close stops and returns all listeners on the session.
func (s *Session) Close() error {
s.l.closed <- true
s.l.closeMutex.Lock()
s.l.closeMutex.Unlock()
return nil
}
type pullMsgMeta struct {
Sender string `json:"actorFbId"`
ThreadKey struct {
ThreadID string `json:"threadFbId"`
OtherUserID string `json:"otherUserFbId"`
} `json:"threadKey"`
MessageID string `json:"messageId"`
Timestamp string `json:"timestamp"`
}
type pullAction struct {
ThreadID string `json:"thread_fbid"`
LogMessageType string `json:"log_message_type"`
LogMessageData string `json:"log_message_data"`
LogMessageBody string `json:"log_message_body"`
Author string `json:"author"`
MessageID string `json:"message_id"`
}
type pullMessage struct {
Type string `json:"type"`
From int64 `json:"from"`
To int64 `json:"to"`
Reader int64 `json:"reader"`
Delta struct {
Class string `json:"class"`
Body string `json:"body"`
Metadata pullMsgMeta `json:"messageMetadata"`
} `json:"delta"`
Event string `json:"event"`
Actions []pullAction `json:"actions"`
St int `json:"st"`
ThreadID int64 `json:"thread_fbid"`
FromMobile bool `json:"from_mobile"`
UserID int64 `json:"realtime_viewer_fbid"`
Reason string `json:"reason"`
}
type pullResponse struct {
Type string `json:"t"`
Sticky struct {
Token string `json:"sticky"`
Pool string `json:"pool"`
} `json:"lb_info"`
Seq int `json:"seq"`
Messages []pullMessage `json:"ms"`
Reason int `json:"reason"`
Error int `json:"error"`
}
func (s *Session) listenRequest() {
idleSeconds := time.Now().Sub(s.l.lastMessage).Seconds()
s.l.form.idleTime = int(idleSeconds)
presence := s.generatePresence()
cookies := s.client.Jar.Cookies(fbURL)
cookies = append(cookies, &http.Cookie{
Name: "presence",
Value: presence,
Domain: ".facebook.com",
})
s.client.Jar.SetCookies(fbURL, cookies)
req, _ := http.NewRequest(http.MethodGet, chatURL+s.l.form.encode(),
nil)
req.Header = defaultHeader()
resp, err := s.client.Do(req)
if err != nil {
go s.l.onError(err)
time.Sleep(time.Second)
return
}
defer resp.Body.Close()
respInfo, err := parseResponse(resp.Body)
if err != nil {
go s.l.onError(err)
time.Sleep(time.Second)
return
}
s.l.lastMessage = time.Now()
s.l.form.messagesReceived += len(respInfo.Messages)
s.l.form.seq = respInfo.Seq
if respInfo.Type == "refresh" && respInfo.Reason == 110 {
s.l.onError(ErrLoggedOut)
if !s.l.shouldClose {
s.l.closed <- true
s.l.closeMutex.Lock()
s.l.closeMutex.Unlock()
}
return
}
if respInfo.Type == "fullReload" {
s.fullReload()
return
}
go s.processPull(respInfo)
time.Sleep(time.Second)
}
func (s *Session) processPull(resp pullResponse) {
if resp.Type == "lb" {
s.l.form.stickyToken = resp.Sticky.Token
s.l.form.stickyPool = resp.Sticky.Pool
}
for _, msg := range resp.Messages {
if msg.Type == "delta" {
if msg.Delta.Class != "NewMessage" {
continue
}
s.handleDeltaMessage(msg.Delta.Body, msg.Delta.Metadata)
} else if msg.Type == "messaging" {
if msg.Event == "read_receipt" {
thread := Thread{
ThreadID: strconv.FormatInt(msg.Reader, 10),
IsGroup: false,
}
if msg.ThreadID != 0 {
thread.ThreadID = strconv.FormatInt(msg.ThreadID, 10)
thread.IsGroup = true
}
go s.l.onRead(thread, strconv.FormatInt(msg.Reader, 10))
}
}
}
}
func (s *Session) handleDeltaMessage(body string, meta pullMsgMeta) {
if meta.Sender == s.userID {
return
}
threadID := meta.ThreadKey.ThreadID
isGroup := true
if threadID == "" {
threadID = meta.Sender
isGroup = false
}
msg := &Message{
FromUserID: meta.Sender,
Thread: Thread{
ThreadID: threadID,
IsGroup: isGroup,
},
Body: body,
MessageID: meta.MessageID,
}
go s.l.onMessage(msg)
}
func (s *Session) fullReload() {
wg := new(sync.WaitGroup)
wg.Add(2)
go func() {
defer wg.Done()
form := make(url.Values)
form.Set("lastSync", strconv.FormatInt(s.l.lastSync.Unix(), 10))
form = s.addFormMeta(form)
req, _ := http.NewRequest(http.MethodPost, syncURL,
strings.NewReader(form.Encode()))
req.Header = defaultHeader()
s.l.lastSync = time.Now()
_, err := s.client.Do(req)
if err != nil {
s.l.onError(err)
return
}
}()
go func() {
defer wg.Done()
form := make(url.Values)
form.Set("client", "mercury")
form.Set("folders[0]", "inbox")
form.Set("last_action_timestamp",
strconv.FormatInt(time.Now().Unix()-60, 10))
form = s.addFormMeta(form)
req, _ := http.NewRequest(http.MethodPost, threadSyncURL,
strings.NewReader(form.Encode()))
_, err := s.client.Do(req)
if err != nil {
s.l.onError(err)
return
}
}()
wg.Wait()
}

132
login.go

@ -0,0 +1,132 @@
package messenger
import (
"bytes"
"errors"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
)
// Errors that are returned by Login.
var (
ErrLoginError = errors.New("messenger: incorrect login credentials")
ErrLoginCheckpoint = errors.New("messenger: login checkpoint")
)
var jsCookiePattern = regexp.MustCompile("\\[\"(_js_[^\"]+)\",\"([^\"]+)\",")
func (s *Session) createLoginRequest(email, password string) (*http.Request, error) {
req, _ := http.NewRequest(http.MethodGet, facebookURL, nil)
req.Header = defaultHeader()
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
form := make(url.Values)
doc.Find("#login_form input").Each(func(i int, s *goquery.Selection) {
name, found := s.Attr("name")
if !found {
return
}
value, found := s.Attr("value")
if !found {
return
}
form.Set(name, value)
})
cookies := s.client.Jar.Cookies(fbURL)
matches := jsCookiePattern.FindAllStringSubmatch(string(data), -1)
for _, match := range matches {
cookies = append(cookies, &http.Cookie{
Name: match[1],
Value: strings.Replace(match[2], "\\/", "/", -1),
Domain: "facebook.com",
})
}
s.client.Jar.SetCookies(fbURL, cookies)
form.Set("email", email)
form.Set("pass", password)
form.Set("default_persistent", "1")
form.Set("lgnjs", strconv.FormatInt(time.Now().Unix(), 10))
_, offset := time.Now().Zone()
form.Set("timezone", strconv.Itoa(-offset/60))
form.Set("lgndim", "eyJ3IjoxNDQwLCJoIjo5MDAsImF3IjoxNDQwLCJhaCI6OTAwLCJjIjoyNH0=")
form.Set("next", "https://www.facebook.com/")
loginReq, _ := http.NewRequest(http.MethodPost, loginURL, strings.NewReader(form.Encode()))
loginReq.Header = defaultHeader()
loginReq.Header.Set("Content-Type", formURLEncoded)
return loginReq, nil
}
// Login logs the session in to a Facebook account.
func (s *Session) Login(email, password string) error {
req, err := s.createLoginRequest(email, password)
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err == nil {
return ErrLoginError
}
urlErr, ok := err.(*url.Error)
if !ok || urlErr.Err != errNoRedirects {
return err
}
err = handleLoginRedirect(resp)
if err != nil {
return err
}
return nil
}
func handleLoginRedirect(resp *http.Response) error {
redirURL, err := resp.Location()
if err != nil {
return err
}
if strings.Contains(redirURL.String(), "https://www.facebook.com/checkpoint") {
return ErrLoginCheckpoint
}
if strings.Contains(redirURL.String(), "https://www.facebook.com/login.php?") {
return ErrLoginError
}
if redirURL.String() == "https://www.facebook.com/" || redirURL.String() == "https://www.facebook.com" {
return nil
}
return ParseError{"unexpected redirect to " + redirURL.String()}
}

28
mark_read.go

@ -0,0 +1,28 @@
package messenger
import (
"net/http"
"net/url"
"strings"
)
// MarkAsRead marks the specified thread as read.
func (s *Session) MarkAsRead(thread Thread) error {
form := make(url.Values)
form.Set("ids["+thread.ThreadID+"]", "true")
form = s.addFormMeta(form)
req, _ := http.NewRequest(http.MethodPost, readStatusURL,
strings.NewReader(form.Encode()))
req.Header = defaultHeader()
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, err = parseResponse(resp.Body)
return err
}

82
meta.go

@ -0,0 +1,82 @@
package messenger
import (
"bytes"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
)
type meta struct {
req int64
revision string
dtsg string
ttstamp string
}
func (s *Session) populateMeta() error {
req, _ := http.NewRequest(http.MethodGet, facebookURL, nil)
req.Header = defaultHeader()
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
s.meta.dtsg, err = searchBetween(data, "name=\"fb_dtsg\" value=\"", '"')
if err != nil {
return err
}
s.meta.revision, err = searchBetween(data, "revision\":", ',')
if err != nil {
return err
}
s.meta.ttstamp = ""
byteDtsg := []byte(s.meta.dtsg)
for _, b := range byteDtsg {
s.meta.ttstamp = s.meta.ttstamp + strconv.Itoa(int(b))
}
s.meta.ttstamp = s.meta.ttstamp + "2"
return nil
}
func searchBetween(data []byte, head string, tail byte) (string, error) {
i := bytes.Index(data, []byte(head))
if i < 0 {
return "", ParseError{"head could not be found"}
}
pos := i + len(head)
var result []byte
for {
if data[pos] == tail {
return strings.TrimSpace(string(result)), nil
}
result = append(result, data[pos])
pos++
}
}
func (s *Session) addFormMeta(form url.Values) url.Values {
form.Set("__user", s.userID)
form.Set("__req", strconv.FormatInt(s.meta.req, 36))
s.meta.req++
form.Set("__rev", s.meta.revision)
form.Set("__a", "1")
form.Set("fb_dtsg", s.meta.dtsg)
form.Set("ttstamp", s.meta.ttstamp)
return form
}

47
parse.go

@ -0,0 +1,47 @@
package messenger
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
)
func parseResponse(rd io.Reader) (pullResponse, error) {
var result pullResponse
err := unmarshalPullData(rd, &result)
if err != nil {
return pullResponse{}, err
}
if result.Error == loggedOutError {
return pullResponse{}, ErrLoggedOut
}
if result.Error > 0 {
return pullResponse{}, ErrUnknown
}
return result, nil
}
func unmarshalPullData(rd io.Reader, to interface{}) error {
data, err := ioutil.ReadAll(rd)
if err != nil {
return err
}
startPos := bytes.IndexByte(data, '{')
if startPos < 0 {
return ParseError{"could not find start of response"}
}
err = json.Unmarshal(data[startPos:], to)
if err != nil {
fmt.Println(string(data))
return err
}
return nil
}

133
presence.go

@ -0,0 +1,133 @@
package messenger
import (
"crypto/rand"
"encoding/json"
"math/big"
"net/url"
"strings"
"time"
)
type presenceStruct struct {
V int `json:"v"`
Time int64 `json:"time"`
User string `json:"user"`
State presenceState `json:"state"`
Ch map[string]int `json:"ch"`
}
type presenceState struct {
Ut int `json:"ut"`
T2 []int `json:"t2"`
Lm2 interface{} `json:"lm2"`
Uct2 int64 `json:"uct2"`
Tr interface{} `json:"tr"`
Tw int64 `json:"tw"`
At int64 `json:"at"`
}
var presenceDecodeMap = map[string]string{
"_": "%",
"A": "%2",
"B": "000",
"C": "%7d",
"D": "%7b%22",
"E": "%2c%22",
"F": "%22%3a",
"G": "%2c%22ut%22%3a1",
"H": "%2c%22bls%22%3a",
"I": "%2c%22n%22%3a%22%",
"J": "%22%3a%7b%22i%22%3a0%7d",
"K": "%2c%22pt%22%3a0%2c%22vis%22%3a",
"L": "%2c%22ch%22%3a%7b%22h%22%3a%22",
"M": "%7b%22v%22%3a2%2c%22time%22%3a1",
"N": ".channel%22%2c%22sub%22%3a%5b",
"O": "%2c%22sb%22%3a1%2c%22t%22%3a%5b",
"P": "%2c%22ud%22%3a100%2c%22lc%22%3a0",
"Q": "%5d%2c%22f%22%3anull%2c%22uct%22%3a",
"R": ".channel%22%2c%22sub%22%3a%5b1%5d",
"S": "%22%2c%22m%22%3a0%7d%2c%7b%22i%22%3a",
"T": "%2c%22blc%22%3a1%2c%22snd%22%3a1%2c%22ct%22%3a",
"U": "%2c%22blc%22%3a0%2c%22snd%22%3a1%2c%22ct%22%3a",
"V": "%2c%22blc%22%3a0%2c%22snd%22%3a0%2c%22ct%22%3a",
"W": "%2c%22s%22%3a0%2c%22blo%22%3a0%7d%2c%22bl%22%3a%7b%22ac%22%3a",
"X": "%2c%22ri%22%3a0%7d%2c%22state%22%3a%7b%22p%22%3a0%2c%22ut%22%3a1",
"Y": "%2c%22pt%22%3a0%2c%22vis%22%3a1%2c%22bls%22%3a0%2c%22blc%22%3a0%2c%22snd%22%3a1%2c%22ct%22%3a",
"Z": "%2c%22sb%22%3a1%2c%22t%22%3a%5b%5d%2c%22f%22%3anull%2c%22uct%22%3a0%2c%22s%22%3a0%2c%22blo%22%3a0%7d%2c%22bl%22%3a%7b%22ac%22%3a",
}
var presenceEncode [][2]string
func init() {
for letter := 'Z'; letter >= 'A'; letter-- {
letterStr := string(letter)
str := presenceDecodeMap[letterStr]
presenceEncode = append(presenceEncode, [2]string{letterStr, str})
}
presenceEncode = append(presenceEncode, [2]string{"_", "%"})
}
func (s *Session) generatePresence() string {
now := time.Now()
presence := presenceStruct{
V: 3,
Time: now.Unix(),
User: s.userID,
State: presenceState{
Ut: 0,
T2: []int{},
Lm2: nil,
Uct2: now.UnixNano() / 1000000,
Tr: nil,
Tw: largeRandomNumber(),
At: now.UnixNano() / 1000000,
},
Ch: map[string]int{
"p_" + s.userID: 0,
},
}
result, err := json.Marshal(presence)
if err != nil {
panic(err)
}
return "E" + encodePresence(string(result))
}
func encodePresence(str string) string {
esc := strings.Replace(strings.ToLower(url.QueryEscape(str)), "_", "%5f", -1)
for _, line := range presenceEncode {
esc = strings.Replace(esc, line[1], line[0], -1)
}
return esc
}
func decodePresence(str string) (string, error) {
output := ""
for i := 0; i < len(str); i++ {
resolve, found := presenceDecodeMap[string(str[i])]
if !found {
output += string(str[i])
continue
}
output += resolve
}
return url.QueryUnescape(output)
}
func largeRandomNumber() int64 {
max := big.NewInt(4294967296)
n, err := rand.Int(rand.Reader, max)
if err != nil {
panic(err)
}
return n.Int64()
}

57
pull_form.go

@ -0,0 +1,57 @@
package messenger
import (
"net/url"
"strconv"
)
type pullForm struct {
userID string
clientID string
stickyToken string
stickyPool string
seq int
partition int
state string
cap int
messagesReceived int
idleTime int
}
func (s *Session) newPullForm() pullForm {
return pullForm{
userID: s.userID,
clientID: s.clientID,
stickyToken: "",
stickyPool: "",
seq: 0,
partition: -2,
state: "active",
cap: 8,
messagesReceived: 0,
idleTime: 0,
}
}
func (p pullForm) encode() string {
form := url.Values{
"channel": []string{"p_" + p.userID},
"seq": []string{strconv.Itoa(p.seq)},
"partition": []string{strconv.Itoa(p.partition)},
"clientid": []string{p.clientID},
"viewer_uid": []string{p.userID},
"uid": []string{p.userID},
"state": []string{p.state},
"idle": []string{strconv.Itoa(p.idleTime)},
"cap": []string{strconv.Itoa(p.cap)},
"msgs_recv": []string{strconv.Itoa(p.messagesReceived)},
}
if p.stickyPool != "" && p.stickyToken != "" {
form.Set("sticky_token", p.stickyToken)
form.Set("sticky_pool", p.stickyPool)
}
return form.Encode()
}

146
send.go

@ -0,0 +1,146 @@
package messenger
import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Attachment represents an attachment
type Attachment struct {
Name string
Data io.Reader
}
// Message represents a message object.
type Message struct {
FromUserID string
Thread Thread
Body string
Attachments []Attachment
MessageID string
offlineThreadID string
}
// NewMessageWithThread creates a new message for the given thread.
func (s *Session) NewMessageWithThread(thread Thread) *Message {
return &Message{
Thread: thread,
offlineThreadID: generateOfflineThreadID(),
}
}
type sendResponse struct {
Payload pullMessage `json:"payload"`
}
// SendMessage sends the message to the session. Only the Thread, Body and
// Attachments fields are used for sending.
func (s *Session) SendMessage(msg *Message) (string, error) {
hasAttachment := "false"
if len(msg.Attachments) > 0 {
hasAttachment = "true"
}
form := url.Values{
"client": []string{"mercury"},
"message_batch[0][action_type]": []string{"ma-type:user-generated-message"},
"message_batch[0][author]": []string{"fbid:" + s.userID},
"message_batch[0][timestamp]": []string{strconv.FormatInt(time.Now().UnixNano()/1000000, 10)},
"message_batch[0][timestamp_absolute]": []string{"Today"},
"message_batch[0][timestamp_relative]": []string{time.Now().Format("15:04")},
"message_batch[0][timestamp_time_passed]": []string{"0"},
"message_batch[0][is_unread]": []string{"false"},
"message_batch[0][is_cleared]": []string{"false"},
"message_batch[0][is_forward]": []string{"false"},
"message_batch[0][is_filtered_content]": []string{"false"},
"message_batch[0][is_filtered_content_bh]": []string{"false"},
"message_batch[0][is_filtered_content_account]": []string{"false"},
"message_batch[0][is_filtered_content_quasar]": []string{"false"},
"message_batch[0][is_filtered_content_invalid_app]": []string{"false"},
"message_batch[0][is_spoof_warning]": []string{"false"},
"message_batch[0][source]": []string{"source:chat:web"},
"message_batch[0][source_tags][0]": []string{"source:chat"},
"message_batch[0][body]": []string{msg.Body},
"message_batch[0][html_body]": []string{"false"},
"message_batch[0][ui_push_phase]": []string{"V3"},
"message_batch[0][status]": []string{"0"},
"message_batch[0][offline_threading_id]": []string{msg.offlineThreadID},
"message_batch[0][message_id]": []string{msg.offlineThreadID},
"message_batch[0][threading_id]": []string{s.generateThreadID()},
"message_batch[0][ephemeral_ttl_mode]:": []string{"0"},
"message_batch[0][manual_retry_cnt]": []string{"0"},
"message_batch[0][has_attachment]": []string{hasAttachment},
"message_batch[0][signatureID]": []string{generateSignatureID()},
}
if msg.Thread.IsGroup {
form.Set("message_batch[0][thread_fbid]", msg.Thread.ThreadID)
} else {
form.Set("message_batch[0][specific_to_list][0]", "fbid:"+
msg.Thread.ThreadID)
form.Set("message_batch[0][specific_to_list][1]", "fbid:"+s.userID)
form.Set("message_batch[0][other_user_fbid]", msg.Thread.ThreadID)
}
form = s.addFormMeta(form)
req, _ := http.NewRequest(http.MethodPost, sendMessageURL,
strings.NewReader(form.Encode()))
req.Header = defaultHeader()
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var respMsg sendResponse
err = unmarshalPullData(resp.Body, &respMsg)
if err != nil {
return "", err
}
if len(respMsg.Payload.Actions) == 0 {
return "", ParseError{"expected more than 0 actions after sending"}
}
messageID := respMsg.Payload.Actions[0].MessageID
if messageID == "" {
return "", ParseError{"missing expected message ID"}
}
return messageID, nil
}
func generateOfflineThreadID() string {
random := strconv.FormatInt(largeRandomNumber(), 2)
if len(random) < 22 {
random = strings.Repeat("0", 22-len(random)) + random
} else {
random = random[:22]
}
now := strconv.FormatInt(time.Now().UnixNano()/1000000, 2)
n, err := strconv.ParseInt(now+random, 2, 64)
if err != nil {
// If this happens, it's the end of the world.
panic(err)
}
return strconv.FormatInt(n, 10)
}
func (s *Session) generateThreadID() string {
now := strconv.FormatInt(time.Now().UnixNano()/1000000, 10)
r := strconv.FormatInt(largeRandomNumber(), 10)
return "<" + now + ":" + r + "-" + s.clientID + "@mail.projektitan.com>"
}
func generateSignatureID() string {
return strconv.FormatInt(largeRandomNumber()/2-1, 16)
}

35
session.go

@ -0,0 +1,35 @@
package messenger
import (
"net/http"
"net/http/cookiejar"
"time"
)
// Session represents a Facebook session.
type Session struct {
client *http.Client
userID string
clientID string
l listener
meta meta
}
// NewSession creates a new Facebook session.
func NewSession() *Session {
jar, _ := cookiejar.New(nil)
return &Session{
client: &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return errNoRedirects
},
Jar: jar,
Timeout: time.Second * 70,
},
meta: meta{
req: 1,
},
}
}

8
thread.go

@ -0,0 +1,8 @@
package messenger
type Thread struct {
ThreadID string
IsGroup bool
}
// TODO: Implement helper functions

40
typing.go

@ -0,0 +1,40 @@
package messenger
import (
"net/http"
"net/url"
"strings"
)
// SetTypingIndicator sets the typing indicator seen by members of the
// thread.
func (s *Session) SetTypingIndicator(thread Thread, typing bool) error {
form := make(url.Values)
form.Set("source", "mercury-chat")
form.Set("thread", thread.ThreadID)
if typing {
form.Set("typ", "1")
} else {
form.Set("typ", "0")
}
form.Set("to", "")
if !thread.IsGroup {
form.Set("to", thread.ThreadID)
}
form = s.addFormMeta(form)
req, _ := http.NewRequest(http.MethodPost, typingURL,
strings.NewReader(form.Encode()))
req.Header = defaultHeader()
_, err := s.client.Do(req)
if err != nil {
return err
}
return nil
}
Loading…
Cancel
Save