From 3697051ecf6c60a0761c7d3e92eea328162dcbba Mon Sep 17 00:00:00 2001 From: wood chen Date: Wed, 18 Sep 2024 01:27:32 +0800 Subject: [PATCH] =?UTF-8?q?python=E8=BD=AC=E6=88=90go=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/dependabot.yml | 2 +- .github/workflows/build and depoly.yml | 17 +- Dockerfile | 48 ++++-- Dockerfile.multi | 25 +++ core/bot_commands.go | 34 ++++ core/database.go | 184 ++++++++++++++++++++ core/functions.go | 90 ++++++++++ core/link_filter.go | 227 +++++++++++++++++++++++++ data/keywords.json | 1 - data/whitelist.json | 3 - go.mod | 18 ++ go.sum | 37 ++++ main.go | 107 ++++++++++++ service/binance.go | 149 ++++++++++++++++ service/guard.go | 193 +++++++++++++++++++++ 15 files changed, 1112 insertions(+), 23 deletions(-) create mode 100644 Dockerfile.multi create mode 100644 core/bot_commands.go create mode 100644 core/database.go create mode 100644 core/functions.go create mode 100644 core/link_filter.go delete mode 100644 data/keywords.json delete mode 100644 data/whitelist.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 service/binance.go create mode 100644 service/guard.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9d866e3..cd88554 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ version: 2 updates: - - package-ecosystem: "pip" # See documentation for possible values + - package-ecosystem: "gomod" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" diff --git a/.github/workflows/build and depoly.yml b/.github/workflows/build and depoly.yml index 738f188..dc26a82 100644 --- a/.github/workflows/build and depoly.yml +++ b/.github/workflows/build and depoly.yml @@ -18,15 +18,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up Go + uses: actions/setup-go@v4 with: - python-version: '3.12.5' + go-version: '1.22' # 使用你项目需要的 Go 版本 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt + - name: Build for amd64 + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main-amd64 . + + - name: Build for arm64 + run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o main-arm64 . - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -44,7 +45,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: Dockerfile + file: Dockerfile.multi platforms: linux/amd64,linux/arm64 push: true tags: | diff --git a/Dockerfile b/Dockerfile index 1209381..8b9e706 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,43 @@ -FROM python:3.12.5-slim - -# 设置时区 -ENV TZ=Asia/Singapore -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +# 使用官方 Go 镜像作为构建环境 +FROM golang:1.22 AS builder +# 设置工作目录 WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# 复制 go mod 和 sum 文件 +COPY go.mod go.sum ./ -COPY src /app/src -COPY data /app/data +# 下载依赖 +RUN go mod download -CMD ["python", "src/main.py"] +# 复制源代码 +COPY . . + +# 编译应用 +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +# 使用轻量级的 alpine 镜像作为运行环境 +FROM alpine:latest + +# 安装 ca-certificates +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +# 从构建阶段复制编译好的应用 +COPY --from=builder /app/main . + +# 设置环境变量 +ENV BOT_TOKEN="" +ENV ADMIN_ID="" +ENV SYMBOLS="" +ENV DEBUG_MODE="false" + +# 创建数据目录 +RUN mkdir -p /app/data + +# 暴露端口(如果你的应用需要的话) +# EXPOSE 8080 + +# 运行应用 +CMD ["./main"] diff --git a/Dockerfile.multi b/Dockerfile.multi new file mode 100644 index 0000000..852fb0d --- /dev/null +++ b/Dockerfile.multi @@ -0,0 +1,25 @@ +# 使用轻量级的基础镜像 +FROM alpine:latest + +# 安装 ca-certificates,通常需要用于 HTTPS +RUN apk --no-cache add ca-certificates + +# 创建工作目录 +WORKDIR /root/ + +# 复制编译好的可执行文件 +COPY main-amd64 main-arm64 ./ + +# 使用 TARGETARCH 参数来选择正确的二进制文件 +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + mv main-amd64 main && rm main-arm64; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + mv main-arm64 main && rm main-amd64; \ + fi + +# 设置执行权限 +RUN chmod +x main + +# 运行应用 +CMD ["./main"] diff --git a/core/bot_commands.go b/core/bot_commands.go new file mode 100644 index 0000000..a7f51ab --- /dev/null +++ b/core/bot_commands.go @@ -0,0 +1,34 @@ +package core + +import ( + "fmt" + "log" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func RegisterCommands(bot *tgbotapi.BotAPI, adminID int64) error { + commands := []tgbotapi.BotCommand{ + {Command: "add", Description: "添加新的关键词"}, + {Command: "delete", Description: "删除现有的关键词"}, + {Command: "list", Description: "列出所有当前的关键词"}, + {Command: "deletecontaining", Description: "删除所有包含指定词语的关键词"}, + {Command: "addwhite", Description: "添加域名到白名单"}, + {Command: "delwhite", Description: "从白名单移除域名"}, + {Command: "listwhite", Description: "列出白名单域名"}, + } + + scope := tgbotapi.NewBotCommandScopeChatAdministrators(adminID) + + config := tgbotapi.NewSetMyCommands(commands...) + config.Scope = &scope // 注意这里使用 &scope 来获取指针 + config.LanguageCode = "" // 空字符串表示默认语言 + + _, err := bot.Request(config) + if err != nil { + return fmt.Errorf("failed to register bot commands: %w", err) + } + + log.Println("Bot commands registered successfully.") + return nil +} diff --git a/core/database.go b/core/database.go new file mode 100644 index 0000000..48482d1 --- /dev/null +++ b/core/database.go @@ -0,0 +1,184 @@ +package core + +import ( + "database/sql" + "os" + "path/filepath" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type Database struct { + db *sql.DB + dbFile string + keywordsCache []string + whitelistCache []string + cacheTime time.Time + mu sync.Mutex +} + +func NewDatabase(dbFile string) (*Database, error) { + os.MkdirAll(filepath.Dir(dbFile), os.ModePerm) + db, err := sql.Open("sqlite3", dbFile) + if err != nil { + return nil, err + } + + database := &Database{ + db: db, + dbFile: dbFile, + } + + if err := database.createTables(); err != nil { + return nil, err + } + + return database, nil +} + +func (d *Database) createTables() error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS keywords + (id INTEGER PRIMARY KEY, keyword TEXT UNIQUE)`, + `CREATE INDEX IF NOT EXISTS idx_keyword ON keywords(keyword)`, + `CREATE TABLE IF NOT EXISTS whitelist + (id INTEGER PRIMARY KEY, domain TEXT UNIQUE)`, + `CREATE INDEX IF NOT EXISTS idx_domain ON whitelist(domain)`, + `CREATE VIRTUAL TABLE IF NOT EXISTS keywords_fts USING fts5(keyword)`, + } + + for _, query := range queries { + _, err := d.db.Exec(query) + if err != nil { + return err + } + } + + return nil +} + +func (d *Database) executeQuery(query string, args ...interface{}) ([]string, error) { + rows, err := d.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []string + for rows.Next() { + var result string + if err := rows.Scan(&result); err != nil { + return nil, err + } + results = append(results, result) + } + + return results, nil +} + +func (d *Database) AddKeyword(keyword string) error { + _, err := d.db.Exec("INSERT OR IGNORE INTO keywords (keyword) VALUES (?)", keyword) + if err != nil { + return err + } + _, err = d.db.Exec("INSERT OR IGNORE INTO keywords_fts (keyword) VALUES (?)", keyword) + if err != nil { + return err + } + d.invalidateCache() + return nil +} + +func (d *Database) RemoveKeyword(keyword string) error { + _, err := d.db.Exec("DELETE FROM keywords WHERE keyword = ?", keyword) + if err != nil { + return err + } + _, err = d.db.Exec("DELETE FROM keywords_fts WHERE keyword = ?", keyword) + if err != nil { + return err + } + d.invalidateCache() + return nil +} + +func (d *Database) GetAllKeywords() ([]string, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.keywordsCache == nil || time.Since(d.cacheTime) > 5*time.Minute { + keywords, err := d.executeQuery("SELECT keyword FROM keywords") + if err != nil { + return nil, err + } + d.keywordsCache = keywords + d.cacheTime = time.Now() + } + + return d.keywordsCache, nil +} + +func (d *Database) RemoveKeywordsContaining(substring string) error { + _, err := d.db.Exec("DELETE FROM keywords WHERE keyword LIKE ?", "%"+substring+"%") + if err != nil { + return err + } + _, err = d.db.Exec("DELETE FROM keywords_fts WHERE keyword LIKE ?", "%"+substring+"%") + if err != nil { + return err + } + d.invalidateCache() + return nil +} + +func (d *Database) AddWhitelist(domain string) error { + _, err := d.db.Exec("INSERT OR IGNORE INTO whitelist (domain) VALUES (?)", domain) + if err != nil { + return err + } + d.invalidateCache() + return nil +} + +func (d *Database) RemoveWhitelist(domain string) error { + _, err := d.db.Exec("DELETE FROM whitelist WHERE domain = ?", domain) + if err != nil { + return err + } + d.invalidateCache() + return nil +} + +func (d *Database) GetAllWhitelist() ([]string, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.whitelistCache == nil || time.Since(d.cacheTime) > 5*time.Minute { + whitelist, err := d.executeQuery("SELECT domain FROM whitelist") + if err != nil { + return nil, err + } + d.whitelistCache = whitelist + d.cacheTime = time.Now() + } + + return d.whitelistCache, nil +} + +func (d *Database) SearchKeywords(pattern string) ([]string, error) { + return d.executeQuery("SELECT keyword FROM keywords_fts WHERE keyword MATCH ?", pattern) +} + +func (d *Database) invalidateCache() { + d.mu.Lock() + defer d.mu.Unlock() + d.keywordsCache = nil + d.whitelistCache = nil + d.cacheTime = time.Time{} +} + +func (d *Database) Close() error { + return d.db.Close() +} diff --git a/core/functions.go b/core/functions.go new file mode 100644 index 0000000..133c603 --- /dev/null +++ b/core/functions.go @@ -0,0 +1,90 @@ +package core + +import ( + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// SendLongMessage sends a long message by splitting it into multiple messages if necessary +// SendLongMessage sends a long message by splitting it into multiple messages if necessary +func SendLongMessage(bot *tgbotapi.BotAPI, chatID int64, prefix string, items []string) error { + const maxMessageLength = 4000 // Leave some room for Telegram's message limit + + message := prefix + "\n" + for i, item := range items { + newLine := fmt.Sprintf("%d. %s\n", i+1, item) + if len(message)+len(newLine) > maxMessageLength { + msg := tgbotapi.NewMessage(chatID, message) + _, err := bot.Send(msg) + if err != nil { + return err + } + message = "" + } + message += newLine + } + + if message != "" { + msg := tgbotapi.NewMessage(chatID, message) + _, err := bot.Send(msg) + if err != nil { + return err + } + } + + return nil +} + +// SendLongMessageWithoutNumbering sends a long message without numbering the items +func SendLongMessageWithoutNumbering(bot *tgbotapi.BotAPI, chatID int64, prefix string, items []string) error { + const maxMessageLength = 4000 // Leave some room for Telegram's message limit + + message := prefix + "\n" + for _, item := range items { + newLine := item + "\n" + if len(message)+len(newLine) > maxMessageLength { + msg := tgbotapi.NewMessage(chatID, message) + _, err := bot.Send(msg) + if err != nil { + return err + } + message = "" + } + message += newLine + } + + if message != "" { + msg := tgbotapi.NewMessage(chatID, message) + _, err := bot.Send(msg) + if err != nil { + return err + } + } + + return nil +} + +// JoinLongMessage joins items into a single long message, splitting it if necessary +func JoinLongMessage(prefix string, items []string) []string { + const maxMessageLength = 4000 // Leave some room for Telegram's message limit + + var messages []string + message := prefix + "\n" + + for i, item := range items { + newLine := fmt.Sprintf("%d. %s\n", i+1, item) + if len(message)+len(newLine) > maxMessageLength { + messages = append(messages, strings.TrimSpace(message)) + message = "" + } + message += newLine + } + + if message != "" { + messages = append(messages, strings.TrimSpace(message)) + } + + return messages +} diff --git a/core/link_filter.go b/core/link_filter.go new file mode 100644 index 0000000..e1f8767 --- /dev/null +++ b/core/link_filter.go @@ -0,0 +1,227 @@ +package core + +import ( + "fmt" + "log" + "net/url" + "regexp" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +var logger = log.New(log.Writer(), "LinkFilter: ", log.Ldate|log.Ltime|log.Lshortfile) + +type LinkFilter struct { + db *Database + keywords []string + whitelist []string + linkPattern *regexp.Regexp +} + +func NewLinkFilter(dbFile string) *LinkFilter { + lf := &LinkFilter{ + db: NewDatabase(dbFile), + } + lf.linkPattern = regexp.MustCompile(`(?i)\b(?:(?:https?://)?(?:(?:www\.)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|(?:t\.me|telegram\.me))(?:/[^\s]*)?)`) + lf.LoadDataFromFile() + return lf +} + +func (lf *LinkFilter) LoadDataFromFile() { + lf.keywords = lf.db.GetAllKeywords() + lf.whitelist = lf.db.GetAllWhitelist() + logger.Printf("Loaded %d keywords and %d whitelist entries from database", len(lf.keywords), len(lf.whitelist)) +} + +func (lf *LinkFilter) NormalizeLink(link string) string { + link = regexp.MustCompile(`^https?://`).ReplaceAllString(link, "") + link = strings.TrimPrefix(link, "/") + parsedURL, err := url.Parse("http://" + link) + if err != nil { + logger.Printf("Error parsing URL: %v", err) + return link + } + normalized := fmt.Sprintf("%s%s", parsedURL.Hostname(), parsedURL.EscapedPath()) + result := strings.TrimSuffix(normalized, "/") + logger.Printf("Normalized link: %s -> %s", link, result) + return result +} + +func (lf *LinkFilter) ExtractDomain(urlStr string) string { + parsedURL, err := url.Parse(urlStr) + if err != nil { + logger.Printf("Error parsing URL: %v", err) + return urlStr + } + domain := parsedURL.Hostname() + parts := strings.Split(domain, ".") + if len(parts) > 2 { + domain = strings.Join(parts[len(parts)-2:], ".") + } + return strings.ToLower(domain) +} + +func (lf *LinkFilter) IsWhitelisted(link string) bool { + domain := lf.ExtractDomain(link) + for _, whiteDomain := range lf.whitelist { + if domain == whiteDomain { + logger.Printf("Whitelist check for %s: Passed", link) + return true + } + } + logger.Printf("Whitelist check for %s: Failed", link) + return false +} + +func (lf *LinkFilter) AddKeyword(keyword string) { + if lf.linkPattern.MatchString(keyword) { + keyword = lf.NormalizeLink(keyword) + } + keyword = strings.TrimPrefix(keyword, "/") + for _, k := range lf.keywords { + if k == keyword { + logger.Printf("Keyword already exists: %s", keyword) + return + } + } + lf.db.AddKeyword(keyword) + logger.Printf("New keyword added: %s", keyword) + lf.LoadDataFromFile() +} + +func (lf *LinkFilter) RemoveKeyword(keyword string) bool { + for _, k := range lf.keywords { + if k == keyword { + lf.db.RemoveKeyword(keyword) + lf.LoadDataFromFile() + return true + } + } + return false +} + +func (lf *LinkFilter) RemoveKeywordsContaining(substring string) []string { + removed := lf.db.RemoveKeywordsContaining(substring) + lf.LoadDataFromFile() + return removed +} + +func (lf *LinkFilter) ShouldFilter(text string) (bool, []string) { + logger.Printf("Checking text: %s", text) + for _, keyword := range lf.keywords { + if strings.Contains(strings.ToLower(text), strings.ToLower(keyword)) { + logger.Printf("Text contains keyword: %s", text) + return true, nil + } + } + + links := lf.linkPattern.FindAllString(text, -1) + logger.Printf("Found links: %v", links) + var newNonWhitelistedLinks []string + for _, link := range links { + normalizedLink := lf.NormalizeLink(link) + normalizedLink = strings.TrimPrefix(normalizedLink, "/") + if !lf.IsWhitelisted(normalizedLink) { + logger.Printf("Link not whitelisted: %s", normalizedLink) + found := false + for _, keyword := range lf.keywords { + if keyword == normalizedLink { + logger.Printf("Existing keyword found: %s", normalizedLink) + return true, nil + } + } + if !found { + newNonWhitelistedLinks = append(newNonWhitelistedLinks, normalizedLink) + lf.AddKeyword(normalizedLink) + } + } + } + + if len(newNonWhitelistedLinks) > 0 { + logger.Printf("New non-whitelisted links found: %v", newNonWhitelistedLinks) + } + return false, newNonWhitelistedLinks +} + +func (lf *LinkFilter) HandleKeywordCommand(bot *tgbotapi.BotAPI, message *tgbotapi.Message, command string, args string) { + switch command { + case "list": + keywords := lf.db.GetAllKeywords() + if len(keywords) == 0 { + bot.Send(tgbotapi.NewMessage(message.Chat.ID, "关键词列表为空。")) + } else { + SendLongMessage(bot, message.Chat.ID, "当前关键词列表:", keywords) + } + case "add": + if args != "" { + keyword := args + if !lf.db.KeywordExists(keyword) { + lf.AddKeyword(keyword) + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("关键词 '%s' 已添加。", keyword))) + } else { + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("关键词 '%s' 已存在。", keyword))) + } + } + case "delete": + if args != "" { + keyword := args + if lf.RemoveKeyword(keyword) { + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("关键词 '%s' 已删除。", keyword))) + } else { + similarKeywords := lf.db.SearchKeywords(keyword) + if len(similarKeywords) > 0 { + SendLongMessage(bot, message.Chat.ID, fmt.Sprintf("未找到精确匹配的关键词 '%s'。\n\n以下是相似的关键词:", keyword), similarKeywords) + } else { + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("关键词 '%s' 不存在。", keyword))) + } + } + } + case "deletecontaining": + if args != "" { + substring := args + removedKeywords := lf.RemoveKeywordsContaining(substring) + if len(removedKeywords) > 0 { + SendLongMessage(bot, message.Chat.ID, fmt.Sprintf("已删除包含 '%s' 的以下关键词:", substring), removedKeywords) + } else { + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("没有找到包含 '%s' 的关键词。", substring))) + } + } + default: + bot.Send(tgbotapi.NewMessage(message.Chat.ID, "无效的命令或参数。")) + } +} + +func (lf *LinkFilter) HandleWhitelistCommand(bot *tgbotapi.BotAPI, message *tgbotapi.Message, command string, args string) { + switch command { + case "listwhite": + whitelist := lf.db.GetAllWhitelist() + if len(whitelist) == 0 { + bot.Send(tgbotapi.NewMessage(message.Chat.ID, "白名单为空。")) + } else { + SendLongMessageWithoutNumbering(bot, message.Chat.ID, "白名单域名列表:", whitelist) + } + case "addwhite": + if args != "" { + domain := strings.ToLower(args) + if !lf.db.WhitelistExists(domain) { + lf.db.AddWhitelist(domain) + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("域名 '%s' 已添加到白名单。", domain))) + } else { + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("域名 '%s' 已在白名单中。", domain))) + } + } + case "delwhite": + if args != "" { + domain := strings.ToLower(args) + if lf.db.WhitelistExists(domain) { + lf.db.RemoveWhitelist(domain) + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("域名 '%s' 已从白名单中删除。", domain))) + } else { + bot.Send(tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("域名 '%s' 不在白名单中。", domain))) + } + } + default: + bot.Send(tgbotapi.NewMessage(message.Chat.ID, "无效的命令或参数。")) + } +} diff --git a/data/keywords.json b/data/keywords.json deleted file mode 100644 index 794e433..0000000 --- a/data/keywords.json +++ /dev/null @@ -1 +0,0 @@ -["推广", "广告", "ad", "promotion"] diff --git a/data/whitelist.json b/data/whitelist.json deleted file mode 100644 index b8c4ada..0000000 --- a/data/whitelist.json +++ /dev/null @@ -1,3 +0,0 @@ -[ - "q58.org" -] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..30bd932 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/woodchen-ink/Q58Bot + +go 1.21.3 + +require ( + github.com/adshao/go-binance/v2 v2.6.0 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/mattn/go-sqlite3 v1.14.23 +) + +require ( + github.com/bitly/go-simplejson v0.5.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c98cd9c --- /dev/null +++ b/go.sum @@ -0,0 +1,37 @@ +github.com/adshao/go-binance/v2 v2.6.0 h1:sXPkfix+SgBojJmkt+sNJbJBQZOJK5GFP/WtAu+B5r0= +github.com/adshao/go-binance/v2 v2.6.0/go.mod h1:41Up2dG4NfMXpCldrDPETEtiOq+pHoGsFZ73xGgaumo= +github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= +github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..fa85be1 --- /dev/null +++ b/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "log" + "os" + "strconv" + "time" + + binance "github.com/woodchen-ink/Q58Bot/service" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +var ( + BOT_TOKEN string + ADMIN_ID int64 +) + +func init() { + BOT_TOKEN = os.Getenv("BOT_TOKEN") + adminIDStr := os.Getenv("ADMIN_ID") + var err error + ADMIN_ID, err = strconv.ParseInt(adminIDStr, 10, 64) + if err != nil { + log.Fatalf("Invalid ADMIN_ID: %v", err) + } +} + +func setupBot() { + bot, err := tgbotapi.NewBotAPI(BOT_TOKEN) + if err != nil { + log.Panic(err) + } + + bot.Debug = true + + log.Printf("Authorized on account %s", bot.Self.UserName) + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := bot.GetUpdatesChan(u) + + for update := range updates { + if update.Message == nil { + continue + } + + if update.Message.Chat.ID != ADMIN_ID { + continue + } + + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "") + + switch update.Message.Command() { + case "start": + msg.Text = "Hello! I'm your bot." + case "help": + msg.Text = "I can help you with various tasks." + default: + msg.Text = "I don't know that command" + } + + if _, err := bot.Send(msg); err != nil { + log.Panic(err) + } + } +} + +func runGuard() { + for { + try(func() { + guard.Run() + }, "Guard") + } +} + +func runBinance() { + for { + try(func() { + binance.Run() + }, "Binance") + } +} + +func try(fn func(), name string) { + defer func() { + if r := recover(); r != nil { + log.Printf("%s process crashed: %v", name, r) + log.Printf("Restarting %s process...", name) + time.Sleep(time.Second) // 添加短暂延迟以防止过快重启 + } + }() + fn() +} + +func main() { + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + + // 使用 goroutines 运行 bot、guard 和 binance 服务 + go setupBot() + go runGuard() + go runBinance() + + // 保持主程序运行 + select {} +} diff --git a/service/binance.go b/service/binance.go new file mode 100644 index 0000000..245dce5 --- /dev/null +++ b/service/binance.go @@ -0,0 +1,149 @@ +package service + +import ( + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/adshao/go-binance/v2" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +var ( + botToken string + chatID int64 + symbols []string + bot *tgbotapi.BotAPI + lastMsgID int + singaporeTZ *time.Location +) + +func init() { + var err error + botToken = os.Getenv("BOT_TOKEN") + chatID = mustParseInt64(os.Getenv("CHAT_ID")) + symbols = strings.Split(os.Getenv("SYMBOLS"), ",") + singaporeTZ, err = time.LoadLocation("Asia/Singapore") + if err != nil { + log.Fatal(err) + } + + bot, err = tgbotapi.NewBotAPI(botToken) + if err != nil { + log.Fatal(err) + } +} + +func mustParseInt64(s string) int64 { + i, err := strconv.ParseInt(s, 10, 64) + if err != nil { + log.Fatal(err) + } + return i +} + +type tickerInfo struct { + symbol string + last float64 + changePercent float64 +} + +func getTickerInfo(symbol string) (tickerInfo, error) { + client := binance.NewClient("", "") + + // 获取当前价格 + ticker, err := client.NewListPricesService().Symbol(symbol).Do(binance.NewContext()) + if err != nil { + return tickerInfo{}, err + } + if len(ticker) == 0 { + return tickerInfo{}, fmt.Errorf("no ticker found for symbol %s", symbol) + } + last, err := ticker[0].Price.Float64() + if err != nil { + return tickerInfo{}, err + } + + // 获取24小时价格变化 + stats, err := client.NewListPriceChangeStatsService().Symbol(symbol).Do(binance.NewContext()) + if err != nil { + return tickerInfo{}, err + } + if len(stats) == 0 { + return tickerInfo{}, fmt.Errorf("no price change stats found for symbol %s", symbol) + } + changePercent, err := stats[0].PriceChangePercent.Float64() + if err != nil { + return tickerInfo{}, err + } + + return tickerInfo{ + symbol: symbol, + last: last, + changePercent: changePercent, + }, nil +} + +func formatChange(changePercent float64) string { + if changePercent > 0 { + return fmt.Sprintf("🔼 +%.2f%%", changePercent) + } else if changePercent < 0 { + return fmt.Sprintf("🔽 %.2f%%", changePercent) + } + return fmt.Sprintf("◀▶ %.2f%%", changePercent) +} + +func sendPriceUpdate() { + now := time.Now().In(singaporeTZ) + message := fmt.Sprintf("市场更新 - %s (SGT)\n\n", now.Format("2006-01-02 15:04:05")) + + for _, symbol := range symbols { + info, err := getTickerInfo(symbol) + if err != nil { + log.Printf("Error getting ticker info for %s: %v", symbol, err) + continue + } + + changeStr := formatChange(info.changePercent) + + message += fmt.Sprintf("*%s*\n", info.symbol) + message += fmt.Sprintf("价格: $%.7f\n", info.last) + message += fmt.Sprintf("24h 涨跌: %s\n\n", changeStr) + } + + if lastMsgID != 0 { + deleteMsg := tgbotapi.NewDeleteMessage(chatID, lastMsgID) + _, err := bot.Request(deleteMsg) + if err != nil { + log.Printf("Failed to delete previous message: %v", err) + } + } + + msg := tgbotapi.NewMessage(chatID, message) + msg.ParseMode = "Markdown" + sentMsg, err := bot.Send(msg) + if err != nil { + log.Printf("Failed to send message: %v", err) + return + } + + lastMsgID = sentMsg.MessageID +} + +func Run() { + log.Println("Sending initial price update...") + sendPriceUpdate() + + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + sendPriceUpdate() + } + } +} diff --git a/service/guard.go b/service/guard.go new file mode 100644 index 0000000..e53a82a --- /dev/null +++ b/service/guard.go @@ -0,0 +1,193 @@ +package service + +import ( + "fmt" + "log" + "os" + "strconv" + "sync" + "time" + + "your-project-name/core" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +var ( + botToken string + adminID int64 + dbFile string + debugMode bool +) + +func init() { + botToken = os.Getenv("BOT_TOKEN") + adminIDStr := os.Getenv("ADMIN_ID") + var err error + adminID, err = strconv.ParseInt(adminIDStr, 10, 64) + if err != nil { + log.Fatalf("Invalid ADMIN_ID: %v", err) + } + dbFile = "/app/data/q58.db" // 新的数据库文件路径 + debugMode = os.Getenv("DEBUG_MODE") == "true" +} + +type RateLimiter struct { + mu sync.Mutex + maxCalls int + period time.Duration + calls []time.Time +} + +func NewRateLimiter(maxCalls int, period time.Duration) *RateLimiter { + return &RateLimiter{ + maxCalls: maxCalls, + period: period, + calls: make([]time.Time, 0, maxCalls), + } +} + +func (r *RateLimiter) Allow() bool { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + if len(r.calls) < r.maxCalls { + r.calls = append(r.calls, now) + return true + } + + if now.Sub(r.calls[0]) >= r.period { + r.calls = append(r.calls[1:], now) + return true + } + + return false +} + +func deleteMessageAfterDelay(bot *tgbotapi.BotAPI, chatID int64, messageID int, delay time.Duration) { + time.Sleep(delay) + deleteMsg := tgbotapi.NewDeleteMessage(chatID, messageID) + _, err := bot.Request(deleteMsg) + if err != nil { + log.Printf("Failed to delete message: %v", err) + } +} + +func processMessage(bot *tgbotapi.BotAPI, message *tgbotapi.Message, linkFilter *core.LinkFilter) { + if message.Chat.Type != "private" { + log.Printf("Processing message: %s", message.Text) + shouldFilter, newLinks := linkFilter.ShouldFilter(message.Text) + if shouldFilter { + log.Printf("Message should be filtered: %s", message.Text) + if message.From.ID != adminID { + deleteMsg := tgbotapi.NewDeleteMessage(message.Chat.ID, message.MessageID) + _, err := bot.Request(deleteMsg) + if err != nil { + log.Printf("Failed to delete message: %v", err) + } + notification := tgbotapi.NewMessage(message.Chat.ID, "已撤回该消息。注:一个链接不能发两次.") + sent, err := bot.Send(notification) + if err != nil { + log.Printf("Failed to send notification: %v", err) + } else { + go deleteMessageAfterDelay(bot, message.Chat.ID, sent.MessageID, 3*time.Minute) + } + } + return + } + if len(newLinks) > 0 { + log.Printf("New non-whitelisted links found: %v", newLinks) + } + } +} + +func messageHandler(bot *tgbotapi.BotAPI, update tgbotapi.Update, linkFilter *core.LinkFilter, rateLimiter *RateLimiter) { + if update.Message == nil { + return + } + + if update.Message.Chat.Type != "private" || update.Message.From.ID != adminID { + if rateLimiter.Allow() { + processMessage(bot, update.Message, linkFilter) + } + } +} + +func commandHandler(bot *tgbotapi.BotAPI, update tgbotapi.Update, linkFilter *core.LinkFilter) { + if update.Message == nil || update.Message.Chat.Type != "private" || update.Message.From.ID != adminID { + return + } + + linkFilter.LoadDataFromFile() + + command := update.Message.Command() + args := update.Message.CommandArguments() + + switch command { + case "add", "delete", "list", "deletecontaining": + linkFilter.HandleKeywordCommand(bot, update.Message, command, args) + case "addwhite", "delwhite", "listwhite": + linkFilter.HandleWhitelistCommand(bot, update.Message, command, args) + } + + if command == "add" || command == "delete" || command == "deletecontaining" || command == "list" || command == "addwhite" || command == "delwhite" || command == "listwhite" { + linkFilter.LoadDataFromFile() + } +} + +func StartBot() error { + bot, err := tgbotapi.NewBotAPI(botToken) + if err != nil { + return fmt.Errorf("failed to create bot: %w", err) + } + + bot.Debug = debugMode + + log.Printf("Authorized on account %s", bot.Self.UserName) + + err = core.RegisterCommands(bot, adminID) + if err != nil { + return fmt.Errorf("error registering commands: %w", err) + } + + linkFilter := core.NewLinkFilter(dbFile) + rateLimiter := NewRateLimiter(10, time.Second) + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := bot.GetUpdatesChan(u) + + for update := range updates { + go messageHandler(bot, update, linkFilter, rateLimiter) + go commandHandler(bot, update, linkFilter) + } + + return nil // 如果 bot 正常退出,返回 nil +} + +func RunGuard() { + baseDelay := time.Second + maxDelay := 5 * time.Minute + delay := baseDelay + + for { + err := StartBot() + if err != nil { + log.Printf("Bot encountered an error: %v", err) + log.Printf("Attempting to restart in %v...", delay) + time.Sleep(delay) + + // 实现指数退避 + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } else { + // 如果 bot 正常退出,重置延迟 + delay = baseDelay + log.Println("Bot disconnected. Attempting to restart immediately...") + } + } +}