Files
sing-box-extended/transport/call/vk/api.go

285 lines
8.7 KiB
Go

package vk
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
type TurnServer struct {
URLs []string `json:"urls"`
Username string `json:"username"`
Credential string `json:"credential"`
}
type StunServer struct {
URLs []string `json:"urls"`
}
type CallInfo struct {
CallID string
JoinLink string
ShortLink string
OKJoinLink string
TurnServer TurnServer
StunServer StunServer
WSEndpoint string
}
type vkTokenResponse struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
type callSettingsResponse struct {
Response struct {
Settings struct {
PublicKey string `json:"public_key"`
} `json:"settings"`
} `json:"response"`
}
type callTokenResponse struct {
Response struct {
Token string `json:"token"`
APIBaseURL string `json:"api_base_url"`
} `json:"response"`
}
type okAuthResponse struct {
SessionKey string `json:"session_key"`
}
type joinResponse struct {
Endpoint string `json:"endpoint"`
TurnServer TurnServer `json:"turn_server"`
StunServer StunServer `json:"stun_server"`
}
func JoinExistingCall(dialer N.Dialer, cookieStr, vkLink string, cfg VKConfig, logger logger.ContextLogger) (*CallInfo, error) {
if cfg.AppID == "" || cfg.APIVersion == "" {
return nil, fmt.Errorf("config incomplete: app_id=%q api=%q", cfg.AppID, cfg.APIVersion)
}
token := extractJoinToken(vkLink)
if token == "" {
return nil, fmt.Errorf("could not extract join token from %q", vkLink)
}
logger.Info(fmt.Sprintf("[auth] Joining existing call token=%s", token))
resp, err := authAndJoin(dialer, cookieStr, token, cfg)
if err != nil {
return nil, err
}
return &CallInfo{
JoinLink: vkLink,
OKJoinLink: token,
TurnServer: resp.TurnServer,
StunServer: resp.StunServer,
WSEndpoint: resp.Endpoint,
}, nil
}
func CreateAndJoinCall(dialer N.Dialer, cookieStr, peerId string, cfg VKConfig, logger logger.ContextLogger) (*CallInfo, error) {
if cfg.AppID == "" || cfg.APIVersion == "" {
return nil, fmt.Errorf("config incomplete: app_id=%q api=%q", cfg.AppID, cfg.APIVersion)
}
auth := func(bearer string) map[string]string {
return map[string]string{"Authorization": "Bearer " + bearer}
}
logger.Info("[auth] Getting VK token...")
r, err := httpPost(dialer, "https://login.vk.com/?act=web_token",
url.Values{"version": {"1"}, "app_id": {cfg.AppID}},
map[string]string{"Cookie": cookieStr})
if err != nil {
return nil, fmt.Errorf("web_token: %w", err)
}
var tok vkTokenResponse
json.Unmarshal(r, &tok)
vkToken := tok.Data.AccessToken
if vkToken == "" {
return nil, fmt.Errorf("empty VK token, response: %s", string(r))
}
logger.Info(fmt.Sprintf("[auth] Creating call peer_id=%s...", peerId))
r, err = httpPost(dialer, "https://api.vk.com/method/calls.start",
url.Values{"v": {cfg.APIVersion}, "peer_id": {peerId}}, auth(vkToken))
if err != nil {
return nil, fmt.Errorf("calls.start: %w", err)
}
var call struct {
Response struct {
CallID string `json:"call_id"`
JoinLink string `json:"join_link"`
OKJoinLink string `json:"ok_join_link"`
ShortCredentials struct {
LinkWithPassword string `json:"link_with_password"`
} `json:"short_credentials"`
} `json:"response"`
}
json.Unmarshal(r, &call)
c := call.Response
if c.CallID == "" {
return nil, fmt.Errorf("empty call_id, response: %s", string(r))
}
if c.OKJoinLink == "" {
return nil, fmt.Errorf("empty ok_join_link, response: %s", string(r))
}
logger.Debug(fmt.Sprintf("[auth] call_id: %s", c.CallID))
logger.Debug(fmt.Sprintf("[auth] join_link: %s", c.JoinLink))
logger.Info("[auth] Joining conversation...")
resp, err := authAndJoin(dialer, cookieStr, c.OKJoinLink, cfg)
if err != nil {
return nil, err
}
return &CallInfo{
CallID: c.CallID, JoinLink: c.JoinLink, ShortLink: c.ShortCredentials.LinkWithPassword,
OKJoinLink: c.OKJoinLink, TurnServer: resp.TurnServer, StunServer: resp.StunServer,
WSEndpoint: resp.Endpoint,
}, nil
}
func BuildICEServers(callInfo *CallInfo) []ICEServerSpec {
var servers []ICEServerSpec
if len(callInfo.StunServer.URLs) > 0 {
servers = append(servers, ICEServerSpec{URLs: callInfo.StunServer.URLs})
}
if len(callInfo.TurnServer.URLs) > 0 {
urls := append([]string{}, callInfo.TurnServer.URLs...)
urls = append(urls, urls[len(urls)-1]+"?transport=tcp")
servers = append(servers, ICEServerSpec{
URLs: urls, Username: callInfo.TurnServer.Username, Credential: callInfo.TurnServer.Credential,
})
}
return servers
}
type ICEServerSpec struct {
URLs []string
Username string
Credential string
}
func authAndJoin(dialer N.Dialer, cookieStr, okJoinLink string, cfg VKConfig) (*joinResponse, error) {
auth := func(bearer string) map[string]string {
return map[string]string{"Authorization": "Bearer " + bearer}
}
r, err := httpPost(dialer, "https://login.vk.com/?act=web_token",
url.Values{"version": {"1"}, "app_id": {cfg.AppID}},
map[string]string{"Cookie": cookieStr})
if err != nil {
return nil, fmt.Errorf("web_token: %w", err)
}
var tok vkTokenResponse
json.Unmarshal(r, &tok)
if tok.Data.AccessToken == "" {
return nil, fmt.Errorf("empty VK token, response: %s", string(r))
}
r, err = httpPost(dialer, "https://api.vk.com/method/calls.getSettings",
url.Values{"v": {cfg.APIVersion}}, auth(tok.Data.AccessToken))
if err != nil {
return nil, fmt.Errorf("calls.getSettings: %w", err)
}
var settings callSettingsResponse
json.Unmarshal(r, &settings)
appKey := settings.Response.Settings.PublicKey
if appKey == "" {
return nil, fmt.Errorf("empty public_key, response: %s", string(r))
}
r, err = httpPost(dialer, "https://api.vk.com/method/messages.getCallToken",
url.Values{"v": {cfg.APIVersion}, "env": {"production"}}, auth(tok.Data.AccessToken))
if err != nil {
return nil, fmt.Errorf("messages.getCallToken: %w", err)
}
var callToken callTokenResponse
json.Unmarshal(r, &callToken)
if callToken.Response.Token == "" {
return nil, fmt.Errorf("empty call token, response: %s", string(r))
}
if callToken.Response.APIBaseURL == "" {
return nil, fmt.Errorf("empty api_base_url, response: %s", string(r))
}
apiBaseURL := strings.TrimRight(callToken.Response.APIBaseURL, "/")
if !strings.HasSuffix(apiBaseURL, "/fb.do") {
apiBaseURL += "/fb.do"
}
sd, _ := json.Marshal(map[string]interface{}{
"device_id": "sing-box-go-1", "client_version": cfg.AppVersion,
"client_type": "SDK_JS", "auth_token": callToken.Response.Token, "version": 3,
})
r, err = httpPost(dialer, apiBaseURL, url.Values{
"method": {"auth.anonymLogin"}, "application_key": {appKey},
"format": {"json"}, "session_data": {string(sd)},
}, nil)
if err != nil {
return nil, fmt.Errorf("auth.anonymLogin: %w", err)
}
var okAuth okAuthResponse
json.Unmarshal(r, &okAuth)
if okAuth.SessionKey == "" {
return nil, fmt.Errorf("empty session_key, response: %s", string(r))
}
ms, _ := json.Marshal(map[string]bool{
"isAudioEnabled": false, "isVideoEnabled": true, "isScreenSharingEnabled": false,
})
r, err = httpPost(dialer, apiBaseURL, url.Values{
"method": {"vchat.joinConversationByLink"}, "session_key": {okAuth.SessionKey},
"application_key": {appKey}, "format": {"json"}, "joinLink": {okJoinLink},
"isVideo": {"true"}, "isAudio": {"false"}, "mediaSettings": {string(ms)},
}, nil)
if err != nil {
return nil, fmt.Errorf("vchat.joinConversationByLink: %w", err)
}
var jr joinResponse
json.Unmarshal(r, &jr)
if jr.Endpoint == "" {
return nil, fmt.Errorf("empty WS endpoint, response: %s", string(r))
}
return &jr, nil
}
func extractJoinToken(link string) string {
link = strings.TrimSpace(link)
if link == "" {
return ""
}
if u, err := url.Parse(link); err == nil && u.Scheme != "" {
path := strings.Trim(u.Path, "/")
if path != "" {
parts := strings.Split(path, "/")
return parts[len(parts)-1]
}
}
if !strings.ContainsAny(link, "/?&=") {
return link
}
parts := strings.Split(strings.TrimRight(link, "/"), "/")
return parts[len(parts)-1]
}
func httpPost(dialer N.Dialer, endpoint string, form url.Values, extraHeaders map[string]string) ([]byte, error) {
body := form.Encode()
req, err := http.NewRequest("POST", endpoint, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", common.UserAgent)
req.Header.Set("Origin", "https://vk.com")
req.Header.Set("Referer", "https://vk.com/")
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := common.HttpClient(dialer).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}