code refactor

This commit is contained in:
Astra 2026-03-23 07:59:53 +00:00
parent 20048736eb
commit cf6e27539b
3 changed files with 423 additions and 245 deletions

584
bot.go
View file

@ -11,6 +11,7 @@ import (
"os" "os"
"regexp" "regexp"
"strings" "strings"
"sync"
"github.com/gotd/td/session" "github.com/gotd/td/session"
"github.com/gotd/td/telegram" "github.com/gotd/td/telegram"
@ -39,6 +40,7 @@ type Config struct {
NtfyTopic string `yaml:"ntfy_topic"` NtfyTopic string `yaml:"ntfy_topic"`
SessionPath string `yaml:"session_path"` SessionPath string `yaml:"session_path"`
YAMLPatterns []YAMLScamPattern `yaml:"scam_patterns"` YAMLPatterns []YAMLScamPattern `yaml:"scam_patterns"`
BadWords []string `yaml:"bad_words"`
Patterns []scamPattern `yaml:"-"` // Compiled patterns, not from YAML Patterns []scamPattern `yaml:"-"` // Compiled patterns, not from YAML
} }
@ -56,6 +58,7 @@ ntfy_token: ""
ntfy_topic: "" ntfy_topic: ""
session_path: "antiscam.session" session_path: "antiscam.session"
scam_patterns: [] scam_patterns: []
bad_words: []
` `
if err := os.WriteFile(path, []byte(defaultConfig), 0644); err != nil { if err := os.WriteFile(path, []byte(defaultConfig), 0644); err != nil {
return nil, fmt.Errorf("creating default config file: %w", err) return nil, fmt.Errorf("creating default config file: %w", err)
@ -158,6 +161,148 @@ var scamPatterns = []scamPattern{
}, },
} }
// BotState holds shared runtime state that may be updated at runtime.
type BotState struct {
cfg *Config
configPath string
selfID int64
badWordRe *regexp.Regexp
mu sync.RWMutex
}
func newBotState(cfg *Config, configPath string, selfID int64) *BotState {
s := &BotState{cfg: cfg, configPath: configPath, selfID: selfID}
s.compileBadWords()
return s
}
// compileBadWords rebuilds the bad word regex from cfg.BadWords.
// Must be called with mu held (write).
func (s *BotState) compileBadWords() {
if len(s.cfg.BadWords) == 0 {
s.badWordRe = nil
return
}
escaped := make([]string, len(s.cfg.BadWords))
for i, w := range s.cfg.BadWords {
escaped[i] = regexp.QuoteMeta(w)
}
s.badWordRe = regexp.MustCompile(`(?i)(` + strings.Join(escaped, "|") + `)`)
log.Printf("✓ Bad word pattern: %s", s.badWordRe)
}
func (s *BotState) saveConfig() error {
data, err := yaml.Marshal(s.cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(s.configPath, data, 0644)
}
func sendAdminReply(ctx context.Context, api *tg.Client, alertPeer *tg.InputPeerChannel, text string) {
sender := message.NewSender(api)
_, err := sender.To(alertPeer).Text(ctx, text)
if err != nil {
log.Printf("Failed to send admin reply: %v", err)
}
}
func handleOwnerCommand(ctx context.Context, api *tg.Client, state *BotState, alertPeer *tg.InputPeerChannel, text string) {
text = strings.TrimSpace(text)
if !strings.HasPrefix(text, "/badword") {
return
}
parts := strings.Fields(text)
if len(parts) < 2 {
sendAdminReply(ctx, api, alertPeer, "Usage: /badword <list|add|rem> [word]")
return
}
subCmd := strings.ToLower(parts[1])
state.mu.Lock()
defer state.mu.Unlock()
switch subCmd {
case "list":
if len(state.cfg.BadWords) == 0 {
sendAdminReply(ctx, api, alertPeer, "No bad words configured.")
return
}
var sb strings.Builder
sb.WriteString("Bad words:\n")
for _, w := range state.cfg.BadWords {
sb.WriteString(" • ")
sb.WriteString(w)
sb.WriteString("\n")
}
reply := sb.String()
if state.badWordRe != nil {
reply += "\nPattern: " + state.badWordRe.String()
}
sendAdminReply(ctx, api, alertPeer, strings.TrimRight(reply, "\n"))
case "add":
if len(parts) < 3 {
sendAdminReply(ctx, api, alertPeer, "Usage: /badword add <word>")
return
}
word := parts[2]
for _, w := range state.cfg.BadWords {
if strings.EqualFold(w, word) {
sendAdminReply(ctx, api, alertPeer, fmt.Sprintf("Word %q already exists.", word))
return
}
}
state.cfg.BadWords = append(state.cfg.BadWords, word)
state.compileBadWords()
if err := state.saveConfig(); err != nil {
log.Printf("Failed to save config: %v", err)
sendAdminReply(ctx, api, alertPeer, fmt.Sprintf("Added %q but failed to save config: %v", word, err))
return
}
reply := fmt.Sprintf("Added bad word: %q", word)
if state.badWordRe != nil {
reply += "\nPattern: " + state.badWordRe.String()
}
sendAdminReply(ctx, api, alertPeer, reply)
case "rem":
if len(parts) < 3 {
sendAdminReply(ctx, api, alertPeer, "Usage: /badword rem <word>")
return
}
word := parts[2]
newWords := make([]string, 0, len(state.cfg.BadWords))
found := false
for _, w := range state.cfg.BadWords {
if strings.EqualFold(w, word) {
found = true
continue
}
newWords = append(newWords, w)
}
if !found {
sendAdminReply(ctx, api, alertPeer, fmt.Sprintf("Word %q not found.", word))
return
}
state.cfg.BadWords = newWords
state.compileBadWords()
if err := state.saveConfig(); err != nil {
log.Printf("Failed to save config: %v", err)
}
patternStr := "none"
if state.badWordRe != nil {
patternStr = state.badWordRe.String()
}
sendAdminReply(ctx, api, alertPeer, fmt.Sprintf("Removed bad word: %q\nPattern: %s", word, patternStr))
default:
sendAdminReply(ctx, api, alertPeer, "Unknown subcommand. Usage: /badword <list|add|rem> [word]")
}
}
func keywordMatchScore(input string, patterns []scamPattern) matchResult { func keywordMatchScore(input string, patterns []scamPattern) matchResult {
matched := make(map[string][]string) matched := make(map[string][]string)
totalScore := 0.0 totalScore := 0.0
@ -195,33 +340,23 @@ func escapeMarkdown(text string) string {
return result.String() return result.String()
} }
func escapeHTML(text string) string { func fullName(first, last string) string {
var result strings.Builder return strings.TrimSpace(first + " " + last)
for _, r := range text {
switch r {
case '&':
result.WriteString("&amp;")
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) { func formatUsername(username string) string {
if !strings.HasSuffix(host, "/") { if username == "" {
host = host + "/" return ""
} }
url := host + topic return " (@" + username + ")"
req, err := http.NewRequest("POST", url, bytes.NewBufferString(message)) }
func notify(body string, cfg *Config, title string) {
host := cfg.NtfyHost
if !strings.HasSuffix(host, "/") {
host += "/"
}
req, err := http.NewRequest("POST", host+cfg.NtfyTopic, bytes.NewBufferString(body))
if err != nil { if err != nil {
log.Printf("notify: creating request: %v", err) log.Printf("notify: creating request: %v", err)
return return
@ -229,11 +364,10 @@ func notify(message, host, topic, title string, priority int, token string) {
if title != "" { if title != "" {
req.Header.Set("Title", title) req.Header.Set("Title", title)
} }
req.Header.Set("Priority", fmt.Sprintf("%d", priority)) req.Header.Set("Priority", "5")
req.Header.Set("Markdown", "yes") req.Header.Set("Markdown", "yes")
if token != "" { if cfg.NtfyToken != "" {
// ntfy uses basic auth with empty username and token as password encoded := base64.StdEncoding.EncodeToString([]byte(":" + cfg.NtfyToken))
encoded := base64.StdEncoding.EncodeToString([]byte(":" + token))
req.Header.Set("Authorization", "Basic "+encoded) req.Header.Set("Authorization", "Basic "+encoded)
} }
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
@ -244,94 +378,162 @@ func notify(message, host, topic, title string, priority int, token string) {
defer resp.Body.Close() defer resp.Body.Close()
} }
func resolveAlertPeer(ctx context.Context, api *tg.Client, cfg *Config) (*tg.InputPeerChannel, error) { func resolveChannel(ctx context.Context, api *tg.Client, channelID int64) (*tg.InputPeerChannel, *tg.Channel, error) {
result, err := api.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{ req := &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerEmpty{}, OffsetPeer: &tg.InputPeerEmpty{},
Limit: 100, Limit: 100,
})
if err != nil {
return nil, err
} }
var chats []tg.ChatClass for {
result, err := api.MessagesGetDialogs(ctx, req)
if err != nil {
return nil, nil, err
}
switch v := result.(type) { var (
case *tg.MessagesDialogs: chats []tg.ChatClass
chats = v.Chats dialogs []tg.DialogClass
case *tg.MessagesDialogsSlice: messages []tg.MessageClass
chats = v.Chats done bool
case *tg.MessagesDialogsNotModified: )
return nil, fmt.Errorf("dialogs not modified")
default:
return nil, fmt.Errorf("unexpected dialogs type: %T", result)
}
for _, chat := range chats { switch v := result.(type) {
if channel, ok := chat.(*tg.Channel); ok { case *tg.MessagesDialogs:
if channel.ID == cfg.AlertChat { chats, dialogs, messages = v.Chats, v.Dialogs, v.Messages
return &tg.InputPeerChannel{ done = true
ChannelID: channel.ID, case *tg.MessagesDialogsSlice:
AccessHash: channel.AccessHash, chats, dialogs, messages = v.Chats, v.Dialogs, v.Messages
}, nil case *tg.MessagesDialogsNotModified:
return nil, nil, fmt.Errorf("dialogs not modified")
default:
return nil, nil, fmt.Errorf("unexpected dialogs type: %T", result)
}
for _, chat := range chats {
if channel, ok := chat.(*tg.Channel); ok {
if channel.ID == channelID {
return &tg.InputPeerChannel{
ChannelID: channel.ID,
AccessHash: channel.AccessHash,
}, channel, nil
}
} }
} }
if done || len(dialogs) == 0 {
break
}
// Build offset from the last dialog for the next page.
last, ok := dialogs[len(dialogs)-1].(*tg.Dialog)
if !ok {
break
}
msgID := last.TopMessage
var msgDate int
for _, m := range messages {
if msg, ok := m.(*tg.Message); ok && msg.ID == msgID {
msgDate = msg.Date
break
}
}
if msgDate == 0 {
break
}
offsetPeer, err := toPeerInput(last.Peer, chats)
if err != nil {
break
}
req.OffsetDate = msgDate
req.OffsetID = msgID
req.OffsetPeer = offsetPeer
} }
return nil, fmt.Errorf("alert channel %d not found", cfg.AlertChat) return nil, nil, fmt.Errorf("channel %d not found in dialogs", channelID)
} }
func handleNewMessage(ctx context.Context, api *tg.Client, alertPeer *tg.InputPeerChannel, cfg *Config, entities tg.Entities, update any) error { func toPeerInput(peer tg.PeerClass, chats []tg.ChatClass) (tg.InputPeerClass, error) {
// Extract message from either UpdateNewMessage or UpdateNewChannelMessage switch p := peer.(type) {
var msg *tg.Message case *tg.PeerChannel:
for _, c := range chats {
if ch, ok := c.(*tg.Channel); ok && ch.ID == p.ChannelID {
return &tg.InputPeerChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash}, nil
}
}
return nil, fmt.Errorf("channel %d not found in chats", p.ChannelID)
case *tg.PeerChat:
return &tg.InputPeerChat{ChatID: p.ChatID}, nil
case *tg.PeerUser:
return &tg.InputPeerUser{UserID: p.UserID}, nil
default:
return nil, fmt.Errorf("unknown peer type %T", peer)
}
}
func extractMessage(update any) *tg.Message {
switch u := update.(type) { switch u := update.(type) {
case *tg.UpdateNewMessage: case *tg.UpdateNewMessage:
m, ok := u.Message.(*tg.Message) m, _ := u.Message.(*tg.Message)
if !ok { return m
return nil
}
msg = m
case *tg.UpdateNewChannelMessage: case *tg.UpdateNewChannelMessage:
m, ok := u.Message.(*tg.Message) m, _ := u.Message.(*tg.Message)
if !ok { return m
return nil
}
msg = m
default: default:
log.Printf("DEBUG: unknown update type %T", update) log.Printf("DEBUG: unknown update type %T", update)
return nil return nil
} }
}
// Only process messages sent by the bot (Out=true) func handleNewMessage(ctx context.Context, api *tg.Client, alertPeer *tg.InputPeerChannel, state *BotState, entities tg.Entities, update any) error {
msg := extractMessage(update)
if msg == nil {
return nil
}
// Handle commands from the bot owner (messages sent by the connected account).
if msg.Out { if msg.Out {
handleOwnerCommand(ctx, api, state, alertPeer, msg.Message)
return nil return nil
} }
// Check if from monitored supergroup (channels in gotd terminology) // Check if from monitored supergroup (channels in gotd terminology)
peerChannel, ok := msg.PeerID.(*tg.PeerChannel) peerChannel, ok := msg.PeerID.(*tg.PeerChannel)
if !ok || int64(peerChannel.ChannelID) != cfg.MonitoredChat { if !ok || int64(peerChannel.ChannelID) != state.cfg.MonitoredChat {
return nil return nil
} }
log.Printf("✓ Processing message in chat %v: %s", msg.PeerID, msg.Message)
chatID := int64(peerChannel.ChannelID) chatID := int64(peerChannel.ChannelID)
// Use YAML patterns if available, otherwise use defaults // Check bad words first — any match sets score to 1.0 immediately.
patterns := cfg.Patterns state.mu.RLock()
if len(patterns) == 0 { badWordRe := state.badWordRe
patterns = scamPatterns state.mu.RUnlock()
log.Printf("Using hardcoded scam patterns (%d patterns)", len(patterns))
var result matchResult
if badWordRe != nil && badWordRe.MatchString(msg.Message) {
result = matchResult{
score: 1.0,
matchedKeywords: map[string][]string{"bad_words": badWordRe.FindAllString(msg.Message, -1)},
}
log.Printf("Bad word match in message, score set to 1.0")
} else { } else {
log.Printf("Using YAML-loaded scam patterns (%d patterns)", len(patterns)) // Use YAML patterns if available, otherwise use defaults.
} patterns := state.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)
result := keywordMatchScore(msg.Message, patterns)
if extended_latin, ok := result.matchedKeywords["extended_latin"]; ok { if extended_latin, ok := result.matchedKeywords["extended_latin"]; ok {
for range extended_latin { for range extended_latin {
result.score += 0.1 result.score += 0.1
if result.score >= 1.0 { if result.score >= 1.0 {
result.score = 1.0 result.score = 1.0
break break
}
} }
} }
} }
@ -340,113 +542,90 @@ func handleNewMessage(ctx context.Context, api *tg.Client, alertPeer *tg.InputPe
return nil return nil
} }
if result.score == 1.0 || log.Printf("Matched message with score %.2f", result.score)
(result.score == 1.0 && for key, values := range result.matchedKeywords {
result.matchedKeywords["extended_latin"] != nil && for _, value := range values {
len(result.matchedKeywords["links"]) > 0) { log.Printf(" %s: %s", key, value)
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 // Delete the message from supergroup
channel, ok := entities.Channels[chatID] channel, ok := entities.Channels[chatID]
if !ok { if !ok {
return fmt.Errorf("channel %d not found in entities", chatID) return fmt.Errorf("channel %d not found in entities", chatID)
} }
_, err := api.ChannelsDeleteMessages(ctx, &tg.ChannelsDeleteMessagesRequest{ _, err := api.ChannelsDeleteMessages(ctx, &tg.ChannelsDeleteMessagesRequest{
Channel: &tg.InputChannel{ Channel: &tg.InputChannel{
ChannelID: chatID, ChannelID: chatID,
AccessHash: channel.AccessHash, AccessHash: channel.AccessHash,
}, },
ID: []int{msg.ID}, ID: []int{msg.ID},
}) })
if err != nil { if err != nil {
log.Printf("Failed to delete message: %v", err) log.Printf("Failed to delete message: %v", err)
} }
// Get sender info // Get sender info
var senderID int64 fromID, ok := msg.FromID.(*tg.PeerUser)
if fromID, ok := msg.FromID.(*tg.PeerUser); ok { if !ok {
senderID = int64(fromID.UserID) return fmt.Errorf("could not determine sender")
} else { }
return fmt.Errorf("could not determine sender") senderID := int64(fromID.UserID)
}
user, ok := entities.Users[senderID] user, ok := entities.Users[senderID]
if !ok { if !ok {
return fmt.Errorf("user %d not found in entities", senderID) return fmt.Errorf("user %d not found in entities", senderID)
} }
displayName := user.FirstName displayName := fullName(user.FirstName, user.LastName)
if user.LastName != "" { userDisplay := displayName
displayName += " " + user.LastName if user.Username != "" {
} userDisplay += " (@" + user.Username + ")"
displayName = strings.TrimSpace(displayName) } else {
userDisplay += " (no username)"
}
username := "no username" // Restrict sender in supergroup
if user.Username != "" { _, err = api.ChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{
username = "@" + user.Username 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)
}
// Restrict sender in supergroup chatName := channel.Title
_, err = api.ChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{ chatDisplay := escapeMarkdown(chatName) + escapeMarkdown(formatUsername(channel.Username))
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 and username matchMessageHTML := fmt.Sprintf("🚨 Matched\n<b>Score</b>: %.2f\n<b>Chat</b>: %s (ID: %d)\n<b>User</b>: %s (ID: %d)\n",
chatName := channel.Title result.score, chatDisplay, chatID, userDisplay, senderID)
chatDisplay := escapeMarkdown(chatName)
if channel.Username != "" {
chatDisplay += " (@" + escapeMarkdown(channel.Username) + ")"
}
// Build alert message with HTML formatting for markdown v2 if state.cfg.NtfyToken != "" || state.cfg.NtfyTopic != "" {
matchMessageHTML := fmt.Sprintf("🚨 Matched\n<b>Score</b>: %.2f\n<b>Chat</b>: %s (ID: %d)\n<b>User</b>: %s (ID: %d)\n", plainMessage := fmt.Sprintf("🚨 Matched\nScore: %.2f\nChat: %s (ID: %d)\nUser: %s (ID: %d)\n\n%s",
result.score, chatDisplay, chatID, displayName+" ("+username+")", senderID) result.score, chatName+formatUsername(channel.Username), chatID, userDisplay, senderID, msg.Message)
notify(plainMessage, state.cfg, fmt.Sprintf("Scam Alert: %s", chatName))
}
// Send ntfy notification if config set (use plain text for ntfy) userResolver := func(id int64) (tg.InputUserClass, error) {
if cfg.NtfyToken != "" || cfg.NtfyTopic != "" { return &tg.InputUserFromMessage{Peer: alertPeer, MsgID: 0, UserID: id}, nil
plainMessage := fmt.Sprintf("🚨 Matched\nScore: %.2f\nChat: %s (ID: %d)\nUser: %s (ID: %d)\n\n%s", }
result.score, escapeMarkdown(chatDisplay), chatID, escapeMarkdown(displayName+" ("+username+")"), senderID, escapeMarkdown(msg.Message)) sender := message.NewSender(api)
notify(plainMessage, cfg.NtfyHost, cfg.NtfyTopic, fmt.Sprintf("Scam Alert: %s", chatName), 5, cfg.NtfyToken) _, err = sender.To(alertPeer).StyledText(ctx, html.String(userResolver, matchMessageHTML))
} if err != nil {
log.Printf("Failed to send alert message: %v", err)
// 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 return nil
@ -513,38 +692,37 @@ func main() {
api := client.API() api := client.API()
// Resolve alert channel peer at startup // Resolve alert channel peer at startup
alertPeer, err := resolveAlertPeer(ctx, api, cfg) alertPeer, alertChannel, err := resolveChannel(ctx, api, cfg.AlertChat)
if err != nil { if err != nil {
return fmt.Errorf("resolving alert peer: %w", err) return fmt.Errorf("resolving alert channel: %w", err)
}
_, monitoredChannel, err := resolveChannel(ctx, api, cfg.MonitoredChat)
if err != nil {
return fmt.Errorf("resolving monitored channel: %w", err)
} }
// Register message handler for private/group messages // Get self ID for state and updates manager
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) status, err := client.Auth().Status(ctx)
if err != nil { if err != nil {
return err return err
} }
state := newBotState(cfg, configPath, int64(status.User.ID))
// 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, state, 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, state, entities, update)
})
user := status.User user := status.User
username := "" log.Printf("✓ Logged in as: %s%s (ID: %d)", fullName(user.FirstName, user.LastName), formatUsername(user.Username), user.ID)
if user.Username != "" { log.Printf("✓ Monitoring chat: %s%s (ID: %d)", monitoredChannel.Title, formatUsername(monitoredChannel.Username), monitoredChannel.ID)
username = " (@" + user.Username + ")" log.Printf("✓ Sending alerts to: %s%s (ID: %d)", alertChannel.Title, formatUsername(alertChannel.Username), alertChannel.ID)
}
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{}) return gapManager.Run(ctx, api, status.User.ID, updates.AuthOptions{})
}) })

28
go.mod
View file

@ -3,8 +3,8 @@ module git.zio.sh/astra/telegram-antiscam
go 1.25.3 go 1.25.3
require ( require (
github.com/gotd/td v0.139.0 github.com/gotd/td v0.142.0
golang.org/x/term v0.40.0 golang.org/x/term v0.41.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@ -22,26 +22,26 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gotd/ige v0.2.2 // indirect github.com/gotd/ige v0.2.2 // indirect
github.com/gotd/neo v0.1.5 // indirect github.com/gotd/neo v0.1.5 // indirect
github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ogen-go/ogen v1.16.0 // indirect github.com/ogen-go/ogen v1.19.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect go.uber.org/zap v1.27.1 // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.43.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
rsc.io/qr v0.2.0 // indirect rsc.io/qr v0.2.0 // indirect
) )

56
go.sum
View file

@ -33,10 +33,10 @@ github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= 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 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= 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.142.0 h1:hsH8zM7Pv98CkSMvrAEzVJurhntUziqKgf4VEofv5Zg=
github.com/gotd/td v0.139.0/go.mod h1:nBietiOYxaXEo6PmRp73LL64upWlk9rcFEZSJu6VieY= github.com/gotd/td v0.142.0/go.mod h1:UHO5Gpwce9mH4zplp2qWo6AdzDjFVg7gK+ANMCztsi8=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -45,8 +45,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.19.0 h1:YvdNpeQJ8A8dLLpS6Vs4WxXL53BT6tBPxH0VSjfALhA=
github.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI= github.com/ogen-go/ogen v1.19.0/go.mod h1:DeShwO+TEpLYXNCuZliSAedphphXsJaTGGbmSomWUjE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
@ -57,12 +57,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 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/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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@ -71,26 +71,26 @@ 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/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 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= 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/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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=