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/message" "github.com/gotd/td/telegram/message/html" "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"` NtfyHost string `yaml:"ntfy_host"` 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_host: "https://ntfy.sh/" 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 escapeHTML(text string) string { var result strings.Builder for _, r := range text { switch r { case '&': result.WriteString("&") case '<': result.WriteString("<") case '>': result.WriteString(">") case '"': result.WriteString(""") case '\'': result.WriteString("'") default: result.WriteRune(r) } } return result.String() } func notify(message, host, topic, title string, priority int, token string) { if !strings.HasSuffix(host, "/") { host = host + "/" } url := host + 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 } if result.score == 1.0 || (result.score == 1.0 && result.matchedKeywords["extended_latin"] != nil && len(result.matchedKeywords["links"]) > 0) { 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 alert message with HTML formatting for markdown v2 matchMessageHTML := fmt.Sprintf("🚨 Matched\nScore: %.2f\nChat: %s (ID: %d)\nUser: %s (ID: %d)\n", result.score, escapeMarkdown(chatName), chatID, displayName+" ("+username+")", senderID) // Send ntfy notification if config set (use plain text for ntfy) if cfg.NtfyToken != "" || cfg.NtfyTopic != "" { plainMessage := fmt.Sprintf("🚨 Matched\nScore: %.2f\nChat: %s (ID: %d)\nUser: %s (ID: %d)\n\n%s", result.score, escapeMarkdown(chatName), chatID, escapeMarkdown(displayName+" ("+username+")"), senderID, escapeMarkdown(msg.Message)) notify(plainMessage, cfg.NtfyHost, cfg.NtfyTopic, fmt.Sprintf("Scam Alert: %s", chatName), 5, cfg.NtfyToken) } // Create a resolver for user mentions (not needed for this message, but required by html.String) userResolver := func(id int64) (tg.InputUserClass, error) { return &tg.InputUserFromMessage{ Peer: alertPeer, MsgID: 0, UserID: id, }, nil } // Send alert message to alert chat using StyledText with HTML sender := message.NewSender(api) _, err = sender.To(alertPeer).StyledText(ctx, html.String(userResolver, matchMessageHTML)) 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) } }