diff --git a/Dockerfile.bot b/Dockerfile.bot new file mode 100644 index 0000000..94c034e --- /dev/null +++ b/Dockerfile.bot @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -o /bot ./cmd/bot + +FROM alpine:latest +WORKDIR /root/ +COPY --from=builder /bot /bot +COPY config.yaml . + +ENTRYPOINT ["/bot"] diff --git a/Makefile b/Makefile index 5fcbf8f..313768b 100644 --- a/Makefile +++ b/Makefile @@ -4,19 +4,23 @@ # VARIABLES # ==================================================================================== -# --- Application Configuration --- -BINARY_NAME=crawler # --- Docker & GCP Configuration --- # IMPORTANT: Replace with your actual GCP Project ID. -GCP_PROJECT_ID= +GCP_PROJECT_ID=aimaren GCR_HOSTNAME=gcr.io -IMAGE_NAME=hermes-crawler IMAGE_TAG=latest -# This variable constructs the full image URL. +# --- Crawler Configuration --- +BINARY_NAME=crawler +IMAGE_NAME=hermes-crawler IMAGE_URL=$(GCR_HOSTNAME)/$(GCP_PROJECT_ID)/$(IMAGE_NAME):$(IMAGE_TAG) +# --- Bot Configuration --- +BOT_BINARY_NAME=bot +BOT_IMAGE_NAME=hermes-bot +BOT_IMAGE_URL=$(GCR_HOSTNAME)/$(GCP_PROJECT_ID)/$(BOT_IMAGE_NAME):$(IMAGE_TAG) + # This allows passing arguments to the `run` command, e.g., `make run ARGS="-debug"` ARGS="" @@ -72,4 +76,40 @@ deploy: docker-push ## ☁️ Deploy the application as a Cloud Run Job --region="us-central1" \ --cpu="1" \ --memory="512Mi" \ - --task-timeout="5m" \ No newline at end of file + --task-timeout="5m" + +# ============================= +# BOT SPECIFIC CONFIG +# ============================= +.PHONY: bot-build bot-run bot-clean bot-docker-build bot-docker-push bot-deploy + +bot-build: ## 🛠️ Build the Go binary for the webhook bot + @echo "--> Building Go bot binary..." + @go build -o $(BOT_BINARY_NAME) ./cmd/bot + +bot-run: bot-build ## 🚀 Run the bot locally + @echo "--> Running bot locally..." + @./$(BOT_BINARY_NAME) + +bot-clean: ## 🗑️ Clean bot binary + @echo "--> Cleaning bot binary..." + @rm -f $(BOT_BINARY_NAME) + +bot-docker-build: ## 🐳 Build the bot Docker image + @echo "--> Building Docker image for bot: $(BOT_IMAGE_URL)" + @docker build -f Dockerfile.bot -t $(BOT_IMAGE_URL) . + +bot-docker-push: bot-docker-build ## ⬆️ Push the bot image + @echo "--> Pushing bot image: $(BOT_IMAGE_URL)" + @docker push $(BOT_IMAGE_URL) + +bot-deploy: bot-docker-push ## ☁️ Deploy the bot to Cloud Run + @echo "--> Deploying bot to Cloud Run..." + @gcloud run deploy $(BOT_IMAGE_NAME) \ + --image=$(BOT_IMAGE_URL) \ + --region=us-central1 \ + --platform=managed \ + --allow-unauthenticated \ + --cpu=1 \ + --memory=512Mi \ + --port=8080 \ No newline at end of file diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..1a65ca3 --- /dev/null +++ b/cmd/bot/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "strconv" + "strings" + + handler "git.pengzhan.dev/aimaren/internal/bothandler" + "git.pengzhan.dev/aimaren/internal/config" + "git.pengzhan.dev/aimaren/internal/storage" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func main() { + // ---- parse flags ---- + modeFlag := flag.String("mode", "unsafe", "Bot mode: unsafe | whitelist | blacklist") + listFlag := flag.String("list", "", "Comma-separated chat IDs for whitelist/blacklist") + flag.Parse() + + mode := parseMode(*modeFlag) + list := parseList(*listFlag) + + log.Printf("🔧 Bot starting with mode=%s list=%v", *modeFlag, list) + + // ---- load config ---- + cfg, err := config.Load() + if err != nil { + log.Fatalf("❌ config load: %v", err) + } + + store, err := storage.NewFirestoreClient(context.Background(), cfg.GCPProjectID) + if err != nil { + log.Fatalf("❌ store init: %v", err) + } + defer store.Close() + + bot, err := tgbotapi.NewBotAPI(cfg.Telegram.Token) + if err != nil { + log.Fatalf("❌ bot init: %v", err) + } + log.Printf("✅ Authorized on account %s", bot.Self.UserName) + + // 👉 Set webhook ONCE manually: + // curl -F "url=https:///" https://api.telegram.org/bot/setWebhook + + http.Handle("/", handler.NewBotHandler(bot, store, mode, list)) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + log.Printf("✅ Listening on :%s (Cloud Run HTTPS handled by platform)", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +// parseMode converts string to BotMode +func parseMode(s string) handler.BotMode { + switch strings.ToLower(s) { + case "whitelist": + return handler.WhiteListMode + case "blacklist": + return handler.BlackListMode + default: + return handler.UnsafeMode + } +} + +// parseList converts comma-separated chat IDs to []int64 +func parseList(s string) []int64 { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + var ids []int64 + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + v, err := strconv.ParseInt(p, 10, 64) + if err != nil { + log.Printf("⚠️ invalid chatID in list: %s (ignored)", p) + continue + } + ids = append(ids, v) + } + return ids +} diff --git a/config.yaml b/config.yaml index cf3c8b0..5d30226 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -gcp_project_id: "" +gcp_project_id: "aimaren" telegram: token: "" chat_ids: diff --git a/internal/bothandler/handler.go b/internal/bothandler/handler.go new file mode 100644 index 0000000..fa4d21b --- /dev/null +++ b/internal/bothandler/handler.go @@ -0,0 +1,223 @@ +package bothandler + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "slices" + "strings" + + "git.pengzhan.dev/aimaren/internal/storage" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +type BotMode int + +const ( + UnsafeMode BotMode = iota + WhiteListMode + BlackListMode +) + +// BotHandler 处理 Telegram 请求 +type BotHandler struct { + bot *tgbotapi.BotAPI + store storage.Storer // 用你的接口而不是具体类型 + mode BotMode + list []int64 +} + +func NewBotHandler(bot *tgbotapi.BotAPI, store storage.Storer, mode BotMode, list []int64) *BotHandler { + return &BotHandler{ + bot: bot, + store: store, + mode: mode, + list: list, + } +} + +func (h *BotHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "cannot read body", http.StatusBadRequest) + return + } + + var update tgbotapi.Update + if err := json.Unmarshal(body, &update); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + if update.Message == nil { + w.WriteHeader(http.StatusOK) + return + } + + text := strings.TrimSpace(update.Message.Text) + chatID := update.Message.Chat.ID + strChatId := fmt.Sprintf("%d", chatID) + + // 模式检查 + switch h.mode { + case UnsafeMode: + // nothing + case WhiteListMode: + if !slices.Contains(h.list, chatID) { + log.Printf("❌ Chat ID %d not in whitelist, ignore: %s", chatID, text) + w.WriteHeader(http.StatusOK) + return + } + case BlackListMode: + if slices.Contains(h.list, chatID) { + log.Printf("❌ Chat ID %d in blacklist, ignore: %s", chatID, text) + w.WriteHeader(http.StatusOK) + return + } + } + ctx := context.Background() + userState, err := h.store.FetchUserState(ctx) + if err != nil { + log.Printf("❌ FetchUserState error: %v", err) + http.Error(w, "fetch user state error", http.StatusInternalServerError) + return + } + if userState.Users == nil { + userState.Users = make(map[string]storage.ChatState) + } + + cs, ok := userState.Users[strChatId] + + // 判断是否为命令 + if update.Message.IsCommand() { + switch update.Message.Command() { + + case "start": + h.send(chatID, helpText()) + case "hello": + h.send(chatID, escapeMarkdownV2(fmt.Sprintf("👋 你好! 你的 chat id 是 %d", chatID))) + case "ride": + if ok && cs.Registered { + h.send(chatID, "✅ 你已经注册过了!") + break + } + if ok && cs.CurrentOp == "ride_waiting" { + h.send(chatID, "⏳ 你已经在等待输入邀请码,请直接发送邀请码。") + break + } + userState.Users[strChatId] = storage.ChatState{ + ChatID: chatID, + CurrentOp: "ride_waiting", + Context: "", + } + if err := h.store.UpdateUserState(ctx, userState); err != nil { + log.Printf("❌ UpdateUserState error: %v", err) + } + h.send(chatID, "📩 请输入你的邀请码:") + + case "tea": + h.send(chatID, "☕ 本项目不盈利,但为了周转,需要你的支持。") + + default: + h.send(chatID, helpText()) + } + + w.WriteHeader(http.StatusOK) + return + } + + // 非命令消息:检查是否在等待邀请码 + if ok && cs.CurrentOp == "ride_waiting" { + code := text + if slices.Contains(userState.FreeCode, code) { + userState.Users[strChatId] = storage.ChatState{ + ChatID: chatID, + Registered: true, + InviteCode: code, + } + userState.FreeCode = slices.Delete(userState.FreeCode, slices.Index(userState.FreeCode, code), slices.Index(userState.FreeCode, code)+1) + if err := h.store.UpdateUserState(ctx, userState); err != nil { + log.Printf("❌ UpdateUserState error: %v", err) + } + log.Printf("✅ Chat ID %d registered with invite code: %s", chatID, code) + h.store.AddChatID(ctx, chatID) + log.Printf("💾 Chat ID %d added to app state.", chatID) + h.send(chatID, "✅ 邀请码校验成功,已注册!") + h.send(chatID, h.currentStockText(ctx)) + } else { + userState.Users[strChatId] = storage.ChatState{ + ChatID: chatID, + } + if err := h.store.UpdateUserState(ctx, userState); err != nil { + log.Printf("❌ UpdateUserState error: %v", err) + } + h.send(chatID, "❌ 邀请码错误。") + } + w.WriteHeader(http.StatusOK) + return + } + + w.WriteHeader(http.StatusOK) +} + +// send 封装消息发送 +func (h *BotHandler) send(chatID int64, text string) { + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "MarkdownV2" + if _, err := h.bot.Send(msg); err != nil { + log.Printf("❌ send message error: %v", err) + } +} + +func helpText() string { + return `🤖 可用命令: +/start 查看帮助 +/hello 查看 chat id +/ride 注册 +/tea 支持作者` +} + +func (h *BotHandler) currentStockText(ctx context.Context) string { + as, err := h.store.FetchAppState(ctx) + if err != nil { + log.Printf("❌ FetchAppState error: %v", err) + return escapeMarkdownV2("❌ 无法获取当前库存状态") + } + + if len(as.Bags) == 0 { + return escapeMarkdownV2("当前没有可用的库存。") + } + + var sb strings.Builder + sb.WriteString(escapeMarkdownV2("当前缓存库存:") + "\n") + + for _, bag := range as.Bags { + if bag.Availability { + // MarkdownV2 link: [escapedName](escapedURL) + name := escapeMarkdownV2(bag.Name) + url := escapeMarkdownV2(bag.URL) + sb.WriteString(fmt.Sprintf("[%s](%s)\n", name, url)) + } + } + + return sb.String() +} + +func escapeMarkdownV2(text string) string { + // List of characters that must be escaped in MarkdownV2 + specialChars := []string{"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"} + + escaped := text + for _, ch := range specialChars { + escaped = strings.ReplaceAll(escaped, ch, "\\"+ch) + } + return escaped +} diff --git a/internal/config/config.go b/internal/config/config.go index 44e70d7..0803246 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,8 +10,9 @@ import ( type Config struct { GCPProjectID string `mapstructure:"gcp_project_id"` Telegram struct { - Token string `mapstructure:"token"` - ChatIDs []int64 `mapstructure:"chat_ids"` + EndPoint string `mapstructure:"endpoint"` + Token string `mapstructure:"token"` + ChatIDs []int64 `mapstructure:"chat_ids"` } `mapstructure:"telegram"` } diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index ab4ceeb..00834e8 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -2,6 +2,8 @@ package notifier // Notifier defines the interface for sending notifications. type Notifier interface { + // Notify sends a message to a specific chat ID. + SendTo(chatID int64, message string) error // Broadcast sends a message to a given list of chat IDs. Broadcast(chatIDs []int64, message string) error } diff --git a/internal/storage/firestore.go b/internal/storage/firestore.go index 273a50e..84ae110 100644 --- a/internal/storage/firestore.go +++ b/internal/storage/firestore.go @@ -2,6 +2,7 @@ package storage import ( "context" + "fmt" "cloud.google.com/go/firestore" "git.pengzhan.dev/aimaren/internal/crawler" @@ -10,10 +11,12 @@ import ( ) const ( - stateCollection = "hermes_state" - stateDocument = "main" + StateCollection = "hermes_state" + AppStateDocument = "main" + UserStateDocument = "users" ) +// FirestoreClient implements Storer type FirestoreClient struct { client *firestore.Client } @@ -26,11 +29,14 @@ func NewFirestoreClient(ctx context.Context, projectID string) (*FirestoreClient return &FirestoreClient{client: client}, nil } +func (fs *FirestoreClient) Close() { + fs.client.Close() +} + // FetchAppState retrieves the entire application state from a single document. func (fs *FirestoreClient) FetchAppState(ctx context.Context) (*AppState, error) { - doc, err := fs.client.Collection(stateCollection).Doc(stateDocument).Get(ctx) + doc, err := fs.client.Collection(StateCollection).Doc(AppStateDocument).Get(ctx) if err != nil { - // If the doc doesn't exist, return a new, empty AppState. if status.Code(err) == codes.NotFound { return &AppState{ Bags: make(map[string]crawler.Bag), @@ -39,29 +45,59 @@ func (fs *FirestoreClient) FetchAppState(ctx context.Context) (*AppState, error) } return nil, err } - var state AppState if err := doc.DataTo(&state); err != nil { return nil, err } + if state.Bags == nil { + state.Bags = make(map[string]crawler.Bag) + } + if state.ChatIDs == nil { + state.ChatIDs = []int64{} + } return &state, nil } // UpdateAppState writes the entire application state back to the document. func (fs *FirestoreClient) UpdateAppState(ctx context.Context, newState *AppState) error { - _, err := fs.client.Collection(stateCollection).Doc(stateDocument).Set(ctx, newState) + _, err := fs.client.Collection(StateCollection).Doc(AppStateDocument).Set(ctx, newState) return err } // AddChatID atomically adds a new chat ID to the list in the main state document. func (fs *FirestoreClient) AddChatID(ctx context.Context, chatID int64) error { - docRef := fs.client.Collection(stateCollection).Doc(stateDocument) + docRef := fs.client.Collection(StateCollection).Doc(AppStateDocument) _, err := docRef.Update(ctx, []firestore.Update{ {Path: "chat_ids", Value: firestore.ArrayUnion(chatID)}, }) + if status.Code(err) == codes.NotFound { + return fs.UpdateAppState(ctx, &AppState{ + Bags: make(map[string]crawler.Bag), + ChatIDs: []int64{chatID}, + }) + } return err } -func (fs *FirestoreClient) Close() { - fs.client.Close() +// FetchUserState retrieves the user state document and converts string keys back to int64. +func (fs *FirestoreClient) FetchUserState(ctx context.Context) (*UserState, error) { + docSnap, err := fs.client.Collection(StateCollection).Doc(UserStateDocument).Get(ctx) + if err != nil { + if status.Code(err) == codes.NotFound { + return &UserState{Users: make(map[string]ChatState)}, nil + } + return nil, err + } + var result UserState + if err := docSnap.DataTo(&result); err != nil { + return nil, fmt.Errorf("failed to decode user state: %w", err) + } + return &result, nil +} + +// UpdateUserState writes the whole UserState back to Firestore. +// It converts int64 keys to string keys for Firestore compatibility. +func (fs *FirestoreClient) UpdateUserState(ctx context.Context, newUserState *UserState) error { + _, err := fs.client.Collection(StateCollection).Doc(UserStateDocument).Set(ctx, newUserState) + return err } diff --git a/internal/storage/models.go b/internal/storage/models.go index 323623c..760c84f 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -9,3 +9,17 @@ type AppState struct { Bags map[string]crawler.Bag `firestore:"bags"` ChatIDs []int64 `firestore:"chat_ids"` } + +// UserState represents the state of all users. +type UserState struct { + Users map[string]ChatState `firestore:"users"` + FreeCode []string `firestore:"free_code"` +} + +type ChatState struct { + ChatID int64 `firestore:"chat_id"` + Registered bool `firestore:"registered"` + InviteCode string `firestore:"invite_code"` + CurrentOp string `firestore:"current_operation"` + Context string `firestore:"context"` +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index b6e9217..7007ad0 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -9,4 +9,6 @@ type Storer interface { FetchAppState(ctx context.Context) (*AppState, error) UpdateAppState(ctx context.Context, newState *AppState) error AddChatID(ctx context.Context, chatID int64) error + FetchUserState(ctx context.Context) (*UserState, error) + UpdateUserState(ctx context.Context, newUserState *UserState) error }