From 064f0e35b131a648349503228047e2d3bd0d4125 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 8 Apr 2026 10:29:17 +0100 Subject: [PATCH] Add /reload, cache channel access hashes --- bot.go | 233 ++++++++++++++++++++++++++------------------------------- 1 file changed, 108 insertions(+), 125 deletions(-) diff --git a/bot.go b/bot.go index 0d93333..fded373 100644 --- a/bot.go +++ b/bot.go @@ -12,6 +12,7 @@ import ( "regexp" "strings" "sync" + "time" "github.com/gotd/td/session" "github.com/gotd/td/telegram" @@ -31,17 +32,19 @@ type YAMLScamPattern struct { } 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"` - BadWords []string `yaml:"bad_words"` - Patterns []scamPattern `yaml:"-"` // Compiled patterns, not from YAML + 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"` + BadWords []string `yaml:"bad_words"` + 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) { @@ -183,11 +186,7 @@ func (s *BotState) compileBadWords() { 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, "|") + `)`) + s.badWordRe = regexp.MustCompile(`(?i)(` + strings.Join(s.cfg.BadWords, "|") + `)`) log.Printf("✓ Bad word pattern: %s", s.badWordRe) } @@ -199,110 +198,6 @@ func (s *BotState) saveConfig() error { 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 [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 ") - 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 ") - 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 [word]") - } -} - func keywordMatchScore(input string, patterns []scamPattern) matchResult { matched := make(map[string][]string) 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 { switch u := update.(type) { case *tg.UpdateNewMessage: @@ -490,10 +471,9 @@ func handleNewMessage(ctx context.Context, api *tg.Client, alertPeer *tg.InputPe return nil } - // Handle commands from the bot owner (messages sent by the connected account). + // Handle owner commands. if msg.Out { - handleOwnerCommand(ctx, api, state, alertPeer, msg.Message) - return nil + return handleOwnerMessage(ctx, api, state, msg, entities) } // 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 - chatDisplay := escapeMarkdown(chatName) + escapeMarkdown(formatUsername(channel.Username)) + chatDisplay := chatName + formatUsername(channel.Username) matchMessageHTML := fmt.Sprintf("🚨 Matched\nScore: %.2f\nChat: %s (ID: %d)\nUser: %s (ID: %d)\n", result.score, chatDisplay, chatID, userDisplay, senderID) if state.cfg.NtfyToken != "" || state.cfg.NtfyTopic != "" { 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)) } @@ -696,10 +676,13 @@ func main() { if err != nil { return fmt.Errorf("resolving alert channel: %w", err) } + cfg.AlertChatAccessHash = alertChannel.AccessHash + _, monitoredChannel, err := resolveChannel(ctx, api, cfg.MonitoredChat) if err != nil { return fmt.Errorf("resolving monitored channel: %w", err) } + cfg.MonitoredChatAccessHash = monitoredChannel.AccessHash // Get self ID for state and updates manager status, err := client.Auth().Status(ctx)