From a396ae3ec209b79e4456484618b0a2edeb4a366d Mon Sep 17 00:00:00 2001 From: pood1e <44490561+pood1e@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:15:11 +0800 Subject: [PATCH 001/127] Implement account transfer registration flow --- internal/app/action_gateway.go | 88 +++++++++- internal/app/native_account_transfer.go | 165 ++++++++++++++++++ internal/app/native_engine.go | 100 +++++++++-- internal/app/native_registration_params.go | 77 +++++++- internal/app/native_state.go | 19 ++ internal/app/ports.go | 28 ++- internal/app/registration_orchestrator.go | 12 ++ internal/app/server_registration.go | 35 +++- .../byte/v/forge/waapp/v1/registration.proto | 24 +++ webui/src/dashboard/wa-account-add.tsx | 97 ++++++++-- webui/src/dashboard/wa-api.ts | 8 +- .../src/dashboard/wa-registration-methods.ts | 1 + webui/src/dashboard/wa-result-model.ts | 2 +- 13 files changed, 611 insertions(+), 45 deletions(-) create mode 100644 internal/app/native_account_transfer.go diff --git a/internal/app/action_gateway.go b/internal/app/action_gateway.go index 2de2b2a..17a194f 100644 --- a/internal/app/action_gateway.go +++ b/internal/app/action_gateway.go @@ -61,6 +61,10 @@ func (g *actionGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { result, err = g.resumeOTP(r.Context(), payload) case "registration/submit-otp": result, err = g.submitOTP(r.Context(), payload) + case "registration/account-transfer/refresh": + result, err = g.refreshAccountTransferChallenge(r.Context(), payload) + case "registration/account-transfer/poll": + result, err = g.pollAccountTransferRegistration(r.Context(), payload) case "registration/cleanup-failed-account": result, err = g.cleanupFailedRegistration(r.Context(), payload) case "registration/persist-login-state": @@ -196,6 +200,10 @@ func (g *actionGateway) requestSMSOTP(ctx context.Context, payload map[string]an "method_statuses": protoMethodStatusMaps(record.GetMethodStatuses()), "proxy": registrationProxyRouteMap(route, managedRoute), } + if challenge := resp.GetAccountTransferChallenge(); challenge != nil { + response["account_transfer_challenge"] = protoMap(challenge) + response["registration_phase"] = "ACCOUNT_TRANSFER_WAITING" + } if seconds := durationSeconds(record.GetRetryAfter()); seconds > 0 { response["retry_after_seconds"] = seconds } @@ -383,6 +391,84 @@ func (g *actionGateway) submitOTP(ctx context.Context, payload map[string]any) ( }, nil } +func (g *actionGateway) refreshAccountTransferChallenge(ctx context.Context, payload map[string]any) (map[string]any, error) { + resp, err := g.server.RefreshAccountTransferChallenge(ctx, &waappv1.RefreshAccountTransferChallengeRequest{ + Context: actionContext(payload), + VerificationRequestId: textField(payload, "verification_request_id"), + }) + if err != nil { + return nil, err + } + if resp.GetError() != nil { + return map[string]any{"success": false, "error": protoMap(resp.GetError()), "error_message": resp.GetError().GetMessage()}, nil + } + return map[string]any{ + "success": true, + "registration_phase": "ACCOUNT_TRANSFER_WAITING", + "account_transfer_challenge": protoMap(resp.GetAccountTransferChallenge()), + }, nil +} + +func (g *actionGateway) pollAccountTransferRegistration(ctx context.Context, payload map[string]any) (map[string]any, error) { + attempts := int(numberField(payload, "max_attempts")) + if attempts <= 0 { + attempts = 1 + } + if attempts > 100 { + attempts = 100 + } + interval := time.Duration(numberField(payload, "interval_seconds")) * time.Second + var result map[string]any + for attempt := 0; attempt < attempts; attempt++ { + if attempt > 0 && interval > 0 { + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + } + submitPayload := cloneActionPayload(payload) + submitPayload["code"] = "" + resultValue, err := g.submitOTP(ctx, submitPayload) + if err != nil { + return nil, err + } + result = resultValue + if boolField(result, "success") { + _ = g.deleteRegistrationOTPWait(ctx, registrationOTPWait{WAAccountID: textField(payload, "wa_account_id"), VerificationRequestID: textField(payload, "verification_request_id")}) + result["attempts"] = attempt + 1 + return result, nil + } + if !accountTransferPollRetryable(result) { + result["attempts"] = attempt + 1 + return result, nil + } + } + if result == nil { + result = map[string]any{"success": false} + } + result["registration_phase"] = "ACCOUNT_TRANSFER_WAITING" + result["attempts"] = attempts + return result, nil +} + +func accountTransferPollRetryable(result map[string]any) bool { + if result == nil { + return true + } + if textField(result, "status") == waappv1.RegistrationStatus_REGISTRATION_STATUS_SUBMITTED.String() { + return true + } + errorMap := objectField(result, "error") + if boolField(errorMap, "retryable") { + return true + } + message := strings.ToLower(firstNonEmpty(textField(result, "error_message"), textField(errorMap, "message"))) + return strings.Contains(message, "pending") || strings.Contains(message, "temporarily") || strings.Contains(message, "too_recent") +} + func (g *actionGateway) cleanupFailedRegistration(ctx context.Context, payload map[string]any) (map[string]any, error) { reqCtx := actionContext(payload) accountID := cleanupWAAccountID(payload) @@ -554,7 +640,7 @@ func (s *Server) ensureDefaultProtocolProfile(ctx context.Context) (*waappv1.Pro waappv1.ProtocolCapability_PROTOCOL_CAPABILITY_MESSAGE_SESSION, waappv1.ProtocolCapability_PROTOCOL_CAPABILITY_ACCOUNT_SETTINGS, }, - RegistrationFlows: []waappv1.RegistrationFlowKind{waappv1.RegistrationFlowKind_REGISTRATION_FLOW_KIND_NEW_ACCOUNT}, + RegistrationFlows: []waappv1.RegistrationFlowKind{waappv1.RegistrationFlowKind_REGISTRATION_FLOW_KIND_NEW_ACCOUNT, waappv1.RegistrationFlowKind_REGISTRATION_FLOW_KIND_EXISTING_ACCOUNT}, MessageTransports: []waappv1.MessageTransportKind{waappv1.MessageTransportKind_MESSAGE_TRANSPORT_KIND_LONG_CONNECTION}, DiscoveredAt: timestamppb.New(now), Audit: &waappv1.AuditStamp{CreatedAt: timestamppb.New(now), UpdatedAt: timestamppb.New(now)}, diff --git a/internal/app/native_account_transfer.go b/internal/app/native_account_transfer.go new file mode 100644 index 0000000..f52efcc --- /dev/null +++ b/internal/app/native_account_transfer.go @@ -0,0 +1,165 @@ +package app + +import ( + "fmt" + "strings" + "time" + + waappv1 "github.com/byte-v-forge/wa-app/gen/go/byte/v/forge/waapp/v1" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + accountTransferMaxCodes = 6 + accountTransferRotationIntervalSec = 60 + accountTransferDeeplinkBase = "https://wa.me/fpm" + accountTransferDeeplinkPort = "8988" + accountTransferDeeplinkVersion = "3" + accountTransferDeeplinkPlatform = "android" + accountTransferAuthMethod = "cert" + accountTransferEncKeyVersion = "1" +) + +func newNativeAccountTransferState(phone *waappv1.PhoneTarget, codes []string, now time.Time) nativeAccountTransferState { + codes = normalizeAccountTransferCodes(codes) + ttlSeconds := int64(len(codes) * accountTransferRotationIntervalSec) + return nativeAccountTransferState{ + Codes: codes, + CurrentIndex: 1, + RequestedAtUnix: now.UTC().Unix(), + ExpiresAtUnix: now.UTC().Unix() + ttlSeconds, + RotationIntervalSec: accountTransferRotationIntervalSec, + SessionID: b64u(randomBytes(32)), + Certificate: b64u(randomBytes(64)), + AuthToken: b64u(randomBytes(32)), + PeerID: b64u(randomBytes(16)), + EncryptionKeyVersion: accountTransferEncKeyVersion, + EncryptionAccountHash: b64u(randomBytes(32)), + EncryptionKeySalt: b64u(randomBytes(32)), + DeeplinkBase: accountTransferDeeplinkBase, + AccountPhoneNumber: fullPhoneKey(phoneCC(phone), phoneNational(phone)), + LastChallengeIssuedSec: now.UTC().Unix(), + } +} + +func normalizeAccountTransferCodes(codes []string) []string { + out := make([]string, 0, min(len(codes), accountTransferMaxCodes)) + seen := map[string]struct{}{} + for _, code := range codes { + code = strings.TrimSpace(code) + if code == "" { + continue + } + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + out = append(out, code) + if len(out) >= accountTransferMaxCodes { + break + } + } + return out +} + +func accountTransferCodesFromResponse(data map[string]any) []string { + return normalizeAccountTransferCodes(stringList(data["code_list"])) +} + +func (s nativeAccountTransferState) empty() bool { + return len(s.Codes) == 0 +} + +func (s nativeAccountTransferState) interval() time.Duration { + if s.RotationIntervalSec <= 0 { + return accountTransferRotationIntervalSec * time.Second + } + return time.Duration(s.RotationIntervalSec) * time.Second +} + +func (s nativeAccountTransferState) expiresAt() time.Time { + if s.ExpiresAtUnix > 0 { + return time.Unix(s.ExpiresAtUnix, 0).UTC() + } + if s.RequestedAtUnix <= 0 || len(s.Codes) == 0 { + return time.Time{} + } + return time.Unix(s.RequestedAtUnix+int64(len(s.Codes))*int64(s.interval()/time.Second), 0).UTC() +} + +func (s *nativeAccountTransferState) currentCode(now time.Time) (string, int, error) { + if s == nil || len(s.Codes) == 0 { + return "", 0, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_EXPIRED, "account transfer challenge is not available", false) + } + requestedAt := s.RequestedAtUnix + if requestedAt <= 0 { + requestedAt = now.UTC().Unix() + s.RequestedAtUnix = requestedAt + } + intervalSeconds := int64(s.interval() / time.Second) + if intervalSeconds <= 0 { + intervalSeconds = accountTransferRotationIntervalSec + } + index := int((now.UTC().Unix()-requestedAt)/intervalSeconds) + 1 + if index < 1 { + index = 1 + } + if index > len(s.Codes) { + return "", index, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_EXPIRED, "account transfer challenge expired", false) + } + s.CurrentIndex = index + s.LastChallengeIssuedSec = now.UTC().Unix() + return s.Codes[index-1], index, nil +} + +func (s *nativeAccountTransferState) challenge(verificationRequestID string, now time.Time) (*waappv1.AccountTransferChallenge, error) { + code, index, err := s.currentCode(now) + if err != nil { + return nil, err + } + payload := s.deeplink(code) + return &waappv1.AccountTransferChallenge{ + VerificationRequestId: verificationRequestID, + CodeCount: int32(len(s.Codes)), + CurrentCodeIndex: int32(index), + CurrentCodeLength: int32(len(code)), + RotationInterval: durationpb.New(s.interval()), + RequestedAt: timestamppb.New(time.Unix(s.RequestedAtUnix, 0).UTC()), + ExpiresAt: timestamppb.New(s.expiresAt()), + QrDeeplink: &waappv1.SensitiveText{ + Value: payload, + RedactedValue: accountTransferDeeplinkBase + "?", + }, + }, nil +} + +func (s nativeAccountTransferState) deeplink(code string) string { + base := firstNonEmpty(s.DeeplinkBase, accountTransferDeeplinkBase) + values := []struct { + key string + value string + }{ + {"version", accountTransferDeeplinkVersion}, + {"platform", accountTransferDeeplinkPlatform}, + {"sessionID", s.SessionID}, + {"authMethod", accountTransferAuthMethod}, + {"cert", s.Certificate}, + {"authToken", s.AuthToken}, + {"peerID", s.PeerID}, + {"ip", ""}, + {"ssid", ""}, + {"ssidPw", ""}, + {"otpCode", code}, + {"port", accountTransferDeeplinkPort}, + {"encKeyVer", firstNonEmpty(s.EncryptionKeyVersion, accountTransferEncKeyVersion)}, + {"encKeyAccHash", s.EncryptionAccountHash}, + {"encKeySalt", s.EncryptionKeySalt}, + {"phoneNumber", s.AccountPhoneNumber}, + } + parts := make([]string, 0, len(values)) + for _, item := range values { + parts = append(parts, item.key+"="+item.value) + } + return fmt.Sprintf("%s?%s", strings.TrimRight(base, "?"), strings.Join(parts, "&")) +} diff --git a/internal/app/native_engine.go b/internal/app/native_engine.go index e8ba00b..a4f9773 100644 --- a/internal/app/native_engine.go +++ b/internal/app/native_engine.go @@ -157,6 +157,9 @@ func (e *NativeEngine) requestVerificationCodeWithState(ctx context.Context, inp if enc != "" { state.LastCodeResult["enc_sha256"] = encHash(enc) } + if input.DeliveryMethod != waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + state.AccountTransfer = nativeAccountTransferState{} + } retryAfter := verificationCodeRetryAfter(data, input.DeliveryMethod) now := e.clock.Now() if err != nil { @@ -188,13 +191,50 @@ func (e *NativeEngine) requestVerificationCodeWithState(ctx context.Context, inp Err: waProtocolError(data, "verification request was rejected"), }, state } - return verificationCodeResult(status, data, input.DeliveryMethod, now, retryAfter), state + result := verificationCodeResult(status, data, input.DeliveryMethod, now, retryAfter) + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + challenge, challengeErr := e.prepareAccountTransferChallenge(input.Phone, &state, data, now) + if challengeErr != nil { + result.Status = waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_REJECTED + result.Err = challengeErr + result.RawStatus = responseStatus(data) + result.RawReason = responseReason(data) + return result, state + } + result.AccountTransferChallenge = challenge + result.ExpectedCodeLength = challenge.GetCurrentCodeLength() + result.ExpiresAt = challenge.GetExpiresAt().AsTime() + result.MethodStatuses = upsertVerificationMethodStatus(result.MethodStatuses, "acc_tr", verificationWaitStatus{Present: true}) + } + return result, state } -func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineSubmitInput) EngineRegisterResult { - if strings.TrimSpace(input.Code) == "" { - return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: NewError(waappv1.WaErrorCode_WA_ERROR_CODE_VALIDATION_FAILED, "verification code is required", false)} +func (e *NativeEngine) prepareAccountTransferChallenge(phone *waappv1.PhoneTarget, state *nativeState, data map[string]any, now time.Time) (*waappv1.AccountTransferChallenge, error) { + codes := accountTransferCodesFromResponse(data) + if len(codes) == 0 { + return nil, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_REJECTED, "account transfer code list is missing", false) } + state.AccountTransfer = newNativeAccountTransferState(phone, codes, now) + return state.AccountTransfer.challenge("", now) +} + +func (e *NativeEngine) RefreshAccountTransferChallenge(ctx context.Context, input EngineAccountTransferChallengeInput) EngineAccountTransferChallengeResult { + state, err := e.loadState(ctx, input.ClientProfileID) + if err != nil { + return EngineAccountTransferChallengeResult{Err: err} + } + if state.AccountTransfer.empty() { + return EngineAccountTransferChallengeResult{Err: NewError(waappv1.WaErrorCode_WA_ERROR_CODE_EXPIRED, "account transfer challenge is not available", false)} + } + challenge, err := state.AccountTransfer.challenge(input.VerificationRequestID, e.clock.Now()) + if err != nil { + return EngineAccountTransferChallengeResult{Err: err} + } + _ = e.saveState(ctx, input.ClientProfileID, state) + return EngineAccountTransferChallengeResult{Challenge: challenge} +} + +func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineSubmitInput) EngineRegisterResult { state, err := e.loadState(ctx, input.ClientProfileID) if err != nil { return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: err} @@ -202,7 +242,17 @@ func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineS if err := ensureNativeSoftwareAttestation(&state); err != nil { return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: err} } - params, rawKeys := e.registerParams(input.Phone, input.DeliveryMethod, input.Code, state, input.AuthCodeContext) + code := strings.TrimSpace(input.Code) + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + code, _, err = state.AccountTransfer.currentCode(e.clock.Now()) + if err != nil { + return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: err} + } + } + if code == "" { + return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: NewError(waappv1.WaErrorCode_WA_ERROR_CODE_VALIDATION_FAILED, "verification code is required", false)} + } + params, rawKeys := e.registerParams(input.Phone, input.DeliveryMethod, code, state, input.AuthCodeContext) logNativeRegistrationMapShape("register", input.Phone, input.DeliveryMethod, params, rawKeys) plain := renderNativePlain(params, rawKeys) client, err := e.httpForProxy() @@ -222,6 +272,13 @@ func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineS return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: classifyHTTPError(data, err)} } if status := responseStatus(data); status != "ok" && status != "registered" { + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER && !accountTransferRegisterTerminalFailure(data) { + _ = e.saveState(ctx, input.ClientProfileID, state) + return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_SUBMITTED, Err: NewError(waappv1.WaErrorCode_WA_ERROR_CODE_CONFLICT, "account transfer confirmation is pending", true)} + } + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + state.AccountTransfer = nativeAccountTransferState{} + } _ = e.saveState(ctx, input.ClientProfileID, state) return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: waProtocolError(data, "registration was rejected")} } @@ -230,6 +287,9 @@ func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineS if login != "" { state.RegistrationJID = normalizeJID(login) } + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + state.AccountTransfer = nativeAccountTransferState{} + } _ = e.saveState(ctx, input.ClientProfileID, state) completedAt := e.clock.Now() return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REGISTERED, RegisteredID: "waid_" + stableID(login), ServiceAccountID: lid, ServiceLoginID: login, CompletedAt: completedAt} @@ -638,13 +698,17 @@ func (e *NativeEngine) codeParams(phone *waappv1.PhoneTarget, method waappv1.Ver "e_skey_val": state.KeyBundle.SignedKeyValue, "e_skey_sig": state.KeyBundle.SignedKeySig, } - if token := e.registrationToken(phone, state); token != "" { - params["token"] = token + if nativeRegistrationMethodUsesToken(methodName) { + if token := e.registrationToken(phone, state); token != "" { + params["token"] = token + } } - if contextValue := strings.TrimSpace(authCodeContext); contextValue != "" { - params["context"] = contextValue + if nativeRegistrationMethodUsesAuthContext(methodName) { + if contextValue := strings.TrimSpace(authCodeContext); contextValue != "" { + params["context"] = contextValue + } } - if advertisingID := nativeAdvertisingID(state); advertisingID != "" && shouldSendNativeAdvertisingID(phone) { + if advertisingID := nativeAdvertisingID(state); advertisingID != "" && shouldSendNativeAdvertisingID(phone) && nativeRegistrationMethodUsesAdvertisingID(methodName) { params["advertising_id"] = advertisingID } raw := map[string]struct{}{"id": {}, "backup_token": {}} @@ -686,13 +750,19 @@ func (e *NativeEngine) registerParams(phone *waappv1.PhoneTarget, method waappv1 "e_skey_val": firstNonEmpty(state.LastCodeParams["e_skey_val"], state.KeyBundle.SignedKeyValue), "e_skey_sig": firstNonEmpty(state.LastCodeParams["e_skey_sig"], state.KeyBundle.SignedKeySig), } - if token := e.registrationToken(phone, state); token != "" { - params["token"] = token + if nativeRegistrationMethodUsesToken(methodName) { + if token := e.registrationToken(phone, state); token != "" { + params["token"] = token + } + } + if nativeRegistrationMethodUsesAuthContext(methodName) { + if contextValue := firstNonEmpty(authCodeContext, state.LastCodeParams["context"]); contextValue != "" { + params["context"] = contextValue + } } - if contextValue := firstNonEmpty(authCodeContext, state.LastCodeParams["context"]); contextValue != "" { - params["context"] = contextValue + if methodName != "acc_tr" { + applyRegisterCodeResultParams(params, state) } - applyRegisterCodeResultParams(params, state) raw := map[string]struct{}{"id": {}, "backup_token": {}} applyNativeRawParamMap(params, raw, registerDeviceMap(methodName, state), true) return params, raw diff --git a/internal/app/native_registration_params.go b/internal/app/native_registration_params.go index 89d7c88..2287889 100644 --- a/internal/app/native_registration_params.go +++ b/internal/app/native_registration_params.go @@ -67,16 +67,23 @@ func (e *NativeEngine) codeRequestOrderedParamsWithWamsys(ctx context.Context, p } params.set("id", state.Profile.ID, true) params.set("backup_token", state.Profile.BackupToken, true) - if token := e.registrationToken(phone, state); token != "" { - params.set("token", token, false) + if nativeRegistrationMethodUsesToken(methodName) { + if token := e.registrationToken(phone, state); token != "" { + params.set("token", token, false) + } } params.set("method", methodName, false) - if contextValue := strings.TrimSpace(authCodeContext); contextValue != "" { - params.set("context", contextValue, false) + if nativeRegistrationMethodUsesAuthContext(methodName) { + if contextValue := strings.TrimSpace(authCodeContext); contextValue != "" { + params.set("context", contextValue, false) + } } - if advertisingID := nativeAdvertisingID(state); advertisingID != "" && shouldSendNativeAdvertisingID(phone) { + if advertisingID := nativeAdvertisingID(state); advertisingID != "" && shouldSendNativeAdvertisingID(phone) && nativeRegistrationMethodUsesAdvertisingID(methodName) { params.set("advertising_id", advertisingID, false) } + if methodName == "acc_tr" { + applyNativeCodeRequestPermissionParams(¶ms, fields) + } applyNativeE2EParams(¶ms, state) applyNativeCodeRequestMapParams(¶ms, fields, methodName) var capture *waappv1.WamsysCapture @@ -95,6 +102,24 @@ func (e *NativeEngine) codeRequestOrderedParamsWithWamsys(ctx context.Context, p return params, nil } +func nativeRegistrationMethodUsesToken(methodName string) bool { + return methodName != "acc_tr" +} + +func nativeRegistrationMethodUsesAuthContext(methodName string) bool { + return methodName != "acc_tr" +} + +func nativeRegistrationMethodUsesAdvertisingID(methodName string) bool { + return methodName != "acc_tr" +} + +func applyNativeCodeRequestPermissionParams(params *orderedParams, fields map[string]string) { + addRawParam(params, "clicked_education_link", firstNonEmpty(fields["clicked_education_link"], "-1")) + addRawParam(params, "manage_call_permission", firstNonEmpty(fields["manage_call_permission"], "false")) + addRawParam(params, "call_log_permission", firstNonEmpty(fields["call_log_permission"], "false")) +} + func applyNativeE2EParams(params *orderedParams, state nativeState) { params.set("authkey", state.AuthKey, false) params.set("e_ident", state.KeyBundle.IdentityPublic, false) @@ -442,7 +467,10 @@ func parseExistProbeResult(data map[string]any) EngineProbeResult { baseProtocolRejected := existProtocolRejected(status, reason) invalidNumber := existInvalidNumberReason(reason) rateLimited := existRateLimitedReason(reason) - registered := !baseProtocolRejected && !blocked && !invalidNumber && !rateLimited && (waOldFallbackEligible(data) || existRegisteredSignal(status, reason, data)) + registered := !baseProtocolRejected && !blocked && !invalidNumber && !rateLimited && (waOldFallbackEligible(data) || accountTransferFallbackEligible(data) || existRegisteredSignal(status, reason, data)) + if registered { + methodStatuses = upsertVerificationMethodStatus(methodStatuses, "acc_tr", verificationWaitStatus{Present: true}) + } protocolRejected := baseProtocolRejected notRegistered := false registeredKnown := registered || invalidNumber @@ -597,6 +625,25 @@ func waProtocolError(data map[string]any, fallback string) error { return NewError(code, message, retryable) } +func accountTransferRegisterTerminalFailure(data map[string]any) bool { + reason := responseReason(data) + status := responseStatus(data) + switch reason { + case "mismatch", "bad_code", "bad_token", "fail_mismatch", "blocked", "fail_blocked", "missing", "fail_missing", "guessed_too_fast", "fail_guessed_too_fast", "security_code", "second_code", "device_confirm_or_second_code", "verified_standalone": + return true + case "too_recent", "too_many", "temporarily_unavailable": + return false + } + switch status { + case "rejected", "blocked", "fail", "failed": + return true + case "", "pending", "sent", "retry", "waiting", "temporarily_unavailable": + return false + default: + return false + } +} + func methodsFromStatuses(statuses []VerificationMethodStatus) []waappv1.VerificationDeliveryMethod { seen := map[waappv1.VerificationDeliveryMethod]struct{}{} out := make([]waappv1.VerificationDeliveryMethod, 0, len(statuses)) @@ -658,7 +705,7 @@ type verificationWaitStatus struct { Present bool } -var apkDefaultRegistrationMethodOrder = []string{"flash", "sms", "voice"} +var apkDefaultRegistrationMethodOrder = []string{"flash", "sms", "voice", "wa_old", "acc_tr", "send_sms", "email_otp"} func verificationMethodStatuses(data map[string]any, _ []waappv1.VerificationDeliveryMethod) []VerificationMethodStatus { out := []VerificationMethodStatus{} @@ -799,6 +846,15 @@ func waOldFallbackEligible(data map[string]any) bool { return false } +func accountTransferFallbackEligible(data map[string]any) bool { + for _, code := range fallbackVerificationMethodCodes(data) { + if code == "acc_tr" { + return verificationMethodEligibleForAPKUI(data, code) + } + } + return false +} + func verificationMethodEligibleForAPKUI(data map[string]any, code string) bool { switch code { case "sms", "voice", "flash": @@ -809,6 +865,11 @@ func verificationMethodEligibleForAPKUI(data map[string]any, code string) bool { return false } return eligibility != 0 && eligibility != 4 + case "acc_tr": + if verificationExplicitlyEligible(data, "pref_acc_tr_eligibility", "acc_tr_eligible", "account_transfer_eligible") { + return true + } + return waOldFallbackEligible(data) case "send_sms": return verificationExplicitlyEligible(data, "pref_send_sms_eligibility", "send_sms_eligible", "can_send_sms_to_wa") && !verificationExplicitlyExhausted(data, "send_sms_attempts_exhausted", "pref_send_sms_attempts_exhausted") case "email_otp": @@ -865,6 +926,8 @@ func verificationMethodWaitValues(data map[string]any, code string) []any { return []any{data["flash_wait"], data["flash_wait_time"], data["flash_retry_time"], data["pref_flash_wait_time"], data["EXTRA_FLASH_RETRY_TIME"]} case "wa_old": return []any{data["wa_old_wait"], data["wa_old_retry_time"], data["pref_wa_old_wait_time"], data["EXTRA_WA_OLD_RETRY_TIME"]} + case "acc_tr": + return []any{data["acc_tr_wait"], data["account_transfer_wait"], data["pref_acc_tr_wait_time"], data["EXTRA_ACC_TR_RETRY_TIME"]} case "email_otp": return []any{data["email_otp_wait"], data["email_otp_retry_time"], data["pref_email_otp_wait_time"], data["EXTRA_EMAIL_OTP_RETRY_TIME"]} case "silent_auth": diff --git a/internal/app/native_state.go b/internal/app/native_state.go index ed59d90..d40d08d 100644 --- a/internal/app/native_state.go +++ b/internal/app/native_state.go @@ -34,6 +34,7 @@ type nativeState struct { LastCodeParams map[string]string `json:"last_code_params,omitempty"` LastCodeResult map[string]any `json:"last_code_result,omitempty"` LastRegister map[string]any `json:"last_register,omitempty"` + AccountTransfer nativeAccountTransferState `json:"account_transfer,omitempty"` RegistrationJID string `json:"registration_jid,omitempty"` ChatRoutingInfo string `json:"chat_routing_info,omitempty"` ChatConnection nativeChatConnectionState `json:"chat_connection,omitempty"` @@ -156,6 +157,24 @@ type nativePrivacyToken struct { Timestamp int64 `json:"timestamp,omitempty"` } +type nativeAccountTransferState struct { + Codes []string `json:"codes,omitempty"` + CurrentIndex int `json:"current_index,omitempty"` + RequestedAtUnix int64 `json:"requested_at_unix,omitempty"` + ExpiresAtUnix int64 `json:"expires_at_unix,omitempty"` + RotationIntervalSec int64 `json:"rotation_interval_sec,omitempty"` + SessionID string `json:"session_id,omitempty"` + Certificate string `json:"certificate,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + PeerID string `json:"peer_id,omitempty"` + EncryptionKeyVersion string `json:"enc_key_version,omitempty"` + EncryptionAccountHash string `json:"enc_key_account_hash,omitempty"` + EncryptionKeySalt string `json:"enc_key_salt,omitempty"` + DeeplinkBase string `json:"deeplink_base,omitempty"` + AccountPhoneNumber string `json:"account_phone_number,omitempty"` + LastChallengeIssuedSec int64 `json:"last_challenge_issued_sec,omitempty"` +} + type nativeChatConnectionState struct { LastHost string `json:"last_host,omitempty"` LastPort int `json:"last_port,omitempty"` diff --git a/internal/app/ports.go b/internal/app/ports.go index 0a27c24..5914957 100644 --- a/internal/app/ports.go +++ b/internal/app/ports.go @@ -88,6 +88,7 @@ type ProtocolEngine interface { PrepareClientProfile(context.Context, EngineProfileInput) error ProbeAccount(context.Context, EngineRegistrationInput) EngineProbeResult RequestVerificationCode(context.Context, EngineRegistrationInput) EngineCodeResult + RefreshAccountTransferChallenge(context.Context, EngineAccountTransferChallengeInput) EngineAccountTransferChallengeResult SubmitVerificationCode(context.Context, EngineSubmitInput) EngineRegisterResult CheckLoginState(context.Context, EngineLoginCheckInput) EngineLoginCheckResult ReceiveMessageBatch(context.Context, EngineMessageInput) EngineMessageBatchResult @@ -120,6 +121,11 @@ type EngineSubmitInput struct { CodeSecretRef string } +type EngineAccountTransferChallengeInput struct { + EngineRegistrationInput + VerificationRequestID string +} + type EngineLoginCheckInput struct { WAAccountID string ClientProfileID string @@ -218,14 +224,20 @@ const ( ) type EngineCodeResult struct { - Status waappv1.VerificationRequestStatus - ExpectedCodeLength int32 - ExpiresAt time.Time - RetryAfter time.Duration - MethodStatuses []VerificationMethodStatus - RawStatus string - RawReason string - Err error + Status waappv1.VerificationRequestStatus + ExpectedCodeLength int32 + ExpiresAt time.Time + RetryAfter time.Duration + MethodStatuses []VerificationMethodStatus + AccountTransferChallenge *waappv1.AccountTransferChallenge + RawStatus string + RawReason string + Err error +} + +type EngineAccountTransferChallengeResult struct { + Challenge *waappv1.AccountTransferChallenge + Err error } type EngineRegisterResult struct { diff --git a/internal/app/registration_orchestrator.go b/internal/app/registration_orchestrator.go index efaf047..9b017cb 100644 --- a/internal/app/registration_orchestrator.go +++ b/internal/app/registration_orchestrator.go @@ -67,6 +67,10 @@ func (s *Server) StartRegistration(ctx context.Context, payload map[string]any) } } record := gateway.server.newVerificationCodeRequestRecord(account, profile, method, codeResult) + challenge := codeResult.AccountTransferChallenge + if challenge != nil { + challenge.VerificationRequestId = record.GetVerificationRequestId() + } if err := gateway.server.store.SaveVerificationRequest(ctx, record); err != nil { _ = gateway.discardRejectedRegistration(context.Background(), basePayload, waAccountID(account), record.GetVerificationRequestId()) return nil, err @@ -100,6 +104,10 @@ func (s *Server) StartRegistration(ctx context.Context, payload map[string]any) "phone_status": registrationCodeResultPhoneStatus(codeResult, method, false), "proxy": registrationOrchestratorProxySummary(registrationProxyRouteMap(route, managedRoute)), } + if challenge != nil { + response["account_transfer_challenge"] = protoMap(challenge) + response["registration_phase"] = "ACCOUNT_TRANSFER_WAITING" + } if seconds := durationSeconds(record.GetRetryAfter()); seconds > 0 { response["retry_after_seconds"] = seconds } @@ -234,6 +242,10 @@ func registrationProbeAllowsMethod(result EngineProbeResult, method waappv1.Veri method == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_SMS { return registrationProbeMethodAvailable(result, waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_SMS) || result.CanSendSMS } + if method == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + return result.Status == waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REACHABLE && + (result.Registered || registrationProbeMethodAvailable(result, method) || registrationProbeMethodAvailable(result, waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_WA_OLD)) + } return registrationProbeMethodAvailable(result, method) } diff --git a/internal/app/server_registration.go b/internal/app/server_registration.go index 91bb016..ccfd976 100644 --- a/internal/app/server_registration.go +++ b/internal/app/server_registration.go @@ -43,10 +43,43 @@ func (s *Server) requestVerificationCode(ctx context.Context, req *waappv1.Reque } result := runner.RequestVerificationCode(ctx, EngineRegistrationInput{WAAccountID: waAccountID(account), ClientProfileID: profile.GetClientProfileId(), ProtocolProfileID: profile.GetProtocolProfileId(), AppVersion: s.clientProfileAppVersion(ctx, profile), Phone: account.GetPhone(), DeliveryMethod: method}) record := s.newVerificationCodeRequestRecord(account, profile, method, result) + challenge := result.AccountTransferChallenge + if challenge != nil { + challenge.VerificationRequestId = record.GetVerificationRequestId() + } if err := s.store.SaveVerificationRequest(ctx, record); err != nil { return &waappv1.RequestVerificationCodeResponse{Error: ToProtoError(err)}, nil } - return &waappv1.RequestVerificationCodeResponse{VerificationRequest: record, Error: record.GetLastError()}, nil + return &waappv1.RequestVerificationCodeResponse{VerificationRequest: record, AccountTransferChallenge: challenge, Error: record.GetLastError()}, nil +} + +func (s *Server) RefreshAccountTransferChallenge(ctx context.Context, req *waappv1.RefreshAccountTransferChallengeRequest) (*waappv1.RefreshAccountTransferChallengeResponse, error) { + if err := validateContext(req.GetContext()); err != nil { + return &waappv1.RefreshAccountTransferChallengeResponse{Error: ToProtoError(err)}, nil + } + verification, err := s.store.GetVerificationRequest(ctx, req.GetVerificationRequestId()) + if err != nil { + return &waappv1.RefreshAccountTransferChallengeResponse{Error: ToProtoError(err)}, nil + } + if verification.GetDeliveryMethod() != waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + return &waappv1.RefreshAccountTransferChallengeResponse{Error: ToProtoError(NewError(waappv1.WaErrorCode_WA_ERROR_CODE_VALIDATION_FAILED, "verification request is not account transfer", false))}, nil + } + account, profile, err := s.waAccountAndProfile(ctx, verification.GetWaAccountId(), verification.GetClientProfileId()) + if err != nil { + return &waappv1.RefreshAccountTransferChallengeResponse{Error: ToProtoError(err)}, nil + } + result := s.runner.RefreshAccountTransferChallenge(ctx, EngineAccountTransferChallengeInput{ + EngineRegistrationInput: EngineRegistrationInput{ + WAAccountID: waAccountID(account), + ClientProfileID: profile.GetClientProfileId(), + ProtocolProfileID: profile.GetProtocolProfileId(), + AppVersion: s.clientProfileAppVersion(ctx, profile), + Phone: account.GetPhone(), + DeliveryMethod: verification.GetDeliveryMethod(), + }, + VerificationRequestID: verification.GetVerificationRequestId(), + }) + return &waappv1.RefreshAccountTransferChallengeResponse{AccountTransferChallenge: result.Challenge, Error: ToProtoError(result.Err)}, nil } func (s *Server) SubmitVerificationCode(ctx context.Context, req *waappv1.SubmitVerificationCodeRequest) (*waappv1.SubmitVerificationCodeResponse, error) { diff --git a/proto/byte/v/forge/waapp/v1/registration.proto b/proto/byte/v/forge/waapp/v1/registration.proto index 266617c..aab2fc6 100644 --- a/proto/byte/v/forge/waapp/v1/registration.proto +++ b/proto/byte/v/forge/waapp/v1/registration.proto @@ -16,6 +16,7 @@ option php_namespace = "ByteVForge\\WaApp\\V1"; service WaRegistrationService { rpc ProbeAccount(ProbeAccountRequest) returns (ProbeAccountResponse); rpc RequestVerificationCode(RequestVerificationCodeRequest) returns (RequestVerificationCodeResponse); + rpc RefreshAccountTransferChallenge(RefreshAccountTransferChallengeRequest) returns (RefreshAccountTransferChallengeResponse); rpc SubmitVerificationCode(SubmitVerificationCodeRequest) returns (SubmitVerificationCodeResponse); rpc GetRegistration(GetRegistrationRequest) returns (GetRegistrationResponse); rpc GetLoginState(GetLoginStateRequest) returns (GetLoginStateResponse); @@ -138,6 +139,18 @@ message VerificationCodeRequestRecord { repeated VerificationMethodStatus method_statuses = 11; } +message AccountTransferChallenge { + string verification_request_id = 1; + int32 code_count = 2; + int32 current_code_index = 3; + int32 current_code_length = 4; + google.protobuf.Duration rotation_interval = 5; + google.protobuf.Timestamp requested_at = 6; + google.protobuf.Timestamp expires_at = 7; + // Sensitive QR/deeplink payload. It carries the current account-transfer token. + SensitiveText qr_deeplink = 8; +} + message RegisteredIdentity { string registered_identity_id = 1; string wa_account_id = 2; @@ -208,6 +221,7 @@ message RequestVerificationCodeRequest { message RequestVerificationCodeResponse { VerificationCodeRequestRecord verification_request = 1; WaError error = 2; + AccountTransferChallenge account_transfer_challenge = 3; } message SubmitVerificationCodeRequest { @@ -220,6 +234,16 @@ message SubmitVerificationCodeRequest { } } +message RefreshAccountTransferChallengeRequest { + RequestContext context = 1; + string verification_request_id = 2; +} + +message RefreshAccountTransferChallengeResponse { + AccountTransferChallenge account_transfer_challenge = 1; + WaError error = 2; +} + message SubmitVerificationCodeResponse { RegistrationRecord registration = 1; WaError error = 2; diff --git a/webui/src/dashboard/wa-account-add.tsx b/webui/src/dashboard/wa-account-add.tsx index 485fefc..a66591a 100644 --- a/webui/src/dashboard/wa-account-add.tsx +++ b/webui/src/dashboard/wa-account-add.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; -import { probeWaPhoneSMS, registerWaPhone, submitWaRegistrationOTP, type WaWorkflowResponse } from './wa-api'; +import { pollWaAccountTransferRegistration, probeWaPhoneSMS, refreshWaAccountTransferChallenge, registerWaPhone, submitWaRegistrationOTP, type WaWorkflowResponse } from './wa-api'; import { WhatsAppIcon } from './wa-brand-icon'; import { accountReasonLabel } from './wa-result-labels'; import { waProbeStatus } from './wa-result-model'; @@ -15,7 +15,7 @@ import { registrationAnyMethodAvailable, registrationChannelsHardBlocked, type S import { WaResultPanel } from './wa-result-panel'; import { resolveWaPhoneTarget, type WaResolvedPhone } from './wa-utils'; type ProbeState = { target: WaResolvedPhone; result: WaWorkflowResponse } | null; -type PendingRegistration = { accountID: string }; +type PendingRegistration = { accountID: string; verificationRequestID: string; accountTransferChallenge?: Record }; type Props = { disabled?: boolean; onChanged: () => void | Promise; onDone: (message: string) => void; onError: (message: string) => void }; export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { const [phone, setPhone] = useState(''); @@ -41,7 +41,8 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { const canRegister = samePhone && registrationAnyMethodAvailable(channelStatus, cooldownElapsedSeconds) && !channelsHardBlocked; const detected = samePhone && Boolean(channelStatus); const badgeVariant = pending ? 'default' : blocked ? 'destructive' : canRegister ? 'default' : detected ? 'secondary' : 'outline'; - const badgeLabel = pending ? '等待 OTP' : blocked ? '已封禁' : canRegister ? '可注册' : detected ? '无可直发' : '待检测'; + const accountTransferPending = Boolean(pending?.accountTransferChallenge); + const badgeLabel = pending ? accountTransferPending ? '等待迁移' : '等待 OTP' : blocked ? '已封禁' : canRegister ? '可注册' : detected ? '无可直发' : '待检测'; useEffect(() => { const activeResult = activeRegistrationResult || (samePhone ? probe?.result : null); @@ -67,8 +68,9 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { setBusy(false); } } - async function submitOTP() { - if (!pending) return onError('没有等待中的 OTP'); + async function submitOTP() { + if (!pending) return onError('没有等待中的 OTP'); + if (pending.accountTransferChallenge) return onError('账号迁移不使用 OTP 输入'); const code = otp.trim(); if (!code) return onError('请输入 OTP'); if (code.length !== WA_REGISTRATION_OTP_LENGTH) return onError(`请输入 ${WA_REGISTRATION_OTP_LENGTH} 位 OTP`); @@ -101,11 +103,12 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { onError(registrationFailureMessage(result, resultStatus)); return; } - const accountID = workflowText(result, 'wa_account_id'); - if (accountID) setPending({ accountID }); - setProbe(null); - setOtp(''); - onDone(accountID ? 'OTP 已发送' : '已发起'); + const accountID = workflowText(result, 'wa_account_id'); + const verificationRequestID = workflowText(result, 'verification_request_id'); + if (accountID) setPending({ accountID, verificationRequestID, accountTransferChallenge: result.account_transfer_challenge }); + setProbe(null); + setOtp(''); + onDone(result.account_transfer_challenge ? '账号迁移已发起' : accountID ? 'OTP 已发送' : '已发起'); await onChanged(); } catch (error) { onError(error instanceof Error ? error.message : String(error)); @@ -118,6 +121,42 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { setCooldownStartedAt(now); setClockNow(now); } + async function refreshAccountTransfer() { + if (!pending?.verificationRequestID) return onError('缺少验证请求'); + setBusy(true); + try { + const result = await refreshWaAccountTransferChallenge(pending.verificationRequestID); + if (result.success === false || result.error_message) throw new Error(accountReasonLabel(result.error_message, result.status) || '刷新迁移 Deeplink 失败'); + setPending({ ...pending, accountTransferChallenge: result.account_transfer_challenge }); + } catch (error) { + onError(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + } + async function pollAccountTransfer() { + if (!pending?.verificationRequestID) return onError('缺少验证请求'); + setBusy(true); + try { + const result = await pollWaAccountTransferRegistration(pending.verificationRequestID, pending.accountID, 1); + if (result.success === false || result.error_message) throw new Error(accountReasonLabel(result.error_message, result.status) || '账号迁移仍在等待确认'); + setPending(null); + onDone('账号迁移已完成'); + await onChanged(); + } catch (error) { + onError(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + } + async function copyText(value: string) { + try { + await navigator.clipboard.writeText(value); + onDone('已复制'); + } catch { + onError('复制失败'); + } + } return ( @@ -150,7 +189,15 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { void startRegistration(method)} /> )} - {pending && void submitOTP()} />} + {pending && (pending.accountTransferChallenge ? ( + void copyText(value)} + onPoll={() => void pollAccountTransfer()} + onRefresh={() => void refreshAccountTransfer()} + /> + ) : void submitOTP()} />)} {(activeRegistrationResult || probe || busy) && ( @@ -168,6 +215,34 @@ function workflowText(result: WaWorkflowResponse, key: keyof WaWorkflowResponse) const value = result[key]; return typeof value === 'string' ? value.trim() : ''; } +function WaAccountTransferCard({ challenge, busy, onRefresh, onPoll, onCopy }: { challenge: Record; busy?: boolean; onRefresh: () => void; onPoll: () => void; onCopy: (value: string) => void }) { + const deeplink = sensitiveValue(challenge.qr_deeplink); + return ( + + + 账号迁移 +
+ 第 {textValue(challenge.current_code_index) || '-'} / {textValue(challenge.code_count) || '-'} 个迁移码,按 APK 策略 60s 轮转。 + +
+
+ + + +
+
+
+ ); +} +function sensitiveValue(value: unknown) { + const data = typeof value === 'object' && value ? value as Record : {}; + return textValue(data.value); +} +function textValue(value: unknown) { + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return ''; +} function registrationFailureMessage(result: WaWorkflowResponse, status: ReturnType) { const detail = status.failureReason || result.error_message || result.status || ''; const reason = accountReasonLabel(detail); diff --git a/webui/src/dashboard/wa-api.ts b/webui/src/dashboard/wa-api.ts index 10b96c8..6e14c0a 100644 --- a/webui/src/dashboard/wa-api.ts +++ b/webui/src/dashboard/wa-api.ts @@ -8,7 +8,7 @@ import type { VerificationDeliveryMethod } from '../proto/byte/v/forge/waapp/v1/ export const ACCOUNT_PAGE_SIZE = 100; export type WaPhoneInput = { region: string; phone: string; e164_number: string; country_calling_code: string; country_iso2: string }; -export type WaWorkflowResponse = { success?: boolean; passed?: boolean; request_failed?: boolean; status?: string; error_message?: string; reject_reason?: string; wa_account_id?: string; client_profile_id?: string; protocol_profile_id?: string; verification_request_id?: string; delivery_method?: string; method?: string; registration_phase?: string; method_statuses?: unknown[]; phone_status?: Record; account_probe?: Record; sms_probe?: Record; phone?: Record; proxy?: Record; verification_request?: Record; registration?: Record; login_state?: Record; check?: Record }; +export type WaWorkflowResponse = { success?: boolean; passed?: boolean; request_failed?: boolean; status?: string; error_message?: string; reject_reason?: string; wa_account_id?: string; client_profile_id?: string; protocol_profile_id?: string; verification_request_id?: string; delivery_method?: string; method?: string; registration_phase?: string; method_statuses?: unknown[]; phone_status?: Record; account_probe?: Record; sms_probe?: Record; phone?: Record; proxy?: Record; verification_request?: Record; account_transfer_challenge?: Record; registration?: Record; login_state?: Record; check?: Record }; export type WaConnectionState = LongConnectionState; export type WaConnectionFilters = { login_state_id?: string; wa_account_id?: string; client_profile_id?: string; registered_identity_id?: string }; export type WaAccountProjection = WAAccount; @@ -121,6 +121,12 @@ export function submitWaRegistrationOTP(account: WAAccount | string, otp: string const accountID = typeof account === 'string' ? account : waAccountID(account); return api('/api/wa/actions/registration/resume-otp', { method: 'POST', body: JSON.stringify({ wa_account_id: accountID, otp }) }); } +export function refreshWaAccountTransferChallenge(verificationRequestID: string) { + return api('/api/wa/actions/registration/account-transfer/refresh', { method: 'POST', body: JSON.stringify({ verification_request_id: verificationRequestID }) }); +} +export function pollWaAccountTransferRegistration(verificationRequestID: string, waAccountID = '', maxAttempts = 1) { + return api('/api/wa/actions/registration/account-transfer/poll', { method: 'POST', body: JSON.stringify({ verification_request_id: verificationRequestID, wa_account_id: waAccountID, max_attempts: maxAttempts }) }); +} export async function setWaTwoFactorAuthSettings(account: WAAccount, pin: string) { return requireAccountSettingsResponse(await api('/api/wa/account-settings/2fa', { method: 'POST', body: JSON.stringify({ ...waAccountSettingsPayload(account), pin }) })); diff --git a/webui/src/dashboard/wa-registration-methods.ts b/webui/src/dashboard/wa-registration-methods.ts index 4e71e13..335a94b 100644 --- a/webui/src/dashboard/wa-registration-methods.ts +++ b/webui/src/dashboard/wa-registration-methods.ts @@ -18,6 +18,7 @@ export const selectableRegistrationMethods: SelectableRegistrationMethodOption[] methodOption(VerificationDeliveryMethod.VERIFICATION_DELIVERY_METHOD_SMS, 'sms'), methodOption(VerificationDeliveryMethod.VERIFICATION_DELIVERY_METHOD_VOICE, 'voice'), methodOption(VerificationDeliveryMethod.VERIFICATION_DELIVERY_METHOD_WA_OLD, 'wa_old'), + methodOption(VerificationDeliveryMethod.VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER, 'acc_tr'), methodOption(VerificationDeliveryMethod.VERIFICATION_DELIVERY_METHOD_EMAIL_OTP, 'email_otp'), methodOption(VerificationDeliveryMethod.VERIFICATION_DELIVERY_METHOD_SEND_SMS, 'send_sms'), ]; diff --git a/webui/src/dashboard/wa-result-model.ts b/webui/src/dashboard/wa-result-model.ts index afcff54..127a29a 100644 --- a/webui/src/dashboard/wa-result-model.ts +++ b/webui/src/dashboard/wa-result-model.ts @@ -175,7 +175,7 @@ function deriveAccountFlow(input: { registered?: boolean; blocked?: boolean; sms export function waProbeCanStartRegistration(result?: WaWorkflowResponse | null, method = 'VERIFICATION_DELIVERY_METHOD_SMS', elapsedSeconds = 0) { const status = waProbeStatus(result); const selectedMethod = methodLabel(method); - if (!['SMS', '语音', '旧设备', '邮箱', '发送 SMS 至 WA'].includes(selectedMethod)) return false; + if (!['SMS', '语音', '旧设备', '账号迁移', '邮箱', '发送 SMS 至 WA'].includes(selectedMethod)) return false; const methodAvailable = status.methodStatuses.some((item) => item.label === selectedMethod && (item.available === true || cooldownExpired(item.cooldownSeconds, elapsedSeconds)) && !cooldownActive(item.cooldownSeconds, elapsedSeconds)); return Boolean(result) && !status.requestFailed From 765ce396e70da4f18e3d18b6a70f819f587dbebf Mon Sep 17 00:00:00 2001 From: pood1e <44490561+pood1e@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:45:58 +0800 Subject: [PATCH 002/127] Align verification code resend attempts --- internal/app/native_engine.go | 1 + internal/app/native_registration_params.go | 63 +++++++++++++++++++--- internal/app/native_state.go | 47 ++++++++-------- 3 files changed, 82 insertions(+), 29 deletions(-) diff --git a/internal/app/native_engine.go b/internal/app/native_engine.go index a4f9773..0caab80 100644 --- a/internal/app/native_engine.go +++ b/internal/app/native_engine.go @@ -141,6 +141,7 @@ func (e *NativeEngine) requestVerificationCodeWithState(ctx context.Context, inp if err := ensureNativeSoftwareAttestation(&state); err != nil { return EngineCodeResult{Status: waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_REJECTED, Err: err}, state } + state.nextGenerateCodeAttempt() params, err := e.codeRequestOrderedParams(ctx, input.Phone, input.DeliveryMethod, state, input.AuthCodeContext) if err != nil { return EngineCodeResult{Status: waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_REJECTED, Err: err}, state diff --git a/internal/app/native_registration_params.go b/internal/app/native_registration_params.go index 2287889..8014d5d 100644 --- a/internal/app/native_registration_params.go +++ b/internal/app/native_registration_params.go @@ -55,6 +55,7 @@ func (e *NativeEngine) codeRequestOrderedParams(ctx context.Context, phone *waap func (e *NativeEngine) codeRequestOrderedParamsWithWamsys(ctx context.Context, phone *waappv1.PhoneTarget, method waappv1.VerificationDeliveryMethod, state nativeState, authCodeContext string, wamsysCapture *waappv1.WamsysCapture, includeWamsys bool) (orderedParams, error) { methodName := registrationMethodName(method, "sms") fields := nativeDeviceMapFields(state) + attempts := nativeCodeRequestAttempts(state) params := orderedParams{} params.set("cc", phoneCC(phone), false) params.set("in", phoneNational(phone), false) @@ -85,7 +86,7 @@ func (e *NativeEngine) codeRequestOrderedParamsWithWamsys(ctx context.Context, p applyNativeCodeRequestPermissionParams(¶ms, fields) } applyNativeE2EParams(¶ms, state) - applyNativeCodeRequestMapParams(¶ms, fields, methodName) + applyNativeCodeRequestMapParams(¶ms, fields, methodName, attempts) var capture *waappv1.WamsysCapture if includeWamsys { var err error @@ -130,11 +131,11 @@ func applyNativeE2EParams(params *orderedParams, state nativeState) { params.set("e_skey_sig", state.KeyBundle.SignedKeySig, false) } -func applyNativeCodeRequestMapParams(params *orderedParams, fields map[string]string, method string) { +func applyNativeCodeRequestMapParams(params *orderedParams, fields map[string]string, method string, attempts int) { addOptionalRawParam(params, "mistyped", fields["mistyped"]) addRawParam(params, "reason", "") addOptionalRawParam(params, "hasav", fields["hasav"]) - addRawParam(params, "client_metrics", nativeCodeClientMetrics()) + addRawParam(params, "client_metrics", nativeCodeClientMetrics(attempts)) addOptionalRawParam(params, "mcc", fields["mcc"]) addOptionalRawParam(params, "mnc", fields["mnc"]) addOptionalRawParam(params, "sim_mcc", fields["sim_mcc"]) @@ -266,7 +267,7 @@ func codeDeviceMap(method string, state nativeState) map[string]string { fields := nativeDeviceMapFields(state) out := map[string]string{ "reason": "", - "client_metrics": nativeCodeClientMetrics(), + "client_metrics": nativeCodeClientMetrics(nativeCodeRequestAttempts(state)), "education_screen_displayed": "false", "prefer_sms_over_flash": nativePreferSMSOverFlash(method, fields), "network_radio_type": fields["network_radio_type"], @@ -368,8 +369,58 @@ func nativeDefaultDeviceMapFields() map[string]string { } } -func nativeCodeClientMetrics() string { - return `{"attempts":1,"app_campaign_download_source":"google-play|unknown"}` +func nativeCodeRequestAttempts(state nativeState) int { + if state.GenerateCodeAttempts > 0 { + return state.GenerateCodeAttempts + } + return nativeCodeClientMetricAttempts(nativeCodeRequestAttemptsFromLastParams(state.LastCodeParams)) +} + +func (s *nativeState) nextGenerateCodeAttempt() int { + previous := s.GenerateCodeAttempts + if previous < 1 { + previous = nativeCodeRequestAttemptsFromLastParams(s.LastCodeParams) + } + if previous < 0 { + previous = 0 + } + s.GenerateCodeAttempts = previous + 1 + return s.GenerateCodeAttempts +} + +func nativeCodeRequestAttemptsFromLastParams(params map[string]string) int { + metrics := strings.TrimSpace(params["client_metrics"]) + if metrics == "" { + return 0 + } + var payload struct { + Attempts int `json:"attempts"` + } + if err := json.Unmarshal([]byte(metrics), &payload); err != nil { + return 0 + } + return payload.Attempts +} + +func nativeCodeClientMetricAttempts(attempts int) int { + if attempts < 1 { + return 1 + } + return attempts +} + +func nativeCodeClientMetrics(attempts int) string { + body, err := json.Marshal(struct { + Attempts int `json:"attempts"` + AppCampaignDownloadSource string `json:"app_campaign_download_source"` + }{ + Attempts: nativeCodeClientMetricAttempts(attempts), + AppCampaignDownloadSource: "google-play|unknown", + }) + if err != nil { + return `{"attempts":1,"app_campaign_download_source":"google-play|unknown"}` + } + return string(body) } func nativeRegisterClientMetrics(method string) string { diff --git a/internal/app/native_state.go b/internal/app/native_state.go index d40d08d..09614f5 100644 --- a/internal/app/native_state.go +++ b/internal/app/native_state.go @@ -23,29 +23,30 @@ const nativeStateSchema = "byte-v-forge-wa-app-native-state/v1" var nativeUserAgentDevicePattern = regexp.MustCompile(`(?:^|\s)Android/([^ ]+)\s+Device/([^- \t/]+)-([^/\s]+)`) type nativeState struct { - Schema string `json:"schema"` - CreatedAtUnix int64 `json:"created_at_unix"` - CC string `json:"cc"` - Phone string `json:"phone"` - AuthKey string `json:"authkey"` - PushName string `json:"push_name,omitempty"` - Profile nativePhoneProfile `json:"profile"` - KeyBundle nativeKeyBundle `json:"key_bundle"` - LastCodeParams map[string]string `json:"last_code_params,omitempty"` - LastCodeResult map[string]any `json:"last_code_result,omitempty"` - LastRegister map[string]any `json:"last_register,omitempty"` - AccountTransfer nativeAccountTransferState `json:"account_transfer,omitempty"` - RegistrationJID string `json:"registration_jid,omitempty"` - ChatRoutingInfo string `json:"chat_routing_info,omitempty"` - ChatConnection nativeChatConnectionState `json:"chat_connection,omitempty"` - ChatStatic nativeCurveKeyPair `json:"chat_static"` - Attestation nativeSoftwareAttestation `json:"attestation,omitempty"` - Signal nativeSignalState `json:"signal"` - AppState nativeAppState `json:"app_state,omitempty"` - ContactHints []waContactHint `json:"contact_hints,omitempty"` - MessagePayloads map[string]nativeMessagePayload `json:"message_payloads,omitempty"` - MessagePlainRef map[string]string `json:"message_plain_ref,omitempty"` - PrivacyTokens map[string]nativePrivacyToken `json:"privacy_tokens,omitempty"` + Schema string `json:"schema"` + CreatedAtUnix int64 `json:"created_at_unix"` + CC string `json:"cc"` + Phone string `json:"phone"` + AuthKey string `json:"authkey"` + PushName string `json:"push_name,omitempty"` + Profile nativePhoneProfile `json:"profile"` + KeyBundle nativeKeyBundle `json:"key_bundle"` + GenerateCodeAttempts int `json:"reg_attempts_generate_code,omitempty"` + LastCodeParams map[string]string `json:"last_code_params,omitempty"` + LastCodeResult map[string]any `json:"last_code_result,omitempty"` + LastRegister map[string]any `json:"last_register,omitempty"` + AccountTransfer nativeAccountTransferState `json:"account_transfer,omitempty"` + RegistrationJID string `json:"registration_jid,omitempty"` + ChatRoutingInfo string `json:"chat_routing_info,omitempty"` + ChatConnection nativeChatConnectionState `json:"chat_connection,omitempty"` + ChatStatic nativeCurveKeyPair `json:"chat_static"` + Attestation nativeSoftwareAttestation `json:"attestation,omitempty"` + Signal nativeSignalState `json:"signal"` + AppState nativeAppState `json:"app_state,omitempty"` + ContactHints []waContactHint `json:"contact_hints,omitempty"` + MessagePayloads map[string]nativeMessagePayload `json:"message_payloads,omitempty"` + MessagePlainRef map[string]string `json:"message_plain_ref,omitempty"` + PrivacyTokens map[string]nativePrivacyToken `json:"privacy_tokens,omitempty"` } type nativePhoneProfile struct { From e558c284214dfa15e6eed2defd55d402df016940 Mon Sep 17 00:00:00 2001 From: pood1e <44490561+pood1e@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:13:19 +0800 Subject: [PATCH 003/127] Clarify wa transfer registration UI --- webui/src/dashboard/wa-account-add-model.ts | 34 ++++++ webui/src/dashboard/wa-account-add.tsx | 102 ++++-------------- .../wa-account-change-number-card.tsx | 49 +++++++++ webui/src/dashboard/wa-account-security.tsx | 2 + .../wa-registration-device-transfer-card.tsx | 43 ++++++++ webui/src/dashboard/wa-result-labels.ts | 2 +- webui/src/dashboard/wa-result-model.ts | 2 +- 7 files changed, 151 insertions(+), 83 deletions(-) create mode 100644 webui/src/dashboard/wa-account-add-model.ts create mode 100644 webui/src/dashboard/wa-account-change-number-card.tsx create mode 100644 webui/src/dashboard/wa-registration-device-transfer-card.tsx diff --git a/webui/src/dashboard/wa-account-add-model.ts b/webui/src/dashboard/wa-account-add-model.ts new file mode 100644 index 0000000..a8046d1 --- /dev/null +++ b/webui/src/dashboard/wa-account-add-model.ts @@ -0,0 +1,34 @@ +import type { WaWorkflowResponse } from './wa-api'; +import { accountReasonLabel } from './wa-result-labels'; +import type { WaProbeStatus } from './wa-result-model'; +import { resolveWaPhoneTarget, type WaResolvedPhone } from './wa-utils'; + +export type WaAccountAddProbeState = { target: WaResolvedPhone; result: WaWorkflowResponse } | null; + +export function probeMatchesValues(probe: WaAccountAddProbeState, phone: string, countryCallingCode: string) { + if (!probe) return false; + return resolveWaPhoneTarget(phone, countryCallingCode).target?.e164 === probe.target.e164; +} + +export function workflowText(result: WaWorkflowResponse, key: keyof WaWorkflowResponse) { + const value = result[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +export function registrationFailureMessage(result: WaWorkflowResponse, status: WaProbeStatus) { + const detail = status.failureReason || result.error_message || result.status || ''; + const reason = accountReasonLabel(detail); + if (status.blocked) return '号码被拒绝/封禁'; + if (status.accountFlow === 'invalid_number') return reason || '号码无效'; + if (status.accountFlow === 'rate_limited') return reason || '请求冷却中'; + return reason || '注册失败'; +} + +export async function copyClipboardText(value: string, onDone: (message: string) => void, onError: (message: string) => void) { + try { + await navigator.clipboard.writeText(value); + onDone('已复制'); + } catch { + onError('复制失败'); + } +} diff --git a/webui/src/dashboard/wa-account-add.tsx b/webui/src/dashboard/wa-account-add.tsx index a66591a..9f1e854 100644 --- a/webui/src/dashboard/wa-account-add.tsx +++ b/webui/src/dashboard/wa-account-add.tsx @@ -6,21 +6,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { pollWaAccountTransferRegistration, probeWaPhoneSMS, refreshWaAccountTransferChallenge, registerWaPhone, submitWaRegistrationOTP, type WaWorkflowResponse } from './wa-api'; +import { copyClipboardText, probeMatchesValues, registrationFailureMessage, workflowText, type WaAccountAddProbeState } from './wa-account-add-model'; import { WhatsAppIcon } from './wa-brand-icon'; import { accountReasonLabel } from './wa-result-labels'; import { waProbeStatus } from './wa-result-model'; import { WaRegistrationChannelButtons } from './wa-registration-channel-buttons'; import { WaRegistrationOtpCard, WA_REGISTRATION_OTP_LENGTH } from './wa-registration-otp-card'; import { registrationAnyMethodAvailable, registrationChannelsHardBlocked, type SelectableRegistrationMethodOption } from './wa-registration-methods'; -import { WaResultPanel } from './wa-result-panel'; import { resolveWaPhoneTarget, type WaResolvedPhone } from './wa-utils'; -type ProbeState = { target: WaResolvedPhone; result: WaWorkflowResponse } | null; +import { WaRegistrationDeviceTransferCard } from './wa-registration-device-transfer-card'; type PendingRegistration = { accountID: string; verificationRequestID: string; accountTransferChallenge?: Record }; type Props = { disabled?: boolean; onChanged: () => void | Promise; onDone: (message: string) => void; onError: (message: string) => void }; export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { const [phone, setPhone] = useState(''); const [countryCallingCode, setCountryCallingCode] = useState(''); - const [probe, setProbe] = useState(null); + const [probe, setProbe] = useState(null); const [pending, setPending] = useState(null); const [registrationResult, setRegistrationResult] = useState(null); const [registrationTarget, setRegistrationTarget] = useState(null); @@ -36,13 +36,13 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { const channelStatus = samePhone ? waProbeStatus(activeRegistrationResult || probe?.result) : null; const cooldownElapsedSeconds = Math.max(0, (clockNow - cooldownStartedAt) / 1000); const blocked = status.blocked === true; - const showChannels = Boolean(channelStatus && !pending); + const showChannels = Boolean(channelStatus); const channelsHardBlocked = registrationChannelsHardBlocked(channelStatus); const canRegister = samePhone && registrationAnyMethodAvailable(channelStatus, cooldownElapsedSeconds) && !channelsHardBlocked; const detected = samePhone && Boolean(channelStatus); const badgeVariant = pending ? 'default' : blocked ? 'destructive' : canRegister ? 'default' : detected ? 'secondary' : 'outline'; const accountTransferPending = Boolean(pending?.accountTransferChallenge); - const badgeLabel = pending ? accountTransferPending ? '等待迁移' : '等待 OTP' : blocked ? '已封禁' : canRegister ? '可注册' : detected ? '无可直发' : '待检测'; + const badgeLabel = pending ? accountTransferPending ? '等待设备确认' : '等待 OTP' : blocked ? '已封禁' : canRegister ? '可注册' : detected ? '无可直发' : '待检测'; useEffect(() => { const activeResult = activeRegistrationResult || (samePhone ? probe?.result : null); @@ -68,9 +68,9 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { setBusy(false); } } - async function submitOTP() { - if (!pending) return onError('没有等待中的 OTP'); - if (pending.accountTransferChallenge) return onError('账号迁移不使用 OTP 输入'); + async function submitOTP() { + if (!pending) return onError('没有等待中的 OTP'); + if (pending.accountTransferChallenge) return onError('设备转移不使用 OTP 输入'); const code = otp.trim(); if (!code) return onError('请输入 OTP'); if (code.length !== WA_REGISTRATION_OTP_LENGTH) return onError(`请输入 ${WA_REGISTRATION_OTP_LENGTH} 位 OTP`); @@ -103,12 +103,12 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { onError(registrationFailureMessage(result, resultStatus)); return; } - const accountID = workflowText(result, 'wa_account_id'); - const verificationRequestID = workflowText(result, 'verification_request_id'); - if (accountID) setPending({ accountID, verificationRequestID, accountTransferChallenge: result.account_transfer_challenge }); - setProbe(null); - setOtp(''); - onDone(result.account_transfer_challenge ? '账号迁移已发起' : accountID ? 'OTP 已发送' : '已发起'); + const accountID = workflowText(result, 'wa_account_id'); + const verificationRequestID = workflowText(result, 'verification_request_id'); + if (accountID) setPending({ accountID, verificationRequestID, accountTransferChallenge: result.account_transfer_challenge }); + setProbe(null); + setOtp(''); + onDone(result.account_transfer_challenge ? '设备转移已发起' : accountID ? 'OTP 已发送' : '已发起'); await onChanged(); } catch (error) { onError(error instanceof Error ? error.message : String(error)); @@ -126,7 +126,7 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { setBusy(true); try { const result = await refreshWaAccountTransferChallenge(pending.verificationRequestID); - if (result.success === false || result.error_message) throw new Error(accountReasonLabel(result.error_message, result.status) || '刷新迁移 Deeplink 失败'); + if (result.success === false || result.error_message) throw new Error(accountReasonLabel(result.error_message, result.status) || '刷新设备转移 Deeplink 失败'); setPending({ ...pending, accountTransferChallenge: result.account_transfer_challenge }); } catch (error) { onError(error instanceof Error ? error.message : String(error)); @@ -139,9 +139,9 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { setBusy(true); try { const result = await pollWaAccountTransferRegistration(pending.verificationRequestID, pending.accountID, 1); - if (result.success === false || result.error_message) throw new Error(accountReasonLabel(result.error_message, result.status) || '账号迁移仍在等待确认'); + if (result.success === false || result.error_message) throw new Error(accountReasonLabel(result.error_message, result.status) || '设备转移仍在等待确认'); setPending(null); - onDone('账号迁移已完成'); + onDone('设备转移已完成'); await onChanged(); } catch (error) { onError(error instanceof Error ? error.message : String(error)); @@ -149,21 +149,10 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { setBusy(false); } } - async function copyText(value: string) { - try { - await navigator.clipboard.writeText(value); - onDone('已复制'); - } catch { - onError('复制失败'); - } - } - return ( -
- 添加 WAAccount -
+
添加 WAAccount
{pending ? : canRegister ? : null} {badgeLabel} @@ -186,68 +175,19 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { {showChannels && ( 通道 - void startRegistration(method)} /> + void startRegistration(method)} /> )} {pending && (pending.accountTransferChallenge ? ( - void copyText(value)} + onCopy={(value) => void copyClipboardText(value, onDone, onError)} onPoll={() => void pollAccountTransfer()} onRefresh={() => void refreshAccountTransfer()} /> ) : void submitOTP()} />)} - {(activeRegistrationResult || probe || busy) && ( - - - - )} - -
- ); -} -function probeMatchesValues(probe: ProbeState, phone: string, countryCallingCode: string) { - if (!probe) return false; - return resolveWaPhoneTarget(phone, countryCallingCode).target?.e164 === probe.target.e164; -} -function workflowText(result: WaWorkflowResponse, key: keyof WaWorkflowResponse) { - const value = result[key]; - return typeof value === 'string' ? value.trim() : ''; -} -function WaAccountTransferCard({ challenge, busy, onRefresh, onPoll, onCopy }: { challenge: Record; busy?: boolean; onRefresh: () => void; onPoll: () => void; onCopy: (value: string) => void }) { - const deeplink = sensitiveValue(challenge.qr_deeplink); - return ( - - - 账号迁移 -
- 第 {textValue(challenge.current_code_index) || '-'} / {textValue(challenge.code_count) || '-'} 个迁移码,按 APK 策略 60s 轮转。 - -
-
- - - -
); } -function sensitiveValue(value: unknown) { - const data = typeof value === 'object' && value ? value as Record : {}; - return textValue(data.value); -} -function textValue(value: unknown) { - if (typeof value === 'string') return value; - if (typeof value === 'number') return String(value); - return ''; -} -function registrationFailureMessage(result: WaWorkflowResponse, status: ReturnType) { - const detail = status.failureReason || result.error_message || result.status || ''; - const reason = accountReasonLabel(detail); - if (status.blocked) return '号码被拒绝/封禁'; - if (status.accountFlow === 'invalid_number') return reason || '号码无效'; - if (status.accountFlow === 'rate_limited') return reason || '请求冷却中'; - return reason || '注册失败'; -} diff --git a/webui/src/dashboard/wa-account-change-number-card.tsx b/webui/src/dashboard/wa-account-change-number-card.tsx new file mode 100644 index 0000000..0936038 --- /dev/null +++ b/webui/src/dashboard/wa-account-change-number-card.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { ArrowRightLeft, PhoneForwarded } from 'lucide-react'; +import type { WAAccount } from '../proto/byte/v/forge/waapp/v1/profile'; +import { Button } from '@/components/ui/button'; +import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { resolveWaPhoneTarget } from './wa-utils'; + +type Props = { + account: WAAccount; + busy?: boolean; + onError: (message: string) => void; +}; + +export function WaAccountChangeNumberCard({ account, busy, onError }: Props) { + const [countryCallingCode, setCountryCallingCode] = useState(''); + const [phone, setPhone] = useState(''); + const currentPhone = account.phone?.e164_number || '-'; + function startChangeNumber() { + const resolved = resolveWaPhoneTarget(phone, countryCallingCode); + if (!resolved.target) return onError(resolved.error || '请输入新手机号和国家拨号码'); + return onError('换绑手机号链路尚未接入:需要按 APK ChangeNumber/ChangeNumberOverview 链路补齐后端实现'); + } + return ( +
+
+
+
账号迁移 / 换绑手机号
+
对应已登录账号安全设置里的 Change number,不是注册侧设备转移。
+
+
+ + + 当前手机号 + + + + 新国家拨号码 + setCountryCallingCode(event.target.value)} disabled={busy} /> + + + 新手机号 + setPhone(event.target.value)} disabled={busy} /> + + + +
+ ); +} diff --git a/webui/src/dashboard/wa-account-security.tsx b/webui/src/dashboard/wa-account-security.tsx index 66f0223..2116653 100644 --- a/webui/src/dashboard/wa-account-security.tsx +++ b/webui/src/dashboard/wa-account-security.tsx @@ -5,6 +5,7 @@ import { AccountSettingsOperationStatus } from '../proto/byte/v/forge/waapp/v1/a import type { GetTwoFactorAuthStatusResponse } from '../proto/byte/v/forge/waapp/v1/account_settings'; import type { WaAccountProjection } from './wa-api'; import { getWaTwoFactorAuthStatus, requestWaAccountEmailOtp, setWaAccountEmail, setWaTwoFactorAuthSettings, verifyWaAccountEmailOtp, waAccountID, waKeys } from './wa-api'; +import { WaAccountChangeNumberCard } from './wa-account-change-number-card'; import { emailBadgeVariant, emailStatusLabel, @@ -142,6 +143,7 @@ export function WaAccountSecurityPanel({ account, onDone, onError }: Props) { {emailConfigured && !emailEditing ? : null} {emailFormVisible ? { setEmail(''); setEmailEditing(false); }} onSubmit={(event) => submit(event, emailSet.mutate)} /> : null} + {emailOtpVisible && (
邮箱 OTP
diff --git a/webui/src/dashboard/wa-registration-device-transfer-card.tsx b/webui/src/dashboard/wa-registration-device-transfer-card.tsx new file mode 100644 index 0000000..5e6cc01 --- /dev/null +++ b/webui/src/dashboard/wa-registration-device-transfer-card.tsx @@ -0,0 +1,43 @@ +import { KeyRound } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; + +type Props = { + challenge: Record; + busy?: boolean; + onRefresh: () => void; + onPoll: () => void; + onCopy: (value: string) => void; +}; + +export function WaRegistrationDeviceTransferCard({ challenge, busy, onRefresh, onPoll, onCopy }: Props) { + const deeplink = sensitiveValue(challenge.qr_deeplink); + return ( + + + 设备转移 +
+ 第 {textValue(challenge.current_code_index) || '-'} / {textValue(challenge.code_count) || '-'} 个转移码,按 APK 策略 60s 轮转。 + +
+
+ + + +
+
+
+ ); +} + +function sensitiveValue(value: unknown) { + const data = typeof value === 'object' && value ? value as Record : {}; + return textValue(data.value); +} + +function textValue(value: unknown) { + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return ''; +} diff --git a/webui/src/dashboard/wa-result-labels.ts b/webui/src/dashboard/wa-result-labels.ts index 94eb74e..a72314d 100644 --- a/webui/src/dashboard/wa-result-labels.ts +++ b/webui/src/dashboard/wa-result-labels.ts @@ -94,7 +94,7 @@ export function methodLabel(value: string) { if (normalized === 'SILENT_AUTH_TS43' || normalized === 'SILENT_AUTH_TS_43') return '静默验证 TS43'; if (normalized === 'EMAIL' || normalized === 'EMAIL_OTP') return '邮箱'; if (normalized === 'OAUTH_EMAIL') return 'OAuth 邮箱'; - if (normalized === 'ACCOUNT_TRANSFER' || normalized === 'ACC_TR') return '账号迁移'; + if (normalized === 'ACCOUNT_TRANSFER' || normalized === 'ACC_TR') return '设备转移'; if (normalized === 'RECAPTCHA') return 'reCAPTCHA'; if (normalized === 'TWO_FACTOR_PIN' || normalized === 'TWOFAC_PIN') return '两步验证 PIN'; if (normalized === 'PASSWORD') return '密码'; diff --git a/webui/src/dashboard/wa-result-model.ts b/webui/src/dashboard/wa-result-model.ts index 127a29a..69d6f1c 100644 --- a/webui/src/dashboard/wa-result-model.ts +++ b/webui/src/dashboard/wa-result-model.ts @@ -175,7 +175,7 @@ function deriveAccountFlow(input: { registered?: boolean; blocked?: boolean; sms export function waProbeCanStartRegistration(result?: WaWorkflowResponse | null, method = 'VERIFICATION_DELIVERY_METHOD_SMS', elapsedSeconds = 0) { const status = waProbeStatus(result); const selectedMethod = methodLabel(method); - if (!['SMS', '语音', '旧设备', '账号迁移', '邮箱', '发送 SMS 至 WA'].includes(selectedMethod)) return false; + if (!['SMS', '语音', '旧设备', '设备转移', '邮箱', '发送 SMS 至 WA'].includes(selectedMethod)) return false; const methodAvailable = status.methodStatuses.some((item) => item.label === selectedMethod && (item.available === true || cooldownExpired(item.cooldownSeconds, elapsedSeconds)) && !cooldownActive(item.cooldownSeconds, elapsedSeconds)); return Boolean(result) && !status.requestFailed From a60ab0e140966d5b4701d64106abf3fa9bd58e9c Mon Sep 17 00:00:00 2001 From: pood1e <44490561+pood1e@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:28:07 +0800 Subject: [PATCH 004/127] Implement old-device OTP inbox banner --- cmd/wa-app-service/dashboard_http.go | 13 ++- internal/app/chatd_client.go | 56 ++++++++----- internal/app/chatd_node.go | 14 ++++ internal/app/chatd_old_registration.go | 81 +++++++++++++++++++ internal/app/migrations/001_init.sql | 2 + internal/app/native_engine.go | 4 +- internal/app/native_long_connection.go | 24 +++--- internal/app/native_text_message.go | 2 +- internal/app/ports.go | 7 +- internal/app/postgres_rows.go | 2 + internal/app/postgres_store.go | 12 +-- internal/app/server_messaging.go | 5 ++ proto/byte/v/forge/waapp/v1/extraction.proto | 1 + webui/src/dashboard/wa-api.ts | 7 +- webui/src/dashboard/wa-contact-list.tsx | 19 +++-- webui/src/dashboard/wa-contact-otp-banner.tsx | 47 +++++++++++ webui/src/dashboard/wa-inbox.tsx | 18 ++++- 17 files changed, 255 insertions(+), 59 deletions(-) create mode 100644 internal/app/chatd_old_registration.go create mode 100644 webui/src/dashboard/wa-contact-otp-banner.tsx diff --git a/cmd/wa-app-service/dashboard_http.go b/cmd/wa-app-service/dashboard_http.go index 808338c..c2aa7cd 100644 --- a/cmd/wa-app-service/dashboard_http.go +++ b/cmd/wa-app-service/dashboard_http.go @@ -267,7 +267,7 @@ func (s *dashboardHTTP) handleAccountOTPMessages(w http.ResponseWriter, r *http. WaAccountId: q.Get("wa_account_id"), Limit: int32(positiveInt(q.Get("limit"), 20)), Cursor: q.Get("cursor"), - IncludeSensitiveValues: true, + IncludeSensitiveValues: queryBool(q.Get("include_sensitive_values"), true), }) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "load WA OTP history failed"}) @@ -847,6 +847,17 @@ func positiveInt(value string, fallback int) int { return parsed } +func queryBool(value string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + default: + return fallback + } +} + func methodNotAllowed(w http.ResponseWriter, allowed string) { w.Header().Set("Allow", allowed) writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) diff --git a/internal/app/chatd_client.go b/internal/app/chatd_client.go index b867d65..998a291 100644 --- a/internal/app/chatd_client.go +++ b/internal/app/chatd_client.go @@ -56,6 +56,7 @@ type chatdSession struct { transport chatdTransport endpoint chatdEndpoint serverStaticPublic string + deviceID string } type chatdSessionUpdate struct { @@ -69,6 +70,7 @@ type chatdSessionUpdate struct { type chatdReceivedItem struct { message *waappv1.InboundMessage payload *chatdEncPayload + otp *waappv1.OtpMessage } func chatdPhase(phase string, err error) error { @@ -101,10 +103,10 @@ func (c *chatdClient) openTimeout() time.Duration { return c.cfg.Timeout } -func (c *chatdClient) receiveBatch(ctx context.Context, state nativeState, input EngineMessageInput, appVersion string, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, error) { +func (c *chatdClient) receiveBatch(ctx context.Context, state nativeState, input EngineMessageInput, appVersion string, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, error) { session, err := c.openSession(ctx, state, input.RegisteredIdentityID, defaultLoginPayload, appVersion) if err != nil { - return nil, nil, chatdSessionUpdate{}, err + return nil, nil, nil, chatdSessionUpdate{}, err } defer session.Close() return session.receiveBatch(input, now) @@ -178,6 +180,7 @@ func (c *chatdClient) openEndpointSession(ctx context.Context, endpoint chatdEnd transport: chatdTransport{rw: rw, keys: keys, codec: c.codec, maxFrameBytes: c.cfg.MaxFrameBytes}, endpoint: endpoint, serverStaticPublic: b64u(keys.serverStaticPublic), + deviceID: chatdDeviceIDFromState(state), }, nil } @@ -252,9 +255,9 @@ func (s *chatdSession) update() chatdSessionUpdate { return chatdSessionUpdate{Endpoint: s.endpoint, ServerStaticPublic: s.serverStaticPublic} } -func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, error) { +func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, error) { if s == nil { - return nil, nil, chatdSessionUpdate{}, fmt.Errorf("chatd session is not open") + return nil, nil, nil, chatdSessionUpdate{}, fmt.Errorf("chatd session is not open") } update := s.update() maxMessages := input.MaxMessages @@ -273,7 +276,7 @@ func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([] if len(items) > 0 { break } - return nil, nil, update, chatdPhase("chatd frame read", err) + return nil, nil, nil, update, chatdPhase("chatd frame read", err) } nextUpdate, nextItems, err := s.consumeIncomingNode(input, node, update, now) update = nextUpdate @@ -281,7 +284,7 @@ func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([] if len(items) > 0 { break } - return nil, nil, update, err + return nil, nil, nil, update, err } before := len(items) items = appendReceivedItems(items, nextItems, maxMessages) @@ -289,8 +292,8 @@ func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([] deadline = minTime(deadline, time.Now().Add(defaultChatdPostMessageDrainWindow)) } } - messages, payloads := splitReceivedItems(items) - return messages, payloads, update, nil + messages, payloads, otps := splitReceivedItems(items) + return messages, payloads, otps, update, nil } func (s *chatdSession) consumeIncomingNode(input EngineMessageInput, node chatdNode, update chatdSessionUpdate, now time.Time) (chatdSessionUpdate, []chatdReceivedItem, error) { @@ -300,7 +303,12 @@ func (s *chatdSession) consumeIncomingNode(input EngineMessageInput, node chatdN if isChatdTerminalNode(node) { return update, nil, newChatdError("server sent %s", controlNodeSummary(node)) } - if ack, ok := buildAckForNode(node); ok { + oldRegistrationOTP, isOldRegistrationNode, oldRegistrationValid := oldRegistrationOTPFromChatdNode(node, s.deviceID, now) + ackAttrs := map[string]string(nil) + if isOldRegistrationNode && s.deviceID != "" { + ackAttrs = map[string]string{"device_id": s.deviceID} + } + if ack, ok := buildAckForNodeWithAttrs(node, ackAttrs); ok { if err := s.transport.sendNode(ack); err != nil { return update, nil, chatdPhase("chatd ack write", err) } @@ -310,21 +318,26 @@ func (s *chatdSession) consumeIncomingNode(input EngineMessageInput, node chatdN } update.ContactHints = dedupeWAContactHints(append(update.ContactHints, contactHintsFromChatdNode(node)...)) update.PrivacyTokens = dedupePrivacyTokenUpdates(append(update.PrivacyTokens, privacyTokenUpdatesFromChatdNode(node)...)) + items := []chatdReceivedItem{} + if oldRegistrationValid { + if otp := oldRegistrationOTPMessage(input, node, oldRegistrationOTP, now); otp != nil { + items = append(items, chatdReceivedItem{otp: otp}) + } + } if input.MessageSessionID == "" { - return update, nil, nil + return update, items, nil } encs := iterEncPayloads(node) if len(encs) == 0 { if node.Tag != "message" { - return update, nil, nil + return update, items, nil } contact := firstNonEmpty(node.Attrs["from"], node.Attrs["participant"]) sender := firstNonEmpty(node.Attrs["participant"], node.Attrs["from"]) payloadSummary := nodePayloadSummary(node) message := &waappv1.InboundMessage{MessageId: inboundMessageID(input.WAAccountID, node.Attrs["id"], node.Tag, sender, payloadSummary), MessageSessionId: input.MessageSessionID, Kind: inboundKind(node.Tag), EncryptionState: waappv1.MessageEncryptionState_MESSAGE_ENCRYPTION_STATE_PLAINTEXT, AckStatus: ackStatusForNode(node), ContactRef: contact, SenderRef: sender, PayloadRef: "node:" + redacted(payloadSummary), ProviderMessageId: node.Attrs["id"], ProviderTimestamp: chatdProviderTimestamp(node.Attrs["t"]), DeleteStatus: waappv1.MessageDeleteStatus_MESSAGE_DELETE_STATUS_NOT_DELETED, ReceivedAt: timestamppb.New(now)} - return update, []chatdReceivedItem{{message: message}}, nil + return update, append(items, chatdReceivedItem{message: message}), nil } - items := make([]chatdReceivedItem, 0, len(encs)) for _, enc := range encs { payload := enc payloadRef := payloadRefForEnc(input.WAAccountID, payload.Payload) @@ -347,19 +360,22 @@ func appendReceivedItems(dst []chatdReceivedItem, src []chatdReceivedItem, limit return dst } -func splitReceivedItems(items []chatdReceivedItem) ([]*waappv1.InboundMessage, []chatdEncPayload) { +func splitReceivedItems(items []chatdReceivedItem) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage) { messages := make([]*waappv1.InboundMessage, 0, len(items)) payloads := []chatdEncPayload{} + otps := []*waappv1.OtpMessage{} for _, item := range items { - if item.message == nil { - continue + if item.otp != nil { + otps = append(otps, item.otp) } - messages = append(messages, item.message) - if item.payload != nil { - payloads = append(payloads, *item.payload) + if item.message != nil { + messages = append(messages, item.message) + if item.payload != nil { + payloads = append(payloads, *item.payload) + } } } - return messages, payloads + return messages, payloads, otps } func chatdProviderTimestamp(value string) *timestamppb.Timestamp { diff --git a/internal/app/chatd_node.go b/internal/app/chatd_node.go index a3fb3e8..a306cbf 100644 --- a/internal/app/chatd_node.go +++ b/internal/app/chatd_node.go @@ -777,6 +777,10 @@ func buildPingNode() chatdNode { } func buildAckForNode(node chatdNode) (chatdNode, bool) { + return buildAckForNodeWithAttrs(node, nil) +} + +func buildAckForNodeWithAttrs(node chatdNode, extra map[string]string) (chatdNode, bool) { nodeID := node.Attrs["id"] sender := node.Attrs["from"] if nodeID == "" || sender == "" { @@ -788,6 +792,7 @@ func buildAckForNode(node chatdNode) (chatdNode, bool) { if t := node.Attrs["type"]; t != "" { attrs["type"] = t } + addAckExtraAttrs(attrs, extra) return chatdNode{Tag: "ack", Attrs: attrs}, true case "message": attrs := map[string]string{"id": nodeID, "to": sender, "class": "message"} @@ -797,12 +802,21 @@ func buildAckForNode(node chatdNode) (chatdNode, bool) { if p := node.Attrs["participant"]; p != "" { attrs["participant"] = p } + addAckExtraAttrs(attrs, extra) return chatdNode{Tag: "ack", Attrs: attrs}, true default: return chatdNode{}, false } } +func addAckExtraAttrs(attrs map[string]string, extra map[string]string) { + for key, value := range extra { + if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" { + attrs[key] = value + } + } +} + func iterEncPayloads(node chatdNode) []chatdEncPayload { out := []chatdEncPayload{} var walk func(chatdNode, []string, chatdMessageRefs) diff --git a/internal/app/chatd_old_registration.go b/internal/app/chatd_old_registration.go new file mode 100644 index 0000000..e340855 --- /dev/null +++ b/internal/app/chatd_old_registration.go @@ -0,0 +1,81 @@ +package app + +import ( + "encoding/hex" + "strconv" + "strings" + "time" + + waappv1 "github.com/byte-v-forge/wa-app/gen/go/byte/v/forge/waapp/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type chatdOldRegistrationOTP struct { + code string + deviceID string + expiresAt time.Time +} + +func chatdDeviceIDFromState(state nativeState) string { + return chatdDeviceIDFromUUID(state.Profile.FDID) +} + +func chatdDeviceIDFromUUID(value string) string { + compact := strings.ReplaceAll(strings.TrimSpace(value), "-", "") + raw, err := hex.DecodeString(compact) + if err != nil || len(raw) != 16 { + return "" + } + return b64u(raw) +} + +func oldRegistrationOTPFromChatdNode(node chatdNode, currentDeviceID string, now time.Time) (chatdOldRegistrationOTP, bool, bool) { + if node.Tag != "notification" { + return chatdOldRegistrationOTP{}, false, false + } + child, ok := chatdChild(node, "wa_old_registration") + if !ok { + return chatdOldRegistrationOTP{}, false, false + } + otp := chatdOldRegistrationOTP{ + code: strings.TrimSpace(child.Attrs["code"]), + deviceID: strings.TrimSpace(child.Attrs["device_id"]), + expiresAt: oldRegistrationExpiresAt(child.Attrs["expiry_t"]), + } + if otp.code == "" || otp.expiresAt.IsZero() || !now.Before(otp.expiresAt) { + return otp, true, false + } + if currentDeviceID != "" && otp.deviceID == currentDeviceID { + return otp, true, false + } + return otp, true, true +} + +func oldRegistrationExpiresAt(value string) time.Time { + stamp, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || stamp <= 0 { + return time.Time{} + } + if stamp > 1_000_000_000_000 { + return time.UnixMilli(stamp).UTC() + } + return time.Unix(stamp, 0).UTC() +} + +func oldRegistrationOTPMessage(input EngineMessageInput, node chatdNode, otp chatdOldRegistrationOTP, now time.Time) *waappv1.OtpMessage { + if strings.TrimSpace(input.WAAccountID) == "" || strings.TrimSpace(otp.code) == "" || otp.expiresAt.IsZero() { + return nil + } + otpID := "waotp_old_" + stableID(strings.Join([]string{input.WAAccountID, node.Attrs["id"], otp.code, strconv.FormatInt(otp.expiresAt.Unix(), 10)}, ":")) + return &waappv1.OtpMessage{ + OtpMessageId: otpID, + WaAccountId: input.WAAccountID, + ClientProfileId: input.ClientProfileID, + RegisteredIdentityId: input.RegisteredIdentityID, + Source: waappv1.WaOtpSource_WA_OTP_SOURCE_LONG_CONNECTION, + SourceParty: firstNonEmpty(node.Attrs["from"], "wa_old_registration"), + Otp: &waappv1.SensitiveText{Value: otp.code, RedactedValue: redacted(otp.code), SecretRef: "wa-otp:" + stableID(otpID)}, + ReceivedAt: timestamppb.New(now.UTC()), + ExpiresAt: timestamppb.New(otp.expiresAt.UTC()), + } +} diff --git a/internal/app/migrations/001_init.sql b/internal/app/migrations/001_init.sql index accddbc..a08d1f4 100644 --- a/internal/app/migrations/001_init.sql +++ b/internal/app/migrations/001_init.sql @@ -213,6 +213,7 @@ CREATE TABLE IF NOT EXISTS wa_otp_messages ( otp_redacted TEXT NOT NULL DEFAULT '', otp_secret_ref TEXT NOT NULL DEFAULT '', received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -267,6 +268,7 @@ ALTER TABLE wa_inbound_messages ADD COLUMN IF NOT EXISTS provider_timestamp TIME ALTER TABLE wa_inbound_messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ; ALTER TABLE wa_inbound_messages ADD COLUMN IF NOT EXISTS delete_status TEXT NOT NULL DEFAULT 'MESSAGE_DELETE_STATUS_NOT_DELETED'; ALTER TABLE wa_inbound_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE wa_otp_messages ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; ALTER TABLE wa_contacts ADD COLUMN IF NOT EXISTS profile_picture_id TEXT NOT NULL DEFAULT ''; CREATE UNIQUE INDEX IF NOT EXISTS wa_accounts_e164_number_key ON wa_accounts (e164_number); diff --git a/internal/app/native_engine.go b/internal/app/native_engine.go index 0caab80..2a1f489 100644 --- a/internal/app/native_engine.go +++ b/internal/app/native_engine.go @@ -354,14 +354,14 @@ func (e *NativeEngine) ReceiveMessageBatch(ctx context.Context, input EngineMess } client := newChatdClient(chatdConfigForState(proxyURL, state, 0)) now := e.clock.Now() - messages, payloads, update, err := client.receiveBatch(ctx, state, input, input.AppVersion, now) + messages, payloads, otps, update, err := client.receiveBatch(ctx, state, input, input.AppVersion, now) if err != nil { return EngineMessageBatchResult{Err: chatdReceiveError(err)} } if applyChatdReceiveState(&state, input, payloads, update) { _ = e.saveState(ctx, input.ClientProfileID, state) } - return EngineMessageBatchResult{Messages: messages, Contacts: contactsFromContactHints(input.WAAccountID, nil, update.ContactHints, now)} + return EngineMessageBatchResult{Messages: messages, Contacts: contactsFromContactHints(input.WAAccountID, nil, update.ContactHints, now), OTPMessages: otps} } func applyChatdReceiveState(state *nativeState, input EngineMessageInput, payloads []chatdEncPayload, update chatdSessionUpdate) bool { diff --git a/internal/app/native_long_connection.go b/internal/app/native_long_connection.go index 530be59..0d33bc9 100644 --- a/internal/app/native_long_connection.go +++ b/internal/app/native_long_connection.go @@ -87,10 +87,10 @@ func (e *longConnectionNativeEngine) ReceiveMessageBatch(ctx context.Context, in return EngineMessageBatchResult{Err: chatdReceiveError(err)} } now := e.clock.Now() - messages, payloads, update, drained := e.drainPendingLocked(input) + messages, payloads, otps, update, drained := e.drainPendingLocked(input) if !drained { var preempted bool - messages, payloads, update, err, preempted = e.receiveBatchWithActiveReadLocked(ctx, session, input, now) + messages, payloads, otps, update, err, preempted = e.receiveBatchWithActiveReadLocked(ctx, session, input, now) if err != nil { if preempted { return EngineMessageBatchResult{} @@ -101,7 +101,7 @@ func (e *longConnectionNativeEngine) ReceiveMessageBatch(ctx context.Context, in return EngineMessageBatchResult{Err: chatdReceiveError(retryErr)} } now = e.clock.Now() - messages, payloads, update, err, preempted = e.receiveBatchWithActiveReadLocked(ctx, session, input, now) + messages, payloads, otps, update, err, preempted = e.receiveBatchWithActiveReadLocked(ctx, session, input, now) if err != nil { if preempted { return EngineMessageBatchResult{} @@ -124,7 +124,7 @@ func (e *longConnectionNativeEngine) ReceiveMessageBatch(ctx context.Context, in } } } - return EngineMessageBatchResult{Messages: messages, Contacts: contactsFromContactHints(input.WAAccountID, nil, update.ContactHints, now)} + return EngineMessageBatchResult{Messages: messages, Contacts: contactsFromContactHints(input.WAAccountID, nil, update.ContactHints, now), OTPMessages: otps} } func (e *longConnectionNativeEngine) ResolveContactProfilePicture(ctx context.Context, input EngineContactProfilePictureInput) EngineContactProfilePictureResult { @@ -204,9 +204,9 @@ func (e *longConnectionNativeEngine) ensureSessionForIQLocked(ctx context.Contex return nil, err } -func (e *longConnectionNativeEngine) drainPendingLocked(input EngineMessageInput) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, bool) { +func (e *longConnectionNativeEngine) drainPendingLocked(input EngineMessageInput) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, bool) { if len(e.pending) == 0 && !hasChatdSessionUpdate(e.pendingUp) { - return nil, nil, chatdSessionUpdate{}, false + return nil, nil, nil, chatdSessionUpdate{}, false } limit := input.MaxMessages if limit <= 0 { @@ -220,8 +220,8 @@ func (e *longConnectionNativeEngine) drainPendingLocked(input EngineMessageInput e.pending = append([]chatdReceivedItem(nil), e.pending[count:]...) update := e.pendingUp e.pendingUp = chatdSessionUpdate{} - messages, payloads := splitReceivedItems(items) - return messages, payloads, update, true + messages, payloads, otps := splitReceivedItems(items) + return messages, payloads, otps, update, true } func (e *longConnectionNativeEngine) bufferPendingLocked(items []chatdReceivedItem, update chatdSessionUpdate) { @@ -232,13 +232,13 @@ func (e *longConnectionNativeEngine) bufferPendingLocked(items []chatdReceivedIt e.pendingUp = mergeChatdSessionUpdate(e.pendingUp, update) } -func (e *longConnectionNativeEngine) receiveBatchWithActiveReadLocked(ctx context.Context, session *chatdSession, input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, error, bool) { +func (e *longConnectionNativeEngine) receiveBatchWithActiveReadLocked(ctx context.Context, session *chatdSession, input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, error, bool) { read, readCtx := e.startActiveReadLocked(ctx) e.mu.Unlock() - messages, payloads, update, err := receiveChatdBatchWithContext(readCtx, session, input, now) + messages, payloads, otps, update, err := receiveChatdBatchWithContext(readCtx, session, input, now) e.mu.Lock() preempted := e.finishActiveReadLocked(read) - return messages, payloads, update, err, preempted + return messages, payloads, otps, update, err, preempted } func (e *longConnectionNativeEngine) startActiveReadLocked(ctx context.Context) (*longConnectionActiveRead, context.Context) { @@ -516,7 +516,7 @@ func (e *longConnectionNativeEngine) closeLocked() error { return err } -func receiveChatdBatchWithContext(ctx context.Context, session *chatdSession, input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, error) { +func receiveChatdBatchWithContext(ctx context.Context, session *chatdSession, input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, error) { stopContextClose := closeChatdSessionOnContext(ctx, session) defer stopContextClose() return session.receiveBatch(input, now) diff --git a/internal/app/native_text_message.go b/internal/app/native_text_message.go index 6acae59..f98ca6e 100644 --- a/internal/app/native_text_message.go +++ b/internal/app/native_text_message.go @@ -113,7 +113,7 @@ func (e *NativeEngine) applyTextMessageSendUpdate(ctx context.Context, clientPro if state == nil { return nil } - _, payloads := splitReceivedItems(items) + _, payloads, _ := splitReceivedItems(items) if !applyChatdReceiveState(state, input, payloads, update) { return nil } diff --git a/internal/app/ports.go b/internal/app/ports.go index 5914957..b29f6a2 100644 --- a/internal/app/ports.go +++ b/internal/app/ports.go @@ -293,9 +293,10 @@ type EngineTextMessageResult struct { } type EngineMessageBatchResult struct { - Messages []*waappv1.InboundMessage - Contacts []*waappv1.WAContact - Err error + Messages []*waappv1.InboundMessage + Contacts []*waappv1.WAContact + OTPMessages []*waappv1.OtpMessage + Err error } type EngineDecryptResult struct { diff --git a/internal/app/postgres_rows.go b/internal/app/postgres_rows.go index 83e485c..f990ca3 100644 --- a/internal/app/postgres_rows.go +++ b/internal/app/postgres_rows.go @@ -472,6 +472,7 @@ type otpMessageRow struct { otpRedacted string otpSecretRef string receivedAt time.Time + expiresAt sql.NullTime createdAt time.Time updatedAt time.Time } @@ -495,6 +496,7 @@ func (r otpMessageRow) toProto(includeSensitiveValue bool) *waappv1.OtpMessage { SourceParty: r.sourceParty, Otp: text, ReceivedAt: timestamppb.New(r.receivedAt.UTC()), + ExpiresAt: sqlTime(r.expiresAt), Audit: audit(r.createdAt, r.updatedAt), } } diff --git a/internal/app/postgres_store.go b/internal/app/postgres_store.go index 09f0016..6cd679b 100644 --- a/internal/app/postgres_store.go +++ b/internal/app/postgres_store.go @@ -532,10 +532,10 @@ func (s *PostgresStore) SaveOTPMessage(ctx context.Context, msg *waappv1.OtpMess otpID := firstNonEmpty(msg.GetOtpMessageId(), stableOTPMessageID(msg.GetWaAccountId(), msg.GetSourceParty(), otpValue)) redactedValue := firstNonEmpty(msg.GetOtp().GetRedactedValue(), redacted(otpValue)) secretRef := firstNonEmpty(msg.GetOtp().GetSecretRef(), "wa-otp:"+stableID(otpID)) - _, err := s.pool.Exec(ctx, `INSERT INTO wa_otp_messages (otp_message_id, wa_account_id, client_profile_id, registered_identity_id, message_id, candidate_id, source, source_party, otp_value, otp_redacted, otp_secret_ref, received_at, created_at, updated_at) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now()) -ON CONFLICT (otp_message_id) DO UPDATE SET client_profile_id=EXCLUDED.client_profile_id, registered_identity_id=EXCLUDED.registered_identity_id, message_id=EXCLUDED.message_id, candidate_id=EXCLUDED.candidate_id, source=EXCLUDED.source, source_party=EXCLUDED.source_party, otp_value=EXCLUDED.otp_value, otp_redacted=EXCLUDED.otp_redacted, otp_secret_ref=EXCLUDED.otp_secret_ref, received_at=EXCLUDED.received_at, updated_at=EXCLUDED.updated_at`, - otpID, msg.GetWaAccountId(), msg.GetClientProfileId(), msg.GetRegisteredIdentityId(), msg.GetMessageId(), msg.GetCandidateId(), source, msg.GetSourceParty(), otpValue, redactedValue, secretRef, timeFromProto(msg.GetReceivedAt())) + _, err := s.pool.Exec(ctx, `INSERT INTO wa_otp_messages (otp_message_id, wa_account_id, client_profile_id, registered_identity_id, message_id, candidate_id, source, source_party, otp_value, otp_redacted, otp_secret_ref, received_at, expires_at, created_at, updated_at) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,now(),now()) +ON CONFLICT (otp_message_id) DO UPDATE SET client_profile_id=EXCLUDED.client_profile_id, registered_identity_id=EXCLUDED.registered_identity_id, message_id=EXCLUDED.message_id, candidate_id=EXCLUDED.candidate_id, source=EXCLUDED.source, source_party=EXCLUDED.source_party, otp_value=EXCLUDED.otp_value, otp_redacted=EXCLUDED.otp_redacted, otp_secret_ref=EXCLUDED.otp_secret_ref, received_at=EXCLUDED.received_at, expires_at=EXCLUDED.expires_at, updated_at=EXCLUDED.updated_at`, + otpID, msg.GetWaAccountId(), msg.GetClientProfileId(), msg.GetRegisteredIdentityId(), msg.GetMessageId(), msg.GetCandidateId(), source, msg.GetSourceParty(), otpValue, redactedValue, secretRef, timeFromProto(msg.GetReceivedAt()), nullableProtoTime(msg.GetExpiresAt())) return err } @@ -553,7 +553,7 @@ func (s *PostgresStore) ListAccountOTPMessages(ctx context.Context, waAccountIDV items := []*waappv1.OtpMessage{} for rows.Next() { var r otpMessageRow - if err := rows.Scan(&r.id, &r.waAccountIDValue, &r.clientProfileID, &r.registeredIdentityID, &r.messageID, &r.candidateID, &r.source, &r.sourceParty, &r.otpValue, &r.otpRedacted, &r.otpSecretRef, &r.receivedAt, &r.createdAt, &r.updatedAt); err != nil { + if err := rows.Scan(&r.id, &r.waAccountIDValue, &r.clientProfileID, &r.registeredIdentityID, &r.messageID, &r.candidateID, &r.source, &r.sourceParty, &r.otpValue, &r.otpRedacted, &r.otpSecretRef, &r.receivedAt, &r.expiresAt, &r.createdAt, &r.updatedAt); err != nil { return nil, "", err } items = append(items, r.toProto(includeSensitiveValues)) @@ -568,7 +568,7 @@ func (s *PostgresStore) ListAccountOTPMessages(ctx context.Context, waAccountIDV } func (s *PostgresStore) queryOTPMessagePage(ctx context.Context, waAccountIDValue string, cursor keysetCursor, limit int) (pgx.Rows, error) { - const base = `SELECT o.otp_message_id,o.wa_account_id,o.client_profile_id,o.registered_identity_id,o.message_id,o.candidate_id,o.source,o.source_party,o.otp_value,o.otp_redacted,o.otp_secret_ref,o.received_at,o.created_at,o.updated_at + const base = `SELECT o.otp_message_id,o.wa_account_id,o.client_profile_id,o.registered_identity_id,o.message_id,o.candidate_id,o.source,o.source_party,o.otp_value,o.otp_redacted,o.otp_secret_ref,o.received_at,o.expires_at,o.created_at,o.updated_at FROM wa_otp_messages o WHERE o.wa_account_id=$1 AND NOT EXISTS ( SELECT 1 FROM wa_inbound_messages m diff --git a/internal/app/server_messaging.go b/internal/app/server_messaging.go index b3ba5f9..d313c42 100644 --- a/internal/app/server_messaging.go +++ b/internal/app/server_messaging.go @@ -81,6 +81,11 @@ func (s *Server) receiveMessageBatch(ctx context.Context, req *waappv1.ReceiveMe if err := s.saveInboundMessagesForSession(ctx, session, result.Messages); err != nil { return &waappv1.ReceiveMessageBatchResponse{Session: session, Error: ToProtoError(err)}, nil } + for _, msg := range result.OTPMessages { + if err := s.store.SaveOTPMessage(ctx, msg); err != nil { + return &waappv1.ReceiveMessageBatchResponse{Session: session, Error: ToProtoError(err)}, nil + } + } if len(result.Contacts) > 0 { _ = s.store.SaveWAContacts(ctx, result.Contacts) } diff --git a/proto/byte/v/forge/waapp/v1/extraction.proto b/proto/byte/v/forge/waapp/v1/extraction.proto index 4fd9826..34379d2 100644 --- a/proto/byte/v/forge/waapp/v1/extraction.proto +++ b/proto/byte/v/forge/waapp/v1/extraction.proto @@ -80,6 +80,7 @@ message OtpMessage { SensitiveText otp = 9; google.protobuf.Timestamp received_at = 10; AuditStamp audit = 11; + google.protobuf.Timestamp expires_at = 12; } message DecryptMessageRequest { diff --git a/webui/src/dashboard/wa-api.ts b/webui/src/dashboard/wa-api.ts index 6e14c0a..838f494 100644 --- a/webui/src/dashboard/wa-api.ts +++ b/webui/src/dashboard/wa-api.ts @@ -50,9 +50,10 @@ export async function getWaAccounts(cursor = '') { return { ...response, accounts }; } -export function getWaAccountOtpMessages(waAccountId: string, cursor = '') { - const params = new URLSearchParams({ wa_account_id: waAccountId, limit: '20' }); - if (cursor) params.set('cursor', cursor); +export function getWaAccountOtpMessages(waAccountId: string, options: { cursor?: string; limit?: number; includeSensitiveValues?: boolean } = {}) { + const params = new URLSearchParams({ wa_account_id: waAccountId, limit: String(options.limit || 20) }); + if (options.cursor) params.set('cursor', options.cursor); + if (options.includeSensitiveValues !== undefined) params.set('include_sensitive_values', String(options.includeSensitiveValues)); return getWaResponse(`/api/wa/account-otp-messages?${params}`); } diff --git a/webui/src/dashboard/wa-contact-list.tsx b/webui/src/dashboard/wa-contact-list.tsx index 2df6d58..86ca93e 100644 --- a/webui/src/dashboard/wa-contact-list.tsx +++ b/webui/src/dashboard/wa-contact-list.tsx @@ -3,9 +3,11 @@ import { useMemo, useRef, useState } from 'react'; import { Loader2, Trash2 } from 'lucide-react'; import { NavLink } from 'react-router'; import { WAContactKind } from '../proto/byte/v/forge/waapp/v1/contacts'; +import type { OtpMessage } from '../proto/byte/v/forge/waapp/v1/extraction'; import type { WaContact } from './wa-chat-model'; import { formatChatTime } from './wa-chat-model'; import { WaContactAvatar } from './wa-contact-avatar'; +import { WaContactOtpBanner } from './wa-contact-otp-banner'; import { waContactPath } from './wa-route-paths'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -13,18 +15,19 @@ import { Button } from '@/components/ui/button'; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'; import { Input } from '@/components/ui/input'; -export function WaContactList({ accountID, contacts, selectedID, loading, error, deletingID, onOpenContact, onDeleteContact }: { accountID: string; contacts: WaContact[]; selectedID: string; loading: boolean; error?: string; deletingID?: string; onOpenContact: (contactID: string) => void; onDeleteContact: (contactID: string) => void }) { +export function WaContactList({ accountID, contacts, selectedID, loading, error, deletingID, otp, onOpenContact, onDeleteContact }: { accountID: string; contacts: WaContact[]; selectedID: string; loading: boolean; error?: string; deletingID?: string; otp?: OtpMessage; onOpenContact: (contactID: string) => void; onDeleteContact: (contactID: string) => void }) { const [query, setQuery] = useState(''); const visibleContacts = useMemo(() => filterContacts(contacts, query), [contacts, query]); const unreadCount = contacts.reduce((sum, contact) => sum + contact.unreadCount, 0); return ( -