telegram-antispam/bot.go
2026-02-28 14:57:02 +01:00

550 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("&lt;")
case '>':
result.WriteString("&gt;")
case '"':
result.WriteString("&quot;")
case '\'':
result.WriteString("&#39;")
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\n<b>Score</b>: %.2f\n<b>Chat</b>: %s (ID: %d)\n<b>User</b>: %s (%s) (ID: %d)\n",
result.score, escapeHTML(chatName), chatID, escapeHTML(displayName+" ("+username+")"), 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 (%s) (ID: %d)\n",
result.score, chatName, chatID, displayName+" ("+username+")", username, senderID)
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)
}
}