diff --git a/Dockerfile b/Dockerfile index 44df3320..a70ec4a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev COPY . /build WORKDIR /build -RUN ./build-fb.sh +RUN sed -i 's/\r$//' build-fb.sh docker-run.sh && ./build-fb.sh FROM alpine:3.24 diff --git a/cmd/bsprobe/.gitignore b/cmd/bsprobe/.gitignore new file mode 100644 index 00000000..98967be6 --- /dev/null +++ b/cmd/bsprobe/.gitignore @@ -0,0 +1,12 @@ +# Live session material. Never commit. +.bsprobe/ +*.curl +session.txt + +# Raw captures and dumps contain unredacted tokens and customer data. +# Only fixtures/operations.json, fixtures/roles.json and fixtures/shapes/ +# are safe to commit, and only after review. +*.har +*.html +fixtures/raw/ +fixtures/frames.jsonl diff --git a/cmd/bsprobe/README.md b/cmd/bsprobe/README.md new file mode 100644 index 00000000..6c19f6dd --- /dev/null +++ b/cmd/bsprobe/README.md @@ -0,0 +1,115 @@ +# bsprobe + +Protocol discovery harness for the Meta Business Suite inbox. + +It exists to answer one question before any connector code is written: + +> Does `business.facebook.com` speak the same Lightspeed protocol that +> `pkg/messagix` already implements, or something else? + +**Case A** (Lightspeed over MQTT) means the new transport reuses +`pkg/messagix/{socket,lightspeed,table,syncManager.go}` and the work is mostly +configuration. **Case B** means a separate decoder under `pkg/bizsuite/realtime/` +and a much larger job. Committing to the wrong one costs weeks, which is why +this tool runs first. + +This is a research instrument. Nothing here is imported by `pkg/connector`, and +it must stay that way. + +## Why nothing is hard-coded + +Meta rotates persisted GraphQL `doc_id` values. A table of them compiled into Go +works for days and then collapses silently. So: + +- `bsprobe har` generates `fixtures/operations.json` from **your** capture. +- `fixtures/roles.json` maps a semantic role (`list_threads`) onto whichever + `friendly_name` that capture revealed. +- When calls start failing, you re-record and re-run `har`. You do not edit Go. + +## Capture procedure + +Use a dedicated test Page and your own Facebook account. Open DevTools **before** +loading the inbox, enable *Preserve log*, then perform only these actions: + +1. Open Business Suite Inbox +2. Select the Page +3. Load the conversation list +4. Open one conversation +5. Receive a test message +6. Send a text reply +7. Mark unread, then read +8. Load older messages + +Right-click the Network panel → *Save all as HAR with content*. + +Chrome only records WebSocket frames for connections opened while DevTools is +already recording. If `bsprobe har` reports no realtime traffic, that is almost +always why. + +## Run order + +```sh +go build ./cmd/bsprobe + +# 1. Confirm the pasted session authenticates. +# Save a DevTools "Copy as cURL" blob to .bsprobe/session.curl first. +./bsprobe validate-session + +# 2. Offline analysis. Produces fixtures/operations.json + fixtures/shapes/, +# and prints the Case A / Case B verdict. +./bsprobe har --out fixtures capture.har + +# 3. Map the roles the connector needs, using the friendly names from step 2. +$EDITOR fixtures/roles.json + +# 4. Extract runtime tokens and the business/asset/mailbox identifiers. +./bsprobe bootstrap --save fixtures/bootstrap.json + +# 5. Confirm the realtime verdict live. The --url comes from step 2's report; +# there is deliberately no default. +./bsprobe watch-events --url wss://... --dump-frames fixtures/frames.jsonl + +# 6. Exercise the captured operations. +./bsprobe list-threads --mailbox +./bsprobe call --op --vars '{"thread_id":"..."}' +``` + +## Redaction + +Everything written to disk passes through `redact.go` at capture time — a +fixture that was never written unredacted cannot leak. + +- **Secrets** (`fb_dtsg`, `lsd`, `jazoest`, cookies, tokens) → removed entirely. +- **Content** (message text, customer names, emails, phones) → replaced with a + length hint. +- **Protocol facts** (`friendly_name`, `doc_id`, variable *keys*, the routing + identifiers) → preserved, because they are the entire point. +- URL query strings are stripped; host and path survive. + +`--shape` (the default on live commands) goes further and emits keys and types +with every value dropped. Prefer shapes for anything you intend to commit. + +`.gitignore` in this directory excludes sessions, HARs and raw HTML. Review +`fixtures/` before committing anyway. + +## Tests + +```sh +go test ./cmd/bsprobe +``` + +The redaction tests are the important ones: they assert that a live token never +survives serialisation, and that `friendly_name` / `doc_id` are *not* destroyed +by over-eager redaction. The HAR tests pin the Case A / Case B / inconclusive +verdict logic against synthetic captures. + +## What this does not do + +It does not send production traffic, automate a browser, or touch the bridge. +It reads a session you paste and a capture you record. + +Note the standing question this work sits on top of: for Page inboxes Meta also +offers the official Messenger Platform API (Conversations API + webhooks + Send +API), which is supported and versioned. It carries a 24-hour outbound messaging +window and needs app review to serve Pages you do not own. If those limits are +acceptable, that route needs none of this. diff --git a/cmd/bsprobe/bootstrap.go b/cmd/bsprobe/bootstrap.go new file mode 100644 index 00000000..bf1cee38 --- /dev/null +++ b/cmd/bsprobe/bootstrap.go @@ -0,0 +1,233 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// The Business Suite inbox is a Comet app: its runtime configuration is +// embedded in the initial HTML as a series of `require`/`define` blocks. This +// file extracts the values we know how to name, and — just as importantly — +// lists the blocks we do not, so field names get read off a real response +// instead of guessed into production code. + +type Bootstrap struct { + CurrentUserID string `json:"current_user_id"` + BusinessID string `json:"business_id,omitempty"` + AssetID string `json:"asset_id,omitempty"` + MailboxID string `json:"mailbox_id,omitempty"` + PageID string `json:"page_id,omitempty"` + + FBDTSG string `json:"fb_dtsg"` + LSD string `json:"lsd"` + Jazoest string `json:"jazoest"` + AppID string `json:"app_id"` + Rev string `json:"revision"` + Haste string `json:"haste_session,omitempty"` + + // Blocks records every `["Name",[],{...}]` key present in the document. + // Anything interesting that is not yet parsed above shows up here. + Blocks []string `json:"define_blocks"` +} + +var ( + dtsgRe = regexp.MustCompile(`\["DTSGInitialData",\s*\[\],\s*\{"token":"([^"]+)"`) + dtsgAltRe = regexp.MustCompile(`"dtsg":\s*\{"token":"([^"]+)"`) + lsdRe = regexp.MustCompile(`\["LSD",\s*\[\],\s*\{"token":"([^"]+)"`) + revRe = regexp.MustCompile(`"(?:client_revision|__spin_r|rev)":\s*(\d{5,})`) + appIDRe = regexp.MustCompile(`"(?:appID|app_id|APP_ID)":\s*"?(\d{6,})"?`) + userIDRe = regexp.MustCompile(`"(?:USER_ID|ACCOUNT_ID)":\s*"(\d+)"`) + hasteRe = regexp.MustCompile(`"haste_session":\s*"([^"]+)"`) + blockRe = regexp.MustCompile(`\["([A-Za-z0-9_]{3,60})",\s*\[\],\s*\{`) + genericIDs = map[string]*regexp.Regexp{ + "business_id": regexp.MustCompile(`"business(?:_id|ID)":\s*"?(\d{6,})"?`), + "asset_id": regexp.MustCompile(`"asset(?:_id|ID)":\s*"?(\d{6,})"?`), + "mailbox_id": regexp.MustCompile(`"mailbox(?:_id|ID)":\s*"?(\d{6,})"?`), + "page_id": regexp.MustCompile(`"page(?:_id|ID)":\s*"?(\d{6,})"?`), + } +) + +func ParseBootstrap(html string) *Bootstrap { + b := &Bootstrap{} + + if m := dtsgRe.FindStringSubmatch(html); m != nil { + b.FBDTSG = m[1] + } else if m := dtsgAltRe.FindStringSubmatch(html); m != nil { + b.FBDTSG = m[1] + } + if m := lsdRe.FindStringSubmatch(html); m != nil { + b.LSD = m[1] + } + if m := revRe.FindStringSubmatch(html); m != nil { + b.Rev = m[1] + } + if m := appIDRe.FindStringSubmatch(html); m != nil { + b.AppID = m[1] + } + if m := userIDRe.FindStringSubmatch(html); m != nil { + b.CurrentUserID = m[1] + } + if m := hasteRe.FindStringSubmatch(html); m != nil { + b.Haste = m[1] + } + if b.FBDTSG != "" { + b.Jazoest = ComputeJazoest(b.FBDTSG) + } + + for key, re := range genericIDs { + m := re.FindStringSubmatch(html) + if m == nil { + continue + } + switch key { + case "business_id": + b.BusinessID = m[1] + case "asset_id": + b.AssetID = m[1] + case "mailbox_id": + b.MailboxID = m[1] + case "page_id": + b.PageID = m[1] + } + } + + seen := map[string]bool{} + for _, m := range blockRe.FindAllStringSubmatch(html, -1) { + if !seen[m[1]] { + seen[m[1]] = true + b.Blocks = append(b.Blocks, m[1]) + } + } + sort.Strings(b.Blocks) + return b +} + +// ComputeJazoest mirrors the client-side derivation: the literal "2" followed +// by the sum of the UTF-8 byte values of fb_dtsg. +func ComputeJazoest(dtsg string) string { + sum := 0 + for _, c := range []byte(dtsg) { + sum += int(c) + } + return "2" + strconv.Itoa(sum) +} + +// Redacted returns a copy safe to write to fixtures: tokens replaced, IDs kept. +func (b *Bootstrap) Redacted() *Bootstrap { + c := *b + if c.FBDTSG != "" { + c.FBDTSG = "" + } + if c.LSD != "" { + c.LSD = "" + } + if c.Jazoest != "" { + c.Jazoest = "" + } + return &c +} + +func cmdBootstrap(args []string) error { + fs := flag.NewFlagSet("bootstrap", flag.ExitOnError) + sessionPath := fs.String("session", defaultSessionFile, "session file") + asset := fs.String("asset", "", "asset/page id to select (appended as a query param)") + save := fs.String("save", "", "write the redacted bootstrap to this file") + dumpHTML := fs.String("dump-html", "", "write the raw inbox HTML here (NOT redacted - do not commit)") + if err := fs.Parse(args); err != nil { + return err + } + + s, err := LoadSession(*sessionPath) + if err != nil { + return err + } + + target := businessHost + "/latest/inbox/all" + if *asset != "" { + target += "?asset_id=" + *asset + } + resp, body, err := s.Get(target) + if err != nil { + return err + } + fmt.Printf("GET %s -> %d (%d bytes)\n\n", target, resp.StatusCode, len(body)) + + if *dumpHTML != "" { + if err := os.MkdirAll(filepath.Dir(*dumpHTML), 0o755); err != nil { + return err + } + if err := os.WriteFile(*dumpHTML, []byte(body), 0o600); err != nil { + return err + } + fmt.Printf("raw html written to %s (contains live tokens - do not commit)\n\n", *dumpHTML) + } + + b := ParseBootstrap(body) + + fmt.Println("extracted:") + report := [][2]string{ + {"current_user_id", b.CurrentUserID}, + {"business_id", b.BusinessID}, + {"asset_id", b.AssetID}, + {"mailbox_id", b.MailboxID}, + {"page_id", b.PageID}, + {"app_id", b.AppID}, + {"revision", b.Rev}, + {"haste_session", b.Haste}, + } + for _, kv := range report { + v := kv[1] + if v == "" { + v = "-" + } + fmt.Printf(" %-16s %s\n", kv[0], v) + } + for _, kv := range [][2]string{{"fb_dtsg", b.FBDTSG}, {"lsd", b.LSD}, {"jazoest", b.Jazoest}} { + status := "MISSING" + if kv[1] != "" { + status = fmt.Sprintf("present (len %d)", len(kv[1])) + } + fmt.Printf(" %-16s %s\n", kv[0], status) + } + + fmt.Printf("\ndefine blocks in document: %d\n", len(b.Blocks)) + for _, blk := range b.Blocks { + if strings.Contains(strings.ToLower(blk), "inbox") || + strings.Contains(strings.ToLower(blk), "business") || + strings.Contains(strings.ToLower(blk), "mailbox") || + strings.Contains(strings.ToLower(blk), "messenger") || + strings.Contains(strings.ToLower(blk), "lightspeed") || + strings.Contains(strings.ToLower(blk), "realtime") || + strings.Contains(strings.ToLower(blk), "mqtt") { + fmt.Printf(" * %s\n", blk) + } + } + fmt.Println(" (starred blocks look inbox/transport related - inspect these first)") + + if b.FBDTSG == "" { + fmt.Println("\nfb_dtsg not found. Either the session is not authenticated, or the token") + fmt.Println("moved to a block name this parser does not know yet. Check define blocks above.") + } + + if *save != "" { + if err := os.MkdirAll(filepath.Dir(*save), 0o755); err != nil { + return err + } + out, err := json.MarshalIndent(b.Redacted(), "", " ") + if err != nil { + return err + } + if err := os.WriteFile(*save, out, 0o644); err != nil { + return err + } + fmt.Printf("\nredacted bootstrap written to %s\n", *save) + } + return nil +} diff --git a/cmd/bsprobe/bsprobe_test.go b/cmd/bsprobe/bsprobe_test.go new file mode 100644 index 00000000..5acfdbaf --- /dev/null +++ b/cmd/bsprobe/bsprobe_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Redaction is the security path here: bsprobe records real customer +// conversations, so a regression that lets a token or a message body reach a +// fixture is the worst failure this tool can have. +func TestRedactRemovesSecretsAndContent(t *testing.T) { + input := map[string]any{ + "fb_dtsg": "AQHxxxxLIVE_TOKEN", + "lsd": "AVqLIVE", + "fb_api_req_friendly_name": "BizInboxThreadListQuery", + "doc_id": "987654321", + "mailbox_id": "100200300", + "sender": map[string]any{ + "name": "Jane Customer", + "email": "jane@example.com", + }, + "message": map[string]any{ + "text": "hi, is this still available?", + }, + "free_text": "contact me at jane@example.com or +1 415 555 0134", + } + + out, ok := Redact(input).(map[string]any) + if !ok { + t.Fatalf("Redact did not return a map, got %T", Redact(input)) + } + + if out["fb_dtsg"] != "" { + t.Errorf("fb_dtsg leaked: %v", out["fb_dtsg"]) + } + if out["lsd"] != "" { + t.Errorf("lsd leaked: %v", out["lsd"]) + } + + // Protocol facts must survive, otherwise the capture is useless. + if out["fb_api_req_friendly_name"] != "BizInboxThreadListQuery" { + t.Errorf("friendly_name was destroyed: %v", out["fb_api_req_friendly_name"]) + } + if out["doc_id"] != "987654321" { + t.Errorf("doc_id was destroyed: %v", out["doc_id"]) + } + if out["mailbox_id"] != "100200300" { + t.Errorf("mailbox_id was destroyed: %v", out["mailbox_id"]) + } + + sender := out["sender"].(map[string]any) + if s, _ := sender["name"].(string); !strings.HasPrefix(s, " 97+98+99 = 294, prefixed with "2". + if got := ComputeJazoest("abc"); got != "2294" { + t.Errorf("ComputeJazoest(abc) = %q, want 2294", got) + } +} + +func TestLoadSessionParsesCurlBlob(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.curl") + blob := `curl 'https://business.facebook.com/latest/inbox/all' ` + + `-H 'user-agent: TestAgent/1.0' ` + + `-H 'cookie: datr=AAA; c_user=61550000000; xs=42%3Aabc; sb=BBB'` + if err := os.WriteFile(path, []byte(blob), 0o600); err != nil { + t.Fatal(err) + } + + s, err := LoadSession(path) + if err != nil { + t.Fatal(err) + } + if len(s.Cookies) != 4 { + t.Errorf("got %d cookies, want 4", len(s.Cookies)) + } + if s.UserID() != "61550000000" { + t.Errorf("UserID = %q, want 61550000000", s.UserID()) + } + if !s.Has("xs") || !s.Has("datr") { + t.Error("required cookies not parsed") + } + if s.UserAgent != "TestAgent/1.0" { + t.Errorf("UserAgent = %q", s.UserAgent) + } +} + +func TestLoadSessionParsesRawCookieString(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.txt") + if err := os.WriteFile(path, []byte("c_user=123; xs=abc"), 0o600); err != nil { + t.Fatal(err) + } + s, err := LoadSession(path) + if err != nil { + t.Fatal(err) + } + if s.UserID() != "123" { + t.Errorf("UserID = %q, want 123", s.UserID()) + } +} + +const lightspeedHAR = `{"log":{"entries":[ +{"request":{"method":"POST","url":"https://business.facebook.com/api/graphql/","headers":[], + "postData":{"mimeType":"application/x-www-form-urlencoded", + "text":"fb_dtsg=LIVE&doc_id=987654321&fb_api_req_friendly_name=BizInboxThreadListQuery&variables=%7B%22mailbox_id%22%3A%22100200300%22%2C%22count%22%3A20%7D"}}, + "response":{"status":200,"content":{"mimeType":"application/json","text":"{\"data\":{\"threads\":[]}}"}}}, +{"request":{"method":"GET","url":"wss://edge-chat.facebook.com/chat","headers":[],"postData":{"mimeType":"","text":""}}, + "response":{"status":101,"content":{"mimeType":"","text":""}}, + "_webSocketMessages":[ + {"type":"receive","time":1,"opcode":1,"data":"{\"request_id\":7,\"payload\":\"x\",\"sp\":[],\"target\":1}"} + ]} +]}}` + +func TestAnalyzeHARExtractsOperationsAndCallsCaseA(t *testing.T) { + var h harFile + if err := json.Unmarshal([]byte(lightspeedHAR), &h); err != nil { + t.Fatal(err) + } + rep := analyzeHAR(&h) + + op, ok := rep.Operations["BizInboxThreadListQuery"] + if !ok { + t.Fatalf("operation not extracted, got %v", rep.Operations) + } + if op.DocID != "987654321" { + t.Errorf("doc_id = %q, want 987654321", op.DocID) + } + if strings.Join(op.VariableKeys, ",") != "count,mailbox_id" { + t.Errorf("variable keys = %v, want [count mailbox_id]", op.VariableKeys) + } + if !strings.HasPrefix(rep.Verdict, "CASE A") { + t.Errorf("verdict = %q, want CASE A", rep.Verdict) + } + if _, ok := rep.Identifiers["mailbox_id"]["100200300"]; !ok { + t.Errorf("mailbox_id not harvested, got %v", rep.Identifiers) + } +} + +const plainTextHAR = `{"log":{"entries":[ +{"request":{"method":"GET","url":"wss://business.facebook.com/rt","headers":[],"postData":{"mimeType":"","text":""}}, + "response":{"status":101,"content":{"mimeType":"","text":""}}, + "_webSocketMessages":[ + {"type":"receive","time":1,"opcode":1,"data":"{\"topic\":\"x\",\"subscription_id\":\"y\"}"} + ]} +]}}` + +func TestAnalyzeHARCallsCaseBOnPlainFrames(t *testing.T) { + var h harFile + if err := json.Unmarshal([]byte(plainTextHAR), &h); err != nil { + t.Fatal(err) + } + rep := analyzeHAR(&h) + if !strings.HasPrefix(rep.Verdict, "CASE B") { + t.Errorf("verdict = %q, want CASE B", rep.Verdict) + } +} + +func TestAnalyzeHARInconclusiveWithoutRealtime(t *testing.T) { + var h harFile + if err := json.Unmarshal([]byte(`{"log":{"entries":[]}}`), &h); err != nil { + t.Fatal(err) + } + rep := analyzeHAR(&h) + if !strings.HasPrefix(rep.Verdict, "INCONCLUSIVE") { + t.Errorf("verdict = %q, want INCONCLUSIVE", rep.Verdict) + } +} + +func TestMQTTPacketName(t *testing.T) { + cases := map[byte]string{ + 0x10: "CONNECT", + 0x20: "CONNACK", + 0x30: "PUBLISH", + 0xC0: "PINGREQ", + 0x70: "", + } + for b, want := range cases { + if got := mqttPacketName(b); got != want { + t.Errorf("mqttPacketName(%#x) = %q, want %q", b, got, want) + } + } +} + +func TestRegistryRoleErrorIsActionable(t *testing.T) { + dir := t.TempDir() + ops := `{"operations":[{"friendly_name":"SomeQuery","doc_id":"1"}]}` + if err := os.WriteFile(filepath.Join(dir, "operations.json"), []byte(ops), 0o644); err != nil { + t.Fatal(err) + } + reg, err := LoadRegistry(dir) + if err != nil { + t.Fatal(err) + } + _, err = reg.ForRole("list_threads") + if err == nil { + t.Fatal("expected an error for an unmapped role") + } + if !strings.Contains(err.Error(), "roles.json") { + t.Errorf("error should point at roles.json, got: %v", err) + } +} diff --git a/cmd/bsprobe/graphql.go b/cmd/bsprobe/graphql.go new file mode 100644 index 00000000..29df1cd4 --- /dev/null +++ b/cmd/bsprobe/graphql.go @@ -0,0 +1,374 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "time" +) + +// Persisted GraphQL doc_ids rotate. Nothing in this file hard-codes one: the +// registry is generated by `bsprobe har` from a real capture, and roles.json +// maps a semantic role ("list_threads") onto whatever friendly_name that +// capture revealed. When Meta rotates, you re-capture and re-run har — you do +// not edit Go code. + +type Registry struct { + Operations []*Operation `json:"operations"` + Roles map[string]string `json:"-"` + dir string +} + +// KnownRoles are the operations a minimum viable connector needs, in the order +// the spec's Phase 1 and Phase 2 require them. +var KnownRoles = []string{ + "list_businesses", + "list_assets", + "list_mailboxes", + "list_threads", + "get_thread", + "send_text", + "mark_read", +} + +func LoadRegistry(dir string) (*Registry, error) { + r := &Registry{dir: dir, Roles: map[string]string{}} + + raw, err := os.ReadFile(filepath.Join(dir, "operations.json")) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("no operations.json in %s\n\n"+ + "Record a DevTools HAR of the inbox, then run:\n"+ + " bsprobe har --out %s ", dir, dir) + } + return nil, err + } + if err := json.Unmarshal(raw, r); err != nil { + return nil, fmt.Errorf("parse operations.json: %w", err) + } + + rolesRaw, err := os.ReadFile(filepath.Join(dir, "roles.json")) + if err == nil { + if err := json.Unmarshal(rolesRaw, &r.Roles); err != nil { + return nil, fmt.Errorf("parse roles.json: %w", err) + } + } else if !os.IsNotExist(err) { + return nil, err + } + return r, nil +} + +func (r *Registry) Get(friendlyName string) (*Operation, bool) { + for _, op := range r.Operations { + if op.FriendlyName == friendlyName { + return op, true + } + } + return nil, false +} + +func (r *Registry) ForRole(role string) (*Operation, error) { + name, ok := r.Roles[role] + if !ok || name == "" { + return nil, fmt.Errorf("role %q is not mapped\n\n"+ + "Open %s and pick the friendly_name that matches, then add it to %s:\n"+ + " { %q: \"\" }\n\n"+ + "Captured operations are listed in operations.json.", + role, + filepath.Join(r.dir, "operations.json"), + filepath.Join(r.dir, "roles.json"), + role) + } + op, ok := r.Get(name) + if !ok { + return nil, fmt.Errorf("role %q maps to %q, which is not in operations.json (re-capture?)", role, name) + } + if op.DocID == "" { + return nil, fmt.Errorf("operation %q has no doc_id in the capture", name) + } + return op, nil +} + +// WriteRolesTemplate drops a roles.json skeleton so the mapping step is +// obvious rather than folklore. +func (r *Registry) WriteRolesTemplate() error { + path := filepath.Join(r.dir, "roles.json") + if _, err := os.Stat(path); err == nil { + return nil + } + m := map[string]string{} + for _, role := range KnownRoles { + m[role] = "" + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} + +var reqCounter atomic.Int64 + +// Call issues one persisted GraphQL request using the session and bootstrap +// tokens. Field names here (av, __user, fb_dtsg, doc_id, ...) are the standard +// Meta web-client form fields; if a capture shows Business Suite using +// different ones, this is the single place to change. +func (s *Session) Call(b *Bootstrap, op *Operation, vars map[string]any) (int, string, error) { + varsJSON, err := json.Marshal(vars) + if err != nil { + return 0, "", err + } + + form := url.Values{} + form.Set("av", b.CurrentUserID) + form.Set("__user", b.CurrentUserID) + form.Set("__a", "1") + form.Set("__req", strconv.FormatInt(reqCounter.Add(1), 36)) + form.Set("fb_dtsg", b.FBDTSG) + form.Set("jazoest", b.Jazoest) + form.Set("lsd", b.LSD) + form.Set("fb_api_caller_class", "RelayModern") + form.Set("fb_api_req_friendly_name", op.FriendlyName) + form.Set("variables", string(varsJSON)) + form.Set("server_timestamps", "true") + form.Set("doc_id", op.DocID) + if b.Rev != "" { + form.Set("__rev", b.Rev) + } + + endpoint := op.Endpoint + if endpoint == "" { + endpoint = businessHost + "/api/graphql/" + } + + req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return 0, "", err + } + req.Header.Set("User-Agent", s.UserAgent) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", businessHost) + req.Header.Set("Referer", businessHost+"/latest/inbox/all") + req.Header.Set("X-FB-Friendly-Name", op.FriendlyName) + req.Header.Set("X-FB-LSD", b.LSD) + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") + + client := *s.client + client.Timeout = 45 * time.Second + resp, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return resp.StatusCode, "", err + } + // Meta prefixes JSON responses with an anti-JSON-hijacking guard. + return resp.StatusCode, strings.TrimPrefix(string(body), "for (;;);"), nil +} + +// runRole is the shared body of list-assets / list-threads / send-test: they +// differ only in which role they resolve and which variables they pass. +func runRole(role string, args []string, buildVars func(*flag.FlagSet) map[string]any, extra func(*flag.FlagSet)) error { + fs := flag.NewFlagSet(role, flag.ExitOnError) + sessionPath := fs.String("session", defaultSessionFile, "session file") + fixtures := fs.String("fixtures", "fixtures", "fixtures directory holding operations.json and roles.json") + shape := fs.Bool("shape", true, "print the response shape instead of raw values") + saveShape := fs.String("save-shape", "", "write the response shape to this file") + if extra != nil { + extra(fs) + } + if err := fs.Parse(args); err != nil { + return err + } + + reg, err := LoadRegistry(*fixtures) + if err != nil { + return err + } + if err := reg.WriteRolesTemplate(); err != nil { + return err + } + op, err := reg.ForRole(role) + if err != nil { + return err + } + + s, err := LoadSession(*sessionPath) + if err != nil { + return err + } + _, html, err := s.Get(businessHost + "/latest/inbox/all") + if err != nil { + return fmt.Errorf("fetch bootstrap: %w", err) + } + b := ParseBootstrap(html) + if b.FBDTSG == "" { + return fmt.Errorf("no fb_dtsg in bootstrap - run 'bsprobe validate-session' first") + } + + vars := map[string]any{} + if buildVars != nil { + vars = buildVars(fs) + } + + fmt.Printf("%s -> %s (doc_id=%s)\n", role, op.FriendlyName, op.DocID) + status, body, err := s.Call(b, op, vars) + if err != nil { + return err + } + fmt.Printf("HTTP %d, %d bytes\n\n", status, len(body)) + + var decoded any + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + trimmed := body + if len(trimmed) > 800 { + trimmed = trimmed[:800] + } + return fmt.Errorf("response was not JSON:\n%s", RedactString(trimmed)) + } + + if m, ok := decoded.(map[string]any); ok { + if errs, ok := m["errors"]; ok { + out, _ := json.MarshalIndent(Redact(errs), "", " ") + fmt.Printf("GraphQL errors:\n%s\n", out) + } + } + + var out []byte + if *shape { + out, _ = json.MarshalIndent(Shape(decoded), "", " ") + } else { + out, _ = json.MarshalIndent(Redact(decoded), "", " ") + } + fmt.Println(string(out)) + + if *saveShape != "" { + if err := os.MkdirAll(filepath.Dir(*saveShape), 0o755); err != nil { + return err + } + payload, _ := json.MarshalIndent(map[string]any{ + "role": role, + "friendly_name": op.FriendlyName, + "doc_id": op.DocID, + "shape": Shape(decoded), + }, "", " ") + if err := os.WriteFile(*saveShape, payload, 0o644); err != nil { + return err + } + fmt.Printf("\nshape written to %s\n", *saveShape) + } + return nil +} + +func cmdListAssets(args []string) error { + return runRole("list_assets", args, nil, nil) +} + +func cmdListThreads(args []string) error { + var mailbox *string + var cursor *string + var count *int + return runRole("list_threads", args, func(fs *flag.FlagSet) map[string]any { + v := map[string]any{"count": *count} + if *mailbox != "" { + v["mailbox_id"] = *mailbox + } + if *cursor != "" { + v["cursor"] = *cursor + } + return v + }, func(fs *flag.FlagSet) { + mailbox = fs.String("mailbox", "", "mailbox id (from bsprobe bootstrap)") + cursor = fs.String("cursor", "", "pagination cursor") + count = fs.Int("count", 20, "threads to request") + }) +} + +func cmdSendTest(args []string) error { + var thread *string + var text *string + return runRole("send_text", args, func(fs *flag.FlagSet) map[string]any { + return map[string]any{"thread_id": *thread, "text": *text} + }, func(fs *flag.FlagSet) { + thread = fs.String("thread", "", "thread id to send to") + text = fs.String("text", "bsprobe test", "message body") + }) +} + +// cmdCall is the escape hatch: invoke any captured operation with arbitrary +// variables, which is how you work out a variable schema without editing code. +func cmdCall(args []string) error { + fs := flag.NewFlagSet("call", flag.ExitOnError) + sessionPath := fs.String("session", defaultSessionFile, "session file") + fixtures := fs.String("fixtures", "fixtures", "fixtures directory") + opName := fs.String("op", "", "friendly_name from operations.json") + varsJSON := fs.String("vars", "{}", "variables as a JSON object") + shape := fs.Bool("shape", true, "print response shape rather than redacted values") + if err := fs.Parse(args); err != nil { + return err + } + if *opName == "" { + return fmt.Errorf("--op is required (see operations.json)") + } + + reg, err := LoadRegistry(*fixtures) + if err != nil { + return err + } + op, ok := reg.Get(*opName) + if !ok { + return fmt.Errorf("operation %q not found in operations.json", *opName) + } + + var vars map[string]any + if err := json.Unmarshal([]byte(*varsJSON), &vars); err != nil { + return fmt.Errorf("--vars must be a JSON object: %w", err) + } + + s, err := LoadSession(*sessionPath) + if err != nil { + return err + } + _, html, err := s.Get(businessHost + "/latest/inbox/all") + if err != nil { + return err + } + b := ParseBootstrap(html) + + status, body, err := s.Call(b, op, vars) + if err != nil { + return err + } + fmt.Printf("HTTP %d, %d bytes\n\n", status, len(body)) + + var decoded any + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + trimmed := body + if len(trimmed) > 800 { + trimmed = trimmed[:800] + } + fmt.Println(RedactString(trimmed)) + return nil + } + var out []byte + if *shape { + out, _ = json.MarshalIndent(Shape(decoded), "", " ") + } else { + out, _ = json.MarshalIndent(Redact(decoded), "", " ") + } + fmt.Println(string(out)) + return nil +} diff --git a/cmd/bsprobe/har.go b/cmd/bsprobe/har.go new file mode 100644 index 00000000..17feddec --- /dev/null +++ b/cmd/bsprobe/har.go @@ -0,0 +1,575 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" +) + +// The HAR analyzer is the one part of bsprobe that needs no live session: it +// runs offline against a DevTools recording. It answers the decisive question +// — does Business Suite speak Lightspeed (reuse pkg/messagix) or something +// else (build a separate transport) — and emits the operation registry that +// every later command reads. + +type harFile struct { + Log struct { + Entries []harEntry `json:"entries"` + } `json:"log"` +} + +type harNV struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type harEntry struct { + StartedDateTime string `json:"startedDateTime"` + Request struct { + Method string `json:"method"` + URL string `json:"url"` + Headers []harNV `json:"headers"` + PostData struct { + MimeType string `json:"mimeType"` + Text string `json:"text"` + Params []harNV `json:"params"` + } `json:"postData"` + } `json:"request"` + Response struct { + Status int `json:"status"` + Content struct { + MimeType string `json:"mimeType"` + Text string `json:"text"` + Encoding string `json:"encoding"` + } `json:"content"` + } `json:"response"` + WebSocketMessages []harWSMessage `json:"_webSocketMessages"` +} + +type harWSMessage struct { + Type string `json:"type"` // "send" | "receive" + Time float64 `json:"time"` + Opcode int `json:"opcode"` // 1 = text, 2 = binary + Data string `json:"data"` +} + +// Operation is one persisted GraphQL call observed in the capture. This is the +// registry entry format the spec calls for — never hard-coded, always sourced +// from a real recording. +type Operation struct { + FriendlyName string `json:"friendly_name"` + DocID string `json:"doc_id"` + Method string `json:"method"` + Endpoint string `json:"endpoint"` + VariableKeys []string `json:"variable_keys"` + Seen int `json:"seen"` + ResponseFile string `json:"response_shape_file,omitempty"` +} + +type realtimeFinding struct { + URL string + TextFrames int + BinaryFrames int + Samples []string +} + +type harReport struct { + Operations map[string]*Operation + GraphQLHosts map[string]int + Realtime []*realtimeFinding + Identifiers map[string]map[string]bool + Verdict string + VerdictWhy []string +} + +func cmdHAR(args []string) error { + fs := flag.NewFlagSet("har", flag.ExitOnError) + out := fs.String("out", "fixtures", "directory to write the registry and response shapes into") + shapes := fs.Bool("shapes", true, "write a redacted response shape file per operation") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: bsprobe har [--out dir] ") + } + + raw, err := os.ReadFile(fs.Arg(0)) + if err != nil { + return err + } + var h harFile + if err := json.Unmarshal(raw, &h); err != nil { + return fmt.Errorf("parse har: %w", err) + } + + rep := analyzeHAR(&h) + + if err := os.MkdirAll(*out, 0o755); err != nil { + return err + } + if *shapes { + if err := writeShapes(&h, rep, *out); err != nil { + return err + } + } + if err := writeRegistry(rep, *out); err != nil { + return err + } + + fmt.Print(renderReport(rep, fs.Arg(0))) + return nil +} + +func analyzeHAR(h *harFile) *harReport { + rep := &harReport{ + Operations: map[string]*Operation{}, + GraphQLHosts: map[string]int{}, + Identifiers: map[string]map[string]bool{}, + } + + for i := range h.Log.Entries { + e := &h.Log.Entries[i] + if isGraphQL(e.Request.URL) { + collectGraphQL(rep, e) + } + if len(e.WebSocketMessages) > 0 || isRealtimeURL(e.Request.URL) { + collectRealtime(rep, e) + } + // Identifiers can appear in request bodies and responses alike. + harvestIdentifiers(rep, e.Request.PostData.Text) + if len(e.Response.Content.Text) < 2_000_000 { + harvestIdentifiers(rep, e.Response.Content.Text) + } + } + + decideVerdict(rep) + return rep +} + +func isGraphQL(raw string) bool { + return strings.Contains(raw, "/api/graphql") || strings.HasSuffix(rawPath(raw), "/graphql/") || + strings.HasSuffix(rawPath(raw), "/graphql") +} + +func isRealtimeURL(raw string) bool { + l := strings.ToLower(raw) + return strings.HasPrefix(l, "ws://") || strings.HasPrefix(l, "wss://") || + strings.Contains(l, "edge-chat") || strings.Contains(l, "mqtt") || + strings.Contains(l, "/ws/realtime") +} + +func rawPath(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + return u.Path +} + +func collectGraphQL(rep *harReport, e *harEntry) { + if u, err := url.Parse(e.Request.URL); err == nil { + rep.GraphQLHosts[u.Host+u.Path]++ + } + + form := parseForm(e) + if form == nil { + return + } + name := form.Get("fb_api_req_friendly_name") + docID := form.Get("doc_id") + if name == "" && docID == "" { + return + } + if name == "" { + name = "unnamed_doc_" + docID + } + + op, ok := rep.Operations[name] + if !ok { + u, _ := url.Parse(e.Request.URL) + endpoint := e.Request.URL + if u != nil { + endpoint = u.Scheme + "://" + u.Host + u.Path + } + op = &Operation{ + FriendlyName: name, + DocID: docID, + Method: e.Request.Method, + Endpoint: endpoint, + } + rep.Operations[name] = op + } + op.Seen++ + if op.DocID == "" { + op.DocID = docID + } + + // Variable *keys* are protocol facts worth keeping; the values are customer + // data and are dropped here. + if vars := form.Get("variables"); vars != "" { + var decoded any + if err := json.Unmarshal([]byte(vars), &decoded); err == nil { + op.VariableKeys = mergeKeys(op.VariableKeys, topLevelKeys(decoded)) + harvestIdentifiersJSON(rep, decoded) + } + } +} + +func parseForm(e *harEntry) url.Values { + pd := e.Request.PostData + if len(pd.Params) > 0 { + v := url.Values{} + for _, p := range pd.Params { + // DevTools percent-encodes param values in the params array. + if dec, err := url.QueryUnescape(p.Value); err == nil { + v.Set(p.Name, dec) + } else { + v.Set(p.Name, p.Value) + } + } + return v + } + if pd.Text == "" { + return nil + } + if strings.Contains(pd.MimeType, "json") { + // Some surfaces post JSON rather than form-encoded bodies. + var m map[string]any + if err := json.Unmarshal([]byte(pd.Text), &m); err != nil { + return nil + } + v := url.Values{} + for k, val := range m { + switch t := val.(type) { + case string: + v.Set(k, t) + default: + b, _ := json.Marshal(t) + v.Set(k, string(b)) + } + } + return v + } + v, err := url.ParseQuery(pd.Text) + if err != nil { + return nil + } + return v +} + +func collectRealtime(rep *harReport, e *harEntry) { + var f *realtimeFinding + for _, existing := range rep.Realtime { + if existing.URL == e.Request.URL { + f = existing + break + } + } + if f == nil { + f = &realtimeFinding{URL: e.Request.URL} + rep.Realtime = append(rep.Realtime, f) + } + for _, m := range e.WebSocketMessages { + if m.Opcode == 2 { + f.BinaryFrames++ + } else { + f.TextFrames++ + } + if len(f.Samples) < 12 { + f.Samples = append(f.Samples, decodeFrame(m)) + } + } +} + +func decodeFrame(m harWSMessage) string { + data := m.Data + if m.Opcode == 2 { + if b, err := base64.StdEncoding.DecodeString(data); err == nil { + data = string(b) + } + } + if len(data) > 600 { + data = data[:600] + } + return RedactString(data) +} + +// lightspeedMarkers are the field names messagix's socket decoder already +// expects. Finding them in a Business Suite frame is the Case A signal. +var lightspeedMarkers = []string{ + `"request_id"`, `"payload"`, `"sp"`, `"target"`, + "ls_req", "lightspeed", "LSPlatform", "deltaNewMessage", +} + +func decideVerdict(rep *harReport) { + var textFrames, binaryFrames int + markerHits := map[string]int{} + for _, f := range rep.Realtime { + textFrames += f.TextFrames + binaryFrames += f.BinaryFrames + for _, s := range f.Samples { + for _, marker := range lightspeedMarkers { + if strings.Contains(s, marker) { + markerHits[marker]++ + } + } + } + } + + switch { + case len(rep.Realtime) == 0: + rep.Verdict = "INCONCLUSIVE - no realtime traffic in capture" + rep.VerdictWhy = append(rep.VerdictWhy, + "No WebSocket entries found. Chrome only records frames if the WS connection is opened while DevTools is already recording.", + "Re-record: open DevTools first, then load the inbox, and keep 'Preserve log' on.") + case len(markerHits) >= 2: + rep.Verdict = "CASE A - Lightspeed markers present, reuse pkg/messagix" + for m, n := range markerHits { + rep.VerdictWhy = append(rep.VerdictWhy, fmt.Sprintf("marker %s seen %d times in realtime frames", m, n)) + } + rep.VerdictWhy = append(rep.VerdictWhy, + "Next: confirm pkg/messagix/lightspeed decodes a captured payload before committing to this path.") + case binaryFrames > 0 && textFrames == 0: + rep.Verdict = "CASE A LIKELY - binary realtime frames (MQTT-shaped), same transport family as messagix" + rep.VerdictWhy = append(rep.VerdictWhy, + fmt.Sprintf("%d binary frames, 0 text frames", binaryFrames), + "Binary frames are consistent with the MQTT transport pkg/messagix/socket already speaks.", + "HAR cannot decode these. Confirm with: bsprobe watch-events --dump-frames") + case textFrames > 0: + rep.Verdict = "CASE B LIKELY - text realtime frames without Lightspeed markers" + rep.VerdictWhy = append(rep.VerdictWhy, + fmt.Sprintf("%d text frames, no Lightspeed field names matched", textFrames), + "Plan for a separate decoder under pkg/bizsuite/realtime/ rather than bending messagix around it.") + default: + rep.Verdict = "INCONCLUSIVE" + rep.VerdictWhy = append(rep.VerdictWhy, "Realtime endpoints seen but no frames captured.") + } +} + +// identifierKeys are the routing IDs the connector will need to address a +// Page mailbox. Collecting the observed values proves which ones actually vary +// per Page versus per business. +var identifierKeys = []string{ + "business_id", "asset_id", "mailbox_id", "page_id", "thread_id", + "selected_item_id", "folder", "sync_group", "database_id", "i_user", "av", +} + +func harvestIdentifiers(rep *harReport, body string) { + if body == "" { + return + } + var decoded any + if err := json.Unmarshal([]byte(body), &decoded); err == nil { + harvestIdentifiersJSON(rep, decoded) + return + } + if v, err := url.ParseQuery(body); err == nil { + for _, k := range identifierKeys { + if got := v.Get(k); got != "" { + addIdentifier(rep, k, got) + } + } + } +} + +func harvestIdentifiersJSON(rep *harReport, v any) { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + for _, want := range identifierKeys { + if strings.EqualFold(k, want) { + switch s := val.(type) { + case string: + addIdentifier(rep, want, s) + case float64: + addIdentifier(rep, want, fmt.Sprintf("%.0f", s)) + } + } + } + harvestIdentifiersJSON(rep, val) + } + case []any: + for _, val := range t { + harvestIdentifiersJSON(rep, val) + } + } +} + +func addIdentifier(rep *harReport, key, val string) { + if val == "" || val == "0" || len(val) > 64 { + return + } + if rep.Identifiers[key] == nil { + rep.Identifiers[key] = map[string]bool{} + } + rep.Identifiers[key][val] = true +} + +func topLevelKeys(v any) []string { + m, ok := v.(map[string]any) + if !ok { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func mergeKeys(a, b []string) []string { + seen := map[string]bool{} + for _, k := range a { + seen[k] = true + } + for _, k := range b { + seen[k] = true + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func writeShapes(h *harFile, rep *harReport, dir string) error { + shapeDir := filepath.Join(dir, "shapes") + if err := os.MkdirAll(shapeDir, 0o755); err != nil { + return err + } + written := map[string]bool{} + for i := range h.Log.Entries { + e := &h.Log.Entries[i] + if !isGraphQL(e.Request.URL) { + continue + } + form := parseForm(e) + if form == nil { + continue + } + name := form.Get("fb_api_req_friendly_name") + if name == "" || written[name] { + continue + } + body := strings.TrimPrefix(e.Response.Content.Text, "for (;;);") + var decoded any + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + continue + } + file := filepath.Join(shapeDir, sanitize(name)+".json") + payload := map[string]any{ + "friendly_name": name, + "doc_id": form.Get("doc_id"), + "shape": Shape(decoded), + } + b, err := json.MarshalIndent(payload, "", " ") + if err != nil { + continue + } + if err := os.WriteFile(file, b, 0o644); err != nil { + return err + } + written[name] = true + if op := rep.Operations[name]; op != nil { + op.ResponseFile = filepath.ToSlash(filepath.Join("shapes", sanitize(name)+".json")) + } + } + return nil +} + +func sanitize(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': + return r + default: + return '_' + } + }, s) +} + +func writeRegistry(rep *harReport, dir string) error { + ops := make([]*Operation, 0, len(rep.Operations)) + for _, op := range rep.Operations { + ops = append(ops, op) + } + sort.Slice(ops, func(i, j int) bool { return ops[i].FriendlyName < ops[j].FriendlyName }) + b, err := json.MarshalIndent(map[string]any{ + "note": "Generated by bsprobe har. Meta rotates doc_id values; re-capture when calls start failing.", + "operations": ops, + }, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "operations.json"), b, 0o644) +} + +func renderReport(rep *harReport, src string) string { + var b strings.Builder + fmt.Fprintf(&b, "\nbsprobe har - %s\n\n", src) + + fmt.Fprintf(&b, "VERDICT: %s\n", rep.Verdict) + for _, w := range rep.VerdictWhy { + fmt.Fprintf(&b, " - %s\n", w) + } + + fmt.Fprintf(&b, "\nGraphQL endpoints (%d)\n", len(rep.GraphQLHosts)) + hosts := make([]string, 0, len(rep.GraphQLHosts)) + for h := range rep.GraphQLHosts { + hosts = append(hosts, h) + } + sort.Strings(hosts) + for _, h := range hosts { + fmt.Fprintf(&b, " %-60s %d calls\n", h, rep.GraphQLHosts[h]) + } + + fmt.Fprintf(&b, "\nOperations (%d)\n", len(rep.Operations)) + ops := make([]*Operation, 0, len(rep.Operations)) + for _, op := range rep.Operations { + ops = append(ops, op) + } + sort.Slice(ops, func(i, j int) bool { return ops[i].Seen > ops[j].Seen }) + for _, op := range ops { + fmt.Fprintf(&b, " %-45s doc_id=%-18s x%d\n", op.FriendlyName, op.DocID, op.Seen) + if len(op.VariableKeys) > 0 { + fmt.Fprintf(&b, " vars: %s\n", strings.Join(op.VariableKeys, ", ")) + } + } + + fmt.Fprintf(&b, "\nRealtime endpoints (%d)\n", len(rep.Realtime)) + for _, f := range rep.Realtime { + fmt.Fprintf(&b, " %s\n text=%d binary=%d\n", f.URL, f.TextFrames, f.BinaryFrames) + } + + fmt.Fprintf(&b, "\nIdentifiers observed\n") + keys := make([]string, 0, len(rep.Identifiers)) + for k := range rep.Identifiers { + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) == 0 { + fmt.Fprintf(&b, " (none - check that the capture includes inbox bootstrap requests)\n") + } + for _, k := range keys { + vals := make([]string, 0, len(rep.Identifiers[k])) + for v := range rep.Identifiers[k] { + vals = append(vals, v) + } + sort.Strings(vals) + if len(vals) > 6 { + vals = append(vals[:6], fmt.Sprintf("... +%d more", len(rep.Identifiers[k])-6)) + } + fmt.Fprintf(&b, " %-18s %s\n", k, strings.Join(vals, ", ")) + } + + fmt.Fprintf(&b, "\nWrote operations.json and shapes/ - review before committing.\n\n") + return b.String() +} diff --git a/cmd/bsprobe/main.go b/cmd/bsprobe/main.go new file mode 100644 index 00000000..dc5a2100 --- /dev/null +++ b/cmd/bsprobe/main.go @@ -0,0 +1,91 @@ +// Command bsprobe is a protocol discovery harness for the Meta Business Suite +// inbox. +// +// It exists to answer one question before any connector code is written: does +// business.facebook.com speak the same Lightspeed protocol that pkg/messagix +// already implements (Case A, reuse it), or something else (Case B, build a +// separate transport)? Guessing that answer wrong costs weeks. +// +// This is a research instrument, not part of the bridge. Nothing here should +// ever be imported by pkg/connector. Every artifact it writes is redacted at +// capture time. +// +// Typical run: +// +// bsprobe validate-session +// bsprobe har --out fixtures capture.har # offline; produces operations.json +// bsprobe bootstrap --save fixtures/bootstrap.json +// bsprobe watch-events --url wss://... # url comes from the har report +// bsprobe list-threads --mailbox +package main + +import ( + "fmt" + "os" +) + +const usage = `bsprobe - Meta Business Suite protocol discovery harness + + validate-session check that the pasted session authenticates against the + Business Suite inbox + har analyse a DevTools recording offline: extract persisted + GraphQL operations, find the realtime endpoint, and decide + Case A (Lightspeed) vs Case B (something else) + bootstrap fetch the inbox document and extract fb_dtsg, lsd, jazoest, + revision and the business/asset/mailbox identifiers + messagix load a Page inbox through the production messagix decoder + watch-events attach to the realtime endpoint and classify live frames + list-assets call the captured "list_assets" operation + list-threads call the captured "list_threads" operation + send-test call the captured "send_text" operation + call --op NAME invoke any captured operation with arbitrary variables + +Flags are per-command; run 'bsprobe -h'. + +Order matters: har first (it writes fixtures/operations.json), then map roles in +fixtures/roles.json, then the live commands. No doc_id is ever hard-coded — when +Meta rotates them, re-record and re-run har. +` + +func main() { + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, usage) + os.Exit(2) + } + + cmd := os.Args[1] + args := os.Args[2:] + + var err error + switch cmd { + case "validate-session": + err = cmdValidateSession(args) + case "har": + err = cmdHAR(args) + case "bootstrap": + err = cmdBootstrap(args) + case "messagix": + err = cmdMessagix(args) + case "watch-events": + err = cmdWatchEvents(args) + case "list-assets": + err = cmdListAssets(args) + case "list-threads": + err = cmdListThreads(args) + case "send-test": + err = cmdSendTest(args) + case "call": + err = cmdCall(args) + case "help", "-h", "--help": + fmt.Print(usage) + return + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", cmd, usage) + os.Exit(2) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "\nbsprobe %s: %v\n", cmd, err) + os.Exit(1) + } +} diff --git a/cmd/bsprobe/messagix.go b/cmd/bsprobe/messagix.go new file mode 100644 index 00000000..78bfb2f4 --- /dev/null +++ b/cmd/bsprobe/messagix.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "regexp" + "time" + + "github.com/rs/zerolog" + + "go.mau.fi/mautrix-meta/pkg/messagix" + "go.mau.fi/mautrix-meta/pkg/messagix/cookies" + messagixTable "go.mau.fi/mautrix-meta/pkg/messagix/table" + "go.mau.fi/mautrix-meta/pkg/messagix/types" +) + +// cmdMessagix is an acceptance probe for the production decoder. Unlike the +// HAR classifier, it loads a real Business Suite Page through pkg/messagix. +// It intentionally prints only counts and IDs, never cookies or message data. +func cmdMessagix(args []string) error { + fs := flag.NewFlagSet("messagix", flag.ExitOnError) + sessionPath := fs.String("session", defaultSessionFile, "session file") + assetID := fs.String("asset", "", "selected Page asset ID") + actorID := fs.String("actor", "", "profile-switcher actor ID to resolve") + username := fs.String("username", "", "public Page username used by the resolver") + connect := fs.Bool("connect", false, "also require a successful production socket sync") + if err := fs.Parse(args); err != nil { + return err + } + if *assetID == "" && *actorID == "" { + return fmt.Errorf("--asset or --actor is required") + } + + session, err := LoadSession(*sessionPath) + if err != nil { + return err + } + values := make(map[cookies.MetaCookieName]string, len(session.Cookies)) + for _, cookie := range session.Cookies { + values[cookies.MetaCookieName(cookie.Name)] = cookie.Value + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if *actorID != "" { + fbCookies := &cookies.Cookies{Platform: types.Facebook} + fbCookies.UpdateValues(values) + fbClient := messagix.NewClient(fbCookies, zerolog.New(io.Discard), &messagix.Config{}) + profile := types.SwitchableProfile{ID: *actorID, Username: *username, Name: "Page"} + if err := fbClient.DiscoverSwitchableProfiles(ctx); err == nil { + for _, candidate := range fbClient.GetSwitchableProfiles() { + if candidate.ID == *actorID { + profile = candidate + break + } + } + } + candidates := regexp.MustCompile(`\d{6,}`).FindAllString(profile.AvatarURL, -1) + fmt.Printf("profile metadata: username_present=%t avatar_id_candidates=%v\n", profile.Username != "", candidates) + resolved, err := fbClient.ResolveBusinessSuitePage(ctx, profile) + if err != nil { + return err + } + *assetID = resolved.AssetID + } + c := &cookies.Cookies{Platform: types.BusinessSuite} + c.UpdateValues(values) + client := messagix.NewClient(c, zerolog.New(io.Discard), &messagix.Config{ + BusinessSuite: &messagix.BusinessSuiteContext{AssetID: *assetID, PageID: *assetID}, + }) + + user, table, err := client.LoadMessagesPage(ctx) + if err != nil { + return err + } + fmt.Printf("production parser accepted Business Suite: user_id=%d page_asset=%s table_present=%t\n", user.GetFBID(), *assetID, table != nil) + if table != nil { + threadGroups := make(map[int64]int) + for _, thread := range table.LSDeleteThenInsertThread { + threadGroups[thread.SyncGroup]++ + } + fmt.Printf( + "Business Inbox snapshot decoded: fields=%d threads=%d messages=%d participants=%d sync_transactions=%d thread_groups=%v ig_thread_markers=%d\n", + len(table.NonNilFields()), + len(table.LSDeleteThenInsertThread), + len(table.LSUpsertMessage), + len(table.LSAddParticipantIdToGroupThread), + len(table.LSExecuteFirstBlockForSyncTransaction), + threadGroups, + len(table.LSDeleteThenInsertIgThreadInfo), + ) + } + if *connect { + events := make(chan any, 8) + client.SetEventHandler(func(_ context.Context, event any) { events <- event }) + if err := client.Connect(ctx); err != nil { + return err + } + defer client.Disconnect() + for { + select { + case event := <-events: + switch typed := event.(type) { + case *messagixTable.LSTable: + threadGroups := make(map[int64]int) + for _, thread := range typed.LSDeleteThenInsertThread { + threadGroups[thread.SyncGroup]++ + } + fmt.Printf( + "socket table decoded: fields=%v threads=%d updated_threads=%d verified_threads=%d messages=%d inserted_messages=%d thread_groups=%v ig_thread_markers=%d\n", + typed.NonNilFields(), + len(typed.LSDeleteThenInsertThread), len(typed.LSUpdateOrInsertThread), + len(typed.LSVerifyThreadExists), len(typed.LSUpsertMessage), + len(typed.LSInsertMessage), threadGroups, + len(typed.LSDeleteThenInsertIgThreadInfo), + ) + case *messagix.ConnectedEvent, *messagix.ReconnectedEvent: + fmt.Println("production socket sync accepted Business Suite") + return nil + case *messagix.PermanentErrorEvent: + return typed.Err + case *messagix.TransientDisconnectEvent: + return typed.Err + } + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for production socket sync: %w", ctx.Err()) + } + } + } + return nil +} diff --git a/cmd/bsprobe/redact.go b/cmd/bsprobe/redact.go new file mode 100644 index 00000000..99c102d2 --- /dev/null +++ b/cmd/bsprobe/redact.go @@ -0,0 +1,233 @@ +package main + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// Everything written to disk by bsprobe passes through this file. The harness +// records real customer conversations, so redaction is applied at capture time +// rather than as a cleanup pass — a fixture that was never written unredacted +// cannot leak. + +var ( + emailRe = regexp.MustCompile(`[\w.+-]+@[\w-]+\.[\w.]{2,}`) + phoneRe = regexp.MustCompile(`\+?\d[\d\s().-]{8,}\d`) + urlRe = regexp.MustCompile(`https?://[^\s"']+`) +) + +// safeKeys survive redaction untouched. These are the protocol facts the whole +// exercise exists to capture, and several of them (friendly_name) would +// otherwise be caught by the contentKeys "name" rule. +var safeKeys = map[string]bool{ + "fb_api_req_friendly_name": true, + "friendly_name": true, + "operation_name": true, + "doc_id": true, + "query_id": true, + "__typename": true, + "type": true, + "id": true, + "cursor": true, + "count": true, + "has_next_page": true, + "end_cursor": true, + "request_id": true, + "target": true, + "sp": true, + "sync_group": true, + "database_id": true, + "mailbox_id": true, + "asset_id": true, + "business_id": true, + "page_id": true, + "thread_id": true, + "folder": true, + "__rev": true, + "__spin_r": true, + "av": true, + "server_timestamp": true, + "timestamp": true, +} + +// secretKeys are auth material. Redacted wholesale, no length hint — length is +// itself a weak signal for a token. +var secretKeys = map[string]bool{ + "fb_dtsg": true, + "lsd": true, + "jazoest": true, + "access_token": true, + "accesstoken": true, + "cookie": true, + "set-cookie": true, + "authorization": true, + "xs": true, + "c_user": true, + "datr": true, + "sb": true, + "fr": true, + "password": true, + "secret": true, + "csrf": true, + "csrf_token": true, + "session_key": true, +} + +// contentKeys carry customer identity or message text. Redacted, but the key +// and a length hint stay so the response shape remains readable. +var contentKeys = map[string]bool{ + "text": true, + "body": true, + "message": true, + "snippet": true, + "preview": true, + "caption": true, + "subtitle": true, + "name": true, + "full_name": true, + "display_name": true, + "first_name": true, + "last_name": true, + "short_name": true, + "username": true, + "email": true, + "phone": true, + "phone_number": true, + "profile_picture": true, + "profile_pic_url": true, + "note": true, + "notes": true, +} + +func classifyKey(k string) string { + lk := strings.ToLower(k) + if safeKeys[lk] { + return "safe" + } + if secretKeys[lk] { + return "secret" + } + if contentKeys[lk] { + return "content" + } + // Catch-all suffix rules for keys we did not enumerate. Checked after the + // allowlist so friendly_name and doc_id are already out of the way. + switch { + case strings.HasSuffix(lk, "_token"), strings.HasSuffix(lk, "_secret"), strings.Contains(lk, "password"): + return "secret" + case strings.HasSuffix(lk, "_name"), strings.HasSuffix(lk, "_text"), strings.HasSuffix(lk, "_email"): + return "content" + } + return "plain" +} + +// Redact walks a decoded JSON value and returns a copy that is safe to commit. +func Redact(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + switch classifyKey(k) { + case "safe": + out[k] = val + case "secret": + out[k] = "" + case "content": + out[k] = redactContent(val) + default: + out[k] = Redact(val) + } + } + return out + case []any: + out := make([]any, len(t)) + for i, val := range t { + out[i] = Redact(val) + } + return out + case string: + return RedactString(t) + default: + return v + } +} + +func redactContent(v any) any { + if s, ok := v.(string); ok { + return fmt.Sprintf("", len(s)) + } + return Redact(v) +} + +// RedactString scrubs free text: emails, phone numbers, and URL query strings +// (which carry CDN and session tokens). The host and path of a URL survive +// because endpoint discovery is the point of the exercise. +func RedactString(s string) string { + s = urlRe.ReplaceAllStringFunc(s, redactURL) + s = emailRe.ReplaceAllString(s, "") + s = phoneRe.ReplaceAllString(s, "") + return s +} + +func redactURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + if u.RawQuery == "" { + return u.Scheme + "://" + u.Host + u.Path + } + return u.Scheme + "://" + u.Host + u.Path + "?" +} + +// RedactCookieHeader keeps cookie names and drops every value, so a capture +// still shows which cookies the surface requires. +func RedactCookieHeader(v string) string { + parts := strings.Split(v, ";") + names := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if i := strings.Index(p, "="); i > 0 { + names = append(names, p[:i]+"=") + } + } + return strings.Join(names, "; ") +} + +// Shape reduces a decoded JSON value to structure only: keys and type names, +// every value dropped. This is the safest fixture form and the one worth +// committing — it records the schema a connector must parse while carrying no +// customer data at all. +func Shape(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + out[k] = Shape(val) + } + return out + case []any: + if len(t) == 0 { + return []any{} + } + return map[string]any{ + "__array_len": len(t), + "__element": Shape(t[0]), + } + case string: + return "string" + case float64: + return "number" + case bool: + return "bool" + case nil: + return "null" + default: + return fmt.Sprintf("%T", v) + } +} diff --git a/cmd/bsprobe/session.go b/cmd/bsprobe/session.go new file mode 100644 index 00000000..c89b5aaa --- /dev/null +++ b/cmd/bsprobe/session.go @@ -0,0 +1,206 @@ +package main + +import ( + "flag" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +// Auth reuses the model mautrix-meta already asks users for: paste an +// authenticated request copied from DevTools ("Copy as cURL"). No password, no +// OAuth, no Meta developer app. + +const defaultSessionFile = ".bsprobe/session.curl" + +// businessHost is the surface under investigation. Kept in one place so a +// redirect to a different host is obvious rather than silently followed. +const businessHost = "https://business.facebook.com" + +type Session struct { + Cookies []*http.Cookie + UserAgent string + client *http.Client +} + +var ( + curlCookieHeaderRe = regexp.MustCompile(`(?i)-H\s+'cookie:\s*([^']*)'`) + curlCookieBRe = regexp.MustCompile(`(?i)-b\s+'([^']*)'`) + curlUARe = regexp.MustCompile(`(?i)-H\s+'user-agent:\s*([^']*)'`) +) + +// LoadSession accepts either a raw "name=value; name=value" cookie string or a +// full "Copy as cURL" blob, because both are what people actually paste. +func LoadSession(path string) (*Session, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("no session at %s\n\n"+ + "In Chrome, open business.facebook.com/latest/inbox/all while logged in,\n"+ + "right-click any request in DevTools > Network, choose Copy > Copy as cURL,\n"+ + "and save it to that path. It is gitignored.", path) + } + return nil, err + } + text := string(raw) + + cookieStr := "" + if m := curlCookieHeaderRe.FindStringSubmatch(text); m != nil { + cookieStr = m[1] + } else if m := curlCookieBRe.FindStringSubmatch(text); m != nil { + cookieStr = m[1] + } else if strings.Contains(text, "=") && !strings.Contains(text, "curl ") { + cookieStr = strings.TrimSpace(text) + } + if cookieStr == "" { + return nil, fmt.Errorf("could not find cookies in %s (expected a 'Copy as cURL' blob or a raw cookie string)", path) + } + + ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + if m := curlUARe.FindStringSubmatch(text); m != nil { + ua = m[1] + } + + s := &Session{UserAgent: ua} + for _, part := range strings.Split(cookieStr, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + i := strings.Index(part, "=") + if i <= 0 { + continue + } + s.Cookies = append(s.Cookies, &http.Cookie{ + Name: part[:i], + Value: part[i+1:], + }) + } + if len(s.Cookies) == 0 { + return nil, fmt.Errorf("parsed zero cookies from %s", path) + } + + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + u, _ := url.Parse(businessHost) + jar.SetCookies(u, s.Cookies) + // Meta shares session cookies across the facebook.com apex. + if fb, err := url.Parse("https://www.facebook.com"); err == nil { + jar.SetCookies(fb, s.Cookies) + } + + s.client = &http.Client{ + Jar: jar, + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + return s, nil +} + +func (s *Session) Has(name string) bool { + for _, c := range s.Cookies { + if c.Name == name { + return true + } + } + return false +} + +// UserID returns the Facebook user ID from the c_user cookie. Note this stays +// the human account even when a Page mailbox is selected — Page identity is +// carried by asset context, not by swapping this value. +func (s *Session) UserID() string { + for _, c := range s.Cookies { + if c.Name == "c_user" { + return c.Value + } + } + return "" +} + +func (s *Session) Get(rawURL string) (*http.Response, string, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, "", err + } + s.decorate(req) + resp, err := s.client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + // Cap the read: the inbox bootstrap document is large but not unbounded. + body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, "", err + } + return resp, string(body), nil +} + +func (s *Session) decorate(req *http.Request) { + req.Header.Set("User-Agent", s.UserAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Upgrade-Insecure-Requests", "1") +} + +func cmdValidateSession(args []string) error { + fs := flag.NewFlagSet("validate-session", flag.ExitOnError) + sessionPath := fs.String("session", defaultSessionFile, "path to a cookie string or 'Copy as cURL' blob") + if err := fs.Parse(args); err != nil { + return err + } + + s, err := LoadSession(*sessionPath) + if err != nil { + return err + } + + fmt.Printf("cookies loaded: %d\n", len(s.Cookies)) + for _, want := range []string{"c_user", "xs", "datr", "sb"} { + status := "MISSING" + if s.Has(want) { + status = "present" + } + fmt.Printf(" %-8s %s\n", want, status) + } + if !s.Has("c_user") || !s.Has("xs") { + return fmt.Errorf("c_user and xs are both required for an authenticated session") + } + fmt.Printf("facebook user id: %s\n", s.UserID()) + + target := businessHost + "/latest/inbox/all" + resp, body, err := s.Get(target) + if err != nil { + return fmt.Errorf("request %s: %w", target, err) + } + fmt.Printf("\nGET %s -> %d (%d bytes)\n", target, resp.StatusCode, len(body)) + + final := resp.Request.URL.String() + if !strings.Contains(final, "business.facebook.com") { + fmt.Printf("redirected off the business surface: %s\n", final) + return fmt.Errorf("session did not stay on business.facebook.com (likely logged out or checkpointed)") + } + if strings.Contains(body, "login") && strings.Contains(body, "password") && len(body) < 200_000 { + return fmt.Errorf("response looks like a login page - session is not authenticated") + } + fmt.Printf("final url: %s\n", final) + fmt.Println("\nsession looks authenticated against the Business Suite inbox") + return nil +} diff --git a/cmd/bsprobe/watch.go b/cmd/bsprobe/watch.go new file mode 100644 index 00000000..113f06bd --- /dev/null +++ b/cmd/bsprobe/watch.go @@ -0,0 +1,232 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/coder/websocket" +) + +// watch-events attaches to the realtime endpoint discovered by `bsprobe har` +// and classifies frames as they arrive. This is the live confirmation of the +// Case A / Case B call: HAR cannot decode binary frames, so a capture can only +// suggest MQTT — this proves it. + +// mqttPacketName maps the high nibble of an MQTT fixed header to its control +// packet type. Recognising these in the first byte is strong evidence that +// Business Suite rides the same MQTT transport pkg/messagix/socket speaks. +func mqttPacketName(b byte) string { + switch b >> 4 { + case 1: + return "CONNECT" + case 2: + return "CONNACK" + case 3: + return "PUBLISH" + case 4: + return "PUBACK" + case 8: + return "SUBSCRIBE" + case 9: + return "SUBACK" + case 12: + return "PINGREQ" + case 13: + return "PINGRESP" + case 14: + return "DISCONNECT" + default: + return "" + } +} + +type frameStats struct { + Text int + Binary int + MQTTLike int + Lightspeed int + GraphQLSub int + MarkerCount map[string]int +} + +func cmdWatchEvents(args []string) error { + fs := flag.NewFlagSet("watch-events", flag.ExitOnError) + sessionPath := fs.String("session", defaultSessionFile, "session file") + wsURL := fs.String("url", "", "realtime endpoint (wss://...) as reported by 'bsprobe har'") + duration := fs.Duration("for", 2*time.Minute, "how long to listen") + dump := fs.String("dump-frames", "", "append redacted frames to this file") + maxFrames := fs.Int("max", 200, "stop after this many frames") + if err := fs.Parse(args); err != nil { + return err + } + if *wsURL == "" { + return fmt.Errorf("--url is required\n\n" + + "Run 'bsprobe har ' first; it prints the realtime endpoints it saw.\n" + + "There is no default here on purpose - guessing the endpoint is exactly the\n" + + "kind of assumption this harness exists to avoid.") + } + + s, err := LoadSession(*sessionPath) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), *duration) + defer cancel() + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + header := http.Header{} + header.Set("User-Agent", s.UserAgent) + header.Set("Origin", businessHost) + var cookiePairs []string + for _, c := range s.Cookies { + cookiePairs = append(cookiePairs, c.Name+"="+c.Value) + } + header.Set("Cookie", strings.Join(cookiePairs, "; ")) + + fmt.Printf("dialing %s\n", *wsURL) + conn, resp, err := websocket.Dial(ctx, *wsURL, &websocket.DialOptions{ + HTTPHeader: header, + }) + if err != nil { + if resp != nil { + return fmt.Errorf("dial failed (HTTP %d): %w", resp.StatusCode, err) + } + return fmt.Errorf("dial failed: %w", err) + } + defer conn.Close(websocket.StatusNormalClosure, "") + conn.SetReadLimit(8 << 20) + fmt.Printf("connected, listening for %s (ctrl-c to stop)\n\n", *duration) + + var dumpFile *os.File + if *dump != "" { + if err := os.MkdirAll(filepath.Dir(*dump), 0o755); err != nil { + return err + } + dumpFile, err = os.OpenFile(*dump, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer dumpFile.Close() + } + + stats := &frameStats{MarkerCount: map[string]int{}} + for i := 0; i < *maxFrames; i++ { + typ, data, err := conn.Read(ctx) + if err != nil { + if ctx.Err() != nil { + break + } + return fmt.Errorf("read frame %d: %w", i, err) + } + classifyFrame(stats, typ, data, i, dumpFile) + } + + fmt.Print(renderFrameVerdict(stats)) + return nil +} + +func classifyFrame(stats *frameStats, typ websocket.MessageType, data []byte, idx int, dumpFile *os.File) { + kind := "text" + if typ == websocket.MessageBinary { + kind = "binary" + stats.Binary++ + } else { + stats.Text++ + } + + note := "" + if typ == websocket.MessageBinary && len(data) > 0 { + if name := mqttPacketName(data[0]); name != "" { + stats.MQTTLike++ + note = "mqtt:" + name + } + } + + body := string(data) + for _, marker := range lightspeedMarkers { + if strings.Contains(body, marker) { + stats.MarkerCount[marker]++ + } + } + if len(stats.MarkerCount) >= 2 { + stats.Lightspeed++ + } + if strings.Contains(body, `"topic"`) && strings.Contains(body, `"subscription`) { + stats.GraphQLSub++ + if note == "" { + note = "graphql-subscription-like" + } + } + + preview := RedactString(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + preview = strings.Map(func(r rune) rune { + if r < 32 && r != '\n' { + return '.' + } + return r + }, preview) + + fmt.Printf("[%03d] %-6s %6dB %-28s %s\n", idx, kind, len(data), note, preview) + + if dumpFile != nil { + rec := map[string]any{ + "idx": idx, + "kind": kind, + "bytes": len(data), + "note": note, + "preview": preview, + } + b, _ := json.Marshal(rec) + fmt.Fprintln(dumpFile, string(b)) + } +} + +func renderFrameVerdict(stats *frameStats) string { + var b strings.Builder + fmt.Fprintf(&b, "\n---\nframes: text=%d binary=%d mqtt-like=%d\n", stats.Text, stats.Binary, stats.MQTTLike) + if len(stats.MarkerCount) > 0 { + fmt.Fprintf(&b, "lightspeed markers:\n") + for m, n := range stats.MarkerCount { + fmt.Fprintf(&b, " %-18s %d\n", m, n) + } + } + + switch { + case stats.MQTTLike > 0 && len(stats.MarkerCount) >= 2: + fmt.Fprintf(&b, "\nVERDICT: CASE A - MQTT transport carrying Lightspeed payloads.\n") + fmt.Fprintf(&b, "Reuse pkg/messagix socket, lightspeed, table and syncManager. Add Business\n") + fmt.Fprintf(&b, "Suite app config, sync groups and mailbox identifiers only.\n") + case stats.MQTTLike > 0: + fmt.Fprintf(&b, "\nVERDICT: CASE A LIKELY - MQTT control packets seen, payloads not yet decoded.\n") + fmt.Fprintf(&b, "Next: feed a captured PUBLISH payload through pkg/messagix/lightspeed and see\n") + fmt.Fprintf(&b, "whether it decodes into an LSTable. That is the real confirmation.\n") + case stats.GraphQLSub > 0: + fmt.Fprintf(&b, "\nVERDICT: CASE B - GraphQL-subscription-shaped frames, not Lightspeed.\n") + fmt.Fprintf(&b, "Build pkg/bizsuite/realtime/ with its own decoder. Do not bend messagix.\n") + case stats.Text+stats.Binary == 0: + fmt.Fprintf(&b, "\nVERDICT: INCONCLUSIVE - no frames received.\n") + fmt.Fprintf(&b, "The endpoint may need a subscribe handshake before it sends anything.\n") + default: + fmt.Fprintf(&b, "\nVERDICT: INCONCLUSIVE - frames received but unrecognised.\n") + fmt.Fprintf(&b, "Inspect the dump and extend lightspeedMarkers in har.go.\n") + } + return b.String() +} diff --git a/pkg/connector/businesssuite_filter.go b/pkg/connector/businesssuite_filter.go new file mode 100644 index 00000000..d0d91cb9 --- /dev/null +++ b/pkg/connector/businesssuite_filter.go @@ -0,0 +1,39 @@ +package connector + +import ( + "go.mau.fi/mautrix-meta/pkg/messagix/table" + "go.mau.fi/mautrix-meta/pkg/messagix/types" +) + +const businessSuiteInstagramSyncGroup int64 = 127 + +// rememberBusinessSuiteThreadChannels records the channel identity carried by +// Business Suite's unified snapshot. The sync group alone is not sufficient: +// Meta can return Instagram threads in group 127 and marks them separately with +// deleteThenInsertIgThreadInfo. +func (m *MetaClient) rememberBusinessSuiteThreadChannels(tbl *table.LSTable) { + if m.LoginMeta == nil || m.LoginMeta.Platform != types.BusinessSuite || tbl == nil { + return + } + for _, thread := range tbl.LSDeleteThenInsertThread { + if thread.SyncGroup == businessSuiteInstagramSyncGroup { + m.businessSuiteInstagramThreads.Store(thread.ThreadKey, struct{}{}) + } + } + for _, thread := range tbl.LSUpdateOrInsertThread { + if thread.SyncGroup == businessSuiteInstagramSyncGroup { + m.businessSuiteInstagramThreads.Store(thread.ThreadKey, struct{}{}) + } + } + for _, info := range tbl.LSDeleteThenInsertIgThreadInfo { + m.businessSuiteInstagramThreads.Store(info.ThreadKey, struct{}{}) + } +} + +func (m *MetaClient) shouldIgnoreBusinessSuiteThread(threadKey int64) bool { + if m.LoginMeta == nil || m.LoginMeta.Platform != types.BusinessSuite { + return false + } + _, ignored := m.businessSuiteInstagramThreads.Load(threadKey) + return ignored +} diff --git a/pkg/connector/businesssuite_filter_test.go b/pkg/connector/businesssuite_filter_test.go new file mode 100644 index 00000000..345b5510 --- /dev/null +++ b/pkg/connector/businesssuite_filter_test.go @@ -0,0 +1,53 @@ +package connector + +import ( + "testing" + + "go.mau.fi/mautrix-meta/pkg/messagix/table" + "go.mau.fi/mautrix-meta/pkg/messagix/types" + "go.mau.fi/mautrix-meta/pkg/metaid" +) + +func TestBusinessSuiteMessengerFilterPrefersInstagramMarkers(t *testing.T) { + client := &MetaClient{LoginMeta: &metaid.UserLoginMetadata{Platform: types.BusinessSuite}} + tbl := &table.LSTable{ + LSDeleteThenInsertThread: []*table.LSDeleteThenInsertThread{ + {ThreadKey: 101, SyncGroup: 205}, + {ThreadKey: 202, SyncGroup: 205}, + {ThreadKey: 303, SyncGroup: 127}, + }, + LSDeleteThenInsertIgThreadInfo: []*table.LSDeleteThenInsertIgThreadInfo{ + {ThreadKey: 202, IgThreadId: "instagram-thread"}, + }, + } + + client.rememberBusinessSuiteThreadChannels(tbl) + + if client.shouldIgnoreBusinessSuiteThread(101) { + t.Fatal("Page Messenger thread in group 205 was incorrectly filtered") + } + if !client.shouldIgnoreBusinessSuiteThread(202) { + t.Fatal("Instagram marker did not override the shared Business Suite sync group") + } + if !client.shouldIgnoreBusinessSuiteThread(303) { + t.Fatal("Instagram group-127 thread was not filtered") + } +} + +func TestBusinessSuiteMessengerFilterDoesNotAffectFacebook(t *testing.T) { + client := &MetaClient{LoginMeta: &metaid.UserLoginMetadata{Platform: types.Facebook}} + tbl := &table.LSTable{ + LSDeleteThenInsertThread: []*table.LSDeleteThenInsertThread{ + {ThreadKey: 303, SyncGroup: 205}, + }, + LSDeleteThenInsertIgThreadInfo: []*table.LSDeleteThenInsertIgThreadInfo{ + {ThreadKey: 303, IgThreadId: "instagram-thread"}, + }, + } + + client.rememberBusinessSuiteThreadChannels(tbl) + + if client.shouldIgnoreBusinessSuiteThread(303) { + t.Fatal("non-Business Suite connection was incorrectly filtered") + } +} diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 9accd964..ae82b26d 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -36,15 +36,16 @@ type MetaClient struct { UserLogin *bridgev2.UserLogin Ghost *bridgev2.Ghost - stopHandlingTables atomic.Pointer[context.CancelFunc] - initialTable atomic.Pointer[table.LSTable] - initialTableHandled atomic.Bool - parsedTables chan *parsedTable - backfillCollectors map[int64]*BackfillCollector - backfillLock sync.Mutex - connectLock sync.Mutex - stopConnectAttempt atomic.Pointer[context.CancelFunc] - permanentErrored atomic.Bool + stopHandlingTables atomic.Pointer[context.CancelFunc] + initialTable atomic.Pointer[table.LSTable] + initialTableHandled atomic.Bool + parsedTables chan *parsedTable + backfillCollectors map[int64]*BackfillCollector + backfillLock sync.Mutex + businessSuiteInstagramThreads sync.Map + connectLock sync.Mutex + stopConnectAttempt atomic.Pointer[context.CancelFunc] + permanentErrored atomic.Bool editChannels *exsync.Map[string, chan *FBEditEvent] @@ -144,10 +145,19 @@ func (m *MetaConnector) getProxy(reason string) (string, error) { func (m *MetaClient) ensureMessagixClient() { if m.LoginMeta.Cookies != nil && m.Client == nil { m.LoginMeta.Cookies.Platform = m.LoginMeta.Platform + config := m.Main.getMessagixConfig() + if m.LoginMeta.Platform == types.BusinessSuite { + config.BusinessSuite = &messagix.BusinessSuiteContext{ + BusinessID: m.LoginMeta.BusinessID, + AssetID: m.LoginMeta.AssetID, + PageID: m.LoginMeta.PageID, + PageName: m.LoginMeta.ActorName, + } + } m.Client = messagix.NewClient( m.LoginMeta.Cookies, m.UserLogin.Log.With().Str("component", "messagix").Logger(), - m.Main.getMessagixConfig(), + config, ) m.Client.SetEventHandler(m.handleMetaEvent) } diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 4b9f1c12..05cb7ca4 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -24,6 +24,7 @@ type Config struct { AllowedModes []types.Platform `yaml:"-"` AllowMessengerComOnFB bool `yaml:"allow_messenger_com_on_fb"` + RequirePageSelection bool `yaml:"require_page_selection"` Proxy string `yaml:"proxy"` GetProxyFrom string `yaml:"get_proxy_from"` @@ -89,6 +90,7 @@ func (c *Config) PostProcess() (err error) { func upgradeConfig(helper up.Helper) { helper.Copy(up.Str, "mode") helper.Copy(up.Bool, "allow_messenger_com_on_fb") + helper.Copy(up.Bool, "require_page_selection") helper.Copy(up.List, "allowed_modes") helper.Copy(up.Str, "displayname_template") helper.Copy(up.Str|up.Null, "proxy") diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml index cd58639c..5792fcaa 100644 --- a/pkg/connector/example-config.yaml +++ b/pkg/connector/example-config.yaml @@ -10,6 +10,8 @@ mode: # Should users be allowed to pick messenger.com login when mode is set to `facebook`? allow_messenger_com_on_fb: false +# Require users to choose a managed Facebook Page instead of bridging their personal inbox. +require_page_selection: false # Explicit list of allowed login methods. Overrides other login method options if non-empty. allowed_modes: [] diff --git a/pkg/connector/handlemeta.go b/pkg/connector/handlemeta.go index be8c1cd9..81a1f288 100644 --- a/pkg/connector/handlemeta.go +++ b/pkg/connector/handlemeta.go @@ -231,21 +231,34 @@ func (m *MetaClient) syncGhost(ctx context.Context, info types.UserInfo) { } func (m *MetaClient) parseTable(ctx context.Context, tbl *table.LSTable) (innerQueue []bridgev2.RemoteEvent) { + m.rememberBusinessSuiteThreadChannels(tbl) threadExists := make(map[int64]*table.LSVerifyThreadExists, len(tbl.LSVerifyThreadExists)) threadResyncs := make(map[int64]*FBChatResync, len(tbl.LSDeleteThenInsertThread)) folderResyncs := make(map[int64]*FBFolderResync, len(tbl.LSUpsertFolder)) waThreadMap := make(map[int64]int64, len(tbl.LSVerifyHybridThreadExists)) activeThreads := make(exmaps.Set[int64]) for _, vte := range tbl.LSVerifyThreadExists { + if m.shouldIgnoreBusinessSuiteThread(vte.ThreadKey) { + continue + } activeThreads.Add(vte.ThreadKey) } for _, uoi := range tbl.LSUpdateOrInsertThread { + if m.shouldIgnoreBusinessSuiteThread(uoi.ThreadKey) { + continue + } activeThreads.Add(uoi.ThreadKey) } for _, inr := range tbl.LSInsertNewMessageRange { + if m.shouldIgnoreBusinessSuiteThread(inr.ThreadKey) { + continue + } activeThreads.Add(inr.ThreadKey) } for _, dit := range tbl.LSDeleteThenInsertThread { + if m.shouldIgnoreBusinessSuiteThread(dit.ThreadKey) { + continue + } activeThreads.Add(dit.ThreadKey) } innerQueue = make([]bridgev2.RemoteEvent, 0, 8) @@ -282,6 +295,9 @@ func (m *MetaClient) parseTable(ctx context.Context, tbl *table.LSTable) (innerQ } for _, thread := range tbl.LSVerifyThreadExists { + if m.shouldIgnoreBusinessSuiteThread(thread.ThreadKey) { + continue + } threadExists[thread.ThreadKey] = thread } for _, folder := range tbl.LSUpsertFolder { @@ -294,6 +310,9 @@ func (m *MetaClient) parseTable(ctx context.Context, tbl *table.LSTable) (innerQ innerQueue = append(innerQueue, rs) } for _, thread := range tbl.LSDeleteThenInsertThread { + if m.shouldIgnoreBusinessSuiteThread(thread.ThreadKey) { + continue + } fbKey := thread.ThreadKey thread.ThreadKey = params.MapWhatsAppThreadKey(thread.ThreadKey) info := m.wrapChatInfo(thread) @@ -314,6 +333,9 @@ func (m *MetaClient) parseTable(ctx context.Context, tbl *table.LSTable) (innerQ } } for _, thread := range tbl.LSUpdateOrInsertThread { + if m.shouldIgnoreBusinessSuiteThread(thread.ThreadKey) { + continue + } fbKey := thread.ThreadKey thread.ThreadKey = params.MapWhatsAppThreadKey(thread.ThreadKey) if _, ok := threadResyncs[thread.ThreadKey]; ok { @@ -386,6 +408,9 @@ func (m *MetaClient) parseTable(ctx context.Context, tbl *table.LSTable) (innerQ // TODO request more inbox if applicable for _, igThread := range tbl.LSDeleteThenInsertIgThreadInfo { + if m.shouldIgnoreBusinessSuiteThread(igThread.ThreadKey) { + continue + } err := m.Main.DB.PutFBIDForIGThread(ctx, igThread.IgThreadId, igThread.ThreadKey, m.UserLogin.ID) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to save FBID for IG thread") @@ -812,6 +837,9 @@ func collectPortalEvents[T ThreadKeyable]( ) { for _, msg := range msgs { threadKey := p.MapWhatsAppThreadKey(msg.GetThreadKey()) + if p.m.shouldIgnoreBusinessSuiteThread(threadKey) { + continue + } sync, syncOK := p.syncs[threadKey] v, ok := p.vtes[threadKey] var threadType table.ThreadType diff --git a/pkg/connector/ids.go b/pkg/connector/ids.go index 4dcddc2b..d3cc91c5 100644 --- a/pkg/connector/ids.go +++ b/pkg/connector/ids.go @@ -6,6 +6,7 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "go.mau.fi/mautrix-meta/pkg/messagix/table" + messagixtypes "go.mau.fi/mautrix-meta/pkg/messagix/types" "go.mau.fi/mautrix-meta/pkg/metaid" ) @@ -41,8 +42,12 @@ func (m *MetaClient) makeWAPortalKey(chatJID types.JID) networkid.PortalKey { func (m *MetaClient) makeFBPortalKey(threadID int64, threadType table.ThreadType) networkid.PortalKey { key := networkid.PortalKey{ID: metaid.MakeFBPortalID(threadID)} - if m.Main.Bridge.Config.SplitPortals || threadType == table.UNKNOWN_THREAD_TYPE || threadType.IsOneToOne() { + if shouldScopeFBPortal(m.LoginMeta.Platform, m.Main.Bridge.Config.SplitPortals, threadType) { key.Receiver = m.UserLogin.ID } return key } + +func shouldScopeFBPortal(platform messagixtypes.Platform, splitPortals bool, threadType table.ThreadType) bool { + return platform == messagixtypes.BusinessSuite || splitPortals || threadType == table.UNKNOWN_THREAD_TYPE || threadType.IsOneToOne() +} diff --git a/pkg/connector/ids_test.go b/pkg/connector/ids_test.go new file mode 100644 index 00000000..1419ccf8 --- /dev/null +++ b/pkg/connector/ids_test.go @@ -0,0 +1,17 @@ +package connector + +import ( + "testing" + + "go.mau.fi/mautrix-meta/pkg/messagix/table" + "go.mau.fi/mautrix-meta/pkg/messagix/types" +) + +func TestBusinessSuiteAlwaysScopesPortalsToTheSelectedPage(t *testing.T) { + if !shouldScopeFBPortal(types.BusinessSuite, false, table.GROUP_THREAD) { + t.Fatal("Business Suite group-like threads must remain scoped to the selected Page") + } + if shouldScopeFBPortal(types.Facebook, false, table.GROUP_THREAD) { + t.Fatal("personal shared portals should retain the existing behavior") + } +} diff --git a/pkg/connector/login.go b/pkg/connector/login.go index cde662ac..7d01199b 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -7,6 +7,7 @@ import ( "maps" "net/http" "slices" + "strconv" "time" "github.com/rs/zerolog" @@ -20,6 +21,7 @@ import ( "go.mau.fi/mautrix-meta/pkg/messagix/bloks" "go.mau.fi/mautrix-meta/pkg/messagix/cookies" "go.mau.fi/mautrix-meta/pkg/messagix/httpclient" + "go.mau.fi/mautrix-meta/pkg/messagix/table" "go.mau.fi/mautrix-meta/pkg/messagix/types" "go.mau.fi/mautrix-meta/pkg/messagix/useragent" "go.mau.fi/mautrix-meta/pkg/metaid" @@ -32,6 +34,7 @@ const ( FlowIDMessengerLiteAndroid = "messenger-lite-android" LoginStepIDCookies = "fi.mau.meta.cookies" + LoginStepIDProfile = "fi.mau.meta.facebook_profile" LoginStepIDComplete = "fi.mau.meta.complete" LoginStepIDCredentials = "fi.mau.meta.credentials" @@ -89,7 +92,9 @@ func (m *MetaConnector) CreateUserLoginFromCredentials(ctx context.Context, user return err } - step, err := login.(bridgev2.LoginProcessCookies).SubmitCookies(ctx, cleanCreds) + cookieLogin := login.(*MetaCookieLogin) + cookieLogin.SkipProfileSelection = true + step, err := cookieLogin.SubmitCookies(ctx, cleanCreds) if err != nil { return err } else if step.Type != bridgev2.LoginStepTypeComplete { @@ -168,9 +173,17 @@ type MetaCookieLogin struct { Mode types.Platform User *bridgev2.User Main *MetaConnector + + SavedClient *messagix.Client + SavedCookies *cookies.Cookies + SavedUser types.UserInfo + SavedTable *table.LSTable + ProfileByOption map[string]*types.SwitchableProfile + SkipProfileSelection bool } var _ bridgev2.LoginProcessCookies = (*MetaCookieLogin)(nil) +var _ bridgev2.LoginProcessUserInput = (*MetaCookieLogin)(nil) func cookieListToFields(cookies []cookies.MetaCookieName, domain string) []bridgev2.LoginCookieField { fields := make([]bridgev2.LoginCookieField, len(cookies)) @@ -214,7 +227,13 @@ func (m *MetaCookieLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error return step, nil } -func (m *MetaCookieLogin) Cancel() {} +func (m *MetaCookieLogin) Cancel() { + m.SavedClient = nil + m.SavedCookies = nil + m.SavedUser = nil + m.SavedTable = nil + m.ProfileByOption = nil +} var ( ErrLoginMissingCookies = bridgev2.RespError{ErrCode: "FI.MAU.META_MISSING_COOKIES", Err: "Missing cookies", StatusCode: http.StatusBadRequest} @@ -222,6 +241,9 @@ var ( ErrLoginConsent = bridgev2.RespError{ErrCode: "FI.MAU.META_CONSENT_ERROR", Err: "Consent required, please check the official website or app and then try again", StatusCode: http.StatusBadRequest} ErrLoginCheckpoint = bridgev2.RespError{ErrCode: "FI.MAU.META_CHECKPOINT_ERROR", Err: "Checkpoint required, please check the official website or app and then try again", StatusCode: http.StatusBadRequest} ErrLoginTokenInvalidated = bridgev2.RespError{ErrCode: "FI.MAU.META_TOKEN_ERROR", Err: "Got logged out immediately", StatusCode: http.StatusBadRequest} + ErrLoginProfileInvalid = bridgev2.RespError{ErrCode: "FI.MAU.META_PROFILE_INVALID", Err: "That Facebook Page is not available to this account", StatusCode: http.StatusBadRequest} + ErrLoginProfileSwitch = bridgev2.RespError{ErrCode: "FI.MAU.META_PROFILE_SWITCH_ERROR", Err: "Facebook did not activate the selected Page", StatusCode: http.StatusBadRequest} + ErrLoginNoPages = bridgev2.RespError{ErrCode: "FI.MAU.META_NO_PAGES", Err: "No managed Facebook Pages were found for this account", StatusCode: http.StatusBadRequest} ErrLoginUnknown = bridgev2.RespError{ErrCode: "M_UNKNOWN", Err: "Internal error logging in", StatusCode: http.StatusInternalServerError} ) @@ -236,35 +258,60 @@ func getMessagixClient(log zerolog.Logger, conn *MetaConnector, c *cookies.Cooki return client, nil } -func loginWithCookies( +func loadLoginWithCookies( ctx context.Context, log zerolog.Logger, client *messagix.Client, - bridgeUser *bridgev2.User, - conn *MetaConnector, - c *cookies.Cookies, -) (*bridgev2.LoginStep, error) { +) (types.UserInfo, *table.LSTable, error) { log.Debug(). - Strs("cookie_names", exslices.CastToString[string](slices.Collect(maps.Keys(c.GetAll())))). + Strs("cookie_names", exslices.CastToString[string](slices.Collect(maps.Keys(client.GetCookies().GetAll())))). Msg("Logging in with cookies") user, tbl, err := client.LoadMessagesPage(ctx) if err != nil { log.Err(err).Msg("Failed to load messages page for login") if errors.Is(err, httpclient.ErrChallengeRequired) { - return nil, ErrLoginChallenge + return nil, nil, ErrLoginChallenge } else if errors.Is(err, httpclient.ErrCheckpointRequired) { - return nil, ErrLoginCheckpoint + return nil, nil, ErrLoginCheckpoint } else if errors.Is(err, httpclient.ErrConsentRequired) { - return nil, ErrLoginConsent + return nil, nil, ErrLoginConsent } else if errors.Is(err, httpclient.ErrTokenInvalidated) { - return nil, ErrLoginTokenInvalidated + return nil, nil, ErrLoginTokenInvalidated } else { - return nil, fmt.Errorf("%w: %w", ErrLoginUnknown, err) + return nil, nil, fmt.Errorf("%w: %w", ErrLoginUnknown, err) } } + return user, tbl, nil +} + +func completeLoginWithCookies( + ctx context.Context, + client *messagix.Client, + user types.UserInfo, + tbl *table.LSTable, + bridgeUser *bridgev2.User, + conn *MetaConnector, + c *cookies.Cookies, + selectedProfile *types.SwitchableProfile, +) (*bridgev2.LoginStep, error) { id := user.GetFBID() + remoteName := user.GetName() + var businessSuite *messagix.BusinessSuiteContext + if client.Platform == types.BusinessSuite { + businessSuite = client.GetBusinessSuiteContext() + if businessSuite == nil { + return nil, fmt.Errorf("Business Suite Page context is missing") + } + pageID, parseErr := strconv.ParseInt(businessSuite.PageID, 10, 64) + if parseErr != nil { + return nil, fmt.Errorf("invalid Business Suite Page ID: %w", parseErr) + } + id = pageID + remoteName = businessSuite.PageName + } + loginID := metaid.MakeUserLoginID(id) var loginUA string if req, ok := ctx.Value("fi.mau.provision.request").(*http.Request); ok { @@ -273,14 +320,40 @@ func loginWithCookies( ul, err := bridgeUser.NewLogin(ctx, &database.UserLogin{ ID: loginID, - RemoteName: user.GetName(), + RemoteName: remoteName, RemoteProfile: status.RemoteProfile{ - Name: user.GetName(), + Name: remoteName, }, Metadata: &metaid.UserLoginMetadata{ - Platform: c.Platform, - Cookies: c, - LoginUA: loginUA, + Platform: c.Platform, + Cookies: c, + LoginUA: loginUA, + ActorID: id, + ActorName: remoteName, + ActorType: func() string { + if selectedProfile != nil { + return "facebook_page" + } + return "personal" + }(), + BusinessID: func() string { + if businessSuite != nil { + return businessSuite.BusinessID + } + return "" + }(), + AssetID: func() string { + if businessSuite != nil { + return businessSuite.AssetID + } + return "" + }(), + PageID: func() string { + if businessSuite != nil { + return businessSuite.PageID + } + return "" + }(), }, }, nil) if err != nil { @@ -300,7 +373,7 @@ func loginWithCookies( return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeComplete, StepID: LoginStepIDComplete, - Instructions: fmt.Sprintf("Logged in as %s (%d)", user.GetName(), id), + Instructions: fmt.Sprintf("Logged in as %s (%d) · %s", remoteName, id, map[bool]string{true: "Facebook Page", false: "Personal profile"}[selectedProfile != nil]), CompleteParams: &bridgev2.LoginCompleteParams{ UserLoginID: ul.ID, UserLogin: ul, @@ -326,7 +399,115 @@ func (m *MetaCookieLogin) SubmitCookies(ctx context.Context, strCookies map[stri if err != nil { return nil, err } - return loginWithCookies(ctx, log, client, m.User, m.Main, c) + user, tbl, err := loadLoginWithCookies(ctx, log, client) + if err != nil { + return nil, err + } + + profiles := client.GetSwitchableProfiles() + requirePage := m.Main.Config.RequirePageSelection && !m.SkipProfileSelection && m.Mode.IsMessenger() && c.Get(cookies.FBCookieIUser) == "" + if requirePage && len(profiles) == 0 { + if err = client.DiscoverSwitchableProfiles(ctx); err != nil { + return nil, fmt.Errorf("%w: %w", ErrLoginNoPages, err) + } + profiles = client.GetSwitchableProfiles() + } + if requirePage && len(profiles) == 0 { + return nil, ErrLoginNoPages + } + if !m.SkipProfileSelection && m.Mode.IsMessenger() && c.Get(cookies.FBCookieIUser) == "" && len(profiles) > 0 { + m.SavedClient = client + m.SavedCookies = c + m.SavedUser = user + m.SavedTable = tbl + m.ProfileByOption = make(map[string]*types.SwitchableProfile, len(profiles)+1) + + options := make([]string, 0, len(profiles)+1) + if !m.Main.Config.RequirePageSelection { + personalOption := fmt.Sprintf("Personal inbox - %s [%d]", user.GetName(), user.GetFBID()) + m.ProfileByOption[personalOption] = nil + options = append(options, personalOption) + } + for i := range profiles { + profile := &profiles[i] + if profile.ID == fmt.Sprint(user.GetFBID()) { + continue + } + option := fmt.Sprintf("%s [%s]", profile.Name, profile.ID) + m.ProfileByOption[option] = profile + options = append(options, option) + } + if len(options) == 0 { + return nil, ErrLoginNoPages + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepIDProfile, + Instructions: "Choose the Facebook Page whose messages you want to bridge.", + UserInputParams: &bridgev2.LoginUserInputParams{Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypeSelect, + ID: "facebook_profile", + Name: "Facebook Page", + Description: "Pages are read from Facebook's signed-in profile switcher.", + Options: options, + Validate: func(value string) (string, error) { + if _, ok := m.ProfileByOption[value]; !ok { + return "", ErrLoginProfileInvalid + } + return value, nil + }, + }}}, + }, nil + } + + var selectedProfile *types.SwitchableProfile + if actorID := c.Get(cookies.FBCookieIUser); actorID != "" { + for i := range profiles { + if profiles[i].ID == actorID { + selectedProfile = &profiles[i] + break + } + } + } + return completeLoginWithCookies(ctx, client, user, tbl, m.User, m.Main, c, selectedProfile) +} + +func (m *MetaCookieLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + if m.SavedCookies == nil || m.SavedClient == nil || m.SavedUser == nil || m.SavedTable == nil { + return nil, ErrLoginProfileInvalid + } + option := input["facebook_profile"] + profile, ok := m.ProfileByOption[option] + if !ok { + return nil, ErrLoginProfileInvalid + } + if profile == nil { + m.SavedCookies.Delete(cookies.FBCookieIUser) + return completeLoginWithCookies(ctx, m.SavedClient, m.SavedUser, m.SavedTable, m.User, m.Main, m.SavedCookies, nil) + } + + log := m.User.Log.With().Str("component", "messagix").Str("facebook_actor_id", profile.ID).Logger() + pageContext, err := m.SavedClient.ResolveBusinessSuitePage(ctx, *profile) + if err != nil { + return nil, err + } + pageCookies := &cookies.Cookies{Platform: types.BusinessSuite} + pageCookies.UpdateValues(m.SavedCookies.GetAll()) + pageCookies.Delete(cookies.FBCookieIUser) + config := m.Main.getMessagixConfig() + config.BusinessSuite = pageContext + client := messagix.NewClient(pageCookies, log, config) + if m.Main.Config.ProxyOther && (m.Main.Config.GetProxyFrom != "" || m.Main.Config.Proxy != "") { + client.GetHTTP().GetNewProxy = m.Main.getProxy + if !client.GetHTTP().UpdateProxy("login") { + return nil, fmt.Errorf("failed to update proxy") + } + } + user, tbl, err := loadLoginWithCookies(ctx, log, client) + if err != nil { + return nil, err + } + return completeLoginWithCookies(ctx, client, user, tbl, m.User, m.Main, pageCookies, profile) } type MetaNativeLogin struct { @@ -390,7 +571,11 @@ func (m *MetaNativeLogin) proceed(ctx context.Context, userInput map[string]stri newClient.GetCookies().UpdateValues(newCookies.GetAll()) - step, err = loginWithCookies(ctx, log, newClient, m.User, m.Main, newCookies) + user, tbl, err := loadLoginWithCookies(ctx, log, newClient) + if err != nil { + return nil, err + } + step, err = completeLoginWithCookies(ctx, newClient, user, tbl, m.User, m.Main, newCookies, nil) if err != nil { return nil, err } diff --git a/pkg/messagix/businesssuite_socket.go b/pkg/messagix/businesssuite_socket.go new file mode 100644 index 00000000..dad54e58 --- /dev/null +++ b/pkg/messagix/businesssuite_socket.go @@ -0,0 +1,375 @@ +package messagix + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/coder/websocket" + "go.mau.fi/util/exsync" + + "go.mau.fi/mautrix-meta/pkg/messagix/useragent" +) + +// Business Suite still delivers Page Lightspeed traffic over Meta's MQTT 3.1 +// websocket. Personal Messenger moved to DGW; Pages have not. Keeping this +// transport isolated prevents Page semantics from leaking into the DGW client. +type businessSuiteSocket struct { + client *Client + conn atomic.Pointer[websocket.Conn] + writeLock sync.Mutex + nextPacket atomic.Uint32 + waiters *exsync.Map[int64, chan *PublishResponseData] + connAck *exsync.Event + packetAcks *exsync.Map[uint16, chan byte] +} + +func newBusinessSuiteSocket(client *Client) *businessSuiteSocket { + return &businessSuiteSocket{ + client: client, + waiters: exsync.NewMap[int64, chan *PublishResponseData](), + connAck: exsync.NewEvent(), + packetAcks: exsync.NewMap[uint16, chan byte](), + } +} + +func (s *businessSuiteSocket) isConnected() bool { return s != nil && s.conn.Load() != nil } + +func (s *businessSuiteSocket) packetID() uint16 { + id := uint16(s.nextPacket.Add(1)) + if id == 0 { + id = uint16(s.nextPacket.Add(1)) + } + return id +} + +func appendMQTTString(dst []byte, value string) []byte { + dst = binary.BigEndian.AppendUint16(dst, uint16(len(value))) + return append(dst, value...) +} + +func appendRemainingLength(dst []byte, value int) []byte { + for { + encoded := byte(value % 128) + value /= 128 + if value > 0 { + encoded |= 128 + } + dst = append(dst, encoded) + if value == 0 { + return dst + } + } +} + +func mqttPacket(header byte, body []byte) []byte { + out := appendRemainingLength([]byte{header}, len(body)) + return append(out, body...) +} + +func (s *businessSuiteSocket) write(ctx context.Context, packet []byte) error { + s.writeLock.Lock() + defer s.writeLock.Unlock() + conn := s.conn.Load() + if conn == nil { + return errors.New("Business Suite socket is closed") + } + writeCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + return conn.Write(writeCtx, websocket.MessageBinary, packet) +} + +func (s *businessSuiteSocket) connectPacket() ([]byte, error) { + config := s.client.configs.BrowserConfigTable.MqttWebConfig + deviceID := s.client.configs.BrowserConfigTable.MqttWebDeviceID.ClientID + username, err := json.Marshal(map[string]any{ + "u": s.client.configs.BrowserConfigTable.CurrentUserInitialData.AccountID, + "s": time.Now().UnixNano(), "cp": config.ClientCapabilities, + "ecp": config.Capabilities, "chat_on": config.ChatVisibility, + "fg": false, "d": deviceID, "ct": "websocket", "mqtt_sid": "", + "aid": config.AppID, "st": config.SubscribedTopics, "pm": []any{}, + "dc": "", "no_auto_fg": true, "gas": nil, "pack": []any{}, + "php_override": config.HostNameOverride, "p": nil, + "a": useragent.UserAgent, "aids": nil, + }) + if err != nil { + return nil, err + } + body := appendMQTTString(nil, "MQIsdp") + body = append(body, 3, 0x82) + body = binary.BigEndian.AppendUint16(body, 15) + body = appendMQTTString(body, "mqttwsclient") + body = appendMQTTString(body, string(username)) + return mqttPacket(0x10, body), nil +} + +// isTrustedMQTTBrokerHost restricts the server-supplied MQTT broker endpoint +// to Meta-owned hosts. The endpoint is read out of live page config, and the +// dial in Connect attaches the full session cookie jar, so an unvalidated +// host here would let a tampered config value exfiltrate the user's session. +func isTrustedMQTTBrokerHost(host string) bool { + host = strings.ToLower(host) + return host == "facebook.com" || strings.HasSuffix(host, ".facebook.com") +} + +func (s *businessSuiteSocket) brokerURL() (string, error) { + raw := s.client.configs.BrowserConfigTable.MqttWebConfig.Endpoint + if raw == "" { + return "", errors.New("Business Suite MQTT endpoint is missing") + } + u, err := url.Parse(raw) + if err != nil { + return "", err + } + if u.Scheme != "wss" { + return "", fmt.Errorf("refusing to dial Business Suite MQTT endpoint with scheme %q", u.Scheme) + } + if !isTrustedMQTTBrokerHost(u.Hostname()) { + return "", fmt.Errorf("refusing to dial untrusted Business Suite MQTT host %q", u.Hostname()) + } + q := u.Query() + q.Set("sid", strconv.FormatInt(time.Now().UnixNano(), 10)) + q.Set("cid", s.client.configs.BrowserConfigTable.MqttWebDeviceID.ClientID) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (s *businessSuiteSocket) Connect(ctx context.Context) error { + broker, err := s.brokerURL() + if err != nil { + return err + } + dialOpts := *s.client.http.GetWebsocketDialer() + dialOpts.HTTPHeader = http.Header{ + "cookie": {s.client.cookies.String()}, "user-agent": {useragent.UserAgent}, + "origin": {s.client.GetEndpoint("base_url")}, + "sec-fetch-dest": {"empty"}, "sec-fetch-mode": {"websocket"}, + "sec-fetch-site": {"same-site"}, + } + conn, resp, err := websocket.Dial(ctx, broker, &dialOpts) + if err != nil { + if resp != nil { + return fmt.Errorf("Business Suite MQTT dial failed (%d): %w", resp.StatusCode, err) + } + return fmt.Errorf("Business Suite MQTT dial failed: %w", err) + } + conn.SetReadLimit(-1) + s.conn.Store(conn) + defer s.conn.Store(nil) + s.connAck.Clear() + errCh := make(chan error, 1) + go func() { errCh <- s.readLoop(ctx, conn) }() + packet, err := s.connectPacket() + if err == nil { + err = s.write(ctx, packet) + } + if err != nil { + _ = conn.CloseNow() + return err + } + select { + case <-s.connAck.GetChan(): + case err = <-errCh: + return err + case <-time.After(10 * time.Second): + return errors.New("Business Suite MQTT connection acknowledgement timed out") + case <-ctx.Done(): + return ctx.Err() + } + if err = s.subscribe(ctx, "/ls_foreground_state"); err != nil { + return err + } + if err = s.subscribe(ctx, "/ls_resp"); err != nil { + return err + } + settings, _ := json.Marshal(map[string]string{"ls_fdid": "", "ls_sv": strconv.FormatInt(s.client.configs.VersionID, 10)}) + if err = s.publish(ctx, "/ls_app_settings", settings, 0); err != nil { + return err + } + if err = s.client.onSocketConnect(ctx); err != nil { + return err + } + return <-errCh +} + +func (s *businessSuiteSocket) Disconnect() { + if conn := s.conn.Load(); conn != nil { + _ = conn.Close(websocket.StatusNormalClosure, "") + } +} + +func (s *businessSuiteSocket) ForceReconnect() { + if conn := s.conn.Load(); conn != nil { + _ = conn.CloseNow() + } +} + +func (s *businessSuiteSocket) subscribe(ctx context.Context, topic string) error { + id := s.packetID() + body := binary.BigEndian.AppendUint16(nil, id) + body = appendMQTTString(body, topic) + body = append(body, 0) + return s.writeAndWaitAck(ctx, mqttPacket(0x82, body), id, 9) +} + +func (s *businessSuiteSocket) publish(ctx context.Context, topic string, payload []byte, requestID int64) error { + id := s.packetID() + body := appendMQTTString(nil, topic) + body = binary.BigEndian.AppendUint16(body, id) + body = append(body, payload...) + if err := s.writeAndWaitAck(ctx, mqttPacket(0x32, body), id, 4); err != nil { + return err + } + return nil +} + +func (s *businessSuiteSocket) writeAndWaitAck(ctx context.Context, packet []byte, id uint16, packetType byte) error { + ack := make(chan byte, 1) + s.packetAcks.Set(id, ack) + defer s.packetAcks.Delete(id) + if err := s.write(ctx, packet); err != nil { + return err + } + select { + case got := <-ack: + if got != packetType { + return fmt.Errorf("unexpected MQTT acknowledgement %d", got) + } + return nil + case <-time.After(15 * time.Second): + return errors.New("Business Suite MQTT acknowledgement timed out") + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *businessSuiteSocket) request(ctx context.Context, payload []byte, requestID int64) (*PublishResponseData, error) { + waiter := make(chan *PublishResponseData, 1) + s.waiters.Set(requestID, waiter) + defer s.waiters.Delete(requestID) + if err := s.publish(ctx, "/ls_req", payload, requestID); err != nil { + return nil, err + } + select { + case response := <-waiter: + return response, nil + case <-time.After(30 * time.Second): + return nil, errors.New("Business Suite Lightspeed response timed out") + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func decodeRemainingLength(packet []byte) (value, offset int, err error) { + multiplier := 1 + for i := 1; i < len(packet) && i <= 4; i++ { + value += int(packet[i]&127) * multiplier + if packet[i]&128 == 0 { + return value, i + 1, nil + } + multiplier *= 128 + } + return 0, 0, errors.New("invalid MQTT remaining length") +} + +func (s *businessSuiteSocket) readLoop(ctx context.Context, conn *websocket.Conn) error { + ping := time.NewTicker(10 * time.Second) + defer ping.Stop() + go func() { + for { + select { + case <-ping.C: + _ = s.write(ctx, []byte{0xC0, 0}) + case <-ctx.Done(): + return + } + } + }() + for { + messageType, packet, err := conn.Read(ctx) + if err != nil { + return err + } + if messageType != websocket.MessageBinary || len(packet) < 2 { + continue + } + packetType := packet[0] >> 4 + _, offset, err := decodeRemainingLength(packet) + if err != nil || offset > len(packet) { + return err + } + switch packetType { + case 2: + if len(packet) < offset+2 || packet[offset+1] != 0 { + return errors.New("Business Suite MQTT connection rejected") + } + s.connAck.Set() + case 4, 9: + if len(packet) >= offset+2 { + id := binary.BigEndian.Uint16(packet[offset : offset+2]) + if ch, ok := s.packetAcks.Pop(id); ok { + ch <- packetType + } + } + case 3: + if err := s.handlePublish(ctx, packet, offset); err != nil { + return err + } + case 13: + // PINGRESP + default: + s.client.Logger.Debug().Int("packet_type", int(packetType)).Msg("Ignoring unsupported Business Suite MQTT packet") + } + } +} + +func (s *businessSuiteSocket) handlePublish(ctx context.Context, packet []byte, offset int) error { + if len(packet) < offset+2 { + return errors.New("short MQTT publish") + } + topicLength := int(binary.BigEndian.Uint16(packet[offset : offset+2])) + start := offset + 2 + if len(packet) < start+topicLength { + return errors.New("short MQTT topic") + } + topic := string(packet[start : start+topicLength]) + start += topicLength + qos := (packet[0] >> 1) & 3 + var messageID uint16 + if qos > 0 { + if len(packet) < start+2 { + return errors.New("short MQTT message ID") + } + messageID = binary.BigEndian.Uint16(packet[start : start+2]) + start += 2 + } + if qos == 1 { + _ = s.write(ctx, mqttPacket(0x40, binary.BigEndian.AppendUint16(nil, messageID))) + } + if topic != "/ls_resp" { + return nil + } + var response PublishResponseData + if err := json.Unmarshal(packet[start:], &response); err != nil { + return fmt.Errorf("invalid Business Suite Lightspeed response: %w", err) + } + if waiter, ok := s.waiters.Pop(response.RequestID); ok { + waiter <- &response + return nil + } + table, err := response.Parse(ctx) + if err != nil { + return err + } + s.client.HandleEvent(ctx, table) + return nil +} diff --git a/pkg/messagix/businesssuite_socket_test.go b/pkg/messagix/businesssuite_socket_test.go new file mode 100644 index 00000000..081d7257 --- /dev/null +++ b/pkg/messagix/businesssuite_socket_test.go @@ -0,0 +1,29 @@ +package messagix + +import "testing" + +func TestIsTrustedMQTTBrokerHost(t *testing.T) { + trusted := []string{ + "facebook.com", + "FACEBOOK.COM", + "gateway.facebook.com", + "edge-chat.facebook.com", + } + for _, host := range trusted { + if !isTrustedMQTTBrokerHost(host) { + t.Errorf("expected %q to be trusted", host) + } + } + + untrusted := []string{ + "evil.com", + "facebook.com.evil.com", + "notfacebook.com", + "", + } + for _, host := range untrusted { + if isTrustedMQTTBrokerHost(host) { + t.Errorf("expected %q to be untrusted", host) + } + } +} diff --git a/pkg/messagix/businesssuite_sync_test.go b/pkg/messagix/businesssuite_sync_test.go new file mode 100644 index 00000000..d353b40d --- /dev/null +++ b/pkg/messagix/businesssuite_sync_test.go @@ -0,0 +1,80 @@ +package messagix + +import ( + "context" + "reflect" + "testing" + + "go.mau.fi/mautrix-meta/pkg/messagix/socket" + "go.mau.fi/mautrix-meta/pkg/messagix/table" + "go.mau.fi/mautrix-meta/pkg/messagix/types" +) + +func TestBusinessSuiteInitialSyncNeverUsesPersonalMailboxDatabases(t *testing.T) { + got := initialSyncDatabases(types.BusinessSuite) + want := []int64{2, 26} + if !reflect.DeepEqual(got, want) { + t.Fatalf("initial databases = %v, want %v", got, want) + } + for _, database := range got { + if database == 1 || database == 95 { + t.Fatalf("personal mailbox database %d must not be used by Business Suite", database) + } + } +} + +func TestRequestedBusinessSuiteThreadResponseIsDispatched(t *testing.T) { + client := &Client{} + var received any + client.SetEventHandler(func(_ context.Context, event any) { + received = event + }) + response := &PublishResponseData{Payload: `{"name":null,"step":[1]}`} + + if err := client.dispatchRequestedTable(context.Background(), response); err != nil { + t.Fatalf("dispatch response: %v", err) + } + if _, ok := received.(*table.LSTable); !ok { + t.Fatalf("received event type %T, want *table.LSTable", received) + } +} + +func TestBusinessSuiteDoesNotUseGenericMessengerGraphQLBootstrap(t *testing.T) { + if shouldBootstrapViaGraphQL(types.BusinessSuite) { + t.Fatal("Business Suite must not bootstrap personal Messenger databases through GraphQL") + } + if !shouldBootstrapViaGraphQL(types.Facebook) { + t.Fatal("Facebook should retain its existing GraphQL bootstrap") + } +} + +func TestBusinessSuiteInitialThreadTasksMatchCapturedPageInboxProtocol(t *testing.T) { + tasks := businessSuiteInitialThreadTasks("messenger-cursor") + if len(tasks) != 1 { + t.Fatalf("task count = %d, want 1 Messenger-only task", len(tasks)) + } + want := []struct { + group int + secondary int + cursor string + }{{205, 24, "messenger-cursor"}} + for i, task := range tasks { + if task.GetLabel() != socket.BusinessInboxFetchThreadsLabel { + t.Errorf("task %d label = %q", i, task.GetLabel()) + } + payload, queue := task.Create() + if queue != "trq" { + t.Errorf("task %d queue = %q", i, queue) + } + request := payload.(*socket.FetchBusinessInboxThreadsTask) + if request.SyncGroup != want[i].group || request.SecondaryFilter != want[i].secondary { + t.Errorf("task %d route = (%d, %d), want (%d, %d)", i, request.SyncGroup, request.SecondaryFilter, want[i].group, want[i].secondary) + } + if request.Cursor != want[i].cursor { + t.Errorf("task %d cursor = %q, want %q", i, request.Cursor, want[i].cursor) + } + if request.ReferenceThreadKey != 0 || request.ReferenceActivityTimestamp != 9999999999999 { + t.Errorf("task %d does not start at the beginning of the Page inbox", i) + } + } +} diff --git a/pkg/messagix/client.go b/pkg/messagix/client.go index 64b45944..0ccc78d4 100644 --- a/pkg/messagix/client.go +++ b/pkg/messagix/client.go @@ -5,6 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" "sync" "sync/atomic" "time" @@ -32,6 +37,14 @@ type EventHandler func(ctx context.Context, evt any) type Config struct { ClientSettings exhttp.ClientSettings LogRedactedBloksPayloads bool + BusinessSuite *BusinessSuiteContext +} + +type BusinessSuiteContext struct { + BusinessID string + AssetID string + PageID string + PageName string } type Client struct { @@ -41,16 +54,19 @@ type Client struct { MessengerLite *MessengerLiteMethods Logger zerolog.Logger Platform types.Platform + BusinessSuite *BusinessSuiteContext socket *dgw.Socket + businessSocket *businessSuiteSocket socketWasSynced atomic.Bool socketWasConnected atomic.Bool packetsSent atomic.Uint32 socketSyncWaiters *exsync.Map[int64, chan *PublishResponseData] - eventHandler EventHandler - configs *httpclient.Configs - syncManager *SyncManager + eventHandler EventHandler + configs *httpclient.Configs + syncManager *SyncManager + switchableProfiles []types.SwitchableProfile cookies *cookies.Cookies @@ -77,6 +93,7 @@ func NewClient(cookies *cookies.Cookies, logger zerolog.Logger, cfg *Config) *Cl cookies: cookies, Logger: logger, Platform: cookies.Platform, + BusinessSuite: cfg.BusinessSuite, connectionLoopStopped: exsync.NewEvent(), canSendMessages: exsync.NewEvent(), socketSyncWaiters: exsync.NewMap[int64, chan *PublishResponseData](), @@ -99,7 +116,16 @@ func NewClient(cookies *cookies.Cookies, logger zerolog.Logger, cfg *Config) *Cl Log: logger.With().Str("socket", "main").Logger(), Facebook: true, LoggingID: true, + AppStreamGroup: func() string { + if cli.Platform == types.BusinessSuite { + return "group1" + } + return "" + }(), }) + if cli.Platform == types.BusinessSuite { + cli.businessSocket = newBusinessSuiteSocket(cli) + } return cli } @@ -175,6 +201,13 @@ func (c *Client) LoadMessagesPage(ctx context.Context) (types.UserInfo, *table.L if err != nil { return nil, nil, fmt.Errorf("failed to load inbox: %w", err) } + c.switchableProfiles = append(c.switchableProfiles[:0], moduleLoader.SwitchableProfiles...) + if c.Platform == types.BusinessSuite && c.BusinessSuite != nil { + c.BusinessSuite.BusinessID = fmt.Sprint(c.configs.BrowserConfigTable.CurrentBusinessUser.BusinessID) + if c.BusinessSuite.PageID == "" { + c.BusinessSuite.PageID = c.BusinessSuite.AssetID + } + } c.syncManager = c.newSyncManager() ls, err := c.setupConfigs(ctx, moduleLoader.LS) @@ -185,6 +218,133 @@ func (c *Client) LoadMessagesPage(ctx context.Context) (types.UserInfo, *table.L return currentUser, ls, nil } +func (c *Client) GetBusinessSuiteContext() *BusinessSuiteContext { + if c == nil || c.BusinessSuite == nil { + return nil + } + copy := *c.BusinessSuite + return © +} + +// GetRequestActorID returns the selected Page actor used by Business Suite's +// GraphQL requests. Authentication remains tied to the human account in +// __user; av scopes the request to the Page mailbox. +func (c *Client) GetRequestActorID() string { + if c == nil || c.Platform != types.BusinessSuite || c.BusinessSuite == nil { + return "" + } + if c.BusinessSuite.PageID != "" { + return c.BusinessSuite.PageID + } + return c.BusinessSuite.AssetID +} + +// ResolveBusinessSuitePage follows Facebook's own Page inbox redirect to map +// a profile-switcher actor ID to the asset ID used by Business Suite. +func (c *Client) ResolveBusinessSuitePage(ctx context.Context, profile types.SwitchableProfile) (*BusinessSuiteContext, error) { + if c == nil || profile.ID == "" { + return nil, fmt.Errorf("messagix: a Page profile is required") + } + // The profile-switcher ID is a persona actor, not necessarily the public + // Page ID. The public Page inbox URL performs the authoritative mapping. + var resp *http.Response + var err error + if profile.Username != "" { + headers := c.http.BuildHeaders(true, true) + var pageBody []byte + resp, pageBody, err = c.http.MakeRequest(ctx, "https://www.facebook.com/"+url.PathEscape(profile.Username), http.MethodGet, headers, nil, types.NONE) + if err == nil { + for _, pattern := range pageAssetPatterns { + if match := pattern.FindSubmatch(pageBody); len(match) > 1 { + assetID := string(match[1]) + return &BusinessSuiteContext{AssetID: assetID, PageID: assetID, PageName: profile.Name}, nil + } + } + } + } + // Some switcher responses omit both username and avatar metadata. Business + // Suite still exposes the account's active Page asset in its bootstrap. + // This is authoritative when the login flow offered a single Page, and is a + // safe fallback until the multi-asset selector is queried directly. + if resp == nil || err != nil { + headers := c.http.BuildHeaders(true, true) + var suiteBody []byte + resp, suiteBody, err = c.http.MakeRequest(ctx, "https://business.facebook.com/latest/inbox/all", http.MethodGet, headers, nil, types.NONE) + if err == nil { + for _, pattern := range pageAssetPatterns { + if match := pattern.FindSubmatch(suiteBody); len(match) > 1 { + assetID := string(match[1]) + return &BusinessSuiteContext{AssetID: assetID, PageID: assetID, PageName: profile.Name}, nil + } + } + } + } + if err != nil || resp == nil { + previousActor := c.cookies.Get(cookies.FBCookieIUser) + c.cookies.Set(cookies.FBCookieIUser, profile.ID) + headers := c.http.BuildHeaders(true, true) + resp, _, err = c.http.MakeRequest(ctx, "https://www.facebook.com/messages", http.MethodGet, headers, nil, types.NONE) + if previousActor == "" { + c.cookies.Delete(cookies.FBCookieIUser) + } else { + c.cookies.Set(cookies.FBCookieIUser, previousActor) + } + } + if err != nil { + return nil, fmt.Errorf("failed to resolve Business Suite Page: %w", err) + } + q := resp.Request.URL.Query() + assetID := q.Get("asset_id") + if assetID == "" { + return nil, fmt.Errorf("Business Suite redirect did not include a Page asset ID (final host %s path %s)", resp.Request.URL.Hostname(), resp.Request.URL.Path) + } + if _, err := strconv.ParseInt(assetID, 10, 64); err != nil { + return nil, fmt.Errorf("Business Suite returned an invalid Page asset ID") + } + businessID := q.Get("business_id") + if businessID == "" { + businessID = q.Get("bpn_id") + } + return &BusinessSuiteContext{ + BusinessID: strings.TrimSpace(businessID), + AssetID: assetID, + PageID: assetID, + PageName: profile.Name, + }, nil +} + +var pageAssetPatterns = []*regexp.Regexp{ + regexp.MustCompile(`"asset_id":"(\d{6,})"`), + regexp.MustCompile(`"pageID":"(\d{6,})"`), + regexp.MustCompile(`"page_id":"(\d{6,})"`), + regexp.MustCompile(`"delegate_page_id":"(\d{6,})"`), +} + +// GetSwitchableProfiles returns Facebook Pages exposed by the current web +// session's profile switcher. The data comes from the page preload and does not +// require a Graph API access token. +func (c *Client) GetSwitchableProfiles() []types.SwitchableProfile { + if c == nil { + return nil + } + return append([]types.SwitchableProfile(nil), c.switchableProfiles...) +} + +// DiscoverSwitchableProfiles loads Facebook's main page, where the account +// switcher is consistently preloaded even when it is absent from /messages. +func (c *Client) DiscoverSwitchableProfiles(ctx context.Context) error { + if c == nil { + return ErrClientIsNil + } + moduleLoader := httpclient.NewModuleParser(c, c.http, c.configs) + if err := moduleLoader.DiscoverSwitchableProfiles(ctx, c.GetEndpoint("base_url")); err != nil { + return fmt.Errorf("failed to load Facebook profile switcher: %w", err) + } + c.switchableProfiles = append(c.switchableProfiles[:0], moduleLoader.SwitchableProfiles...) + c.Logger.Info().Int("switchable_profile_count", len(c.switchableProfiles)).Msg("Discovered Facebook switchable profiles") + return nil +} + func (c *Client) GetPlatform() types.Platform { if c == nil { return types.Unset @@ -212,6 +372,12 @@ func (c *Client) configurePlatformClient() { selectedEndpoints = endpoints.MessengerLiteAndroidEndpoints c.Facebook = &FacebookMethods{client: c} c.MessengerLite = &MessengerLiteMethods{client: c} + case types.BusinessSuite: + if c.BusinessSuite == nil || c.BusinessSuite.AssetID == "" { + panic("messagix: Business Suite platform requires an asset ID") + } + selectedEndpoints = endpoints.MakeBusinessSuiteEndpoints(c.BusinessSuite.AssetID) + c.Facebook = &FacebookMethods{client: c} } c.endpoints = selectedEndpoints @@ -255,7 +421,12 @@ func (c *Client) Connect(ctx context.Context) error { for { c.canSendMessages.Clear() // In case we're reconnecting from a normal network error connectStart := time.Now() - err := c.socket.Connect(ctx) + var err error + if c.Platform == types.BusinessSuite { + err = c.businessSocket.Connect(ctx) + } else { + err = c.socket.Connect(ctx) + } c.clearSocketSyncWaiters() c.canSendMessages.Clear() if ctx.Err() != nil { @@ -310,14 +481,24 @@ func (c *Client) Disconnect() { if fn := c.stopCurrentConnections.Load(); fn != nil { (*fn)() } - c.socket.Disconnect() + if c.Platform == types.BusinessSuite { + c.businessSocket.Disconnect() + } else { + c.socket.Disconnect() + } if !c.connectionLoopStopped.WaitTimeout(5 * time.Second) { c.Logger.Warn().Msg("Connection loop didn't stop in time") } } func (c *Client) IsConnected() bool { - return c != nil && c.socket.IsConnected() + if c == nil { + return false + } + if c.Platform == types.BusinessSuite { + return c.businessSocket.isConnected() + } + return c.socket.IsConnected() } func (c *Client) GetEndpoint(name string) string { @@ -374,7 +555,11 @@ func (c *Client) ForceReconnect() { if c == nil { return } - c.socket.ForceReconnect() + if c.Platform == types.BusinessSuite { + c.businessSocket.ForceReconnect() + } else { + c.socket.ForceReconnect() + } } func (c *Client) FetchMoreThreads(ctx context.Context, syncGroup int64) (*socket.KeyStoreData, *table.LSTable, error) { diff --git a/pkg/messagix/configs.go b/pkg/messagix/configs.go index 5609f689..0b75db0c 100644 --- a/pkg/messagix/configs.go +++ b/pkg/messagix/configs.go @@ -7,8 +7,13 @@ import ( "go.mau.fi/mautrix-meta/pkg/messagix/socket" "go.mau.fi/mautrix-meta/pkg/messagix/table" + "go.mau.fi/mautrix-meta/pkg/messagix/types" ) +func shouldBootstrapViaGraphQL(platform types.Platform) bool { + return platform != types.BusinessSuite +} + func (c *Client) updateSocketIDs() { c.socket.AppID = c.configs.BrowserConfigTable.DGWWebConfig.AppID c.socket.UserID = cmp.Or(c.configs.BrowserConfigTable.PolarisViewer.ID, c.configs.BrowserConfigTable.CurrentUserInitialData.UserID) @@ -24,8 +29,25 @@ func (c *Client) setupConfigs(ctx context.Context, ls *table.LSTable) (*table.LS } c.updateSocketIDs() - c.syncManager.syncParams = &c.configs.BrowserConfigTable.LSPlatformMessengerSyncParams + if c.Platform == types.BusinessSuite { + biz := c.configs.BrowserConfigTable.LSPlatformBizInboxSyncParams + c.syncManager.syncParams = &types.LSPlatformMessengerSyncParams{ + Mailbox: biz.Mailbox, + Contact: biz.Contact, + E2Ee: biz.E2Ee, + } + } else { + c.syncManager.syncParams = &c.configs.BrowserConfigTable.LSPlatformMessengerSyncParams + } if len(ls.LSExecuteFinallyBlockForSyncTransaction) == 0 { + if !shouldBootstrapViaGraphQL(c.Platform) { + // The generic GraphQL bootstrap targets Messenger databases 1 and + // 95, which belong to the signed-in person's inbox. Business Suite + // bootstraps its Page-scoped databases after the MQTT connection is + // established instead. + c.Logger.Debug().Msg("Deferring Business Inbox database sync to the Page-scoped socket") + return ls, nil + } c.Logger.Warn().Msg("Syncing initial data via graphql") err := c.syncManager.UpdateDatabaseSyncParams( []*socket.QueryMetadata{ diff --git a/pkg/messagix/cookies/cookies.go b/pkg/messagix/cookies/cookies.go index 749f3359..0f3c3ec8 100644 --- a/pkg/messagix/cookies/cookies.go +++ b/pkg/messagix/cookies/cookies.go @@ -24,6 +24,10 @@ const ( FBCookieXS MetaCookieName = "xs" // FBCookieCUser contains the user ID for Facebook FBCookieCUser MetaCookieName = "c_user" + // FBCookieIUser contains the currently selected Facebook profile/Page ID. + // Facebook keeps c_user as the owning personal account while i_user selects + // the actor used by web requests and the Messenger inbox. + FBCookieIUser MetaCookieName = "i_user" FBCookieSB MetaCookieName = "sb" FBCookieFR MetaCookieName = "fr" @@ -124,7 +128,10 @@ func (c *Cookies) GetUserID() int64 { defer c.lock.RUnlock() var cookieKey MetaCookieName if c.Platform.IsMessenger() { - cookieKey = FBCookieCUser + cookieKey = FBCookieIUser + if c.values[cookieKey] == "" { + cookieKey = FBCookieCUser + } } else { cookieKey = IGCookieDSUserID } @@ -154,12 +161,21 @@ func (c *Cookies) Set(key MetaCookieName, value string) { c.values[key] = value } +func (c *Cookies) Delete(key MetaCookieName) { + c.lock.Lock() + defer c.lock.Unlock() + delete(c.values, key) +} + func (c *Cookies) UpdateFromResponse(r *http.Response) { c.lock.Lock() defer c.lock.Unlock() // Note: this will fail to parse rur, shbid and shbts because they have quotes and backslashes for _, cookie := range r.Cookies() { - if cookie.MaxAge == 0 || cookie.Expires.Before(time.Now()) { + // MaxAge == 0 means the Set-Cookie header did not specify Max-Age. It is + // a normal session cookie, not a deletion. Negative MaxAge explicitly + // deletes a cookie. An unset Expires value must also be preserved. + if cookie.MaxAge < 0 || (!cookie.Expires.IsZero() && cookie.Expires.Before(time.Now())) { delete(c.values, MetaCookieName(cookie.Name)) } else { c.values[MetaCookieName(cookie.Name)] = cookie.Value diff --git a/pkg/messagix/cookies/cookies_test.go b/pkg/messagix/cookies/cookies_test.go new file mode 100644 index 00000000..eb6e8d8c --- /dev/null +++ b/pkg/messagix/cookies/cookies_test.go @@ -0,0 +1,38 @@ +package cookies + +import ( + "net/http" + "testing" + + "go.mau.fi/mautrix-meta/pkg/messagix/types" +) + +func TestUpdateFromResponsePreservesSessionCookie(t *testing.T) { + c := &Cookies{Platform: types.Facebook} + c.UpdateValues(map[MetaCookieName]string{FBCookieXS: "old"}) + resp := &http.Response{Header: http.Header{ + "Set-Cookie": []string{"xs=next; Path=/; HttpOnly; Secure"}, + }} + + c.UpdateFromResponse(resp) + + if got := c.Get(FBCookieXS); got != "next" { + t.Fatalf("expected session cookie to be updated, got %q", got) + } +} + +func TestGetUserIDPrefersSelectedFacebookActor(t *testing.T) { + c := &Cookies{Platform: types.Facebook} + c.UpdateValues(map[MetaCookieName]string{ + FBCookieCUser: "100", + FBCookieIUser: "200", + }) + + if got := c.GetUserID(); got != 200 { + t.Fatalf("expected selected actor 200, got %d", got) + } + c.Delete(FBCookieIUser) + if got := c.GetUserID(); got != 100 { + t.Fatalf("expected owning account 100, got %d", got) + } +} diff --git a/pkg/messagix/endpoints/businesssuite_test.go b/pkg/messagix/endpoints/businesssuite_test.go new file mode 100644 index 00000000..a2ded265 --- /dev/null +++ b/pkg/messagix/endpoints/businesssuite_test.go @@ -0,0 +1,18 @@ +package endpoints + +import ( + "strings" + "testing" +) + +func TestBusinessSuiteMessengerEndpointsDoNotOpenUnifiedInbox(t *testing.T) { + endpoints := MakeBusinessSuiteEndpoints("12345") + for _, name := range []string{"messages", "thread"} { + if !strings.Contains(endpoints[name], "/latest/inbox/messenger") { + t.Errorf("%s endpoint = %q, want Messenger-only Business Suite route", name, endpoints[name]) + } + if strings.Contains(endpoints[name], "/latest/inbox/all") { + t.Errorf("%s endpoint must not open the unified inbox", name) + } + } +} diff --git a/pkg/messagix/endpoints/facebook.go b/pkg/messagix/endpoints/facebook.go index 45e5c20f..7c2cbeb8 100644 --- a/pkg/messagix/endpoints/facebook.go +++ b/pkg/messagix/endpoints/facebook.go @@ -14,6 +14,24 @@ var FacebookTorEndpoints = makeFacebookEndpoints(facebookTorHost) var MessengerLiteIOSEndpoints = makeMessengerLiteEndpoints(facebookHost, "graph.facebook.com", false) var MessengerLiteAndroidEndpoints = makeMessengerLiteEndpoints(facebookHost, "b-graph.facebook.com", true) +func MakeBusinessSuiteEndpoints(assetID string) map[string]string { + baseURL := "https://business.facebook.com" + return map[string]string{ + "host": "business.facebook.com", + "base_url": baseURL, + "messages": baseURL + "/latest/inbox/messenger?asset_id=" + assetID, + "thread": baseURL + "/latest/inbox/messenger?asset_id=" + assetID + "&selected_item_id=", + "cookie_consent": "https://www.facebook.com/cookie/consent/", + "graphql": baseURL + "/api/graphql/", + "media_upload": baseURL + "/ajax/mercury/upload.php?", + "web_push": baseURL + "/push/register/service_worker/", + "dgw_lightspeed": "https://gateway.facebook.com/ws/realtime", + "e2ee_ws_url": "wss://web-chat-e2ee.facebook.com/ws/chat", + "icdc_fetch": "https://reg-e2ee.facebook.com/v2/fb_icdc_fetch", + "icdc_register": "https://reg-e2ee.facebook.com/v2/fb_register_v2", + } +} + func makeFacebookEndpoints(host string) map[string]string { wwwHost := "www." + host baseURL := "https://" + wwwHost diff --git a/pkg/messagix/graphql/responses.go b/pkg/messagix/graphql/responses.go index b02e3482..10799627 100644 --- a/pkg/messagix/graphql/responses.go +++ b/pkg/messagix/graphql/responses.go @@ -7,7 +7,8 @@ import ( type LSPlatformGraphQLLightspeedRequestQuery = Response[*struct { Viewer struct { - LightspeedWebRequest *LightspeedWebRequest `json:"lightspeed_web_request,omitempty"` + LightspeedWebRequest *LightspeedWebRequest `json:"lightspeed_web_request,omitempty"` + UnifiedInboxLightspeedWebRequest *LightspeedWebRequest `json:"unified_inbox_lightspeed_web_request,omitempty"` } `json:"viewer,omitempty"` LightspeedWebRequestForIG *LightspeedWebRequest `json:"lightspeed_web_request_for_igd,omitempty"` }] diff --git a/pkg/messagix/httpclient/businesssuite_modules_test.go b/pkg/messagix/httpclient/businesssuite_modules_test.go new file mode 100644 index 00000000..bd854c91 --- /dev/null +++ b/pkg/messagix/httpclient/businesssuite_modules_test.go @@ -0,0 +1,54 @@ +package httpclient + +import ( + "encoding/json" + "testing" + + "go.mau.fi/mautrix-meta/pkg/messagix/graphql" + "go.mau.fi/mautrix-meta/pkg/messagix/table" +) + +func TestBusinessInboxLightspeedResponseShape(t *testing.T) { + raw := json.RawMessage(`{"data":{"viewer":{"unified_inbox_lightspeed_web_request":{"payload":"{\"name\":null,\"step\":[1]}","dependencies":[]}}}}`) + var response graphql.LSPlatformGraphQLLightspeedRequestQuery + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatal(err) + } + if response.Data.Viewer.UnifiedInboxLightspeedWebRequest == nil { + t.Fatal("Business Inbox Lightspeed payload was not decoded") + } +} + +func TestBusinessInboxPreloaderIsRoutedToLightspeedDecoder(t *testing.T) { + parser := &ModuleParser{LS: &table.LSTable{}} + raw := json.RawMessage(`{"data":{"viewer":{"unified_inbox_lightspeed_web_request":{"payload":"not-json","dependencies":[]}}}}`) + if err := parser.handleLightSpeedQLRequest(raw, "LSBizInboxGraphQLLightspeedRequestQuery"); err == nil { + t.Fatal("Business Inbox preloader was ignored instead of being routed to the Lightspeed decoder") + } +} + +func TestBusinessInboxPreloaderNameParsing(t *testing.T) { + parser := &ModuleParser{} + got := parser.parseGraphMethodName("adp_LSBizInboxGraphQLLightspeedRequestQueryRelayPreloader_fixture") + if got != "LSBizInboxGraphQLLightspeedRequestQuery" { + t.Fatalf("parsed name = %q", got) + } +} + +func TestBusinessInboxHashedRelayModuleIsHandled(t *testing.T) { + parser := &ModuleParser{LS: &table.LSTable{}} + entry := &ModuleEntry{ + Name: "RelayPrefetchedStreamCache@fixturehash", + Data: []json.RawMessage{ + json.RawMessage(`null`), + json.RawMessage(`null`), + json.RawMessage(`["adp_LSBizInboxGraphQLLightspeedRequestQueryRelayPreloader_fixture",{"__bbox":{"complete":true,"result":{"data":{"viewer":{"unified_inbox_lightspeed_web_request":{"payload":"{\"name\":null,\"step\":[1]}","dependencies":[]}}}}}}]`), + }, + } + if err := parser.handleRequire(entry); err != nil { + t.Fatal(err) + } + if entry.Name != "RelayPrefetchedStreamCache" { + t.Fatalf("module name = %q", entry.Name) + } +} diff --git a/pkg/messagix/httpclient/graphql.go b/pkg/messagix/httpclient/graphql.go index 280c6964..c934fab1 100644 --- a/pkg/messagix/httpclient/graphql.go +++ b/pkg/messagix/httpclient/graphql.go @@ -215,6 +215,18 @@ func (c *HTTPClient) MakeGraphQLRequest(ctx context.Context, name string, variab if !ok { return nil, nil, fmt.Errorf("could not find graphql doc by the name of: %s", name) } + return c.makeGraphQLRequest(ctx, name, graphQLDoc, variables) +} + +func (c *HTTPClient) MakeGraphQLRequestWithDoc(ctx context.Context, name, docID string, variables interface{}) (*http.Response, []byte, error) { + return c.makeGraphQLRequest(ctx, name, graphql.GraphQLDoc{ + DocID: docID, + CallerClass: "RelayModern", + FriendlyName: name, + }, variables) +} + +func (c *HTTPClient) makeGraphQLRequest(ctx context.Context, name string, graphQLDoc graphql.GraphQLDoc, variables interface{}) (*http.Response, []byte, error) { vBytes, err := json.Marshal(variables) if err != nil { diff --git a/pkg/messagix/httpclient/http.go b/pkg/messagix/httpclient/http.go index 8cfe4f37..46f46f11 100644 --- a/pkg/messagix/httpclient/http.go +++ b/pkg/messagix/httpclient/http.go @@ -49,6 +49,21 @@ type Client interface { IsAuthenticated() bool } +type requestActorProvider interface { + GetRequestActorID() string +} + +func requestActorID(client Client) string { + if client.GetPlatform() != types.BusinessSuite { + return "" + } + provider, ok := client.(requestActorProvider) + if !ok { + return "" + } + return provider.GetRequestActorID() +} + func NewHTTPClient(cli Client, configs *Configs, settings exhttp.ClientSettings) *HTTPClient { c := &HTTPClient{ parent: cli, @@ -268,6 +283,7 @@ func (c *HTTPClient) NewHTTPQuery() *HTTPQuery { siteConfig := c.configs.BrowserConfigTable.SiteData dpr := strconv.FormatFloat(siteConfig.Pr, 'g', 4, 64) query := &HTTPQuery{ + Av: requestActorID(c.parent), User: c.configs.BrowserConfigTable.CurrentUserInitialData.UserID, A: "1", Req: strconv.FormatInt(int64(c.graphQLRequests), 36), diff --git a/pkg/messagix/httpclient/js_module_parser.go b/pkg/messagix/httpclient/js_module_parser.go index 3e65f5b0..68728ebd 100644 --- a/pkg/messagix/httpclient/js_module_parser.go +++ b/pkg/messagix/httpclient/js_module_parser.go @@ -37,6 +37,15 @@ import ( // var jsDatrPattern = regexp.MustCompile(`"_js_datr","([^"]+)"`) var versionPattern = regexp.MustCompile(`__d\("LSVersion"[^)]+\)\{\w\.exports="(\d+)"\}`) +var profileSwitcherDocPattern = regexp.MustCompile(`params:\{id:"(\d+)",metadata:\{\},name:"CometSettingsDropdownListQuery"`) + +func findProfileSwitcherDocID(jsContent []byte) string { + matches := profileSwitcherDocPattern.FindSubmatch(jsContent) + if len(matches) < 2 { + return "" + } + return string(matches[1]) +} type BBoxContainer struct { BBox *BBox `json:"__bbox,omitempty"` @@ -88,6 +97,12 @@ type ModuleParser struct { http *HTTPClient LS *table.LSTable + // SwitchableProfiles is populated from Facebook's preloaded profile + // switcher query. It does not use the public Graph API. + SwitchableProfiles []types.SwitchableProfile + profileSwitcherDocID string + profileSwitcherVariables json.RawMessage + assetURLs []string } func NewModuleParser(client Client, http *HTTPClient, configs *Configs) *ModuleParser { @@ -115,6 +130,17 @@ func (m *ModuleParser) Load(ctx context.Context, page string) error { } scriptTags := m.findScriptTags(doc) + linkTags := m.findLinkTags(doc) + for _, tag := range scriptTags { + if href := tag.Attributes["src"]; strings.HasPrefix(href, "https://") { + m.assetURLs = append(m.assetURLs, href) + } + } + for _, tag := range linkTags { + if tag.Attributes["as"] == "script" && tag.Attributes["href"] != "" { + m.assetURLs = append(m.assetURLs, tag.Attributes["href"]) + } + } for _, tag := range scriptTags { id := tag.Attributes["id"] switch id { @@ -162,7 +188,6 @@ func (m *ModuleParser) Load(ctx context.Context, page string) error { } else if m.configs.VersionID == 0 && authenticated { m.log.Warn().Msg("Version ID not found in index page") var doneCrawling bool - linkTags := m.findLinkTags(doc) for _, tag := range linkTags { as := tag.Attributes["as"] href := tag.Attributes["href"] @@ -302,6 +327,61 @@ func (m *ModuleParser) crawlJavascriptFile(ctx context.Context, href string) (bo return false, nil } +func (m *ModuleParser) discoverProfileSwitcherDoc(ctx context.Context) error { + seen := make(map[string]bool, len(m.assetURLs)) + for _, href := range m.assetURLs { + if href == "" || seen[href] { + continue + } + seen[href] = true + _, jsContent, err := m.http.MakeRequest(ctx, href, "GET", http.Header{}, nil, types.NONE) + if err != nil { + continue + } + if docID := findProfileSwitcherDocID(jsContent); docID != "" { + m.profileSwitcherDocID = docID + m.log.Info().Msg("Found Facebook profile switcher query") + return nil + } + } + return fmt.Errorf("Facebook profile switcher query was not found") +} + +func (m *ModuleParser) DiscoverSwitchableProfiles(ctx context.Context, page string) error { + if err := m.Load(ctx, page); err != nil { + return err + } + if len(m.SwitchableProfiles) > 0 { + return nil + } + if m.profileSwitcherDocID == "" { + if err := m.discoverProfileSwitcherDoc(ctx); err != nil { + return err + } + } + variables := any(map[string]any{ + "fetchTestUserProfileListCell": false, + "includeHorizBadging": false, + "inProfileSwitcherEntry": false, + "inSimpleHeaderEntry": true, + "scale": 2, + }) + if len(m.profileSwitcherVariables) > 0 { + variables = m.profileSwitcherVariables + } + _, response, err := m.http.MakeGraphQLRequestWithDoc( + ctx, + "CometSettingsDropdownListQuery", + m.profileSwitcherDocID, + variables, + ) + if err != nil { + return err + } + m.parseSwitchableProfiles(response) + return nil +} + func (m *ModuleParser) handleModule(data *ModuleEntry) error { if len(data.Data) < 3 { return fmt.Errorf("module %s has less than 3 data elements", data.Name) diff --git a/pkg/messagix/httpclient/modules.go b/pkg/messagix/httpclient/modules.go index c1562501..0032823f 100644 --- a/pkg/messagix/httpclient/modules.go +++ b/pkg/messagix/httpclient/modules.go @@ -1,6 +1,7 @@ package httpclient import ( + "cmp" "encoding/base64" "encoding/json" "fmt" @@ -102,6 +103,11 @@ func (rpsc *RelayPrefetchedStreamCache) UnmarshalJSON(data []byte) error { func (m *ModuleParser) handleRequire(data *ModuleEntry) error { if strings.HasPrefix(data.Name, "CometPlatformRootClient@") { data.Name = "CometPlatformRootClient" + } else if strings.HasPrefix(data.Name, "RelayPrefetchedStreamCache@") { + // Business Suite emits the Relay module with a build hash suffix. + // Without normalizing it, the embedded Page inbox snapshot is + // silently skipped and the connector falls back to an empty table. + data.Name = "RelayPrefetchedStreamCache" } switch data.Name { case "CometPlatformRootClient": @@ -144,16 +150,25 @@ func (m *ModuleParser) handleRequire(data *ModuleEntry) error { return fmt.Errorf("failed to parse graphql preload requests from CometPlatformRootClient: %w", err) } for _, req := range requests { - if !strings.HasPrefix(req.PreloaderID, "adp_LSPlatformGraphQLLightspeedRequest") { + if strings.Contains(req.PreloaderID, "CometSettingsDropdownListQuery") && req.QueryID != "" { + m.profileSwitcherDocID = req.QueryID + m.profileSwitcherVariables = req.Variables + } + if !strings.HasPrefix(req.PreloaderID, "adp_LSPlatformGraphQLLightspeedRequest") && + !strings.HasPrefix(req.PreloaderID, "adp_LSBizInboxGraphQLLightspeedRequest") { continue } - vars, err := req.ParseVariables() - if err != nil { + // Business Inbox serializes requestId as a string while the + // Messenger preloader uses a number. Only requestPayload is needed + // here, so avoid imposing the Messenger-only shape on both. + var vars struct { + RequestPayload string `json:"requestPayload"` + } + if err := json.Unmarshal(req.Variables, &vars); err != nil { return fmt.Errorf("failed to parse graphql lightspeed preload request variables: %w", err) } var syncData *graphql.LSPlatformGraphQLLightspeedVariables - err = json.Unmarshal([]byte(vars.RequestPayload), &syncData) - if err != nil { + if err := json.Unmarshal([]byte(vars.RequestPayload), &syncData); err != nil { return fmt.Errorf("failed to parse graphql lightspeed preload request payload: %w", err) } m.configs.VersionID = syncData.Version @@ -180,6 +195,9 @@ func (m *ModuleParser) handleLightSpeedQLRequest(data json.RawMessage, parserFun var lsPayloadStr string var deps lightspeed.DependencyList switch parserFunc { + case "CometSettingsDropdownListQuery", "CometSettingsDropdownTriggerQuery": + m.parseSwitchableProfiles(data) + return nil case "LSPlatformGraphQLLightspeedRequestForIGDQuery": var lsData *graphql.LSPlatformGraphQLLightspeedRequestQuery err := json.Unmarshal(data, &lsData) @@ -202,6 +220,17 @@ func (m *ModuleParser) handleLightSpeedQLRequest(data json.RawMessage, parserFun } lsPayloadStr = lsData.Data.Viewer.LightspeedWebRequest.Payload deps = lsData.Data.Viewer.LightspeedWebRequest.Dependencies + case "LSBizInboxGraphQLLightspeedRequestQuery": + var lsData *graphql.LSPlatformGraphQLLightspeedRequestQuery + err := json.Unmarshal(data, &lsData) + if err != nil { + return fmt.Errorf("messagix-moduleparser: failed to parse Business Inbox LightSpeed request data: %w", err) + } + if lsData.Data.Viewer.UnifiedInboxLightspeedWebRequest == nil { + return nil + } + lsPayloadStr = lsData.Data.Viewer.UnifiedInboxLightspeedWebRequest.Payload + deps = lsData.Data.Viewer.UnifiedInboxLightspeedWebRequest.Dependencies default: //m.handleGraphQLData(parserFunc, data) return nil @@ -222,6 +251,88 @@ func (m *ModuleParser) handleLightSpeedQLRequest(data json.RawMessage, parserFun return nil } +type switchableProfileNode struct { + Typename string `json:"__typename,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Username string `json:"username,omitempty"` + TypeName string `json:"profile_type_name_for_content,omitempty"` + Picture struct { + URI string `json:"uri,omitempty"` + } `json:"profile_picture,omitempty"` + SettingsPicture struct { + URI string `json:"uri,omitempty"` + } `json:"settings_dropdown_profile_picture,omitempty"` +} + +// parseSwitchableProfiles walks the preloaded profile-switcher response rather +// than relying on a persisted GraphQL document ID. Meta changes those IDs +// frequently, while the response field is already shipped with the page. +func (m *ModuleParser) parseSwitchableProfiles(data json.RawMessage) { + var root any + if err := json.Unmarshal(data, &root); err != nil { + m.log.Debug().Err(err).Msg("Failed to parse Facebook profile switcher preload") + return + } + + seen := make(map[string]bool, len(m.SwitchableProfiles)) + for _, profile := range m.SwitchableProfiles { + seen[profile.ID] = true + } + var collect func(any) + collect = func(value any) { + switch typed := value.(type) { + case []any: + for _, item := range typed { + collect(item) + } + case map[string]any: + if rawID, ok := typed["id"].(string); ok && rawID != "" { + raw, _ := json.Marshal(typed) + var node switchableProfileNode + if json.Unmarshal(raw, &node) == nil { + if node.Name != "" && !seen[node.ID] { + seen[node.ID] = true + m.SwitchableProfiles = append(m.SwitchableProfiles, types.SwitchableProfile{ + ID: node.ID, + Name: node.Name, + Username: node.Username, + AvatarURL: cmp.Or(node.Picture.URI, node.SettingsPicture.URI), + Type: cmp.Or(node.TypeName, node.Typename), + }) + } + } + } + for _, item := range typed { + collect(item) + } + } + } + + // Facebook currently labels Page identities as `User` in some profile + // switcher responses. Only trust identities nested under the switcher's + // eligible-profile collection, instead of relying on __typename. + var findCollections func(any) + findCollections = func(value any) { + switch typed := value.(type) { + case []any: + for _, item := range typed { + findCollections(item) + } + case map[string]any: + for key, item := range typed { + switch key { + case "profile_switcher_eligible_profiles", "profiles", "first_profiles", "first_profile": + collect(item) + default: + findCollections(item) + } + } + } + } + findCollections(root) +} + func (m *ModuleParser) parseGraphMethodName(name string) string { var s string s = strings.Replace(name, "adp_", "", -1) diff --git a/pkg/messagix/httpclient/page_actor_test.go b/pkg/messagix/httpclient/page_actor_test.go new file mode 100644 index 00000000..7e5bde47 --- /dev/null +++ b/pkg/messagix/httpclient/page_actor_test.go @@ -0,0 +1,33 @@ +package httpclient + +import ( + "testing" + + "github.com/rs/zerolog" + + "go.mau.fi/mautrix-meta/pkg/messagix/cookies" + "go.mau.fi/mautrix-meta/pkg/messagix/types" +) + +type pageActorClient struct{} + +func (pageActorClient) GetPlatform() types.Platform { return types.BusinessSuite } +func (pageActorClient) GetLogger() *zerolog.Logger { return nil } +func (pageActorClient) GetCookies() *cookies.Cookies { return nil } +func (pageActorClient) GetEndpoint(string) string { return "" } +func (pageActorClient) IsAuthenticated() bool { return true } +func (pageActorClient) GetRequestActorID() string { return "281134752317319" } + +type personalClient struct{ pageActorClient } + +func (personalClient) GetPlatform() types.Platform { return types.Facebook } +func (personalClient) GetRequestActorID() string { return "" } + +func TestRequestActorIDUsesSelectedBusinessPage(t *testing.T) { + if got := requestActorID(pageActorClient{}); got != "281134752317319" { + t.Fatalf("expected selected Page actor, got %q", got) + } + if got := requestActorID(personalClient{}); got != "" { + t.Fatalf("personal inbox must not set a Page actor, got %q", got) + } +} diff --git a/pkg/messagix/httpclient/profile_query_test.go b/pkg/messagix/httpclient/profile_query_test.go new file mode 100644 index 00000000..2e3205f1 --- /dev/null +++ b/pkg/messagix/httpclient/profile_query_test.go @@ -0,0 +1,11 @@ +package httpclient + +import "testing" + +func TestFindProfileSwitcherDocID(t *testing.T) { + js := `__d("CometSettingsDropdownListQuery.graphql",[],function(){return{params:{id:"5589011011152787",metadata:{},name:"CometSettingsDropdownListQuery",operationKind:"query",text:null}}})` + + if got := findProfileSwitcherDocID([]byte(js)); got != "5589011011152787" { + t.Fatalf("expected profile switcher query ID, got %q", got) + } +} diff --git a/pkg/messagix/httpclient/profiles_test.go b/pkg/messagix/httpclient/profiles_test.go new file mode 100644 index 00000000..ec40d2cd --- /dev/null +++ b/pkg/messagix/httpclient/profiles_test.go @@ -0,0 +1,31 @@ +package httpclient + +import ( + "encoding/json" + "testing" + + "github.com/rs/zerolog" +) + +func TestParseSwitchableProfiles(t *testing.T) { + log := zerolog.Nop() + parser := &ModuleParser{log: &log} + payload := json.RawMessage(`{ + "data": {"viewer": {"actor": { + "profiles": {"edges": [ + {"node":{"profile":{"__typename":"User","id":"200","name":"TMT Muscle Cars","username":"TMTMC1","profile_picture":{"uri":"https://example.test/page.jpg"}}}}, + {"node":{"profile":{"__typename":"User","id":"200","name":"TMT Muscle Cars"}}} + ]} + }}}} + `) + + parser.parseSwitchableProfiles(payload) + + if len(parser.SwitchableProfiles) != 1 { + t.Fatalf("expected one unique Page, got %#v", parser.SwitchableProfiles) + } + profile := parser.SwitchableProfiles[0] + if profile.ID != "200" || profile.Name != "TMT Muscle Cars" || profile.Username != "TMTMC1" { + t.Fatalf("unexpected Page: %#v", profile) + } +} diff --git a/pkg/messagix/socket.go b/pkg/messagix/socket.go index 3fbd0572..fcffbd03 100644 --- a/pkg/messagix/socket.go +++ b/pkg/messagix/socket.go @@ -12,6 +12,7 @@ import ( "go.mau.fi/mautrix-meta/pkg/messagix/methods" "go.mau.fi/mautrix-meta/pkg/messagix/socket" "go.mau.fi/mautrix-meta/pkg/messagix/table" + "go.mau.fi/mautrix-meta/pkg/messagix/types" ) type TransientDisconnectEvent struct { @@ -28,15 +29,29 @@ type ConnectedEvent struct{} var ( minimalFBSync = []int64{1, 2, 95, 104} + // Business Inbox thread groups 127 and 205 are hydrated by the embedded + // Page snapshot and continued with task 313. Asking the socket to sync them + // as databases stalls because Meta only accepts its browser-owned cursors. + businessSuiteSync = []int64{2, 26} shouldRecurseDatabase = map[int64]bool{ 1: true, 2: true, 95: true, 104: true, + 26: true, + 127: true, + 205: true, } ) +func initialSyncDatabases(platform types.Platform) []int64 { + if platform == types.BusinessSuite { + return businessSuiteSync + } + return minimalFBSync +} + type SocketLSRequestPayload struct { AppID string `json:"app_id"` Payload string `json:"payload"` @@ -48,18 +63,19 @@ func (c *Client) onSocketConnect(ctx context.Context) error { c.canSendMessages.Set() reconnect := c.socketWasSynced.Load() + initialSync := initialSyncDatabases(c.Platform) + err := c.syncManager.ensureSyncedSocket(ctx, initialSync) + if err != nil { + return fmt.Errorf("failed to ensure initial databases are synced: %w", err) + } + if !reconnect { - err := c.sendInitialThreadFetch(ctx) + err = c.sendInitialThreadFetch(ctx) if err != nil { return err } } - err := c.syncManager.ensureSyncedSocket(ctx, minimalFBSync) - if err != nil { - return fmt.Errorf("failed to ensure db 1 is synced: %w", err) - } - if reconnect { c.HandleEvent(ctx, &ReconnectedEvent{}) } else { @@ -73,6 +89,23 @@ func (c *Client) onSocketConnect(ctx context.Context) error { func (c *Client) sendInitialThreadFetch(ctx context.Context) error { tskm := c.newTaskManager() + if c.Platform == types.BusinessSuite { + // The embedded Business Suite snapshot is unified and its group-127 cursor + // may have advanced through Instagram rows. Start the Messenger-only fetch + // without that mixed cursor so Page threads are not skipped. + for _, task := range businessSuiteInitialThreadTasks("") { + tskm.AddNewTask(task) + } + payload, err := tskm.FinalizePayload() + if err != nil { + return fmt.Errorf("failed to finalize Business Inbox sync tasks: %w", err) + } + response, err := c.makeLSRequest(ctx, payload, 3) + if err != nil { + return fmt.Errorf("failed to send Business Inbox sync tasks: %w", err) + } + return c.dispatchRequestedTable(ctx, response) + } ptks := c.configs.ParentThreadKeys if len(ptks) == 0 { zerolog.Ctx(ctx).Warn().Msg("Parent thread keys are not known") @@ -138,6 +171,42 @@ func (c *Client) sendInitialThreadFetch(ctx context.Context) error { return nil } +func businessSuiteInitialThreadTasks(messengerCursor string) []socket.Task { + const ( + messengerSyncGroup = 205 + messengerFilter = 24 + ) + makeTask := func(syncGroup, secondaryFilter int, cursor string) socket.Task { + return &socket.FetchBusinessInboxThreadsTask{ + Cursor: cursor, + Filter: 0, + FilterValue: "", + IsAfter: 0, + ParentThreadKey: 0, + ReferenceActivityTimestamp: 9999999999999, + ReferenceThreadKey: 0, + SecondaryFilter: secondaryFilter, + SyncGroup: syncGroup, + } + } + return []socket.Task{ + makeTask(messengerSyncGroup, messengerFilter, messengerCursor), + } +} + +func (c *Client) dispatchRequestedTable(ctx context.Context, response *PublishResponseData) error { + if response == nil { + return nil + } + tbl, err := response.Parse(ctx) + if err != nil { + return fmt.Errorf("failed to parse requested Business Inbox threads: %w", err) + } + c.PostHandlePublishResponse(tbl) + c.HandleEvent(ctx, tbl) + return nil +} + func (c *Client) handleFrame(ctx context.Context, frame []byte) error { var prd PublishResponseData err := json.Unmarshal(frame, &prd) @@ -216,6 +285,17 @@ func (c *Client) makeLSRequest(ctx context.Context, payload []byte, t int) (*Pub if err != nil { return nil, err } + if c.Platform == types.BusinessSuite { + var request SocketLSRequestPayload + if err = json.Unmarshal(jsonPayload, &request); err != nil { + return nil, err + } + if t == 4 { + err = c.businessSocket.publish(ctx, "/ls_req", jsonPayload, int64(request.RequestID)) + return nil, err + } + return c.businessSocket.request(ctx, jsonPayload, int64(request.RequestID)) + } resp, err := c.socket.DoOneOffStream(ctx, jsonPayload, t == 4) if err != nil { diff --git a/pkg/messagix/socket/database.go b/pkg/messagix/socket/database.go index b1af01de..1059aed2 100644 --- a/pkg/messagix/socket/database.go +++ b/pkg/messagix/socket/database.go @@ -1,5 +1,7 @@ package socket +const BusinessInboxFetchThreadsLabel = "313" + type SyncChannel int64 const ( @@ -40,3 +42,28 @@ func (t *FetchThreadsTask) GetLabel() string { func (t *FetchThreadsTask) Create() (any, string) { return t, "trq" } + +// FetchBusinessInboxThreadsTask is the thread-range request used by Meta +// Business Suite. It is intentionally separate from FetchThreadsTask: the +// latter addresses the personal Messenger mailbox (sync groups 1 and 95), +// while Business Suite routes Page and linked Instagram threads through +// business inbox sync groups with channel-specific secondary filters. +type FetchBusinessInboxThreadsTask struct { + Cursor string `json:"cursor"` + Filter int `json:"filter"` + FilterValue string `json:"filter_value"` + IsAfter int `json:"is_after"` + ParentThreadKey int64 `json:"parent_thread_key"` + ReferenceActivityTimestamp int64 `json:"reference_activity_timestamp"` + ReferenceThreadKey int64 `json:"reference_thread_key"` + SecondaryFilter int `json:"secondary_filter"` + SyncGroup int `json:"sync_group"` +} + +func (t *FetchBusinessInboxThreadsTask) GetLabel() string { + return BusinessInboxFetchThreadsLabel +} + +func (t *FetchBusinessInboxThreadsTask) Create() (any, string) { + return t, "trq" +} diff --git a/pkg/messagix/syncManager.go b/pkg/messagix/syncManager.go index 1dfb95cf..eb175455 100644 --- a/pkg/messagix/syncManager.go +++ b/pkg/messagix/syncManager.go @@ -34,13 +34,13 @@ func (c *Client) newSyncManager() *SyncManager { 1: {SendSyncParams: false, SyncChannel: socket.MailBox}, 2: {SendSyncParams: true, SyncChannel: socket.Contact}, // FB/IG sync params (previously null?): {"locale": "en_US"} //2: {SendSyncParams: false, SyncChannel: socket.Contact}, - 5: {SendSyncParams: true}, // FB sync params: {"locale": "en_US"} TODO may be removed - 6: {SendSyncParams: true}, // IG sync params: {"locale": "en_US"} - 7: {SendSyncParams: true}, // IG sync params: {"mnet_rank_types": [44]} - 16: {SendSyncParams: true}, // FB/IG sync params: {"locale": "en_US"} - 26: {SendSyncParams: true}, // FB sync params: {"locale": "en_US"} - 28: {SendSyncParams: true}, // FB sync params: {"locale": "en_US"} - 89: {SendSyncParams: true}, // FB/IG sync params: {"locale": "en_US"} + 5: {SendSyncParams: true}, // FB sync params: {"locale": "en_US"} TODO may be removed + 6: {SendSyncParams: true}, // IG sync params: {"locale": "en_US"} + 7: {SendSyncParams: true}, // IG sync params: {"mnet_rank_types": [44]} + 16: {SendSyncParams: true}, // FB/IG sync params: {"locale": "en_US"} + 26: {SendSyncParams: true, SyncChannel: socket.Contact}, // Business Inbox contact data: {"locale": "en_US"} + 28: {SendSyncParams: true}, // FB sync params: {"locale": "en_US"} + 89: {SendSyncParams: true}, // FB/IG sync params: {"locale": "en_US"} 95: {SendSyncParams: false, SyncChannel: socket.Contact}, 104: {SendSyncParams: true}, // FB sync params: {"locale": "en_US"} 120: {SendSyncParams: true}, // FB sync params: {"locale": "en_US"} @@ -53,6 +53,10 @@ func (c *Client) newSyncManager() *SyncManager { 197: {SendSyncParams: true}, // FB/IG sync params: {"locale": "en_US"} 198: {SendSyncParams: true}, // FB/IG sync params: {"locale": "en_US"} 202: {SendSyncParams: true}, // FB sync params: {"locale": "en_US"} + // Meta Business Suite's Page/linked-Instagram inbox databases. + // These are distinct from personal Messenger databases 1 and 95. + 127: {SendSyncParams: false, SyncChannel: socket.MailBox}, + 205: {SendSyncParams: false, SyncChannel: socket.MailBox}, }, keyStore: map[int64]*socket.KeyStoreData{ 1: {MinThreadKey: 0, ParentThreadKey: -1, MinLastActivityTimestampMs: 9999999999999, HasMoreBefore: false}, @@ -124,20 +128,25 @@ func (sm *SyncManager) recursivelySyncSocketData( if err != nil { return fmt.Errorf("failed to marshal database query: %w", err) } - req, packetID, err := sm.client.encodeLSRequest(jsonPayload, t) - if err != nil { - return fmt.Errorf("failed to encode lightspeed request: %w", err) - } - ch := make(chan *PublishResponseData) - if oldCh, swapped := sm.client.socketSyncWaiters.Swap(packetID, ch); swapped { - close(oldCh) - } - sm.client.Logger.Trace(). RawJSON("payload", jsonPayload). Int64("database_id", databaseID). Msg("Syncing database via socket") - if stream == nil { + var resp *PublishResponseData + if sm.client.Platform == types.BusinessSuite { + resp, err = sm.client.makeLSRequest(ctx, jsonPayload, t) + if err != nil { + return fmt.Errorf("failed to sync through Business Suite MQTT: %w", err) + } + } else if stream == nil { + req, packetID, encodeErr := sm.client.encodeLSRequest(jsonPayload, t) + if encodeErr != nil { + return fmt.Errorf("failed to encode lightspeed request: %w", encodeErr) + } + ch := make(chan *PublishResponseData) + if oldCh, swapped := sm.client.socketSyncWaiters.Swap(packetID, ch); swapped { + close(oldCh) + } stream, err = sm.client.socket.EstablishStream(ctx, dgw.StreamInit{ InitPayload: req, LogName: fmt.Sprintf("db %d", databaseID), @@ -146,23 +155,41 @@ func (sm *SyncManager) recursivelySyncSocketData( if err != nil { return fmt.Errorf("failed to establish stream: %w", err) } + select { + case resp = <-ch: + if resp == nil { + return fmt.Errorf("publish response data not received") + } + case <-ctx.Done(): + return ctx.Err() + case <-time.After(SyncResponseTimeout): + sm.client.socketSyncWaiters.Delete(packetID) + return fmt.Errorf("timeout waiting for database sync response") + } } else { + req, packetID, encodeErr := sm.client.encodeLSRequest(jsonPayload, t) + if encodeErr != nil { + return fmt.Errorf("failed to encode lightspeed request: %w", encodeErr) + } + ch := make(chan *PublishResponseData) + if oldCh, swapped := sm.client.socketSyncWaiters.Swap(packetID, ch); swapped { + close(oldCh) + } err = stream.SendData(ctx, req) if err != nil { return fmt.Errorf("failed to send recursive query: %w", err) } - } - var resp *PublishResponseData - select { - case resp = <-ch: - if resp == nil { - return fmt.Errorf("publish response data not received") + select { + case resp = <-ch: + if resp == nil { + return fmt.Errorf("publish response data not received") + } + case <-ctx.Done(): + return ctx.Err() + case <-time.After(SyncResponseTimeout): + sm.client.socketSyncWaiters.Delete(packetID) + return fmt.Errorf("timeout waiting for database sync response") } - case <-ctx.Done(): - return ctx.Err() - case <-time.After(SyncResponseTimeout): - sm.client.socketSyncWaiters.Delete(packetID) - return fmt.Errorf("timeout waiting for database sync response") } tbl, err := resp.Parse(ctx) diff --git a/pkg/messagix/types/account.go b/pkg/messagix/types/account.go index 9ac57aab..b7ef8f04 100644 --- a/pkg/messagix/types/account.go +++ b/pkg/messagix/types/account.go @@ -40,6 +40,17 @@ type CurrentBusinessAccount struct { ShouldShowAccountSwitchComponents bool `json:"shouldShowAccountSwitchComponents,omitempty"` } +// SwitchableProfile is a Facebook identity that the current personal account +// can act as. Facebook Pages are exposed in the web app's profile switcher and +// use i_user as the active actor while c_user remains the owning account. +type SwitchableProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Type string `json:"type,omitempty"` +} + type MessengerWebInitData struct { AccountKey string `json:"accountKey,omitempty"` //ActiveThreadKeys diff --git a/pkg/messagix/types/client.go b/pkg/messagix/types/client.go index 3618c0d4..14b0f37f 100644 --- a/pkg/messagix/types/client.go +++ b/pkg/messagix/types/client.go @@ -21,6 +21,7 @@ const ( MessengerLiteIOS MessengerLiteAndroid FacebookTor + BusinessSuite ) func PlatformFromString(s string) Platform { @@ -37,6 +38,8 @@ func PlatformFromString(s string) Platform { return MessengerLiteIOS case "messenger-lite-android": return MessengerLiteAndroid + case "business-suite": + return BusinessSuite default: return Unset } @@ -56,6 +59,8 @@ func (p *Platform) UnmarshalJSON(data []byte) error { *p = MessengerLiteIOS case `"messenger-lite-android"`, `6`: *p = MessengerLiteAndroid + case `"business-suite"`, `7`: + *p = BusinessSuite default: *p = Unset } @@ -76,6 +81,8 @@ func (p Platform) String() string { return "messenger-lite" case MessengerLiteAndroid: return "messenger-lite-android" + case BusinessSuite: + return "business-suite" default: return "" } @@ -94,7 +101,7 @@ func (p Platform) IsViaMessenger() bool { } func (p Platform) IsMessenger() bool { - return p.IsViaFacebook() || p.IsViaMessenger() + return p.IsViaFacebook() || p.IsViaMessenger() || p == BusinessSuite } func (p Platform) IsMessengerLite() bool { @@ -106,5 +113,5 @@ func (p Platform) IsInstagram() bool { } func (p Platform) IsValid() bool { - return p == Instagram || p == Facebook || p == FacebookTor || p == Messenger || p == MessengerLiteIOS || p == MessengerLiteAndroid + return p == Instagram || p == Facebook || p == FacebookTor || p == Messenger || p == MessengerLiteIOS || p == MessengerLiteAndroid || p == BusinessSuite } diff --git a/pkg/messagix/types/configs.go b/pkg/messagix/types/configs.go index b201ff5a..4ac0a4e7 100644 --- a/pkg/messagix/types/configs.go +++ b/pkg/messagix/types/configs.go @@ -92,6 +92,15 @@ type LSPlatformMessengerSyncParams struct { E2Ee string `json:"e2ee,omitempty"` } +// LSPlatformBizInboxSyncParams is the Page mailbox sync configuration emitted +// by business.facebook.com. It has the same wire shape as Messenger sync +// params, but points at the selected Page rather than the human account. +type LSPlatformBizInboxSyncParams struct { + Mailbox string `json:"mailbox,omitempty"` + Contact string `json:"contact,omitempty"` + E2Ee string `json:"e2ee,omitempty"` +} + type InitialCookieConsent struct { DeferCookies bool `json:"deferCookies,omitempty"` InitialConsent []int `json:"initialConsent,omitempty"` @@ -241,6 +250,7 @@ type SchedulerJSDefineConfig struct { DTSGInitialData DTSGInitialData CurrentUserInitialData CurrentUserInitialData LSPlatformMessengerSyncParams LSPlatformMessengerSyncParams + LSPlatformBizInboxSyncParams LSPlatformBizInboxSyncParams ServerNonce ServerNonce InitialCookieConsent InitialCookieConsent InstagramPasswordEncryption InstagramPasswordEncryption diff --git a/pkg/metaid/dbmeta.go b/pkg/metaid/dbmeta.go index 10b56be8..f2062e2f 100644 --- a/pkg/metaid/dbmeta.go +++ b/pkg/metaid/dbmeta.go @@ -34,6 +34,12 @@ type UserLoginMetadata struct { PushKeys *pushcrypto.PushKeys `json:"push_keys,omitempty"` LoginUA string `json:"login_ua,omitempty"` IGID string `json:"igid,omitempty"` + ActorID int64 `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + ActorType string `json:"actor_type,omitempty"` + BusinessID string `json:"business_id,omitempty"` + AssetID string `json:"asset_id,omitempty"` + PageID string `json:"page_id,omitempty"` // Thread backfill state BackfillCompleted bool `json:"backfill_completed,omitempty"`