Watch
1
0
Fork
You've already forked mautrix-discord
0
mirror of https://github.com/mautrix/discord.git synced 2026-08-22 12:04:54 -04:00
mautrix-discord/pkg/meowcord/droid.go
Skip R e2d47d7d32 meow: import beeper fork
[skip ci] for now, will fix lints and checks in a later pass.
2026-07-14 11:36:09 -07:00

346 lines
12 KiB
Go

// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 The mautrix-discord contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package meowcord
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"github.com/google/uuid"
)
const (
droidOS = "Windows"
droidOSVersion = "10"
droidBrowser = "Chrome"
droidReferrer = "https://discord.com/channels/@me"
droidReferringDomain = "discord.com"
droidReleaseChannel = "stable"
droidStatus = "invisible"
droidSystemLocale = "en-US"
)
var (
droidCapabilities = 1734653
droidClientBuildNumber = 497254
droidGatewayURL = ""
mainPageLoaded = false
)
var mainPageLoadLock sync.Mutex
const (
DroidBrowserMajorVersion = "144"
DroidBrowserVersion = DroidBrowserMajorVersion + ".0.0.0"
DroidBrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + DroidBrowserVersion + " Safari/537.36"
)
// BaseProperties contains the data common to both the X-Super-Properties header value
// and the properties sent during IDENTIFY.
type BaseProperties struct {
OS string `json:"os"`
Browser string `json:"browser"`
Device string `json:"device"`
SystemLocale string `json:"system_locale"`
HasClientMods bool `json:"has_client_mods"`
BrowserUserAgent string `json:"browser_user_agent"`
BrowserVersion string `json:"browser_version"`
OSVersion string `json:"os_version"`
Referrer string `json:"referrer"`
ReferringDomain string `json:"referring_domain"`
ReferrerCurrent string `json:"referrer_current"`
ReferringDomainCurrent string `json:"referring_domain_current"`
ReleaseChannel string `json:"release_channel"`
ClientBuildNumber int `json:"client_build_number"`
ClientEventSource *string `json:"client_event_source"`
ClientLaunchID uuid.UUID `json:"client_launch_id"`
LaunchSignature LaunchSignature `json:"launch_signature"`
// ClientAppState is either "unfocused" or "focused".
ClientAppState string `json:"client_app_state"`
}
// SuperProperties is sent as the X-Super-Properties header.
type SuperProperties struct {
BaseProperties
ClientHeartbeatSessionID uuid.UUID `json:"client_heartbeat_session_id"`
}
// UserIdentifyProperties is sent when IDENTIFYing to the gateway.
type UserIdentifyProperties struct {
BaseProperties
IsFastConnect bool `json:"is_fast_connect"`
GatewayConnectReasons string `json:"gateway_connect_reasons"`
}
type ClientState struct {
GuildVersions struct{} `json:"guild_versions"`
HighestLastMessageID string `json:"highest_last_message_id,omitempty"`
ReadStateVersion int `json:"read_state_version,omitempty"`
UserGuildSettingsVersion int `json:"user_guild_settings_version,omitempty"`
UserSettingsVersion int `json:"user_settings_version,omitempty"`
PrivateChannelsVersion string `json:"private_channels_version,omitempty"`
APICodeVersion int `json:"api_code_version,omitempty"`
}
func mustMarshalJSON(data interface{}) string {
dat, err := json.Marshal(data)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(dat)
}
func basedOn(base map[string]string, additional map[string]string) map[string]string {
for k, v := range base {
_, exists := additional[k]
if !exists {
additional[k] = v
}
}
return additional
}
func (s *Session) UpdateVersion(version, capabilities int) {
droidClientBuildNumber = version
droidCapabilities = capabilities
droidBaseProperties.ClientBuildNumber = version
s.UpdateUserHeaders()
}
func (s *Session) UpdateUserHeaders() {
baseProps := *droidBaseProperties
baseProps.LaunchSignature = s.launchSignature
baseProps.ClientLaunchID = s.launchID
superProps := SuperProperties{
BaseProperties: baseProps,
ClientHeartbeatSessionID: s.HeartbeatSession.ID,
}
superPropsHeader := "X-Super-Properties"
encodedSuperProps := mustMarshalJSON(superProps)
s.fetchHeaders = basedOn(DroidBaseHeaders, map[string]string{
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"X-Debug-Options": "bugReporterEnabled",
"X-Discord-Locale": droidSystemLocale,
"X-Discord-Timezone": "UTC",
superPropsHeader: encodedSuperProps,
})
s.downloadHeaders = basedOn(s.fetchHeaders, map[string]string{
"Sec-Fetch-Mode": "no-cors",
superPropsHeader: encodedSuperProps,
})
s.imageHeaders = basedOn(s.downloadHeaders, map[string]string{
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Sec-Fetch-Dest": "image",
superPropsHeader: encodedSuperProps,
})
identifyProps := UserIdentifyProperties{
BaseProperties: baseProps,
IsFastConnect: false,
GatewayConnectReasons: "AppSkeleton",
}
s.Identify.Properties = identifyProps
}
func (s *Session) SetGatewayURL(url string) {
s.gateway = url
s.noClearGateway = true
}
var apiVersionRegex = regexp.MustCompile(`"?API_VERSION"?:\s?(\d+),`)
var gatewayURLRegex = regexp.MustCompile(`"?GATEWAY_ENDPOINT"?:\s?['"](.+?)['"],`)
var mainJSRegex = regexp.MustCompile(`src="(/assets/web.[a-f0-9]{12,32}.js)"`)
var buildNumberRegex = regexp.MustCompile(`(?:buildNumber|build_number):\s?['"]?(\d{6,})['"]?`)
func (s *Session) LoadMainPage(ctx context.Context) error {
mainPageLoadLock.Lock()
defer mainPageLoadLock.Unlock()
if mainPageLoaded && droidGatewayURL != "" {
s.SetGatewayURL(droidGatewayURL)
return nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com/channels/@me", nil)
if err != nil {
return fmt.Errorf("failed to prepare request: %w", err)
}
for name, value := range DroidBaseHeaders {
req.Header.Add(name, value)
}
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "none")
req.Header.Set("Sec-Fetch-User", "?1")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
resp, err := s.Client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch main page: %w", err)
}
data, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return fmt.Errorf("failed to read main page: %w", err)
}
apiVersionMatch := apiVersionRegex.FindSubmatch(data)
if apiVersionMatch == nil {
return fmt.Errorf("failed to find API version")
} else if string(apiVersionMatch[1]) != APIVersion {
return fmt.Errorf("API version mismatch: expected %s, got %s", APIVersion, apiVersionMatch[1])
}
gatewayURLMatch := gatewayURLRegex.FindSubmatch(data)
if gatewayURLMatch == nil {
return fmt.Errorf("failed to find gateway URL")
}
droidGatewayURL = string(gatewayURLMatch[1])
if !strings.HasSuffix(droidGatewayURL, "/") {
droidGatewayURL += "/"
}
s.log(LogInformational, "Found gateway URL %s and confirmed API version", droidGatewayURL)
s.SetGatewayURL(droidGatewayURL)
mainJSMatch := mainJSRegex.FindSubmatch(data)
if mainJSMatch == nil {
return fmt.Errorf("failed to find main JS URL")
}
jsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com"+string(mainJSMatch[1]), nil)
if err != nil {
return fmt.Errorf("failed to prepare JS request: %w", err)
}
for name, value := range DroidBaseHeaders {
req.Header.Add(name, value)
}
jsReq.Header.Set("Sec-Fetch-Dest", "script")
jsReq.Header.Set("Sec-Fetch-Mode", "no-cors")
jsReq.Header.Set("Sec-Fetch-Site", "same-origin")
jsReq.Header.Set("Accept", "*/*")
jsResp, err := s.Client.Do(jsReq)
if err != nil {
return fmt.Errorf("failed to fetch JS: %w", err)
}
jsData, err := io.ReadAll(jsResp.Body)
_ = jsResp.Body.Close()
if err != nil {
return fmt.Errorf("failed to read JS: %w", err)
}
buildNumberMatch := buildNumberRegex.FindSubmatch(jsData)
if buildNumberMatch == nil {
return fmt.Errorf("failed to find build number")
}
buildNumberInt, err := strconv.Atoi(string(buildNumberMatch[1]))
if err != nil {
return fmt.Errorf("failed to parse build number %s: %w", buildNumberMatch[1], err)
}
s.log(LogInformational, "Found build number %d from JS file %s", buildNumberInt, string(mainJSMatch[1]))
// TODO parse capabilities too?
s.UpdateVersion(buildNumberInt, droidCapabilities)
mainPageLoaded = true
return nil
}
var (
droidBaseProperties = &BaseProperties{
OS: droidOS,
OSVersion: droidOSVersion,
Browser: droidBrowser,
BrowserVersion: DroidBrowserVersion,
BrowserUserAgent: DroidBrowserUserAgent,
//Referrer: droidReferrer,
//ReferringDomain: droidReferringDomain,
ClientBuildNumber: droidClientBuildNumber,
ReleaseChannel: droidReleaseChannel,
SystemLocale: droidSystemLocale,
ClientAppState: "focused",
}
DroidBaseHeaders = map[string]string{
"Sec-Ch-Ua": fmt.Sprintf(`" Not A;Brand";v="99", "Chromium";v="%[1]s", "Google Chrome";v="%[1]s"`, DroidBrowserMajorVersion),
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": `"` + droidOS + `"`,
"Accept": "*/*",
"Origin": "https://discord.com",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": DroidBrowserUserAgent,
}
DroidFetchHeaders = basedOn(DroidBaseHeaders, map[string]string{})
DroidDownloadHeaders = basedOn(DroidFetchHeaders, map[string]string{
"Sec-Fetch-Mode": "no-cors",
})
DroidWSHeaders = map[string]string{
"User-Agent": DroidBrowserUserAgent,
"Origin": "https://discord.com",
"Accept-Language": "en-US,en;q=0.9",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
"Accept-Encoding": "gzip, deflate, br",
//"Sec-Fetch-Dest": "websocket",
//"Sec-Fetch-Mode": "websocket",
//"Sec-Fetch-Site": "cross-site",
}
)
const (
ThreadJoinLocationContextMenu = "Context Menu"
ThreadJoinLocationToolbarOverflow = "Toolbar Overflow"
ThreadJoinLocationSidebarOverflow = "Sidebar Overflow"
)
const (
ReactionLocationHoverBar = "Message Hover Bar"
ReactionLocationInlineButton = "Message Inline Button"
ReactionLocationPicker = "Message Reaction Picker"
ReactionLocationContextMenu = "Message Context Menu"
)
func (s *Session) MessageReactionAddUser(guildID, channelID, messageID, emojiID string, options ...RequestOption) error {
if s.IsUser {
options = append(
options,
WithChannelReferer(guildID, channelID),
WithLocationParam(ReactionLocationPicker),
WithQueryParam("type", "0"),
)
}
return s.MessageReactionAdd(channelID, messageID, emojiID, options...)
}
func (s *Session) MessageReactionRemoveUser(guildID, channelID, messageID, emojiID, userID string, options ...RequestOption) error {
if s.IsUser {
options = append(
options,
WithChannelReferer(guildID, channelID),
WithLocationParam(ReactionLocationInlineButton),
WithQueryParam("burst", "false"),
)
}
return s.MessageReactionRemove(channelID, messageID, emojiID, userID, options...)
}