feat: Scaffolding bot

This commit is contained in:
2025-07-26 17:18:41 -07:00
parent 4acfd33eae
commit 8d3bcbc01d
10 changed files with 443 additions and 18 deletions
+15
View File
@@ -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"]
+46 -6
View File
@@ -4,19 +4,23 @@
# VARIABLES # VARIABLES
# ==================================================================================== # ====================================================================================
# --- Application Configuration ---
BINARY_NAME=crawler
# --- Docker & GCP Configuration --- # --- Docker & GCP Configuration ---
# IMPORTANT: Replace with your actual GCP Project ID. # IMPORTANT: Replace with your actual GCP Project ID.
GCP_PROJECT_ID= GCP_PROJECT_ID=aimaren
GCR_HOSTNAME=gcr.io GCR_HOSTNAME=gcr.io
IMAGE_NAME=hermes-crawler
IMAGE_TAG=latest 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) 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"` # This allows passing arguments to the `run` command, e.g., `make run ARGS="-debug"`
ARGS="" ARGS=""
@@ -72,4 +76,40 @@ deploy: docker-push ## ☁️ Deploy the application as a Cloud Run Job
--region="us-central1" \ --region="us-central1" \
--cpu="1" \ --cpu="1" \
--memory="512Mi" \ --memory="512Mi" \
--task-timeout="5m" --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
+92
View File
@@ -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://<your-cloudrun-url>/" https://api.telegram.org/bot<token>/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
}
+1 -1
View File
@@ -1,4 +1,4 @@
gcp_project_id: "" gcp_project_id: "aimaren"
telegram: telegram:
token: "" token: ""
chat_ids: chat_ids:
+223
View File
@@ -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
}
+3 -2
View File
@@ -10,8 +10,9 @@ import (
type Config struct { type Config struct {
GCPProjectID string `mapstructure:"gcp_project_id"` GCPProjectID string `mapstructure:"gcp_project_id"`
Telegram struct { Telegram struct {
Token string `mapstructure:"token"` EndPoint string `mapstructure:"endpoint"`
ChatIDs []int64 `mapstructure:"chat_ids"` Token string `mapstructure:"token"`
ChatIDs []int64 `mapstructure:"chat_ids"`
} `mapstructure:"telegram"` } `mapstructure:"telegram"`
} }
+2
View File
@@ -2,6 +2,8 @@ package notifier
// Notifier defines the interface for sending notifications. // Notifier defines the interface for sending notifications.
type Notifier interface { 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 sends a message to a given list of chat IDs.
Broadcast(chatIDs []int64, message string) error Broadcast(chatIDs []int64, message string) error
} }
+45 -9
View File
@@ -2,6 +2,7 @@ package storage
import ( import (
"context" "context"
"fmt"
"cloud.google.com/go/firestore" "cloud.google.com/go/firestore"
"git.pengzhan.dev/aimaren/internal/crawler" "git.pengzhan.dev/aimaren/internal/crawler"
@@ -10,10 +11,12 @@ import (
) )
const ( const (
stateCollection = "hermes_state" StateCollection = "hermes_state"
stateDocument = "main" AppStateDocument = "main"
UserStateDocument = "users"
) )
// FirestoreClient implements Storer
type FirestoreClient struct { type FirestoreClient struct {
client *firestore.Client client *firestore.Client
} }
@@ -26,11 +29,14 @@ func NewFirestoreClient(ctx context.Context, projectID string) (*FirestoreClient
return &FirestoreClient{client: client}, nil return &FirestoreClient{client: client}, nil
} }
func (fs *FirestoreClient) Close() {
fs.client.Close()
}
// FetchAppState retrieves the entire application state from a single document. // FetchAppState retrieves the entire application state from a single document.
func (fs *FirestoreClient) FetchAppState(ctx context.Context) (*AppState, error) { 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 err != nil {
// If the doc doesn't exist, return a new, empty AppState.
if status.Code(err) == codes.NotFound { if status.Code(err) == codes.NotFound {
return &AppState{ return &AppState{
Bags: make(map[string]crawler.Bag), Bags: make(map[string]crawler.Bag),
@@ -39,29 +45,59 @@ func (fs *FirestoreClient) FetchAppState(ctx context.Context) (*AppState, error)
} }
return nil, err return nil, err
} }
var state AppState var state AppState
if err := doc.DataTo(&state); err != nil { if err := doc.DataTo(&state); err != nil {
return nil, err return nil, err
} }
if state.Bags == nil {
state.Bags = make(map[string]crawler.Bag)
}
if state.ChatIDs == nil {
state.ChatIDs = []int64{}
}
return &state, nil return &state, nil
} }
// UpdateAppState writes the entire application state back to the document. // UpdateAppState writes the entire application state back to the document.
func (fs *FirestoreClient) UpdateAppState(ctx context.Context, newState *AppState) error { 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 return err
} }
// AddChatID atomically adds a new chat ID to the list in the main state document. // 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 { 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{ _, err := docRef.Update(ctx, []firestore.Update{
{Path: "chat_ids", Value: firestore.ArrayUnion(chatID)}, {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 return err
} }
func (fs *FirestoreClient) Close() { // FetchUserState retrieves the user state document and converts string keys back to int64.
fs.client.Close() 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
} }
+14
View File
@@ -9,3 +9,17 @@ type AppState struct {
Bags map[string]crawler.Bag `firestore:"bags"` Bags map[string]crawler.Bag `firestore:"bags"`
ChatIDs []int64 `firestore:"chat_ids"` 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"`
}
+2
View File
@@ -9,4 +9,6 @@ type Storer interface {
FetchAppState(ctx context.Context) (*AppState, error) FetchAppState(ctx context.Context) (*AppState, error)
UpdateAppState(ctx context.Context, newState *AppState) error UpdateAppState(ctx context.Context, newState *AppState) error
AddChatID(ctx context.Context, chatID int64) error AddChatID(ctx context.Context, chatID int64) error
FetchUserState(ctx context.Context) (*UserState, error)
UpdateUserState(ctx context.Context, newUserState *UserState) error
} }