Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 45 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ on:
branches:
- main
workflow_dispatch:
inputs:
version:
description: 'Manual release version (e.g. "1.2.3" or "v1.2.3"). Leave empty for automatic semver bump from commit messages.'
required: false
type: string

jobs:
build:
Expand Down Expand Up @@ -193,6 +198,8 @@ jobs:

- name: Prepare release metadata
id: meta
env:
MANUAL_VERSION: ${{ github.event.inputs.version }}
run: |
LATEST_TAG="$(git tag -l | grep -E '^(v)?[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)"
PREFIX=""
Expand All @@ -205,33 +212,50 @@ jobs:
BASE_VERSION="${LATEST_TAG#v}"
fi

IFS=. read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"

if [ -n "$LATEST_TAG" ]; then
RANGE="$LATEST_TAG..HEAD"
else
RANGE="HEAD"
fi

COMMITS="$(git log --no-merges --format='%s' "$RANGE")"
BUMP=""

if printf '%s\n' "$COMMITS" | grep -Eiq '^break([(:! ]|$)'; then
BUMP="major"
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif printf '%s\n' "$COMMITS" | grep -Eiq '^feat([(:! ]|$)'; then
BUMP="minor"
MINOR=$((MINOR + 1))
PATCH=0
elif printf '%s\n' "$COMMITS" | grep -Eiq '^(fix|perf|refactor)([(:! ]|$)'; then
BUMP="patch"
PATCH=$((PATCH + 1))
fi
if [ -n "$MANUAL_VERSION" ]; then
# Manual override: use the version given via workflow_dispatch as-is,
# validated against a plain (v)X.Y.Z shape. Skips the conventional-commit
# bump detection below entirely.
CANDIDATE="$(printf '%s' "$MANUAL_VERSION" | tr -d '[:space:]')"
if ! printf '%s' "$CANDIDATE" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Invalid version input '$MANUAL_VERSION' — expected format X.Y.Z or vX.Y.Z"
exit 1
fi
if [ "$CANDIDATE" = "$LATEST_TAG" ]; then
echo "::error::Version '$CANDIDATE' matches the latest existing tag; choose a new version"
exit 1
fi

NEXT_VERSION="${MAJOR}.${MINOR}.${PATCH}"
NEXT_TAG="${PREFIX}${NEXT_VERSION}"
NEXT_TAG="$CANDIDATE"
BUMP="manual"
else
IFS=. read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"

COMMITS="$(git log --no-merges --format='%s' "$RANGE")"
BUMP=""

if printf '%s\n' "$COMMITS" | grep -Eiq '^break([(:! ]|$)'; then
BUMP="major"
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif printf '%s\n' "$COMMITS" | grep -Eiq '^feat([(:! ]|$)'; then
BUMP="minor"
MINOR=$((MINOR + 1))
PATCH=0
elif printf '%s\n' "$COMMITS" | grep -Eiq '^(fix|perf|refactor)([(:! ]|$)'; then
BUMP="patch"
PATCH=$((PATCH + 1))
fi

NEXT_TAG="${PREFIX}${MAJOR}.${MINOR}.${PATCH}"
fi

{
echo "previous_tag=$LATEST_TAG"
Expand Down Expand Up @@ -306,7 +330,7 @@ jobs:
if: steps.meta.outputs.should_release != 'true'
run: |
echo "No new semantic release created."
echo "Add a commit starting with break, feat, or fix after the latest tag."
echo "Add a commit starting with break, feat, or fix after the latest tag, or re-run this workflow with a manual version input."

docker:
name: Publish Docker image
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ docker run --rm -p 56000:56000/udp \
| `-device-id` | `unknown` | ID устройства (для логов) |
| `-captcha-mode` | `auto` | режим капчи: `auto`, `wv` (WebView), `rjs` (Go solver) |
| `-vk-auth` | `anonymous` | режим авторизации VK: `anonymous` или `account` |
| `-vk-anon-path` | `vkcalls` | анонимный путь получения TURN-кредов: `vkcalls` (основной, через api.vk.me) или `legacy` (старая цепочка через login.vk.ru/api.vk.ru) |
| `-vk-creds-file` | пусто | JSON-файл с TURN-кредами от аккаунта VK |
| `-stats-interval` | `30` | интервал статистики в секундах (`0` = выключить) |
| `-turn` | из ссылки | переопределить IP TURN-сервера |
Expand Down
82 changes: 66 additions & 16 deletions client/captcha_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ var (
errCaptchaV2RateLimit = errors.New("captcha session rate limit reached")
errCaptchaV2Bot = errors.New("captcha bot challenge")

captchaV2MaxAttempts = 2
captchaV2MaxAttempts = 2
captchaV2MaxSliderChecks = 2

captchaV2DebugCache sync.Map // scriptURL -> string
captchaV2HeaderOrder = []string{
Expand Down Expand Up @@ -263,6 +264,33 @@ func captchaV2BaseValues(sessionToken string) [][2]string {
}
}

func isCaptchaSessionExhausted(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errCaptchaV2RateLimit) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "getcontent status:") ||
strings.Contains(msg, "error_limit") ||
strings.Contains(msg, "rate limit")
}

func captchaV2DeviceJSON(savedProfile *SavedProfile) string {
if savedProfile != nil && strings.TrimSpace(savedProfile.DeviceJSON) != "" {
return savedProfile.DeviceJSON
}
return captchaV2DeviceInfo
}

func captchaV2AcceptLanguage(profile Profile) string {
if strings.Contains(profile.SecChUaMobile, "?1") {
return "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7"
}
return "en-US,en;q=0.9"
}

func captchaV2BrowserFP() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
Expand Down Expand Up @@ -426,10 +454,7 @@ func (s *captchaV2Session) solveCheckboxCaptcha(
hash string,
debugInfo string,
) (string, error) {
deviceJSON := captchaV2DeviceInfo
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
deviceJSON = s.savedProfile.DeviceJSON
}
deviceJSON := captchaV2DeviceJSON(s.savedProfile)
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
Expand All @@ -444,7 +469,7 @@ func (s *captchaV2Session) solveCheckboxCaptcha(
select {
case <-s.ctx.Done():
return "", s.ctx.Err()
case <-time.After(time.Duration(400+mathrand.Intn(250)) * time.Millisecond):
case <-time.After(time.Duration(800+mathrand.Intn(500)) * time.Millisecond):
}

check, err := s.performCaptchaCheck(sessionToken, browserFP, hash, "{}", "[]", debugInfo)
Expand Down Expand Up @@ -595,7 +620,7 @@ func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) {
req.Header.Set("sec-ch-ua", profile.SecChUa)
req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Accept-Language", captchaV2AcceptLanguage(profile))
req.Header.Set("DNT", "1")
}

Expand All @@ -606,14 +631,39 @@ type VkCaptchaError struct {
CaptchaSid string
RedirectURI string
SessionToken string
CaptchaTs string
CaptchaTS string
CaptchaAttempt string
CaptchaImg string
}

func (e *VkCaptchaError) Error() string {
if e == nil {
return "VK captcha required"
}
if e.ErrorCode != 0 && e.ErrorCode != 14 {
if e.ErrorMsg != "" {
return fmt.Sprintf("VK API error %d: %s", e.ErrorCode, e.ErrorMsg)
}
return fmt.Sprintf("VK API error %d", e.ErrorCode)
}
if e.RedirectURI != "" {
return fmt.Sprintf("VK captcha required: redirect_uri, sid=%q", e.CaptchaSid)
}
if e.CaptchaImg != "" {
return fmt.Sprintf("VK captcha required: captcha_img, sid=%q", e.CaptchaSid)
}
if e.CaptchaSid != "" {
return fmt.Sprintf("VK captcha required: sid=%q", e.CaptchaSid)
}
if e.ErrorMsg != "" {
return fmt.Sprintf("VK captcha required: %s", e.ErrorMsg)
}
return "VK captcha required"
}

func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
codeFloat, _ := errData["error_code"].(float64)
redirectUri, _ := errData["redirect_uri"].(string)
redirectURI, _ := errData["redirect_uri"].(string)
errorMsg, _ := errData["error_msg"].(string)
captchaImg, _ := errData["captcha_img"].(string)

Expand All @@ -625,17 +675,17 @@ func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
}

var sessionToken string
if redirectUri != "" {
if parsed, err := neturl.Parse(redirectUri); err == nil {
if redirectURI != "" {
if parsed, err := neturl.Parse(redirectURI); err == nil {
sessionToken = parsed.Query().Get("session_token")
}
}

var captchaTs string
var captchaTS string
if tsFloat, ok := errData["captcha_ts"].(float64); ok {
captchaTs = fmt.Sprintf("%.0f", tsFloat)
captchaTS = fmt.Sprintf("%.0f", tsFloat)
} else if tsStr, ok := errData["captcha_ts"].(string); ok {
captchaTs = tsStr
captchaTS = tsStr
}

var captchaAttempt string
Expand All @@ -650,9 +700,9 @@ func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
ErrorMsg: errorMsg,
CaptchaSid: captchaSid,
CaptchaImg: captchaImg,
RedirectURI: redirectUri,
RedirectURI: redirectURI,
SessionToken: sessionToken,
CaptchaTs: captchaTs,
CaptchaTS: captchaTS,
CaptchaAttempt: captchaAttempt,
}
}
Expand Down
19 changes: 15 additions & 4 deletions client/captcha_v2_slider.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"strconv"
"strings"
"sync"
"time"
)

type sliderPuzzleV2 struct {
Expand Down Expand Up @@ -67,6 +68,9 @@ func (s *captchaV2Session) solveSliderCaptcha(
}

limit := puzzle.Attempts
if limit > captchaV2MaxSliderChecks {
limit = captchaV2MaxSliderChecks
}
if limit > len(guesses) {
limit = len(guesses)
}
Expand All @@ -75,10 +79,7 @@ func (s *captchaV2Session) solveSliderCaptcha(
}
log.Printf("[КАПЧА] v2 slider guesses ranked: total=%d limit=%d", len(guesses), limit)

deviceJSON := captchaV2DeviceInfo
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
deviceJSON = s.savedProfile.DeviceJSON
}
deviceJSON := captchaV2DeviceJSON(s.savedProfile)
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
Expand Down Expand Up @@ -119,6 +120,13 @@ func (s *captchaV2Session) solveSliderCaptcha(
if strings.EqualFold(check.Status, "error_limit") {
return "", errCaptchaV2RateLimit
}
if i+1 < limit {
select {
case <-s.ctx.Done():
return "", s.ctx.Err()
case <-time.After(time.Duration(350+mathrand.Intn(300)) * time.Millisecond):
}
}
}
return "", errors.New("slider guesses exhausted")
}
Expand All @@ -130,6 +138,9 @@ func parseSliderPuzzleV2(raw map[string]any) (*sliderPuzzleV2, error) {
}
status := captchaV2StringifyAny(resp["status"])
if !strings.EqualFold(status, "ok") {
if strings.EqualFold(status, "error") || strings.EqualFold(status, "error_limit") {
return nil, errCaptchaV2RateLimit
}
return nil, fmt.Errorf("slider getContent status: %s", status)
}
rawImage := captchaV2StringifyAny(resp["image"])
Expand Down
30 changes: 28 additions & 2 deletions client/creds.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@ func fetchVkCreds(ctx context.Context, link string, streamID int) (string, strin
return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active")
}

if getVkAnonPath() == "vkcalls" {
user, pass, addrs, err := getVKCredsViaVKCallsPath(ctx, link, streamID)
if err == nil {
log.Printf("[STREAM %d] [VK Auth] Success via VK Calls path", streamID)
return user, pass, addrs, nil
}
log.Printf("[STREAM %d] [VK Auth] VK Calls path failed (%s), falling back to legacy", streamID, describeVKCallsFailure(err))
} else {
log.Printf("[STREAM %d] [VK Auth] Legacy path selected, skipping VK Calls", streamID)
}

var lastErr error
jar := tlsclient.NewCookieJar()

Expand Down Expand Up @@ -291,6 +302,10 @@ func fetchVkCreds(ctx context.Context, link string, streamID int) (string, strin

func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) {
profile := getRandomProfile()
if saved, err := LoadProfileFromDisk(); err == nil && saved != nil && strings.TrimSpace(saved.UserAgent) != "" {
profile = saved.Profile
log.Printf("[STREAM %d] [VK Auth] Используем профиль устройства из vk_profile.json", streamID)
}

client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
tlsclient.WithTimeoutSeconds(20),
Expand Down Expand Up @@ -413,7 +428,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
}

data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s",
link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaAttempt, token1)
link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTS, captchaAttempt, token1)
continue
}
return "", "", nil, fmt.Errorf("VK API error: %v", errObj)
Expand Down Expand Up @@ -513,6 +528,10 @@ func solveCaptchaBySelectedMode(
if ctx.Err() != nil {
return "", solveErr
}
if isCaptchaSessionExhausted(solveErr) {
log.Printf("[STREAM %d] [КАПЧА] RJS: сессия капчи исчерпана, открываем WebView", streamID)
return requestWebViewCaptcha(streamID, captchaErr, "auto", captchaAutoWebViewTimeout)
}
log.Printf("[STREAM %d] [КАПЧА] RJS: ошибка, fallback на WBV Auto: %v", streamID, solveErr)
return requestWebViewCaptcha(streamID, captchaErr, "auto", captchaAutoWebViewTimeout)
}
Expand All @@ -528,6 +547,14 @@ func solveCaptchaBySelectedMode(
return "", solveErr
}
lastErr := solveErr
if isCaptchaSessionExhausted(solveErr) {
log.Printf("[STREAM %d] [КАПЧА] AUTO: сессия капчи исчерпана, сразу ручной WebView", streamID)
token, solveErr = requestWebViewCaptcha(streamID, captchaErr, "manual", captchaManualWebViewTimeout)
if solveErr == nil {
return token, nil
}
return "", solveErr
}
log.Printf("[STREAM %d] [КАПЧА] AUTO: Go v2 не решил за 2 попытки: %v", streamID, solveErr)

for wbvAttempt := 1; wbvAttempt <= 2; wbvAttempt++ {
Expand Down Expand Up @@ -662,7 +689,6 @@ func setupGlobalResolver() {
if err == nil {
return conn, nil
}
lastErr = err
conn, err = dialer.DialContext(ctx, "tcp", dns)
if err == nil {
return conn, nil
Expand Down
Loading
Loading