package bitrix import ( "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "regexp" "strconv" "strings" "github.com/sagernet/sing-box/transport/call/common" "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" "github.com/google/uuid" headless "github.com/kulikov0/headless-client" ) const ( slbBase = "https://slb.bitrix24.tech/v2/join" signalSDK = "js" signalVersion = "1.6.7" signalProtocol = "8" clientVersion = "1.0.0" clientPlatform = "web" callTypeInstant = 2 roomTypeSmall = 1 ) var ( reConferenceID = regexp.MustCompile(`conferenceId:\s*'([^']+)'`) reChatID = regexp.MustCompile(`chatId:\s*'([^']+)'`) reAlias = regexp.MustCompile(`alias:\s*'([^']+)'`) reSessid = regexp.MustCompile(`bitrix_sessid["']?\s*[:=]\s*["']([a-f0-9]{8,})`) reUserID = regexp.MustCompile(`"USER_ID"\s*:\s*"?(\d+)`) ) type Client struct { HTTP *http.Client Logger logger.ContextLogger portal string sessid string userAgent string instanceID string email string password string chatID string conferenceID string selfUserID string } type JoinResult struct { RoomData string `json:"roomData"` RoomID string `json:"roomId"` MediaServerURL string `json:"mediaServerUrl"` } type conferenceParams struct { ConferenceID string ChatID string Alias string } type callInfo struct { UUID string ChatID string CallToken string UserToken string Alias string GuestLink string } type PullConfigResult struct { WebSocket string ChannelID string Hostname string Revision int } type extraHeader struct { Name string Value string } type slbRequest struct { UserToken string `json:"userToken"` IsOneToOne bool `json:"isOneToOne"` ClientVersion string `json:"clientVersion"` ClientPlatform string `json:"clientPlatform"` CallType int `json:"callType"` RoomType int `json:"roomType"` InstanceID string `json:"instanceId"` CallToken string `json:"callToken"` Provider string `json:"provider"` IsVideo bool `json:"isVideo"` CallUUID string `json:"callUuid"` } func NewClient(portal, userAgent string, dialer N.Dialer, log logger.ContextLogger) (*Client, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } if log == nil { log = logger.NOP() } httpClient := common.HttpClient(dialer) httpClient.Jar = jar return &Client{ HTTP: httpClient, Logger: log, portal: strings.TrimRight(portal, "/"), userAgent: userAgent, instanceID: uuid.New().String(), }, nil } func SignalURL(res JoinResult) string { q := url.Values{} q.Set("auto_subscribe", "1") q.Set("sdk", signalSDK) q.Set("version", signalVersion) q.Set("protocol", signalProtocol) q.Set("roomData", res.RoomData) q.Set("clientVersion", clientVersion) q.Set("clientPlatform", clientPlatform) return res.MediaServerURL + "?" + q.Encode() } func (c *Client) JoinAsGuest(alias, displayName string) (JoinResult, error) { var res JoinResult conf, err := c.fetchConference(alias) if err != nil { return res, err } c.chatID = conf.ChatID c.conferenceID = conf.ConferenceID userToken, err := c.registerGuest(conf, displayName) if err != nil { return res, err } info, err := c.tryJoinCall(conf.ChatID) if err != nil { return res, err } info.UserToken = userToken return c.slbJoin(info, false) } func (c *Client) JoinAsHost(alias string) (JoinResult, error) { var res JoinResult conf, err := c.fetchConference(alias) if err != nil { return res, err } c.Logger.Debug(fmt.Sprintf("[host] conference chatId=%s conferenceId=%s", conf.ChatID, conf.ConferenceID)) c.chatID = conf.ChatID callToken, userToken, err := c.getCallToken(conf.ChatID) if err != nil { return res, err } info := callInfo{ UUID: uuid.New().String(), ChatID: conf.ChatID, CallToken: callToken, UserToken: userToken, } c.Logger.Debug(fmt.Sprintf("[host] creating media room callUUID=%s callToken=%dB userToken=%dB", info.UUID, len(info.CallToken), len(info.UserToken))) return c.slbJoin(info, true) } func (c *Client) CreateAndJoin() (JoinResult, string, error) { var res JoinResult info, err := c.createRoom() if err != nil { return res, "", err } c.chatID = info.ChatID guestLink := info.GuestLink if guestLink == "" { guestLink, err = c.getGuestLink(info.ChatID) if err != nil { return res, "", err } } if info.CallToken == "" { callToken, userToken, err := c.getCallToken(info.ChatID) if err != nil { return res, "", err } info.CallToken = callToken if info.UserToken == "" { info.UserToken = userToken } } res, err = c.slbJoin(info, true) if err != nil { return res, "", err } return res, guestLink, nil } func (c *Client) resetJar() error { jar, err := cookiejar.New(nil) if err != nil { return err } c.HTTP.Jar = jar c.sessid = "" return nil } func secFetchSiteFor(endpoint, origin string) string { endpointURL, err := url.Parse(endpoint) if err != nil { return "cross-site" } originURL, err := url.Parse(origin) if err != nil { return "cross-site" } if endpointURL.Host == originURL.Host { return "same-origin" } return "cross-site" } func (c *Client) do(method, endpoint, contentType string, body io.Reader, extraHeaders ...extraHeader) ([]byte, int, error) { req, err := http.NewRequest(method, endpoint, body) if err != nil { return nil, 0, err } req.Header = headless.ChromeWindows.Headers(headless.DestEmpty) if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } req.Header.Set("Accept", "application/json, text/plain, */*") if c.portal != "" { req.Header.Set("Origin", c.portal) req.Header.Set("Referer", c.portal+"/") req.Header.Set("Sec-Fetch-Site", secFetchSiteFor(endpoint, c.portal)) } if contentType != "" { req.Header.Set("Content-Type", contentType) } for _, h := range extraHeaders { req.Header.Set(h.Name, h.Value) } resp, err := c.HTTP.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) return data, resp.StatusCode, err } func (c *Client) restForm(action string, form url.Values) ([]byte, int, error) { endpoint := c.portal + "/rest/" + action + ".json" return c.do("POST", endpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) } func (c *Client) restFormAuthed(action string, form url.Values) ([]byte, int, error) { endpoint := c.portal + "/rest/" + action + ".json" var extraHeaders []extraHeader if c.sessid != "" { form.Set("sessid", c.sessid) endpoint += "?sessid=" + url.QueryEscape(c.sessid) extraHeaders = append(extraHeaders, extraHeader{Name: "X-Bitrix-Csrf-Token", Value: c.sessid}) } return c.do("POST", endpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()), extraHeaders...) } func (c *Client) ajaxAction(action string, form url.Values) ([]byte, int, error) { endpoint := c.portal + "/bitrix/services/main/ajax.php?action=" + action var extraHeaders []extraHeader if c.sessid != "" { endpoint += "&sessid=" + url.QueryEscape(c.sessid) form.Set("sessid", c.sessid) extraHeaders = append(extraHeaders, extraHeader{Name: "X-Bitrix-Csrf-Token", Value: c.sessid}) } return c.do("POST", endpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()), extraHeaders...) } func (c *Client) fetchConference(alias string) (conferenceParams, error) { var p conferenceParams body, status, err := c.do("GET", c.portal+"/video/"+alias, "", nil) if err != nil { return p, err } if status != 200 { return p, fmt.Errorf("fetch conference page: status %d", status) } page := string(body) if m := reConferenceID.FindStringSubmatch(page); m != nil { p.ConferenceID = m[1] } if m := reChatID.FindStringSubmatch(page); m != nil { p.ChatID = m[1] } if m := reAlias.FindStringSubmatch(page); m != nil { p.Alias = m[1] } else { p.Alias = alias } if m := reSessid.FindStringSubmatch(page); m != nil { c.sessid = m[1] } if p.ConferenceID == "" || p.ChatID == "" { return p, fmt.Errorf("conference params not found in page (conferenceId=%q chatId=%q)", p.ConferenceID, p.ChatID) } return p, nil } func (c *Client) registerGuest(p conferenceParams, displayName string) (userToken string, err error) { form := url.Values{} form.Set("call_auth_id", "guest") form.Set("videoconf_id", p.ConferenceID) form.Set("call_chat_id", p.ChatID) form.Set("alias", p.Alias) form.Set("user_hash", "") if displayName != "" { form.Set("name", displayName) } body, status, err := c.restForm("call.user.register", form) if err != nil { return "", err } var out struct { Result struct { UserToken string `json:"userToken"` } `json:"result"` Error string `json:"error"` ErrorDescription string `json:"error_description"` } if err := json.Unmarshal(body, &out); err != nil { return "", fmt.Errorf("register guest: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } if out.Result.UserToken == "" { return "", fmt.Errorf("register guest: no userToken (status %d, err %s %s)", status, out.Error, out.ErrorDescription) } return out.Result.UserToken, nil } func (c *Client) tryJoinCall(chatID string) (callInfo, error) { var info callInfo form := url.Values{} form.Set("entityType", "chat") form.Set("entityId", "chat"+chatID) form.Set("provider", "Bitrix") form.Set("callType", fmt.Sprintf("%d", callTypeInstant)) body, status, err := c.withRelogin(func() ([]byte, int, error) { return c.ajaxAction("call.Call.tryJoinCall", form) }) if err != nil { return info, err } var out struct { Status string `json:"status"` Data json.RawMessage `json:"data"` Errors []struct { Message string `json:"message"` Code any `json:"code"` } `json:"errors"` } if err := json.Unmarshal(body, &out); err != nil { return info, fmt.Errorf("tryJoinCall: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } var data struct { Success bool `json:"success"` Call struct { UUID string `json:"UUID"` ChatID json.Number `json:"CHAT_ID"` } `json:"call"` CallToken string `json:"callToken"` } if err := json.Unmarshal(out.Data, &data); err != nil && out.Status == "success" { return info, fmt.Errorf("tryJoinCall: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } if out.Status == "success" && !data.Success { return info, fmt.Errorf("tryJoinCall: no active call at chat%s, organizer not in the room", chatID) } if out.Status != "success" || data.Call.UUID == "" { return info, fmt.Errorf("tryJoinCall failed (status %d, %s, errors %+v, body %s)", status, out.Status, out.Errors, common.BodySnippet(body)) } info.UUID = data.Call.UUID info.ChatID = data.Call.ChatID.String() info.CallToken = data.CallToken return info, nil } func (c *Client) slbJoin(info callInfo, mustCreate bool) (JoinResult, error) { var res JoinResult reqBody := slbRequest{ UserToken: info.UserToken, IsOneToOne: false, ClientVersion: clientVersion, ClientPlatform: clientPlatform, CallType: callTypeInstant, RoomType: roomTypeSmall, InstanceID: c.instanceID, CallToken: info.CallToken, Provider: "Bitrix", IsVideo: true, CallUUID: info.UUID, } payload, _ := json.Marshal(reqBody) endpoint := slbBase + "?mustCreate=" + strconv.FormatBool(mustCreate) body, status, err := c.do("POST", endpoint, "text/plain;charset=UTF-8", strings.NewReader(string(payload))) if err != nil { return res, err } var out struct { Result JoinResult `json:"result"` Error json.RawMessage `json:"error"` } if err := json.Unmarshal(body, &out); err != nil { return res, fmt.Errorf("slb join: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } if out.Result.MediaServerURL == "" || out.Result.RoomData == "" { return res, fmt.Errorf("slb join: missing roomData/mediaServerUrl (status %d, err %s)", status, string(out.Error)) } return out.Result, nil } func (c *Client) currentUserID() (string, error) { body, status, err := c.do("GET", c.portal+"/online/", "", nil) if err != nil { return "", err } if m := reUserID.FindSubmatch(body); m != nil && string(m[1]) != "0" { return string(m[1]), nil } return "", fmt.Errorf("current user id not found (status %d)", status) } func (c *Client) createRoom() (callInfo, error) { var info callInfo uid, err := c.currentUserID() if err != nil { return info, fmt.Errorf("createRoom: %w", err) } form := url.Values{} form.Set("fields[entityType]", "VIDEOCONF") form.Set("fields[title]", "") form.Set("fields[memberEntities][0][0]", "user") form.Set("fields[memberEntities][0][1]", uid) form.Set("fields[ownerId]", uid) form.Set("fields[description]", "") form.Set("fields[manageUsersAdd]", "member") form.Set("fields[manageUsersDelete]", "manager") form.Set("fields[manageUi]", "member") form.Set("fields[manageMessages]", "member") form.Set("fields[conferencePassword]", "") body, status, err := c.withRelogin(func() ([]byte, int, error) { return c.ajaxAction("im.v2.Chat.add", form) }) if err != nil { return info, err } var out struct { Status string `json:"status"` Data json.RawMessage `json:"data"` Errors []struct { Code any `json:"code"` Message string `json:"message"` } `json:"errors"` } if err := json.Unmarshal(body, &out); err != nil { return info, fmt.Errorf("createRoom: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } var data struct { ChatID json.Number `json:"chatId"` Alias string `json:"alias"` Link string `json:"link"` } if err := json.Unmarshal(out.Data, &data); err == nil { info.ChatID = data.ChatID.String() info.Alias = data.Alias info.GuestLink = data.Link } if info.ChatID == "" { errMsg := "" if len(out.Errors) > 0 { errMsg = fmt.Sprintf("%v %s", out.Errors[0].Code, out.Errors[0].Message) } return info, fmt.Errorf("createRoom: no chatId (status %d, status=%q err=%q, body %s)", status, out.Status, errMsg, common.BodySnippet(body)) } c.Logger.Debug(fmt.Sprintf("[create] videoconf chatId=%s alias=%s", info.ChatID, info.Alias)) return info, nil } func (c *Client) KickUser(userID string) error { if c.chatID == "" { return fmt.Errorf("kick: no active chatId") } if userID == "" { return fmt.Errorf("kick: empty userId") } form := url.Values{} form.Set("chatId", c.chatID) form.Set("userId", userID) body, status, err := c.withRelogin(func() ([]byte, int, error) { return c.ajaxAction("im.v2.Chat.deleteUser", form) }) if err != nil { return err } var out struct { Status string `json:"status"` Errors []struct { Code any `json:"code"` Message string `json:"message"` } `json:"errors"` } if err := json.Unmarshal(body, &out); err != nil { return fmt.Errorf("kick: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } if len(out.Errors) > 0 { return fmt.Errorf("kick: chatId=%s userId=%s rejected (status %d, %v %s)", c.chatID, userID, status, out.Errors[0].Code, out.Errors[0].Message) } c.Logger.Debug(fmt.Sprintf("[kick] removed userId=%s from chatId=%s", userID, c.chatID)) return nil } func (c *Client) SelfUserID() (string, error) { if c.selfUserID != "" { return c.selfUserID, nil } id, err := c.currentUserID() if err != nil { return "", err } c.selfUserID = id return id, nil } func (c *Client) callHash() string { if c.HTTP == nil || c.HTTP.Jar == nil { return "" } u, err := url.Parse(c.portal) if err != nil { return "" } for _, ck := range c.HTTP.Jar.Cookies(u) { if ck.Name == "BITRIX_CALL_HASH" { return ck.Value } } return "" } func (c *Client) PullConfig() (PullConfigResult, error) { var pc PullConfigResult form := url.Values{} form.Set("CACHE", "N") var body []byte var status int var err error if hash := c.callHash(); hash != "" { form.Set("call_auth_id", hash) form.Set("videoconf_id", c.conferenceID) form.Set("call_chat_id", c.chatID) body, status, err = c.restForm("pull.config.get", form) } else { body, status, err = c.withRelogin(func() ([]byte, int, error) { return c.restFormAuthed("pull.config.get", form) }) } if err != nil { return pc, err } var out struct { Result struct { Server struct { WebSocket string `json:"websocket"` } `json:"server"` API struct { RevisionWeb int `json:"revision_web"` } `json:"api"` Channels struct { Shared struct { ID string `json:"id"` } `json:"shared"` Private struct { ID string `json:"id"` } `json:"private"` } `json:"channels"` } `json:"result"` Error string `json:"error"` } if err := json.Unmarshal(body, &out); err != nil { return pc, fmt.Errorf("pull config: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } if out.Result.Server.WebSocket == "" || out.Result.Channels.Private.ID == "" { return pc, fmt.Errorf("pull config: missing websocket/channel (status %d, body %s)", status, common.BodySnippet(body)) } pc.WebSocket = out.Result.Server.WebSocket pc.ChannelID = out.Result.Channels.Private.ID if out.Result.Channels.Shared.ID != "" { pc.ChannelID += "/" + out.Result.Channels.Shared.ID } pc.Revision = out.Result.API.RevisionWeb pc.Hostname = c.portal if u, perr := url.Parse(c.portal); perr == nil && u.Host != "" { pc.Hostname = u.Host } return pc, nil } func (c *Client) getGuestLink(chatID string) (string, error) { form := url.Values{} form.Set("chatId", chatID) body, status, err := c.withRelogin(func() ([]byte, int, error) { return c.ajaxAction("call.Call.getGuestLink", form) }) if err != nil { return "", err } var out struct { Status string `json:"status"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &out); err != nil { return "", fmt.Errorf("getGuestLink: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } var data struct { GuestLink string `json:"guestLink"` } if err := json.Unmarshal(out.Data, &data); err != nil || data.GuestLink == "" { return "", fmt.Errorf("getGuestLink: empty (status %d, body %s)", status, common.BodySnippet(body)) } return data.GuestLink, nil } func (c *Client) getCallToken(chatID string) (callToken, userToken string, err error) { form := url.Values{} form.Set("chatId", chatID) body, status, err := c.withRelogin(func() ([]byte, int, error) { return c.ajaxAction("call.Call.getCallToken", form) }) if err != nil { return "", "", err } var out struct { Status string `json:"status"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &out); err != nil { return "", "", fmt.Errorf("getCallToken: %w (status %d, body %s)", err, status, common.BodySnippet(body)) } var data struct { CallToken string `json:"callToken"` UserToken string `json:"userToken"` } if err := json.Unmarshal(out.Data, &data); err != nil || data.CallToken == "" { return "", "", fmt.Errorf("getCallToken: empty (status %d, body %s)", status, common.BodySnippet(body)) } return data.CallToken, data.UserToken, nil }