feat: Scaffolding bot
This commit is contained in:
@@ -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"]
|
||||
@@ -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=""
|
||||
|
||||
@@ -73,3 +77,39 @@ deploy: docker-push ## ☁️ Deploy the application as a Cloud Run Job
|
||||
--cpu="1" \
|
||||
--memory="512Mi" \
|
||||
--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
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
gcp_project_id: ""
|
||||
gcp_project_id: "aimaren"
|
||||
telegram:
|
||||
token: ""
|
||||
chat_ids:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user