commit
01147297cd
17 changed files with 1357 additions and 0 deletions
@ -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. |
@ -0,0 +1,8 @@
|
||||
# messenger |
||||
[](https://goreportcard.com/report/github.com/1lann/messenger) |
||||
[](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). |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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{}) |
||||
} |
@ -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 |
||||
} |
@ -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() |
||||
} |
@ -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()} |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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() |
||||
} |
@ -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() |
||||
} |
@ -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) |
||||
} |
@ -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, |
||||
}, |
||||
} |
||||
} |
@ -0,0 +1,8 @@
|
||||
package messenger |
||||
|
||||
type Thread struct { |
||||
ThreadID string |
||||
IsGroup bool |
||||
} |
||||
|
||||
// TODO: Implement helper functions
|
@ -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…
Reference in new issue