Add /reload, cache channel access hashes

This commit is contained in:
Astra 2026-04-08 10:29:17 +01:00
parent ddd5eafe4e
commit 064f0e35b1

233
bot.go
View file

@ -12,6 +12,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"time"
"github.com/gotd/td/session" "github.com/gotd/td/session"
"github.com/gotd/td/telegram" "github.com/gotd/td/telegram"
@ -31,17 +32,19 @@ type YAMLScamPattern struct {
} }
type Config struct { type Config struct {
APIId int `yaml:"api_id"` APIId int `yaml:"api_id"`
APIHash string `yaml:"api_hash"` APIHash string `yaml:"api_hash"`
MonitoredChat int64 `yaml:"monitored_chat"` MonitoredChat int64 `yaml:"monitored_chat"`
AlertChat int64 `yaml:"alert_chat"` AlertChat int64 `yaml:"alert_chat"`
NtfyHost string `yaml:"ntfy_host"` NtfyHost string `yaml:"ntfy_host"`
NtfyToken string `yaml:"ntfy_token"` NtfyToken string `yaml:"ntfy_token"`
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"` BadWords []string `yaml:"bad_words"`
Patterns []scamPattern `yaml:"-"` // Compiled patterns, not from YAML Patterns []scamPattern `yaml:"-"` // Compiled patterns, not from YAML
MonitoredChatAccessHash int64 `yaml:"-"` // Runtime: access hash for monitored channel
AlertChatAccessHash int64 `yaml:"-"` // Runtime: access hash for alert channel
} }
func loadConfig(path string) (*Config, error) { func loadConfig(path string) (*Config, error) {
@ -183,11 +186,7 @@ func (s *BotState) compileBadWords() {
s.badWordRe = nil s.badWordRe = nil
return return
} }
escaped := make([]string, len(s.cfg.BadWords)) s.badWordRe = regexp.MustCompile(`(?i)(` + strings.Join(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) log.Printf("✓ Bad word pattern: %s", s.badWordRe)
} }
@ -199,110 +198,6 @@ func (s *BotState) saveConfig() error {
return os.WriteFile(s.configPath, data, 0644) 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.SplitN(text, " ", 3)
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 := strings.TrimSpace(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 := strings.TrimSpace(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
@ -470,6 +365,92 @@ func toPeerInput(peer tg.PeerClass, chats []tg.ChatClass) (tg.InputPeerClass, er
} }
} }
func getUserJoinDate(ctx context.Context, api *tg.Client, channelID, accessHash, userID int64) (time.Time, error) {
result, err := api.ChannelsGetParticipant(ctx, &tg.ChannelsGetParticipantRequest{
Channel: &tg.InputChannel{
ChannelID: channelID,
AccessHash: accessHash,
},
Participant: &tg.InputPeerUser{
UserID: userID,
},
})
if err != nil {
return time.Time{}, err
}
// Get the date from the participant (works for most participant types)
switch p := result.Participant.(type) {
case interface{ GetDate() int }:
return time.Unix(int64(p.GetDate()), 0), nil
default:
return time.Time{}, fmt.Errorf("cannot determine join date for participant type: %T", result.Participant)
}
}
func handleOwnerMessage(ctx context.Context, api *tg.Client, state *BotState, msg *tg.Message, entities tg.Entities) error {
text := strings.TrimSpace(msg.Message)
switch text {
case "/reload":
cfg, err := loadConfig(state.configPath)
if err != nil {
log.Printf("Failed to reload config: %v", err)
return nil
}
state.mu.Lock()
state.cfg = cfg
state.compileBadWords()
state.mu.Unlock()
var inputPeer tg.InputPeerClass
// Handle channel peer
if peerChan, ok := msg.PeerID.(*tg.PeerChannel); ok {
if channel, ok := entities.Channels[int64(peerChan.ChannelID)]; ok {
inputPeer = &tg.InputPeerChannel{
ChannelID: int64(peerChan.ChannelID),
AccessHash: channel.AccessHash,
}
} else {
log.Printf("Channel %d not found in entities", peerChan.ChannelID)
return nil
}
} else {
// Fallback for other peer types
var chats []tg.ChatClass
for _, chat := range entities.Chats {
chats = append(chats, chat)
}
inputPeer, err = toPeerInput(msg.PeerID, chats)
if err != nil {
log.Printf("Failed to convert peer: %v", err)
return nil
}
}
_, err = api.MessagesSendReaction(ctx, &tg.MessagesSendReactionRequest{
Peer: inputPeer,
MsgID: msg.ID,
Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "👍"}},
})
if err != nil {
log.Printf("Failed to set reaction: %v", err)
}
case "/test":
joinDate, err := getUserJoinDate(ctx, api, state.cfg.MonitoredChat, state.cfg.MonitoredChatAccessHash, state.selfID)
if err != nil {
log.Printf("getUserJoinDate error: %v", err)
} else {
log.Printf("✓ Join date: %v", joinDate)
}
}
return nil
}
func extractMessage(update any) *tg.Message { func extractMessage(update any) *tg.Message {
switch u := update.(type) { switch u := update.(type) {
case *tg.UpdateNewMessage: case *tg.UpdateNewMessage:
@ -490,10 +471,9 @@ func handleNewMessage(ctx context.Context, api *tg.Client, alertPeer *tg.InputPe
return nil return nil
} }
// Handle commands from the bot owner (messages sent by the connected account). // Handle owner commands.
if msg.Out { if msg.Out {
handleOwnerCommand(ctx, api, state, alertPeer, msg.Message) return handleOwnerMessage(ctx, api, state, msg, entities)
return nil
} }
// Check if from monitored supergroup (channels in gotd terminology) // Check if from monitored supergroup (channels in gotd terminology)
@ -608,14 +588,14 @@ func handleNewMessage(ctx context.Context, api *tg.Client, alertPeer *tg.InputPe
} }
chatName := channel.Title chatName := channel.Title
chatDisplay := escapeMarkdown(chatName) + escapeMarkdown(formatUsername(channel.Username)) chatDisplay := chatName + formatUsername(channel.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", matchMessageHTML := fmt.Sprintf("🚨 Matched\n<b>Score</b>: %.2f\n<b>Chat</b>: %s (ID: %d)\n<b>User</b>: %s (ID: %d)\n",
result.score, chatDisplay, chatID, userDisplay, senderID) result.score, chatDisplay, chatID, userDisplay, senderID)
if state.cfg.NtfyToken != "" || state.cfg.NtfyTopic != "" { if state.cfg.NtfyToken != "" || state.cfg.NtfyTopic != "" {
plainMessage := fmt.Sprintf("🚨 Matched\nScore: %.2f\nChat: %s (ID: %d)\nUser: %s (ID: %d)\n\n%s", plainMessage := fmt.Sprintf("🚨 Matched\nScore: %.2f\nChat: %s (ID: %d)\nUser: %s (ID: %d)\n\n%s",
result.score, chatName+formatUsername(channel.Username), chatID, userDisplay, senderID, msg.Message) result.score, chatDisplay, chatID, userDisplay, senderID, msg.Message)
notify(plainMessage, state.cfg, fmt.Sprintf("Scam Alert: %s", chatName)) notify(plainMessage, state.cfg, fmt.Sprintf("Scam Alert: %s", chatName))
} }
@ -696,10 +676,13 @@ func main() {
if err != nil { if err != nil {
return fmt.Errorf("resolving alert channel: %w", err) return fmt.Errorf("resolving alert channel: %w", err)
} }
cfg.AlertChatAccessHash = alertChannel.AccessHash
_, monitoredChannel, err := resolveChannel(ctx, api, cfg.MonitoredChat) _, monitoredChannel, err := resolveChannel(ctx, api, cfg.MonitoredChat)
if err != nil { if err != nil {
return fmt.Errorf("resolving monitored channel: %w", err) return fmt.Errorf("resolving monitored channel: %w", err)
} }
cfg.MonitoredChatAccessHash = monitoredChannel.AccessHash
// Get self ID for state and updates manager // Get self ID for state and updates manager
status, err := client.Auth().Status(ctx) status, err := client.Auth().Status(ctx)