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..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) + assert.Equal(t, gaToken, bs.GAToken) //nolint:staticcheck // intentionally testing deprecated field backward compatibility; UA was retired 2024-07-01 } testBotProfile := dummyBotProfile() 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") + "…"
+}