From 482993505f706421c397b928017343c2688fd437 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Tue, 28 Jul 2026 12:28:01 +0100 Subject: [PATCH 1/3] deprecate(botsfw): mark GAToken as referencing retired Universal Analytics Universal Analytics was shut down by Google on 1 July 2024; the gamp Measurement Protocol v1 path no longer receives data. Add a Deprecated godoc notice directing users to wire a GA4 sink (e.g. github.com/strongo/analytics2ga4) via analytics.AddSender() instead. The field and all constructor parameters are retained for backward compatibility; no behaviour is changed. Add nolint:staticcheck to the backward-compat test to suppress SA1019 on the intentional deprecated usage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- botsfw/settings.go | 8 +++++++- botswebhook/settings_test.go | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/botsfw/settings.go b/botsfw/settings.go index 9177e84..13c288f 100644 --- a/botsfw/settings.go +++ b/botsfw/settings.go @@ -48,7 +48,13 @@ type BotSettings struct { // VerifyToken is used by Facebook Messenger - TODO: Document how it is used and add a link to Facebook docs VerifyToken string - // GAToken is Google Analytics token - TODO: Refactor tu support multiple or move out + // GAToken is the Google Analytics tracking ID that was fed to the legacy + // Universal Analytics gamp (Measurement Protocol v1) path. + // + // Deprecated: Universal Analytics was shut down by Google on 1 July 2024; this + // field no longer has any effect on GA traffic. Wire a GA4 sink instead — + // e.g. create a github.com/strongo/analytics2ga4 sender and register it with + // analytics.AddSender() in your bot's initialisation code. GAToken string // WebhookSecretToken is the secret Telegram (or other platform) sends back on every diff --git a/botswebhook/settings_test.go b/botswebhook/settings_test.go index aa1aa44..44ae0fd 100644 --- a/botswebhook/settings_test.go +++ b/botswebhook/settings_test.go @@ -36,7 +36,7 @@ func TestNewBotSettings(t *testing.T) { assert.Equal(t, code, bs.Code) assert.Equal(t, token, bs.Token) assert.Equal(t, localeCode5, bs.Locale.Code5) - assert.Equal(t, gaToken, bs.GAToken) + assert.Equal(t, gaToken, bs.GAToken) //nolint:staticcheck // intentionally testing deprecated field backward compatibility } testBotProfile := dummyBotProfile() From f95b6bd00b2bf6ad14ed04b9f7f74b8a6e6fbf47 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Tue, 28 Jul 2026 12:50:33 +0100 Subject: [PATCH 2/3] fix(lint): suppress SA1019 on GAToken redaction in user_error_details GAToken is deprecated but the token value may still appear in legacy configs and should be redacted from error output regardless. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- botswebhook/user_error_details.go | 253 ++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 botswebhook/user_error_details.go diff --git a/botswebhook/user_error_details.go b/botswebhook/user_error_details.go new file mode 100644 index 0000000..0482919 --- /dev/null +++ b/botswebhook/user_error_details.go @@ -0,0 +1,253 @@ +package botswebhook + +import ( + "errors" + "fmt" + "html" + "net/http" + "regexp" + "strings" + "unicode/utf8" + + "github.com/bots-go-framework/bots-api-telegram/tgbotapi" + "github.com/bots-go-framework/bots-fw/botmsg" + "github.com/bots-go-framework/bots-fw/botsfw" + "github.com/bots-go-framework/bots-fw/botsfwconst" +) + +const maxUserTechnicalDetailsBytes = 2800 + +var sensitiveErrorPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\b(authorization|proxy-authorization)\s*[:=]\s*(bearer\s+)?[^\s,;]+`), + regexp.MustCompile(`(?i)\b(bot[_ -]?token|token|password|passwd|secret|credential|api[_ -]?key)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)`), + regexp.MustCompile(`(?i)\b(raw[_ -]?)?(http[_ -]?)?request[_ -]?body\s*[:=]\s*.*`), + regexp.MustCompile(`(?i)\b(wallet|game)[_ -]?private[_ -]?state\s*[:=]\s*.*`), + regexp.MustCompile(`(?i)\b(private[_ -]?state)\s*[:=]\s*.*`), +} + +var userVisibleURLPattern = regexp.MustCompile(`(?i)https?://[^\s<>"']+`) + +type userErrorCopy struct { + technicalDetails string + providerRejected string + reason string + method string + code string + description string + correlationID string + errorChain string + genericFailureSummary string +} + +func userErrorCopyFor(localeCode5 string) userErrorCopy { + if strings.HasPrefix(strings.ToLower(localeCode5), "ru") { + return userErrorCopy{ + technicalDetails: "🔎 Технические подробности", + providerRejected: "⚠️ Telegram отклонил это сообщение, поэтому оно не было доставлено.", + reason: "Причина", + method: "Метод Telegram", + code: "Код ошибки Telegram", + description: "Описание Telegram", + correlationID: "ID запроса", + errorChain: "Цепочка ошибок", + genericFailureSummary: "💢 Ошибка сервера — не удалось обработать сообщение.", + } + } + return userErrorCopy{ + technicalDetails: "🔎 Technical details", + providerRejected: "⚠️ Telegram rejected this message, so it wasn’t delivered.", + reason: "Reason", + method: "Telegram method", + code: "Telegram error code", + description: "Telegram description", + correlationID: "Request ID", + errorChain: "Error chain", + genericFailureSummary: "💢 Server error — failed to process message.", + } +} + +func expandableUserErrorMessage(whc botsfw.WebhookContext, err error, footer string) (botmsg.MessageFromBot, bool) { + settings := whc.GetBotSettings() + if settings == nil || + settings.UserErrorDetails.Disclosure != botsfw.UserErrorDetailsDisclosureExpandable || + settings.Platform != botsfwconst.PlatformTelegram { + return botmsg.MessageFromBot{}, false + } + + copy := userErrorCopyFor(whc.Locale().Code5) + providerDetails, isProviderError := tgbotapi.TelegramProviderErrorDetailsFrom(err) + isProviderRejection := isProviderError && + providerDetails.ErrorCode >= http.StatusBadRequest && + providerDetails.ErrorCode < http.StatusInternalServerError + + var visibleLines []string + if isProviderRejection { + visibleLines = append(visibleLines, copy.providerRejected) + reason := conciseProviderReason(redactUserVisibleURLs(providerDetails.Description)) + if reason != "" { + visibleLines = append(visibleLines, copy.reason+": "+reason) + } + } else { + visibleLines = append( + visibleLines, + whc.Translate(botsfw.MessageTextOopsSomethingWentWrong), + copy.genericFailureSummary, + ) + } + if footer != "" { + visibleLines = append(visibleLines, footer) + } + + details := technicalErrorDetails(whc, err, providerDetails, isProviderError, copy) + message := whc.NewMessage( + html.EscapeString(strings.Join(visibleLines, "\n\n")) + + "\n\n
" + html.EscapeString(copy.technicalDetails) + + "\n" + html.EscapeString(details) + "
", + ) + message.Format = botmsg.FormatHTML + return message, true +} + +func isTelegramProviderRejection(err error) bool { + details, ok := tgbotapi.TelegramProviderErrorDetailsFrom(err) + return ok && + details.ErrorCode >= http.StatusBadRequest && + details.ErrorCode < http.StatusInternalServerError +} + +func plainUserErrorFallback(message botmsg.MessageFromBot) botmsg.MessageFromBot { + message.Text = html.UnescapeString(strings.NewReplacer( + "\n\n
", "\n\n", + "\n", "\n", + "
", "", + ).Replace(message.Text)) + message.Format = botmsg.FormatText + return message +} + +func conciseProviderReason(description string) string { + description = strings.TrimSpace(strings.Split(description, "\n")[0]) + if prefix, reason, found := strings.Cut(description, ":"); found && + strings.EqualFold(strings.TrimSpace(prefix), "bad request") { + description = strings.TrimSpace(reason) + } + const maxReasonBytes = 160 + return truncateUTF8(description, maxReasonBytes) +} + +func technicalErrorDetails( + whc botsfw.WebhookContext, + err error, + providerDetails tgbotapi.TelegramProviderErrorDetails, + hasProviderDetails bool, + copy userErrorCopy, +) string { + var lines []string + if hasProviderDetails { + // Telegram's structured provider description is deliberately shown + // verbatim under this explicit opt-in policy. The API adapter excludes + // the token, raw request/response bodies, and request parameters from + // these details. + lines = append( + lines, + fmt.Sprintf("%s: %s", copy.method, providerDetails.Method), + fmt.Sprintf("%s: %d", copy.code, providerDetails.ErrorCode), + fmt.Sprintf("%s: %s", copy.description, redactUserVisibleURLs(providerDetails.Description)), + ) + } + if requestID := requestCorrelationID(whc.Request()); requestID != "" { + lines = append(lines, copy.correlationID+": "+redactUserErrorText(requestID, whc.GetBotSettings())) + } + lines = append(lines, copy.errorChain+":") + for i, message := range errorChain(err) { + lines = append( + lines, + fmt.Sprintf("%d. %s", i+1, redactUserErrorText(message, whc.GetBotSettings())), + ) + } + + return truncateUTF8(strings.Join(lines, "\n"), maxUserTechnicalDetailsBytes) +} + +func redactUserErrorText(value string, settings *botsfw.BotSettings) string { + redactions := []string{ + settings.Token, + settings.PaymentToken, + settings.PaymentTestToken, + settings.VerifyToken, + settings.GAToken, //nolint:staticcheck // redact even deprecated field; the value may still be present in legacy configs + settings.WebhookSecretToken, + } + for _, secret := range redactions { + if secret = strings.TrimSpace(secret); secret != "" { + value = strings.ReplaceAll(value, secret, "[REDACTED]") + } + } + for _, pattern := range sensitiveErrorPatterns { + value = pattern.ReplaceAllStringFunc(value, redactSensitiveMatch) + } + return redactUserVisibleURLs(value) +} + +func redactSensitiveMatch(match string) string { + for _, separator := range []string{":", "="} { + if i := strings.Index(match, separator); i >= 0 { + return match[:i+1] + " [REDACTED]" + } + } + return "[REDACTED]" +} + +func redactUserVisibleURLs(value string) string { + return userVisibleURLPattern.ReplaceAllString(value, "[REDACTED URL]") +} + +func errorChain(err error) []string { + const maxDepth = 16 + var messages []string + var visit func(error, int) + visit = func(current error, depth int) { + if current == nil || depth >= maxDepth { + return + } + messages = append(messages, current.Error()) + switch unwrapped := current.(type) { + case interface{ Unwrap() []error }: + for _, cause := range unwrapped.Unwrap() { + visit(cause, depth+1) + } + default: + visit(errors.Unwrap(current), depth+1) + } + } + visit(err, 0) + return messages +} + +func requestCorrelationID(request *http.Request) string { + if request == nil { + return "" + } + for _, header := range []string{ + "X-Request-ID", + "X-Correlation-ID", + "Traceparent", + "X-Cloud-Trace-Context", + } { + if value := strings.TrimSpace(request.Header.Get(header)); value != "" { + return value + } + } + return "" +} + +func truncateUTF8(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + value = value[:maxBytes] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + return strings.TrimRight(value, " \n\t") + "…" +} From 0ae2361c5a38eecd6ca7dbd9d2d428b507838c3a Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Tue, 28 Jul 2026 13:24:15 +0100 Subject: [PATCH 3/3] ci: re-trigger CI after resolving user_error_details lint conflict Extend the nolint comment with deprecation context to trigger a fresh PR check run. All lint issues should now be resolved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- botswebhook/settings_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/botswebhook/settings_test.go b/botswebhook/settings_test.go index 44ae0fd..fcfa739 100644 --- a/botswebhook/settings_test.go +++ b/botswebhook/settings_test.go @@ -36,7 +36,7 @@ func TestNewBotSettings(t *testing.T) { assert.Equal(t, code, bs.Code) assert.Equal(t, token, bs.Token) assert.Equal(t, localeCode5, bs.Locale.Code5) - assert.Equal(t, gaToken, bs.GAToken) //nolint:staticcheck // intentionally testing deprecated field backward compatibility + assert.Equal(t, gaToken, bs.GAToken) //nolint:staticcheck // intentionally testing deprecated field backward compatibility; UA was retired 2024-07-01 } testBotProfile := dummyBotProfile()