diff --git a/bot.go b/bot.go new file mode 100644 index 0000000..d64d09e --- /dev/null +++ b/bot.go @@ -0,0 +1,507 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "log" + "math" + "net/http" + "os" + "regexp" + "strings" + + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/auth" + "github.com/gotd/td/telegram/updates" + "github.com/gotd/td/tg" + "golang.org/x/term" + "gopkg.in/yaml.v3" +) + +type YAMLScamPattern struct { + Name string `yaml:"name"` + Pattern string `yaml:"pattern"` + Weight float64 `yaml:"weight"` +} + +type Config struct { + APIId int `yaml:"api_id"` + APIHash string `yaml:"api_hash"` + MonitoredChat int64 `yaml:"monitored_chat"` + AlertChat int64 `yaml:"alert_chat"` + NtfyToken string `yaml:"ntfy_token"` + NtfyTopic string `yaml:"ntfy_topic"` + SessionPath string `yaml:"session_path"` + YAMLPatterns []YAMLScamPattern `yaml:"scam_patterns"` + Patterns []scamPattern `yaml:"-"` // Compiled patterns, not from YAML +} + +func loadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + // Create default config file + defaultConfig := `api_id: 0 +api_hash: "" +monitored_chat: 0 +alert_chat: 0 +ntfy_token: "" +ntfy_topic: "" +session_path: "antiscam.session" +scam_patterns: [] +` + if err := os.WriteFile(path, []byte(defaultConfig), 0644); err != nil { + return nil, fmt.Errorf("creating default config file: %w", err) + } + log.Printf("Created default config file at %s. Please fill in the required values.", path) + data = []byte(defaultConfig) + } else { + return nil, fmt.Errorf("reading config file: %w", err) + } + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + // Validate required fields + if cfg.APIId == 0 { + return nil, fmt.Errorf("api_id is not set in config") + } + if cfg.APIHash == "" { + return nil, fmt.Errorf("api_hash is not set in config") + } + if cfg.MonitoredChat == 0 { + return nil, fmt.Errorf("monitored_chat is not set in config") + } + if cfg.SessionPath == "" { + return nil, fmt.Errorf("session_path is not set in config") + } + + // Compile patterns from YAML + if len(cfg.YAMLPatterns) > 0 { + cfg.Patterns = make([]scamPattern, len(cfg.YAMLPatterns)) + for i, p := range cfg.YAMLPatterns { + re, err := regexp.Compile(p.Pattern) + if err != nil { + return nil, fmt.Errorf("compiling pattern %q: %w", p.Name, err) + } + cfg.Patterns[i] = scamPattern{ + name: p.Name, + re: re, + weight: p.Weight, + } + } + log.Printf("✓ Loaded %d scam patterns from YAML config:", len(cfg.Patterns)) + for _, p := range cfg.Patterns { + log.Printf(" - %s (%s) (weight: %.1f)", p.name, p.re, p.weight) + } + } + + return &cfg, nil +} + +type scamPattern struct { + name string + re *regexp.Regexp + weight float64 +} + +type matchResult struct { + score float64 + matchedKeywords map[string][]string +} + +var scamPatterns = []scamPattern{ + { + name: "seeking", + re: regexp.MustCompile(`(?i)(?:(?:several\s+people|seeking|looking\s+for)\s+(?:partner|(\d+\s*[-–]\s*\d+|\d+)|two)?)|(new\s+project)`), + weight: 0.5, + }, + { + name: "job_opportunity", + re: regexp.MustCompile(`(?i)(?:remote|earning|in\s+a)\s+(?:collaboration|growing\s+field|opportunity|work)`), + weight: 0.4, + }, + { + name: "money", + re: regexp.MustCompile(`(?i)(?:\d+[\s,]*(?:\$|€|₹|£)|(?:\$|€|₹|£)\s*\d+,?\d+)(\s+per\s+(week|day))?`), + weight: 0.6, + }, + { + name: "contact", + re: regexp.MustCompile(`(?i)(?:\bdm\b|(send|write).*(?:private)?\s+.?\+.?|get\s+in\s+touch)|@\w+`), + weight: 0.5, + }, + { + name: "links", + re: regexp.MustCompile(`https://t\.me/\+\S+|https://t\.me/[a-z0-9_]+`), + weight: 0.5, + }, + { + name: "call_to_action", + re: regexp.MustCompile(`(?i)[⬇👇👆⬆💬🪂🎯⤵⤴📍]+|(?:click|tap|join|link|check)\s+(?:here|now|below|out)`), + weight: 0.5, + }, + { + name: "extended_latin", + re: regexp.MustCompile(`(?i)[äöüß]+`), + weight: 0.2, + }, +} + +func keywordMatchScore(input string, patterns []scamPattern) matchResult { + matched := make(map[string][]string) + totalScore := 0.0 + + for _, p := range patterns { + var matches []string + for _, m := range p.re.FindAllString(input, -1) { + m = strings.TrimSpace(m) + if m != "" { + matches = append(matches, m) + } + } + if len(matches) > 0 { + matched[p.name] = matches + totalScore += p.weight + } + } + + score := math.Min(totalScore, 1.0) + // Round to 2 decimal places + score = math.Round(score*100) / 100 + + return matchResult{score: score, matchedKeywords: matched} +} + +func escapeMarkdown(text string) string { + escapeChars := `\_*[]()~` + "`" + `>#+-=|{}.!` + var result strings.Builder + for _, r := range text { + if strings.ContainsRune(escapeChars, r) { + result.WriteRune('\\') + } + result.WriteRune(r) + } + return result.String() +} + +func notify(message, topic, title string, priority int, token string) { + url := "https://ntfy.zio.sh/" + topic + req, err := http.NewRequest("POST", url, bytes.NewBufferString(message)) + if err != nil { + log.Printf("notify: creating request: %v", err) + return + } + if title != "" { + req.Header.Set("Title", title) + } + req.Header.Set("Priority", fmt.Sprintf("%d", priority)) + req.Header.Set("Markdown", "yes") + if token != "" { + // ntfy uses basic auth with empty username and token as password + encoded := base64.StdEncoding.EncodeToString([]byte(":" + token)) + req.Header.Set("Authorization", "Basic "+encoded) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("notify: sending request: %v", err) + return + } + defer resp.Body.Close() +} + +func resolveAlertPeer(ctx context.Context, api *tg.Client, cfg *Config) (*tg.InputPeerChannel, error) { + result, err := api.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 100, + }) + if err != nil { + return nil, err + } + + var chats []tg.ChatClass + + switch v := result.(type) { + case *tg.MessagesDialogs: + chats = v.Chats + case *tg.MessagesDialogsSlice: + chats = v.Chats + case *tg.MessagesDialogsNotModified: + return nil, fmt.Errorf("dialogs not modified") + default: + return nil, fmt.Errorf("unexpected dialogs type: %T", result) + } + + for _, chat := range chats { + if channel, ok := chat.(*tg.Channel); ok { + if channel.ID == cfg.AlertChat { + return &tg.InputPeerChannel{ + ChannelID: channel.ID, + AccessHash: channel.AccessHash, + }, nil + } + } + } + + return nil, fmt.Errorf("alert channel %d not found", cfg.AlertChat) +} + +func handleNewMessage(ctx context.Context, api *tg.Client, alertPeer *tg.InputPeerChannel, cfg *Config, entities tg.Entities, update any) error { + // Extract message from either UpdateNewMessage or UpdateNewChannelMessage + var msg *tg.Message + switch u := update.(type) { + case *tg.UpdateNewMessage: + m, ok := u.Message.(*tg.Message) + if !ok { + return nil + } + msg = m + case *tg.UpdateNewChannelMessage: + m, ok := u.Message.(*tg.Message) + if !ok { + return nil + } + msg = m + default: + log.Printf("DEBUG: unknown update type %T", update) + return nil + } + + // Only process messages sent by the bot (Out=true) + if msg.Out { + return nil + } + + // Check if from monitored supergroup (channels in gotd terminology) + peerChannel, ok := msg.PeerID.(*tg.PeerChannel) + if !ok || int64(peerChannel.ChannelID) != cfg.MonitoredChat { + return nil + } + log.Printf("✓ Processing message in chat %v: %s", msg.PeerID, msg.Message) + chatID := int64(peerChannel.ChannelID) + + // Use YAML patterns if available, otherwise use defaults + patterns := cfg.Patterns + if len(patterns) == 0 { + patterns = scamPatterns + log.Printf("Using hardcoded scam patterns (%d patterns)", len(patterns)) + } else { + log.Printf("Using YAML-loaded scam patterns (%d patterns)", len(patterns)) + } + + // Score the message + result := keywordMatchScore(msg.Message, patterns) + + if extended_latin, ok := result.matchedKeywords["extended_latin"]; ok { + for range extended_latin { + result.score += 0.1 + if result.score >= 1.0 { + result.score = 1.0 + break + } + } + } + + if result.score < 1.0 { + return nil + } + + log.Printf("Matched message with score %.2f", result.score) + for key, values := range result.matchedKeywords { + for _, value := range values { + log.Printf(" %s: %s", key, value) + } + } + + // Delete the message from supergroup + channel, ok := entities.Channels[chatID] + if !ok { + return fmt.Errorf("channel %d not found in entities", chatID) + } + _, err := api.ChannelsDeleteMessages(ctx, &tg.ChannelsDeleteMessagesRequest{ + Channel: &tg.InputChannel{ + ChannelID: chatID, + AccessHash: channel.AccessHash, + }, + ID: []int{msg.ID}, + }) + if err != nil { + log.Printf("Failed to delete message: %v", err) + } + + // Get sender info + var senderID int64 + if fromID, ok := msg.FromID.(*tg.PeerUser); ok { + senderID = int64(fromID.UserID) + } else { + return fmt.Errorf("could not determine sender") + } + + user, ok := entities.Users[senderID] + if !ok { + return fmt.Errorf("user %d not found in entities", senderID) + } + + displayName := user.FirstName + if user.LastName != "" { + displayName += " " + user.LastName + } + displayName = strings.TrimSpace(displayName) + + username := "no username" + if user.Username != "" { + username = "@" + user.Username + } + + // Restrict sender in supergroup + _, err = api.ChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{ + Channel: &tg.InputChannel{ + ChannelID: chatID, + AccessHash: channel.AccessHash, + }, + Participant: &tg.InputPeerUser{ + UserID: senderID, + AccessHash: user.AccessHash, + }, + BannedRights: tg.ChatBannedRights{ + SendMessages: true, + SendMedia: true, + SendStickers: true, + SendGifs: true, + UntilDate: 0, + }, + }) + if err != nil { + log.Printf("Failed to restrict user %d: %v", senderID, err) + } + + // Get supergroup name + chatName := channel.Title + + // Build and send alert message + matchMessage := fmt.Sprintf("🚨 Matched\n**Score**: %.2f\n**Chat**: %s (ID: %d)\n**User**: %s (%s) (ID: %d)\n", + result.score, escapeMarkdown(chatName), chatID, escapeMarkdown(displayName+" ("+username+")"), username, senderID) + + // Send ntfy notification if config set + if cfg.NtfyToken != "" || cfg.NtfyTopic != "" { + notify(matchMessage, cfg.NtfyTopic, fmt.Sprintf("Scam Alert: %s", chatName), 5, cfg.NtfyToken) + } + + // Send alert message to alert chat + _, err = api.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: alertPeer, + Message: matchMessage, + }) + if err != nil { + log.Printf("Failed to send alert message: %v", err) + } + + return nil +} + +func main() { + ctx := context.Background() + + // Load configuration + configPath := "config.yaml" + if len(os.Args) > 1 { + configPath = os.Args[1] + } + + cfg, err := loadConfig(configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + dispatcher := tg.NewUpdateDispatcher() + gapManager := updates.New(updates.Config{Handler: dispatcher}) + + client := telegram.NewClient(cfg.APIId, cfg.APIHash, telegram.Options{ + UpdateHandler: gapManager, + SessionStorage: &session.FileStorage{Path: cfg.SessionPath}, + }) + + err = client.Run(ctx, func(ctx context.Context) error { + // Check if session exists; if not, authenticate + _, err := os.Stat(cfg.SessionPath) + if err != nil { + // Session doesn't exist, prompt for credentials + fmt.Print("Enter phone number: ") + var phone string + fmt.Scanln(&phone) + + fmt.Print("Enter 2FA password: ") + pwBytes, err := term.ReadPassword(0) + if err != nil { + return fmt.Errorf("reading password: %w", err) + } + fmt.Println() + password := string(pwBytes) + + flow := auth.NewFlow( + auth.Constant( + phone, + password, + auth.CodeAuthenticatorFunc(func(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) { + fmt.Print("Enter code: ") + var code string + fmt.Scanln(&code) + return code, nil + }), + ), + auth.SendCodeOptions{}, + ) + + if err := client.Auth().IfNecessary(ctx, flow); err != nil { + return err + } + } + + api := client.API() + + // Resolve alert channel peer at startup + alertPeer, err := resolveAlertPeer(ctx, api, cfg) + if err != nil { + return fmt.Errorf("resolving alert peer: %w", err) + } + + // Register message handler for private/group messages + dispatcher.OnNewMessage(func(ctx context.Context, entities tg.Entities, update *tg.UpdateNewMessage) error { + return handleNewMessage(ctx, api, alertPeer, cfg, entities, update) + }) + + // Register handler for channel messages (supergroups) + dispatcher.OnNewChannelMessage(func(ctx context.Context, entities tg.Entities, update *tg.UpdateNewChannelMessage) error { + return handleNewMessage(ctx, api, alertPeer, cfg, entities, update) + }) + + // Get self ID for updates manager + status, err := client.Auth().Status(ctx) + if err != nil { + return err + } + + user := status.User + username := "" + if user.Username != "" { + username = " (@" + user.Username + ")" + } + displayName := user.FirstName + if user.LastName != "" { + displayName += " " + user.LastName + } + log.Printf("✓ Logged in as: %s%s (ID: %d)", displayName, username, user.ID) + log.Printf("✓ Bot running, monitoring chat %d", cfg.MonitoredChat) + return gapManager.Run(ctx, api, status.User.ID, updates.AuthOptions{}) + }) + + if err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..98c163d --- /dev/null +++ b/go.mod @@ -0,0 +1,47 @@ +module git.zio.sh/astra/telegram-antiscam + +go 1.25.3 + +require ( + github.com/gotd/td v0.139.0 + golang.org/x/term v0.40.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/xor v1.0.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gotd/ige v0.2.2 // indirect + github.com/gotd/neo v0.1.5 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ogen-go/ogen v1.16.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.41.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..225ab57 --- /dev/null +++ b/go.sum @@ -0,0 +1,104 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= +github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.139.0 h1:3viuXqNdC0+mmd5GerDFp/rlII/QcZSzh/pjuG56NSU= +github.com/gotd/td v0.139.0/go.mod h1:nBietiOYxaXEo6PmRp73LL64upWlk9rcFEZSJu6VieY= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ogen-go/ogen v1.16.0 h1:fKHEYokW/QrMzVNXId74/6RObRIUs9T2oroGKtR25Iw= +github.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=