Add /reload, cache channel access hashes
This commit is contained in:
parent
ddd5eafe4e
commit
064f0e35b1
1 changed files with 108 additions and 125 deletions
233
bot.go
233
bot.go
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue