diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603f..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/blog.iml b/.idea/blog.iml deleted file mode 100644 index 24643cc3..00000000 --- a/.idea/blog.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index e81aaae0..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/bot-go/.gitignore b/bot-go/.gitignore deleted file mode 100644 index 2514bd4e..00000000 --- a/bot-go/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -writebot.exe -.env diff --git a/bot-go/_fix_import.py b/bot-go/_fix_import.py deleted file mode 100644 index fd984f92..00000000 --- a/bot-go/_fix_import.py +++ /dev/null @@ -1,7 +0,0 @@ -p = r"E:\GitHub\blog\bot-go\cmd\writebot\main.go" -with open(p, "r", encoding="utf-8") as f: - c = f.read() -c = c.replace('"net/http"\n', '') -with open(p, "w", encoding="utf-8", newline="\n") as f: - f.write(c) -print("fixed") diff --git a/bot-go/_fix_inline.py b/bot-go/_fix_inline.py deleted file mode 100644 index e854c14a..00000000 --- a/bot-go/_fix_inline.py +++ /dev/null @@ -1,14 +0,0 @@ -p = r"E:\GitHub\blog\bot-go\cmd\writebot\main.go" -with open(p, "r", encoding="utf-8") as f: - c = f.read() - -# Replace logutil.Init + log = logutil.Logger with inline -c = c.replace( - 'logutil.Init("writebot")\n\tlog = logutil.Logger', - 'log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writebot")' -) -c = c.replace('\t"writebot/internal/logutil"\n', '') - -with open(p, "w", encoding="utf-8", newline="\n") as f: - f.write(c) -print("Fixed bot-go inline slog") diff --git a/bot-go/_fix_nil.py b/bot-go/_fix_nil.py deleted file mode 100644 index 4a53ffc4..00000000 --- a/bot-go/_fix_nil.py +++ /dev/null @@ -1,11 +0,0 @@ -p = r"E:\GitHub\blog\bot-go\cmd\writebot\main.go" -with open(p, "r", encoding="utf-8") as f: - c = f.read() - -# Fix: move log usage after logutil.Init -c = c.replace("logutil.Init(\"writebot\")\n\tlog = logutil.Logger\n\n\tcfg := config.Load()", - "cfg := config.Load()\n\tlogutil.Init(\"writebot\")\n\tlog = logutil.Logger") - -with open(p, "w", encoding="utf-8", newline="\n") as f: - f.write(c) -print("Fixed bot-go logger init order") diff --git a/bot-go/cmd/writebot/main.go b/bot-go/cmd/writebot/main.go deleted file mode 100644 index 2a58c7d2..00000000 --- a/bot-go/cmd/writebot/main.go +++ /dev/null @@ -1,204 +0,0 @@ -package main - -import ( - "fmt" - "log/slog" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "writebot/internal/botcfg" - "writebot/internal/sessions" - "writebot/internal/tgcore" - "writebot/internal/tghandlers" - - "write-server/pkg/posts" -) - -var log *slog.Logger - -func main() { - cfg := botcfg.Load() - posts.Init() - tgcore.Init(cfg.TGToken) - - log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writebot") - log.Info("WriteBot starting") - - if cfg.TGToken == "" { - log.Error("no TG_BOT_TOKEN configured") - os.Exit(1) - } - - // Register bot - me, err := tgcore.TG("getMe", map[string]interface{}{}) - if err == nil { - if ok, _ := me["ok"].(bool); ok { - if result, ok := me["result"].(map[string]interface{}); ok { - log.Info("telegram connected", "username", tgcore.GetString(result, "username")) - } - } - } - tgcore.TG("setMyCommands", map[string]interface{}{ - "commands": []map[string]string{ - {"command": "start", "description": "启动"}, - {"command": "new", "description": "写新文章"}, - {"command": "list", "description": "文章列表"}, - {"command": "edit", "description": "编辑文章"}, - {"command": "save", "description": "保存草稿"}, - {"command": "publish", "description": "发布到 GitHub"}, - {"command": "stats", "description": "统计"}, - {"command": "help", "description": "帮助"}, - }, - }) - - h := tghandlers.New(&tghandlers.Config{ - WritePort: cfg.WritePort, - ControlPort: cfg.ControlPort, - PreferIface: cfg.PreferIface, - }) - - argCommands := map[string]bool{ - "new": true, "title": true, "categories": true, "tags": true, "author": true, - "edit": true, "view": true, "delete": true, - "link_add": true, "link_del": true, "link_toggle": true, - "feed_add": true, "feed_del": true, - "list": true, "links": true, "feeds": true, - } - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - - go func() { - var lastOffset int64 - for { - data, err := tgcore.TG("getUpdates", map[string]interface{}{ - "offset": lastOffset + 1, "timeout": 30, - "allowed_updates": []string{"message", "callback_query"}, - }) - if err != nil { - log.Warn("tg poll error", "err", err) - time.Sleep(3 * time.Second) - continue - } - ok, _ := data["ok"].(bool) - if !ok { continue } - results, _ := data["result"].([]interface{}) - for _, updateRaw := range results { - update, _ := updateRaw.(map[string]interface{}) - if updateID, ok := update["update_id"].(float64); ok { lastOffset = int64(updateID) } - - if cbRaw, ok := update["callback_query"]; ok { - cb, _ := cbRaw.(map[string]interface{}) - cbMsg, _ := cb["message"].(map[string]interface{}) - cbChat, _ := cbMsg["chat"].(map[string]interface{}) - cbFrom, _ := cb["from"].(map[string]interface{}) - var cbChatID int64 - if cbChat != nil { cbChatID = tgcore.GetInt64(cbChat, "id") } - if cbChatID == 0 && cbFrom != nil { cbChatID = tgcore.GetInt64(cbFrom, "id") } - queryID := tgcore.GetString(cb, "id") - cbData := tgcore.GetString(cb, "data") - tgcore.HandleCallback(cbChatID, queryID, cbData, h) - continue - } - - msg, _ := update["message"].(map[string]interface{}) - if msg == nil { continue } - chat, _ := msg["chat"].(map[string]interface{}) - if chat == nil { continue } - chatID := tgcore.GetInt64(chat, "id") - if !tgcore.Allowed(chatID, cfg.AllowedIDs) { continue } - - if photos, ok := msg["photo"]; ok { - photoArr, _ := photos.([]interface{}) - if len(photoArr) > 0 && sessions.HasSession(chatID) { - s := sessions.GetSession(chatID) - largest := photoArr[len(photoArr)-1].(map[string]interface{}) - fileID := tgcore.GetString(largest, "file_id") - fileRes, err := tgcore.TG("getFile", map[string]interface{}{"file_id": fileID}) - if err == nil && fileRes["ok"] == true { - if fileResult, ok := fileRes["result"].(map[string]interface{}); ok { - filePath := tgcore.GetString(fileResult, "file_path") - caption := tgcore.GetString(msg, "caption") - if caption == "" { caption = "image" } - imgURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", cfg.TGToken, filePath) - s.Content += " -![" + caption + "](" + imgURL + ") -" - } - } - } - continue - } - - text := tgcore.GetString(msg, "text") - if text == "" { continue } - fullText := strings.TrimSpace(text) - if idx := strings.Index(fullText, "@"); idx >= 0 { - afterAt := fullText[idx:] - if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 { afterAt = afterAt[spaceIdx:] } else { afterAt = "" } - fullText = strings.TrimSpace(fullText[:idx] + afterAt) - } - parts := strings.SplitN(fullText, " ", 2) - slashCmd := ""; slashArg := "" - if strings.HasPrefix(parts[0], "/") { - slashCmd = strings.ToLower(parts[0][1:]) - if len(parts) > 1 { slashArg = parts[1] } - } - if sessions.HasSession(chatID) && !strings.HasPrefix(fullText, "/") { - s := sessions.GetSession(chatID) - s.Content += fullText + " -" - continue - } - if slashCmd == "" { continue } - log.Info("tg command", "chat", chatID, "cmd", slashCmd) - handleCmd(h, chatID, slashCmd, slashArg, argCommands) - } - } - }() - - sig := <-sigCh - log.Info("shutting down", "signal", sig.String()) -} - -func handleCmd(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) { - defer func() { if r := recover(); r != nil { log.Error("handler panic", "cmd", cmd, "panic", r) } }() - if argCmds[cmd] { - switch cmd { - case "new": h.New(chatID, arg) - case "title": h.Title(chatID, arg) - case "categories": h.Categories(chatID, arg) - case "tags": h.Tags(chatID, arg) - case "author": h.Author(chatID, arg) - case "edit": h.Edit(chatID, arg) - case "view": h.View(chatID, arg) - case "delete": h.Delete(chatID, arg) - case "link_add": h.LinkAdd(chatID, arg) - case "link_del": h.LinkDel(chatID, arg) - case "link_toggle": h.LinkToggle(chatID, arg) - case "feed_add": h.FeedAdd(chatID, arg) - case "feed_del": h.FeedDel(chatID, arg) - case "list": h.List(chatID, arg) - case "links": h.Links(chatID, arg) - case "feeds": h.Feeds(chatID, arg) - default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil) - } - } else { - switch cmd { - case "start": h.Start(chatID) - case "help": h.Help(chatID) - case "save": h.Save(chatID) - case "publish": h.Publish(chatID) - case "draft": h.Draft(chatID) - case "preview": h.Preview(chatID) - case "cancel": h.Cancel(chatID) - case "stats": h.Stats(chatID) - case "status": h.Status(chatID) - case "ip": h.IP(chatID) - default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil) - } - } -} diff --git a/bot-go/config.go b/bot-go/config.go deleted file mode 100644 index 849763a6..00000000 --- a/bot-go/config.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "bufio" - "os" - "path/filepath" - "strconv" - "strings" -) - -type Config struct { - TGToken string - AllowedIDs []int64 - WritePort int - ControlPort int - PreferIface string - WriteDir string - BlogDir string // parent of WriteDir, for hugo -} - -func LoadConfig() *Config { - c := &Config{ - WritePort: 8016, - ControlPort: 8017, - PreferIface: "WLAN", - } - - // Try .env next to executable first, then fallback to cwd - exe, _ := os.Executable() - envPath := filepath.Join(filepath.Dir(exe), ".env") - if _, err := os.Stat(envPath); os.IsNotExist(err) { - envPath = ".env" - } - - f, err := os.Open(envPath) - if err != nil { - return c - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - - switch key { - case "TG_BOT_TOKEN": - c.TGToken = val - case "TG_ALLOWED_CHAT_IDS": - for _, s := range strings.Split(val, ",") { - s = strings.TrimSpace(s) - if id, err := strconv.ParseInt(s, 10, 64); err == nil { - c.AllowedIDs = append(c.AllowedIDs, id) - } - } - case "WRITE_PORT": - if p, err := strconv.Atoi(val); err == nil { - c.WritePort = p - } - case "BOT_PORT": - if p, err := strconv.Atoi(val); err == nil { - c.ControlPort = p - } - case "PREFER_IFACE": - c.PreferIface = val - } - } - - // WriteDir and BlogDir derived from executable location - exeDir := filepath.Dir(exe) - writeDir := filepath.Join(exeDir, "..", "write") - blogDir := filepath.Join(exeDir, "..") - if abs, err := filepath.Abs(writeDir); err == nil { - c.WriteDir = abs - } - if abs, err := filepath.Abs(blogDir); err == nil { - c.BlogDir = abs - } - - return c -} diff --git a/bot-go/go.mod b/bot-go/go.mod deleted file mode 100644 index 603a46b9..00000000 --- a/bot-go/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module writebot - -go 1.26.3 diff --git a/bot-go/internal/adminpanel/adminpanel.go b/bot-go/internal/adminpanel/adminpanel.go deleted file mode 100644 index 717bfba8..00000000 --- a/bot-go/internal/adminpanel/adminpanel.go +++ /dev/null @@ -1,158 +0,0 @@ -package adminpanel - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "writebot/internal/helpers" - "writebot/internal/writeserver" -) - -var ( - writePort int - controlPort int -) - -func Init(wp, cp int) { - writePort = wp - controlPort = cp -} - -const adminHTML = ` - - - - -WriteBot - - - -
-

WriteBot

-
Ctrl+D bookmark this page
- -
Loading...
-
-
-
-
-
-
-
-
-
- - - -
- - -
- - -` - -func CreateControlServer() *http.Server { - html := strings.ReplaceAll(adminHTML, "WRITE_PORT", fmt.Sprintf("%d", writePort)) - - mux := http.NewServeMux() - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - w.Header().Set("Access-Control-Allow-Origin", "*") - - switch path { - case "/": - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(html)) - case "/status": - w.Header().Set("Content-Type", "application/json") - s := writeserver.GetWriteStatus() - s["ip"] = helpers.GetLanIP("WLAN") - s["port"] = writePort - json.NewEncoder(w).Encode(s) - case "/start": - if r.Method == "POST" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(writeserver.StartWriteServer()) - } else { - http.NotFound(w, r) - } - case "/stop": - if r.Method == "POST" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"ok": writeserver.StopWriteServer()}) - } else { - http.NotFound(w, r) - } - case "/ip": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "ip": helpers.GetLanIP("WLAN"), - "port": writePort, - }) - case "/health": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"ok": true}) - default: - w.WriteHeader(http.StatusNotFound) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) - } - }) - - addr := fmt.Sprintf("127.0.0.1:%d", controlPort) - srv := &http.Server{ - Addr: addr, - Handler: mux, - } - - fmt.Printf("[control] http://%s\n", addr) - - return srv -} diff --git a/bot-go/internal/botcfg/config.go b/bot-go/internal/botcfg/config.go deleted file mode 100644 index c6bca069..00000000 --- a/bot-go/internal/botcfg/config.go +++ /dev/null @@ -1,72 +0,0 @@ -package botcfg - -import ( - "bufio" - "os" - "path/filepath" - "runtime" - "strconv" - "strings" -) - -type Config struct { - TGToken string - AllowedIDs []int64 - WritePort int - ControlPort int - PreferIface string -} - -func Load() *Config { - cfg := &Config{ - WritePort: 8016, - ControlPort: 8017, - PreferIface: "WLAN", - } - - _, filename, _, _ := runtime.Caller(0) - srcDir := filepath.Dir(filename) - rootDir := filepath.Dir(filepath.Dir(filepath.Dir(srcDir))) - - envPaths := []string{ - filepath.Join(rootDir, "bot-go", ".env"), - filepath.Join(rootDir, ".env"), - } - var f *os.File - var err error - for _, envPath := range envPaths { - f, err = os.Open(envPath) - if err == nil { break } - } - if err != nil { return cfg } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { continue } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { continue } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - if os.Getenv(key) == "" { os.Setenv(key, val) } - } - - cfg.TGToken = os.Getenv("TG_BOT_TOKEN") - allowedStr := os.Getenv("TG_ALLOWED_CHAT_IDS") - if allowedStr != "" { - for _, s := range strings.Split(allowedStr, ",") { - s = strings.TrimSpace(s) - if id, err := strconv.ParseInt(s, 10, 64); err == nil { cfg.AllowedIDs = append(cfg.AllowedIDs, id) } - } - } - if p := os.Getenv("WRITE_PORT"); p != "" { - if port, err := strconv.Atoi(p); err == nil { cfg.WritePort = port } - } - if p := os.Getenv("BOT_PORT"); p != "" { - if port, err := strconv.Atoi(p); err == nil { cfg.ControlPort = port } - } - if p := os.Getenv("PREFER_IFACE"); p != "" { cfg.PreferIface = p } - - return cfg -} diff --git a/bot-go/internal/config/config.go b/bot-go/internal/config/config.go deleted file mode 100644 index 4afaff69..00000000 --- a/bot-go/internal/config/config.go +++ /dev/null @@ -1,99 +0,0 @@ -package config - -import ( - "bufio" - "os" - "path/filepath" - "runtime" - "strconv" - "strings" -) - -type Config struct { - TGToken string - AllowedIDs []int64 - WritePort int - ControlPort int - PreferIface string - WriteDir string -} - -func Load() *Config { - cfg := &Config{ - WritePort: 8016, - ControlPort: 8017, - PreferIface: "WLAN", - } - - _, filename, _, _ := runtime.Caller(0) - srcDir := filepath.Dir(filename) - rootDir := filepath.Dir(filepath.Dir(filepath.Dir(srcDir))) - // Try multiple .env locations: bot-go/.env first, then blog/.env - envPaths := []string{ - filepath.Join(rootDir, "bot-go", ".env"), - filepath.Join(rootDir, ".env"), - filepath.Join(filepath.Dir(rootDir), "bot", ".env"), - } - var f *os.File - var err error - for _, envPath := range envPaths { - f, err = os.Open(envPath) - if err == nil { - break - } - } - if err != nil { - return cfg - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - if existing := os.Getenv(key); existing != "" { - continue - } - os.Setenv(key, val) - } - - cfg.TGToken = os.Getenv("TG_BOT_TOKEN") - allowedStr := os.Getenv("TG_ALLOWED_CHAT_IDS") - if allowedStr != "" { - for _, s := range strings.Split(allowedStr, ",") { - s = strings.TrimSpace(s) - if s == "" { - continue - } - if id, err := strconv.ParseInt(s, 10, 64); err == nil { - cfg.AllowedIDs = append(cfg.AllowedIDs, id) - } - } - } - - if p := os.Getenv("WRITE_PORT"); p != "" { - if port, err := strconv.Atoi(p); err == nil { - cfg.WritePort = port - } - } - if p := os.Getenv("BOT_PORT"); p != "" { - if port, err := strconv.Atoi(p); err == nil { - cfg.ControlPort = port - } - } - if p := os.Getenv("PREFER_IFACE"); p != "" { - cfg.PreferIface = p - } - - exe, _ := os.Executable(); exeDir := filepath.Dir(exe); cfg.WriteDir = filepath.Join(filepath.Dir(exeDir), "write-go"); if _, err := os.Stat(cfg.WriteDir); os.IsNotExist(err) { cfg.WriteDir = filepath.Join(filepath.Dir(rootDir), "write-go") } - - return cfg -} diff --git a/bot-go/internal/helpers/helpers.go b/bot-go/internal/helpers/helpers.go deleted file mode 100644 index 5a9d48e2..00000000 --- a/bot-go/internal/helpers/helpers.go +++ /dev/null @@ -1,159 +0,0 @@ -package helpers - -import ( - "fmt" - "net" - "strings" - "time" -) - -var lanIPCache struct { - ip string - ts time.Time -} - -func GetLanIP(preferIface string) string { - if lanIPCache.ip != "" && time.Since(lanIPCache.ts) < 30*time.Second { - return lanIPCache.ip - } - - ip := "0.0.0.0" - ifaces, err := net.Interfaces() - if err != nil { - lanIPCache.ip = ip - lanIPCache.ts = time.Now() - return ip - } - - type candidate struct { - name string - address string - } - var candidates []candidate - - for _, iface := range ifaces { - addrs, err := iface.Addrs() - if err != nil { - continue - } - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - ip4 := ipNet.IP.To4() - if ip4 == nil { - continue - } - if ip4.IsLoopback() { - continue - } - ipStr := ip4.String() - if strings.HasPrefix(ipStr, "169.254.") { - continue - } - candidates = append(candidates, candidate{name: iface.Name, address: ipStr}) - } - } - - prefLower := strings.ToLower(preferIface) - for _, c := range candidates { - if strings.Contains(strings.ToLower(c.name), prefLower) { - ip = c.address - lanIPCache.ip = ip - lanIPCache.ts = time.Now() - return ip - } - } - - for _, c := range candidates { - n := strings.ToLower(c.name) - if !strings.Contains(n, "vmware") && !strings.Contains(n, "virtual") && !strings.Contains(n, "vethernet") { - ip = c.address - lanIPCache.ip = ip - lanIPCache.ts = time.Now() - return ip - } - } - - if len(candidates) > 0 { - ip = candidates[0].address - } - - lanIPCache.ip = ip - lanIPCache.ts = time.Now() - return ip -} - -func CheckPort(port int) bool { - conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 1500*time.Millisecond) - if err != nil { - return false - } - conn.Close() - return true -} - -func WaitForReady(port int, timeoutMs int) bool { - if timeoutMs <= 0 { - timeoutMs = 30000 - } - deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) - for time.Now().Before(deadline) { - if CheckPort(port) { - return true - } - time.Sleep(1500 * time.Millisecond) - } - return false -} - -func MakeSlug(title string) string { - dateStr := time.Now().Format("2006-01-02") - slugPart := title - // Replace non-word, non-CJK chars with - - slugPart = strings.Map(func(r rune) rune { - if (r >= 0x4e00 && r <= 0x9fff) || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { - return r - } - return '-' - }, slugPart) - slugPart = strings.Trim(slugPart, "-") - slugPart = strings.ToLower(slugPart) - if slugPart == "" { - slugPart = "post" - } - return dateStr + "-" + slugPart -} - -func SessionSummary(title string, categories, tags []string, content string) string { - lines := 0 - chars := len([]rune(content)) - for _, ch := range content { - if ch == '\n' { - lines++ - } - } - if content != "" { - lines++ - } - - displayTitle := title - if displayTitle == "" { - displayTitle = "未命名" - } - - parts := []string{ - fmt.Sprintf("📝 %s", displayTitle), - } - if len(categories) > 0 { - parts = append(parts, "📂 "+strings.Join(categories, "、")) - } - if len(tags) > 0 { - parts = append(parts, "🏷 "+strings.Join(tags, "、")) - } - if chars > 0 { - parts = append(parts, fmt.Sprintf("✍ %d 行 %d 字", lines, chars)) - } - return strings.Join(parts, " · ") -} diff --git a/bot-go/internal/logutil/logutil.go b/bot-go/internal/logutil/logutil.go deleted file mode 100644 index 3a953922..00000000 --- a/bot-go/internal/logutil/logutil.go +++ /dev/null @@ -1,14 +0,0 @@ -package logutil - -import ( - "log/slog" - "os" -) - -var Logger *slog.Logger - -func Init(service string) { - Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })).With("svc", service) -} diff --git a/bot-go/internal/sessions/sessions.go b/bot-go/internal/sessions/sessions.go deleted file mode 100644 index 715b3afd..00000000 --- a/bot-go/internal/sessions/sessions.go +++ /dev/null @@ -1,71 +0,0 @@ -package sessions - -import ( - "sync" - - "write-server/pkg/posts" -) - -type Session struct { - Title string `json:"title"` - Slug string `json:"slug"` - Content string `json:"content"` - Categories []string `json:"categories"` - Tags []string `json:"tags"` - Author string `json:"author"` - Draft bool `json:"draft"` -} - -var mu sync.Mutex -var sessions = make(map[int64]*Session) - -func GetSession(chatID int64) *Session { - mu.Lock() - defer mu.Unlock() - if s, ok := sessions[chatID]; ok { - return s - } - s := &Session{Author: "qunlin", Draft: true} - sessions[chatID] = s - return s -} - -func ClearSession(chatID int64) { - mu.Lock() - defer mu.Unlock() - delete(sessions, chatID) -} - -func HasSession(chatID int64) bool { - mu.Lock() - defer mu.Unlock() - _, ok := sessions[chatID] - return ok -} - -func Save(s *Session, isDraft bool) error { - fm := posts.FrontMatter{ - Title: s.Title, - Slug: s.Slug, - Draft: isDraft, - Categories: s.Categories, - Tags: s.Tags, - Author: s.Author, - Layout: "post", - } - - existing := posts.GetPost(s.Slug) - if existing != nil { - updates := map[string]interface{}{} - updates["title"] = s.Title - updates["draft"] = isDraft - updates["categories"] = s.Categories - updates["tags"] = s.Tags - updates["author"] = s.Author - _, err := posts.UpdatePost(s.Slug, updates, &s.Content) - return err - } - - _, err := posts.CreatePost(fm, s.Content) - return err -} diff --git a/bot-go/internal/tgcore/tgcore.go b/bot-go/internal/tgcore/tgcore.go deleted file mode 100644 index fd9853ad..00000000 --- a/bot-go/internal/tgcore/tgcore.go +++ /dev/null @@ -1,301 +0,0 @@ -package tgcore - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "strings" - "time" -) - -var API string - -func Init(token string) { - API = "https://api.telegram.org/bot" + token -} - -func TG(method string, body interface{}) (map[string]interface{}, error) { - jsonBody, err := json.Marshal(body) - if err != nil { - return nil, err - } - resp, err := http.Post(API+"/"+method, "application/json", bytes.NewReader(jsonBody)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - return nil, err - } - return result, nil -} - -func Allowed(chatID int64, allowedIDs []int64) bool { - if len(allowedIDs) == 0 { - return true - } - for _, id := range allowedIDs { - if id == chatID { - return true - } - } - return false -} - -func SendMessage(chatID int64, text string, opts map[string]interface{}) error { - safeText := text - body := map[string]interface{}{ - "chat_id": chatID, - "text": safeText, - "parse_mode": "HTML", - "disable_web_page_preview": true, - } - if opts != nil { - for k, v := range opts { - body[k] = v - } - } - - _, err := TG("sendMessage", body) - if err != nil && (strings.Contains(err.Error(), "parse") || strings.Contains(err.Error(), "HTML")) { - delete(body, "parse_mode") - _, err = TG("sendMessage", body) - } - return err -} - -// TgHandler is the callback handler interface -type TgHandler interface { - List(chatID int64, page string) - Links(chatID int64, page string) - Feeds(chatID int64, page string) - // Session callbacks - SessionCallback(chatID int64, action string) - MenuCallback(chatID int64, action string) -} - -func HandleCallback(chatID int64, queryID string, data string, h TgHandler) { - if data == "" { - return - } - - // Session callbacks - if strings.HasPrefix(data, "session_") { - action := strings.TrimPrefix(data, "session_") - if h != nil { - h.SessionCallback(chatID, action) - } - TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID}) - return - } - - // Menu callbacks - if strings.HasPrefix(data, "menu_") { - action := strings.TrimPrefix(data, "menu_") - if h != nil { - h.MenuCallback(chatID, action) - } - TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID}) - return - } - - // Pagination callbacks: list_N, links_N, feeds_N - parts := strings.SplitN(data, "_", 2) - if len(parts) == 2 { - cmd := parts[0] - page := parts[1] - if h != nil { - switch cmd { - case "list": - h.List(chatID, page) - case "links": - h.Links(chatID, page) - case "feeds": - h.Feeds(chatID, page) - } - } - } - TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID}) -} - -func GetInt64(m map[string]interface{}, key string) int64 { - if v, ok := m[key]; ok { - switch n := v.(type) { - case float64: - return int64(n) - case int64: - return n - } - } - return 0 -} - -func GetString(m map[string]interface{}, key string) string { - if v, ok := m[key]; ok { - if s, ok := v.(string); ok { - return s - } - } - return "" -} - -// === Inlined from helpers === - -var lanIPCache struct { - ip string - ts time.Time -} - -func GetLanIP(preferIface string) string { - if lanIPCache.ip != "" && time.Since(lanIPCache.ts) < 30*time.Second { - return lanIPCache.ip - } - - ip := "0.0.0.0" - ifaces, err := net.Interfaces() - if err != nil { - lanIPCache.ip = ip - lanIPCache.ts = time.Now() - return ip - } - - type candidate struct { - name string - address string - } - var candidates []candidate - - for _, iface := range ifaces { - addrs, err := iface.Addrs() - if err != nil { - continue - } - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - ip4 := ipNet.IP.To4() - if ip4 == nil { - continue - } - if ip4.IsLoopback() { - continue - } - ipStr := ip4.String() - if strings.HasPrefix(ipStr, "169.254.") { - continue - } - candidates = append(candidates, candidate{name: iface.Name, address: ipStr}) - } - } - - prefLower := strings.ToLower(preferIface) - for _, c := range candidates { - if strings.Contains(strings.ToLower(c.name), prefLower) { - ip = c.address - lanIPCache.ip = ip - lanIPCache.ts = time.Now() - return ip - } - } - - for _, c := range candidates { - n := strings.ToLower(c.name) - if !strings.Contains(n, "vmware") && !strings.Contains(n, "virtual") && !strings.Contains(n, "vethernet") { - ip = c.address - lanIPCache.ip = ip - lanIPCache.ts = time.Now() - return ip - } - } - - if len(candidates) > 0 { - ip = candidates[0].address - } - - lanIPCache.ip = ip - lanIPCache.ts = time.Now() - return ip -} - -func CheckPort(port int) bool { - conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 1500*time.Millisecond) - if err != nil { - return false - } - conn.Close() - return true -} - -func WaitForReady(port int, timeoutMs int) bool { - if timeoutMs <= 0 { - timeoutMs = 30000 - } - deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) - for time.Now().Before(deadline) { - if CheckPort(port) { - return true - } - time.Sleep(1500 * time.Millisecond) - } - return false -} - -func MakeSlug(title string) string { - dateStr := time.Now().Format("2006-01-02") - slugPart := title - // Replace non-word, non-CJK chars with - - slugPart = strings.Map(func(r rune) rune { - if (r >= 0x4e00 && r <= 0x9fff) || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { - return r - } - return '-' - }, slugPart) - slugPart = strings.Trim(slugPart, "-") - slugPart = strings.ToLower(slugPart) - if slugPart == "" { - slugPart = "post" - } - return dateStr + "-" + slugPart -} - -func SessionSummary(title string, categories, tags []string, content string) string { - lines := 0 - chars := len([]rune(content)) - for _, ch := range content { - if ch == '\n' { - lines++ - } - } - if content != "" { - lines++ - } - - displayTitle := title - if displayTitle == "" { - displayTitle = "未命名" - } - - parts := []string{ - fmt.Sprintf("📝 %s", displayTitle), - } - if len(categories) > 0 { - parts = append(parts, "📂 "+strings.Join(categories, "、")) - } - if len(tags) > 0 { - parts = append(parts, "🏷 "+strings.Join(tags, "、")) - } - if chars > 0 { - parts = append(parts, fmt.Sprintf("✍ %d 行 %d 字", lines, chars)) - } - return strings.Join(parts, " · ") -} diff --git a/bot-go/internal/tghandlers/tghandlers.go b/bot-go/internal/tghandlers/tghandlers.go deleted file mode 100644 index 2694926c..00000000 --- a/bot-go/internal/tghandlers/tghandlers.go +++ /dev/null @@ -1,622 +0,0 @@ -package tghandlers - -import ( - "fmt" - "math" - "strconv" - "strings" - - "write-server/internal/helpers" - "write-server/internal/sessions" - "write-server/internal/tgcore" - "write-server/internal/writeserver" - - "write-server/pkg/posts" - "write-server/pkg/rssapi" - "write-server/pkg/recycle" -) - -type Handlers struct { - cfg *Config -} - -type Config struct { - WritePort int - ControlPort int - PreferIface string -} - -func New(cfg *Config) *Handlers { - return &Handlers{cfg: cfg} -} - -func (h *Handlers) dispatchList(chatID int64, pageStr string) error { - page, _ := strconv.Atoi(pageStr) - if page < 1 { page = 1 } - perPage := 5 - - list, total, _ := posts.ListPosts("", page, 9999, true) - if len(list) == 0 { - return tgcore.SendMessage(chatID, "📭 暂无文章。", nil) - } - - totalPages := int(math.Ceil(float64(total) / float64(perPage))) - if page > totalPages { page = totalPages } - - start := (page - 1) * perPage - end := start + perPage - if end > len(list) { end = len(list) } - pageItems := list[start:end] - - header := fmt.Sprintf("📋 文章列表 共%d篇 · 第%d/%d页", total, page, totalPages) - lines := []string{header} - for _, p := range pageItems { - emoji := "✅" - if p.Draft { emoji = "📝" } - date := "" - if len(p.Date) >= 10 { date = p.Date[:10] } - lines = append(lines, fmt.Sprintf("%s %s /edit_%s %s", emoji, p.Title, p.Slug, date)) - } - - kb := map[string]interface{}{} - var nav []map[string]interface{} - if page > 1 { - nav = append(nav, map[string]interface{}{"text": "◀ 上一页", "callback_data": fmt.Sprintf("list_%d", page-1)}) - } - if page < totalPages { - nav = append(nav, map[string]interface{}{"text": "下一页 ▶", "callback_data": fmt.Sprintf("list_%d", page+1)}) - } - if len(nav) > 0 { - kb["inline_keyboard"] = []interface{}{nav} - tgcore.TG("sendMessage", map[string]interface{}{ - "chat_id": chatID, - "text": strings.Join(lines, "\n"), - "parse_mode": "HTML", - "reply_markup": kb, - "disable_web_page_preview": true, - }) - return nil - } - return tgcore.SendMessage(chatID, strings.Join(lines, "\n"), nil) -} - -func (h *Handlers) List(chatID int64, page string) { h.dispatchList(chatID, page) } - - -func (h *Handlers) Start(chatID int64) { - ip := helpers.GetLanIP(h.cfg.PreferIface) - ready := EnsureWriteReady(chatID, h.cfg.WritePort) - if ready { - kb := map[string]interface{}{ - "inline_keyboard": []interface{}{ - []map[string]interface{}{ - {"text": "📝 开始写作", "callback_data": "menu_new"}, - {"text": "📋 文章列表", "callback_data": "list_1"}, - }, - []map[string]interface{}{ - {"text": "🔗 友链管理", "callback_data": "links_1"}, - {"text": "📡 订阅源", "callback_data": "feeds_1"}, - }, - []map[string]interface{}{ - {"text": "📊 网站数据", "callback_data": "menu_stats"}, - {"text": "ℹ️ 帮助", "callback_data": "menu_help"}, - }, - }, - } - tgcore.TG("sendMessage", map[string]interface{}{ - "chat_id": chatID, - "text": fmt.Sprintf("✅ Write 已就绪\n\n📱 手机: http://%s:%d/\n💻 电脑: http://localhost:%d/\n📰 文章: http://%s:1313/posts/", ip, h.cfg.WritePort, h.cfg.WritePort, ip), - "parse_mode": "HTML", - "reply_markup": kb, - "disable_web_page_preview": true, - }) - return - } - tgcore.SendMessage(chatID, "❌ 启动超时,请查看控制台日志。", nil) -} - -func (h *Handlers) Stop(chatID int64) { - if writeserver.StopWriteServer() { - tgcore.SendMessage(chatID, "⚡ Write 已停止。", nil) - } else { - tgcore.SendMessage(chatID, "⏸ Write 未运行。", nil) - } -} - -func (h *Handlers) Status(chatID int64) { - s := writeserver.GetWriteStatus() - status := "⏸ 未运行" - if s["running"].(bool) { status = "✅ 运行中" } - if s["starting"].(bool) { status = "⏳ 启动中..." } - lines := []string{"Write 状态: " + status} - if s["running"].(bool) { - ip := helpers.GetLanIP(h.cfg.PreferIface) - lines = append(lines, fmt.Sprintf("🌐 局域网: http://%s:%d", ip, h.cfg.WritePort)) - lines = append(lines, fmt.Sprintf("💻 本地: http://localhost:%d", h.cfg.WritePort)) - } - tgcore.SendMessage(chatID, strings.Join(lines, "\n"), nil) -} - -func (h *Handlers) IP(chatID int64) { - ip := helpers.GetLanIP(h.cfg.PreferIface) - tgcore.SendMessage(chatID, fmt.Sprintf("📱 手机访问: http://%s:%d\n💻 电脑访问: http://localhost:%d", ip, h.cfg.WritePort, h.cfg.WritePort), nil) -} - -func (h *Handlers) Help(chatID int64) { - kb := map[string]interface{}{ - "inline_keyboard": []interface{}{ - []map[string]interface{}{{"text": "📝 开始写作", "callback_data": "menu_new"}}, - []map[string]interface{}{{"text": "📋 文章列表", "callback_data": "list_1"}}, - []map[string]interface{}{{"text": "🔗 友链", "callback_data": "links_1"}, {"text": "📡 订阅", "callback_data": "feeds_1"}}, - }, - } - tgcore.TG("sendMessage", map[string]interface{}{ - "chat_id": chatID, - "text": "🤖 WriteBot 命令\n\n📝 /new 标题 — 开始写作\n✏️ /edit slug — 编辑文章\n📋 /list — 文章列表\n🔍 /view slug — 查看文章\n🗑 /delete slug — 删除文章\n\n✍️ 写作中:\n/publish — 发布\n/draft — 存草稿\n/preview — 预览\n/cancel — 丢弃\n\n🔗 友链 /links\n📡 订阅 /feeds\n📊 /stats 统计", - "parse_mode": "HTML", - "reply_markup": kb, - "disable_web_page_preview": true, - }) -} - - -// --- Writing commands --- - -func (h *Handlers) New(chatID int64, title string) { - if title == "" { - tgcore.SendMessage(chatID, "📝 请提供标题:/new 你的标题", nil) - return - } - s := sessions.GetSession(chatID) - s.Title = title - s.Slug = helpers.MakeSlug(title) - s.Content = "" - s.Categories = nil - s.Tags = nil - s.Draft = true - s.Author = "qunlin" - - kb := map[string]interface{}{ - "inline_keyboard": []interface{}{ - []map[string]interface{}{ - {"text": "📂 分类", "callback_data": "session_categories"}, - {"text": "🏷 标签", "callback_data": "session_tags"}, - }, - []map[string]interface{}{ - {"text": "🚀 发布", "callback_data": "session_publish"}, - {"text": "📄 存草稿", "callback_data": "session_draft"}, - }, - []map[string]interface{}{ - {"text": "🔍 预览", "callback_data": "session_preview"}, - {"text": "❌ 丢弃", "callback_data": "session_cancel"}, - }, - }, - } - tgcore.TG("sendMessage", map[string]interface{}{ - "chat_id": chatID, - "text": fmt.Sprintf("📝 %s\n\n直接发消息开始写作\n用下方按钮管理文章属性", title), - "parse_mode": "HTML", - "reply_markup": kb, - }) -} - -func (h *Handlers) Edit(chatID int64, slug string) { - if slug == "" { - tgcore.SendMessage(chatID, "✏️ 用法:/edit slug\n先用 /list 查看 slug。", nil) - return - } - p := posts.GetPost(slug) - if p == nil { - tgcore.SendMessage(chatID, "❌ 未找到该文章。", nil) - return - } - - s := sessions.GetSession(chatID) - s.Title = p.FrontMatter.Title - s.Slug = p.FrontMatter.Slug - s.Content = p.Content - s.Categories = p.FrontMatter.Categories - s.Tags = p.FrontMatter.Tags - s.Author = p.FrontMatter.Author - if s.Author == "" { s.Author = "qunlin" } - s.Draft = p.FrontMatter.Draft - - summary := helpers.SessionSummary(s.Title, s.Categories, s.Tags, s.Content) - kb := map[string]interface{}{ - "inline_keyboard": []interface{}{ - []map[string]interface{}{ - {"text": "📂 分类", "callback_data": "session_categories"}, - {"text": "🏷 标签", "callback_data": "session_tags"}, - }, - []map[string]interface{}{ - {"text": "🚀 发布", "callback_data": "session_publish"}, - {"text": "📄 存草稿", "callback_data": "session_draft"}, - }, - []map[string]interface{}{ - {"text": "🔍 预览", "callback_data": "session_preview"}, - {"text": "❌ 丢弃", "callback_data": "session_cancel"}, - }, - }, - } - tgcore.TG("sendMessage", map[string]interface{}{ - "chat_id": chatID, - "text": fmt.Sprintf("✏️ %s\n\n%s\n\n直接发消息继续写作", s.Title, summary), - "parse_mode": "HTML", - "reply_markup": kb, - }) -} - -func (h *Handlers) Delete(chatID int64, slug string) { - if slug == "" { - tgcore.SendMessage(chatID, "🗑 用法:/delete slug\n先用 /list 查看。", nil) - return - } - p := posts.GetPost(slug) - if p == nil { - tgcore.SendMessage(chatID, "❌ 未找到该文章。", nil) - return - } - if err := recycle.MoveToRecycle(slug, p.DirPath, p.FrontMatter.Title); err != nil { - tgcore.SendMessage(chatID, "❌ "+err.Error(), nil) - return - } - tgcore.SendMessage(chatID, "🗑 "+p.FrontMatter.Title+" 已移入回收站(30天后自动清除)。", nil) -} - -func (h *Handlers) View(chatID int64, slug string) { - if slug == "" { - tgcore.SendMessage(chatID, "🔍 用法:/view slug", nil) - return - } - p := posts.GetPost(slug) - if p == nil { - tgcore.SendMessage(chatID, "❌ 未找到该文章。", nil) - return - } - draftMark := "✅" - if p.FrontMatter.Draft { draftMark = "📝" } - header := fmt.Sprintf("%s %s\n📂 %s 🏷 %s ✍️ %s 📅 %s", - draftMark, p.FrontMatter.Title, - strings.Join(p.FrontMatter.Categories, "、"), - strings.Join(p.FrontMatter.Tags, "、"), - p.FrontMatter.Author, - p.FrontMatter.Date[:10]) - - content := p.Content - if len([]rune(content)) > 3500 { - content = string([]rune(content)[:3500]) + "\n\n..." - } - tgcore.SendMessage(chatID, header+"\n\n"+content, nil) -} - - -func (h *Handlers) Title(chatID int64, arg string) { - s := sessions.GetSession(chatID) - if arg == "" { - tgcore.SendMessage(chatID, fmt.Sprintf("当前标题:%s\n修改:/title 新标题", s.Title), nil) - return - } - s.Title = arg - tgcore.SendMessage(chatID, "✅ 标题已更新:"+arg+"", nil) -} - -func (h *Handlers) Categories(chatID int64, arg string) { - s := sessions.GetSession(chatID) - if arg == "" { - tgcore.SendMessage(chatID, fmt.Sprintf("当前分类:%s\n修改:/categories 分类1,分类2", strings.Join(s.Categories, "、")), nil) - return - } - s.Categories = nil - for _, item := range strings.Split(arg, ",") { - item = strings.TrimSpace(item) - if item != "" { s.Categories = append(s.Categories, item) } - } - tgcore.SendMessage(chatID, "✅ 分类:"+strings.Join(s.Categories, "、")+"", nil) -} - -func (h *Handlers) Tags(chatID int64, arg string) { - s := sessions.GetSession(chatID) - if arg == "" { - tgcore.SendMessage(chatID, fmt.Sprintf("当前标签:%s\n修改:/tags 标签1,标签2", strings.Join(s.Tags, "、")), nil) - return - } - s.Tags = nil - for _, item := range strings.Split(arg, ",") { - item = strings.TrimSpace(item) - if item != "" { s.Tags = append(s.Tags, item) } - } - tgcore.SendMessage(chatID, "✅ 标签:"+strings.Join(s.Tags, "、")+"", nil) -} - -func (h *Handlers) Author(chatID int64, arg string) { - s := sessions.GetSession(chatID) - if arg == "" { - tgcore.SendMessage(chatID, fmt.Sprintf("当前作者:%s\n修改:/author 新作者", s.Author), nil) - return - } - s.Author = arg - tgcore.SendMessage(chatID, "✅ 作者:"+arg+"", nil) -} - -func (h *Handlers) Publish(chatID int64) { - s := sessions.GetSession(chatID) - if s.Title == "" { - tgcore.SendMessage(chatID, "❌ 请先 /new 标题 开始写作。", nil) - return - } - if err := sessions.Save(s, false); err != nil { - tgcore.SendMessage(chatID, "❌ "+err.Error(), nil) - return - } - sessions.ClearSession(chatID) - tgcore.SendMessage(chatID, "🚀 "+s.Title+" 已发布!", nil) -} - -func (h *Handlers) Draft(chatID int64) { - s := sessions.GetSession(chatID) - if s.Title == "" { - tgcore.SendMessage(chatID, "❌ 请先 /new 标题 开始写作。", nil) - return - } - if err := sessions.Save(s, true); err != nil { - tgcore.SendMessage(chatID, "❌ "+err.Error(), nil) - return - } - sessions.ClearSession(chatID) - tgcore.SendMessage(chatID, "📄 "+s.Title+" 已存为草稿。", nil) -} - -func (h *Handlers) Preview(chatID int64) { - s := sessions.GetSession(chatID) - if s.Title == "" { - tgcore.SendMessage(chatID, "❌ 请先 /new 标题 开始写作。", nil) - return - } - summary := helpers.SessionSummary(s.Title, s.Categories, s.Tags, s.Content) - preview := s.Content - if len([]rune(preview)) > 3500 { - preview = string([]rune(preview)[:3500]) + "\n\n... (内容过长)" - } - tgcore.SendMessage(chatID, summary+"\n\n"+preview, nil) -} - -func (h *Handlers) Cancel(chatID int64) { - sessions.ClearSession(chatID) - tgcore.SendMessage(chatID, "🗑 已丢弃当前写作内容。", nil) -} - - -// --- Links --- - -func (h *Handlers) Links(chatID int64, page string) { h.dispatchLinks(chatID, page) } - -func (h *Handlers) dispatchLinks(chatID int64, pageStr string) error { - page, _ := strconv.Atoi(pageStr) - if page < 1 { page = 1 } - perPage := 8 - - links, err := rssapi.GetLinks(true) - if err != nil || len(links) == 0 { - return tgcore.SendMessage(chatID, "🔗 暂无友链。", nil) - } - - totalPages := int(math.Ceil(float64(len(links)) / float64(perPage))) - if page > totalPages { page = totalPages } - start := (page - 1) * perPage - end := start + perPage - if end > len(links) { end = len(links) } - - lines := []string{fmt.Sprintf("🔗 友链 共%d个 · 第%d/%d页", len(links), page, totalPages)} - for _, l := range links[start:end] { - icon := "🌐" - if l.Hidden { icon = "🔒" } - lines = append(lines, fmt.Sprintf("%s %s\n %s", icon, l.Name, l.URL)) - } - - kb := map[string]interface{}{} - var nav []map[string]interface{} - if page > 1 { - nav = append(nav, map[string]interface{}{"text": "◀", "callback_data": fmt.Sprintf("links_%d", page-1)}) - } - if page < totalPages { - nav = append(nav, map[string]interface{}{"text": "▶", "callback_data": fmt.Sprintf("links_%d", page+1)}) - } - if len(nav) > 0 { - kb["inline_keyboard"] = []interface{}{nav} - tgcore.TG("sendMessage", map[string]interface{}{ - "chat_id": chatID, "text": strings.Join(lines, "\n"), - "parse_mode": "HTML", "reply_markup": kb, "disable_web_page_preview": true, - }) - return nil - } - return tgcore.SendMessage(chatID, strings.Join(lines, "\n"), nil) -} - -func (h *Handlers) LinkAdd(chatID int64, arg string) { - if arg == "" { - tgcore.SendMessage(chatID, "用法:/link_add url 名称", nil) - return - } - parts := strings.SplitN(arg, " ", 2) - url := parts[0] - name := "" - if len(parts) > 1 { name = parts[1] } - if err := rssapi.AddLink(rssapi.Link{URL: url, Name: name}); err != nil { - tgcore.SendMessage(chatID, "❌ "+err.Error(), nil) - return - } - tgcore.SendMessage(chatID, "✅ 友链已添加:"+url, nil) -} - -func (h *Handlers) LinkDel(chatID int64, url string) { - if url == "" { tgcore.SendMessage(chatID, "用法:/link_del url", nil); return } - if err := rssapi.DeleteLink(url); err != nil { - tgcore.SendMessage(chatID, "❌ "+err.Error(), nil) - return - } - tgcore.SendMessage(chatID, "✅ 友链已删除:"+url, nil) -} - -func (h *Handlers) LinkToggle(chatID int64, url string) { - if url == "" { tgcore.SendMessage(chatID, "用法:/link_toggle url", nil); return } - links, _ := rssapi.GetLinks(true) - var target *rssapi.Link - for i := range links { - if links[i].URL == url { target = &links[i]; break } - } - if target == nil { tgcore.SendMessage(chatID, "❌ 未找到该友链。", nil); return } - - newHidden := !target.Hidden - rssapi.UpdateLink(url, map[string]interface{}{"hidden": newHidden}) - label := "🌐 已显示" - if newHidden { label = "🔒 已隐藏" } - tgcore.SendMessage(chatID, label+": "+url, nil) -} - -// --- Feeds --- - -func (h *Handlers) Feeds(chatID int64, page string) { h.dispatchFeeds(chatID, page) } - -func (h *Handlers) dispatchFeeds(chatID int64, pageStr string) error { - page, _ := strconv.Atoi(pageStr) - if page < 1 { page = 1 } - perPage := 8 - - feeds, err := rssapi.GetFeeds() - if err != nil || len(feeds) == 0 { - return tgcore.SendMessage(chatID, "📡 暂无订阅源。", nil) - } - - totalPages := int(math.Ceil(float64(len(feeds)) / float64(perPage))) - if page > totalPages { page = totalPages } - start := (page - 1) * perPage - end := start + perPage - if end > len(feeds) { end = len(feeds) } - - lines := []string{fmt.Sprintf("📡 订阅源 共%d个 · 第%d/%d页", len(feeds), page, totalPages)} - for _, f := range feeds[start:end] { - title := f.FeedTitle - if title == "" { title = f.URL } - if len([]rune(title)) > 50 { title = string([]rune(title)[:50]) } - lines = append(lines, fmt.Sprintf("🗜 %s\n %s", title, f.URL)) - } - - kb := map[string]interface{}{} - var nav []map[string]interface{} - if page > 1 { - nav = append(nav, map[string]interface{}{"text": "◀", "callback_data": fmt.Sprintf("feeds_%d", page-1)}) - } - if page < totalPages { - nav = append(nav, map[string]interface{}{"text": "▶", "callback_data": fmt.Sprintf("feeds_%d", page+1)}) - } - if len(nav) > 0 { - kb["inline_keyboard"] = []interface{}{nav} - tgcore.TG("sendMessage", map[string]interface{}{ - "chat_id": chatID, "text": strings.Join(lines, "\n"), - "parse_mode": "HTML", "reply_markup": kb, "disable_web_page_preview": true, - }) - return nil - } - return tgcore.SendMessage(chatID, strings.Join(lines, "\n"), nil) -} - -func (h *Handlers) FeedAdd(chatID int64, arg string) { - if arg == "" { tgcore.SendMessage(chatID, "用法:/feed_add url", nil); return } - parts := strings.SplitN(arg, " ", 2) - url := parts[0] - title := "" - if len(parts) > 1 { title = parts[1] } - if err := rssapi.AddFeed(url, title); err != nil { - tgcore.SendMessage(chatID, "❌ "+err.Error(), nil) - return - } - tgcore.SendMessage(chatID, "✅ 订阅源已添加:"+url, nil) -} - -func (h *Handlers) FeedDel(chatID int64, url string) { - if url == "" { tgcore.SendMessage(chatID, "用法:/feed_del url", nil); return } - if err := rssapi.DeleteFeed(url); err != nil { - tgcore.SendMessage(chatID, "❌ "+err.Error(), nil) - return - } - tgcore.SendMessage(chatID, "✅ 订阅源已删除:"+url, nil) -} - -// --- Stats --- - -func (h *Handlers) Stats(chatID int64) { - postCount := posts.GetPostCount() - linkCount := 0 - feedCount := 0 - if links, err := rssapi.GetLinks(true); err == nil { linkCount = len(links) } - if feeds, err := rssapi.GetFeeds(); err == nil { feedCount = len(feeds) } - - tgcore.SendMessage(chatID, fmt.Sprintf("📊 网站数据\n\n📝 文章:%d 篇\n🔗 友链:%d 个\n📡 订阅源:%d 个\n\n🌐 usj.cc", postCount, linkCount, feedCount), nil) -} - - -// --- EnsureWriteReady --- - -func EnsureWriteReady(chatID int64, writePort int) bool { - s := writeserver.GetWriteStatus() - running, _ := s["running"].(bool) - starting, _ := s["starting"].(bool) - - if running { - writeserver.StartHugoIfNeeded() - return true - } - if starting { - tgcore.SendMessage(chatID, "⌛ Write 正在启动...", nil) - if helpers.WaitForReady(writePort, 0) { - writeserver.SetWriteReady(true) - writeserver.StartHugoIfNeeded() - return true - } - return false - } - - result := writeserver.StartWriteServer() - if started, ok := result["started"].(bool); !ok || !started { - return false - } - tgcore.SendMessage(chatID, "⌛ 正在启动 Write...", nil) - if helpers.WaitForReady(writePort, 0) { - writeserver.SetWriteReady(true) - writeserver.StartHugoIfNeeded() - return true - } - return false -} - -// --- Callback handlers for inline buttons --- - -func (h *Handlers) SessionCallback(chatID int64, action string) { - switch action { - case "categories": - s := sessions.GetSession(chatID) - tgcore.SendMessage(chatID, fmt.Sprintf("📂 当前分类:%s\n\n请发送新的分类(逗号分隔):", strings.Join(s.Categories, "、")), nil) - case "tags": - s := sessions.GetSession(chatID) - tgcore.SendMessage(chatID, fmt.Sprintf("🏷 当前标签:%s\n\n请发送新的标签(逗号分隔):", strings.Join(s.Tags, "、")), nil) - case "publish": - h.Publish(chatID) - case "draft": - h.Draft(chatID) - case "preview": - h.Preview(chatID) - case "cancel": - h.Cancel(chatID) - } -} - -func (h *Handlers) MenuCallback(chatID int64, action string) { - switch action { - case "new": - tgcore.SendMessage(chatID, "📝 请输入:/new 文章标题", nil) - case "help": - h.Help(chatID) - case "stats": - h.Stats(chatID) - } -} diff --git a/bot-go/internal/writeserver/writeserver.go b/bot-go/internal/writeserver/writeserver.go deleted file mode 100644 index 2fb8aa51..00000000 --- a/bot-go/internal/writeserver/writeserver.go +++ /dev/null @@ -1,156 +0,0 @@ -package writeserver - -import ( - "fmt" - "net" - "os/exec" - "path/filepath" - "sync" - "time" -) - -var ( - mu sync.Mutex - writeProc *exec.Cmd - writeReady bool - hugoProc *exec.Cmd - WritePort int - WriteDir string -) - -func Init(writePort int, writeDir string) { - WritePort = writePort - WriteDir = writeDir -} - -func StartWriteServer() map[string]interface{} { - mu.Lock() - defer mu.Unlock() - - if writeProc != nil { - if writeReady { - return map[string]interface{}{"started": false, "reason": "already_running"} - } - return map[string]interface{}{"started": false, "reason": "starting"} - } - - writeReady = false - writeProc = exec.Command(filepath.Join(WriteDir, "writeserver.exe")) - writeProc.Dir = WriteDir - - if err := writeProc.Start(); err != nil { - writeProc = nil - return map[string]interface{}{"started": false, "reason": err.Error()} - } - - writeReady = true - - go func() { - err := writeProc.Wait() - mu.Lock() - defer mu.Unlock() - fmt.Printf("[write] exited: %v\n", err) - writeProc = nil - writeReady = false - }() - - return map[string]interface{}{"started": true} -} - -func StopWriteServer() bool { - mu.Lock() - defer mu.Unlock() - - if hugoProc != nil { - killProcessTree(hugoProc) - hugoProc = nil - } - - if writeProc == nil { - return false - } - - killProcessTree(writeProc) - writeProc = nil - writeReady = false - return true -} - -func killProcessTree(cmd *exec.Cmd) { - if cmd == nil || cmd.Process == nil { - return - } - // On Windows, use taskkill /T to kill process tree - pid := cmd.Process.Pid - killCmd := exec.Command("taskkill", "/PID", fmt.Sprintf("%d", pid), "/T", "/F") - killCmd.Run() - cmd.Process.Kill() -} - -func StartHugoIfNeeded() { - mu.Lock() - defer mu.Unlock() - - if hugoProc != nil { - return - } - - hugoDir := WriteDir + "/.." - hugoProc = exec.Command("hugo", "server", "--bind", "0.0.0.0", "--port", "1313", "--disableFastRender") - hugoProc.Dir = hugoDir - - if err := hugoProc.Start(); err != nil { - fmt.Printf("[hugo] start error: %v\n", err) - hugoProc = nil - return - } - - fmt.Println("[hugo] started on 0.0.0.0:1313") - - go func() { - err := hugoProc.Wait() - mu.Lock() - defer mu.Unlock() - fmt.Printf("[hugo] exited: %v\n", err) - hugoProc = nil - }() -} - -func GetWriteStatus() map[string]interface{} { - mu.Lock() - running := writeProc != nil && writeReady - starting := writeProc != nil && !writeReady - mu.Unlock() - - // Also check via TCP if we are not tracking a process - if !running && !starting { - conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", WritePort), 2*time.Second) - if err == nil { - conn.Close() - running = true - } - } - - return map[string]interface{}{ - "running": running, - "starting": starting, - } -} - -func SetWriteReady(ready bool) { - mu.Lock() - defer mu.Unlock() - writeReady = ready -} - -func IsWriteReady() bool { - mu.Lock() - defer mu.Unlock() - return writeReady -} - -func IsWriteRunning() bool { - mu.Lock() - defer mu.Unlock() - return writeProc != nil -} diff --git a/bot-go/start_bot.bat b/bot-go/start_bot.bat deleted file mode 100644 index 20305000..00000000 --- a/bot-go/start_bot.bat +++ /dev/null @@ -1,5 +0,0 @@ -@echo off -title WriteBot -cd /d E:\GitHub\blog\bot-go -echo [%date% %time%] WriteBot starting... -writebot.exe diff --git a/write/.recycle/meta/2026-06-01-深圳真的好热.json b/write/.recycle/meta/2026-06-01-深圳真的好热.json new file mode 100644 index 00000000..07465d5e --- /dev/null +++ b/write/.recycle/meta/2026-06-01-深圳真的好热.json @@ -0,0 +1,7 @@ +{ + "slug": "2026-06-01-深圳真的好热", + "title": "深圳真的好热", + "deletedAt": "2026-06-01T16:21:45.062Z", + "originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-深圳真的好热", + "expiresAt": "2026-07-01T16:21:45.062Z" +} \ No newline at end of file diff --git a/write/.recycle/meta/2026-06-01-深圳真的热.json b/write/.recycle/meta/2026-06-01-深圳真的热.json new file mode 100644 index 00000000..49bd22d7 --- /dev/null +++ b/write/.recycle/meta/2026-06-01-深圳真的热.json @@ -0,0 +1,7 @@ +{ + "slug": "2026-06-01-深圳真的热", + "title": "深圳真的热", + "deletedAt": "2026-06-01T14:59:57.582Z", + "originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-深圳真的热", + "expiresAt": "2026-07-01T14:59:57.582Z" +} \ No newline at end of file diff --git a/write/.recycle/meta/202605311204.json b/write/.recycle/meta/202605311204.json new file mode 100644 index 00000000..75f5d903 --- /dev/null +++ b/write/.recycle/meta/202605311204.json @@ -0,0 +1,7 @@ +{ + "slug": "202605311204", + "title": "周末我把手机系统降级了一个大版本", + "deletedAt": "2026-06-01T15:06:56.071Z", + "originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-05-31-周末我把手机系统降级了一个大版本", + "expiresAt": "2026-07-01T15:06:56.071Z" +} \ No newline at end of file diff --git a/write/.recycle/posts/2026-06-01-深圳真的好热/index.md b/write/.recycle/posts/2026-06-01-深圳真的好热/index.md new file mode 100644 index 00000000..4b83c075 --- /dev/null +++ b/write/.recycle/posts/2026-06-01-深圳真的好热/index.md @@ -0,0 +1,24 @@ +--- +title: 深圳真的好热 +date: '2026-06-01T00:00:00.000Z' +slug: 2026-06-01-深圳真的好热 +draft: false +categories: + - 技术 +tags: + - 测试 +author: 小赵 +layout: post +--- +你好 + + +![image](1780326640500-AgACAgQA.jpg) + + +今天的天气不错 + +你觉得呢 + +哈哈哈哈 + diff --git a/write/.recycle/posts/2026-06-01-深圳真的热/1780325877248-AgACAgQA.jpg b/write/.recycle/posts/2026-06-01-深圳真的热/1780325877248-AgACAgQA.jpg new file mode 100644 index 00000000..f58c5462 Binary files /dev/null and b/write/.recycle/posts/2026-06-01-深圳真的热/1780325877248-AgACAgQA.jpg differ diff --git a/write/.recycle/posts/2026-06-01-深圳真的热/index.md b/write/.recycle/posts/2026-06-01-深圳真的热/index.md new file mode 100644 index 00000000..9a8f4b37 --- /dev/null +++ b/write/.recycle/posts/2026-06-01-深圳真的热/index.md @@ -0,0 +1,20 @@ +--- +title: 深圳真的热 +date: '2026-06-01' +slug: 2026-06-01-深圳真的热 +draft: false +categories: + - 技术 +tags: + - 深圳 +author: 小赵 +layout: post +--- +好热啊 +超级热 + +![image](1780325877248-AgACAgQA.jpg) + +哈哈哈 +此文章使用 telegram 撰写 +哈哈哈 diff --git a/write/.recycle/posts/202605311204/index.md b/write/.recycle/posts/202605311204/index.md new file mode 100644 index 00000000..bf1956ba --- /dev/null +++ b/write/.recycle/posts/202605311204/index.md @@ -0,0 +1,35 @@ +--- +title: 周末我把手机系统降级了一个大版本 +date: '2026-05-31T00:00:00.000Z' +slug: '202605311204' +draft: false +categories: + - 生活 +tags: + - OPPO;ColorOS +author: 小赵 +layout: post +ai_comment: >- + 从技术角度看,ColorOS 16 的 AI + 功能堆砌是典型的版本迭代陷阱。厂商为追求差异化强行注入AI模块,却忽略了系统层级的功耗平衡优化。建议关闭非必要的AI服务,这就像给过度兴奋的CPU降频——有时候减法比加法更能体现工程智慧。续航劣化可能源于AI进程的常驻唤醒,等待后续热修复补丁或许是更理性的选择。 +--- +从 iPhone 16E 换到 OPPO Find X8s 之后,它就一直是主力机,没再变过。 + +说实话,OPPO 这套系统我用着挺满意,功能到位,稳定性也没得挑。 + +之所以彻底放弃 iPhone 当主力,理由其实很充分很现实:微信双开和通话录音。这两样,iPhone 都给不了,而我的工作又偏偏离不开。 + +中间也动过双持的念头,两台手机分工协作,听起来挺美。但真用起来,麻烦比便利多。光是琢磨哪个 App 装在哪台手机上,就够头疼的。用了一阵子发现,我根本没有那份“区分使用”的需求,反而更想要一个干净利落的统一。 + +手机到手时,预装的是 ColorOS 15。我就是被它的宣传语打动的——“流畅稳定,德芙一样丝滑”。用下来,确实名副其实,那种跟手和舒心,让我觉得自己选对了。 + +所以 ColorOS 16 一推送,我毫不犹豫就更了。我的手机一直是默认自动更新,总相信新的一定更好。 + +但很快我就发现,厂商的劲头,好像全往一个方向使了:AI。 + +到处都是 AI。系统里那些曾经让我安心的“本分”感,开始变了味。 + +带给我实际的体验就是,耗电和发热更厉害了,手机续航也来到了一天两充,看着手机里的电池健康,实际上还是百分之百 + + + diff --git a/write/docs/bot_design/design.md b/write/docs/bot_design/design.md new file mode 100644 index 00000000..d3983e89 --- /dev/null +++ b/write/docs/bot_design/design.md @@ -0,0 +1,914 @@ +# WriteBot 模块设计文档 + +## 一、架构 + +Bot 作为 Next.js 的 sidecar 模块运行,通过 `instrumentation.ts` 在服务端启动时自动拉起。 + +``` +write/src/lib/bot/ +├── index.ts # 启动入口 +├── config.ts # Bot 专用配置 +├── tg.ts # Telegram API 封装 +├── sessions.ts # 写作会话状态管理 +├── handlers.ts # 全部命令处理器 +├── poll.ts # 长轮询循环(带指数退避) +└── helpers.ts # 工具函数 +``` + +Bot 不再通过 HTTP 调 write API,直接调用 `src/lib/posts.ts`、`src/lib/rssapi.ts` 等 lib 函数。 + +--- + +## 二、交互方式 + +### 2.1 Telegram 原生组件使用规则 + +| 组件 | 用途 | 使用场景 | +|------|------|---------| +| ReplyKeyboard(底部键盘) | 常驻操作按钮 | 所有文本回复都带,分主键盘和写作键盘两套 | +| InlineKeyboard(消息内按钮) | 列表操作、确认、分页 | 文章列表编辑/删除、删除确认、分页导航、打开编辑器 | +| ForceReply(强制回复) | 引导输入 | 新建标题、设置分类/标签、添加友链/订阅源 | +| Web App | 内置浏览器打开编辑器 | /start 时提供 | + +**关键规则:ReplyKeyboard 和 InlineKeyboard 不能混在同一个 reply_markup 里。** + +### 2.2 ReplyKeyboard 布局 + +主键盘(非写作状态): +``` +[/new 写文章] [/list 文章列表] +[/links 友链] [/feeds 订阅] +[/stats 数据] [/help 帮助] +``` + +写作键盘(写作会话中): +``` +[/preview 预览] [/publish 发布] +[/draft 草稿] [/cancel 取消] +[/title 标题] [/categories 分类] +``` + +--- + +## 三、命令清单 + +### 3.1 系统命令 + +| 命令 | 参数 | 说明 | +|------|------|------| +| `/start` | 无 | 启动 Hugo,显示地址,发 Web App 编辑器按钮 | +| `/stop` | 无 | 停止 Hugo | +| `/status` | 无 | 显示 Hugo 运行状态和地址 | +| `/ip` | 无 | 显示局域网 IP | +| `/help` | 无 | 显示命令帮助(根据是否在写作中显示不同内容) | + +### 3.2 文章命令 + +| 命令 | 参数 | 说明 | +|------|------|------| +| `/new` | 标题(可选) | 新建文章,见写作流程 | +| `/edit` | slug | 编辑已有文章,见编辑流程 | +| `/delete` | slug | 删除文章(二次确认),移入回收站 | +| `/list` | 页码(可选) | 文章列表,纯文本 + 分页 | +| `/view` | slug | 显示预览链接 | +| `/preview` | 无 | 预览当前写作内容 | +| `/publish` | 无 | 保存到本地(draft: false),不触发 git push | +| `/draft` | 无 | 保存到本地(draft: true) | +| `/deploy` | 无 | Git add → commit → pull --rebase → push | +| `/sync` | 无 | Git pull --rebase,同步远程 | +| `/undo` | 无 | 撤销最后一次追加的内容,自动预览 | +| `/cancel` | 无 | 丢弃当前写作会话 | +| `/title` | 新标题 | 修改文章标题(ForceReply 引导) | +| `/categories` | 分类 | 设置分类(逗号分隔) | +| `/tags` | 标签 | 设置标签(逗号分隔) | +| `/author` | 作者名 | 修改作者 | + +### 3.3 友链命令 + +| 命令 | 参数 | 说明 | +|------|------|------| +| `/links` | 页码(可选) | 友链列表,纯文本 + 分页 + 命令提示 | +| `/link_add` | url 名称 | 添加友链(ForceReply 引导) | +| `/link_del` | url | 删除友链 | +| `/link_toggle` | url | 切换显示/隐藏 | + +### 3.4 订阅命令 + +| 命令 | 参数 | 说明 | +|------|------|------| +| `/feeds` | 页码(可选) | 订阅源列表,纯文本 + 分页 + 命令提示 | +| `/feed_add` | url | 添加订阅源(ForceReply 引导) | +| `/feed_del` | url | 删除订阅源 | + +### 3.5 数据命令 + +| 命令 | 参数 | 说明 | +|------|------|------| +| `/stats` | 无 | 显示文章数、友链数、订阅源数 | + +--- + +## 四、核心流程 + +### 4.1 新建文章 `/new` + +``` +用户: /new 标题 + ↓ (如果没有标题,ForceReply 引导输入) +Bot: 📝 开始写:《标题》 + 📂 设置分类(可选) + [跳过] [设置分类] ← InlineKeyboard + ↓ +用户: 点 [跳过] → 直接进写作 + 或: 点 [设置分类] → ForceReply 引导输入分类 + → 然后同样问标签 [跳过] [设置标签] + ↓ +Bot: 切换写作键盘 + "直接发消息写正文,完成后点「发布」" + ↓ +用户: 发消息 → 追加到 content +用户: 发照片 → 下载到文章文件夹,追加 markdown 引用 +用户: /undo → 撤销最后一次追加,自动预览 + ↓ +用户: /publish + ↓ +Bot: 保存到本地文件(draft: false) + "✅ 已保存,发 /deploy 推送到线上" + ↓ +用户: /deploy + ↓ +Bot: git add → commit → pull --rebase → push + "🚀 已发布并部署!" +``` + +Session 初始状态: +```typescript +{ + title: "标题", + slug: "2026-06-01-标题", + content: "", + categories: [], + tags: [], + author: "小赵", // DEFAULT_AUTHOR 环境变量可覆盖 + draft: true, + chunks: [], // 用于 /undo,记录每次追加的内容 + mode: "write", // write | edit +} +``` + +### 4.2 编辑文章 `/edit`(方案 B) + +``` +用户: /edit slug + ↓ +Bot: 加载文章,显示原文内容(分条,每条 ≤3800 字) + 清空 session.content(保留 title、slug、categories、tags、author) + 切换写作键盘 + "原文已显示,现在重新写正文,写完点「发布」" + ↓ +用户: 发消息 → 追加到新 content(和 /new 一样) + ↓ +后续流程同新建 +``` + +关键区别:`/new` 的 slug 是新生成的,`/edit` 保留原文章的 slug。保存时调用 `updatePost` 而不是 `createPost`。 + +### 4.3 删除文章 `/delete` + +``` +用户: /delete slug + ↓ +Bot: "确认删除《标题》?" + [确认删除] [取消] ← InlineKeyboard + ↓ +用户: 点 [确认删除] + → moveToRecycle(slug, dirPath, title) + → "✂ 已移入回收站:《标题》\n30天内可恢复。" + ↓ +用户: 点 [取消] + → "已取消" +``` + +poll.ts callback 处理新增 `confirm_del` 前缀: +```typescript +const CALLBACK_PREFIXES = { + edit: "edit", + confirm_del: "delete", // 确认删除 + cancel_del: "noop", // 取消删除 + list: "list", + links: "links", + feeds: "feeds", + skip_cat: "skip_categories", // 跳过分类 + skip_tag: "skip_tags", // 跳过标签 +}; +``` + +### 4.4 文章列表 `/list` + +纯文本显示,无 InlineKeyboard 操作按钮: +``` +📖 文章列表 共 46 篇 · 第 1/10 页 + +我的第一篇文章 + 2026-06-01-my-first-post +另一篇文章 📝(草稿) + 2026-05-28-another-post + +◀ 上一页 下一页 ▶ ← InlineKeyboard(仅分页) +``` + +用户通过 slug 操作:`/edit slug`、`/delete slug`、`/view slug`。 + +### 4.5 友链列表 `/links` + +纯文本 + 分页 + 命令提示: +``` +🔗 友链 共 46 个 · 第 1/10 页 + +🌐 强仔博客 +🌐 七栀 +🔒 若志随笔(已隐藏) + +操作命令: +/link_add url 名称 — 添加 +/link_del url — 删除 +/link_toggle url — 显示/隐藏 + +◀ 上一页 下一页 ▶ +``` + +### 4.6 订阅列表 `/feeds` + +同友链,纯文本 + 分页 + 命令提示。 + +--- + +## 五、图片处理(Hugo Page Bundle) + +### 5.1 存储策略 + +图片存到文章自己的文件夹里,符合 Hugo page bundle 规范: + +``` +content/post/2026/2026-06-01-文章标题/ +├── index.md +├── photo1.jpg +└── photo2.jpg +``` + +正文引用:`![描述](photo1.jpg)`(相对路径) + +### 5.2 实现逻辑 + +写作中收到照片时: +1. 根据 session 的 slug 计算文章文件夹路径 + - slug: `2026-06-01-文章标题` + - 文件夹: `content/post/2026/2026-06-01-文章标题/` +2. 文件夹不存在则创建 +3. 下载图片到文件夹内 +4. content 追加 `![caption](filename.jpg)` + +保存时(draft/publish): +1. index.md 写到同一个文件夹 +2. 图片已经在里面,无需移动 + +取消时: +1. 文件夹里只有图片没有 index.md +2. Hugo 不会渲染,无害 + +--- + +## 六、撤销 `/undo` + +### 6.1 实现机制 + +Session 新增 `chunks: string[]` 字段: + +```typescript +interface Session { + // ... 其他字段 + chunks: string[]; // 每次追加的内容记录 +} +``` + +每次用户发消息追加到 content 时,同时 push 到 chunks: +```typescript +s.chunks.push(fullText + "\n"); +s.content += fullText + "\n"; +``` + +### 6.2 `/undo` 流程 + +``` +用户: /undo + ↓ +如果 chunks 为空: "没有可撤销的内容" + ↓ +否则: + lastChunk = chunks.pop() + content = content 去掉 lastChunk + → 自动调用 preview 逻辑,显示当前文章内容 + → "↩ 已撤销最后一段(上方为当前内容)" +``` + +--- + +## 七、发布与部署(分离设计) + +### 7.1 与网页端保持一致 + +| 操作 | 命令 | 效果 | +|------|------|------| +| 保存草稿 | `/draft` | 保存到本地,draft: true | +| 发布 | `/publish` | 保存到本地,draft: false,不触发 git | +| 部署 | `/deploy` | git add → commit → pull --rebase → push | +| 同步 | `/sync` | git pull --rebase,拉取远程最新 | + +### 7.2 Git 冲突处理 + +`/deploy` 执行流程: +1. `git add content/post static` +2. `git diff --cached --quiet` → 有变更则 `git commit` +3. `git pull --rebase --autostash origin main` + - 成功 → 继续 + - 冲突 → `git rebase --abort` → 回复"远程有冲突,已自动放弃合并。请先 /sync 同步,手动解决后再 deploy" +4. `git push` + +--- + +## 八、配置 + +### 8.1 环境变量(write/.env) + +```env +# Bot 配置 +TG_BOT_TOKEN=xxx +TG_ALLOWED_CHAT_IDS=xxx # 留空允许所有人 +PREFER_IFACE=WLAN +DEFAULT_AUTHOR=小赵 # 新增:默认作者名 +``` + +### 8.2 config.ts 新增 + +```typescript +export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵"; +``` + +--- + +## 九、命令解析 + +### 9.1 ReplyKeyboard 按钮文字处理 + +按钮发出的文字如 `/new 写文章`,解析规则: +1. `split("@")[0]` 去掉 bot mention +2. `split(/\s+/)` 拆词 +3. 第一个词去掉 `/` 前缀作为命令名 +4. 其余词作为参数 + +`/new 写文章` → 命令 `new`,参数 `写文章` +但 `new` 命令的参数是标题,所以实际行为:`/new 写文章` 会创建一篇标题为"写文章"的文章。 + +需要特殊处理:如果参数和命令的中文描述匹配(如 `new` 的参数是 `写文章`),则忽略该参数。 +或者更简单的方案:ReplyKeyboard 按钮只发命令,不带中文。 + +**最终方案:ReplyKeyboard 按钮文字改为纯命令:** +``` +[/new] [/list] +[/links] [/feeds] +[/stats] [/help] +``` + +### 9.2 ForceReply 回复识别 + +poll.ts 通过 `reply_to_message.text` 匹配关键词来识别 ForceReply 回复: + +| 关键词 | 路由到 | +|--------|--------| +| "请输入文章标题" | `handleCommand(chatId, "new", text)` | +| "请输入新标题" | `handleCommand(chatId, "title", text)` | +| "请输入友链信息" | `handleCommand(chatId, "link_add", text)` | +| "请输入订阅源 URL" | `handleCommand(chatId, "feed_add", text)` | +| "请输入分类" | `handleCommand(chatId, "categories", text)` | +| "请输入标签" | `handleCommand(chatId, "tags", text)` | + +--- + +## 十、会话状态机 + +Session 有一个 `state` 字段,控制消息处理行为: + +``` +idle → 非写作状态,普通命令处理 +writing_title → 等待 ForceReply 标题输入 +writing_cat → 等待 ForceReply 分类输入(或 InlineKeyboard 跳过) +writing_tag → 等待 ForceReply 标签输入(或 InlineKeyboard 跳过) +writing → 写作中,消息追加到 content +confirm_del → 等待删除确认(InlineKeyboard) +``` + +状态转换: +``` +idle → ( /new ) → writing_title → ( 输入标题 ) → writing_cat + → ( 跳过/输入 ) → writing_tag → ( 跳过/输入 ) → writing → idle + +idle → ( /edit slug ) → 显示原文 → 清空 content → writing → idle + +idle → ( /delete slug ) → confirm_del → ( 确认 ) → idle + → ( 取消 ) → idle +``` + + + +--- + +## 十一、友链模块 + +### 11.1 命令清单 + +| 命令 | 参数 | 说明 | +|------|------|------| +| `/links` | 页码(可选) | 友链列表,纯文本 + 分页 | +| `/link_add` | url 名称 | ForceReply 引导输入,添加友链 | +| `/link_edit` | url | ForceReply 引导输入新名称,通过 oldUrl 编辑 | +| `/link_del` | url | 删除友链 | +| `/link_toggle` | url | 切换显示/隐藏 | + +### 11.2 列表显示格式 + +``` +🔗 友链 共 46 个 · 第 1/10 页 + +🌐 吾柯 https://blog.keepke.com +🌐 强仔博客 https://loseu.cc +🔒 七栀 https://blog.qydzz.cn + +操作:/link_add /link_edit /link_del /link_toggle + +◀ 上一页 下一页 ▶ +``` + +每条显示:图标(🌐 正常 / 🔒 隐藏)+ 名称 + URL。 +每页 10 条。底部 InlineKeyboard 仅分页按钮。 +底部文字提示可用操作命令。 + +### 11.3 `/link_edit` 流程 + +``` +用户: /link_edit url + ↓ +Bot: 查找该友链,显示当前信息 + "请输入新名称(留空保持不变):" ← ForceReply + ↓ +用户: 输入新名称(或发"跳过") + ↓ +Bot: 调用 POST /api/links { oldUrl, name: newName } + "✅ 已更新:新名称" +``` + +### 11.4 API 调用 + +- 列表:`GET /api/links?all=1` +- 添加:`POST /api/links` body: `{ name, url }` +- 编辑:`POST /api/links` body: `{ oldUrl, name, ... }` +- 删除:`DELETE /api/links?url=xxx` +- 切换:先 GET 获取 hidden 状态,再 POST `{ url, hidden: !current }` + +--- + +## 十二、订阅模块 + +### 12.1 命令清单 + +| 命令 | 参数 | 说明 | +|------|------|------| +| `/feeds` | 页码(可选) | 订阅源列表,纯文本 + 分页 | +| `/feed_add` | url 名称 | ForceReply 引导输入 URL 和名称 | +| `/feed_edit` | url | ForceReply 引导编辑,通过 oldUrl | +| `/feed_del` | url | 删除订阅源 | +| `/feed_health` | 无 | 检测全部订阅源健康状态 | + +### 12.2 列表显示格式 + +``` +📡 订阅源 共 52 个 · 第 1/10 页 + +📰 吾柯 https://blog.keepke.com +📰 强仔博客 https://loseu.cc + +操作:/feed_add /feed_edit /feed_del /feed_health + +◀ 上一页 下一页 ▶ +``` + +每页 10 条。 + +### 12.3 `/feed_health` 流程 + +调用 `GET /api/health`(不传 url 参数,检测全部订阅源)。 + +``` +用户: /feed_health + ↓ +Bot: "⏳ 正在检测 52 个订阅源..." + ↓ +Bot: + 📊 订阅源健康报告 + ✅ 存活:49 ❌ 异常:3 + + ❌ 异常站点: + 🔴 dead-blog.com — timeout + 🔴 another.com — 500 + 🔴 third.com — 连接失败 +``` + +只列出异常站点,正常的不逐一显示。 + +### 12.4 `/feed_edit` 流程 + +同友链编辑,通过 oldUrl 参数调用 POST /api/feeds。 + +### 12.5 API 调用 + +- 列表:`GET /api/feeds` +- 添加:`POST /api/feeds` body: `{ url, feedTitle }` +- 编辑:`POST /api/feeds` body: `{ oldUrl, url, feedTitle }` +- 删除:`DELETE /api/feeds?url=xxx` +- 健康:`GET /api/health` + +--- + +## 十三、新增 `/read` 命令 + +利用 API 的 `GET /api/articles` 接口,获取友链博客的最新文章。 + +### 13.1 命令 + +| 命令 | 参数 | 说明 | +|------|------|------| +| `/read` | 数量(可选,默认 10) | 友链最新文章 | + +### 13.2 显示格式 + +``` +📰 友链最新文章(10 篇) + +吾柯 — 如何在 Hugo 中实现友链 + https://blog.keepke.com/xxx + +强仔博客 — 2026 年度总结 + https://loseu.cc/xxx + +七栀 — 我的第一次旅行 + https://blog.qydzz.cn/xxx +``` + +每条显示:博客名 — 文章标题 + 链接。 +Telegram 自动将 URL 转为可点击链接。 + +### 13.3 API 调用 + +`GET /api/articles?limit=10` + +响应结构: +```json +{ + "total": 380, + "articles": [ + { + "title": "...", + "link": "...", + "pubDate": "...", + "feedName": "吾柯", + "siteUrl": "...", + "favicon": "...", + "author": "..." + } + ] +} +``` + +--- + +## 十四、完整命令注册列表 + +更新 `setMyCommands` 注册的命令: + +```typescript +commands: [ + { command: "new", description: "写一篇文章" }, + { command: "edit", description: "编辑文章" }, + { command: "delete", description: "删除文章" }, + { command: "list", description: "文章列表" }, + { command: "publish", description: "保存发布" }, + { command: "draft", description: "保存草稿" }, + { command: "deploy", description: "推送到线上" }, + { command: "sync", description: "同步远程" }, + { command: "undo", description: "撤销最后一段" }, + { command: "preview", description: "预览" }, + { command: "links", description: "友链管理" }, + { command: "link_add", description: "添加友链" }, + { command: "link_edit", description: "编辑友链" }, + { command: "link_del", description: "删除友链" }, + { command: "link_toggle", description: "显示/隐藏友链" }, + { command: "feeds", description: "订阅管理" }, + { command: "feed_add", description: "添加订阅源" }, + { command: "feed_edit", description: "编辑订阅源" }, + { command: "feed_del", description: "删除订阅源" }, + { command: "feed_health", description: "订阅源健康检查" }, + { command: "read", description: "友链最新文章" }, + { command: "stats", description: "网站数据" }, + { command: "help", description: "帮助" }, +] +``` + +--- + +## 十五、其他可考虑的功能 + +### 15.1 `/random` — 随机图片 + +利用 API 的 `GET /api/random-image` 接口,可以做一个随机壁纸/图片功能。 +适合在聊天中发一张随机图片,增加趣味性。优先级低。 + +### 15.2 `/greeting` — 问候语 + +利用 API 的 `GET /api/ai/greeting` 接口,获取 AI 生成的问候语。 +适合每天早上发一条问候。可以和定时任务结合。优先级低。 + +### 15.3 公众号同步 `/wechat` + +利用 API 的 `POST /api/wechat-material` 接口,在 bot 中直接把文章同步到微信公众号草稿箱。 +当前 write 已有公众号页面,bot 可以加一个 `/wechat slug` 命令。 +优先级中——如果经常在手机上写文章,同步到公众号是个高频操作。 + +### 15.4 定时任务 + +利用 Cowork 的 scheduled-tasks 功能,可以设置: +- 每天早上 9 点发一条问候语 + 友链最新文章摘要 +- 每周检测一次订阅源健康状态 +- 每天自动部署未发布的草稿(可选) + +这些功能当前不急,后续按需添加。 + + +--- + +## 十六、确认的补充项 + +### 16.1 写作键盘更新 + +加入 `/undo` 和 `/deploy`: + +``` +[/preview] [/undo] +[/publish] [/deploy] +[/draft] [/cancel] +[/title] [/categories] +``` + +### 16.2 主键盘更新 + +按钮文字改为纯命令: + +``` +[/new] [/list] +[/links] [/feeds] +[/stats] [/help] +``` + +### 16.3 /stats 增强 + +显示本地数据 + API 数据: + +``` +📊 网站数据 + +📝 本地文章:128 篇 +🔗 友链:46 个 +📡 订阅源:52 个 + +📰 友链文章:380 篇(来自 52 个博客) +🌐 usj.cc +``` + +### 16.4 API 不可用降级 + +所有调用 `api.usj.cc` 的操作统一处理: +- 超时:10 秒 +- 失败回复:"⚠️ API 暂时不可用,请稍后再试" +- 涉及命令:/links、/link_add、/link_edit、/link_del、/link_toggle、/feeds、/feed_add、/feed_edit、/feed_del、/feed_health、/read、/stats(API 部分) + +--- + +## 十七、完整的 poll.ts 命令解析更新 + +### 17.1 ARG_COMMANDS 更新 + +```typescript +const ARG_COMMANDS = new Set([ + "new", "title", "categories", "tags", "author", + "edit", "view", "delete", + "link_add", "link_edit", "link_del", "link_toggle", + "feed_add", "feed_edit", "feed_del", + "list", "links", "feeds", "read", +]); +``` + +### 17.2 CALLBACK_PREFIXES 更新 + +```typescript +const CALLBACK_PREFIXES: Record = { + edit: "edit", + confirm_del: "delete", + cancel_del: "noop", + list: "list", + links: "links", + feeds: "feeds", + skip_cat: "skip_categories", + skip_tag: "skip_tags", +}; +``` + +### 17.3 ForceReply 关键词更新 + +```typescript +if (original.includes("请输入文章标题")) → new +if (original.includes("请输入新标题")) → title +if (original.includes("请输入分类")) → categories +if (original.includes("请输入标签")) → tags +if (original.includes("请输入友链信息")) → link_add +if (original.includes("请输入新名称")) → link_edit +if (original.includes("请输入订阅源 URL")) → feed_add +if (original.includes("请输入新的订阅信息")) → feed_edit +``` + +### 17.4 InlineKeyboard callback 处理 + +新增 `confirm_del` 和 `cancel_del` 处理: +- `confirm_del_slug` → 执行删除 +- `cancel_del_slug` → 取消,回复"已取消" + +新增 `skip_cat` 和 `skip_tag` 处理: +- `skip_cat` → 跳过分类设置 +- `skip_tag` → 跳过标签设置 + +--- + +## 十八、sessions.ts 更新 + +### 18.1 Session 接口 + +```typescript +interface Session { + title: string; + slug: string; + content: string; + categories: string[]; + tags: string[]; + author: string; + draft: boolean; + chunks: string[]; // 每次追加的内容,用于 /undo + originalSlug?: string; // /edit 时保留原 slug +} +``` + +### 18.2 默认作者 + +从 `DEFAULT_AUTHOR` 环境变量读取,默认 "小赵"。 + +--- + +## 十九、helpers.ts 更新 + +### 19.1 makeSlug 保持不变 + +`2026-06-01-标题拼音` + +### 19.2 新增 getArticleDirPath + +根据 slug 计算文章文件夹路径: + +```typescript +function getArticleDirPath(slug: string): string { + // slug: "2026-06-01-文章标题" + // 从 slug 提取年份前四位 + const year = slug.slice(0, 4); + return path.join(CONTENT_DIR, year, slug); +} +``` + +### 19.3 新增 truncateText + +截断文本到指定长度,用于 Telegram 消息: + +```typescript +function truncateText(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字"; +} +``` + +--- + +## 二十、config.ts 更新 + +新增环境变量: + +```typescript +export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵"; +export const UAPI_BASE = process.env.RSS_API_BASE || "https://api.usj.cc"; +export const UAPI_TOKEN = process.env.RSS_API_TOKEN || ""; +``` + +--- + +## 二十一、rssapi.ts 新增函数 + +### 21.1 getArticles + +```typescript +export async function getArticles(limit = 10): Promise { + const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`); + return data.articles || []; +} +``` + +### 21.2 checkHealth + +```typescript +export async function checkHealth(): Promise { + return rssFetch("/api/health"); +} +``` + +### 21.3 updateLink 和 updateFeed 已存在 + +rssapi.ts 已有 `updateLink(oldUrl, data)` 函数,用于友链编辑。 +需要新增 `updateFeed(oldUrl, data)` 函数用于订阅编辑。 + +--- + +## 二十二、git.ts 更新 + +### 22.1 GitDeployQueue 修改 + +`enqueue` 方法不再自动 add static 目录,改为只 add content: + +```typescript +const addResult = await this.runGit(["add", "content"]); +``` + +### 22.2 新增 sync 方法 + +```typescript +async sync(): Promise { + try { + const result = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]); + return { success: true, output: result.stdout + result.stderr }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (this.isRebaseConflict(msg)) { + await this.runGit(["rebase", "--abort"], true); + return { success: false, conflict: true, error: "远程冲突,已放弃合并" }; + } + return { success: false, error: msg }; + } +} +``` + +--- + +## 二十三、实施清单 + +按依赖顺序: + +### 第一步:基础设施修改 +1. config.ts — 新增 DEFAULT_AUTHOR、UAPI_BASE、UAPI_TOKEN +2. git.ts — 修改 add 路径、新增 sync 方法 +3. rssapi.ts — 新增 getArticles、checkHealth、updateFeed +4. helpers.ts — 新增 getArticleDirPath、truncateText +5. sessions.ts — 新增 chunks、originalSlug 字段、默认作者从 config 读取 + +### 第二步:poll.ts 重写 +6. 更新 ARG_COMMANDS +7. 更新 CALLBACK_PREFIXES +8. 更新 ForceReply 关键词路由 +9. 新增 confirm_del / cancel_del / skip_cat / skip_tag 处理 + +### 第三步:handlers.ts 重写 +10. 系统命令(start/stop/status/ip/help) +11. 文章命令(new/edit/delete/list/view/title/categories/tags/author) +12. 写作命令(preview/publish/draft/deploy/sync/undo/cancel) +13. 友链命令(links/link_add/link_edit/link_del/link_toggle) +14. 订阅命令(feeds/feed_add/feed_edit/feed_del/feed_health) +15. 数据命令(stats/read) + +### 第四步:键盘和命令注册 +16. mainKeyboard — 纯命令 +17. writingKeyboard — 加 /undo /deploy +18. setMyCommands — 完整命令列表 + +### 第五步:验证 +19. TypeScript 类型检查 +20. 本地 npm run dev 测试 diff --git a/write/docs/integration-plan.md b/write/docs/integration-plan.md new file mode 100644 index 00000000..93d25192 --- /dev/null +++ b/write/docs/integration-plan.md @@ -0,0 +1,477 @@ +# Bot + Write 整合架构方案 + +## 目标 + +将 `bot/`(Telegram Bot)的全部功能合并进 `write/`(Next.js 16)项目,消除双进程问题,统一文件操作、Hugo 管理、Git 部署。 + +--- + +## 一、整合后的目录结构 + +``` +write/ +├── src/ +│ ├── app/ # Next.js 页面 + API 路由(现有) +│ ├── components/ # React 组件(现有) +│ ├── lib/ +│ │ ├── config.ts # 统一配置(合并 bot 的 .env 字段) +│ │ ├── posts.ts # 文章 CRUD(现有,改异步) +│ │ ├── ai.ts # AI 功能(现有) +│ │ ├── artalk.ts # 评论管理(现有) +│ │ ├── recycle.ts # 回收站(现有) +│ │ ├── rssapi.ts # 友链/订阅 API(现有) +│ │ ├── hugo.ts # ★ 新增:Hugo 进程单例管理器 +│ │ ├── git.ts # ★ 新增:Git 操作队列 +│ │ └── bot/ # ★ 新增:Telegram Bot 模块 +│ │ ├── index.ts # bot 启动入口,导出 startBot() +│ │ ├── config.ts # bot 专用配置(TG_TOKEN, ALLOWED_IDS) +│ │ ├── tg.ts # Telegram API 封装(tg(), sendMessage(), handleCallback()) +│ │ ├── sessions.ts # 写作会话状态管理 +│ │ ├── handlers.ts # 全部 /command 处理器 +│ │ ├── poll.ts # 长轮询循环(带指数退避) +│ │ └── helpers.ts # 工具函数(getLanIP, makeSlug, sessionSummary) +│ └── instrumentation.ts # ★ 新增:Next.js 服务端启动钩子,启动 bot +├── .env # 合并后的环境变量 +├── start.bat # 启动脚本(不变) +└── ...(其余不变) +``` + +--- + +## 二、三个核心共享模块 + +### 2.1 Hugo 进程管理器 — `src/lib/hugo.ts` + +**解决的问题:** 当前 bot 的 `write-server.js` 和 write 的 `hugo/route.ts` 各自管理 Hugo 进程,互不知道对方状态,可能互相杀进程。 + +```typescript +// src/lib/hugo.ts + +class HugoManager { + private process: ChildProcess | null = null; + private ready = false; + private starting = false; + private healthTimer: NodeJS.Timeout | null = null; + + /** 启动 Hugo server,如果已在运行则跳过 */ + async start(): Promise<{ ok: boolean; alreadyRunning?: boolean }> { ... } + + /** 优雅停止 Hugo */ + stop(): void { ... } + + /** 当前状态 */ + status(): { running: boolean; starting: boolean } { ... } + + /** 确保 Hugo 在运行,不在则启动并等待就绪 */ + async ensureRunning(): Promise { ... } + + /** 每 30 秒检查一次 Hugo 是否还活着,死了自动重启 */ + private startHealthCheck(): void { ... } + + /** 用 HTTP 请求检查 Hugo 是否响应 */ + private async ping(): Promise { ... } +} + +// 全局单例 +export const hugo = new HugoManager(); +``` + +关键设计点: +- `start()` 内部有互斥锁,两个并发调用不会重复启动 +- 健康检查每 30 秒 ping 一次 Hugo 的 localhost:1313,失败则标记 ready=false +- Web 端的 `/api/hugo/route.ts` 改为调用 `hugo.start()` / `hugo.stop()` / `hugo.status()` +- Bot 的 `/start` 命令改为调用 `hugo.ensureRunning()` +- 启动参数统一为 `hugo server --bind 0.0.0.0 --port 1313 --disableFastRender --noHTTPCache` + +### 2.2 Git 部署队列 — `src/lib/git.ts` + +**解决的问题:** 当前 deploy route 没有并发控制,两个同时触发的 deploy 会导致 rebase 冲突或仓库损坏。 + +```typescript +// src/lib/git.ts + +class GitDeployQueue { + private queue: Array<{ + message: string; + resolve: (result: DeployResult) => void; + reject: (err: Error) => void; + }> = []; + private running = false; + + /** 入队一次部署任务,返回 Promise 等待结果 */ + async enqueue(commitMessage: string): Promise { ... } + + /** 实际执行 git add → commit → pull --rebase → push */ + private async execute(message: string): Promise { ... } + + /** 处理队列中的下一个任务 */ + private async next(): Promise { ... } +} + +export interface DeployResult { + success: boolean; + output?: string; + error?: string; + conflict?: boolean; +} + +export const deployQueue = new GitDeployQueue(); +``` + +关键设计点: +- 同一时间只有一个 git 操作在执行,其余排队等待 +- Web 端的 `/api/deploy/route.ts` 改为调用 `deployQueue.enqueue(title)` +- Bot 的 `/publish` 保存文章后也调用 `deployQueue.enqueue()` 触发自动部署 +- 返回 `{ conflict: true }` 时前端提示用户手动解决 + +### 2.3 Bot 模块 — `src/lib/bot/` + +**解决的问题:** bot 作为独立进程通过 HTTP 调 write API,引入了不必要的网络依赖和状态不一致。 + +整合后 bot 是 Next.js 进程内的一个模块,handler 直接调用 `src/lib/posts.ts`、`src/lib/rssapi.ts` 等函数,不再走 HTTP。 + +--- + +## 三、Bot 模块详细设计 + +### 3.1 启动方式 — `src/instrumentation.ts` + +Next.js 13+ 支持 `instrumentation.ts`,在服务端进程启动时执行一次: + +```typescript +// src/instrumentation.ts +export async function register() { + // 仅在 Node.js 运行时执行(不是 Edge Runtime) + if (process.env.NEXT_RUNTIME === "nodejs") { + const { startBot } = await import("@/lib/bot"); + startBot(); + } +} +``` + +需要在 `next.config.ts` 中启用: + +```typescript +const nextConfig: NextConfig = { + devIndicators: false, + experimental: { + instrumentationHook: true, // Next.js 15+ 已默认开启,16 可能不需要 + }, +}; +``` + +### 3.2 Telegram API 封装 — `src/lib/bot/tg.ts` + +从 bot 的 `tg-core.js` 移植,改为 TypeScript: + +```typescript +const API = `https://api.telegram.org/bot${TG_TOKEN}`; + +export async function tg(method: string, body: Record): Promise> { + const res = await fetch(`${API}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.json(); +} + +export function allowed(chatId: number): boolean { + if (ALLOWED_IDS.length === 0) return true; + return ALLOWED_IDS.includes(chatId); +} + +export async function sendMessage(chatId: number, text: string, opts?: Record): Promise { + // 先尝试 HTML 解析,失败则降级纯文本 + try { + await tg("sendMessage", { chat_id: chatId, text, parse_mode: "HTML", disable_web_page_preview: true, ...opts }); + } catch { + await tg("sendMessage", { chat_id: chatId, text, ...opts }); + } +} +``` + +### 3.3 写作会话 — `src/lib/bot/sessions.ts` + +从 bot 的 `sessions.js` 移植,改为直接调用 `src/lib/posts.ts`: + +```typescript +interface Session { + title: string; + slug: string; + content: string; + categories: string[]; + tags: string[]; + author: string; + draft: boolean; +} + +const sessions = new Map(); + +export function getSession(chatId: number): Session { ... } +export function clearSession(chatId: number): void { ... } + +// 关键变化:直接调用 lib/posts.ts,不再走 HTTP +export async function saveSession(s: Session, isDraft: boolean): Promise { + const { createPost, updatePost, getPost } = await import("@/lib/posts"); + const existing = getPost(s.slug); + if (existing) { + updatePost(s.slug, { + title: s.title, + date: new Date().toISOString().slice(0, 10), + draft: isDraft, + categories: s.categories, + tags: s.tags, + author: s.author, + }, s.content); + } else { + createPost({ + title: s.title, + slug: s.slug, + date: new Date().toISOString().slice(0, 10), + draft: isDraft, + categories: s.categories, + tags: s.tags, + author: s.author, + }, s.content); + } +} +``` + +### 3.4 命令处理器 — `src/lib/bot/handlers.ts` + +从 bot 的 `tg-handlers.js` 移植,核心变化: + +| 原来(HTTP 调 write API) | 现在(直接调 lib) | +|---|---| +| `fetch("http://127.0.0.1:8016/api/posts/" + slug)` | `getPost(slug)` | +| `fetch("http://127.0.0.1:8016/api/posts", { method: "POST" })` | `createPost(frontMatter, content)` | +| `fetch("http://127.0.0.1:8016/api/posts/" + slug, { method: "DELETE" })` | `moveToRecycle(slug, dirPath, title)` | +| `fetch("http://127.0.0.1:8016/api/rss/links")` | `getLinks(true)` | +| `fetch("http://127.0.0.1:8016/api/rss/feeds")` | `getFeeds()` | +| `fetch("http://127.0.0.1:8016/api/deploy", ...)` | `deployQueue.enqueue(title)` | + +另外,`/publish` 命令现在会自动触发 git 部署: + +```typescript +// /publish handler +async function handlePublish(chatId: number) { + const s = sessions.get(chatId); + await saveSession(s, false); // 保存文章 + const result = await deployQueue.enqueue(`发布: ${s.title}`); // 自动部署 + if (result.success) { + sendMessage(chatId, `✅ 已发布并部署: ${s.title}`); + } else if (result.conflict) { + sendMessage(chatId, `⚠️ 已保存但部署冲突,请手动解决`); + } else { + sendMessage(chatId, `❌ 部署失败: ${result.error}`); + } + clearSession(chatId); +} +``` + +### 3.5 长轮询 — `src/lib/bot/poll.ts` + +从 bot 的 `tg-poll.js` 移植,修复两个问题: + +1. **添加指数退避**:出错后等待 3s → 6s → 12s → 最大 60s,避免 Telegram API 限流 +2. **添加正常间隔**:`getUpdates` 返回后等待 100ms 再发起下一次,避免空转 + +```typescript +export async function startPolling(): Promise { + let lastOffset = 0; + let backoff = 3000; + + async function poll() { + if (!TG_TOKEN) return; + try { + const data = await tg("getUpdates", { + offset: lastOffset + 1, + timeout: 30, + allowed_updates: ["message", "callback_query"], + }); + if (data.ok && data.result) { + for (const update of data.result) { + lastOffset = update.update_id; + // ... 处理消息(逻辑不变) + } + } + backoff = 3000; // 成功则重置退避 + } catch (e) { + console.error("[bot:poll]", e.message); + await sleep(backoff); + backoff = Math.min(backoff * 2, 60000); + } + await sleep(100); // 正常间隔 + setImmediate(poll); + } + + poll(); +} +``` + +### 3.6 图片处理改进 + +当前 bot 直接引用 Telegram 临时 URL,改为下载到本地: + +```typescript +// 在 poll.ts 中处理 photo 消息 +if (msg.photo && sessions.has(chatId)) { + const s = getSession(chatId); + const largestPhoto = msg.photo[msg.photo.length - 1]; + const fileRes = await tg("getFile", { file_id: largestPhoto.file_id }); + if (fileRes.ok) { + const filePath = fileRes.result.file_path; + const fileUrl = `https://api.telegram.org/file/bot${TG_TOKEN}/${filePath}`; + const imgBuffer = await fetch(fileUrl).then(r => r.arrayBuffer()); + + // 保存到 static/upload/ 目录 + const filename = `${Date.now()}-${largestPhoto.file_id.slice(0, 8)}.jpg`; + const savePath = path.join(STATIC_DIR, "upload", filename); + fs.writeFileSync(savePath, Buffer.from(imgBuffer)); + + // 引用本地路径 + const caption = msg.caption || ""; + s.content += `\n![${caption || "image"}](/upload/${filename})\n`; + } +} +``` + +--- + +## 四、Web 端 API 路由适配 + +### 4.1 `/api/hugo/route.ts` — 改用 HugoManager + +```typescript +import { hugo } from "@/lib/hugo"; + +export async function GET() { + const result = await hugo.ensureRunning(); + return NextResponse.json({ running: result, url: "http://localhost:1313" }); +} +``` + +### 4.2 `/api/deploy/route.ts` — 改用 GitDeployQueue + +```typescript +import { deployQueue } from "@/lib/git"; + +export async function POST(request: NextRequest) { + const { title } = await request.json(); + const result = await deployQueue.enqueue(`发布: ${title}`); + return NextResponse.json(result); +} +``` + +--- + +## 五、配置合并 + +将 bot 的 `.env` 字段合入 write 的 `.env`: + +```env +# ---- Write 原有 ---- +RSS_API_BASE=https://api.usj.cc +RSS_API_TOKEN=xxx +WECHAT_APP_ID=xxx +WECHAT_APP_SECRET=xxx +DEEPSEEK_API_KEY=xxx +ARTALK_SERVER=https://artalk.usj.cc + +# ---- Bot 合并过来 ---- +TG_BOT_TOKEN=xxx +TG_ALLOWED_CHAT_IDS=xxx +PREFER_IFACE=WLAN +``` + +`src/lib/config.ts` 新增: + +```typescript +export const TG_BOT_TOKEN = process.env.TG_BOT_TOKEN || ""; +export const TG_ALLOWED_IDS = (process.env.TG_ALLOWED_CHAT_IDS || "") + .split(",").map(s => s.trim()).filter(Boolean).map(Number); +export const PREFER_IFACE = process.env.PREFER_IFACE || "WLAN"; +``` + +--- + +## 六、Bot 管理页面(替代 admin-panel) + +将 bot 的 `admin-panel.js`(独立 HTTP 服务器)改为 Next.js 页面 `/bot/page.tsx`: + +- 显示 Telegram Bot 连接状态(是否在轮询、最后收到消息的时间) +- 显示 Hugo 进程状态 +- 显示最近的 bot 操作日志(最近 10 条) +- 提供重启 bot 的按钮 + +对应 API 路由 `/api/bot/status/route.ts` 和 `/api/bot/restart/route.ts`。 + +这样 bot 的管理界面和 write 的其他页面共用同一个 Next.js 服务器,不再需要单独的 8017 端口。 + +--- + +## 七、实施步骤 + +按依赖关系排序: + +### 第一步:基础设施(无现有代码改动) +1. 创建 `src/lib/hugo.ts` — HugoManager 单例 +2. 创建 `src/lib/git.ts` — GitDeployQueue +3. 更新 `src/lib/config.ts` — 合并 bot 环境变量 +4. 更新 `.env` — 合并 bot 配置项 + +### 第二步:移植 Bot 模块 +5. 创建 `src/lib/bot/helpers.ts` — 工具函数 +6. 创建 `src/lib/bot/config.ts` — bot 专用配置 +7. 创建 `src/lib/bot/tg.ts` — Telegram API 封装 +8. 创建 `src/lib/bot/sessions.ts` — 会话管理 +9. 创建 `src/lib/bot/handlers.ts` — 命令处理器(直接调 lib) +10. 创建 `src/lib/bot/poll.ts` — 长轮询(带退避) +11. 创建 `src/lib/bot/index.ts` — 启动入口 + +### 第三步:Next.js 集成 +12. 创建 `src/instrumentation.ts` — 服务端启动钩子 +13. 更新 `next.config.ts` — 确保 instrumentation 启用 + +### 第四步:适配现有 API 路由 +14. 改造 `src/app/api/hugo/route.ts` — 使用 HugoManager +15. 改造 `src/app/api/deploy/route.ts` — 使用 GitDeployQueue + +### 第五步:新增 Bot 管理页面 +16. 创建 `src/app/api/bot/status/route.ts` +17. 创建 `src/app/api/bot/restart/route.ts` +18. 创建 `src/app/bot/page.tsx` — Bot 管理 UI + +### 第六步:清理 +19. 删除 `bot/` 目录(或归档) +20. 更新 `start.bat` — 不再需要分别启动两个进程 +21. 更新 `.gitignore` — 移除 bot 相关条目 + +--- + +## 八、解决的问题清单 + +| 原问题 | 解决方式 | +|---|---| +| 两个进程各自操作同一份文件系统 | 合并为一个进程,所有文件操作走同一层 lib | +| Hugo 两边各管各的 | HugoManager 单例,一个进程只启动一个 Hugo | +| Git deploy 无并发控制 | GitDeployQueue 串行执行 | +| bot 调 write API 走 HTTP 网络 | 直接调 lib 函数,零网络开销 | +| bot 的 writeReady 不会自动恢复 | HugoManager 带健康检查 + 自动重启 | +| 长轮询无退避策略 | 指数退避 3s→60s | +| Telegram 图片用临时 URL | 下载到 static/upload/ 本地引用 | +| bot 的 admin-panel 单独占一个端口 | 改为 Next.js 内页面 | + +--- + +## 九、不变的部分 + +以下保持不变,不做改动: +- `src/app/` 下所有页面(前端 UI) +- `src/components/` 下所有组件 +- `src/lib/posts.ts` 的核心逻辑(仅改为在 handler 中直接调用) +- `src/lib/ai.ts`、`src/lib/artalk.ts`、`src/lib/recycle.ts`、`src/lib/rssapi.ts` +- `hugo.toml`、`content/`、`themes/`、`static/`、`data/` +- GitHub Actions workflows 和 `scripts/` 目录 +- `package.json` 的根目录脚本依赖 diff --git a/write/install-startup.bat b/write/install-startup.bat new file mode 100644 index 00000000..667c0ebd --- /dev/null +++ b/write/install-startup.bat @@ -0,0 +1,13 @@ +@echo off +chcp 65001 >nul +set "STARTUP=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup" +set "VBS=E:\GitHub\blog\write\startup.vbs" + +REM Create shortcut in Startup folder +echo Set WshShell = CreateObject("WScript.Shell") > "%STARTUP%\WriteBot.vbs" +echo WshShell.Run Chr(34) ^& "E:\GitHub\blog\write\start-silent.bat" ^& Chr(34), 0, False >> "%STARTUP%\WriteBot.vbs" +echo Set WshShell = Nothing >> "%STARTUP%\WriteBot.vbs" + +echo Done! WriteBot will start silently on Windows login. +echo Startup folder: %STARTUP% +pause diff --git a/write/install-startup.ps1 b/write/install-startup.ps1 new file mode 100644 index 00000000..e04b3b37 --- /dev/null +++ b/write/install-startup.ps1 @@ -0,0 +1,21 @@ +# Get Startup folder path +$startup = [Environment]::GetFolderPath('Startup') +$batPath = "E:\GitHub\blog\write\start-silent.bat" +$vbsPath = Join-Path $startup "WriteBot.vbs" + +# Create VBS wrapper for silent execution (no cmd window) +$vbs = @" +Set WshShell = CreateObject("WScript.Shell") +WshShell.Run Chr(34) & "$batPath" & Chr(34), 0, False +Set WshShell = Nothing +"@ + +$vbs | Out-File -FilePath $vbsPath -Encoding ASCII + +# Verify +if (Test-Path $vbsPath) { + Write-Host "WriteBot startup script installed to: $vbsPath" + Write-Host "It will start silently on Windows login." +} else { + Write-Host "Failed to install startup script." +} diff --git a/write/package-lock.json b/write/package-lock.json index e26fe424..222f0a69 100644 --- a/write/package-lock.json +++ b/write/package-lock.json @@ -11,6 +11,7 @@ "date-fns": "^4.2.1", "gray-matter": "^4.0.3", "lucide-react": "^1.16.0", + "marked": "^18.0.4", "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4" @@ -5020,6 +5021,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/write/package.json b/write/package.json index 23eb9657..4806c33a 100644 --- a/write/package.json +++ b/write/package.json @@ -12,6 +12,7 @@ "date-fns": "^4.2.1", "gray-matter": "^4.0.3", "lucide-react": "^1.16.0", + "marked": "^18.0.4", "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4" diff --git a/write/setup-autostart.bat b/write/setup-autostart.bat new file mode 100644 index 00000000..06e0b7b1 --- /dev/null +++ b/write/setup-autostart.bat @@ -0,0 +1,33 @@ +@echo off +chcp 65001 >nul +echo ======================================== +echo WriteBot Auto-Start Setup +echo ======================================== +echo. + +REM Check for admin rights +net session >nul 2>&1 +if %errorlevel% neq 0 ( + echo This script requires Administrator privileges. + echo Right-click this file and select "Run as administrator". + echo. + pause + exit /b 1 +) + +echo Creating scheduled task... +schtasks /create /tn "WriteBot" /tr "cmd /c cd /d E:\GitHub\blog\write && npm run dev -- -p 8016" /sc onlogon /rl highest /f + +if %errorlevel%==0 ( + echo. + echo [OK] WriteBot scheduled task created. + echo. + echo Verify: schtasks /query /tn "WriteBot" + echo Remove: schtasks /delete /tn "WriteBot" /f +) else ( + echo. + echo [FAIL] Could not create task. +) + +echo. +pause diff --git a/write/src/app/api/bot/restart/route.ts b/write/src/app/api/bot/restart/route.ts new file mode 100644 index 00000000..757b13e0 --- /dev/null +++ b/write/src/app/api/bot/restart/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { hugo } from "@/lib/hugo"; + +export async function POST() { + try { + hugo.stop(); + const result = await hugo.start(); + return NextResponse.json({ ok: result.ok }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed" }, + { status: 500 } + ); + } +} diff --git a/write/src/app/api/bot/status/route.ts b/write/src/app/api/bot/status/route.ts new file mode 100644 index 00000000..eea7e7a7 --- /dev/null +++ b/write/src/app/api/bot/status/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { hugo } from "@/lib/hugo"; +import { TG_BOT_TOKEN } from "@/lib/config"; + +export async function GET() { + const hugoStatus = hugo.status(); + return NextResponse.json({ + bot: { + enabled: !!TG_BOT_TOKEN, + polling: !!TG_BOT_TOKEN, + }, + hugo: hugoStatus, + }); +} diff --git a/write/src/app/api/deploy/route.ts b/write/src/app/api/deploy/route.ts index 25bc7ed4..9bd354e5 100644 --- a/write/src/app/api/deploy/route.ts +++ b/write/src/app/api/deploy/route.ts @@ -1,39 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { execFile } from "child_process"; -import { promisify } from "util"; -import { BLOG_ROOT } from "@/lib/config"; - -const execFileAsync = promisify(execFile); - -type GitExecError = Error & { - stdout?: string; - stderr?: string; - code?: number; -}; - -async function runGit(args: string[], allowFailure = false) { - try { - const { stdout, stderr } = await execFileAsync("git", args, { - cwd: BLOG_ROOT, - windowsHide: true, - maxBuffer: 1024 * 1024, - }); - return { stdout, stderr, code: 0 }; - } catch (error) { - const err = error as GitExecError; - const result = { - stdout: err.stdout || "", - stderr: err.stderr || err.message, - code: typeof err.code === "number" ? err.code : 1, - }; - if (allowFailure) return result; - throw new Error((result.stderr || result.stdout || err.message).trim()); - } -} - -function isRebaseConflict(message: string) { - return /conflict|CONFLICT|Resolve all conflicts|Automatic merge failed|could not apply|rebase/i.test(message); -} +import { deployQueue } from "@/lib/git"; export async function POST(request: NextRequest) { try { @@ -44,40 +10,23 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Title is required" }, { status: 400 }); } - let output = ""; + const result = await deployQueue.enqueue(`发布: ${title}`); - const addResult = await runGit(["add", "content/post", "static"]); - output += addResult.stdout + addResult.stderr; - - const diffResult = await runGit(["diff", "--cached", "--quiet"], true); - if (diffResult.code === 1) { - const commitResult = await runGit(["commit", "-m", `发布: ${title}`]); - output += commitResult.stdout + commitResult.stderr; - } else if (diffResult.code !== 0) { - throw new Error((diffResult.stderr || diffResult.stdout || "检查暂存区失败").trim()); + if (result.success) { + return NextResponse.json({ success: true, output: result.output }); } - try { - const pullResult = await runGit(["pull", "--rebase", "--autostash", "origin", "main"]); - output += pullResult.stdout + pullResult.stderr; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (isRebaseConflict(message)) { - return NextResponse.json( - { - error: "远程仓库有新内容,自动合并失败。请在终端解决冲突后执行 git rebase --continue,再重新发布。", - detail: message.slice(0, 1000), - }, - { status: 409 } - ); - } - throw error; + if (result.conflict) { + return NextResponse.json( + { + error: result.error, + detail: result.output, + }, + { status: 409 } + ); } - const pushResult = await runGit(["push"]); - output += pushResult.stdout + pushResult.stderr; - - return NextResponse.json({ success: true, output: output.slice(0, 1000) }); + return NextResponse.json({ error: result.error }, { status: 500 }); } catch (error: unknown) { return NextResponse.json( { error: error instanceof Error ? error.message : "Deploy failed" }, diff --git a/write/src/app/api/hugo/route.ts b/write/src/app/api/hugo/route.ts index afffae7e..802c6c46 100644 --- a/write/src/app/api/hugo/route.ts +++ b/write/src/app/api/hugo/route.ts @@ -1,50 +1,27 @@ import { NextResponse } from "next/server"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { BLOG_ROOT, HUGO_PREVIEW } from "@/lib/config"; - -const execAsync = promisify(exec); - -async function isHugoRunning(): Promise { - try { - const res = await fetch(HUGO_PREVIEW, { signal: AbortSignal.timeout(1500) }); - return res.ok || res.status < 500; - } catch { - return false; - } -} - -let starting = false; +import { hugo } from "@/lib/hugo"; +import { HUGO_PREVIEW } from "@/lib/config"; export async function GET() { try { - const running = await isHugoRunning(); - if (running) { + const result = await hugo.start(); + if (result.alreadyRunning || result.ok) { return NextResponse.json({ running: true, url: HUGO_PREVIEW }); } - - if (starting) { - return NextResponse.json({ running: false, starting: true, url: HUGO_PREVIEW }); - } - - starting = true; - // Kill existing process on port 1313, then start hugo serve - const killCmd = `for /f "tokens=5" %a in ('netstat -ano ^| findstr :1313 ^| findstr LISTENING') do taskkill /F /PID %a 2>nul`; - const startCmd = `cd "${BLOG_ROOT}" && hugo serve --port 1313 --noHTTPCache`; - - try { await execAsync(killCmd); } catch { /* nothing to kill */ } - exec(startCmd, (err) => { - if (err) console.error("Hugo start error:", err.message); - }); - - // Wait a moment for Hugo to start - await new Promise((r) => setTimeout(r, 2000)); - starting = false; - - const started = await isHugoRunning(); - return NextResponse.json({ running: started, url: HUGO_PREVIEW }); + return NextResponse.json({ running: false, url: HUGO_PREVIEW }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed" }, + { status: 500 } + ); + } +} + +export async function DELETE() { + try { + hugo.stop(); + return NextResponse.json({ running: false }); } catch (error: unknown) { - starting = false; return NextResponse.json( { error: error instanceof Error ? error.message : "Failed" }, { status: 500 } diff --git a/write/src/app/bot/page.tsx b/write/src/app/bot/page.tsx new file mode 100644 index 00000000..f6cf5759 --- /dev/null +++ b/write/src/app/bot/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Bot, RefreshCw, Server } from "lucide-react"; + +interface BotStatus { + bot: { enabled: boolean; polling: boolean }; + hugo: { running: boolean; starting: boolean }; +} + +export default function BotPage() { + const [status, setStatus] = useState(null); + const [restarting, setRestarting] = useState(false); + + async function fetchStatus() { + try { + const res = await fetch("/api/bot/status"); + const data = await res.json(); + setStatus(data); + } catch { + setStatus(null); + } + } + + async function restartHugo() { + setRestarting(true); + try { + await fetch("/api/bot/restart", { method: "POST" }); + } catch { /* ignore */ } + setTimeout(async () => { + await fetchStatus(); + setRestarting(false); + }, 3000); + } + + useEffect(() => { + fetchStatus(); + const timer = setInterval(fetchStatus, 10000); + return () => clearInterval(timer); + }, []); + + return ( +
+

Bot 管理

+ + {/* Bot Status */} +
+

+ Telegram Bot +

+
+
+ + {status?.bot.enabled + ? "已配置,随服务自动启动" + : "未配置 TG_BOT_TOKEN"} + +
+
+ + {/* Hugo Status */} +
+

+ Hugo Server +

+
+
+ + {status?.hugo.running + ? "运行中 — localhost:1313" + : status?.hugo.starting + ? "启动中..." + : "已停止"} + +
+ +
+
+ ); +} diff --git a/write/src/components/Sidebar.tsx b/write/src/components/Sidebar.tsx index 7d3840fc..b1071ee0 100644 --- a/write/src/components/Sidebar.tsx +++ b/write/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; -import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X } from "lucide-react"; +import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X, Bot } from "lucide-react"; import ThemeToggle from "./ThemeToggle"; const navItems = [ @@ -15,6 +15,7 @@ const navItems = [ { href: "/images", label: "图片", icon: Image }, { href: "/wechat", label: "公众号", icon: MessageCircle }, { href: "/recycle", label: "回收站", icon: Trash2 }, + { href: "/bot", label: "Bot", icon: Bot }, ]; export default function Sidebar() { diff --git a/write/src/instrumentation.ts b/write/src/instrumentation.ts new file mode 100644 index 00000000..ded2b0a1 --- /dev/null +++ b/write/src/instrumentation.ts @@ -0,0 +1,8 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { startBot } = await import("@/lib/bot"); + startBot().catch((e) => { + console.error("[instrumentation] bot startup failed:", e); + }); + } +} diff --git a/write/src/lib/bot/cache.ts b/write/src/lib/bot/cache.ts new file mode 100644 index 00000000..b6696c54 --- /dev/null +++ b/write/src/lib/bot/cache.ts @@ -0,0 +1,30 @@ +interface CacheEntry { + data: T; + expiresAt: number; +} + +const cache = new Map>(); + +export function getCached(key: string): T | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.data as T; +} + +export function setCache(key: string, data: T, ttlMs: number): void { + cache.set(key, { data, expiresAt: Date.now() + ttlMs }); +} + +export function invalidateCache(key: string): void { + cache.delete(key); +} + +export function invalidateByPrefix(prefix: string): void { + for (const key of cache.keys()) { + if (key.startsWith(prefix)) cache.delete(key); + } +} diff --git a/write/src/lib/bot/config.ts b/write/src/lib/bot/config.ts new file mode 100644 index 00000000..37dddb21 --- /dev/null +++ b/write/src/lib/bot/config.ts @@ -0,0 +1,5 @@ +import { TG_BOT_TOKEN, TG_ALLOWED_IDS } from "@/lib/config"; + +export const TG_TOKEN = TG_BOT_TOKEN; +export const ALLOWED_IDS = TG_ALLOWED_IDS; +export const API = TG_TOKEN ? `https://api.telegram.org/bot${TG_TOKEN}` : ""; diff --git a/write/src/lib/bot/handlers.ts b/write/src/lib/bot/handlers.ts new file mode 100644 index 00000000..57861f88 --- /dev/null +++ b/write/src/lib/bot/handlers.ts @@ -0,0 +1,654 @@ +import { getPost, listPosts } from "@/lib/posts"; +import { getLinks, addLink, updateLink, deleteLink, getFeeds, addFeed, updateFeed, deleteFeed, getFeedsHealth, getArticles } from "@/lib/rssapi"; +import { moveToRecycle } from "@/lib/recycle"; +import { deployQueue } from "@/lib/git"; +import { hugo } from "@/lib/hugo"; +import { getLanIP, DEFAULT_AUTHOR } from "@/lib/config"; +import { sendMessage, tg } from "./tg"; +import { getSession, hasSession, clearSession, saveSession, undoLastChunk } from "./sessions"; +import { makeSlug, sessionSummary, truncateText } from "./helpers"; +import { getCached, setCache, invalidateByPrefix } from "./cache"; + +type Handler = (chatId: number, arg?: string) => Promise; + +const handlers: Record = {}; + +// ---- Cached API wrappers ---- + +const CACHE_TTL_LINKS = 5 * 60 * 1000; +const CACHE_TTL_FEEDS = 5 * 60 * 1000; +const CACHE_TTL_ARTICLES = 3 * 60 * 1000; +const CACHE_TTL_HEALTH = 10 * 60 * 1000; + +async function cachedLinks(all = true) { + const key = "links_" + (all ? "all" : "visible"); + const hit = getCached extends Promise ? R : never>(key); + if (hit) return hit; + const data = await getLinks(all); + setCache(key, data, CACHE_TTL_LINKS); + return data; +} + +async function cachedFeeds() { + const key = "feeds"; + const hit = getCached extends Promise ? R : never>(key); + if (hit) return hit; + const data = await getFeeds(); + setCache(key, data, CACHE_TTL_FEEDS); + return data; +} + +async function cachedArticles(limit: number) { + const key = "articles_" + limit; + const hit = getCached extends Promise ? R : never>(key); + if (hit) return hit; + const data = await getArticles(limit); + setCache(key, data, CACHE_TTL_ARTICLES); + return data; +} + +async function cachedHealth() { + const key = "health"; + const hit = getCached extends Promise ? R : never>(key); + if (hit) return hit; + const data = await getFeedsHealth(); + setCache(key, data, CACHE_TTL_HEALTH); + return data; +} + +// ---- 键盘 ---- + +function mainKeyboard() { + return { + keyboard: [ + [{ text: "/new" }, { text: "/list" }], + [{ text: "/links" }, { text: "/feeds" }], + [{ text: "/stats" }, { text: "/help" }], + ], + resize_keyboard: true, + one_time_keyboard: false, + }; +} + +function writingKeyboard() { + return { + keyboard: [ + [{ text: "/preview" }, { text: "/undo" }], + [{ text: "/publish" }, { text: "/deploy" }], + [{ text: "/draft" }, { text: "/cancel" }], + [{ text: "/title" }, { text: "/categories" }], + ], + resize_keyboard: true, + one_time_keyboard: false, + }; +} + +async function sendMain(chatId: number, text: string) { + await sendMessage(chatId, text, { reply_markup: mainKeyboard() }); +} + +async function sendWrite(chatId: number, text: string) { + await sendMessage(chatId, text, { reply_markup: writingKeyboard() }); +} + +// ---- System ---- + +handlers.start = async (chatId) => { + const ok = await hugo.ensureRunning(); + const ip = getLanIP(); + await sendMain(chatId, "✅ 已启动\n\n📱 手机: http://" + ip + ":8016/\n💻 电脑: http://localhost:8016/"); + await tg("sendMessage", { + chat_id: chatId, + text: "点下方按钮打开编辑器:", + reply_markup: { inline_keyboard: [[{ text: "🌐 打开编辑器", web_app: { url: "http://" + ip + ":8016/" } }]] }, + }); +}; + +handlers.stop = async (chatId) => { hugo.stop(); await sendMain(chatId, "⚡ Hugo 已停止。"); }; + +handlers.status = async (chatId) => { + const s = hugo.status(); + const status = s.running ? "✅ 运行中" : s.starting ? "⏳ 启动中..." : "⏸ 已停止"; + const lines = ["Hugo: " + status]; + if (s.running) { const ip = getLanIP(); lines.push("📱 http://" + ip + ":8016", "💻 http://localhost:8016"); } + await sendMain(chatId, lines.join("\n")); +}; + +handlers.ip = async (chatId) => { await sendMain(chatId, "Phone: http://" + getLanIP() + ":8016"); }; + +handlers.shutdown = async (chatId) => { + await tg("sendMessage", { + chat_id: chatId, + text: "⚠️ 确认关机?\n\n电脑将在确认后 10 秒关机。", + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [[ + { text: "✅ 确认关机", callback_data: "confirm_shutdown" }, + { text: "❌ 取消", callback_data: "cancel_shutdown" }, + ]], + }, + }); +}; + +handlers.confirm_shutdown = async (chatId) => { + await sendMessage(chatId, "⏻ 电脑将在 10 秒后关机..."); + const { exec } = await import("child_process"); + exec("shutdown /s /t 10", (err) => { + if (err) console.error("[shutdown]", err.message); + }); +}; + +handlers.cancel_shutdown = async (chatId) => { + const { exec } = await import("child_process"); + exec("shutdown /a", () => {}); + await sendMain(chatId, "已取消关机。"); +}; + +handlers.reboot = async (chatId) => { + await tg("sendMessage", { + chat_id: chatId, + text: "⚠️ 确认重启?\n\n电脑将在确认后 10 秒重启。", + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [[ + { text: "✅ 确认重启", callback_data: "confirm_reboot" }, + { text: "❌ 取消", callback_data: "cancel_reboot" }, + ]], + }, + }); +}; + +handlers.confirm_reboot = async (chatId) => { + await sendMessage(chatId, "🔄 电脑将在 10 秒后重启..."); + const { exec } = await import("child_process"); + exec("shutdown /r /t 10", (err) => { + if (err) console.error("[reboot]", err.message); + }); +}; + +handlers.cancel_reboot = async (chatId) => { + const { exec } = await import("child_process"); + exec("shutdown /a", () => {}); + await sendMain(chatId, "已取消重启。"); +}; + +handlers.help = async (chatId) => { + if (hasSession(chatId)) { + await sendWrite(chatId, + "✍ 写作中\n\n" + + "/publish 保存 /draft 草稿 /deploy 部署\n" + + "/preview 预览 /undo 撤销 /cancel 取消\n" + + "/title 标题 /categories 分类 /tags 标签\n\n" + + "直接发消息追加正文,发照片追加图片" + ); + } else { + await sendMain(chatId, + "🤖 WriteBot 命令\n\n" + + "📝 文章\n" + + "/new 标题 — 新建\n" + + "/edit slug — 编辑\n" + + "/delete slug — 删除\n" + + "/list — 文章列表\n\n" + + "🔗 友链\n" + + "/links — 列表\n" + + "/link_add url 名称 — 添加\n" + + "/link_edit url — 编辑\n" + + "/link_del url — 删除\n" + + "/link_toggle url — 显示/隐藏\n\n" + + "📡 订阅\n" + + "/feeds — 列表\n" + + "/feed_add url — 添加\n" + + "/feed_edit url — 编辑\n" + + "/feed_del url — 删除\n" + + "/feed_health — 健康检查\n\n" + + "📰 /read — 友链最新文章\n" + + "📊 /stats — 网站数据\n\n" + + "⏻ /shutdown — 关机 🔄 /reboot — 重启" + ); + } +}; + +// ---- Articles: New ---- + +handlers.new = async (chatId, title) => { + if (title) { + const s = getSession(chatId); + s.title = title; + s.slug = makeSlug(title); + s.content = ""; + s.categories = []; + s.tags = []; + s.draft = true; + s.author = DEFAULT_AUTHOR; + s.chunks = []; + await tg("sendMessage", { + chat_id: chatId, + text: "📝 开始写:" + title + "\n\n📂 设置分类(可选)", + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [[ + { text: "跳过", callback_data: "skip_cat" }, + { text: "设置分类", callback_data: "skip_cat" }, + ]], + }, + }); + await tg("sendMessage", { + chat_id: chatId, + text: "请输入分类(逗号分隔,或发「跳过」):", + reply_markup: { force_reply: true, input_field_placeholder: "技术、生活" }, + }); + } else { + await tg("sendMessage", { chat_id: chatId, text: "📝 请输入文章标题:", reply_markup: { force_reply: true, input_field_placeholder: "在这里输入标题..." } }); + } +}; + +handlers.skip_categories = async (chatId) => { + const s = getSession(chatId); + if (!s.title) return; + s.categories = []; + await tg("sendMessage", { + chat_id: chatId, + text: "🏷 设置标签(可选)", + reply_markup: { + inline_keyboard: [[ + { text: "跳过", callback_data: "skip_tag" }, + { text: "设置标签", callback_data: "skip_tag" }, + ]], + }, + }); + await tg("sendMessage", { + chat_id: chatId, + text: "请输入标签(逗号分隔,或发「跳过」):", + reply_markup: { force_reply: true, input_field_placeholder: "Hugo、博客" }, + }); +}; + +handlers.skip_tags = async (chatId) => { + const s = getSession(chatId); + if (!s.title) return; + s.tags = []; + await sendWrite(chatId, "✅ 准备就绪,开始写正文吧!\n\n📝 " + s.title + "\n📂 " + (s.categories.join("、") || "无分类") + "\n🏷 " + (s.tags.join("、") || "无标签")); +}; + +handlers.categories = async (chatId, cats) => { + const s = getSession(chatId); + s.categories = cats ? cats.split(/[,;",、]+/).map((c) => c.trim()).filter(Boolean) : []; + await tg("sendMessage", { + chat_id: chatId, + text: "📂 分类:" + (s.categories.join("、") || "无") + "\n\n🏷 设置标签(可选)", + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [[ + { text: "跳过", callback_data: "skip_tag" }, + { text: "设置标签", callback_data: "skip_tag" }, + ]], + }, + }); + await tg("sendMessage", { + chat_id: chatId, + text: "请输入标签(逗号分隔,或发「跳过」):", + reply_markup: { force_reply: true, input_field_placeholder: "Hugo、博客" }, + }); +}; + +handlers.tags = async (chatId, tags) => { + const s = getSession(chatId); + s.tags = tags ? tags.split(/[,;",、]+/).map((t) => t.trim()).filter(Boolean) : []; + await sendWrite(chatId, "✅ 准备就绪,开始写正文吧!\n\n📝 " + s.title + "\n📂 " + (s.categories.join("、") || "无分类") + "\n🏷 " + (s.tags.join("、") || "无标签")); +}; + +// ---- Articles: Edit (方案 B) ---- + +handlers.edit = async (chatId, slug) => { + if (!slug) { await sendMain(chatId, "格式:/edit slug\n先用 /list 查看文章。"); return; } + const post = getPost(slug); + if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; } + + const originalContent = post.content || ""; + if (originalContent) { + const maxLen = 3800; + for (let i = 0; i < originalContent.length; i += maxLen) { + const chunk = originalContent.slice(i, i + maxLen); + const prefix = originalContent.length > maxLen ? "(" + (Math.floor(i / maxLen) + 1) + "/" + Math.ceil(originalContent.length / maxLen) + ")\n" : ""; + await sendMessage(chatId, prefix + chunk); + } + } + + const s = getSession(chatId); + s.title = post.frontMatter.title; + s.slug = post.frontMatter.slug; + s.originalSlug = post.frontMatter.slug; + s.content = ""; + s.chunks = []; + s.categories = (post.frontMatter.categories as string[]) || []; + s.tags = (post.frontMatter.tags as string[]) || []; + s.author = post.frontMatter.author || DEFAULT_AUTHOR; + s.draft = post.frontMatter.draft ?? true; + + await sendWrite(chatId, + "✏ 编辑:" + s.title + "\n\n" + + "原文已显示,现在重新写正文\n" + + "📂 " + (s.categories.join("、") || "无分类") + "\n" + + "🏷 " + (s.tags.join("、") || "无标签") + "\n\n" + + "直接发消息写正文,完成后点「发布」" + ); +}; + +// ---- Articles: Delete (二次确认) ---- + +handlers.delete = async (chatId, slug) => { + if (!slug) { await sendMain(chatId, "格式:/delete slug\n先用 /list 查看文章。"); return; } + const post = getPost(slug); + if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; } + await tg("sendMessage", { + chat_id: chatId, + text: "⚠️ 确认删除《" + post.frontMatter.title + "》?\n\n删除后移入回收站,30天内可恢复。", + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [[ + { text: "✅ 确认删除", callback_data: "confirm_del_" + slug }, + { text: "❌ 取消", callback_data: "cancel_del_" + slug }, + ]], + }, + }); +}; + +handlers.confirm_del = async (chatId, slug) => { + if (!slug) return; + const post = getPost(slug); + if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; } + moveToRecycle(slug, post.dirPath, post.frontMatter.title); + await sendMain(chatId, "✂ 已移入回收站:" + post.frontMatter.title + "\n30天内可恢复。"); +}; + +handlers.cancel_del = async (chatId) => { + await sendMain(chatId, "已取消删除。"); +}; + +// ---- Articles: List ---- + +handlers.list = async (chatId, pageStr) => { + const page = parseInt(pageStr || "1") || 1; + const perPage = 10; + try { + const { posts, total, totalPages } = listPosts({ page, perPage }); + if (total === 0) { await sendMain(chatId, "📖 还没有文章,发 /new 开始写。"); return; } + const lines = ["📖 文章列表 共 " + total + " 篇 · 第 " + page + "/" + totalPages + " 页\n"]; + for (const p of posts) { + const draft = p.draft ? " 📝" : ""; + lines.push(p.title + draft + "\n " + p.slug + ""); + } + const navRow: { text: string; callback_data: string }[] = []; + if (page > 1) navRow.push({ text: "◀ 上一页", callback_data: "list_" + (page - 1) }); + if (page < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "list_" + (page + 1) }); + await tg("sendMessage", { + chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true, + reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined, + }); + } catch (e: unknown) { await sendMain(chatId, "❌ " + (e instanceof Error ? e.message : String(e))); } +}; + +// ---- Articles: View / Title / Author / Preview ---- + +handlers.view = async (chatId, slug) => { + if (!slug) { await sendMain(chatId, "格式:/view slug"); return; } + const ip = getLanIP(); + await tg("sendMessage", { + chat_id: chatId, + text: "🔖 " + slug + "\n\n📱 http://" + ip + ":1313/" + slug + ".html\n💻 http://localhost:1313/" + slug + ".html", + parse_mode: "HTML", disable_web_page_preview: true, + reply_markup: { inline_keyboard: [[{ text: "🌐 浏览器打开", url: "http://localhost:1313/" + slug + ".html" }]] }, + }); +}; + +handlers.title = async (chatId, newTitle) => { + if (!newTitle) { await tg("sendMessage", { chat_id: chatId, text: "请输入新标题:", reply_markup: { force_reply: true, input_field_placeholder: "新标题..." } }); return; } + const s = getSession(chatId); + s.title = newTitle; + await sendWrite(chatId, "✏ 标题已更新:" + newTitle + ""); +}; + +handlers.author = async (chatId, author) => { + getSession(chatId).author = author || DEFAULT_AUTHOR; + await sendWrite(chatId, "✍ 作者:" + getSession(chatId).author); +}; + +handlers.preview = async (chatId) => { + const s = getSession(chatId); + if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; } + if (!s.content) { await sendWrite(chatId, "正文为空。"); return; } + await sendWrite(chatId, "📝 " + s.title + "\n\n" + truncateText(s.content, 3800)); +}; + +// ---- Writing: Publish / Draft / Deploy / Sync / Undo / Cancel ---- + +handlers.publish = async (chatId) => { + const s = getSession(chatId); + if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; } + if (!s.content) { await sendWrite(chatId, "正文为空,先写点内容再发布。"); return; } + try { + const title = s.title; + await saveSession(s, false); + clearSession(chatId); + await sendMain(chatId, "✅ 已保存:" + title + "\n\n发 /deploy 推送到线上"); + } catch (e: unknown) { await sendWrite(chatId, "❌ 保存失败:" + (e instanceof Error ? e.message : String(e))); } +}; + +handlers.draft = async (chatId) => { + const s = getSession(chatId); + if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; } + if (!s.content) { await sendWrite(chatId, "正文为空,先写点内容再保存。"); return; } + try { + const title = s.title; + await saveSession(s, true); + clearSession(chatId); + await sendMain(chatId, "📄 草稿已保存:" + title + ""); + } catch (e: unknown) { await sendWrite(chatId, "❌ 保存失败:" + (e instanceof Error ? e.message : String(e))); } +}; + +handlers.deploy = async (chatId) => { + await sendMessage(chatId, "⏳ 正在部署...", { reply_markup: { remove_keyboard: true } }); + const result = await deployQueue.enqueue("发布文章"); + if (result.success) await sendMain(chatId, "🚀 已部署!\n\n🌐 https://usj.cc"); + else if (result.conflict) await sendMain(chatId, "⚠️ " + (result.error || "远程冲突")); + else await sendMain(chatId, "❌ 部署失败:" + (result.error || "未知错误")); +}; + +handlers.sync = async (chatId) => { + await sendMessage(chatId, "⏳ 正在同步远程..."); + const result = await deployQueue.sync(); + if (result.success) await sendMain(chatId, "✅ 同步完成\n\n" + (result.output || "").slice(0, 500)); + else await sendMain(chatId, "❌ " + (result.error || "同步失败")); +}; + +handlers.undo = async (chatId) => { + if (!hasSession(chatId)) { await sendMain(chatId, "没有活动的草稿。"); return; } + const ok = undoLastChunk(chatId); + if (!ok) { await sendWrite(chatId, "没有可撤销的内容。"); return; } + const s = getSession(chatId); + await sendWrite(chatId, "↩ 已撤销\n\n📝 " + s.title + "\n\n" + (s.content ? truncateText(s.content, 3800) : "(正文为空)")); +}; + +handlers.cancel = async (chatId) => { + if (!hasSession(chatId)) { await sendMain(chatId, "没有活动的草稿。"); return; } + clearSession(chatId); + await sendMain(chatId, "✂ 草稿已丢弃。"); +}; + +// ---- Links ---- + +handlers.links = async (chatId, pageStr) => { + const page = parseInt(pageStr || "1") || 1; + const perPage = 10; + try { + const allLinks = await cachedLinks(true); + if (allLinks.length === 0) { await sendMain(chatId, "还没有友链。\n\n添加:/link_add url 名称"); return; } + const totalPages = Math.ceil(allLinks.length / perPage); + const p = Math.min(page, totalPages); + const start = (p - 1) * perPage; + const pageLinks = allLinks.slice(start, start + perPage); + const lines = ["🔗 友链 共 " + allLinks.length + " 个 · 第 " + p + "/" + totalPages + " 页\n"]; + for (const l of pageLinks) { + const icon = l.hidden ? "🔒" : "🌐"; + lines.push(icon + " " + (l.name || l.url) + "\n " + l.url); + } + lines.push("\n/link_add /link_edit /link_del /link_toggle"); + const navRow: { text: string; callback_data: string }[] = []; + if (p > 1) navRow.push({ text: "◀ 上一页", callback_data: "links_" + (p - 1) }); + if (p < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "links_" + (p + 1) }); + await tg("sendMessage", { + chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true, + reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined, + }); + } catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +handlers.link_add = async (chatId, arg) => { + if (!arg) { await tg("sendMessage", { chat_id: chatId, text: "请输入友链信息:\n格式:url 名称", reply_markup: { force_reply: true, input_field_placeholder: "url 名称" } }); return; } + const parts = arg.split(/\s+/); + let url = parts[0]; + const name = parts.slice(1).join(" ") || url; + if (!url.startsWith("http")) url = "https://" + url; + try { await addLink({ name, url }); invalidateByPrefix("links"); await sendMain(chatId, "✅ 友链已添加:" + name); } + catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +handlers.link_edit = async (chatId, arg) => { + if (!arg) { await sendMain(chatId, "格式:/link_edit url\n用 /links 查看友链。"); return; } + const s = getSession(chatId); + s.originalSlug = arg; + await tg("sendMessage", { + chat_id: chatId, + text: "当前 URL:" + arg + "\n请输入新名称(空则保持不变):", + reply_markup: { force_reply: true, input_field_placeholder: "新名称" }, + }); +}; + +handlers.link_del = async (chatId, url) => { + if (!url) { await sendMain(chatId, "格式:/link_del url"); return; } + try { await deleteLink(url); invalidateByPrefix("links"); await sendMain(chatId, "✅ 已删除:" + url); } + catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +handlers.link_toggle = async (chatId, url) => { + if (!url) { await sendMain(chatId, "格式:/link_toggle url"); return; } + try { + const allLinks = await cachedLinks(true); + const link = allLinks.find((l) => l.url === url); + if (!link) { await sendMain(chatId, "❌ 未找到:" + url); return; } + await updateLink(url, { hidden: !link.hidden }); + invalidateByPrefix("links"); + await sendMain(chatId, (!link.hidden ? "🔒 已隐藏" : "🌐 已显示") + ":" + (link.name || url)); + } catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +// ---- Feeds ---- + +handlers.feeds = async (chatId, pageStr) => { + const page = parseInt(pageStr || "1") || 1; + const perPage = 10; + try { + const allFeeds = await cachedFeeds(); + if (allFeeds.length === 0) { await sendMain(chatId, "还没有订阅源。\n\n添加:/feed_add url"); return; } + const totalPages = Math.ceil(allFeeds.length / perPage); + const p = Math.min(page, totalPages); + const start = (p - 1) * perPage; + const pageFeeds = allFeeds.slice(start, start + perPage); + const lines = ["📡 订阅源 共 " + allFeeds.length + " 个 · 第 " + p + "/" + totalPages + " 页\n"]; + for (const f of pageFeeds) { + lines.push("📰 " + (f.feedTitle || f.url) + "\n " + f.url); + } + lines.push("\n/feed_add /feed_edit /feed_del /feed_health"); + const navRow: { text: string; callback_data: string }[] = []; + if (p > 1) navRow.push({ text: "◀ 上一页", callback_data: "feeds_" + (p - 1) }); + if (p < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "feeds_" + (p + 1) }); + await tg("sendMessage", { + chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true, + reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined, + }); + } catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +handlers.feed_add = async (chatId, arg) => { + if (!arg) { await tg("sendMessage", { chat_id: chatId, text: "请输入订阅源 URL:", reply_markup: { force_reply: true, input_field_placeholder: "https://example.com/rss.xml" } }); return; } + const parts = arg.split(/\s+/); + const url = parts[0]; + const feedTitle = parts.slice(1).join(" ") || ""; + try { await addFeed(url, feedTitle); invalidateByPrefix("feeds"); await sendMain(chatId, "✅ 订阅源已添加:" + url); } + catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +handlers.feed_edit = async (chatId, arg) => { + if (!arg) { await sendMain(chatId, "格式:/feed_edit url\n用 /feeds 查看。"); return; } + const s = getSession(chatId); + s.originalSlug = arg; + await tg("sendMessage", { + chat_id: chatId, + text: "当前 URL:" + arg + "\n请输入新的订阅信息:\n格式:新URL 新名称", + reply_markup: { force_reply: true, input_field_placeholder: "新URL 新名称" }, + }); +}; + +handlers.feed_del = async (chatId, url) => { + if (!url) { await sendMain(chatId, "格式:/feed_del url"); return; } + try { await deleteFeed(url); invalidateByPrefix("feeds"); await sendMain(chatId, "✅ 已删除:" + url); } + catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +handlers.feed_health = async (chatId) => { + try { + await sendMessage(chatId, "⏳ 正在检测订阅源..."); + const result = await cachedHealth(); + const lines = ["📊 订阅源健康报告\n", "✅ 存活:" + result.alive + " ❌ 异常:" + result.dead]; + const dead = (result.results || []).filter((r) => !r.ok); + if (dead.length > 0) { + lines.push("\n❌ 异常站点:"); + for (const d of dead.slice(0, 10)) { + lines.push("🔴 " + d.url + " — " + (d.error || String(d.status))); + } + } + await sendMain(chatId, lines.join("\n")); + } catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +// ---- Data ---- + +handlers.stats = async (chatId) => { + try { + const { total } = listPosts({ perPage: 1 }); + const allLinks = await cachedLinks(true); + const allFeeds = await cachedFeeds(); + const visibleLinks = allLinks.filter((l) => !l.hidden).length; + + let extra = ""; + try { + const healthRes = await cachedHealth(); + extra = "\n\n📰 友链博客:" + healthRes.total + " 个"; + } catch (e) { /* ignore */ } + + await sendMain(chatId, + "📊 网站数据\n\n" + + "📝 本地文章:" + total + " 篇\n" + + "🔗 友链:" + visibleLinks + " 个\n" + + "📡 订阅源:" + allFeeds.length + " 个" + + extra + "\n\n🌐 usj.cc" + ); + } catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +handlers.read = async (chatId, limitStr) => { + const limit = parseInt(limitStr || "10") || 10; + try { + const articles = await cachedArticles(Math.min(limit, 20)); + if (articles.length === 0) { await sendMain(chatId, "暂无最新文章。"); return; } + const lines = ["📰 友链最新文章(" + articles.length + " 篇)\n"]; + for (const a of articles) { + lines.push((a.feedName || "未知") + " — " + a.title + "\n " + a.link); + } + await tg("sendMessage", { + chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true, + }); + } catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); } +}; + +export default handlers; diff --git a/write/src/lib/bot/helpers.ts b/write/src/lib/bot/helpers.ts new file mode 100644 index 00000000..81f36cad --- /dev/null +++ b/write/src/lib/bot/helpers.ts @@ -0,0 +1,37 @@ +import path from "path"; +import { CONTENT_DIR } from "@/lib/config"; + +export function makeSlug(title: string): string { + const dateStr = new Date().toISOString().slice(0, 10); + const slugPart = (title || "untitled") + .replace(/[^\w一-鿿㐀-䶿]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() || "post"; + return dateStr + "-" + slugPart; +} + +export function sessionSummary(s: { + title: string; + categories: string[]; + tags: string[]; + content: string; +}): string { + const lines = s.content ? s.content.split("\n").filter(Boolean).length : 0; + const chars = s.content.length; + const parts: string[] = []; + parts.push(`\u{1f4dd} ${s.title || "未命名"}`); + if (s.categories.length) parts.push(`\u{1f4c2} ${s.categories.join("、")}`); + if (s.tags.length) parts.push(`\u{1f3f7} ${s.tags.join("、")}`); + if (chars) parts.push(`✍ ${lines} 行 ${chars} 字`); + return parts.filter(Boolean).join(" · "); +} + +export function getArticleDirPath(slug: string): string { + const year = slug.slice(0, 4); + return path.join(CONTENT_DIR, year, slug); +} + +export function truncateText(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字"; +} diff --git a/write/src/lib/bot/index.ts b/write/src/lib/bot/index.ts new file mode 100644 index 00000000..6c6ecbe7 --- /dev/null +++ b/write/src/lib/bot/index.ts @@ -0,0 +1,21 @@ +import { startPolling } from "./poll"; +import { TG_TOKEN } from "./config"; + +const g = globalThis as Record; + +export async function startBot(): Promise { + if (g.__botStarted) return; + g.__botStarted = true; + + if (!TG_TOKEN) { + console.log("[bot] TG_BOT_TOKEN not configured, bot disabled"); + return; + } + + console.log("[bot] initializing..."); + await startPolling(); +} + +export { TG_TOKEN } from "./config"; +export { sendMessage } from "./tg"; +export { getSession, hasSession, clearSession } from "./sessions"; diff --git a/write/src/lib/bot/poll.ts b/write/src/lib/bot/poll.ts new file mode 100644 index 00000000..21d93069 --- /dev/null +++ b/write/src/lib/bot/poll.ts @@ -0,0 +1,246 @@ +import { TG_TOKEN } from "./config"; +import { tg, allowed, sendMessage } from "./tg"; +import { hasSession, getSession, appendContent } from "./sessions"; +import handlers from "./handlers"; + +const ARG_COMMANDS = new Set([ + "new", "title", "categories", "tags", "author", + "edit", "view", "delete", + "link_add", "link_edit", "link_del", "link_toggle", + "feed_add", "feed_edit", "feed_del", + "list", "links", "feeds", "read", +]); + +const CALLBACK_PREFIXES: Record = { + edit: "edit", + confirm_del: "delete", + cancel_del: "noop", + list: "list", + links: "links", + feeds: "feeds", + skip_cat: "skip_categories", + skip_tag: "skip_tags", + confirm_shutdown: "confirm_shutdown", + cancel_shutdown: "cancel_shutdown", + confirm_reboot: "confirm_reboot", + cancel_reboot: "cancel_reboot", +}; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function startPolling(): Promise { + if (!TG_TOKEN) { + console.log("[bot] TG_BOT_TOKEN not set, skipping bot startup"); + return; + } + + console.log("[bot] starting Telegram polling..."); + + let lastOffset = 0; + let backoff = 3000; + + async function poll() { + try { + const data = await tg[]>("getUpdates", { + offset: lastOffset + 1, + timeout: 30, + allowed_updates: ["message", "callback_query"], + }); + + if (data.ok && data.result) { + for (const update of data.result) { + lastOffset = update.update_id as number; + + if (update.callback_query) { + const cb = update.callback_query as { + message?: { chat?: { id: number } }; + from?: { id: number }; + id: string; + data?: string; + }; + const cbChatId = cb.message?.chat?.id ?? cb.from?.id ?? 0; + if (cb.data && allowed(cbChatId)) { + await handleInlineCallback(cbChatId, cb.id, cb.data); + } + continue; + } + + const msg = update.message as { + chat?: { id: number }; + photo?: { file_id: string }[]; + text?: string; + caption?: string; + reply_to_message?: { text?: string }; + } | undefined; + if (!msg?.chat?.id) continue; + const chatId = msg.chat.id; + if (!allowed(chatId)) continue; + + if (msg.photo && hasSession(chatId)) { + await handlePhoto(chatId, msg.photo, msg.caption); + continue; + } + + if (!msg.text) continue; + const fullText = msg.text.trim(); + + // ForceReply routing + if (msg.reply_to_message?.text) { + const original = msg.reply_to_message.text; + if (original.includes("请输入文章标题")) { await handleCommand(chatId, "new", fullText); continue; } + if (original.includes("请输入新标题")) { await handleCommand(chatId, "title", fullText); continue; } + if (original.includes("请输入分类")) { await handleCommand(chatId, "categories", fullText); continue; } + if (original.includes("请输入标签")) { await handleCommand(chatId, "tags", fullText); continue; } + if (original.includes("请输入友链信息")) { await handleCommand(chatId, "link_add", fullText); continue; } + if (original.includes("请输入新名称")) { await handleCommand(chatId, "link_edit", fullText); continue; } + if (original.includes("请输入订阅源 URL")) { await handleCommand(chatId, "feed_add", fullText); continue; } + if (original.includes("请输入新的订阅信息")) { await handleCommand(chatId, "feed_edit", fullText); continue; } + } + + const parts = fullText.split("@")[0].split(/\s+/); + const cmdRaw = parts[0].toLowerCase(); + const slashCmd = cmdRaw.startsWith("/") ? cmdRaw.slice(1) : ""; + const slashArg = parts.slice(1).join(" "); + + // In writing session, non-slash text appends to content + if (hasSession(chatId) && !cmdRaw.startsWith("/")) { + try { appendContent(chatId, fullText); } + catch (e) { console.error("[bot:append]", e instanceof Error ? e.message : String(e)); } + continue; + } + + if (cmdRaw.startsWith("/")) { + await handleCommand(chatId, slashCmd, slashArg); + } + } + } + + backoff = 3000; + } catch (e) { + console.error("[bot:poll]", e instanceof Error ? e.message : String(e)); + await sleep(backoff); + backoff = Math.min(backoff * 2, 60000); + } + + await sleep(100); + setImmediate(poll); + } + + // Register commands with Telegram + try { + await tg("setMyCommands", { + commands: [ + { command: "new", description: "写一篇文章" }, + { command: "edit", description: "编辑文章" }, + { command: "delete", description: "删除文章" }, + { command: "list", description: "文章列表" }, + { command: "publish", description: "保存发布" }, + { command: "draft", description: "保存草稿" }, + { command: "deploy", description: "推送到线上" }, + { command: "sync", description: "同步远程" }, + { command: "undo", description: "撤销最后一段" }, + { command: "preview", description: "预览" }, + { command: "links", description: "友链管理" }, + { command: "link_add", description: "添加友链" }, + { command: "link_edit", description: "编辑友链" }, + { command: "link_del", description: "删除友链" }, + { command: "link_toggle", description: "显示/隐藏友链" }, + { command: "feeds", description: "订阅管理" }, + { command: "feed_add", description: "添加订阅源" }, + { command: "feed_edit", description: "编辑订阅源" }, + { command: "feed_del", description: "删除订阅源" }, + { command: "feed_health", description: "订阅源健康检查" }, + { command: "read", description: "友链最新文章" }, + { command: "stats", description: "网站数据" }, + { command: "shutdown", description: "关机" }, + { command: "reboot", description: "重启" }, + { command: "help", description: "帮助" }, + ], + }); + console.log("[bot] commands registered"); + } catch (e) { + console.error("[bot] failed to register commands:", e instanceof Error ? e.message : String(e)); + } + + poll(); +} + +async function handlePhoto(chatId: number, photos: { file_id: string }[], caption?: string) { + try { + const fs = await import("fs"); + const path = await import("path"); + const { CONTENT_DIR } = await import("@/lib/config"); + const { getSession } = await import("./sessions"); + const { getArticleDirPath } = await import("./helpers"); + + const s = getSession(chatId); + const largestPhoto = photos[photos.length - 1]; + const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: largestPhoto.file_id }); + if (!fileRes.ok || !fileRes.result?.file_path) return; + + const filePath = fileRes.result.file_path; + const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath; + const imgBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer()); + + // Save to article's own folder (Hugo page bundle) + const articleDir = getArticleDirPath(s.slug); + if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true }); + + const ext = filePath.split(".").pop() || "jpg"; + const filename = Date.now() + "-" + largestPhoto.file_id.slice(0, 8) + "." + ext; + fs.writeFileSync(path.join(articleDir, filename), Buffer.from(imgBuffer)); + + const imgCaption = caption || "image"; + appendContent(chatId, `\n![${imgCaption}](${filename})\n`); + await sendMessage(chatId, `📷 图片已保存:${filename}`); + } catch (e) { + console.error("[bot:photo]", e instanceof Error ? e.message : String(e)); + await sendMessage(chatId, "❌ 图片保存失败"); + } +} + +async function handleCommand(chatId: number, cmd: string, arg?: string) { + try { + if (cmd === "noop") return; + if (ARG_COMMANDS.has(cmd)) { + const handler = handlers[cmd]; + if (handler) await handler(chatId, arg); + else await sendMessage(chatId, "❌ 未知命令:/" + cmd); + } else { + const handler = handlers[cmd]; + if (handler) await handler(chatId); + else if (cmd) await sendMessage(chatId, "❌ 未知命令:/" + cmd); + } + } catch (e) { + console.error("[bot:" + cmd + "]", e instanceof Error ? e.message : String(e)); + try { + await sendMessage(chatId, "❌ " + (e instanceof Error ? e.message : "error").slice(0, 200)); + } catch (e) { /* ignore */ } + } +} + +async function handleInlineCallback(chatId: number, queryId: string, data: string) { + let prefix = ""; + let arg = ""; + // First try prefix match (e.g. "edit_slug", "list_2") + for (const p of Object.keys(CALLBACK_PREFIXES)) { + if (data.startsWith(p + "_")) { + prefix = p; + arg = data.slice(p.length + 1); + break; + } + } + // Then try exact match (e.g. "confirm_shutdown", "skip_cat") + if (!prefix && CALLBACK_PREFIXES[data]) { + prefix = data; + } + if (!prefix) { + try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {} + return; + } + const cmd = CALLBACK_PREFIXES[prefix]; + await handleCommand(chatId, cmd, arg); + try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {} +} diff --git a/write/src/lib/bot/sessions.ts b/write/src/lib/bot/sessions.ts new file mode 100644 index 00000000..875b445b --- /dev/null +++ b/write/src/lib/bot/sessions.ts @@ -0,0 +1,77 @@ +import { createPost, updatePost, getPost } from "@/lib/posts"; +import { DEFAULT_AUTHOR } from "@/lib/config"; + +export interface Session { + title: string; + slug: string; + content: string; + categories: string[]; + tags: string[]; + author: string; + draft: boolean; + chunks: string[]; + originalSlug?: string; +} + +const sessions = new Map(); + +export function getSession(chatId: number): Session { + if (!sessions.has(chatId)) { + sessions.set(chatId, { + title: "", + slug: "", + content: "", + categories: [], + tags: [], + author: DEFAULT_AUTHOR, + draft: true, + chunks: [], + }); + } + return sessions.get(chatId)!; +} + +export function hasSession(chatId: number): boolean { + return sessions.has(chatId); +} + +export function clearSession(chatId: number): void { + sessions.delete(chatId); +} + +export function appendContent(chatId: number, text: string): void { + const s = getSession(chatId); + s.chunks.push(text + "\n\n"); + s.content += text + "\n\n"; +} + +export function undoLastChunk(chatId: number): boolean { + const s = getSession(chatId); + if (s.chunks.length === 0) return false; + const lastChunk = s.chunks.pop()!; + const idx = s.content.lastIndexOf(lastChunk); + if (idx >= 0) { + s.content = s.content.slice(0, idx) + s.content.slice(idx + lastChunk.length); + } + return true; +} + +export async function saveSession(s: Session, isDraft: boolean): Promise { + const frontMatter = { + title: s.title, + slug: s.slug, + date: new Date().toISOString().slice(0, 10), + draft: isDraft, + categories: s.categories, + tags: s.tags, + author: s.author, + }; + + const existing = getPost(s.slug); + if (existing) { + const result = updatePost(s.slug, frontMatter, s.content); + if (!result.success) throw new Error("更新文章失败"); + } else { + createPost(frontMatter, s.content); + } +} diff --git a/write/src/lib/bot/tg.ts b/write/src/lib/bot/tg.ts new file mode 100644 index 00000000..4543f30c --- /dev/null +++ b/write/src/lib/bot/tg.ts @@ -0,0 +1,52 @@ +import { API, ALLOWED_IDS } from "./config"; + +interface TgResponse { + ok: boolean; + result?: T; + description?: string; +} + +export async function tg( + method: string, + body: Record +): Promise> { + const res = await fetch(`${API}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.json() as Promise>; +} + +export function allowed(chatId: number): boolean { + if (ALLOWED_IDS.length === 0) return true; + return ALLOWED_IDS.includes(chatId); +} + +export async function sendMessage( + chatId: number, + text: string, + opts?: Record +): Promise { + const safeText = String(text || ""); + try { + await tg("sendMessage", { + chat_id: chatId, + text: safeText, + parse_mode: "HTML", + disable_web_page_preview: true, + ...opts, + }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("parse") || msg.includes("HTML")) { + await tg("sendMessage", { + chat_id: chatId, + text: safeText, + ...opts, + }); + } else { + throw e; + } + } +} diff --git a/write/src/lib/config.ts b/write/src/lib/config.ts index b78978dd..44c4f24c 100644 --- a/write/src/lib/config.ts +++ b/write/src/lib/config.ts @@ -1,7 +1,9 @@ import path from "path"; +import os from "os"; export const BLOG_ROOT = path.resolve(process.cwd(), ".."); export const CONTENT_DIR = path.join(BLOG_ROOT, "content", "post"); +export const STATIC_DIR = path.join(BLOG_ROOT, "static"); export const RECYCLE_DIR = path.join(process.cwd(), ".recycle"); export const RECYCLE_POSTS_DIR = path.join(RECYCLE_DIR, "posts"); export const RECYCLE_META_DIR = path.join(RECYCLE_DIR, "meta"); @@ -14,3 +16,33 @@ export const RSS_API_BASE = process.env.RSS_API_BASE || "https://api.usj.cc"; export const RSS_API_TOKEN = process.env.RSS_API_TOKEN || ""; export const WECHAT_APP_ID = process.env.WECHAT_APP_ID || ""; export const WECHAT_APP_SECRET = process.env.WECHAT_APP_SECRET || ""; + +// Bot 配置 +export const TG_BOT_TOKEN = process.env.TG_BOT_TOKEN || ""; +export const TG_ALLOWED_IDS: number[] = (process.env.TG_ALLOWED_CHAT_IDS || "") + .split(",").map((s) => s.trim()).filter(Boolean).map(Number); +export const PREFER_IFACE = process.env.PREFER_IFACE || "WLAN"; +export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵"; + +export function getLanIP(): string { + const ifs = os.networkInterfaces(); + const candidates: { name: string; address: string }[] = []; + for (const [name, addrs] of Object.entries(ifs)) { + if (!addrs) continue; + for (const a of addrs) { + if (a.family !== "IPv4" || a.internal) continue; + if (a.address.startsWith("169.254.")) continue; + candidates.push({ name, address: a.address }); + } + } + const pref = candidates.find((c) => + c.name.toLowerCase().includes(PREFER_IFACE.toLowerCase()) + ); + if (pref) return pref.address; + const real = candidates.find((c) => { + const n = c.name.toLowerCase(); + return !n.includes("vmware") && !n.includes("virtual") && !n.includes("vethernet"); + }); + if (real) return real.address; + return candidates[0]?.address || "0.0.0.0"; +} diff --git a/write/src/lib/git.ts b/write/src/lib/git.ts new file mode 100644 index 00000000..aeedf914 --- /dev/null +++ b/write/src/lib/git.ts @@ -0,0 +1,137 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; +import { BLOG_ROOT } from "./config"; + +const execFileAsync = promisify(execFile); + +type GitExecError = Error & { + stdout?: string; + stderr?: string; + code?: number; +}; + +export interface DeployResult { + success: boolean; + output?: string; + error?: string; + conflict?: boolean; +} + +interface QueueItem { + message: string; + resolve: (result: DeployResult) => void; + reject: (err: Error) => void; +} + +class GitDeployQueue { + private queue: QueueItem[] = []; + private running = false; + + async enqueue(commitMessage: string): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ message: commitMessage, resolve, reject }); + if (!this.running) this.next(); + }); + } + + async sync(): Promise { + try { + const result = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]); + return { success: true, output: result.stdout + result.stderr }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (this.isRebaseConflict(msg)) { + await this.runGit(["rebase", "--abort"], true); + return { success: false, conflict: true, error: "远程冲突,已放弃合并" }; + } + return { success: false, error: msg }; + } + } + + private async next(): Promise { + const item = this.queue.shift(); + if (!item) { + this.running = false; + return; + } + this.running = true; + try { + const result = await this.execute(item.message); + item.resolve(result); + } catch (e) { + item.reject(e instanceof Error ? e : new Error(String(e))); + } + this.next(); + } + + private async runGit(args: string[], allowFailure = false) { + try { + const { stdout, stderr } = await execFileAsync("git", args, { + cwd: BLOG_ROOT, + windowsHide: true, + maxBuffer: 1024 * 1024, + }); + return { stdout, stderr, code: 0 }; + } catch (error) { + const err = error as GitExecError; + const result = { + stdout: err.stdout || "", + stderr: err.stderr || err.message, + code: typeof err.code === "number" ? err.code : 1, + }; + if (allowFailure) return result; + throw new Error((result.stderr || result.stdout || err.message).trim()); + } + } + + private isRebaseConflict(message: string): boolean { + return /conflict|CONFLICT|Resolve all conflicts|Automatic merge failed|could not apply|rebase/i.test(message); + } + + private async execute(message: string): Promise { + let output = ""; + + // git add + const addResult = await this.runGit(["add", "content"]); + output += addResult.stdout + addResult.stderr; + + // git diff --cached --quiet (exit 1 = staged changes exist) + const diffResult = await this.runGit(["diff", "--cached", "--quiet"], true); + if (diffResult.code === 1) { + const commitResult = await this.runGit(["commit", "-m", message]); + output += commitResult.stdout + commitResult.stderr; + } else if (diffResult.code !== 0) { + return { success: false, error: (diffResult.stderr || diffResult.stdout || "检查暂存区失败").trim() }; + } + + // git pull --rebase --autostash + try { + const pullResult = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]); + output += pullResult.stdout + pullResult.stderr; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (this.isRebaseConflict(msg)) { + await this.runGit(["rebase", "--abort"], true); + return { + success: false, + conflict: true, + error: "远程有冲突,已自动放弃合并。请先 /sync 同步,手动解决后再 deploy。", + output: msg.slice(0, 1000), + }; + } + return { success: false, error: msg }; + } + + // git push + try { + const pushResult = await this.runGit(["push"]); + output += pushResult.stdout + pushResult.stderr; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + + return { success: true, output: output.slice(0, 1000) }; + } +} + +export const deployQueue = new GitDeployQueue(); diff --git a/write/src/lib/hugo.ts b/write/src/lib/hugo.ts new file mode 100644 index 00000000..dbf1a3d9 --- /dev/null +++ b/write/src/lib/hugo.ts @@ -0,0 +1,114 @@ +import { spawn, execSync, type ChildProcess } from "child_process"; +import { BLOG_ROOT } from "./config"; + +class HugoManager { + private process: ChildProcess | null = null; + private ready = false; + private starting = false; + private healthTimer: NodeJS.Timeout | null = null; + + async start(): Promise<{ ok: boolean; alreadyRunning?: boolean }> { + if (this.process && this.ready) { + return { ok: true, alreadyRunning: true }; + } + if (this.starting) { + return { ok: false }; + } + + this.starting = true; + this.ready = false; + + this.process = spawn("hugo", ["server", "--bind", "0.0.0.0", "--port", "1313", "--disableFastRender", "--noHTTPCache"], { + cwd: BLOG_ROOT, + shell: true, + stdio: "pipe", + windowsHide: true, + }); + + this.process.stdout?.on("data", (d: Buffer) => process.stdout.write(`[hugo] ${d}`)); + this.process.stderr?.on("data", (d: Buffer) => process.stderr.write(`[hugo] ${d}`)); + + this.process.on("exit", (code) => { + console.log(`[hugo] exited ${code}`); + this.process = null; + this.ready = false; + this.starting = false; + }); + + // 等待 Hugo 就绪(最多 15 秒) + const ok = await this.waitForReady(15000); + this.starting = false; + if (ok) { + this.ready = true; + this.startHealthCheck(); + console.log("[hugo] ready on 0.0.0.0:1313"); + } + return { ok }; + } + + stop(): void { + this.stopHealthCheck(); + if (!this.process) return; + try { + const pid = this.process.pid; + if (process.platform === "win32" && pid) { + execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); + } else { + this.process.kill(); + } + } catch (_e) { /* nothing to kill */ } + this.process = null; + this.ready = false; + this.starting = false; + } + + status(): { running: boolean; starting: boolean } { + return { running: !!(this.process && this.ready), starting: this.starting }; + } + + async ensureRunning(): Promise { + if (this.process && this.ready) return true; + const result = await this.start(); + return result.ok; + } + + private async waitForReady(timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await this.ping()) return true; + await new Promise((r) => setTimeout(r, 1500)); + } + return false; + } + + private async ping(): Promise { + try { + const res = await fetch("http://127.0.0.1:1313", { signal: AbortSignal.timeout(1500) }); + return res.ok || res.status < 500; + } catch (_e) { + return false; + } + } + + private startHealthCheck(): void { + this.stopHealthCheck(); + this.healthTimer = setInterval(async () => { + if (!this.process) return; + const alive = await this.ping(); + if (!alive) { + console.warn("[hugo] health check failed, marking as not ready"); + this.ready = false; + this.stopHealthCheck(); + } + }, 30_000); + } + + private stopHealthCheck(): void { + if (this.healthTimer) { + clearInterval(this.healthTimer); + this.healthTimer = null; + } + } +} + +export const hugo = new HugoManager(); diff --git a/write/src/lib/posts.ts b/write/src/lib/posts.ts index 4664a8ec..cb8b7f9f 100644 --- a/write/src/lib/posts.ts +++ b/write/src/lib/posts.ts @@ -164,7 +164,7 @@ export function createPost(frontMatter: PostFrontMatter, content: string): strin const dirName = `${dateStr}-${titlePart}`; const dirPath = path.join(CONTENT_DIR, year, dirName); - if (fs.existsSync(dirPath)) { + if (fs.existsSync(path.join(dirPath, "index.md"))) { throw new Error(`Post already exists at ${dirPath}`); } diff --git a/write/src/lib/rssapi.ts b/write/src/lib/rssapi.ts index ee5b1c7e..86cafded 100644 --- a/write/src/lib/rssapi.ts +++ b/write/src/lib/rssapi.ts @@ -48,6 +48,23 @@ export interface LinkHealth { error?: string; } +export interface Article { + title: string; + link: string; + pubDate: string; + feedName: string; + siteUrl: string; + favicon: string; + author: string; +} + +export interface HealthResult { + total: number; + alive: number; + dead: number; + results: FeedHealth[]; +} + // ---- Feeds API ---- export async function getFeeds(): Promise { @@ -80,13 +97,17 @@ export async function deleteFeed(url: string): Promise { await rssFetch(`/api/feeds?${qs}`, { method: "DELETE" }); } -export async function getFeedsHealth(): Promise<{ - total: number; alive: number; dead: number; - results: FeedHealth[]; -}> { +export async function getFeedsHealth(): Promise { return rssFetch("/api/health"); } +// ---- Articles API ---- + +export async function getArticles(limit = 10): Promise { + const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`); + return data.articles || []; +} + // ---- Links API ---- export async function getLinks(all = false): Promise { diff --git a/write/src/types/marked.d.ts b/write/src/types/marked.d.ts new file mode 100644 index 00000000..fe365636 --- /dev/null +++ b/write/src/types/marked.d.ts @@ -0,0 +1,4 @@ +declare module "marked" { + export function marked(src: string): string; + export function parse(src: string): string; +} diff --git a/write/start-silent.bat b/write/start-silent.bat new file mode 100644 index 00000000..26865a33 --- /dev/null +++ b/write/start-silent.bat @@ -0,0 +1,4 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0write" +start /b npm run dev -- -p 8016 diff --git a/write/startup.vbs b/write/startup.vbs new file mode 100644 index 00000000..a9b37fa2 --- /dev/null +++ b/write/startup.vbs @@ -0,0 +1,3 @@ +Set WshShell = CreateObject("WScript.Shell") +WshShell.Run Chr(34) & "E:\GitHub\blog\write\start-silent.bat" & Chr(34), 0, False +Set WshShell = Nothing