From 69bc48bec067a3acc0375c66b8bbd4ee841ef232 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 14:09:21 +0000 Subject: [PATCH 1/2] fix: parse VK captcha error without captcha_sid/captcha_img VK's error_code:14 ("Captcha need") response now omits captcha_sid and captcha_img, providing only redirect_uri with an embedded session_token for the Smart Captcha v2 flow. ParseVkCaptchaError returned nil on the missing fields, so the auto-solver was never invoked and every stream failed auth. Make captcha_sid and captcha_img optional; the v2 solver only needs redirect_uri + session_token. Also build Android for all ABIs (arm64 prioritized first, then arm, x86, x86_64) in CI and release workflows, client and server cores alike. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HhExd8rxbEfhzJjBiawRQH --- .github/workflows/ci.yml | 50 +++++++++++++++++++++---------- .github/workflows/release.yml | 56 ++++++++++++++++++++++++----------- client/main.go | 25 ++++++---------- client/main_test.go | 52 ++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c913016f..43d00ff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,14 +60,11 @@ jobs: name: Build binaries runs-on: ubuntu-latest strategy: + fail-fast: false matrix: include: - - goos: linux - goarch: amd64 - cgo: 0 - - goos: darwin - goarch: arm64 - cgo: 0 + # Android first — arm64 is the primary/priority target, then the + # remaining Android ABIs (arm, x86, x86_64). - goos: android goarch: arm64 cgo: 1 @@ -76,7 +73,20 @@ jobs: goarch: arm cgo: 1 api: 21 - # you can add more (android/arm, windows, etc.) later + - goos: android + goarch: 386 + cgo: 1 + api: 21 + - goos: android + goarch: amd64 + cgo: 1 + api: 21 + - goos: linux + goarch: amd64 + cgo: 0 + - goos: darwin + goarch: arm64 + cgo: 0 steps: - name: Check out code @@ -117,14 +127,24 @@ jobs: TOOLCHAIN_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" - if [ "$GOARCH" = "arm64" ]; then - export CC="$TOOLCHAIN_BIN/aarch64-linux-android${ANDROID_API}-clang" - elif [ "$GOARCH" = "arm" ]; then - export CC="$TOOLCHAIN_BIN/armv7a-linux-androideabi${ANDROID_API}-clang" - else - echo "Unsupported ANDROID GOARCH=$GOARCH" - exit 1 - fi + case "$GOARCH" in + arm64) + export CC="$TOOLCHAIN_BIN/aarch64-linux-android${ANDROID_API}-clang" + ;; + arm) + export CC="$TOOLCHAIN_BIN/armv7a-linux-androideabi${ANDROID_API}-clang" + ;; + 386) + export CC="$TOOLCHAIN_BIN/i686-linux-android${ANDROID_API}-clang" + ;; + amd64) + export CC="$TOOLCHAIN_BIN/x86_64-linux-android${ANDROID_API}-clang" + ;; + *) + echo "Unsupported ANDROID GOARCH=$GOARCH" + exit 1 + ;; + esac echo "Using Android NDK CC=$CC" fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3013fe95..b9427ba2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,9 +13,29 @@ jobs: build: name: Build binaries runs-on: ubuntu-latest - strategy: + # Fail-fast disabled so a single arch failure does not cancel the + # priority android/arm64 build or the other targets. + fail-fast: false matrix: include: + # Android first — arm64 is the primary/priority target, then the + # remaining Android ABIs (arm, x86, x86_64). + - goos: android + goarch: arm64 + cgo: 1 + api: 21 + - goos: android + goarch: arm + cgo: 1 + api: 21 + - goos: android + goarch: 386 + cgo: 1 + api: 21 + - goos: android + goarch: amd64 + cgo: 1 + api: 21 - goos: linux goarch: amd64 cgo: 0 @@ -59,14 +79,6 @@ jobs: - goos: windows goarch: 386 cgo: 0 - - goos: android - goarch: arm64 - cgo: 1 - api: 21 - - goos: android - goarch: arm - cgo: 1 - api: 21 - goos: freebsd goarch: amd64 cgo: 0 @@ -110,14 +122,24 @@ jobs: TOOLCHAIN_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" - if [ "$GOARCH" = "arm64" ]; then - export CC="$TOOLCHAIN_BIN/aarch64-linux-android${ANDROID_API}-clang" - elif [ "$GOARCH" = "arm" ]; then - export CC="$TOOLCHAIN_BIN/armv7a-linux-androideabi${ANDROID_API}-clang" - else - echo "Unsupported ANDROID GOARCH=$GOARCH" - exit 1 - fi + case "$GOARCH" in + arm64) + export CC="$TOOLCHAIN_BIN/aarch64-linux-android${ANDROID_API}-clang" + ;; + arm) + export CC="$TOOLCHAIN_BIN/armv7a-linux-androideabi${ANDROID_API}-clang" + ;; + 386) + export CC="$TOOLCHAIN_BIN/i686-linux-android${ANDROID_API}-clang" + ;; + amd64) + export CC="$TOOLCHAIN_BIN/x86_64-linux-android${ANDROID_API}-clang" + ;; + *) + echo "Unsupported ANDROID GOARCH=$GOARCH" + exit 1 + ;; + esac echo "Using Android NDK CC=$CC" fi diff --git a/client/main.go b/client/main.go index 1310559d..6e1bdaf3 100644 --- a/client/main.go +++ b/client/main.go @@ -455,24 +455,17 @@ func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { return nil } - // Extract captcha_sid - captchaSid, ok := errData["captcha_sid"].(string) - if !ok { - // try numeric - if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 { - captchaSid = fmt.Sprintf("%.0f", sidNum) - } else { - log.Printf("missing captcha_sid in captcha error data") - return nil - } + // Extract captcha_sid (optional: the VK Smart Captcha v2 flow only relies on + // redirect_uri + session_token and no longer returns captcha_sid). + var captchaSid string + if sid, sidOk := errData["captcha_sid"].(string); sidOk { + captchaSid = sid + } else if sidNum, sidNumOk := errData["captcha_sid"].(float64); sidNumOk { + captchaSid = fmt.Sprintf("%.0f", sidNum) } - // Extract captcha_img - captchaImg, ok := errData["captcha_img"].(string) - if !ok { - log.Printf("missing captcha_img in captcha error data") - return nil - } + // Extract captcha_img (optional: absent in the v2 redirect_uri flow). + captchaImg, _ := errData["captcha_img"].(string) // Extract error_msg errorMsg, ok := errData["error_msg"].(string) diff --git a/client/main_test.go b/client/main_test.go index e0f2d779..1a496534 100644 --- a/client/main_test.go +++ b/client/main_test.go @@ -59,3 +59,55 @@ func TestCaptchaSolveModeForAttempt(t *testing.T) { } }) } + +func TestParseVkCaptchaErrorV2RedirectOnly(t *testing.T) { + t.Parallel() + + // VK's current error_code:14 response no longer includes captcha_sid or + // captcha_img; it only carries redirect_uri with an embedded session_token. + errData := map[string]interface{}{ + "error_code": float64(14), + "error_msg": "Captcha need", + "redirect_uri": "https://id.vk.ru/not_robot_captcha?domain=vk.com" + + "&session_token=abc.def.ghi&variant=popup&blank=1", + } + + captchaErr := ParseVkCaptchaError(errData) + if captchaErr == nil { + t.Fatal("expected captcha error to be parsed, got nil") + } + if !captchaErr.IsCaptchaError() { + t.Fatalf("expected IsCaptchaError to be true, got code=%d redirect=%q session=%q", + captchaErr.ErrorCode, captchaErr.RedirectURI, captchaErr.SessionToken) + } + if captchaErr.SessionToken != "abc.def.ghi" { + t.Fatalf("expected session_token to be extracted from redirect_uri, got %q", captchaErr.SessionToken) + } + if captchaErr.CaptchaSid != "" { + t.Fatalf("expected empty captcha_sid, got %q", captchaErr.CaptchaSid) + } +} + +func TestParseVkCaptchaErrorLegacySid(t *testing.T) { + t.Parallel() + + // Legacy format with captcha_sid/captcha_img must still parse. + errData := map[string]interface{}{ + "error_code": float64(14), + "error_msg": "Captcha needed", + "captcha_sid": "123456789", + "captcha_img": "https://api.vk.com/captcha.php?sid=123456789", + "redirect_uri": "https://id.vk.com/not_robot_captcha?session_token=tok", + } + + captchaErr := ParseVkCaptchaError(errData) + if captchaErr == nil { + t.Fatal("expected captcha error to be parsed, got nil") + } + if captchaErr.CaptchaSid != "123456789" { + t.Fatalf("expected captcha_sid 123456789, got %q", captchaErr.CaptchaSid) + } + if captchaErr.CaptchaImg == "" { + t.Fatal("expected captcha_img to be preserved") + } +} From e57269d38619795477a0daf08a6c411027ca5b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 14:20:13 +0000 Subject: [PATCH 2/2] feat: add throttled captcha/auth debug logging Under -debug, surface the captcha/auth diagnostics that were previously missing: a redacted breakdown of each error_code:14 challenge (parsed fields, redirect base, session_token preview/length), the chosen solve mode per attempt, the v2 page parse summary (show_type, pow difficulty, slider availability, script URL), and the raw error map whenever an error_code:14 is not recognized as a solvable captcha (so the next VK format change is diagnosable without a code edit). All new lines go through debugThrottledf, which emits at most once per 3s per key, so eight concurrent streams hitting the same challenge no longer flood the log. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HhExd8rxbEfhzJjBiawRQH --- client/captcha_v2.go | 8 ++++++ client/main.go | 65 ++++++++++++++++++++++++++++++++++++++++++++ client/main_test.go | 35 +++++++++++++++++++++++- 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/client/captcha_v2.go b/client/captcha_v2.go index 1a3b9259..4a43653f 100644 --- a/client/captcha_v2.go +++ b/client/captcha_v2.go @@ -172,6 +172,14 @@ func (s *captchaV2Session) solveOnce(captchaErr *VkCaptchaError) (string, error) return "", errors.New("failed to find slider captcha settings") } + pageShowType := "" + if page.Init != nil { + pageShowType = page.Init.Data.ShowCaptchaType + } + debugThrottledf("captcha-v2-page", + "[Captcha][debug] v2 page parsed show_type=%q pow_difficulty=%d slider_available=%t script=%s html_len=%d", + pageShowType, page.PowDifficulty, sliderSettings != "", page.ScriptURL, len(html)) + log.Printf("v2 captcha solving pow difficulty=%d", page.PowDifficulty) hash := solveCaptchaPoWV2(s.ctx, page.PowInput, page.PowDifficulty) if hash == "" { diff --git a/client/main.go b/client/main.go index 6e1bdaf3..f470ecd8 100644 --- a/client/main.go +++ b/client/main.go @@ -78,6 +78,58 @@ func debugf(format string, v ...any) { } } +// debugThrottleInterval is the minimum gap between two emissions of the same +// throttled debug key. It keeps verbose per-stream diagnostics readable when +// many streams hit the same code path at once (e.g. all of them getting the +// same captcha challenge within a second). +const debugThrottleInterval = 3 * time.Second + +var debugThrottleState sync.Map // key string -> *atomic.Int64 (last emit, unix nano) + +// debugThrottledf logs like debugf but at most once per debugThrottleInterval +// for a given key. Use distinct keys for distinct message groups. +func debugThrottledf(key, format string, v ...any) { + if !isDebug { + return + } + now := time.Now().UnixNano() + val, _ := debugThrottleState.LoadOrStore(key, new(atomic.Int64)) + last, ok := val.(*atomic.Int64) + if !ok { + return + } + prev := last.Load() + if prev != 0 && now-prev < int64(debugThrottleInterval) { + return + } + // CompareAndSwap ensures only one goroutine wins the slot per interval. + if !last.CompareAndSwap(prev, now) { + return + } + log.Printf(format, v...) +} + +// debugCaptchaError emits a throttled, human-readable breakdown of a captcha +// challenge: the parsed fields plus a redacted session_token preview, without +// dumping the multi-kilobyte raw redirect_uri on every stream. +func debugCaptchaError(streamID int, c *VkCaptchaError) { + if !isDebug || c == nil { + return + } + redirectBase := c.RedirectURI + if u, err := neturl.Parse(c.RedirectURI); err == nil { + redirectBase = u.Scheme + "://" + u.Host + u.Path + } + tokenPreview := c.SessionToken + if len(tokenPreview) > 12 { + tokenPreview = tokenPreview[:12] + "…" + } + debugThrottledf("captcha-error", + "[STREAM %d] [Captcha][debug] code=%d msg=%q captcha_sid=%q captcha_img=%t redirect=%s session_token=%s (len=%d)", + streamID, c.ErrorCode, c.ErrorMsg, c.CaptchaSid, c.CaptchaImg != "", + redirectBase, tokenPreview, len(c.SessionToken)) +} + type captchaSolveMode int const ( @@ -1160,6 +1212,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr { captchaErr := ParseVkCaptchaError(errObj) if captchaErr != nil && captchaErr.IsCaptchaError() { + debugCaptchaError(streamID, captchaErr) solveMode, hasSolveMode := captchaSolveModeForAttempt(attempt, manualCaptcha, autoCaptchaSliderPOC) if !hasSolveMode { log.Printf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1) @@ -1175,6 +1228,10 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED") } + debugThrottledf("captcha-mode", + "[STREAM %d] [Captcha][debug] attempt=%d solving via %s", + streamID, attempt+1, captchaSolveModeLabel(solveMode)) + var successToken string var captchaKey string var solveErr error @@ -1286,6 +1343,14 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede } continue } + // error_code:14 that did not pass IsCaptchaError means VK changed the + // challenge shape again — surface the raw map under -debug so the next + // breakage is diagnosable without a code change. + if code, _ := errObj["error_code"].(float64); int(code) == 14 { + debugThrottledf("captcha-unrecognized", + "[STREAM %d] [Captcha][debug] error_code:14 not recognized as solvable captcha, raw=%v", + streamID, errObj) + } return "", "", nil, fmt.Errorf("VK API error: %v", errObj) } diff --git a/client/main_test.go b/client/main_test.go index 1a496534..1e4f2c08 100644 --- a/client/main_test.go +++ b/client/main_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "bytes" + "log" + "strings" + "testing" +) func TestCaptchaSolveModeForAttempt(t *testing.T) { t.Parallel() @@ -111,3 +116,31 @@ func TestParseVkCaptchaErrorLegacySid(t *testing.T) { t.Fatal("expected captcha_img to be preserved") } } + +func TestDebugThrottledfSuppressesRepeats(t *testing.T) { + // Not parallel: mutates package-global isDebug and log output. + prevDebug := isDebug + isDebug = true + defer func() { isDebug = prevDebug }() + + var buf bytes.Buffer + prevOut := log.Writer() + prevFlags := log.Flags() + log.SetOutput(&buf) + log.SetFlags(0) + defer func() { + log.SetOutput(prevOut) + log.SetFlags(prevFlags) + }() + + key := "test-throttle-" + t.Name() + debugThrottleState.Delete(key) + + for i := 0; i < 5; i++ { + debugThrottledf(key, "tick %d", i) + } + + if got := strings.Count(buf.String(), "tick "); got != 1 { + t.Fatalf("expected throttle to emit exactly once within interval, got %d lines: %q", got, buf.String()) + } +}