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, escapeMarkdown(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)
}
}