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
50 changes: 35 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
56 changes: 39 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions client/captcha_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
90 changes: 74 additions & 16 deletions client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,58 @@
}
}

// 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 (
Expand Down Expand Up @@ -455,24 +507,17 @@
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)

Check failure on line 520 in client/main.go

View workflow job for this annotation

GitHub Actions / Lint, fmt-check, and tests

Error return value is not checked (errcheck)

// Extract error_msg
errorMsg, ok := errData["error_msg"].(string)
Expand Down Expand Up @@ -1167,6 +1212,7 @@
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)
Expand All @@ -1182,6 +1228,10 @@
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
Expand Down Expand Up @@ -1293,6 +1343,14 @@
}
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 {

Check failure on line 1349 in client/main.go

View workflow job for this annotation

GitHub Actions / Lint, fmt-check, and tests

Error return value is not checked (errcheck)
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)
}

Expand Down
87 changes: 86 additions & 1 deletion client/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package main

import "testing"
import (
"bytes"
"log"
"strings"
"testing"
)

func TestCaptchaSolveModeForAttempt(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -59,3 +64,83 @@ 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")
}
}

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())
}
}
Loading