diff --git a/bot-go/.gitignore b/bot-go/.gitignore
new file mode 100644
index 00000000..2514bd4e
--- /dev/null
+++ b/bot-go/.gitignore
@@ -0,0 +1,2 @@
+writebot.exe
+.env
diff --git a/bot-go/_fix_import.py b/bot-go/_fix_import.py
new file mode 100644
index 00000000..fd984f92
--- /dev/null
+++ b/bot-go/_fix_import.py
@@ -0,0 +1,7 @@
+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
new file mode 100644
index 00000000..e854c14a
--- /dev/null
+++ b/bot-go/_fix_inline.py
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 00000000..4a53ffc4
--- /dev/null
+++ b/bot-go/_fix_nil.py
@@ -0,0 +1,11 @@
+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
new file mode 100644
index 00000000..2a58c7d2
--- /dev/null
+++ b/bot-go/cmd/writebot/main.go
@@ -0,0 +1,204 @@
+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 += "
+
+"
+ }
+ }
+ }
+ 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
new file mode 100644
index 00000000..849763a6
--- /dev/null
+++ b/bot-go/config.go
@@ -0,0 +1,89 @@
+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
new file mode 100644
index 00000000..603a46b9
--- /dev/null
+++ b/bot-go/go.mod
@@ -0,0 +1,3 @@
+module writebot
+
+go 1.26.3
diff --git a/bot-go/internal/adminpanel/adminpanel.go b/bot-go/internal/adminpanel/adminpanel.go
new file mode 100644
index 00000000..717bfba8
--- /dev/null
+++ b/bot-go/internal/adminpanel/adminpanel.go
@@ -0,0 +1,158 @@
+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
new file mode 100644
index 00000000..c6bca069
--- /dev/null
+++ b/bot-go/internal/botcfg/config.go
@@ -0,0 +1,72 @@
+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
new file mode 100644
index 00000000..4afaff69
--- /dev/null
+++ b/bot-go/internal/config/config.go
@@ -0,0 +1,99 @@
+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
new file mode 100644
index 00000000..5a9d48e2
--- /dev/null
+++ b/bot-go/internal/helpers/helpers.go
@@ -0,0 +1,159 @@
+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
new file mode 100644
index 00000000..3a953922
--- /dev/null
+++ b/bot-go/internal/logutil/logutil.go
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 00000000..715b3afd
--- /dev/null
+++ b/bot-go/internal/sessions/sessions.go
@@ -0,0 +1,71 @@
+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
new file mode 100644
index 00000000..fd9853ad
--- /dev/null
+++ b/bot-go/internal/tgcore/tgcore.go
@@ -0,0 +1,301 @@
+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
new file mode 100644
index 00000000..2694926c
--- /dev/null
+++ b/bot-go/internal/tghandlers/tghandlers.go
@@ -0,0 +1,622 @@
+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
new file mode 100644
index 00000000..2fb8aa51
--- /dev/null
+++ b/bot-go/internal/writeserver/writeserver.go
@@ -0,0 +1,156 @@
+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
new file mode 100644
index 00000000..20305000
--- /dev/null
+++ b/bot-go/start_bot.bat
@@ -0,0 +1,5 @@
+@echo off
+title WriteBot
+cd /d E:\GitHub\blog\bot-go
+echo [%date% %time%] WriteBot starting...
+writebot.exe
diff --git a/bot-go/writebot.exe~ b/bot-go/writebot.exe~
new file mode 100644
index 00000000..2314c133
Binary files /dev/null and b/bot-go/writebot.exe~ differ
diff --git a/bot/.gitignore b/bot/.gitignore
new file mode 100644
index 00000000..2d7ec5ce
--- /dev/null
+++ b/bot/.gitignore
@@ -0,0 +1,2 @@
+.env
+node_modules/
diff --git a/bot/README.md b/bot/README.md
new file mode 100644
index 00000000..9d56cf62
--- /dev/null
+++ b/bot/README.md
@@ -0,0 +1,46 @@
+# WriteBot
+
+纯 Node.js,零 npm 依赖。后台管理 `write` 开发服务,提供 Web 控制面板 + Telegram 机器人。
+
+## 快速开始
+
+### 1. 配置
+
+```powershell
+cd bot
+copy .env.example .env
+```
+
+### 2. 启动
+
+```powershell
+node index.js
+```
+
+或双击 `start.bat`。
+
+### 3. 控制面板
+
+浏览器打开:`http://127.0.0.1:8017/`
+
+一键启动/停止 Write、查看状态、复制局域网地址。
+
+### 4. Telegram 机器人
+
+1. 联系 [@BotFather](https://t.me/BotFather),`/newbot` 获得 token。
+2. 联系 [@userinfobot](https://t.me/userinfobot) 获得 chat id。
+3. 填入 `.env`。
+
+命令:`/start` `/stop` `/status` `/ip` `/help`
+
+## 开机自启
+
+已配好启动文件夹的快捷方式(WriteBot.lnk)。开机登录后自动启动。
+
+## 防火墙
+
+首次手机访问不了要放行 8016:
+
+```powershell
+New-NetFirewallRule -DisplayName "Write Dev 8016" -Direction Inbound -Protocol TCP -LocalPort 8016 -Action Allow
+```
diff --git a/bot/_apply_fixes.js b/bot/_apply_fixes.js
new file mode 100644
index 00000000..1c32403f
--- /dev/null
+++ b/bot/_apply_fixes.js
@@ -0,0 +1,105 @@
+const fs = require("fs");
+const path = "E:/GitHub/blog/bot/index.js";
+let c = fs.readFileSync(path, "utf8");
+
+function findBlock(name) {
+ var idx = c.indexOf("CMD_HANDLERS." + name);
+ if (idx === -1) return null;
+ var depth = 0;
+ var i = idx;
+ for (; i < c.length; i++) {
+ if (c[i] === "{") depth++;
+ else if (c[i] === "}") {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return { start: idx, end: i + 2 };
+}
+
+// Fix 2: link_toggle
+var b = findBlock("link_toggle");
+if (!b) { console.log("ERR: link_toggle not found"); process.exit(1); }
+var nb =
+"CMD_HANDLERS.link_toggle = async function (chatId, url) {\n" +
+" if (!url) return sendMessage(chatId, \"Usage: /link_toggle \\nToggle visibility of a link.\");\n" +
+" try {\n" +
+" // Fetch all links to find current hidden state\n" +
+" const listRes = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/links?all=1\");\n" +
+" const listData = await listRes.json();\n" +
+" var link = (listData.links || []).find(function(l) { return l.url === url; });\n" +
+" if (!link) return sendMessage(chatId, \"\u274c Link not found: \" + url);\n" +
+" var newHidden = !link.hidden;\n" +
+" const res = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/links\", {\n" +
+" method: \"PUT\",\n" +
+" headers: { \"Content-Type\": \"application/json\" },\n" +
+" body: JSON.stringify({ oldUrl: url, hidden: newHidden }),\n" +
+" });\n" +
+" if (!res.ok) throw new Error((await res.json()).error || \"Toggle failed\");\n" +
+" var label = newHidden ? \"\uD83D\uDD12 Hidden\" : \"\uD83C\uDF10 Visible\";\n" +
+" return sendMessage(chatId, label + \": \" + url);\n" +
+" } catch (e) {\n" +
+" return sendMessage(chatId, \"\u274c \" + e.message);\n" +
+" }\n" +
+"};\n";
+c = c.substring(0, b.start) + nb + c.substring(b.end);
+console.log("2/4: link_toggle fixed");
+
+// Fix 3: feed_check
+b = findBlock("feed_check");
+if (!b) { console.log("ERR: feed_check not found"); process.exit(1); }
+nb =
+"CMD_HANDLERS.feed_check = async function (chatId) {\n" +
+" try {\n" +
+" // First get all feeds\n" +
+" const feedsRes = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/feeds\");\n" +
+" const feedsData = await feedsRes.json();\n" +
+" var feeds = feedsData.feeds || [];\n" +
+" if (feeds.length === 0) return sendMessage(chatId, \"No feeds to check.\");\n" +
+" var results = [];\n" +
+" for (var i = 0; i < feeds.length; i++) {\n" +
+" var f = feeds[i];\n" +
+" try {\n" +
+" var hRes = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/feeds?health=\" + encodeURIComponent(f.url));\n" +
+" var hData = await hRes.json();\n" +
+" results.push({ feedTitle: f.feedTitle, url: f.url, ok: hData.ok, status: hData.status, latency: hData.latency, error: hData.error });\n" +
+" } catch (e2) {\n" +
+" results.push({ feedTitle: f.feedTitle, url: f.url, ok: false, error: e2.message });\n" +
+" }\n" +
+" }\n" +
+" var lines = [\"\uD83D\uDCE1 Feed Health\\n\"];\n" +
+" results.forEach(function(r) {\n" +
+" var icon = r.ok ? \"\u2705\" : \"\u274c\";\n" +
+" var label = (r.feedTitle || r.url).slice(0, 40);\n" +
+" var info = r.ok ? (r.latency || 0) + \"ms\" : (r.error || \"error\");\n" +
+" lines.push(icon + \" \" + label + \" (\" + info + \")\");\n" +
+" });\n" +
+" return sendMessage(chatId, lines.join(\"\\n\"));\n" +
+" } catch (e) {\n" +
+" return sendMessage(chatId, \"Please /start Write first.\");\n" +
+" }\n" +
+"};\n";
+c = c.substring(0, b.start) + nb + c.substring(b.end);
+console.log("3/4: feed_check fixed");
+
+// Fix 4: feed_del
+b = findBlock("feed_del");
+if (!b) { console.log("ERR: feed_del not found"); process.exit(1); }
+nb =
+"CMD_HANDLERS.feed_del = async function (chatId, url) {\n" +
+" if (!url) return sendMessage(chatId, \"Usage: /feed_del \\nUse /feeds to find the URL.\");\n" +
+" try {\n" +
+" const res = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/feeds?url=\" + encodeURIComponent(url), {\n" +
+" method: \"DELETE\",\n" +
+" });\n" +
+" if (!res.ok) throw new Error((await res.json()).error || \"Delete failed\");\n" +
+" return sendMessage(chatId, \"\u2702 Feed deleted: \" + url);\n" +
+" } catch (e) {\n" +
+" return sendMessage(chatId, \"\u274c \" + e.message);\n" +
+" }\n" +
+"};\n";
+c = c.substring(0, b.start) + nb + c.substring(b.end);
+console.log("4/4: feed_del fixed");
+
+fs.writeFileSync(path, c, "utf8");
+console.log("All fixes applied successfully!");
diff --git a/bot/_apply_fixes2.js b/bot/_apply_fixes2.js
new file mode 100644
index 00000000..f13db2e0
--- /dev/null
+++ b/bot/_apply_fixes2.js
@@ -0,0 +1,40 @@
+const fs = require("fs");
+const path = "E:/GitHub/blog/bot/index.js";
+let c = fs.readFileSync(path, "utf8");
+
+function findBlock(name) {
+ var idx = c.indexOf("CMD_HANDLERS." + name);
+ if (idx === -1) return null;
+ var depth = 0;
+ var i = idx;
+ for (; i < c.length; i++) {
+ if (c[i] === "{") depth++;
+ else if (c[i] === "}") {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return { start: idx, end: i + 2 };
+}
+
+// Fix 1: link_del (was missed earlier)
+var b = findBlock("link_del");
+if (!b) { console.log("ERR: link_del not found"); process.exit(1); }
+var nb =
+"CMD_HANDLERS.link_del = async function (chatId, url) {\n" +
+" if (!url) return sendMessage(chatId, \"Usage: /link_del \\nUse /links to find the URL.\");\n" +
+" try {\n" +
+" const res = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/links?url=\" + encodeURIComponent(url), {\n" +
+" method: \"DELETE\",\n" +
+" });\n" +
+" if (!res.ok) throw new Error((await res.json()).error || \"Delete failed\");\n" +
+" return sendMessage(chatId, \"\u2702 Link deleted: \" + url);\n" +
+" } catch (e) {\n" +
+" return sendMessage(chatId, \"\u274c \" + e.message);\n" +
+" }\n" +
+"};\n";
+c = c.substring(0, b.start) + nb + c.substring(b.end);
+console.log("1/1: link_del fixed");
+
+fs.writeFileSync(path, c, "utf8");
+console.log("Done!");
diff --git a/bot/_gen_admin.js b/bot/_gen_admin.js
new file mode 100644
index 00000000..60d7ed49
--- /dev/null
+++ b/bot/_gen_admin.js
@@ -0,0 +1,134 @@
+const fs = require("fs");
+const path = require("path");
+
+const html = `
+
+
+
+
+WriteBot
+
+
+
+
+
WriteBot
+
Ctrl+D bookmark this page
+
+
Loading...
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+const fileContent = `// admin-panel.js - Minimal web admin panel + HTTP control API
+const http = require("http");
+const config = require("./config");
+const { getLanIP } = require("./helpers");
+const { startWriteServer, stopWriteServer, getWriteStatus } = require("./write-server");
+
+const ADMIN_HTML = \`${html}\`.replace("var port=8016", "var port=\${config.WRITE_PORT}");
+
+function createControlServer() {
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, "http://127.0.0.1:" + config.CONTROL_PORT);
+ const path = url.pathname;
+ res.setHeader("Access-Control-Allow-Origin", "*");
+
+ if (path === "/" && req.method === "GET") {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(ADMIN_HTML);
+ } else if (path === "/status" && req.method === "GET") {
+ res.setHeader("Content-Type", "application/json");
+ const s = getWriteStatus();
+ s.ip = getLanIP();
+ s.port = config.WRITE_PORT;
+ res.end(JSON.stringify(s));
+ } else if (path === "/start" && req.method === "POST") {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify(startWriteServer()));
+ } else if (path === "/stop" && req.method === "POST") {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ ok: stopWriteServer() }));
+ } else if (path === "/ip" && req.method === "GET") {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ ip: getLanIP(), port: config.WRITE_PORT }));
+ } else if (path === "/health" && req.method === "GET") {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ ok: true }));
+ } else {
+ res.statusCode = 404;
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ error: "not found" }));
+ }
+ });
+
+ server.listen(config.CONTROL_PORT, "127.0.0.1", () => {
+ console.log("[control] http://127.0.0.1:" + config.CONTROL_PORT);
+ });
+
+ return server;
+}
+
+module.exports = { createControlServer };
+`;
+
+fs.writeFileSync(path.join(__dirname, "src", "admin-panel.js"), fileContent, "utf8");
+console.log("admin-panel.js written (" + fileContent.length + " bytes)");
diff --git a/bot/_go.js b/bot/_go.js
new file mode 100644
index 00000000..2ffdb8a5
--- /dev/null
+++ b/bot/_go.js
@@ -0,0 +1,3 @@
+var fs=require("fs");
+var c=fs.readFileSync("E:/GitHub/blog/bot/index.js","utf8");
+function fb(n){var i=c.indexOf("CMD_HANDLERS."+n);if(i===-1)return null;var d=0;for(var j=i;j {
+ console.log("\\n[bot] " + sig + ", stopping...");
+ stopWriteServer();
+ controlServer.close();
+ process.exit(0);
+ });
+ }
+}
+
+main();
diff --git a/bot/package-lock.json b/bot/package-lock.json
new file mode 100644
index 00000000..8df4b821
--- /dev/null
+++ b/bot/package-lock.json
@@ -0,0 +1,12 @@
+{
+ "name": "writebot",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "writebot",
+ "version": "1.0.0"
+ }
+ }
+}
diff --git a/bot/package.json b/bot/package.json
new file mode 100644
index 00000000..d3beb48e
--- /dev/null
+++ b/bot/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "writebot",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Telegram bot to start the write dev server on LAN",
+ "main": "index.js",
+ "scripts": {
+ "start": "node index.js"
+ }
+}
diff --git a/bot/src/admin-panel.js b/bot/src/admin-panel.js
new file mode 100644
index 00000000..2443621f
--- /dev/null
+++ b/bot/src/admin-panel.js
@@ -0,0 +1,125 @@
+// admin-panel.js - Minimal web admin panel + HTTP control API
+const http = require("http");
+const config = require("./config");
+const { getLanIP } = require("./helpers");
+const { startWriteServer, stopWriteServer, getWriteStatus } = require("./write-server");
+
+const ADMIN_HTML = `
+
+
+
+
+WriteBot
+
+
+
+
+
WriteBot
+
Ctrl+D bookmark this page
+
+
Loading...
+
+
+
+
+
+
+
+
+
+
+
+`.replace("var port=8016", "var port=${config.WRITE_PORT}");
+
+function createControlServer() {
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, "http://127.0.0.1:" + config.CONTROL_PORT);
+ const path = url.pathname;
+ res.setHeader("Access-Control-Allow-Origin", "*");
+
+ if (path === "/" && req.method === "GET") {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(ADMIN_HTML);
+ } else if (path === "/status" && req.method === "GET") {
+ res.setHeader("Content-Type", "application/json");
+ const s = getWriteStatus();
+ s.ip = getLanIP();
+ s.port = config.WRITE_PORT;
+ res.end(JSON.stringify(s));
+ } else if (path === "/start" && req.method === "POST") {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify(startWriteServer()));
+ } else if (path === "/stop" && req.method === "POST") {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ ok: stopWriteServer() }));
+ } else if (path === "/ip" && req.method === "GET") {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ ip: getLanIP(), port: config.WRITE_PORT }));
+ } else if (path === "/health" && req.method === "GET") {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ ok: true }));
+ } else {
+ res.statusCode = 404;
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ error: "not found" }));
+ }
+ });
+
+ server.listen(config.CONTROL_PORT, "127.0.0.1", () => {
+ console.log("[control] http://127.0.0.1:" + config.CONTROL_PORT);
+ });
+
+ return server;
+}
+
+module.exports = { createControlServer };
diff --git a/bot/src/config.js b/bot/src/config.js
new file mode 100644
index 00000000..a96bf358
--- /dev/null
+++ b/bot/src/config.js
@@ -0,0 +1,21 @@
+// config.js - Environment and constants
+const { existsSync, readFileSync } = require("fs");
+const { resolve } = require("path");
+
+const envPath = resolve(__dirname, ".env");
+if (existsSync(envPath)) {
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
+ }
+}
+
+module.exports = {
+ TG_TOKEN: process.env.TG_BOT_TOKEN || "",
+ ALLOWED_IDS: (process.env.TG_ALLOWED_CHAT_IDS || "")
+ .split(",").map((s) => s.trim()).filter(Boolean).map(Number),
+ WRITE_PORT: parseInt(process.env.WRITE_PORT || "8016", 10),
+ CONTROL_PORT: parseInt(process.env.BOT_PORT || "8017", 10),
+ PREFER_IFACE: process.env.PREFER_IFACE || "WLAN",
+ WRITE_DIR: resolve(__dirname, "..", "write"),
+};
diff --git a/bot/src/helpers.js b/bot/src/helpers.js
new file mode 100644
index 00000000..44a25541
--- /dev/null
+++ b/bot/src/helpers.js
@@ -0,0 +1,76 @@
+// helpers.js - Utility functions
+const os = require("os");
+const net = require("net");
+const config = require("./config");
+
+function getLanIP() {
+ const now = Date.now();
+ if (getLanIP._cache && getLanIP._cache.ip && now - getLanIP._cache.ts < 30000)
+ return getLanIP._cache.ip;
+ let ip = "0.0.0.0";
+ try {
+ const ifs = os.networkInterfaces();
+ const candidates = [];
+ 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(config.PREFER_IFACE.toLowerCase())
+ );
+ if (pref) ip = pref.address;
+ else {
+ const real = candidates.find((c) => {
+ const n = c.name.toLowerCase();
+ return !n.includes("vmware") && !n.includes("virtual") && !n.includes("vethernet");
+ });
+ if (real) ip = real.address;
+ else if (candidates[0]) ip = candidates[0].address;
+ }
+ } catch {}
+ getLanIP._cache = { ip, ts: now };
+ return ip;
+}
+
+function checkPort(port) {
+ return new Promise((resolve) => {
+ const s = net.createConnection(port, "127.0.0.1", () => { s.destroy(); resolve(true); });
+ s.on("error", () => { s.destroy(); resolve(false); });
+ s.setTimeout(1500, () => { s.destroy(); resolve(false); });
+ });
+}
+
+async function waitForReady(port, timeoutMs) {
+ const start = Date.now();
+ while (Date.now() - start < (timeoutMs || 30000)) {
+ if (await checkPort(port)) return true;
+ await new Promise((r) => setTimeout(r, 1500));
+ }
+ return false;
+}
+
+function makeSlug(title) {
+ const dateStr = new Date().toISOString().slice(0, 10);
+ const slugPart = (title || "untitled")
+ .replace(/[^\w\u4e00-\u9fff]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .toLowerCase() || "post";
+ return dateStr + "-" + slugPart;
+}
+
+function sessionSummary(s) {
+ const lines = s.content ? s.content.split("\\n").filter(Boolean).length : 0;
+ const chars = s.content.length;
+ return [
+ "\\uD83D\\uDCDD " + (s.title || "\\u672A\\u547D\\u540D") + "",
+ s.categories.length ? "\\uD83D\\uDCC2 " + s.categories.join("\\u3001") : "",
+ s.tags.length ? "\\uD83C\\uDFF7 " + s.tags.join("\\u3001") : "",
+ chars ? "\\u270D " + lines + " \\u884C " + chars + " \\u5B57" : "",
+ ].filter(Boolean).join(" \\u00B7 ");
+}
+
+module.exports = { getLanIP, checkPort, waitForReady, makeSlug, sessionSummary };
diff --git a/bot/src/sessions.js b/bot/src/sessions.js
new file mode 100644
index 00000000..c5a2f99c
--- /dev/null
+++ b/bot/src/sessions.js
@@ -0,0 +1,59 @@
+// sessions.js - Per-chat writing sessions
+const config = require("./config");
+const { makeSlug } = require("./helpers");
+
+const sessions = new Map();
+
+function getSession(chatId) {
+ if (!sessions.has(chatId)) {
+ sessions.set(chatId, {
+ title: "",
+ slug: "",
+ content: "",
+ categories: [],
+ tags: [],
+ author: "qunlin",
+ draft: true,
+ });
+ }
+ return sessions.get(chatId);
+}
+
+function clearSession(chatId) {
+ sessions.delete(chatId);
+}
+
+// Save session via Write API
+async function saveViaAPI(s, isDraft) {
+ const body = JSON.stringify({
+ 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,
+ },
+ content: s.content,
+ });
+
+ const checkRes = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts/" + s.slug);
+ if (checkRes.ok) {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts/" + s.slug, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body,
+ });
+ if (!res.ok) throw new Error((await res.json()).error || "API error (PUT)");
+ } else {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body,
+ });
+ if (!res.ok) throw new Error((await res.json()).error || "API error (POST)");
+ }
+}
+
+module.exports = { sessions, getSession, clearSession, saveViaAPI };
diff --git a/bot/src/tg-core.js b/bot/src/tg-core.js
new file mode 100644
index 00000000..4151ee22
--- /dev/null
+++ b/bot/src/tg-core.js
@@ -0,0 +1,54 @@
+// tg-core.js - Telegram API helpers
+const config = require("./config");
+
+const API = config.TG_TOKEN ? "https://api.telegram.org/bot" + config.TG_TOKEN : "";
+
+async function tg(method, body) {
+ const res = await fetch(API + "/" + method, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ return res.json();
+}
+
+function allowed(chatId) {
+ if (config.ALLOWED_IDS.length === 0) return true;
+ return config.ALLOWED_IDS.includes(chatId);
+}
+
+async function sendMessage(chatId, text, opts) {
+ const safeText = String(text || "");
+ try {
+ return await tg("sendMessage", Object.assign(
+ { chat_id: chatId, text: safeText, parse_mode: "HTML", disable_web_page_preview: true },
+ opts || {}
+ ));
+ } catch (e) {
+ if (e.message && (e.message.includes("parse") || e.message.includes("HTML"))) {
+ return await tg("sendMessage", Object.assign(
+ { chat_id: chatId, text: safeText },
+ opts || {}
+ ));
+ }
+ throw e;
+ }
+}
+
+async function handleCallback(chatId, queryId, data, handlers) {
+ if (!data) return;
+ const parts = data.split("_");
+ const cmd = parts[0];
+ const page = parseInt(parts[1]) || 1;
+
+ if (cmd === "list" && handlers.list) {
+ await handlers.list(chatId, String(page));
+ } else if (cmd === "links" && handlers.links) {
+ await handlers.links(chatId, String(page));
+ } else if (cmd === "feeds" && handlers.feeds) {
+ await handlers.feeds(chatId, String(page));
+ }
+ try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch {}
+}
+
+module.exports = { tg, allowed, sendMessage, handleCallback };
diff --git a/bot/src/tg-handlers.js b/bot/src/tg-handlers.js
new file mode 100644
index 00000000..5a838a75
--- /dev/null
+++ b/bot/src/tg-handlers.js
@@ -0,0 +1,413 @@
+// tg-handlers.js - All Telegram /command handlers
+const config = require("./config");
+const { getLanIP, makeSlug, sessionSummary } = require("./helpers");
+const { getSession, clearSession, sessions, saveViaAPI } = require("./sessions");
+const { getWriteStatus, ensureWriteReady } = require("./write-server");
+const { sendMessage } = require("./tg-core");
+
+const CMD_HANDLERS = {};
+
+// ---------- System ----------
+CMD_HANDLERS.start = async function (chatId) {
+ const ip = getLanIP();
+ const ready = await ensureWriteReady(chatId, sendMessage);
+ if (ready) {
+ return sendMessage(chatId,
+ "\\u2705 Write \\u5DF2\\u542F\\u52A8\\n\\n" +
+ "\\uD83D\\uDCF1 \\u624B\\u673A: http://" + ip + ":8016/\\n" +
+ "\\uD83D\\uDCDD \\u6587\\u7AE0: http://" + ip + ":1313/posts/\\n" +
+ "\\uD83D\\uDCBB \\u7535\\u8111: http://localhost:8016/\\n\\n" +
+ "/help \\u67E5\\u770B\\u5168\\u90E8\\u547D\\u4EE4"
+ );
+ }
+ return sendMessage(chatId, "\\u274C \\u542F\\u52A8\\u8D85\\u65F6\\uFF0C\\u67E5\\u770B\\u65E5\\u5FD7 http://127.0.0.1:" + config.CONTROL_PORT + "/log");
+};
+
+CMD_HANDLERS.stop = async function (chatId) {
+ const { stopWriteServer } = require("./write-server");
+ if (stopWriteServer()) {
+ return sendMessage(chatId, "\\u26A1 Write stopped.");
+ }
+ return sendMessage(chatId, "\\u23F8 Write is not running.");
+};
+
+CMD_HANDLERS.status = async function (chatId) {
+ const s = getWriteStatus();
+ const status = s.running ? "\\u2705 Running" : s.starting ? "\\u23F3 Starting..." : "\\u23F8 Stopped";
+ const lines = ["Status: " + status];
+ if (s.running) {
+ const ip = getLanIP();
+ lines.push("Local: http://localhost:" + config.WRITE_PORT);
+ lines.push("Phone: http://" + ip + ":" + config.WRITE_PORT);
+ }
+ return sendMessage(chatId, lines.join("\\n"));
+};
+
+CMD_HANDLERS.ip = async function (chatId) {
+ const ip = getLanIP();
+ return sendMessage(chatId, "Phone: http://" + ip + ":" + config.WRITE_PORT + " (" + config.PREFER_IFACE + ")");
+};
+
+CMD_HANDLERS.help = async function (chatId) {
+ if (sessions.has(chatId)) {
+ return sendMessage(chatId,
+ "\\u270D \\u6B63\\u5728\\u5199\\u4F5C\\u4E2D\\n\\n" +
+ "\\u26A1 /publish \\u53D1\\u5E03 \\uD83D\\uDCC4 /draft \\u8349\\u7A3F \\uD83D\\uDD0D /preview \\u9884\\u89C8\\n" +
+ "\\u270F /title /categories /tags /author\\n" +
+ "\\u274C /cancel \\u4E22\\u5F03"
+ );
+ }
+ return sendMessage(chatId,
+ "\\uD83E\\uDD16 WriteBot \\u547D\\u4EE4\\n\\n" +
+ "\\uD83D\\uDCDD \\u5199\\u4F5C:\\n" +
+ " /new \\u6807\\u9898 - \\u5F00\\u59CB\\u5199\\u4E00\\u7BC7\\n" +
+ " /edit slug - \\u7F16\\u8F91\\u5DF2\\u6709\\u6587\\u7AE0\\n" +
+ " /delete slug - \\u5220\\u9664\\u6587\\u7AE0\\n" +
+ " /list - \\u6587\\u7AE0\\u5217\\u8868\\n\\n" +
+ "\\uD83D\\uDD17 \\u53CB\\u94FE:\\n" +
+ " /links - \\u67E5\\u770B\\u53CB\\u94FE\\n" +
+ " /link_add url name - \\u6DFB\\u52A0\\u53CB\\u94FE\\n" +
+ " /link_del url - \\u5220\\u9664\\u53CB\\u94FE\\n" +
+ " /link_toggle url - \\u663E\\u793A/\\u9690\\u85CF\\n\\n" +
+ "\\uD83D\\uDCE1 \\u8BA2\\u9605:\\n" +
+ " /feeds - \\u67E5\\u770B\\u8BA2\\u9605\\u6E90\\n" +
+ " /feed_add url - \\u6DFB\\u52A0\\u8BA2\\u9605\\u6E90\\n" +
+ " /feed_del url - \\u5220\\u9664\\u8BA2\\u9605\\u6E90\\n\\n" +
+ "\\uD83D\\uDCCA /stats \\u7F51\\u7AD9\\u6570\\u636E"
+ );
+};
+
+// ---------- Articles ----------
+CMD_HANDLERS.new = async function (chatId, title) {
+ if (!title) return sendMessage(chatId, "\\u683C\\u5F0F\\uFF1A\\u5199\\u4E00\\u7BC7 \\u6807\\u9898");
+ const s = getSession(chatId);
+ s.title = title;
+ s.slug = makeSlug(title);
+ s.content = "";
+ s.categories = [];
+ s.tags = [];
+ s.draft = true;
+ s.author = "qunlin";
+ return sendMessage(chatId,
+ "\\uD83D\\uDCDD \\u5F00\\u59CB\\u5199\\uFF1A" + title + "\\n\\n" +
+ "\\u76F4\\u63A5\\u53D1\\u6D88\\u606F\\u5199\\u4F5C\\uFF0C\\u5B8C\\u6210\\u540E /publish\\n" +
+ "\\u968F\\u65F6 /preview \\u67E5\\u770B\\uFF0C/help \\u5168\\u90E8\\u547D\\u4EE4"
+ );
+};
+
+CMD_HANDLERS.edit = async function (chatId, slug) {
+ if (!slug) return sendMessage(chatId, "\\u683C\\u5F0F\\uFF1A/edit slug\\n\\u5148\\u7528 /list \\u67E5\\u770B\\u6587\\u7AE0 slug\\u3002");
+ try {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts/" + slug);
+ if (!res.ok) throw new Error("\\u672A\\u627E\\u5230\\u8BE5\\u6587\\u7AE0");
+ const post = await res.json();
+ const s = getSession(chatId);
+ s.title = post.frontMatter.title;
+ s.slug = post.frontMatter.slug;
+ s.content = post.content || "";
+ s.categories = post.frontMatter.categories || [];
+ s.tags = post.frontMatter.tags || [];
+ s.author = post.frontMatter.author || "qunlin";
+ s.draft = post.frontMatter.draft;
+ return sendMessage(chatId, "\\u270F \\u7F16\\u8F91\\uFF1A" + s.title + "\\n\\n" + sessionSummary(s) + "\\n\\n\\u76F4\\u63A5\\u53D1\\u6D88\\u606F\\u8FFD\\u52A0\\u5185\\u5BB9\\uFF0C\\u5199\\u5B8C\\u53D1\\u300C\\u53D1\\u5E03\\u300D\\u3002");
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C " + e.message);
+ }
+};
+
+CMD_HANDLERS.delete = async function (chatId, slug) {
+ if (!slug) return sendMessage(chatId, "Usage: /delete \\nFind slugs with /list\\n\\nArticles are moved to recycle bin (30-day retention).");
+ try {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts/" + slug, { method: "DELETE" });
+ if (!res.ok) throw new Error((await res.json()).error || "Delete failed");
+ return sendMessage(chatId, "\\u2702 Moved to recycle bin: " + slug + "\\nRecoverable within 30 days.");
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C " + e.message);
+ }
+};
+
+CMD_HANDLERS.view = async function (chatId, slug) {
+ if (!slug) return sendMessage(chatId, "Usage: /view \\nFind slugs with /list");
+ const ip = getLanIP();
+ const url = "http://" + ip + ":1313/" + slug + ".html";
+ return sendMessage(chatId, "\\uD83D\\uDCC9 Preview\\n\\n" + url + "\\n\\n\\uD83D\\uDCBB http://localhost:1313/" + slug + ".html");
+};
+
+CMD_HANDLERS.title = async function (chatId, newTitle) {
+ const s = getSession(chatId);
+ if (!newTitle) return sendMessage(chatId, "\\u683C\\u5F0F\\uFF1A\\u6807\\u9898 \\u65B0\\u6807\\u9898");
+ s.title = newTitle;
+ s.slug = makeSlug(newTitle);
+ return sendMessage(chatId, "\\u270F \\u6807\\u9898\\u5DF2\\u66F4\\u65B0\\uFF1A" + newTitle + "");
+};
+
+CMD_HANDLERS.categories = async function (chatId, cats) {
+ const s = getSession(chatId);
+ s.categories = cats ? cats.split(/[,;\"\\",\\"\\uFF0C\\u3001]+/).map(function(c){return c.trim();}).filter(Boolean) : [];
+ return sendMessage(chatId, "\\uD83D\\uDCC2 \\u5206\\u7C7B\\uFF1A" + (s.categories.join("\\u3001") || "\\u5DF2\\u6E05\\u7A7A"));
+};
+
+CMD_HANDLERS.tags = async function (chatId, tags) {
+ const s = getSession(chatId);
+ s.tags = tags ? tags.split(/[,;\"\\",\\"\\uFF0C\\u3001]+/).map(function(t){return t.trim();}).filter(Boolean) : [];
+ return sendMessage(chatId, "\\uD83C\\uDFF7 \\u6807\\u7B7E\\uFF1A" + (s.tags.join("\\u3001") || "\\u5DF2\\u6E05\\u7A7A"));
+};
+
+CMD_HANDLERS.author = async function (chatId, author) {
+ const s = getSession(chatId);
+ s.author = author || "qunlin";
+ return sendMessage(chatId, "\\u270D \\u4F5C\\u8005\\uFF1A" + s.author);
+};
+
+CMD_HANDLERS.preview = async function (chatId) {
+ const s = sessions.get(chatId);
+ if (!s || !s.title) return sendMessage(chatId, "\\u8FD8\\u6CA1\\u5F00\\u59CB\\u5199\\u3002\\u53D1\\u300C\\u5199\\u4E00\\u7BC7\\u300D\\u5F00\\u59CB\\u3002");
+ if (!s.content) return sendMessage(chatId, "\\u6B63\\u6587\\u4E3A\\u7A7A\\u3002");
+ const maxLen = 3800;
+ const preview = s.content.slice(0, maxLen);
+ return sendMessage(chatId, "\\uD83D\\uDCDD " + s.title + "\\n\\n" + preview + (s.content.length > maxLen ? "\\n\\n... \\u524D " + maxLen + " \\u5B57" : ""));
+};
+
+CMD_HANDLERS.draft = async function (chatId) {
+ const s = sessions.get(chatId);
+ if (!s || !s.title) return sendMessage(chatId, "\\u8FD8\\u6CA1\\u5F00\\u59CB\\u5199\\u3002\\u53D1\\u300C\\u5199\\u4E00\\u7BC7\\u300D\\u5F00\\u59CB\\u3002");
+ if (!s.content) return sendMessage(chatId, "\\u6B63\\u6587\\u4E3A\\u7A7A\\uFF0C\\u5148\\u5199\\u70B9\\u5185\\u5BB9\\u518D\\u4FDD\\u5B58\\u3002");
+ try {
+ await saveViaAPI(s, true);
+ clearSession(chatId);
+ return sendMessage(chatId, "\\uD83D\\uDCC4 \\u8349\\u7A3F\\u5DF2\\u4FDD\\u5B58\\uFF1A" + s.title + "");
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C \\u4FDD\\u5B58\\u5931\\u8D25\\uFF1A" + e.message + "\\n\\n\\u8BF7\\u5148 /start \\u542F\\u52A8\\u540E\\u53F0\\u670D\\u52A1\\u3002");
+ }
+};
+
+CMD_HANDLERS.publish = async function (chatId) {
+ const s = sessions.get(chatId);
+ if (!s || !s.title) return sendMessage(chatId, "\\u8FD8\\u6CA1\\u5F00\\u59CB\\u5199\\u3002\\u53D1\\u300C\\u5199\\u4E00\\u7BC7\\u300D\\u5F00\\u59CB\\u3002");
+ if (!s.content) return sendMessage(chatId, "\\u6B63\\u6587\\u4E3A\\u7A7A\\uFF0C\\u5148\\u5199\\u70B9\\u5185\\u5BB9\\u518D\\u53D1\\u5E03\\u3002");
+ try {
+ await saveViaAPI(s, false);
+ const slug = s.slug;
+ clearSession(chatId);
+ const ip = getLanIP();
+ return sendMessage(chatId, "\\u2705 \\u5DF2\\u53D1\\u5E03\\uFF01\\n\\n\\uD83D\\uDCDD " + s.title + "\\n\\u9884\\u89C8\\uFF1Ahttp://localhost:1313/" + slug + ".html\\n\\u7EBF\\u4E0A\\uFF1Ahttps://usj.cc/" + slug + ".html");
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C \\u53D1\\u5E03\\u5931\\u8D25\\uFF1A" + e.message + "\\n\\n\\u8BF7\\u5148 /start \\u542F\\u52A8\\u540E\\u53F0\\u670D\\u52A1\\u3002");
+ }
+};
+
+CMD_HANDLERS.cancel = async function (chatId) {
+ const s = sessions.get(chatId);
+ if (!s || !s.title) return sendMessage(chatId, "\\u6CA1\\u6709\\u6D3B\\u52A8\\u7684\\u8349\\u7A3F\\u3002");
+ clearSession(chatId);
+ return sendMessage(chatId, "\\u2702 \\u8349\\u7A3F\\u5DF2\\u4E22\\u5F03\\u3002");
+};
+
+CMD_HANDLERS.list = async function (chatId, pageStr) {
+ const ip = getLanIP();
+ return sendMessage(chatId,
+ "\\uD83D\\uDCD1 \\u6587\\u7AE0\\u5217\\u8868\\n\\n" +
+ "\\uD83D\\uDCF1 \\u624B\\u673A\\u6253\\u5F00: http://" + ip + ":1313/posts/\\n" +
+ "\\uD83D\\uDCBB \\u7535\\u8111\\u6253\\u5F00: http://localhost:1313/posts/\\n\\n" +
+ "\\u2705 \\u65B0\\u5EFA: /new \\u300A\\u6807\\u9898\\u300B\\n" +
+ "\\uD83D\\uDCD1 \\u7F16\\u8F91: /edit slug\\n" +
+ "\\uD83D\\uDCC4 \\u8349\\u7A3F: /draft\\n" +
+ "\\uD83D\\uDE80 \\u53D1\\u5E03: /publish"
+ );
+};
+
+// ---------- Links (友链) ----------
+CMD_HANDLERS.links = async function (chatId, pageStr) {
+ const page = parseInt(pageStr) || 1;
+ const perPage = 10;
+ try {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links?all=1");
+ const data = await res.json();
+ const allLinks = data.links || [];
+ if (allLinks.length === 0) return sendMessage(chatId, "No links found.");
+
+ const totalPages = Math.ceil(allLinks.length / perPage);
+ const p = page > totalPages ? totalPages : page;
+ const start = (p - 1) * perPage;
+ const pageLinks = allLinks.slice(start, start + perPage);
+
+ const lines = ["\\uD83D\\uDD17 \\u53CB\\u94FE \\u5171" + allLinks.length + " \\u4E2A \\u00B7 \\u7B2C" + p + "/" + totalPages + " \\u9875"];
+ pageLinks.forEach(function(l) {
+ const icon = l.hidden ? "\\uD83D\\uDD12" : "\\uD83C\\uDF10";
+ const name = (l.name || l.url).slice(0, 40);
+ lines.push(icon + " " + name + " " + l.url);
+ });
+
+ const keyboard = { inline_keyboard: [] };
+ const navRow = [];
+ if (p > 1) navRow.push({ text: "\\u25C0 \\u4E0A\\u4E00\\u9875", callback_data: "links_" + (p - 1) });
+ if (p < totalPages) navRow.push({ text: "\\u4E0B\\u4E00\\u9875 \\u25B6", callback_data: "links_" + (p + 1) });
+ if (navRow.length) keyboard.inline_keyboard.push(navRow);
+
+ const payload = {
+ chat_id: chatId,
+ text: lines.join("\\n"),
+ parse_mode: "HTML",
+ reply_markup: navRow.length ? keyboard : undefined,
+ disable_web_page_preview: true,
+ };
+ const { tg } = require("./tg-core");
+ return tg("sendMessage", payload);
+ } catch (e) {
+ return sendMessage(chatId, "Please /start Write first.");
+ }
+};
+
+CMD_HANDLERS.link_add = async function (chatId, arg) {
+ if (!arg) return sendMessage(chatId, "Usage: /link_add \\nExample: /link_add https://example.com My Blog");
+ const parts = arg.split(/\\s+/);
+ let url = parts[0];
+ const name = parts.slice(1).join(" ") || url;
+ if (!url.startsWith("http")) url = "https://" + url;
+ try {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url, name }),
+ });
+ if (!res.ok) throw new Error((await res.json()).error || "Add failed");
+ return sendMessage(chatId, "\\u2705 Link added: " + name + "\\n" + url);
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C " + e.message);
+ }
+};
+
+CMD_HANDLERS.link_del = async function (chatId, url) {
+ if (!url) return sendMessage(chatId, "Usage: /link_del \\nUse /links to find the URL.");
+ try {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links?url=" + encodeURIComponent(url), {
+ method: "DELETE",
+ });
+ if (!res.ok) throw new Error((await res.json()).error || "Delete failed");
+ return sendMessage(chatId, "\\u2705 Link deleted: " + url);
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C " + e.message);
+ }
+};
+
+CMD_HANDLERS.link_toggle = async function (chatId, url) {
+ if (!url) return sendMessage(chatId, "Usage: /link_toggle \\nToggle visibility of a link.");
+ try {
+ const listRes = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links?all=1");
+ const listData = await listRes.json();
+ const link = (listData.links || []).find(function(l) { return l.url === url; });
+ if (!link) return sendMessage(chatId, "\\u274C Link not found: " + url);
+ const newHidden = !link.hidden;
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ oldUrl: url, hidden: newHidden }),
+ });
+ if (!res.ok) throw new Error((await res.json()).error || "Toggle failed");
+ const label = newHidden ? "\\uD83D\\uDD12 Hidden" : "\\uD83C\\uDF10 Visible";
+ return sendMessage(chatId, label + ": " + url);
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C " + e.message);
+ }
+};
+
+// ---------- Feeds (订阅) ----------
+CMD_HANDLERS.feeds = async function (chatId, pageStr) {
+ const page = parseInt(pageStr) || 1;
+ const perPage = 10;
+ try {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/feeds");
+ const data = await res.json();
+ const allFeeds = data.feeds || [];
+ if (allFeeds.length === 0) return sendMessage(chatId, "No feeds configured.");
+
+ const totalPages = Math.ceil(allFeeds.length / perPage);
+ const p = page > totalPages ? totalPages : page;
+ const start = (p - 1) * perPage;
+ const pageFeeds = allFeeds.slice(start, start + perPage);
+
+ const lines = ["\\uD83D\\uDCE1 \\u8BA2\\u9605\\u6E90 \\u5171" + allFeeds.length + " \\u4E2A \\u00B7 \\u7B2C" + p + "/" + totalPages + " \\u9875"];
+ pageFeeds.forEach(function(f) {
+ const title = (f.feedTitle || f.url).slice(0, 50);
+ lines.push("\\uD83D\\uDCDC " + title + "\\n " + f.url);
+ });
+
+ const keyboard = { inline_keyboard: [] };
+ const navRow = [];
+ if (p > 1) navRow.push({ text: "\\u25C0 \\u4E0A\\u4E00\\u9875", callback_data: "feeds_" + (p - 1) });
+ if (p < totalPages) navRow.push({ text: "\\u4E0B\\u4E00\\u9875 \\u25B6", callback_data: "feeds_" + (p + 1) });
+ if (navRow.length) keyboard.inline_keyboard.push(navRow);
+
+ const { tg } = require("./tg-core");
+ return tg("sendMessage", {
+ chat_id: chatId,
+ text: lines.join("\\n"),
+ parse_mode: "HTML",
+ reply_markup: navRow.length ? keyboard : undefined,
+ disable_web_page_preview: true,
+ });
+ } catch (e) {
+ return sendMessage(chatId, "Please /start Write first.");
+ }
+};
+
+CMD_HANDLERS.feed_add = async function (chatId, arg) {
+ if (!arg) return sendMessage(chatId, "Usage: /feed_add \\nExample: /feed_add https://example.com/rss.xml");
+ const parts = arg.split(/\\s+/);
+ const url = parts[0];
+ const feedTitle = parts.slice(1).join(" ") || "";
+ try {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/feeds", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url, feedTitle }),
+ });
+ if (!res.ok) throw new Error((await res.json()).error || "Add failed");
+ return sendMessage(chatId, "\\u2705 Feed added: " + url);
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C " + e.message);
+ }
+};
+
+CMD_HANDLERS.feed_del = async function (chatId, url) {
+ if (!url) return sendMessage(chatId, "Usage: /feed_del \\nUse /feeds to find the URL.");
+ try {
+ const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/feeds?url=" + encodeURIComponent(url), {
+ method: "DELETE",
+ });
+ if (!res.ok) throw new Error((await res.json()).error || "Delete failed");
+ return sendMessage(chatId, "\\u2705 Feed deleted: " + url);
+ } catch (e) {
+ return sendMessage(chatId, "\\u274C " + e.message);
+ }
+};
+
+// ---------- Stats ----------
+CMD_HANDLERS.stats = async function (chatId) {
+ try {
+ const [resPosts, resLinks, resFeeds] = await Promise.all([
+ fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts?perPage=1"),
+ fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links?all=1"),
+ fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/feeds"),
+ ]);
+ const postsData = await resPosts.json();
+ const linksData = await resLinks.json();
+ const feedsData = await resFeeds.json();
+
+ const totalPosts = postsData.totalCount || postsData.total || 0;
+ const totalLinks = (linksData.links || []).filter(function(l) { return !l.hidden; }).length;
+ const totalFeeds = (feedsData.feeds || []).length;
+
+ return sendMessage(chatId,
+ "\\uD83D\\uDCCA \\u7F51\\u7AD9\\u6570\\u636E\\n\\n" +
+ "\\uD83D\\uDCDD \\u6587\\u7AE0\\uFF1A" + totalPosts + " \\u7BC7\\n" +
+ "\\uD83D\\uDD17 \\u53CB\\u94FE\\uFF1A" + totalLinks + " \\u4E2A\\n" +
+ "\\uD83D\\uDCE1 \\u8BA2\\u9605\\u6E90\\uFF1A" + totalFeeds + " \\u4E2A\\n\\n" +
+ "\\uD83C\\uDF10 usj.cc"
+ );
+ } catch (e) {
+ return sendMessage(chatId, "\\u8BF7\\u5148 /start \\u542F\\u52A8\\u540E\\u53F0\\u670D\\u52A1\\u3002");
+ }
+};
+
+module.exports = CMD_HANDLERS;
diff --git a/bot/src/tg-poll.js b/bot/src/tg-poll.js
new file mode 100644
index 00000000..2989b339
--- /dev/null
+++ b/bot/src/tg-poll.js
@@ -0,0 +1,103 @@
+// tg-poll.js - Telegram polling loop and message dispatch
+const config = require("./config");
+const { tg, allowed, sendMessage, handleCallback } = require("./tg-core");
+const { sessions, getSession } = require("./sessions");
+const CMD_HANDLERS = require("./tg-handlers");
+
+let lastOffset = 0;
+
+// Commands that accept an argument
+const ARG_COMMANDS = new Set([
+ "new", "title", "categories", "tags", "author",
+ "edit", "view", "delete",
+ "link_add", "link_del", "link_toggle",
+ "feed_add", "feed_del",
+ "list", "links", "feeds",
+]);
+
+async function tgPoll() {
+ if (!config.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;
+
+ // Handle callback queries (inline buttons)
+ if (update.callback_query) {
+ const cb = update.callback_query;
+ const cbChatId = cb.message ? cb.message.chat.id : (cb.from ? cb.from.id : 0);
+ handleCallback(cbChatId, cb.id, cb.data, CMD_HANDLERS);
+ continue;
+ }
+
+ const msg = update.message;
+ const chatId = msg.chat.id;
+ if (!allowed(chatId)) continue;
+
+ // Handle photo in writing session - append as Markdown image
+ if (msg.photo && sessions.has(chatId)) {
+ try {
+ 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 caption = msg.caption || "";
+ const imgUrl = "https://api.telegram.org/file/bot" + config.TG_TOKEN + "/" + filePath;
+ s.content += "\\n\\n";
+ }
+ } catch (e) {
+ console.error("[photo]", e.message);
+ }
+ continue;
+ }
+
+ if (!msg?.text) continue;
+
+ const fullText = msg.text ? msg.text.trim() : (msg.caption || "").trim();
+ const parts = fullText.split("@")[0].split(/\\s+/);
+ const slashCmd = parts[0].toLowerCase().slice(1);
+ const slashArg = parts.slice(1).join(" ");
+ const hasSession = sessions.has(chatId);
+
+ // 1) If in writing session and NOT a slash command, append text silently
+ if (hasSession && !fullText.startsWith("/")) {
+ try {
+ const s = getSession(chatId);
+ s.content += fullText + "\\n";
+ } catch (e) {
+ console.error("[append]", e.message);
+ }
+ continue;
+ }
+
+ // 2) Slash commands - each wrapped in try/catch
+ try {
+ if (ARG_COMMANDS.has(slashCmd)) {
+ const handler = CMD_HANDLERS[slashCmd];
+ if (handler) await handler(chatId, slashArg);
+ else if (slashCmd) await sendMessage(chatId, "\\u274C Unknown: /" + slashCmd);
+ } else {
+ const handler = CMD_HANDLERS[slashCmd];
+ if (handler) await handler(chatId);
+ else if (slashCmd) await sendMessage(chatId, "\\u274C Unknown: /" + slashCmd);
+ }
+ } catch (e) {
+ console.error("[handler " + slashCmd + "]", e.message);
+ try { await sendMessage(chatId, "\\u274C " + (e.message || "error").slice(0, 200)); } catch {}
+ }
+ }
+ }
+ } catch (e) {
+ console.error("[tg poll]", e.message);
+ await new Promise((r) => setTimeout(r, 3000));
+ }
+ setImmediate(tgPoll);
+}
+
+module.exports = { tgPoll };
diff --git a/bot/src/write-server.js b/bot/src/write-server.js
new file mode 100644
index 00000000..1e02401e
--- /dev/null
+++ b/bot/src/write-server.js
@@ -0,0 +1,118 @@
+// write-server.js - Manage the Write (Next.js) and Hugo processes
+const { spawn, execSync } = require("child_process");
+const config = require("./config");
+const { waitForReady } = require("./helpers");
+
+let writeProcess = null;
+let writeReady = false;
+let hugoProcess = null;
+
+function startWriteServer() {
+ if (writeProcess) {
+ return { started: false, reason: writeReady ? "already_running" : "starting" };
+ }
+ writeReady = false;
+ const isWin = process.platform === "win32";
+ writeProcess = spawn(
+ isWin ? "npm.cmd" : "npm",
+ ["run", "dev", "--", "-p", String(config.WRITE_PORT), "-H", "0.0.0.0"],
+ { cwd: config.WRITE_DIR, shell: isWin, stdio: "pipe", windowsHide: true }
+ );
+ writeProcess.stdout?.on("data", (d) => process.stdout.write(d));
+ writeProcess.stderr?.on("data", (d) => process.stderr.write(d));
+ writeProcess.on("exit", (code) => {
+ console.log("[write] exited " + code);
+ writeProcess = null;
+ writeReady = false;
+ });
+ return { started: true };
+}
+
+function stopWriteServer() {
+ if (hugoProcess) {
+ try {
+ const hpid = hugoProcess.pid;
+ if (process.platform === "win32" && hpid) {
+ execSync("taskkill /PID " + hpid + " /T /F", { stdio: "ignore" });
+ } else {
+ hugoProcess.kill();
+ }
+ } catch {}
+ hugoProcess = null;
+ }
+ if (!writeProcess) return false;
+ const pid = writeProcess.pid;
+ try {
+ if (process.platform === "win32" && pid) {
+ execSync("taskkill /PID " + pid + " /T /F", { stdio: "ignore" });
+ } else {
+ writeProcess.kill();
+ }
+ } catch {}
+ writeProcess = null;
+ writeReady = false;
+ return true;
+}
+
+function startHugoIfNeeded() {
+ if (hugoProcess) return;
+ try {
+ hugoProcess = spawn("hugo", ["server", "--bind", "0.0.0.0", "--port", "1313", "--disableFastRender"], {
+ cwd: config.WRITE_DIR + "/..",
+ shell: true,
+ stdio: "pipe",
+ windowsHide: true,
+ });
+ hugoProcess.on("exit", (code) => {
+ console.log("[hugo] exited " + code);
+ hugoProcess = null;
+ });
+ console.log("[hugo] started on 0.0.0.0:1313");
+ } catch (e) {
+ console.error("[hugo]", e.message);
+ }
+}
+
+function getWriteStatus() {
+ return {
+ running: !!(writeProcess && writeReady),
+ starting: !!(writeProcess && !writeReady),
+ };
+}
+
+async function ensureWriteReady(chatId, sendMessage) {
+ if (writeProcess && writeReady) {
+ startHugoIfNeeded();
+ return true;
+ }
+ if (writeProcess && !writeReady) {
+ await sendMessage(chatId, "\\u231B Write \\u6B63\\u5728\\u542F\\u52A8\\u4E2D...");
+ const ready = await waitForReady(config.WRITE_PORT);
+ if (ready) {
+ writeReady = true;
+ startHugoIfNeeded();
+ return true;
+ }
+ return false;
+ }
+ const result = startWriteServer();
+ if (!result.started) return false;
+ await sendMessage(chatId, "\\u231B \\u6B63\\u5728\\u542F\\u52A8 Write...");
+ const ready = await waitForReady(config.WRITE_PORT);
+ if (ready) {
+ writeReady = true;
+ startHugoIfNeeded();
+ return true;
+ }
+ return false;
+}
+
+module.exports = {
+ get writeReady() { return writeReady; },
+ get writePort() { return config.WRITE_PORT; },
+ startWriteServer,
+ stopWriteServer,
+ startHugoIfNeeded,
+ getWriteStatus,
+ ensureWriteReady,
+};
diff --git a/bot/start.bat b/bot/start.bat
new file mode 100644
index 00000000..5ed68ea4
--- /dev/null
+++ b/bot/start.bat
@@ -0,0 +1,5 @@
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0"
+node index.js
+pause
diff --git a/bot/writebot.ico b/bot/writebot.ico
new file mode 100644
index 00000000..8a595e74
Binary files /dev/null and b/bot/writebot.ico differ
diff --git a/check.ps1 b/check.ps1
new file mode 100644
index 00000000..c73ef904
--- /dev/null
+++ b/check.ps1
@@ -0,0 +1 @@
+$e=$null; $t=$null; [void][System.Management.Automation.Language.Parser]::ParseFile('E:\GitHub\blog\bot\tray.ps1',[ref]$t,[ref]$e); if($e -and $e.Count -gt 0){foreach($err in $e){Write-Host $err.Message}}else{Write-Host "tray.ps1: OK"}
\ No newline at end of file
diff --git a/content/post/2026/2026-05-31-周末我把手机系统降级了一个大版本/index.md b/content/post/2026/2026-05-31-周末我把手机系统降级了一个大版本/index.md
new file mode 100644
index 00000000..f2626a0b
--- /dev/null
+++ b/content/post/2026/2026-05-31-周末我把手机系统降级了一个大版本/index.md
@@ -0,0 +1,32 @@
+---
+title: 周末我把手机系统降级了一个大版本
+date: '2026-05-31T00:00:00.000Z'
+slug: '202605311204'
+draft: false
+categories:
+ - 生活
+tags:
+ - OPPO;ColorOS
+author: 小赵
+layout: post
+---
+从 iPhone 16E 换到 OPPO Find X8s 之后,它就一直是主力机,没再变过。
+
+说实话,OPPO 这套系统我用着挺满意,功能到位,稳定性也没得挑。
+
+之所以彻底放弃 iPhone 当主力,理由其实很充分很现实:微信双开和通话录音。这两样,iPhone 都给不了,而我的工作又偏偏离不开。
+
+中间也动过双持的念头,两台手机分工协作,听起来挺美。但真用起来,麻烦比便利多。光是琢磨哪个 App 装在哪台手机上,就够头疼的。用了一阵子发现,我根本没有那份“区分使用”的需求,反而更想要一个干净利落的统一。
+
+手机到手时,预装的是 ColorOS 15。我就是被它的宣传语打动的——“流畅稳定,德芙一样丝滑”。用下来,确实名副其实,那种跟手和舒心,让我觉得自己选对了。
+
+所以 ColorOS 16 一推送,我毫不犹豫就更了。我的手机一直是默认自动更新,总相信新的一定更好。
+
+但很快我就发现,厂商的劲头,好像全往一个方向使了:AI。
+
+到处都是 AI。系统里那些曾经让我安心的“本分”感,开始变了味。
+
+带给我实际的体验就是,耗电和发热更厉害了,手机续航也来到了一天两充,看着手机里的电池健康,实际上还是百分之百
+
+
+
diff --git a/content/post/2026/2026-06-01-datetest/index.md b/content/post/2026/2026-06-01-datetest/index.md
new file mode 100644
index 00000000..075bf38a
--- /dev/null
+++ b/content/post/2026/2026-06-01-datetest/index.md
@@ -0,0 +1,8 @@
+---
+title: DateTest
+date:
+slug: date-test
+draft: true
+author: qunlin
+---
+Testing date auto-fill.
diff --git a/content/post/2026/2026-06-01-go-test-post/index.md b/content/post/2026/2026-06-01-go-test-post/index.md
new file mode 100644
index 00000000..669391e7
--- /dev/null
+++ b/content/post/2026/2026-06-01-go-test-post/index.md
@@ -0,0 +1,15 @@
+---
+title: Go Test Post
+date:
+slug: go-test-post
+draft: true
+categories:
+ - test
+tags:
+ - go
+ - test
+author: qunlin
+---
+This is a test post created by the Go write server.
+
+It works!
diff --git a/fix-shortcut.ps1 b/fix-shortcut.ps1
new file mode 100644
index 00000000..dc66bc19
--- /dev/null
+++ b/fix-shortcut.ps1
@@ -0,0 +1,16 @@
+$startup = [Environment]::GetFolderPath("Startup")
+$shortcutPath = Join-Path $startup "WriteBot.lnk"
+$icoPath = "E:\GitHub\blog\bot\writebot.ico"
+
+$wsh = New-Object -ComObject WScript.Shell
+$shortcut = $wsh.CreateShortcut($shortcutPath)
+$shortcut.TargetPath = "C:\nvm4w\nodejs\node.exe"
+$shortcut.Arguments = "index.js"
+$shortcut.WorkingDirectory = "E:\GitHub\blog\bot"
+$shortcut.WindowStyle = 7
+$shortcut.Description = "WriteBot"
+$shortcut.IconLocation = "$icoPath,0"
+$shortcut.Save()
+
+Write-Host "Startup shortcut updated: $shortcutPath"
+Write-Host "Target: node.exe index.js (cwd: E:\GitHub\blog\bot)"
\ No newline at end of file
diff --git a/go.work b/go.work
new file mode 100644
index 00000000..92851eeb
--- /dev/null
+++ b/go.work
@@ -0,0 +1,6 @@
+go 1.26.3
+
+use (
+ ./bot-go
+ ./write-go
+)
diff --git a/install-startup.ps1 b/install-startup.ps1
new file mode 100644
index 00000000..17e6443d
--- /dev/null
+++ b/install-startup.ps1
@@ -0,0 +1,18 @@
+$startup = [Environment]::GetFolderPath("Startup")
+$shortcutPath = Join-Path $startup "WriteBot.lnk"
+$targetPath = "E:\GitHub\blog\bot\start-tray.vbs"
+
+$wsh = New-Object -ComObject WScript.Shell
+$shortcut = $wsh.CreateShortcut($shortcutPath)
+$shortcut.TargetPath = "wscript.exe"
+$shortcut.Arguments = "`"$targetPath`""
+$shortcut.WorkingDirectory = "E:\GitHub\blog\bot"
+$shortcut.WindowStyle = 7 # Minimized
+$shortcut.Description = "WriteBot tray launcher"
+$shortcut.IconLocation = "wscript.exe,0"
+$shortcut.Save()
+
+Write-Host "Startup folder: $startup"
+Write-Host "Shortcut created: $shortcutPath"
+Write-Host ""
+Get-ChildItem -LiteralPath $shortcutPath | Format-List Name, FullName, LastWriteTime
\ No newline at end of file
diff --git a/patch.ps1 b/patch.ps1
new file mode 100644
index 00000000..f94d6425
--- /dev/null
+++ b/patch.ps1
@@ -0,0 +1,23 @@
+$f = "E:\GitHub\blog\bot\tray.ps1"
+$s = [System.IO.File]::ReadAllText($f, [System.Text.Encoding]::UTF8)
+# Find the MessageBox catch and replace it
+$old = ' [System.Windows.Forms.MessageBox]::Show(' + [char]10 + ' "Node.js' + [char]0x672A + [char]0x5B89 + [char]0x88C5 + [char]0x6216 + [char]0x65E0 + [char]0x6CD5 + [char]0x542F + [char]0x52A8' + [char]0x3002 + '"`n`n$_",'
+Write-Host "searching..."
+$idx = $s.IndexOf("catch {`n [System.Windows.Forms.MessageBox]")
+if ($idx -lt 0) { $idx = $s.IndexOf("MessageBox") }
+Write-Host "idx: $idx"
+if ($idx -gt 0) {
+ $before = $s.Substring(0, $idx)
+ $afterMarker = " exit 1`n}`n`n|notifyIcon"
+ # find the closing } after the MessageBox catch
+ $afterIdx = $idx + 200
+ $remaining = $s.Substring($afterIdx)
+ $closePos = $remaining.IndexOf("exit 1") + 7
+ $after = $s.Substring($afterIdx + $closePos)
+ $replacement = "} catch {`n `$log = Join-Path `$BotDir `".tray-error.log`"`n `"tray.ps1 failed: `$_`" | Out-File `$log -Encoding UTF8`n `$notifyIcon.Visible = `$false`n exit 1`n}`n"
+ $s = $before + $replacement + $after
+ [System.IO.File]::WriteAllText($f, $s, [System.Text.UTF8Encoding]::new($true))
+ Write-Host "PATCHED"
+} else {
+ Write-Host "NOT FOUND"
+}
\ No newline at end of file
diff --git a/tmp_bot.txt b/tmp_bot.txt
new file mode 100644
index 00000000..6f6a3d46
--- /dev/null
+++ b/tmp_bot.txt
@@ -0,0 +1,17 @@
+=== BOT LENGTH: 9922 ===
+package main
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "writebot/internal/adminpanel"
+ "writebot/internal/config"
+ "writebot/internal/helpers"
+ "writeb
\ No newline at end of file
diff --git a/tmp_bot2.py b/tmp_bot2.py
new file mode 100644
index 00000000..62d1c2d2
--- /dev/null
+++ b/tmp_bot2.py
@@ -0,0 +1,208 @@
+# Write minimal bot-only main.go
+bot_main = '''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 += "\n\n"
+ }
+ }
+ }
+ 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 + "\n"
+ 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)
+ }
+ }
+}
+'''
+
+with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "w", encoding="utf-8") as f:
+ f.write(bot_main)
+
+print("Bot main.go: simplified, size:", len(bot_main))
diff --git a/tmp_botcfg.py b/tmp_botcfg.py
new file mode 100644
index 00000000..4decd17b
--- /dev/null
+++ b/tmp_botcfg.py
@@ -0,0 +1,9 @@
+base = r"E:\GitHub\blog"
+with open(base + "\\bot-go\\internal\\config\\config.go", "rb") as f:
+ content = f.read()
+text = content.decode("utf-8")
+text = text.replace("package config", "package botconfig")
+text = text.replace("writebot/internal/", "write-server/internal/")
+with open(base + "\\write-go\\internal\\botconfig\\config.go", "wb") as f:
+ f.write(text.encode("utf-8"))
+print("botconfig fixed")
diff --git a/tmp_botcfg2.py b/tmp_botcfg2.py
new file mode 100644
index 00000000..3f6c2804
--- /dev/null
+++ b/tmp_botcfg2.py
@@ -0,0 +1,80 @@
+botcfg = '''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
+}
+'''
+
+import os
+dst = r"E:\GitHub\blog\bot-go\internal\botcfg"
+os.makedirs(dst, exist_ok=True)
+with open(os.path.join(dst, "config.go"), "w", encoding="utf-8") as f:
+ f.write(botcfg)
+print("botcfg created")
diff --git a/tmp_check.py b/tmp_check.py
new file mode 100644
index 00000000..8d18de49
--- /dev/null
+++ b/tmp_check.py
@@ -0,0 +1,3 @@
+import urllib.request
+r = urllib.request.urlopen('http://127.0.0.1:8017/', timeout=5)
+print('127.0.0.1 OK:', r.status, len(r.read().decode()), 'bytes')
diff --git a/tmp_check2.py b/tmp_check2.py
new file mode 100644
index 00000000..bec86423
--- /dev/null
+++ b/tmp_check2.py
@@ -0,0 +1,11 @@
+import urllib.request
+try:
+ r = urllib.request.urlopen('http://localhost:8017/', timeout=5)
+ print('localhost OK:', r.status)
+except Exception as e:
+ print('localhost FAIL:', e)
+try:
+ r = urllib.request.urlopen('http://127.0.0.1:8017/', timeout=5)
+ print('127.0.0.1 OK:', r.status)
+except Exception as e:
+ print('127.0.0.1 FAIL:', e)
diff --git a/tmp_client.go b/tmp_client.go
new file mode 100644
index 00000000..d233d0ca
--- /dev/null
+++ b/tmp_client.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+)
+
+func main() {
+ resp, err := http.Get("http://localhost:8016/api/posts")
+ if err != nil {
+ fmt.Println("ERROR:", err)
+ os.Exit(1)
+ }
+ defer resp.Body.Close()
+ fmt.Println("Status:", resp.Status)
+ body, _ := io.ReadAll(resp.Body)
+ if len(body) > 500 {
+ body = body[:500]
+ }
+ fmt.Println("Body:", string(body))
+}
diff --git a/tmp_client_test.go b/tmp_client_test.go
new file mode 100644
index 00000000..d233d0ca
--- /dev/null
+++ b/tmp_client_test.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+)
+
+func main() {
+ resp, err := http.Get("http://localhost:8016/api/posts")
+ if err != nil {
+ fmt.Println("ERROR:", err)
+ os.Exit(1)
+ }
+ defer resp.Body.Close()
+ fmt.Println("Status:", resp.Status)
+ body, _ := io.ReadAll(resp.Body)
+ if len(body) > 500 {
+ body = body[:500]
+ }
+ fmt.Println("Body:", string(body))
+}
diff --git a/tmp_diag.py b/tmp_diag.py
new file mode 100644
index 00000000..68680f87
--- /dev/null
+++ b/tmp_diag.py
@@ -0,0 +1,39 @@
+import urllib.request, json
+
+# 1. write-go root
+try:
+ r = urllib.request.urlopen('http://localhost:8016/', timeout=5)
+ print('8016 / :', r.status)
+except Exception as e:
+ print('8016 / : ERROR -', e)
+
+# 2. write-go admin page (fe)
+try:
+ r = urllib.request.urlopen('http://localhost:8016/admin', timeout=5)
+ print('8016 /admin:', r.status)
+except Exception as e:
+ print('8016 /admin: ERROR -', e)
+
+# 3. bot-go status
+try:
+ r = urllib.request.urlopen('http://127.0.0.1:8017/status', timeout=5)
+ d = json.loads(r.read().decode())
+ print('8017 /status:', d)
+except Exception as e:
+ print('8017 /status: ERROR -', e)
+
+# 4. bot-go start
+try:
+ req = urllib.request.Request('http://127.0.0.1:8017/start', method='POST')
+ r = urllib.request.urlopen(req, timeout=5)
+ print('8017 /start:', r.status, r.read().decode()[:200])
+except Exception as e:
+ print('8017 /start: ERROR -', e)
+
+# 5. bot-go stop
+try:
+ req = urllib.request.Request('http://127.0.0.1:8017/stop', method='POST')
+ r = urllib.request.urlopen(req, timeout=5)
+ print('8017 /stop:', r.status, r.read().decode()[:200])
+except Exception as e:
+ print('8017 /stop: ERROR -', e)
diff --git a/tmp_extract.py b/tmp_extract.py
new file mode 100644
index 00000000..5308304c
--- /dev/null
+++ b/tmp_extract.py
@@ -0,0 +1,18 @@
+# Read bot-go main.go to get TG poll and admin panel startup code
+with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "r", encoding="utf-8", errors="replace") as f:
+ bot_main = f.read()
+
+# Read current write-go main.go
+with open(r"E:\GitHub\blog\write-go\cmd\writeserver\main.go", "r", encoding="utf-8", errors="replace") as f:
+ write_main = f.read()
+
+# Save for inspection
+with open(r"E:\GitHub\blog\tmp_bot.txt", "w", encoding="utf-8") as f:
+ f.write("=== BOT LENGTH: " + str(len(bot_main)) + " ===\n")
+ f.write(bot_main[:200])
+
+with open(r"E:\GitHub\blog\tmp_write.txt", "w", encoding="utf-8") as f:
+ f.write("=== WRITE LENGTH: " + str(len(write_main)) + " ===\n")
+ f.write(write_main[:200])
+
+print("Extracted both files, lengths:", len(bot_main), len(write_main))
diff --git a/tmp_final.py b/tmp_final.py
new file mode 100644
index 00000000..c8ed3e3f
--- /dev/null
+++ b/tmp_final.py
@@ -0,0 +1,46 @@
+import urllib.request, json
+
+# 1. write-go root (admin page)
+try:
+ r = urllib.request.urlopen('http://localhost:8016/', timeout=5)
+ body = r.read().decode()
+ print('8016 / :', r.status, 'HTML length:', len(body))
+except Exception as e:
+ print('8016 / : ERROR -', e)
+
+# 2. write-go stats
+try:
+ r = urllib.request.urlopen('http://localhost:8016/api/stats', timeout=5)
+ print('8016 stats:', r.read().decode())
+except Exception as e:
+ print('8016 stats: ERROR -', e)
+
+# 3. bot-go health
+try:
+ r = urllib.request.urlopen('http://127.0.0.1:8017/health', timeout=5)
+ print('8017 health:', r.read().decode())
+except Exception as e:
+ print('8017 health: ERROR -', e)
+
+# 4. bot-go status
+try:
+ r = urllib.request.urlopen('http://127.0.0.1:8017/status', timeout=5)
+ print('8017 status:', r.read().decode())
+except Exception as e:
+ print('8017 status: ERROR -', e)
+
+# 5. bot-go start (should say already running)
+try:
+ req = urllib.request.Request('http://127.0.0.1:8017/start', method='POST')
+ r = urllib.request.urlopen(req, timeout=5)
+ print('8017 start:', r.read().decode())
+except Exception as e:
+ print('8017 start: ERROR -', e)
+
+# 6. bot-go stop (just test it works, then restart)
+try:
+ req = urllib.request.Request('http://127.0.0.1:8017/stop', method='POST')
+ r = urllib.request.urlopen(req, timeout=5)
+ print('8017 stop:', r.read().decode())
+except Exception as e:
+ print('8017 stop: ERROR -', e)
diff --git a/tmp_final2.py b/tmp_final2.py
new file mode 100644
index 00000000..c467bddb
--- /dev/null
+++ b/tmp_final2.py
@@ -0,0 +1,24 @@
+import urllib.request, json
+
+print("=== WriteServer (8016) ===")
+r = urllib.request.urlopen('http://localhost:8016/', timeout=5)
+print(" / :", r.status, "- HTML page OK")
+
+r = urllib.request.urlopen('http://localhost:8016/api/stats', timeout=5)
+print(" stats :", r.read().decode())
+
+r = urllib.request.urlopen('http://localhost:8016/api/posts?perPage=1', timeout=5)
+print(" posts :", r.status, "- OK")
+
+print("")
+print("=== WriteBot (8017) ===")
+r = urllib.request.urlopen('http://127.0.0.1:8017/health', timeout=5)
+print(" health :", r.read().decode())
+
+r = urllib.request.urlopen('http://127.0.0.1:8017/status', timeout=5)
+d = json.loads(r.read().decode())
+print(" status : running=" + str(d.get('running')) + " ip=" + str(d.get('ip')))
+print("")
+print("Admin Panel: http://127.0.0.1:8017/")
+print("WriteServer: http://localhost:8016/")
+print("LAN Access : http://" + str(d.get('ip')) + ":8016/")
diff --git a/tmp_find.py b/tmp_find.py
new file mode 100644
index 00000000..0442b24b
--- /dev/null
+++ b/tmp_find.py
@@ -0,0 +1,26 @@
+# Reconstruct the full main.go by merging:
+# 1. The new skeleton from main_new.go
+# 2. Handler functions extracted from a known working old copy
+
+# Since old main.go was overwritten, I need to reconstruct handlers from what we know.
+# The handlers are standard CRUD + deploy/hugo/stats etc - about 500 lines.
+# Let's take a shortcut: use the write-go API that already works,
+# and just add the bot startup in a separate file that doesn't redefine handlers.
+
+# Better approach: keep old main.go handlers separate in a handlers.go file in cmd/writeserver/
+# and have the new main.go just wire them together.
+
+# Let me check if the old main.go got saved anywhere
+import os
+for root, dirs, files in os.walk(r"E:\GitHub\blog\write-go"):
+ for f in files:
+ if "main" in f.lower() and f.endswith(".go"):
+ fp = os.path.join(root, f)
+ sz = os.path.getsize(fp)
+ print(f" {fp} ({sz} bytes)")
+
+print("\nChecking for .bak files...")
+for root, dirs, files in os.walk(r"E:\GitHub\blog\write-go"):
+ for f in files:
+ if f.endswith(".bak") or "backup" in f.lower():
+ print(f" {os.path.join(root, f)}")
diff --git a/tmp_fixcfg.py b/tmp_fixcfg.py
new file mode 100644
index 00000000..943e9420
--- /dev/null
+++ b/tmp_fixcfg.py
@@ -0,0 +1,17 @@
+# Fix remaining writebot/internal/config -> writebot/internal/botcfg in all bot-go files
+import os, re
+
+for root, dirs, files in os.walk(r"E:\GitHub\blog\bot-go"):
+ for fn in files:
+ if fn.endswith(".go"):
+ fp = os.path.join(root, fn)
+ with open(fp, "rb") as f:
+ content = f.read().decode("utf-8")
+ # Don't modify files in the old internal directories we're replacing
+ if "old_" in fp:
+ continue
+ content = content.replace('"writebot/internal/config"', '"writebot/internal/botcfg"')
+ content = re.sub(r'writebot/internal/config\b', 'writebot/internal/botcfg', content)
+ with open(fp, "wb") as f:
+ f.write(content.encode("utf-8"))
+print("All config references updated to botcfg")
diff --git a/tmp_fixlines.py b/tmp_fixlines.py
new file mode 100644
index 00000000..858a1900
--- /dev/null
+++ b/tmp_fixlines.py
@@ -0,0 +1,33 @@
+path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
+with open(path, "rb") as f:
+ content = f.read().decode("utf-8")
+
+# Fix line 117: non-boolean condition
+# The Stop function was using writeserver.StopWriteServer() which returned bool
+# Now it's replaced with a SendMessage call which returns error
+# Fix:
+
+old_stop = ''' if tgcore.SendMessage(chatID, "WriteServer stop not available in bot-only mode.", nil) {
+ tgcore.SendMessage(chatID, "⚡ Write 已停止。", nil)
+ } else {
+ tgcore.SendMessage(chatID, "⏸ Write 未运行。", nil)
+ }'''
+
+new_stop = ''' tgcore.SendMessage(chatID, "WriteServer 已与 Bot 合并,无需单独启停。", nil)'''
+
+content = content.replace(old_stop, new_stop)
+
+# Fix line 578: cannot index result
+# Find the problematic line around 578
+lines = content.split("\n")
+for i, line in enumerate(lines):
+ if i >= 575 and i <= 582:
+ print(f" {i+1}: {line}")
+
+# Fix writeserver.GetWriteStatus() replacement - it was replaced with a map literal
+# but used in an if condition
+content = content.replace('if s := map[string]interface{}{"running": true, "starting": false};', 'if s := tgcore.GetWriteStatus();')
+
+with open(path, "wb") as f:
+ f.write(content.encode("utf-8"))
+print("\nFixed Stop and GetWriteStatus")
diff --git a/tmp_fixstop.py b/tmp_fixstop.py
new file mode 100644
index 00000000..90600f51
--- /dev/null
+++ b/tmp_fixstop.py
@@ -0,0 +1,30 @@
+# Fix stop function - the old replacement didn't work due to line breaks
+path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
+with open(path, "rb") as f:
+ content = f.read().decode("utf-8")
+
+# The Stop function has:
+# if tgcore.SendMessage(chatID, "WriteServer stop not available...", nil) {
+# This is wrong because SendMessage returns error not bool
+# Fix: just call SendMessage directly
+
+import re
+
+# Match the whole if block
+pattern = r'if tgcore\.SendMessage\(chatID, "WriteServer stop not available in bot-only mode\..*?\n\t\}'
+content = re.sub(pattern, 'tgcore.SendMessage(chatID, "WriteServer \u5df2\u4e0e Bot \u5408\u5e76\uff0c\u65e0\u9700\u5355\u72ec\u542f\u505c\u3002", nil)', content, flags=re.DOTALL)
+
+# Fix the Status function - line 578/578
+# Looking for: if s := map[string]interface{}{"running": true, ...}; !s["running"]
+# This was the replacement for writeserver.GetWriteStatus()
+old_status = 'if s := map[string]interface{}{"running": true, "starting": false};'
+new_status = 's := map[string]interface{}{"running": true, "starting": false}; if'
+content = content.replace(old_status, new_status)
+
+# Also fix other Status references
+content = content.replace('"running"].(bool)', '["running"].(bool)')
+content = content.replace('s["running"]', 's["running"]')
+
+with open(path, "wb") as f:
+ f.write(content.encode("utf-8"))
+print("Fixed Stop and Status")
diff --git a/tmp_fixtg.py b/tmp_fixtg.py
new file mode 100644
index 00000000..d031b228
--- /dev/null
+++ b/tmp_fixtg.py
@@ -0,0 +1,15 @@
+import re
+
+# Fix tgcore - remove helpers dependency
+path = r"E:\GitHub\blog\bot-go\internal\tgcore\tgcore.go"
+with open(path, "rb") as f:
+ content = f.read().decode("utf-8")
+
+content = re.sub(r'\t"writebot/internal/helpers"\r?\n', '', content)
+# Replace helpers.CheckPort with inline check
+content = content.replace('helpers.CheckPort', 'checkPort')
+content = content.replace('helpers.GetLanIP', 'getLanIP')
+
+with open(path, "wb") as f:
+ f.write(content.encode("utf-8"))
+print("tgcore fixed")
diff --git a/tmp_fixth.py b/tmp_fixth.py
new file mode 100644
index 00000000..07944af2
--- /dev/null
+++ b/tmp_fixth.py
@@ -0,0 +1,27 @@
+import re
+
+# Fix tghandlers to remove writeserver/adminpanel deps
+path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
+with open(path, "rb") as f:
+ content = f.read().decode("utf-8")
+
+# Remove writeserver import
+content = re.sub(r'\t"writebot/internal/writeserver"\r?\n', '', content)
+
+# Remove adminpanel import
+content = re.sub(r'\t"writebot/internal/adminpanel"\r?\n', '', content)
+
+# Remove helpers import
+content = re.sub(r'\t"writebot/internal/helpers"\r?\n', '', content)
+
+# Remove unused package references in code
+# writeserver.StopWriteServer() => no-op (tgcore.SendMessage)
+content = content.replace('writeserver.StopWriteServer()', 'tgcore.SendMessage(chatID, "WriteServer stop not available in bot-only mode.", nil)')
+content = content.replace('writeserver.StartWriteServer()', 'tgcore.SendMessage(chatID, "WriteServer start not available in bot-only mode.", nil)')
+content = content.replace('writeserver.GetWriteStatus()', 'map[string]interface{}{"running": true, "starting": false}')
+content = content.replace('writeserver.SetWriteReady(true)', '// noop: merged mode')
+content = content.replace('writeserver.StartHugoIfNeeded()', '// noop: handled by writeserver')
+
+with open(path, "wb") as f:
+ f.write(content.encode("utf-8"))
+print("tghandlers fixed")
diff --git a/tmp_fixth2.py b/tmp_fixth2.py
new file mode 100644
index 00000000..5aa67bf3
--- /dev/null
+++ b/tmp_fixth2.py
@@ -0,0 +1,13 @@
+path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
+with open(path, "rb") as f:
+ content = f.read().decode("utf-8")
+
+# Replace helpers.GetLanIP -> tgcore.getLanIP (or just inline)
+# Replace helpers.CheckPort -> tgcore.checkPort
+content = content.replace("helpers.GetLanIP", "tgcore.GetLanIP")
+content = content.replace("helpers.CheckPort", "tgcore.CheckPort")
+content = content.replace("helpers.", "tgcore.")
+
+with open(path, "wb") as f:
+ f.write(content.encode("utf-8"))
+print("Fixed helpers refs in tghandlers")
diff --git a/tmp_fixutf8.py b/tmp_fixutf8.py
new file mode 100644
index 00000000..5b444672
--- /dev/null
+++ b/tmp_fixutf8.py
@@ -0,0 +1,27 @@
+import re, shutil
+
+# Copy with correct encoding
+files_to_fix = [
+ ("bot-go/internal/helpers/helpers.go", "write-go/internal/helpers/helpers.go"),
+ ("bot-go/internal/tgcore/tgcore.go", "write-go/internal/tgcore/tgcore.go"),
+ ("bot-go/internal/tghandlers/tghandlers.go", "write-go/internal/tghandlers/tghandlers.go"),
+ ("bot-go/internal/sessions/sessions.go", "write-go/internal/sessions/sessions.go"),
+ ("bot-go/internal/adminpanel/adminpanel.go", "write-go/internal/adminpanel/adminpanel.go"),
+ ("bot-go/internal/writeserver/writeserver.go", "write-go/internal/writeserver/writeserver.go"),
+]
+
+base = r"E:\GitHub\blog"
+
+for src_rel, dst_rel in files_to_fix:
+ src = base + "\\" + src_rel
+ dst = base + "\\" + dst_rel
+ with open(src, "rb") as f:
+ content = f.read()
+ text = content.decode("utf-8")
+ text = re.sub(r'writebot/internal/', 'write-server/internal/', text)
+ text = text.replace('writebot/internal/config', 'write-server/internal/botconfig')
+ with open(dst, "wb") as f:
+ f.write(text.encode("utf-8"))
+ print(f"OK: {dst_rel}")
+
+print("All fixed with proper UTF-8")
diff --git a/tmp_gen_main.py b/tmp_gen_main.py
new file mode 100644
index 00000000..c9292d7b
--- /dev/null
+++ b/tmp_gen_main.py
@@ -0,0 +1,354 @@
+# Reconstruct main.go from known-working old version
+# We know it had all the API handlers plus withCORS/panic recovery/parseDate fix
+
+main_go = r'''package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "write-server/internal/ai"
+ "write-server/internal/adminpanel"
+ "write-server/internal/artalk"
+ "write-server/internal/botconfig"
+ "write-server/internal/config"
+ "write-server/internal/helpers"
+ "write-server/internal/images"
+ "write-server/internal/posts"
+ "write-server/internal/recycle"
+ "write-server/internal/rssapi"
+ "write-server/internal/sessions"
+ "write-server/internal/tgcore"
+ "write-server/internal/tghandlers"
+ "write-server/internal/wechat"
+ "write-server/internal/writeserver"
+)
+
+var hugoStarting bool
+var lg *slog.Logger
+
+func main() {
+ config.Init()
+ recycle.InitRecycleBin()
+ posts.Init()
+ appCfg := botconfig.Load()
+ writeserver.Init(appCfg.WritePort, appCfg.WriteDir)
+ adminpanel.Init(appCfg.WritePort, appCfg.ControlPort)
+
+ lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeapp")
+
+ ip := helpers.GetLanIP(appCfg.PreferIface)
+ lg.Info("WriteApp starting", "writePort", appCfg.WritePort)
+
+ fmt.Println("")
+ fmt.Println(" =========================================")
+ fmt.Println(" WriteApp - Blog CMS + Telegram Bot")
+ fmt.Println(" =========================================")
+ fmt.Printf(" Frontend : http://127.0.0.1:%d/\n", appCfg.WritePort)
+ fmt.Printf(" Admin : http://127.0.0.1:%d/admin\n", appCfg.WritePort)
+ fmt.Printf(" LAN : http://%s:%d/\n", ip, appCfg.WritePort)
+ fmt.Println(" =========================================")
+ fmt.Println("")
+
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/api/posts/batch", handlePostsBatch)
+ mux.HandleFunc("/api/posts/", handlePostBySlug)
+ mux.HandleFunc("/api/posts", handlePosts)
+ mux.HandleFunc("/api/recycle", handleRecycle)
+ mux.HandleFunc("/api/ai", handleAI)
+ mux.HandleFunc("/api/images", handleImages)
+ mux.HandleFunc("/api/upload", handleUpload)
+ mux.HandleFunc("/api/hugo", handleHugo)
+ mux.HandleFunc("/api/deploy", handleDeploy)
+ mux.HandleFunc("/api/stats", handleStats)
+ mux.HandleFunc("/api/rss/links", handleRSSLinks)
+ mux.HandleFunc("/api/rss/feeds", handleRSSFeeds)
+ mux.HandleFunc("/api/rss/wechat", handleWechatHandler)
+ mux.HandleFunc("/api/comments/stats", handleCommentsStats)
+ mux.HandleFunc("/api/comments/cleanup", handleCommentsCleanup)
+ mux.HandleFunc("/api/comments/login", handleCommentsLogin)
+ mux.HandleFunc("/api/comments/", handleCommentsSub)
+ mux.HandleFunc("/api/comments", handleComments)
+
+ // Admin panel on /admin
+ mux.HandleFunc("/admin", handleAdmin)
+ mux.HandleFunc("/admin/health", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`+"`"+`{"ok":true}`+"`"+`))
+ })
+
+ // Reverse proxy to Next.js for non-API routes
+ nextURL, _ := url.Parse("http://localhost:3000")
+ proxy := httputil.NewSingleHostReverseProxy(nextURL)
+ proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(fallbackHTML))
+ }
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin") {
+ http.NotFound(w, r)
+ return
+ }
+ proxy.ServeHTTP(w, r)
+ })
+
+ // Auto-start Next.js
+ go startNextJS()
+
+ // Start Telegram bot
+ if appCfg.TGToken != "" {
+ tgcore.Init(appCfg.TGToken)
+ go runTelegramBot(appCfg)
+ } else {
+ lg.Warn("no TG_TOKEN configured, bot disabled")
+ }
+
+ port := 8016
+ if p := os.Getenv("WRITE_PORT"); p != "" {
+ if n, err := strconv.Atoi(p); err == nil {
+ port = n
+ }
+ }
+
+ // Graceful shutdown
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+
+ addr := fmt.Sprintf("0.0.0.0:%d", port)
+ fmt.Printf("[write-app] listening on %s\n", addr)
+ go func() {
+ if err := http.ListenAndServe(addr, withCORS(mux)); err != nil {
+ lg.Error("server error", "err", err)
+ }
+ }()
+
+ sig := <-sigCh
+ lg.Info("shutting down", "signal", sig.String())
+}
+
+const fallbackHTML = `+"`"+`
+
+
+
+
+WriteApp
+
+`+"`"+`
+
+func startNextJS() {
+ time.Sleep(2 * time.Second)
+ if _, err := net.DialTimeout("tcp", "127.0.0.1:3000", 2*time.Second); err == nil {
+ lg.Info("Next.js already running on :3000")
+ return
+ }
+ lg.Info("starting Next.js frontend")
+ cmd := exec.Command("npx.cmd", "next", "start", "-p", "3000")
+ cmd.Dir = filepath.Join(config.BLOG_ROOT, "write")
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ lg.Warn("failed to start Next.js", "err", err)
+ } else {
+ lg.Info("Next.js started", "pid", cmd.Process.Pid)
+ }
+}
+
+func runTelegramBot(appCfg *botconfig.Config) {
+ 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: appCfg.WritePort,
+ ControlPort: appCfg.ControlPort,
+ PreferIface: appCfg.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,
+ }
+
+ 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 {
+ lg.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, appCfg.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", appCfg.TGToken, filePath)
+ s.Content += "\n\n"
+ }
+ }
+ }
+ 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 = fullText[:idx] + afterAt
+ fullText = strings.TrimSpace(fullText)
+ }
+ 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 + "\n"
+ continue
+ }
+ if slashCmd == "" { continue }
+ lg.Info("tg command", "chat", chatID, "cmd", slashCmd)
+ handleTGCommand(h, chatID, slashCmd, slashArg, argCommands)
+ }
+ }
+}
+
+func handleTGCommand(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) {
+ defer func() {
+ if r := recover(); r != nil { lg.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 "stop": h.Stop(chatID)
+ case "status": h.Status(chatID)
+ case "ip": h.IP(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)
+ default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
+ }
+ }
+}
+
+func handleAdmin(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(fmt.Sprintf(adminHTML, helpers.GetLanIP("WLAN"))))
+}
+
+func withCORS(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if rec := recover(); rec != nil {
+ lg.Error("panic in handler", "path", r.URL.Path, "method", r.Method, "panic", fmt.Sprintf("%v", rec))
+ http.Error(w, fmt.Sprintf("Internal Server Error: %v", rec), http.StatusInternalServerError)
+ }
+ }()
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
+ if r.Method == "OPTIONS" {
+ w.WriteHeader(204)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+'''
+
+with open(r"E:\GitHub\blog\write-go\cmd\writeserver\main_new.go", "w", encoding="utf-8") as f:
+ f.write(main_go)
+
+print("main_new.go written, length:", len(main_go))
diff --git a/tmp_handlers.py b/tmp_handlers.py
new file mode 100644
index 00000000..b9901d2c
--- /dev/null
+++ b/tmp_handlers.py
@@ -0,0 +1,396 @@
+# Generate handlers.go based on the known API routes
+handlers = r'''package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "write-server/internal/ai"
+ "write-server/internal/artalk"
+ "write-server/internal/config"
+ "write-server/internal/images"
+ "write-server/internal/posts"
+ "write-server/internal/recycle"
+ "write-server/internal/rssapi"
+ "write-server/internal/wechat"
+)
+
+const adminHTML = ` + "`" + `
+
+
+WriteApp
+
+WriteApp
Blog CMS + Telegram Bot — Go Edition
+
All Systems Running
Port 8016 · LAN: %s
+
+
+
+
` + "`" + `
+
+func jsonOK(w http.ResponseWriter, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
+
+func jsonErr(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
+
+func readJSON(r *http.Request) (map[string]interface{}, error) {
+ body, _ := io.ReadAll(r.Body)
+ defer r.Body.Close()
+ var data map[string]interface{}
+ err := json.Unmarshal(body, &data)
+ return data, err
+}
+
+func gs(m map[string]interface{}, key string) string {
+ if v, ok := m[key]; ok {
+ if s, ok := v.(string); ok { return s }
+ }
+ return ""
+}
+
+func gb(m map[string]interface{}, key string) bool {
+ if v, ok := m[key]; ok {
+ if b, ok := v.(bool); ok { return b }
+ }
+ return false
+}
+
+func gsa(m map[string]interface{}, key string) []string {
+ if v, ok := m[key]; ok {
+ switch arr := v.(type) {
+ case []interface{}:
+ var result []string
+ for _, item := range arr {
+ if s, ok := item.(string); ok { result = append(result, s) }
+ }
+ return result
+ case []string:
+ return arr
+ }
+ }
+ return nil
+}
+
+func handlePosts(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ search := r.URL.Query().Get("search")
+ page, _ := strconv.Atoi(r.URL.Query().Get("page"))
+ if page < 1 { page = 1 }
+ perPage, _ := strconv.Atoi(r.URL.Query().Get("perPage"))
+ if perPage < 1 { perPage = 20 }
+ includeDrafts := r.URL.Query().Get("includeDrafts") != "false"
+ recycle.CleanExpired()
+ list, total, totalPages := posts.ListPosts(search, page, perPage, includeDrafts)
+ totalCount := posts.GetPostCount()
+ jsonOK(w, map[string]interface{}{
+ "posts": list, "total": total, "totalPages": totalPages, "totalCount": totalCount,
+ })
+ case "POST":
+ body, _ := readJSON(r)
+ fmRaw, _ := body["frontMatter"].(map[string]interface{})
+ if fmRaw == nil { jsonErr(w, 400, "frontMatter required"); return }
+ fm := posts.FrontMatter{
+ Title: gs(fmRaw, "title"), Date: gs(fmRaw, "date"), Slug: gs(fmRaw, "slug"),
+ Draft: gb(fmRaw, "draft"), Categories: gsa(fmRaw, "categories"),
+ Tags: gsa(fmRaw, "tags"), Author: gs(fmRaw, "author"),
+ Layout: gs(fmRaw, "layout"), AiComment: gs(fmRaw, "ai_comment"),
+ }
+ if fm.Layout == "" { fm.Layout = "post" }
+ if fm.Title == "" || fm.Slug == "" { jsonErr(w, 400, "Title and slug required"); return }
+ dirPath, err := posts.CreatePost(fm, gs(body, "content"))
+ if err != nil { jsonErr(w, 500, err.Error()); return }
+ jsonOK(w, map[string]interface{}{"success": true, "dirPath": dirPath})
+ default:
+ jsonErr(w, 405, "method not allowed")
+ }
+}
+
+func handlePostBySlug(w http.ResponseWriter, r *http.Request) {
+ slug := extractSlug(r.URL.Path, "/api/posts/")
+ switch r.Method {
+ case "GET":
+ p := posts.GetPost(slug)
+ if p == nil { jsonErr(w, 404, "Post not found"); return }
+ jsonOK(w, p)
+ case "PUT":
+ body, _ := readJSON(r)
+ fmRaw, _ := body["frontMatter"].(map[string]interface{})
+ if fmRaw == nil { fmRaw = make(map[string]interface{}) }
+ var contentPtr *string
+ if c, ok := body["content"]; ok {
+ if s, ok := c.(string); ok { contentPtr = &s }
+ }
+ result, err := posts.UpdatePost(slug, fmRaw, contentPtr)
+ if err != nil { jsonErr(w, 404, err.Error()); return }
+ jsonOK(w, map[string]interface{}{"success": true, "dirPath": result.DirPath})
+ case "DELETE":
+ p := posts.GetPost(slug)
+ if p == nil { jsonErr(w, 404, "Post not found"); return }
+ if err := recycle.MoveToRecycle(slug, p.DirPath, p.FrontMatter.Title); err != nil {
+ jsonErr(w, 500, err.Error()); return
+ }
+ jsonOK(w, map[string]interface{}{"success": true})
+ default:
+ jsonErr(w, 405, "method not allowed")
+ }
+}
+
+func handlePostsBatch(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "PUT" { jsonErr(w, 405, "method not allowed"); return }
+ body, _ := readJSON(r)
+ slugsArr, _ := body["slugs"].([]interface{})
+ if len(slugsArr) == 0 { jsonErr(w, 400, "slugs array required"); return }
+ action := gs(body, "action")
+ categories := gsa(body, "categories")
+ tags := gsa(body, "tags")
+ draft := gb(body, "draft")
+ var results []string
+ for _, s := range slugsArr {
+ slug := fmt.Sprintf("%v", s)
+ if posts.GetPost(slug) == nil { results = append(results, "Not found: "+slug); continue }
+ updates := make(map[string]interface{})
+ switch action {
+ case "categories": updates["categories"] = categories
+ case "tags": updates["tags"] = tags
+ case "draft": updates["draft"] = draft
+ case "all": updates["categories"] = categories; updates["tags"] = tags; updates["draft"] = draft
+ default: jsonErr(w, 400, "invalid action"); return
+ }
+ posts.UpdatePost(slug, updates, nil)
+ results = append(results, "Updated: "+slug)
+ }
+ jsonOK(w, map[string]interface{}{"results": results})
+}
+
+func handleRecycle(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ jsonOK(w, map[string]interface{}{"items": recycle.ListRecycle()})
+ case "POST":
+ body, _ := readJSON(r)
+ slug := gs(body, "slug")
+ if slug == "" { jsonErr(w, 400, "slug required"); return }
+ if recycle.Restore(slug) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
+ case "DELETE":
+ body, _ := readJSON(r)
+ slug := gs(body, "slug")
+ if slug == "" { jsonErr(w, 400, "slug required"); return }
+ if recycle.PermanentDelete(slug) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
+ default:
+ jsonErr(w, 405, "method not allowed")
+ }
+}
+
+func handleAI(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
+ body, _ := readJSON(r)
+ prompt := gs(body, "prompt")
+ if prompt == "" { jsonErr(w, 400, "prompt required"); return }
+ resp, err := ai.Chat(prompt)
+ if err != nil { jsonErr(w, 500, err.Error()); return }
+ jsonOK(w, map[string]interface{}{"response": resp})
+}
+
+func handleImages(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ jsonOK(w, map[string]interface{}{"images": images.ScanImages()})
+ case "DELETE":
+ body, _ := readJSON(r)
+ filename := gs(body, "filename")
+ if filename == "" { jsonErr(w, 400, "filename required"); return }
+ if images.DeleteImage(filename) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
+ default:
+ jsonErr(w, 405, "method not allowed")
+ }
+}
+
+func handleUpload(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
+ r.ParseMultipartForm(32 << 20)
+ file, header, err := r.FormFile("file")
+ if err != nil { jsonErr(w, 400, "file required"); return }
+ defer file.Close()
+ ext := filepath.Ext(header.Filename)
+ filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
+ destPath := filepath.Join(config.BLOG_ROOT, "static", "uploads", filename)
+ os.MkdirAll(filepath.Dir(destPath), 0755)
+ dst, err := os.Create(destPath)
+ if err != nil { jsonErr(w, 500, err.Error()); return }
+ defer dst.Close()
+ io.Copy(dst, file)
+ url := "/uploads/" + filename
+ jsonOK(w, map[string]interface{}{"url": url, "success": true})
+}
+
+func handleHugo(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
+ if hugoStarting { jsonOK(w, map[string]interface{}{"running": false, "starting": true}); return }
+ hugoStarting = true
+ defer func() { hugoStarting = false }()
+ exec.Command("taskkill", "/F", "/IM", "hugo.exe").Run()
+ cmd := exec.Command("hugo", "serve", "--port", "1313", "--noHTTPCache")
+ cmd.Dir = config.BLOG_ROOT
+ if err := cmd.Start(); err != nil { jsonErr(w, 500, err.Error()); return }
+ jsonOK(w, map[string]interface{}{"running": true, "url": "http://localhost:1313"})
+}
+
+func handleDeploy(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
+ body, _ := readJSON(r)
+ title := gs(body, "title")
+ if title == "" { jsonErr(w, 400, "Title required"); return }
+ runGit := func(args ...string) (string, error) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = config.BLOG_ROOT
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+ var output string
+ if o, err := runGit("add", "content/post", "static"); err != nil { jsonErr(w, 500, string(o)); return } else { output += o }
+ if _, err := runGit("diff", "--cached", "--quiet"); err != nil {
+ if o, err2 := runGit("commit", "-m", "发布: "+title); err2 != nil { jsonErr(w, 500, string(o)); return } else { output += o }
+ }
+ o, _ := runGit("push", "origin", "main")
+ output += o
+ jsonOK(w, map[string]interface{}{"success": true, "output": output})
+}
+
+func handleStats(w http.ResponseWriter, r *http.Request) {
+ 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) }
+ jsonOK(w, map[string]interface{}{"posts": postCount, "feeds": feedCount, "links": linkCount})
+}
+
+func handleRSSLinks(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ all := r.URL.Query().Get("all") == "1"
+ links, err := rssapi.GetLinks(all)
+ if err != nil { jsonErr(w, 500, err.Error()); return }
+ jsonOK(w, map[string]interface{}{"links": links})
+ case "POST":
+ body, _ := readJSON(r)
+ url := gs(body, "url"); title := gs(body, "title"); desc := gs(body, "description")
+ if url == "" || title == "" { jsonErr(w, 400, "url and title required"); return }
+ rssapi.AddLink(url, title, desc)
+ jsonOK(w, map[string]interface{}{"success": true})
+ case "PUT":
+ body, _ := readJSON(r)
+ id := gs(body, "id")
+ enabled := gb(body, "enabled")
+ rssapi.ToggleLink(id, enabled)
+ jsonOK(w, map[string]interface{}{"success": true})
+ case "DELETE":
+ body, _ := readJSON(r)
+ id := gs(body, "id")
+ rssapi.DeleteLink(id)
+ jsonOK(w, map[string]interface{}{"success": true})
+ default:
+ jsonErr(w, 405, "method not allowed")
+ }
+}
+
+func handleRSSFeeds(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ feeds, err := rssapi.GetFeeds()
+ if err != nil { jsonErr(w, 500, err.Error()); return }
+ jsonOK(w, map[string]interface{}{"feeds": feeds})
+ case "POST":
+ body, _ := readJSON(r)
+ url := gs(body, "url"); title := gs(body, "title")
+ if url == "" || title == "" { jsonErr(w, 400, "url and title required"); return }
+ rssapi.AddFeed(url, title)
+ jsonOK(w, map[string]interface{}{"success": true})
+ case "DELETE":
+ body, _ := readJSON(r)
+ id := gs(body, "id")
+ rssapi.DeleteFeed(id)
+ jsonOK(w, map[string]interface{}{"success": true})
+ default:
+ jsonErr(w, 405, "method not allowed")
+ }
+}
+
+func handleWechatHandler(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ articles, err := wechat.GetArticles()
+ if err != nil { jsonErr(w, 500, err.Error()); return }
+ jsonOK(w, map[string]interface{}{"articles": articles})
+ case "POST":
+ msg := r.URL.Query().Get("msg")
+ jsonOK(w, map[string]interface{}{"ok": true, "msg": msg})
+ default:
+ jsonErr(w, 405, "method not allowed")
+ }
+}
+
+func handleCommentsStats(w http.ResponseWriter, r *http.Request) {
+ stats := artalk.GetStats()
+ jsonOK(w, stats)
+}
+
+func handleCommentsCleanup(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
+ body, _ := readJSON(r)
+ slug := gs(body, "slug")
+ if slug == "" { jsonErr(w, 400, "slug required"); return }
+ result := artalk.CleanupComments(slug)
+ jsonOK(w, result)
+}
+
+func handleCommentsLogin(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
+ body, _ := readJSON(r)
+ name := gs(body, "name"); email := gs(body, "email"); password := gs(body, "password")
+ if name == "" || email == "" || password == "" { jsonErr(w, 400, "name, email, password required"); return }
+ result := artalk.Login(name, email, password)
+ jsonOK(w, result)
+}
+
+func handleCommentsSub(w http.ResponseWriter, r *http.Request) {
+ slug := extractSlug(r.URL.Path, "/api/comments/")
+ comments := artalk.GetComments(slug)
+ jsonOK(w, comments)
+}
+
+func handleComments(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
+ body, _ := readJSON(r)
+ slug := gs(body, "slug"); content := gs(body, "content"); pageKey := gs(body, "page_key")
+ if slug == "" { jsonErr(w, 400, "slug required"); return }
+ result := artalk.PostComment(slug, content, pageKey)
+ jsonOK(w, result)
+}
+
+func extractSlug(path, prefix string) string {
+ p := strings.TrimPrefix(path, prefix)
+ p = strings.TrimSuffix(p, "/")
+ return p
+}
+'''
+
+import "time" # needed below
+
+with open(r"E:\GitHub\blog\write-go\cmd\writeserver\handlers.go", "w", encoding="utf-8") as f:
+ f.write(handlers)
+
+print("handlers.go written, size:", len(handlers))
diff --git a/tmp_imp2.py b/tmp_imp2.py
new file mode 100644
index 00000000..12e68532
--- /dev/null
+++ b/tmp_imp2.py
@@ -0,0 +1,26 @@
+path = r"E:\GitHub\blog\bot-go\internal\tgcore\tgcore.go"
+with open(path, "rb") as f:
+ content = f.read().decode("utf-8")
+
+old = '''import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+)'''
+new = '''import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)'''
+content = content.replace(old, new)
+
+with open(path, "wb") as f:
+ f.write(content.encode("utf-8"))
+print("Fixed imports properly")
diff --git a/tmp_import.py b/tmp_import.py
new file mode 100644
index 00000000..56ed56af
--- /dev/null
+++ b/tmp_import.py
@@ -0,0 +1,27 @@
+path = r"E:\GitHub\blog\bot-go\internal\tgcore\tgcore.go"
+with open(path, "rb") as f:
+ content = f.read().decode("utf-8")
+
+# Add missing imports
+old_import = '''import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+)'''
+new_import = '''import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)'''
+
+content = content.replace(old_import, new_import)
+
+with open(path, "wb") as f:
+ f.write(content.encode("utf-8"))
+print("Added net, time imports to tgcore")
diff --git a/tmp_inline.py b/tmp_inline.py
new file mode 100644
index 00000000..e40c45b9
--- /dev/null
+++ b/tmp_inline.py
@@ -0,0 +1,23 @@
+# Read helpers.go and append its functions to tgcore.go
+helpers_path = r"E:\GitHub\blog\bot-go\internal\helpers\helpers.go"
+tgcore_path = r"E:\GitHub\blog\bot-go\internal\tgcore\tgcore.go"
+
+with open(helpers_path, "rb") as f:
+ helpers = f.read().decode("utf-8")
+
+with open(tgcore_path, "rb") as f:
+ tgcore = f.read().decode("utf-8")
+
+# Strip package declaration and imports from helpers
+helpers = helpers.replace("package helpers\n", "")
+# Remove import block
+import_end = helpers.find(")\n")
+if import_end > 0:
+ helpers = helpers[import_end+2:]
+
+# Add helper functions to end of tgcore
+tgcore = tgcore.rstrip() + "\n\n// === Inlined from helpers ===\n" + helpers
+
+with open(tgcore_path, "wb") as f:
+ f.write(tgcore.encode("utf-8"))
+print("helpers inlined into tgcore")
diff --git a/tmp_patch.py b/tmp_patch.py
new file mode 100644
index 00000000..063e5487
--- /dev/null
+++ b/tmp_patch.py
@@ -0,0 +1,20 @@
+with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "r", encoding="utf-8", errors="replace") as f:
+ content = f.read()
+
+# 1. Remove writeconfig line
+import re
+content = re.sub(r'\t+writeconfig "write-server/internal/config"\r?\n', '', content)
+
+# 2. Replace writeconfig.Init() with posts.Init()
+content = content.replace('writeconfig.Init()', 'posts.Init()')
+
+# 3. Add posts import after writeserver
+content = content.replace(
+ '"writebot/internal/writeserver"',
+ '"writebot/internal/writeserver"\n\t"write-server/pkg/posts"'
+)
+
+with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "w", encoding="utf-8", errors="replace") as f:
+ f.write(content)
+
+print("Done")
diff --git a/tmp_patch2.py b/tmp_patch2.py
new file mode 100644
index 00000000..348af4cb
--- /dev/null
+++ b/tmp_patch2.py
@@ -0,0 +1,12 @@
+with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "r", encoding="utf-8", errors="replace") as f:
+ content = f.read()
+
+# Fix the import block formatting
+content = content.replace('"writebot/internal/writeserver"\n\t"write-server/pkg/posts"\n\n)', '"writebot/internal/writeserver"\n\t"write-server/pkg/posts"\n)')
+
+# Fix the weird indentation on "os"
+content = content.replace('\t\t"os"', '\t"os"')
+
+with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "w", encoding="utf-8", errors="replace") as f:
+ f.write(content)
+print("Fixed formatting")
diff --git a/tmp_patch3.py b/tmp_patch3.py
new file mode 100644
index 00000000..48e02bc0
--- /dev/null
+++ b/tmp_patch3.py
@@ -0,0 +1,19 @@
+with open(r"E:\GitHub\blog\write-go\internal\posts\posts.go", "r", encoding="utf-8", errors="replace") as f:
+ content = f.read()
+
+# Simplify Init - just call config.Init, not recycle
+old_init = '''func Init() {
+ config.Init()
+ recycle.InitRecycleBin()
+}'''
+new_init = '''func Init() {
+ config.Init()
+}'''
+content = content.replace(old_init, new_init)
+
+# Remove recycle import
+content = content.replace('\t"write-server/internal/recycle"\n', '')
+
+with open(r"E:\GitHub\blog\write-go\internal\posts\posts.go", "w", encoding="utf-8", errors="replace") as f:
+ f.write(content)
+print("Done")
diff --git a/tmp_patch4.py b/tmp_patch4.py
new file mode 100644
index 00000000..b3b3be99
--- /dev/null
+++ b/tmp_patch4.py
@@ -0,0 +1,84 @@
+with open(r"E:\GitHub\blog\write-go\cmd\writeserver\main.go", "r", encoding="utf-8", errors="replace") as f:
+ content = f.read()
+
+# Find the root handler and replace it with a reverse proxy to Next.js
+# Remove the embedded HTML page approach, replace with reverse proxy
+
+# 1. Remove the embed directive
+content = content.replace('\n//go:embed admin_page.html\nvar adminPageHTML string\n', '\n')
+content = content.replace('//go:embed admin_page.html\nvar adminPageHTML string\n', '\n')
+
+# 2. Remove _ "embed" from imports
+content = content.replace('\t_ "embed"\n\t"net"\n', '\t"net"\n')
+content = content.replace('\t_ "embed"\n\t"log/slog"\n', '\t"log/slog"\n')
+
+# 3. Add net/http/httputil and net/url for reverse proxy
+content = content.replace(
+ '\t"net/http"\n',
+ '\t"net/http"\n\t"net/http/httputil"\n\t"net/url"\n'
+)
+
+# 4. Replace the root handler with reverse proxy
+old_root = ''' // Root page - admin dashboard
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(404)
+ w.Write([]byte("{\"error\":\"not found\"}"))
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(adminPageHTML))
+ })'''
+
+new_root = ''' // Root and non-API routes -> reverse proxy to Next.js (localhost:3000)
+ nextURL, _ := url.Parse("http://localhost:3000")
+ proxy := httputil.NewSingleHostReverseProxy(nextURL)
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ // Let API handlers deal with it
+ http.NotFound(w, r)
+ return
+ }
+ proxy.ServeHTTP(w, r)
+ })'''
+
+content = content.replace(old_root, new_root)
+
+# 5. Add Next.js auto-start goroutine
+old_goroutine = 'go autoStartBot()'
+new_goroutine = '''go autoStartBot()
+ go autoStartNextJS()'''
+
+content = content.replace(old_goroutine, new_goroutine)
+
+# 6. Add the autoStartNextJS function before the last }
+old_end = '\n\taddr := fmt.Sprintf("0.0.0.0:%d", port)'
+new_end = '''
+// autoStartNextJS starts Next.js if not running
+func autoStartNextJS() {
+ time.Sleep(1 * time.Second)
+ // Check if Next.js is already running
+ if _, err := net.DialTimeout("tcp", "127.0.0.1:3000", 2*time.Second); err == nil {
+ lg.Info("Next.js already running on :3000")
+ return
+ }
+ lg.Info("starting Next.js frontend")
+ cmd := exec.Command("npx.cmd", "next", "start", "-p", "3000")
+ cmd.Dir = filepath.Join(config.BLOG_ROOT, "write")
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ lg.Warn("failed to start Next.js", "err", err)
+ } else {
+ lg.Info("Next.js started", "pid", cmd.Process.Pid)
+ }
+}
+
+ addr := fmt.Sprintf("0.0.0.0:%d", port)'
+
+content = content.replace(old_end, new_end)
+
+with open(r"E:\GitHub\blog\write-go\cmd\writeserver\main.go", "w", encoding="utf-8", errors="replace") as f:
+ f.write(content)
+print("Done")
diff --git a/tmp_route2.exe b/tmp_route2.exe
new file mode 100644
index 00000000..a1fdccec
Binary files /dev/null and b/tmp_route2.exe differ
diff --git a/tmp_route2.go b/tmp_route2.go
new file mode 100644
index 00000000..c1b51b8b
--- /dev/null
+++ b/tmp_route2.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+)
+
+var lg *log.Logger
+
+func main() {
+ lg = log.New(os.Stdout, "[TEST] ", log.LstdFlags)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/posts/", func(w http.ResponseWriter, r *http.Request) {
+ lg.Printf("GOT posts/ handler: path=%s method=%s", r.URL.Path, r.Method)
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"handler":"posts/","path":"` + r.URL.Path + `"}`))
+ })
+ mux.HandleFunc("/api/posts", func(w http.ResponseWriter, r *http.Request) {
+ lg.Printf("GOT posts handler: path=%s method=%s", r.URL.Path, r.Method)
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"handler":"posts","path":"` + r.URL.Path + `"}`))
+ })
+
+ lg.Println("listening on :8701")
+ log.Fatal(http.ListenAndServe(":8701", mux))
+}
diff --git a/tmp_router_test.go b/tmp_router_test.go
new file mode 100644
index 00000000..df5081f2
--- /dev/null
+++ b/tmp_router_test.go
@@ -0,0 +1,18 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+)
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/posts/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "GOT posts/ handler: path=%s\n", r.URL.Path)
+ })
+ mux.HandleFunc("/api/posts", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "GOT posts handler: path=%s\n", r.URL.Path)
+ })
+ log.Fatal(http.ListenAndServe(":8700", mux))
+}
diff --git a/tmp_show.py b/tmp_show.py
new file mode 100644
index 00000000..4b27d744
--- /dev/null
+++ b/tmp_show.py
@@ -0,0 +1,11 @@
+path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
+with open(path, "rb") as f:
+ lines = f.read().decode("utf-8").split("\n")
+
+# Show lines 115-120
+for i in range(114, 122):
+ print(f"{i+1}: {repr(lines[i][:80])}")
+
+# Show lines 575-580
+for i in range(574, 582):
+ print(f"{i+1}: {repr(lines[i][:80])}")
diff --git a/tmp_test.py b/tmp_test.py
new file mode 100644
index 00000000..b6168c92
--- /dev/null
+++ b/tmp_test.py
@@ -0,0 +1,8 @@
+import urllib.request, urllib.error
+try:
+ r = urllib.request.urlopen('http://localhost:8016/api/posts', timeout=5)
+ print('STATUS:', r.status)
+ print(r.read().decode()[:500])
+except urllib.error.HTTPError as e:
+ print('ERROR STATUS:', e.code)
+ print(e.read().decode()[:500])
diff --git a/tmp_test2.py b/tmp_test2.py
new file mode 100644
index 00000000..3cd5e0c8
--- /dev/null
+++ b/tmp_test2.py
@@ -0,0 +1,20 @@
+import urllib.request, json
+
+# Test write-go: create a post endpoint
+req = urllib.request.Request('http://localhost:8016/api/posts',
+ data=json.dumps({"frontMatter":{"title":"Test Article","slug":"test-article"},"content":"Hello world"}).encode(),
+ headers={"Content-Type":"application/json"},
+ method='POST')
+try:
+ r = urllib.request.urlopen(req, timeout=5)
+ print('CREATE POST:', r.status, r.read().decode()[:200])
+except Exception as e:
+ print('CREATE POST ERROR:', e)
+
+# Cleanup
+req2 = urllib.request.Request('http://localhost:8016/api/posts/test-article', method='DELETE')
+try:
+ r2 = urllib.request.urlopen(req2, timeout=5)
+ print('DELETE POST:', r2.status, r2.read().decode()[:200])
+except Exception as e:
+ print('DELETE POST ERROR:', e)
diff --git a/tmp_verify.py b/tmp_verify.py
new file mode 100644
index 00000000..52567f02
--- /dev/null
+++ b/tmp_verify.py
@@ -0,0 +1,10 @@
+import urllib.request, json
+# Quick check via API
+r = urllib.request.urlopen('http://localhost:8016/api/stats', timeout=5)
+print('stats:', r.read().decode())
+
+r = urllib.request.urlopen('http://localhost:8016/api/posts?perPage=3', timeout=5)
+data = json.loads(r.read().decode())
+print('posts count:', data['total'])
+for p in data['posts']:
+ print(' -', p['slug'], p['title'][:30])
diff --git a/tmp_write.txt b/tmp_write.txt
new file mode 100644
index 00000000..8fddb7ab
--- /dev/null
+++ b/tmp_write.txt
@@ -0,0 +1,21 @@
+=== WRITE LENGTH: 24294 ===
+package main
+
+import (
+ _ "embed"
+ "net"
+ "log/slog"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "mime"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "write-server/internal/a
\ No newline at end of file
diff --git a/write-go/.gitignore b/write-go/.gitignore
new file mode 100644
index 00000000..d4bf51da
--- /dev/null
+++ b/write-go/.gitignore
@@ -0,0 +1,3 @@
+writeserver.exe
+.env
+
diff --git a/write-go/.recycle/meta/test-article.json b/write-go/.recycle/meta/test-article.json
new file mode 100644
index 00000000..0d722c04
--- /dev/null
+++ b/write-go/.recycle/meta/test-article.json
@@ -0,0 +1,7 @@
+{
+ "deletedAt": "2026-06-01T17:01:39+08:00",
+ "expiresAt": "2026-07-01T17:01:39+08:00",
+ "originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-test-article",
+ "slug": "test-article",
+ "title": "Test Article"
+}
\ No newline at end of file
diff --git a/write-go/.recycle/posts/test-article/index.md b/write-go/.recycle/posts/test-article/index.md
new file mode 100644
index 00000000..6ac62f67
--- /dev/null
+++ b/write-go/.recycle/posts/test-article/index.md
@@ -0,0 +1,7 @@
+---
+title: Test Article
+date: 2026-06-01
+slug: test-article
+draft: false
+---
+Hello world
diff --git a/write-go/_create_shortcuts.vbs b/write-go/_create_shortcuts.vbs
new file mode 100644
index 00000000..0102eedc
--- /dev/null
+++ b/write-go/_create_shortcuts.vbs
@@ -0,0 +1,14 @@
+Set WshShell = CreateObject("WScript.Shell")
+Set lnk = WshShell.CreateShortcut("C:\Users\qunlin\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\WriteBot.lnk")
+lnk.TargetPath = "E:\GitHub\blog\bot-go\start_bot.bat"
+lnk.WorkingDirectory = "E:\GitHub\blog\bot-go"
+lnk.WindowStyle = 7
+lnk.Description = "WriteBot - Telegram Bot + Admin Panel"
+lnk.Save
+
+Set lnk2 = WshShell.CreateShortcut("C:\Users\qunlin\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\WriteServer.lnk")
+lnk2.TargetPath = "E:\GitHub\blog\write-go\start_write.bat"
+lnk2.WorkingDirectory = "E:\GitHub\blog\write-go"
+lnk2.WindowStyle = 7
+lnk2.Description = "Write Server - Blog CMS API"
+lnk2.Save
diff --git a/write-go/_err.txt b/write-go/_err.txt
new file mode 100644
index 00000000..59cec9e9
--- /dev/null
+++ b/write-go/_err.txt
@@ -0,0 +1,14 @@
+panic: runtime error: invalid memory address or nil pointer dereference
+[signal 0xc0000005 code=0x0 addr=0x0 pc=0x7ff752d05b18]
+
+goroutine 1 [running]:
+log/slog.(*Logger).Handler(...)
+ C:/Program Files/Go/src/log/slog/logger.go:121
+log/slog.(*Logger).Enabled(0x7ff752c1ca98?, {0x7ff752fbae60?, 0x7ff7532f9640?}, 0x30?)
+ C:/Program Files/Go/src/log/slog/logger.go:168 +0x18
+log/slog.(*Logger).log(0x0, {0x7ff752fbae60?, 0x7ff7532f9640?}, 0x0, {0x7ff752f96aad, 0x14}, {0x0, 0x0, 0x0})
+ C:/Program Files/Go/src/log/slog/logger.go:244 +0x98
+log/slog.(*Logger).Info(...)
+ C:/Program Files/Go/src/log/slog/logger.go:209
+main.main()
+ E:/GitHub/blog/write-go/cmd/writeserver/main.go:39 +0x7f
diff --git a/write-go/_fix_inline.py b/write-go/_fix_inline.py
new file mode 100644
index 00000000..c3996253
--- /dev/null
+++ b/write-go/_fix_inline.py
@@ -0,0 +1,16 @@
+p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
+with open(p, "r", encoding="utf-8") as f:
+ c = f.read()
+
+# Replace: lg = logutil.Logger
+# With: lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeserver")
+old = "lg = logutil.Logger"
+new = 'lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeserver")'
+c = c.replace(old, new)
+
+# Also remove logutil import if present
+c = c.replace('\t"write-server/internal/logutil"\n', '')
+
+with open(p, "w", encoding="utf-8", newline="\n") as f:
+ f.write(c)
+print("Fixed: inline slog init")
diff --git a/write-go/_fix_lg.py b/write-go/_fix_lg.py
new file mode 100644
index 00000000..3f07cd99
--- /dev/null
+++ b/write-go/_fix_lg.py
@@ -0,0 +1,13 @@
+p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
+with open(p, "r", encoding="utf-8") as f:
+ c = f.read()
+
+# Add "var lg *slog.Logger" back at top level (before main)
+c = c.replace(
+ 'func main() {',
+ 'var lg *slog.Logger\n\nfunc main() {'
+)
+
+with open(p, "w", encoding="utf-8", newline="\n") as f:
+ f.write(c)
+print("Fixed: added var lg declaration")
diff --git a/write-go/_fix_log.py b/write-go/_fix_log.py
new file mode 100644
index 00000000..87095258
--- /dev/null
+++ b/write-go/_fix_log.py
@@ -0,0 +1,11 @@
+p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
+with open(p, "r", encoding="utf-8") as f:
+ c = f.read()
+
+c = c.replace("var log = logutil.Logger", "var lg = logutil.Logger")
+c = c.replace("log.Info(", "lg.Info(")
+c = c.replace("log.Error(", "lg.Error(")
+
+with open(p, "w", encoding="utf-8", newline="\n") as f:
+ f.write(c)
+print("Fixed log variable")
diff --git a/write-go/_fix_nil.py b/write-go/_fix_nil.py
new file mode 100644
index 00000000..f3aad196
--- /dev/null
+++ b/write-go/_fix_nil.py
@@ -0,0 +1,28 @@
+p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
+with open(p, "r", encoding="utf-8") as f:
+ c = f.read()
+
+# Move lg.Info after config.Init() + recycle.InitRecycleBin()
+old = '''func main() {
+\tlg.Info("WriteServer starting")
+\tconfig.Init()
+\trecycle.InitRecycleBin()
+
+\tgo autoStartBot()'''
+
+new = '''func main() {
+\tconfig.Init()
+\trecycle.InitRecycleBin()
+\tlg = logutil.Logger
+\tlg.Info("WriteServer starting")
+
+\tgo autoStartBot()'''
+
+c = c.replace(old, new)
+
+# Also remove the "var lg = logutil.Logger" at top level (now in main)
+c = c.replace("var lg = logutil.Logger\n", "")
+
+with open(p, "w", encoding="utf-8", newline="\n") as f:
+ f.write(c)
+print("Fixed nil logger: lg.Info moved after config.Init()")
diff --git a/write-go/_fix_slog.py b/write-go/_fix_slog.py
new file mode 100644
index 00000000..c7dd5f13
--- /dev/null
+++ b/write-go/_fix_slog.py
@@ -0,0 +1,13 @@
+p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
+with open(p, "r", encoding="utf-8") as f:
+ c = f.read()
+
+# Need "log/slog" import. It was removed when "log" was dropped.
+c = c.replace(
+ '"encoding/json"',
+ '"log/slog"\n\t"encoding/json"'
+)
+
+with open(p, "w", encoding="utf-8", newline="\n") as f:
+ f.write(c)
+print("Added slog import")
diff --git a/write-go/_out.txt b/write-go/_out.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/write-go/_patch_main.py b/write-go/_patch_main.py
new file mode 100644
index 00000000..eef0d21a
--- /dev/null
+++ b/write-go/_patch_main.py
@@ -0,0 +1,71 @@
+import os
+
+p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
+with open(p, "r", encoding="utf-8") as f:
+ c = f.read()
+
+# 1. Add logutil import
+old = '"write-server/internal/images"'
+new = '"write-server/internal/logutil"\n\t"write-server/internal/images"'
+c = c.replace(old, new, 1)
+
+# 2. Replace main start to add logging
+old_func = 'func main() {\n\tconfig.Init()\n\trecycle.InitRecycleBin()'
+new_func = '''var log = logutil.Logger
+
+func main() {
+\tlog.Info("WriteServer starting")
+\tconfig.Init()
+\trecycle.InitRecycleBin()
+
+\tgo autoStartBot()'''
+c = c.replace(old_func, new_func, 1)
+
+# 3. Append autoStartBot and helper at end of file
+auto_start = '''
+
+func autoStartBot() {
+\ttime.Sleep(2 * time.Second)
+\tbotPort := 8017
+\tif helpersCheckPort(botPort) {
+\t\tlog.Info("bot-go already running", "port", botPort)
+\t\treturn
+\t}
+\tlog.Info("bot-go not responding, starting")
+\tpaths := []string{
+\t\tfilepath.Join(config.BLOG_ROOT, "bot-go", "writebot.exe"),
+\t\tfilepath.Join(config.BLOG_ROOT, "write-go", "writebot.exe"),
+\t}
+\tfor _, p := range paths {
+\t\tif _, err := os.Stat(p); err == nil {
+\t\t\tcmd := exec.Command(p)
+\t\t\tcmd.Dir = filepath.Dir(p)
+\t\t\tif err := cmd.Start(); err != nil {
+\t\t\t\tlog.Error("failed to start bot-go", "err", err)
+\t\t\t} else {
+\t\t\t\tlog.Info("bot-go started", "pid", cmd.Process.Pid)
+\t\t\t}
+\t\t\treturn
+\t\t}
+\t}
+}
+
+func helpersCheckPort(port int) bool {
+\tconn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
+\tif err != nil {
+\t\treturn false
+\t}
+\tconn.Close()
+\treturn true
+}
+'''
+c += auto_start
+
+# 4. Ensure "net" is imported
+if '"net"' not in c[:c.find("func main")]:
+ c = c.replace('import (\n\t"encoding/json"', 'import (\n\t"net"\n\t"encoding/json"', 1)
+
+with open(p, "w", encoding="utf-8", newline="\n") as f:
+ f.write(c)
+print("Patched write-server main.go")
+print("New file size:", len(c.splitlines()), "lines")
diff --git a/write-go/cmd/writeserver/admin_page.html b/write-go/cmd/writeserver/admin_page.html
new file mode 100644
index 00000000..efc19bf8
--- /dev/null
+++ b/write-go/cmd/writeserver/admin_page.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+WriteServer - Blog CMS
+
+
+
+
+
WriteServer
+
Blog CMS - Go Edition
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/write-go/cmd/writeserver/main.go b/write-go/cmd/writeserver/main.go
new file mode 100644
index 00000000..203a5289
--- /dev/null
+++ b/write-go/cmd/writeserver/main.go
@@ -0,0 +1,7 @@
+package main
+
+import "write-server/internal/app"
+
+func main() {
+ app.New().Run()
+}
\ No newline at end of file
diff --git a/write-go/cmd/writeserver/main_new.go b/write-go/cmd/writeserver/main_new.go
new file mode 100644
index 00000000..d4946d03
--- /dev/null
+++ b/write-go/cmd/writeserver/main_new.go
@@ -0,0 +1,345 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "write-server/internal/ai"
+ "write-server/internal/adminpanel"
+ "write-server/internal/artalk"
+ "write-server/internal/botconfig"
+ "write-server/internal/config"
+ "write-server/internal/helpers"
+ "write-server/internal/images"
+ "write-server/internal/posts"
+ "write-server/internal/recycle"
+ "write-server/internal/rssapi"
+ "write-server/internal/sessions"
+ "write-server/internal/tgcore"
+ "write-server/internal/tghandlers"
+ "write-server/internal/wechat"
+ "write-server/internal/writeserver"
+)
+
+var hugoStarting bool
+var lg *slog.Logger
+
+func main() {
+ config.Init()
+ recycle.InitRecycleBin()
+ posts.Init()
+ appCfg := botconfig.Load()
+ writeserver.Init(appCfg.WritePort, appCfg.WriteDir)
+ adminpanel.Init(appCfg.WritePort, appCfg.ControlPort)
+
+ lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeapp")
+
+ ip := helpers.GetLanIP(appCfg.PreferIface)
+ lg.Info("WriteApp starting", "writePort", appCfg.WritePort)
+
+ fmt.Println("")
+ fmt.Println(" =========================================")
+ fmt.Println(" WriteApp - Blog CMS + Telegram Bot")
+ fmt.Println(" =========================================")
+ fmt.Printf(" Frontend : http://127.0.0.1:%d/\n", appCfg.WritePort)
+ fmt.Printf(" Admin : http://127.0.0.1:%d/admin\n", appCfg.WritePort)
+ fmt.Printf(" LAN : http://%s:%d/\n", ip, appCfg.WritePort)
+ fmt.Println(" =========================================")
+ fmt.Println("")
+
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/api/posts/batch", handlePostsBatch)
+ mux.HandleFunc("/api/posts/", handlePostBySlug)
+ mux.HandleFunc("/api/posts", handlePosts)
+ mux.HandleFunc("/api/recycle", handleRecycle)
+ mux.HandleFunc("/api/ai", handleAI)
+ mux.HandleFunc("/api/images", handleImages)
+ mux.HandleFunc("/api/upload", handleUpload)
+ mux.HandleFunc("/api/hugo", handleHugo)
+ mux.HandleFunc("/api/deploy", handleDeploy)
+ mux.HandleFunc("/api/stats", handleStats)
+ mux.HandleFunc("/api/rss/links", handleRSSLinks)
+ mux.HandleFunc("/api/rss/feeds", handleRSSFeeds)
+ mux.HandleFunc("/api/rss/wechat", handleWechatHandler)
+ mux.HandleFunc("/api/comments/stats", handleCommentsStats)
+ mux.HandleFunc("/api/comments/cleanup", handleCommentsCleanup)
+ mux.HandleFunc("/api/comments/login", handleCommentsLogin)
+ mux.HandleFunc("/api/comments/", handleCommentsSub)
+ mux.HandleFunc("/api/comments", handleComments)
+
+ // Admin panel on /admin
+ mux.HandleFunc("/admin", handleAdmin)
+ mux.HandleFunc("/admin/health", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`+"`"+`{"ok":true}`+"`"+`))
+ })
+
+ // Reverse proxy to Next.js for non-API routes
+ nextURL, _ := url.Parse("http://localhost:3000")
+ proxy := httputil.NewSingleHostReverseProxy(nextURL)
+ proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(fallbackHTML))
+ }
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin") {
+ http.NotFound(w, r)
+ return
+ }
+ proxy.ServeHTTP(w, r)
+ })
+
+ // Auto-start Next.js
+ go startNextJS()
+
+ // Start Telegram bot
+ if appCfg.TGToken != "" {
+ tgcore.Init(appCfg.TGToken)
+ go runTelegramBot(appCfg)
+ } else {
+ lg.Warn("no TG_TOKEN configured, bot disabled")
+ }
+
+ port := 8016
+ if p := os.Getenv("WRITE_PORT"); p != "" {
+ if n, err := strconv.Atoi(p); err == nil {
+ port = n
+ }
+ }
+
+ // Graceful shutdown
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+
+ addr := fmt.Sprintf("0.0.0.0:%d", port)
+ fmt.Printf("[write-app] listening on %s\n", addr)
+ go func() {
+ if err := http.ListenAndServe(addr, withCORS(mux)); err != nil {
+ lg.Error("server error", "err", err)
+ }
+ }()
+
+ sig := <-sigCh
+ lg.Info("shutting down", "signal", sig.String())
+}
+
+const fallbackHTML = `+"`"+`
+
+
+
+
+WriteApp
+
+`+"`"+`
+
+func startNextJS() {
+ time.Sleep(2 * time.Second)
+ if _, err := net.DialTimeout("tcp", "127.0.0.1:3000", 2*time.Second); err == nil {
+ lg.Info("Next.js already running on :3000")
+ return
+ }
+ lg.Info("starting Next.js frontend")
+ cmd := exec.Command("npx.cmd", "next", "start", "-p", "3000")
+ cmd.Dir = filepath.Join(config.BLOG_ROOT, "write")
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ lg.Warn("failed to start Next.js", "err", err)
+ } else {
+ lg.Info("Next.js started", "pid", cmd.Process.Pid)
+ }
+}
+
+func runTelegramBot(appCfg *botconfig.Config) {
+ 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: appCfg.WritePort,
+ ControlPort: appCfg.ControlPort,
+ PreferIface: appCfg.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,
+ }
+
+ 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 {
+ lg.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, appCfg.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", appCfg.TGToken, filePath)
+ s.Content += "\n\n"
+ }
+ }
+ }
+ 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 = fullText[:idx] + afterAt
+ fullText = strings.TrimSpace(fullText)
+ }
+ 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 + "\n"
+ continue
+ }
+ if slashCmd == "" { continue }
+ lg.Info("tg command", "chat", chatID, "cmd", slashCmd)
+ handleTGCommand(h, chatID, slashCmd, slashArg, argCommands)
+ }
+ }
+}
+
+func handleTGCommand(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) {
+ defer func() {
+ if r := recover(); r != nil { lg.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 "stop": h.Stop(chatID)
+ case "status": h.Status(chatID)
+ case "ip": h.IP(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)
+ default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
+ }
+ }
+}
+
+func handleAdmin(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(fmt.Sprintf(adminHTML, helpers.GetLanIP("WLAN"))))
+}
+
+func withCORS(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if rec := recover(); rec != nil {
+ lg.Error("panic in handler", "path", r.URL.Path, "method", r.Method, "panic", fmt.Sprintf("%v", rec))
+ http.Error(w, fmt.Sprintf("Internal Server Error: %v", rec), http.StatusInternalServerError)
+ }
+ }()
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
+ if r.Method == "OPTIONS" {
+ w.WriteHeader(204)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/write-go/go.mod b/write-go/go.mod
new file mode 100644
index 00000000..ce419dc8
--- /dev/null
+++ b/write-go/go.mod
@@ -0,0 +1,3 @@
+module write-server
+
+go 1.26.3
diff --git a/write-go/internal/adminpanel/adminpanel.go b/write-go/internal/adminpanel/adminpanel.go
new file mode 100644
index 00000000..2529e006
--- /dev/null
+++ b/write-go/internal/adminpanel/adminpanel.go
@@ -0,0 +1,158 @@
+package adminpanel
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "write-server/internal/helpers"
+ "write-server/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/write-go/internal/ai/ai.go b/write-go/internal/ai/ai.go
new file mode 100644
index 00000000..5b6004ab
--- /dev/null
+++ b/write-go/internal/ai/ai.go
@@ -0,0 +1,73 @@
+package ai
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "write-server/internal/config"
+)
+
+var AI_PROMPTS = map[string]string{
+ "polish": "\u4f60\u662f\u4e00\u4e2a\u7f16\u8f91\u3002\u6da6\u8272\u4ee5\u4e0b\u6587\u672c\uff0c\u4f7f\u5176\u66f4\u6d41\u7545\u3001\u66f4\u4e13\u4e1a\u3002\u4fdd\u6301\u539f\u610f\u548c\u8bed\u6c14\uff0c\u53ea\u8fd4\u56de\u6da6\u8272\u540e\u7684\u6587\u672c\uff0c\u4e0d\u8981\u89e3\u91ca\u3002",
+ "expand": "\u4f60\u662f\u4e00\u4e2a\u7f16\u8f91\u3002\u6269\u5c55\u4ee5\u4e0b\u6587\u672c\uff0c\u6dfb\u52a0\u66f4\u591a\u7ec6\u8282\u548c\u89e3\u91ca\uff0c\u540c\u65f6\u4fdd\u6301\u6e05\u6670\u548c\u5f15\u4eba\u5165\u80dc\u3002\u53ea\u8fd4\u56de\u6269\u5c55\u540e\u7684\u6587\u672c\uff0c\u4e0d\u8981\u89e3\u91ca\u3002",
+ "shorten": "\u4f60\u662f\u4e00\u4e2a\u7f16\u8f91\u3002\u7f29\u77ed\u4ee5\u4e0b\u6587\u672c\uff0c\u4fdd\u6301\u6838\u5fc3\u610f\u601d\uff0c\u4f7f\u8868\u8fbe\u66f4\u7b80\u6d01\u3002\u53ea\u8fd4\u56de\u7f29\u77ed\u540e\u7684\u6587\u672c\uff0c\u4e0d\u8981\u89e3\u91ca\u3002",
+ "fix": "\u4f60\u662f\u4e00\u4e2a\u7f16\u8f91\u3002\u4fee\u590d\u4ee5\u4e0b\u6587\u672c\u4e2d\u7684\u8bed\u6cd5\u9519\u8bef\u3001\u62fc\u5199\u9519\u8bef\u548c\u4e0d\u81ea\u7136\u8868\u8fbe\uff0c\u4fdd\u6301\u539f\u610f\u3002\u53ea\u8fd4\u56de\u4fee\u6b63\u540e\u7684\u6587\u672c\uff0c\u4e0d\u8981\u89e3\u91ca\u3002",
+}
+
+type ChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type ChatRequest struct {
+ Model string `json:"model"`
+ Messages []ChatMessage `json:"messages"`
+}
+
+type ChatResponse struct {
+ Choices []struct {
+ Message ChatMessage `json:"message"`
+ } `json:"choices"`
+}
+
+func AIChat(systemPrompt, userMessage string) (string, error) {
+ if config.DEEPSEEK_API_KEY == "" {
+ return "", fmt.Errorf("DEEPSEEK_API_KEY not configured")
+ }
+
+ req := ChatRequest{
+ Model: config.DEEPSEEK_MODEL,
+ Messages: []ChatMessage{
+ {Role: "system", Content: systemPrompt},
+ {Role: "user", Content: userMessage},
+ },
+ }
+
+ data, _ := json.Marshal(req)
+ httpReq, _ := http.NewRequest("POST", config.DEEPSEEK_BASE_URL+"/chat/completions", bytes.NewReader(data))
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Authorization", "Bearer "+config.DEEPSEEK_API_KEY)
+
+ resp, err := http.DefaultClient.Do(httpReq)
+ if err != nil {
+ return "", fmt.Errorf("AI request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("AI error: %s", string(body))
+ }
+
+ var chatResp ChatResponse
+ if err := json.Unmarshal(body, &chatResp); err != nil {
+ return "", err
+ }
+ if len(chatResp.Choices) == 0 {
+ return "", fmt.Errorf("no response from AI")
+ }
+ return chatResp.Choices[0].Message.Content, nil
+}
diff --git a/write-go/internal/app/app.go b/write-go/internal/app/app.go
new file mode 100644
index 00000000..7f97789d
--- /dev/null
+++ b/write-go/internal/app/app.go
@@ -0,0 +1,453 @@
+package app
+
+import (
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "write-server/internal/adminpanel"
+ "write-server/internal/botconfig"
+ "write-server/internal/config"
+ "write-server/internal/helpers"
+ "write-server/internal/sessions"
+ "write-server/internal/tgcore"
+ "write-server/internal/tghandlers"
+ "write-server/internal/writeserver"
+
+ "write-server/pkg/posts"
+)
+
+var lg *slog.Logger
+
+type App struct {
+ cfg *botconfig.Config
+ controlSrv *http.Server
+}
+
+func New() *App {
+ return &App{}
+}
+
+func (a *App) Run() {
+ // Init
+ config.Init()
+ posts.Init()
+ a.cfg = botconfig.Load()
+ writeserver.Init(a.cfg.WritePort, a.cfg.WriteDir)
+ adminpanel.Init(a.cfg.WritePort, a.cfg.ControlPort)
+ tgcore.Init(a.cfg.TGToken)
+
+ lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeapp")
+ lg.Info("WriteApp starting",
+ "writePort", a.cfg.WritePort,
+ "controlPort", a.cfg.ControlPort,
+ )
+
+ ip := helpers.GetLanIP(a.cfg.PreferIface)
+
+ fmt.Println("")
+ fmt.Println(" =========================================")
+ fmt.Println(" WriteApp - Blog CMS + Telegram Bot")
+ fmt.Println(" =========================================")
+ fmt.Printf(" Frontend : http://127.0.0.1:%d/\n", a.cfg.WritePort)
+ fmt.Printf(" Admin : http://127.0.0.1:%d/admin\n", a.cfg.WritePort)
+ fmt.Printf(" LAN : http://%s:%d/\n", ip, a.cfg.WritePort)
+ fmt.Println(" =========================================")
+ fmt.Println("")
+
+ // Root mux on writePort
+ mux := http.NewServeMux()
+
+ // API routes
+ mux.HandleFunc("/api/posts/batch", handlePostsBatch)
+ mux.HandleFunc("/api/posts/", handlePostBySlug)
+ mux.HandleFunc("/api/posts", handlePosts)
+ mux.HandleFunc("/api/recycle", handleRecycle)
+ mux.HandleFunc("/api/ai", handleAI)
+ mux.HandleFunc("/api/images", handleImages)
+ mux.HandleFunc("/api/upload", handleUpload)
+ mux.HandleFunc("/api/hugo", handleHugo)
+ mux.HandleFunc("/api/deploy", handleDeploy)
+ mux.HandleFunc("/api/stats", handleStats)
+ mux.HandleFunc("/api/rss/links", handleRSSLinks)
+ mux.HandleFunc("/api/rss/feeds", handleRSSFeeds)
+ mux.HandleFunc("/api/rss/wechat", handleWechatHandler)
+ mux.HandleFunc("/api/comments/stats", handleCommentsStats)
+ mux.HandleFunc("/api/comments/cleanup", handleCommentsCleanup)
+ mux.HandleFunc("/api/comments/login", handleCommentsLogin)
+ mux.HandleFunc("/api/comments/", handleCommentsSub)
+ mux.HandleFunc("/api/comments", handleComments)
+
+ // Admin panel route
+ mux.HandleFunc("/admin", a.handleAdmin)
+ mux.HandleFunc("/admin/", a.handleAdmin)
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"ok":true}`))
+ })
+ mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"running":true,"port":%d,"ip":"%s"}`, a.cfg.WritePort, ip)
+ })
+
+ // Reverse proxy to Next.js for everything else
+ nextURL, _ := url.Parse("http://localhost:3000")
+ proxy := httputil.NewSingleHostReverseProxy(nextURL)
+ proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(`WriteApp`))
+ }
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/admin" || strings.HasPrefix(r.URL.Path, "/admin/") || r.URL.Path == "/health" || r.URL.Path == "/status" {
+ http.NotFound(w, r)
+ return
+ }
+ proxy.ServeHTTP(w, r)
+ })
+
+ // Start Next.js
+ go a.startNextJS()
+
+ // Start Telegram bot
+ go a.startTelegramBot()
+
+ // HTTP server
+ addr := fmt.Sprintf("0.0.0.0:%d", a.cfg.WritePort)
+ go func() {
+ lg.Info("HTTP server listening", "addr", addr)
+ if err := http.ListenAndServe(addr, withCORS(mux)); err != nil {
+ lg.Error("HTTP server error", "err", err)
+ }
+ }()
+
+ // Wait for interrupt
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ sig := <-sigCh
+ lg.Info("shutting down", "signal", sig.String())
+}
+
+func (a *App) startNextJS() {
+ time.Sleep(2 * time.Second)
+ if _, err := net.DialTimeout("tcp", "127.0.0.1:3000", 2*time.Second); err == nil {
+ lg.Info("Next.js already running on :3000")
+ return
+ }
+ lg.Info("starting Next.js frontend")
+ cmd := exec.Command("npx.cmd", "next", "start", "-p", "3000")
+ cmd.Dir = filepath.Join(config.BLOG_ROOT, "write")
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ lg.Warn("failed to start Next.js", "err", err)
+ } else {
+ lg.Info("Next.js started", "pid", cmd.Process.Pid)
+ }
+}
+
+func (a *App) startTelegramBot() {
+ if a.cfg.TGToken == "" {
+ lg.Warn("no TG_TOKEN configured, bot disabled")
+ return
+ }
+
+ time.Sleep(1 * time.Second)
+
+ // 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 {
+ username := tgcore.GetString(result, "username")
+ lg.Info("telegram connected", "username", username)
+ }
+ }
+ tgcore.TG("setMyCommands", map[string]interface{}{
+ "commands": []map[string]string{
+ {"command": "start", "description": "启动写作后台"},
+ {"command": "new", "description": "开始写一篇"},
+ {"command": "edit", "description": "编辑已有文章"},
+ {"command": "list", "description": "文章列表"},
+ {"command": "save", "description": "保存草稿到本地"},
+ {"command": "publish", "description": "发布到 GitHub"},
+ {"command": "stats", "description": "网站数据统计"},
+ {"command": "preview", "description": "预览全文"},
+ {"command": "links", "description": "友链列表"},
+ {"command": "feeds", "description": "订阅源列表"},
+ {"command": "help", "description": "全部命令"},
+ },
+ })
+ lg.Info("telegram commands registered")
+ } else {
+ lg.Warn("telegram connection failed", "err", err)
+ }
+
+ h := tghandlers.New(&tghandlers.Config{
+ WritePort: a.cfg.WritePort,
+ ControlPort: a.cfg.ControlPort,
+ PreferIface: a.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,
+ }
+
+ 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 {
+ lg.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")
+ } else if 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, a.cfg.AllowedIDs) {
+ lg.Warn("tg blocked user", "chatID", chatID)
+ continue
+ }
+
+ // Handle photos
+ 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", a.cfg.TGToken, filePath)
+ s.Content += fmt.Sprintf("\n\n", caption, imgURL)
+ lg.Info("photo received", "chat", chatID, "url", 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 = fullText[:idx] + afterAt
+ fullText = strings.TrimSpace(fullText)
+ }
+
+ 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]
+ }
+ }
+
+ hasSession := sessions.HasSession(chatID)
+
+ if hasSession && !strings.HasPrefix(fullText, "/") {
+ s := sessions.GetSession(chatID)
+ s.Content += fullText + "\n"
+ continue
+ }
+
+ if slashCmd == "" {
+ continue
+ }
+
+ lg.Info("tg command", "chat", chatID, "cmd", slashCmd, "arg", slashArg)
+ a.handleCommand(h, chatID, slashCmd, slashArg, argCommands)
+ }
+ }
+}
+
+func (a *App) handleCommand(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) {
+ defer func() {
+ if r := recover(); r != nil {
+ lg.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 "stop": h.Stop(chatID)
+ case "status": h.Status(chatID)
+ case "ip": h.IP(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)
+ default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
+ }
+ }
+}
+
+func (a *App) handleAdmin(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/admin", "/admin/":
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(a.adminHTML()))
+ case "/admin/status":
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"running":true,"port":%d,"ip":"%s"}`,
+ a.cfg.WritePort, helpers.GetLanIP(a.cfg.PreferIface))
+ case "/admin/start":
+ if r.Method == "POST" {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"started":true}`))
+ }
+ case "/admin/stop":
+ if r.Method == "POST" {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"ok":false,"reason":"bot runs in-process"}`))
+ }
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+func (a *App) adminHTML() string {
+ return fmt.Sprintf(`
+
+
+
+
+WriteApp
+
+
+
+
+
WriteApp
+
Blog CMS + Telegram Bot
+
Server Running
Port %d · LAN: %s
+
+
+
+
+
+
+`, a.cfg.WritePort, helpers.GetLanIP(a.cfg.PreferIface))
+}
diff --git a/write-go/internal/artalk/artalk.go b/write-go/internal/artalk/artalk.go
new file mode 100644
index 00000000..78ff4766
--- /dev/null
+++ b/write-go/internal/artalk/artalk.go
@@ -0,0 +1,190 @@
+package artalk
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "write-server/internal/config"
+)
+
+var authToken string
+
+func SetAuthToken(token string) { authToken = token }
+func GetAuthToken() string { return authToken }
+
+type ArtalkPage struct {
+ ID int `json:"id"`
+ AdminOnly bool `json:"admin_only"`
+ Key string `json:"key"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ View int `json:"view"`
+ CommentCount int `json:"comment_count"`
+ Date string `json:"date"`
+}
+
+type ArtalkComment struct {
+ ID int `json:"id"`
+ Content string `json:"content"`
+ Nick string `json:"nick"`
+ Email string `json:"email"`
+ PageKey string `json:"page_key"`
+ IsCollapsed bool `json:"is_collapsed"`
+ IsPending bool `json:"is_pending"`
+ IsPinned bool `json:"is_pinned"`
+ VoteUp int `json:"vote_up"`
+ VoteDown int `json:"vote_down"`
+ RID int `json:"rid"`
+ Date string `json:"date"`
+}
+
+func Login(email, password string) (map[string]interface{}, error) {
+ u := config.ARTALK_SERVER + "/api/v2/auth/email/login"
+ body, _ := json.Marshal(map[string]string{"email": email, "password": password})
+ resp, err := http.Post(u, "application/json", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ data, _ := io.ReadAll(resp.Body)
+ var result map[string]interface{}
+ json.Unmarshal(data, &result)
+
+ if token, ok := result["token"].(string); ok {
+ authToken = token
+ }
+ return result, nil
+}
+
+func artalkFetch(path string) (map[string]interface{}, error) {
+ u := config.ARTALK_SERVER + path
+ req, _ := http.NewRequest("GET", u, nil)
+ req.Header.Set("Content-Type", "application/json")
+ if authToken != "" {
+ req.Header.Set("Authorization", "Bearer "+authToken)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ data, _ := io.ReadAll(resp.Body)
+ var result map[string]interface{}
+ json.Unmarshal(data, &result)
+ return result, nil
+}
+
+func GetPages(offset, limit int, pageType string) ([]ArtalkPage, int, error) {
+ params := url.Values{}
+ params.Set("site_name", config.ARTALK_SITE)
+ params.Set("offset", fmt.Sprintf("%d", offset))
+ params.Set("limit", fmt.Sprintf("%d", limit))
+ params.Set("type", pageType)
+
+ result, err := artalkFetch("/api/v2/pages?" + params.Encode())
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var pages []ArtalkPage
+ if raw, ok := result["pages"]; ok {
+ d, _ := json.Marshal(raw)
+ json.Unmarshal(d, &pages)
+ } else if raw, ok := result["data"]; ok {
+ d, _ := json.Marshal(raw)
+ json.Unmarshal(d, &pages)
+ }
+
+ total := 0
+ if v, ok := result["count"]; ok {
+ fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &total)
+ } else if v, ok := result["total"]; ok {
+ fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &total)
+ }
+ return pages, total, nil
+}
+
+func GetComments(pageKey string, offset, limit int) ([]ArtalkComment, int, error) {
+ params := url.Values{}
+ params.Set("site_name", config.ARTALK_SITE)
+ params.Set("page_key", pageKey)
+ params.Set("offset", fmt.Sprintf("%d", offset))
+ params.Set("limit", fmt.Sprintf("%d", limit))
+ params.Set("flat_mode", "true")
+ params.Set("sort_by", "date_desc")
+
+ result, err := artalkFetch("/api/v2/comments?" + params.Encode())
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var comments []ArtalkComment
+ if raw, ok := result["comments"]; ok {
+ d, _ := json.Marshal(raw)
+ json.Unmarshal(d, &comments)
+ } else if raw, ok := result["data"]; ok {
+ d, _ := json.Marshal(raw)
+ json.Unmarshal(d, &comments)
+ }
+ total := 0
+ if v, ok := result["count"]; ok {
+ fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &total)
+ } else if v, ok := result["total"]; ok {
+ fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &total)
+ }
+ return comments, total, nil
+}
+
+func UpdateComment(id int, data map[string]interface{}) error {
+ u := config.ARTALK_SERVER + fmt.Sprintf("/api/v2/comments/%d", id)
+ data["site_name"] = config.ARTALK_SITE
+ body, _ := json.Marshal(data)
+ req, _ := http.NewRequest("PUT", u, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ if authToken != "" {
+ req.Header.Set("Authorization", "Bearer "+authToken)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ return nil
+}
+
+func CreateComment(data map[string]interface{}) (map[string]interface{}, error) {
+ u := config.ARTALK_SERVER + "/api/v2/comments"
+ data["site_name"] = config.ARTALK_SITE
+ body, _ := json.Marshal(data)
+ req, _ := http.NewRequest("POST", u, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ if authToken != "" {
+ req.Header.Set("Authorization", "Bearer "+authToken)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ respData, _ := io.ReadAll(resp.Body)
+ var result map[string]interface{}
+ json.Unmarshal(respData, &result)
+ return result, nil
+}
+
+func DeleteComment(id int) error {
+ u := config.ARTALK_SERVER + fmt.Sprintf("/api/v2/comments/%d", id)
+ req, _ := http.NewRequest("DELETE", u, bytes.NewReader([]byte(`{"site_name":"` + config.ARTALK_SITE + `"}`)))
+ req.Header.Set("Content-Type", "application/json")
+ if authToken != "" {
+ req.Header.Set("Authorization", "Bearer "+authToken)
+ }
+ _, err := http.DefaultClient.Do(req)
+ return err
+}
diff --git a/write-go/internal/botconfig/config.go b/write-go/internal/botconfig/config.go
new file mode 100644
index 00000000..113b498e
--- /dev/null
+++ b/write-go/internal/botconfig/config.go
@@ -0,0 +1,99 @@
+package botconfig
+
+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/write-go/internal/config/config.go b/write-go/internal/config/config.go
new file mode 100644
index 00000000..8ecdfdf0
--- /dev/null
+++ b/write-go/internal/config/config.go
@@ -0,0 +1,93 @@
+package config
+
+import (
+ "bufio"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+var (
+ BLOG_ROOT string
+ CONTENT_DIR string
+ RECYCLE_DIR string
+ ARTALK_SERVER = "https://artalk.usj.cc"
+ ARTALK_SITE = "???"
+ ITEMS_PER_PAGE = 20
+ RECYCLE_DAYS = 30
+ HUGO_PREVIEW = "http://localhost:1313"
+ RSS_API_BASE string
+ RSS_API_TOKEN string
+ SCF_PROXY string
+ WECHAT_APP_ID string
+ WECHAT_APP_SECRET string
+ DEEPSEEK_API_KEY string
+ DEEPSEEK_BASE_URL string
+ DEEPSEEK_MODEL string
+)
+
+func Init() {
+ cwd, _ := os.Getwd()
+ BLOG_ROOT = filepath.Clean(filepath.Join(cwd, ".."))
+ CONTENT_DIR = filepath.Join(BLOG_ROOT, "content", "post")
+ RECYCLE_DIR = filepath.Join(cwd, ".recycle")
+
+ loadEnv()
+
+ RSS_API_BASE = getEnv("RSS_API_BASE", "https://api.usj.cc")
+ RSS_API_TOKEN = os.Getenv("RSS_API_TOKEN")
+ SCF_PROXY = getEnv("SCF_PROXY", "")
+ WECHAT_APP_ID = os.Getenv("WECHAT_APP_ID")
+ WECHAT_APP_SECRET = os.Getenv("WECHAT_APP_SECRET")
+ DEEPSEEK_API_KEY = os.Getenv("DEEPSEEK_API_KEY")
+ DEEPSEEK_BASE_URL = getEnv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
+ DEEPSEEK_MODEL = getEnv("DEEPSEEK_MODEL", "deepseek-chat")
+}
+
+func loadEnv() {
+ envPaths := []string{
+ filepath.Join(BLOG_ROOT, "write-go", ".env"),
+ filepath.Join(BLOG_ROOT, "write", ".env"),
+ }
+ for _, p := range envPaths {
+ f, err := os.Open(p)
+ if err != nil {
+ continue
+ }
+ 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)
+ }
+ }
+ f.Close()
+ break
+ }
+}
+
+func getEnv(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
+
+func GetEnvInt(key string, fallback int) int {
+ if v := os.Getenv(key); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ return n
+ }
+ }
+ return fallback
+}
diff --git a/write-go/internal/helpers/helpers.go b/write-go/internal/helpers/helpers.go
new file mode 100644
index 00000000..5a9d48e2
--- /dev/null
+++ b/write-go/internal/helpers/helpers.go
@@ -0,0 +1,159 @@
+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/write-go/internal/helpers/helpers/helpers.go b/write-go/internal/helpers/helpers/helpers.go
new file mode 100644
index 00000000..5a9d48e2
--- /dev/null
+++ b/write-go/internal/helpers/helpers/helpers.go
@@ -0,0 +1,159 @@
+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/write-go/internal/images/images.go b/write-go/internal/images/images.go
new file mode 100644
index 00000000..f6de046c
--- /dev/null
+++ b/write-go/internal/images/images.go
@@ -0,0 +1,95 @@
+package images
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "write-server/internal/config"
+ "write-server/internal/posts"
+)
+
+type ImageFile struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Folder string `json:"folder"`
+ Size int64 `json:"size"`
+ URL string `json:"url"`
+ Slug string `json:"slug"`
+ LocalURL string `json:"localUrl"`
+}
+
+var imageExts = map[string]bool{
+ ".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
+ ".webp": true, ".svg": true, ".bmp": true,
+}
+
+func ScanImages() []ImageFile {
+ var images []ImageFile
+ filepath.WalkDir(config.CONTENT_DIR, func(p string, d os.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if d.IsDir() {
+ if strings.HasPrefix(d.Name(), ".") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ ext := strings.ToLower(filepath.Ext(d.Name()))
+ if !imageExts[ext] {
+ return nil
+ }
+ info, _ := d.Info()
+ size := int64(0)
+ if info != nil {
+ size = info.Size()
+ }
+
+ rel, _ := filepath.Rel(config.CONTENT_DIR, p)
+ rel = strings.ReplaceAll(rel, "\\", "/")
+ dir := filepath.Dir(p)
+
+ slug := ""
+ indexPath := filepath.Join(dir, "index.md")
+ if data, err := os.ReadFile(indexPath); err == nil {
+ fm, _ := posts.ParseFrontMatter(string(data))
+ slug = fm.Slug
+ }
+
+ u := "/" + rel
+ if slug != "" {
+ u = "/" + slug + "/" + d.Name()
+ }
+
+ images = append(images, ImageFile{
+ Name: d.Name(),
+ Path: rel,
+ Folder: filepath.Dir(rel),
+ Size: size,
+ URL: u,
+ Slug: slug,
+ LocalURL: fmt.Sprintf("/api/images?file=%s", rel),
+ })
+ return nil
+ })
+
+ // Sort by size desc
+ for i := 0; i < len(images); i++ {
+ for j := i + 1; j < len(images); j++ {
+ if images[j].Size > images[i].Size {
+ images[i], images[j] = images[j], images[i]
+ }
+ }
+ }
+ return images
+}
+
+func DeleteImage(relPath string) error {
+ fullPath := filepath.Join(config.CONTENT_DIR, relPath)
+ if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(config.CONTENT_DIR)) {
+ return fmt.Errorf("invalid path")
+ }
+ return os.Remove(fullPath)
+}
diff --git a/write-go/internal/logutil/logutil.go b/write-go/internal/logutil/logutil.go
new file mode 100644
index 00000000..3a953922
--- /dev/null
+++ b/write-go/internal/logutil/logutil.go
@@ -0,0 +1,14 @@
+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/write-go/internal/posts/posts.go b/write-go/internal/posts/posts.go
new file mode 100644
index 00000000..5b23d13f
--- /dev/null
+++ b/write-go/internal/posts/posts.go
@@ -0,0 +1,524 @@
+package posts
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "write-server/internal/config"
+)
+
+type FrontMatter struct {
+ Pid int `json:"pid,omitempty"`
+ Title string `json:"title"`
+ Date string `json:"date"`
+ Slug string `json:"slug"`
+ Draft bool `json:"draft"`
+ Categories []string `json:"categories,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Author string `json:"author,omitempty"`
+ Layout string `json:"layout,omitempty"`
+ AiComment string `json:"ai_comment,omitempty"`
+ Extra map[string]interface{} `json:"-"`
+}
+
+type Post struct {
+ Slug string `json:"slug"`
+ FrontMatter FrontMatter `json:"frontMatter"`
+ Content string `json:"content"`
+ FilePath string `json:"-"`
+ DirPath string `json:"-"`
+}
+
+type PostListItem struct {
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Date string `json:"date"`
+ Draft bool `json:"draft"`
+ Categories []string `json:"categories"`
+ Tags []string `json:"tags"`
+ Author string `json:"author"`
+}
+
+var dateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-(.+)$`)
+var yearRE = regexp.MustCompile(`^(\d{4})`)
+
+func Init() {
+ config.Init()
+}
+
+func findPostDirs() []string {
+ var result []string
+ if _, err := os.Stat(config.CONTENT_DIR); os.IsNotExist(err) {
+ return result
+ }
+ entries, err := os.ReadDir(config.CONTENT_DIR)
+ if err != nil {
+ return result
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() || !yearRE.MatchString(entry.Name()) {
+ continue
+ }
+ yearDir := filepath.Join(config.CONTENT_DIR, entry.Name())
+ posts, err := os.ReadDir(yearDir)
+ if err != nil {
+ continue
+ }
+ for _, post := range posts {
+ if post.IsDir() {
+ result = append(result, filepath.Join(yearDir, post.Name()))
+ }
+ }
+ }
+ return result
+}
+
+func slugFromDir(dirName string) string {
+ m := dateRE.FindStringSubmatch(dirName)
+ if m != nil {
+ return m[1]
+ }
+ return dirName
+}
+
+func parseDate(dateStr string) time.Time {
+ if len(dateStr) < 10 {
+ return time.Time{}
+ }
+ t, err := time.Parse("2006-01-02", dateStr[:10])
+ if err != nil {
+ return time.Time{}
+ }
+ return t
+}
+
+// ParseFrontMatter parses YAML-like frontmatter (---...---) and body
+func ParseFrontMatter(raw string) (FrontMatter, string) {
+ fm := FrontMatter{Layout: "post"}
+ fm.Extra = make(map[string]interface{})
+
+ raw = strings.TrimSpace(raw)
+ if !strings.HasPrefix(raw, "---") {
+ return fm, raw
+ }
+
+ rest := raw[3:]
+ endIdx := strings.Index(rest, "\n---")
+ if endIdx < 0 {
+ return fm, raw
+ }
+
+ yamlBlock := rest[:endIdx]
+ body := rest[endIdx+4:]
+
+ // Simple YAML-like key: value parser
+ lines := strings.Split(yamlBlock, "\n")
+ var currentListKey string
+ for _, line := range lines {
+ // Handle multi-line YAML list items (- value)
+ if currentListKey != "" && strings.HasPrefix(strings.TrimSpace(line), "- ") {
+ item := strings.Trim(strings.TrimPrefix(strings.TrimSpace(line), "- "), "\"'")
+ switch currentListKey {
+ case "categories":
+ fm.Categories = append(fm.Categories, item)
+ case "tags":
+ fm.Tags = append(fm.Tags, item)
+ }
+ continue
+ }
+ currentListKey = ""
+ line = strings.TrimSpace(line)
+ 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])
+
+ // Unquoted string value
+ val = strings.Trim(val, "\"'")
+
+ switch key {
+ case "title":
+ fm.Title = val
+ case "date":
+ fm.Date = val
+ case "slug":
+ fm.Slug = val
+ case "draft":
+ fm.Draft = val == "true"
+ case "author":
+ fm.Author = val
+ case "layout":
+ fm.Layout = val
+ case "ai_comment":
+ fm.AiComment = val
+ case "categories":
+ if val == "" {
+ currentListKey = "categories"
+ } else if val != "[]" {
+ fm.Categories = parseYAMLList(val, line)
+ }
+ case "tags":
+ if val == "" {
+ currentListKey = "tags"
+ } else if val != "[]" {
+ fm.Tags = parseYAMLList(val, line)
+ }
+ case "pid":
+ fmt.Sscanf(val, "%d", &fm.Pid)
+ default:
+ fm.Extra[key] = val
+ }
+ }
+
+ return fm, body
+}
+
+func parseYAMLList(firstVal string, fullLine string) []string {
+ // Try inline: [a, b, c]
+ if strings.HasPrefix(firstVal, "[") && strings.HasSuffix(firstVal, "]") {
+ inner := firstVal[1 : len(firstVal)-1]
+ var items []string
+ for _, item := range strings.Split(inner, ",") {
+ item = strings.TrimSpace(item)
+ item = strings.Trim(item, "\"'")
+ if item != "" {
+ items = append(items, item)
+ }
+ }
+ return items
+ }
+ // Single value on same line
+ if firstVal != "" && firstVal != "[]" {
+ return []string{strings.Trim(firstVal, "\"'")}
+ }
+ return nil
+}
+
+// ParseYAMLListMultiLine is called for multi-line list items
+// In our simple parser, multi-line categories/tags aren't handled fully
+// but this stub exists for testing
+
+func ListPosts(search string, page, perPage int, includeDrafts bool) ([]PostListItem, int, int) {
+ dirs := findPostDirs()
+ var allPosts []PostListItem
+
+ for _, dirPath := range dirs {
+ indexPath := filepath.Join(dirPath, "index.md")
+ data, err := os.ReadFile(indexPath)
+ if err != nil {
+ continue
+ }
+
+ fm, _ := ParseFrontMatter(string(data))
+ dirName := filepath.Base(dirPath)
+
+ slug := fm.Slug
+ if slug == "" {
+ slug = slugFromDir(dirName)
+ }
+ title := fm.Title
+ if title == "" {
+ title = "Untitled"
+ }
+
+ if !includeDrafts && fm.Draft {
+ continue
+ }
+
+ if search != "" {
+ q := strings.ToLower(search)
+ match := strings.Contains(strings.ToLower(title), q) ||
+ strings.Contains(strings.ToLower(slug), q)
+ if !match {
+ for _, c := range fm.Categories {
+ if strings.Contains(strings.ToLower(c), q) {
+ match = true
+ break
+ }
+ }
+ }
+ if !match {
+ for _, t := range fm.Tags {
+ if strings.Contains(strings.ToLower(t), q) {
+ match = true
+ break
+ }
+ }
+ }
+ if !match {
+ continue
+ }
+ }
+
+ allPosts = append(allPosts, PostListItem{
+ Slug: slug,
+ Title: title,
+ Date: fm.Date,
+ Draft: fm.Draft,
+ Categories: fm.Categories,
+ Tags: fm.Tags,
+ Author: fm.Author,
+ })
+ }
+
+ sort.Slice(allPosts, func(i, j int) bool {
+ return parseDate(allPosts[i].Date).After(parseDate(allPosts[j].Date))
+ })
+
+ total := len(allPosts)
+ totalPages := (total + perPage - 1) / perPage
+ if totalPages < 1 {
+ totalPages = 1
+ }
+
+ start := (page - 1) * perPage
+ if start > total {
+ start = total
+ }
+ end := start + perPage
+ if end > total {
+ end = total
+ }
+
+ return allPosts[start:end], total, totalPages
+}
+
+func GetPost(slug string) *Post {
+ dirs := findPostDirs()
+ for _, dirPath := range dirs {
+ indexPath := filepath.Join(dirPath, "index.md")
+ data, err := os.ReadFile(indexPath)
+ if err != nil {
+ continue
+ }
+
+ fm, body := ParseFrontMatter(string(data))
+ existingSlug := fm.Slug
+ if existingSlug == "" {
+ existingSlug = slugFromDir(filepath.Base(dirPath))
+ }
+
+ if existingSlug == slug {
+ return &Post{
+ Slug: existingSlug,
+ FrontMatter: fm,
+ Content: strings.TrimSpace(body),
+ FilePath: indexPath,
+ DirPath: dirPath,
+ }
+ }
+ }
+ return nil
+}
+
+func CreatePost(fm FrontMatter, content string) (string, error) {
+ date := time.Now()
+ if fm.Date != "" {
+ if d, err := time.Parse("2006-01-02", fm.Date[:10]); err == nil {
+ date = d
+ }
+ } else {
+ fm.Date = date.Format("2006-01-02")
+ }
+ year := fmt.Sprintf("%04d", date.Year())
+ dateStr := date.Format("2006-01-02")
+
+ titlePart := fm.Title
+ if titlePart == "" {
+ titlePart = "untitled"
+ }
+ titlePart = sanitizePath(titlePart)
+ if titlePart == "" {
+ titlePart = "post"
+ }
+
+ dirName := dateStr + "-" + titlePart
+ dirPath := filepath.Join(config.CONTENT_DIR, year, dirName)
+
+ if _, err := os.Stat(dirPath); err == nil {
+ return "", fmt.Errorf("Post already exists at %s", dirPath)
+ }
+
+ if err := os.MkdirAll(dirPath, 0755); err != nil {
+ return "", err
+ }
+
+ fileContent := buildMarkdown(fm, content)
+ indexPath := filepath.Join(dirPath, "index.md")
+ if err := os.WriteFile(indexPath, []byte(fileContent), 0644); err != nil {
+ return "", err
+ }
+ return dirPath, nil
+}
+
+func UpdatePost(slug string, fmUpdates map[string]interface{}, content *string) (*Post, error) {
+ post := GetPost(slug)
+ if post == nil {
+ return nil, fmt.Errorf("post not found")
+ }
+
+ // Apply updates
+ for k, v := range fmUpdates {
+ switch k {
+ case "title":
+ if s, ok := v.(string); ok {
+ post.FrontMatter.Title = s
+ }
+ case "date":
+ if s, ok := v.(string); ok {
+ post.FrontMatter.Date = s
+ }
+ case "draft":
+ if b, ok := v.(bool); ok {
+ post.FrontMatter.Draft = b
+ }
+ case "categories":
+ if arr, ok := v.([]interface{}); ok {
+ post.FrontMatter.Categories = nil
+ for _, item := range arr {
+ if s, ok := item.(string); ok {
+ post.FrontMatter.Categories = append(post.FrontMatter.Categories, s)
+ }
+ }
+ }
+ case "tags":
+ if arr, ok := v.([]interface{}); ok {
+ post.FrontMatter.Tags = nil
+ for _, item := range arr {
+ if s, ok := item.(string); ok {
+ post.FrontMatter.Tags = append(post.FrontMatter.Tags, s)
+ }
+ }
+ }
+ case "author":
+ if s, ok := v.(string); ok {
+ post.FrontMatter.Author = s
+ }
+ case "slug":
+ if s, ok := v.(string); ok {
+ post.FrontMatter.Slug = s
+ }
+ case "ai_comment":
+ if s, ok := v.(string); ok {
+ post.FrontMatter.AiComment = s
+ }
+ }
+ }
+
+ if content != nil {
+ post.Content = *content
+ }
+
+ // Handle slug rename
+ if newSlug, ok := fmUpdates["slug"].(string); ok && newSlug != slug {
+ date := time.Now()
+ if post.FrontMatter.Date != "" {
+ if d, err := time.Parse("2006-01-02", post.FrontMatter.Date[:10]); err == nil {
+ date = d
+ }
+ }
+ year := fmt.Sprintf("%04d", date.Year())
+ dateStr := date.Format("2006-01-02")
+ newDirName := dateStr + "-" + newSlug
+ newDirPath := filepath.Join(config.CONTENT_DIR, year, newDirName)
+
+ if _, err := os.Stat(newDirPath); err == nil {
+ return nil, fmt.Errorf("Target path already exists: %s", newDirPath)
+ }
+ os.MkdirAll(filepath.Dir(newDirPath), 0755)
+ os.Rename(post.DirPath, newDirPath)
+ post.DirPath = newDirPath
+ post.FilePath = filepath.Join(newDirPath, "index.md")
+ post.Slug = newSlug
+ }
+
+ fileContent := buildMarkdown(post.FrontMatter, post.Content)
+ if err := os.WriteFile(post.FilePath, []byte(fileContent), 0644); err != nil {
+ return nil, err
+ }
+ return post, nil
+}
+
+func GetPostCount() int {
+ count := 0
+ dirs := findPostDirs()
+ for _, dir := range dirs {
+ if _, err := os.Stat(filepath.Join(dir, "index.md")); err == nil {
+ count++
+ }
+ }
+ return count
+}
+
+func sanitizePath(s string) string {
+ var result []byte
+ for _, r := range []byte(s) {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
+ result = append(result, r)
+ } else if r >= 0x80 {
+ // Allow multi-byte (CJK etc.)
+ result = append(result, r)
+ } else {
+ result = append(result, '-')
+ }
+ }
+ out := string(result)
+ out = strings.Trim(out, "-")
+ return strings.ToLower(out)
+}
+
+func buildMarkdown(fm FrontMatter, content string) string {
+ var sb strings.Builder
+ sb.WriteString("---\n")
+ sb.WriteString(fmt.Sprintf("title: %s\n", quoteYAML(fm.Title)))
+ sb.WriteString(fmt.Sprintf("date: %s\n", fm.Date))
+ sb.WriteString(fmt.Sprintf("slug: %s\n", fm.Slug))
+ sb.WriteString(fmt.Sprintf("draft: %v\n", fm.Draft))
+ if len(fm.Categories) > 0 {
+ sb.WriteString("categories:\n")
+ for _, c := range fm.Categories {
+ sb.WriteString(fmt.Sprintf(" - %s\n", quoteYAML(c)))
+ }
+ }
+ if len(fm.Tags) > 0 {
+ sb.WriteString("tags:\n")
+ for _, t := range fm.Tags {
+ sb.WriteString(fmt.Sprintf(" - %s\n", quoteYAML(t)))
+ }
+ }
+ if fm.Author != "" {
+ sb.WriteString(fmt.Sprintf("author: %s\n", quoteYAML(fm.Author)))
+ }
+ if fm.Layout != "post" && fm.Layout != "" {
+ sb.WriteString(fmt.Sprintf("layout: %s\n", fm.Layout))
+ }
+ if fm.AiComment != "" {
+ sb.WriteString(fmt.Sprintf("ai_comment: %s\n", quoteYAML(fm.AiComment)))
+ }
+ if fm.Pid != 0 {
+ sb.WriteString(fmt.Sprintf("pid: %d\n", fm.Pid))
+ }
+ sb.WriteString("---\n")
+ sb.WriteString(content)
+ if !strings.HasSuffix(content, "\n") {
+ sb.WriteString("\n")
+ }
+ return sb.String()
+}
+
+func quoteYAML(s string) string {
+ if strings.ContainsAny(s, ":{}[]&*?|>!%@`'\"#,\n") || s == "" {
+ return "\"" + strings.ReplaceAll(s, "\"", "\\\"") + "\""
+ }
+ return s
+}
diff --git a/write-go/internal/recycle/recycle.go b/write-go/internal/recycle/recycle.go
new file mode 100644
index 00000000..3a44d6da
--- /dev/null
+++ b/write-go/internal/recycle/recycle.go
@@ -0,0 +1,168 @@
+package recycle
+
+import (
+ "encoding/json"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "write-server/internal/config"
+)
+
+var RECYCLE_POSTS_DIR string
+var RECYCLE_META_DIR string
+
+type RecycleItem struct {
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ DeletedAt string `json:"deletedAt"`
+ OriginalPath string `json:"originalPath"`
+ ExpiresAt string `json:"expiresAt"`
+ DaysRemaining int `json:"daysRemaining"`
+}
+
+func InitRecycleBin() {
+ base := config.RECYCLE_DIR
+ RECYCLE_POSTS_DIR = filepath.Join(base, "posts")
+ RECYCLE_META_DIR = filepath.Join(base, "meta")
+ if err := os.MkdirAll(RECYCLE_POSTS_DIR, 0755); err != nil {
+ panic(err)
+ }
+ if err := os.MkdirAll(RECYCLE_META_DIR, 0755); err != nil {
+ panic(err)
+ }
+}
+
+func MoveToRecycle(slug, sourceDir, title string) error {
+ InitRecycleBin()
+
+ now := time.Now()
+ expires := now.AddDate(0, 0, config.RECYCLE_DAYS)
+
+ meta := map[string]interface{}{
+ "slug": slug,
+ "title": title,
+ "deletedAt": now.Format(time.RFC3339),
+ "originalPath": sourceDir,
+ "expiresAt": expires.Format(time.RFC3339),
+ }
+
+ targetDir := filepath.Join(RECYCLE_POSTS_DIR, slug)
+ os.RemoveAll(targetDir)
+ if err := os.Rename(sourceDir, targetDir); err != nil {
+ return err
+ }
+
+ metaPath := filepath.Join(RECYCLE_META_DIR, slug+".json")
+ data, _ := json.MarshalIndent(meta, "", " ")
+ return os.WriteFile(metaPath, data, 0644)
+}
+
+func ListRecycle() []RecycleItem {
+ if _, err := os.Stat(RECYCLE_META_DIR); os.IsNotExist(err) {
+ return nil
+ }
+ entries, err := os.ReadDir(RECYCLE_META_DIR)
+ if err != nil {
+ return nil
+ }
+ var items []RecycleItem
+ now := time.Now()
+ for _, entry := range entries {
+ if !strings.HasSuffix(entry.Name(), ".json") {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(RECYCLE_META_DIR, entry.Name()))
+ if err != nil {
+ continue
+ }
+ var meta map[string]interface{}
+ if err := json.Unmarshal(data, &meta); err != nil {
+ continue
+ }
+ expiresAt, _ := time.Parse(time.RFC3339, getString(meta, "expiresAt"))
+ daysRemaining := int(math.Ceil(expiresAt.Sub(now).Hours() / 24))
+ if daysRemaining < 0 {
+ daysRemaining = 0
+ }
+ items = append(items, RecycleItem{
+ Slug: getString(meta, "slug"),
+ Title: getString(meta, "title"),
+ DeletedAt: getString(meta, "deletedAt"),
+ OriginalPath: getString(meta, "originalPath"),
+ ExpiresAt: getString(meta, "expiresAt"),
+ DaysRemaining: daysRemaining,
+ })
+ }
+ sort.Slice(items, func(i, j int) bool {
+ ti, _ := time.Parse(time.RFC3339, items[i].DeletedAt)
+ tj, _ := time.Parse(time.RFC3339, items[j].DeletedAt)
+ return ti.After(tj)
+ })
+ return items
+}
+
+func RestoreFromRecycle(slug string) bool {
+ metaPath := filepath.Join(RECYCLE_META_DIR, slug+".json")
+ data, err := os.ReadFile(metaPath)
+ if err != nil {
+ return false
+ }
+ var meta map[string]interface{}
+ json.Unmarshal(data, &meta)
+
+ recyclePostPath := filepath.Join(RECYCLE_POSTS_DIR, slug)
+ if _, err := os.Stat(recyclePostPath); os.IsNotExist(err) {
+ return false
+ }
+
+ originalPath := getString(meta, "originalPath")
+ os.MkdirAll(filepath.Dir(originalPath), 0755)
+ os.RemoveAll(originalPath)
+
+ if err := os.Rename(recyclePostPath, originalPath); err != nil {
+ return false
+ }
+ os.Remove(metaPath)
+ return true
+}
+
+func PermanentDelete(slug string) bool {
+ deleted := false
+ recyclePostPath := filepath.Join(RECYCLE_POSTS_DIR, slug)
+ if _, err := os.Stat(recyclePostPath); err == nil {
+ os.RemoveAll(recyclePostPath)
+ deleted = true
+ }
+ metaPath := filepath.Join(RECYCLE_META_DIR, slug+".json")
+ if _, err := os.Stat(metaPath); err == nil {
+ os.Remove(metaPath)
+ deleted = true
+ }
+ return deleted
+}
+
+func CleanExpired() int {
+ items := ListRecycle()
+ count := 0
+ for _, item := range items {
+ if item.DaysRemaining <= 0 {
+ PermanentDelete(item.Slug)
+ count++
+ }
+ }
+ return count
+}
+
+func getString(m map[string]interface{}, key string) string {
+ if v, ok := m[key]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
diff --git a/write-go/internal/rssapi/rssapi.go b/write-go/internal/rssapi/rssapi.go
new file mode 100644
index 00000000..75b14931
--- /dev/null
+++ b/write-go/internal/rssapi/rssapi.go
@@ -0,0 +1,157 @@
+package rssapi
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "write-server/internal/config"
+)
+
+type Feed struct {
+ URL string `json:"url"`
+ FeedTitle string `json:"feedTitle"`
+ SiteURL string `json:"siteUrl,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type FeedHealth struct {
+ URL string `json:"url"`
+ Status int `json:"status"`
+ OK bool `json:"ok"`
+ Latency int `json:"latency,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type Link struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Description string `json:"description,omitempty"`
+ Image string `json:"image,omitempty"`
+ Hidden bool `json:"hidden,omitempty"`
+ RSS string `json:"rss,omitempty"`
+ AddedAt string `json:"addedAt,omitempty"`
+}
+
+func rssFetch(path string, method string, body interface{}) (map[string]interface{}, error) {
+ u := config.RSS_API_BASE + path
+ var req *http.Request
+ var err error
+
+ if body != nil && (method == "POST" || method == "PUT") {
+ data, _ := json.Marshal(body)
+ req, err = http.NewRequest(method, u, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ } else {
+ req, err = http.NewRequest(method, u, nil)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if config.RSS_API_TOKEN != "" {
+ q := req.URL.Query()
+ q.Set("token", config.RSS_API_TOKEN)
+ req.URL.RawQuery = q.Encode()
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ 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, fmt.Errorf("RSS API error: %d", resp.StatusCode)
+ }
+ return result, nil
+}
+
+func GetFeeds() ([]Feed, error) {
+ result, err := rssFetch("/api/rss/feeds", "GET", nil)
+ if err != nil {
+ return nil, err
+ }
+ var feeds []Feed
+ if raw, ok := result["feeds"]; ok {
+ data, _ := json.Marshal(raw)
+ json.Unmarshal(data, &feeds)
+ }
+ return feeds, nil
+}
+
+func AddFeed(feedURL, feedTitle string) error {
+ _, err := rssFetch("/api/rss/feeds", "POST", map[string]string{
+ "url": feedURL,
+ "feedTitle": feedTitle,
+ })
+ return err
+}
+
+func DeleteFeed(feedURL string) error {
+ _, err := rssFetch("/api/rss/feeds?url="+url.QueryEscape(feedURL), "DELETE", nil)
+ return err
+}
+
+func UpdateFeed(oldURL, newURL, feedTitle string) error {
+ _, err := rssFetch("/api/rss/feeds", "PUT", map[string]string{
+ "oldUrl": oldURL,
+ "url": newURL,
+ "feedTitle": feedTitle,
+ })
+ return err
+}
+
+func GetLinks(all bool) ([]Link, error) {
+ path := "/api/rss/links"
+ if all {
+ path += "?all=1"
+ }
+ result, err := rssFetch(path, "GET", nil)
+ if err != nil {
+ return nil, err
+ }
+ var links []Link
+ if raw, ok := result["links"]; ok {
+ data, _ := json.Marshal(raw)
+ json.Unmarshal(data, &links)
+ }
+ return links, nil
+}
+
+func AddLink(link Link) error {
+ _, err := rssFetch("/api/rss/links", "POST", link)
+ return err
+}
+
+func UpdateLink(oldURL string, data map[string]interface{}) error {
+ body := map[string]interface{}{"oldUrl": oldURL}
+ for k, v := range data {
+ if k != "oldUrl" {
+ body[k] = v
+ }
+ }
+ _, err := rssFetch("/api/rss/links", "PUT", body)
+ return err
+}
+
+func DeleteLink(linkURL string) error {
+ _, err := rssFetch("/api/rss/links?url="+url.QueryEscape(linkURL), "DELETE", nil)
+ return err
+}
+
+func CheckLinkHealth(targetURL string) (map[string]interface{}, error) {
+ return rssFetch("/api/rss/links?health="+url.QueryEscape(targetURL), "GET", nil)
+}
diff --git a/write-go/internal/sessions/sessions.go b/write-go/internal/sessions/sessions.go
new file mode 100644
index 00000000..715b3afd
--- /dev/null
+++ b/write-go/internal/sessions/sessions.go
@@ -0,0 +1,71 @@
+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/write-go/internal/tgcore/tgcore.go b/write-go/internal/tgcore/tgcore.go
new file mode 100644
index 00000000..a9946dc9
--- /dev/null
+++ b/write-go/internal/tgcore/tgcore.go
@@ -0,0 +1,145 @@
+package tgcore
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+)
+
+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 ""
+}
diff --git a/write-go/internal/tgcore/tgcore/tgcore.go b/write-go/internal/tgcore/tgcore/tgcore.go
new file mode 100644
index 00000000..a9946dc9
--- /dev/null
+++ b/write-go/internal/tgcore/tgcore/tgcore.go
@@ -0,0 +1,145 @@
+package tgcore
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+)
+
+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 ""
+}
diff --git a/write-go/internal/tghandlers/tghandlers.go b/write-go/internal/tghandlers/tghandlers.go
new file mode 100644
index 00000000..2694926c
--- /dev/null
+++ b/write-go/internal/tghandlers/tghandlers.go
@@ -0,0 +1,622 @@
+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/write-go/internal/wechat/wechat.go b/write-go/internal/wechat/wechat.go
new file mode 100644
index 00000000..a7ce686c
--- /dev/null
+++ b/write-go/internal/wechat/wechat.go
@@ -0,0 +1,80 @@
+package wechat
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "write-server/internal/config"
+)
+
+type WechatStatus struct {
+ Configured bool `json:"configured"`
+ AppID string `json:"appId,omitempty"`
+}
+
+type WechatArticle struct {
+ Title string `json:"title"`
+ Author string `json:"author"`
+ Content string `json:"content"`
+ ContentSourceURL string `json:"content_source_url,omitempty"`
+ ThumbMediaURL string `json:"thumb_media_url,omitempty"`
+ ShowCoverPic int `json:"show_cover_pic"`
+ NeedOpenComment int `json:"need_open_comment"`
+}
+
+var imgRE = regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
+
+func GetWechatStatus() WechatStatus {
+ return WechatStatus{
+ Configured: config.WECHAT_APP_ID != "" && config.WECHAT_APP_SECRET != "",
+ AppID: config.WECHAT_APP_ID,
+ }
+}
+
+func MDRender(markdown, slug string) (string, []string) {
+ var imageURLs []string
+ matches := imgRE.FindAllStringSubmatch(markdown, -1)
+ for _, m := range matches {
+ if len(m) > 1 {
+ imageURLs = append(imageURLs, m[1])
+ }
+ }
+
+ html := strings.ReplaceAll(markdown, "\r\n", "\n")
+
+ h2RE := regexp.MustCompile(`(?m)^## (.+)$`)
+ html = h2RE.ReplaceAllString(html, `$1
`)
+
+ boldRE := regexp.MustCompile(`\*\*(.+?)\*\*`)
+ html = boldRE.ReplaceAllString(html, `$1`)
+
+ html = imgRE.ReplaceAllString(html, `
`)
+
+ linkRE := regexp.MustCompile(`\[(.+?)\]\((.+?)\)`)
+ html = linkRE.ReplaceAllString(html, `$1`)
+
+ html = `` + html + `
`
+
+ return html, imageURLs
+}
+
+func SyncWechat(articles []WechatArticle) (map[string]interface{}, error) {
+ if config.SCF_PROXY == "" {
+ return nil, fmt.Errorf("SCF_PROXY not configured")
+ }
+ data, _ := json.Marshal(map[string]interface{}{"articles": articles})
+ resp, err := http.Post(config.SCF_PROXY+"/wechat/sync", "application/json", bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ var result map[string]interface{}
+ json.Unmarshal(body, &result)
+ return result, nil
+}
\ No newline at end of file
diff --git a/write-go/internal/writeserver/writeserver.go b/write-go/internal/writeserver/writeserver.go
new file mode 100644
index 00000000..2fb8aa51
--- /dev/null
+++ b/write-go/internal/writeserver/writeserver.go
@@ -0,0 +1,156 @@
+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/write-go/out.log b/write-go/out.log
new file mode 100644
index 00000000..f8cb23ce
--- /dev/null
+++ b/write-go/out.log
@@ -0,0 +1,2 @@
+time=2026-06-01T16:58:10.099+08:00 level=INFO msg="WriteServer starting" svc=writeserver
+[write-server] listening on 0.0.0.0:8016
diff --git a/write-go/panic.log b/write-go/panic.log
new file mode 100644
index 00000000..95765091
--- /dev/null
+++ b/write-go/panic.log
@@ -0,0 +1 @@
+2026/06/01 16:58:10 listen tcp 0.0.0.0:8016: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.
diff --git a/write-go/pkg/posts/posts.go b/write-go/pkg/posts/posts.go
new file mode 100644
index 00000000..dcb0f0d4
--- /dev/null
+++ b/write-go/pkg/posts/posts.go
@@ -0,0 +1,18 @@
+package posts
+
+import "write-server/internal/posts"
+
+type FrontMatter = posts.FrontMatter
+type Post = posts.Post
+type PostListItem = posts.PostListItem
+
+var ListPosts = posts.ListPosts
+var GetPost = posts.GetPost
+var CreatePost = posts.CreatePost
+var UpdatePost = posts.UpdatePost
+var GetPostCount = posts.GetPostCount
+var ParseFrontMatter = posts.ParseFrontMatter
+
+func Init() {
+ posts.Init()
+}
\ No newline at end of file
diff --git a/write-go/pkg/recycle/recycle.go b/write-go/pkg/recycle/recycle.go
new file mode 100644
index 00000000..c954d444
--- /dev/null
+++ b/write-go/pkg/recycle/recycle.go
@@ -0,0 +1,12 @@
+package recycle
+
+import "write-server/internal/recycle"
+
+var InitRecycleBin = recycle.InitRecycleBin
+var MoveToRecycle = recycle.MoveToRecycle
+var ListRecycle = recycle.ListRecycle
+var RestoreFromRecycle = recycle.RestoreFromRecycle
+var PermanentDelete = recycle.PermanentDelete
+var CleanExpired = recycle.CleanExpired
+
+type RecycleItem = recycle.RecycleItem
diff --git a/write-go/pkg/rssapi/rssapi.go b/write-go/pkg/rssapi/rssapi.go
new file mode 100644
index 00000000..a2ef4630
--- /dev/null
+++ b/write-go/pkg/rssapi/rssapi.go
@@ -0,0 +1,14 @@
+package rssapi
+
+import "write-server/internal/rssapi"
+
+type Feed = rssapi.Feed
+type Link = rssapi.Link
+
+var GetFeeds = rssapi.GetFeeds
+var AddFeed = rssapi.AddFeed
+var DeleteFeed = rssapi.DeleteFeed
+var GetLinks = rssapi.GetLinks
+var AddLink = rssapi.AddLink
+var UpdateLink = rssapi.UpdateLink
+var DeleteLink = rssapi.DeleteLink
diff --git a/write-go/start_write.bat b/write-go/start_write.bat
new file mode 100644
index 00000000..2ee874ae
--- /dev/null
+++ b/write-go/start_write.bat
@@ -0,0 +1,5 @@
+@echo off
+title WriteServer
+cd /d E:\GitHub\blog\write-go
+echo [%date% %time%] WriteServer starting...
+writeserver.exe
diff --git a/write-go/stderr.log b/write-go/stderr.log
new file mode 100644
index 00000000..13bb5af8
--- /dev/null
+++ b/write-go/stderr.log
@@ -0,0 +1,34 @@
+2026/06/01 16:55:31 http: panic serving [::1]:50574: runtime error: slice bounds out of range [:10] with length 0
+goroutine 9 [running]:
+net/http.(*conn).serve.func1()
+ C:/Program Files/Go/src/net/http/server.go:1907 +0xbd
+panic({0x7ff6dda1c5c0?, 0x66fdd19e1b0?})
+ C:/Program Files/Go/src/runtime/panic.go:860 +0x13a
+write-server/internal/posts.parseDate(...)
+ E:/GitHub/blog/write-go/internal/posts/posts.go:86
+write-server/internal/posts.ListPosts.func1(0x58?, 0x0?)
+ E:/GitHub/blog/write-go/internal/posts/posts.go:268 +0xfa
+sort.partition_func({0x66fdd19b690?, 0x66fdd320ab0?}, 0x0, 0x74, 0x0?)
+ C:/Program Files/Go/src/sort/zsortfunc.go:142 +0xa9
+sort.pdqsort_func({0x66fdd19b690?, 0x66fdd320ab0?}, 0x18?, 0x7ff6dd9c5de0?, 0x2?)
+ C:/Program Files/Go/src/sort/zsortfunc.go:114 +0x23d
+sort.Slice({0x7ff6dd9bb940?, 0x66fdd1c4030?}, 0x66fdd19b690)
+ C:/Program Files/Go/src/sort/slice.go:29 +0xc5
+write-server/internal/posts.ListPosts({0x0, 0x0}, 0x1, 0x14, 0x1)
+ E:/GitHub/blog/write-go/internal/posts/posts.go:267 +0x869
+main.handlePosts({0x7ff6dda6cc90, 0x66fdd1b4000}, 0x66fdd192000)
+ E:/GitHub/blog/write-go/cmd/writeserver/main.go:159 +0x415
+net/http.HandlerFunc.ServeHTTP(0x66fdd05a3c0?, {0x7ff6dda6cc90?, 0x66fdd1b4000?}, 0x7ff6dda4da40?)
+ C:/Program Files/Go/src/net/http/server.go:2286 +0x29
+net/http.(*ServeMux).ServeHTTP(0x66fdd1820f0?, {0x7ff6dda6cc90, 0x66fdd1b4000}, 0x66fdd192000)
+ C:/Program Files/Go/src/net/http/server.go:2828 +0x1c7
+main.main.withCORS.func1({0x7ff6dda6cc90, 0x66fdd1b4000}, 0x66fdd192000)
+ E:/GitHub/blog/write-go/cmd/writeserver/main.go:84 +0x10d
+net/http.HandlerFunc.ServeHTTP(0x7ff6dd6cced9?, {0x7ff6dda6cc90?, 0x66fdd1b4000?}, 0x66fdd19bb68?)
+ C:/Program Files/Go/src/net/http/server.go:2286 +0x29
+net/http.serverHandler.ServeHTTP({0x66fdd188000?}, {0x7ff6dda6cc90?, 0x66fdd1b4000?}, 0x1?)
+ C:/Program Files/Go/src/net/http/server.go:3311 +0x8e
+net/http.(*conn).serve(0x66fdd078360, {0x7ff6dda6d3f0, 0x66fdcfa58f0})
+ C:/Program Files/Go/src/net/http/server.go:2073 +0x650
+created by net/http.(*Server).Serve in goroutine 1
+ C:/Program Files/Go/src/net/http/server.go:3464 +0x485
diff --git a/write-go/stdout.log b/write-go/stdout.log
new file mode 100644
index 00000000..804910c6
--- /dev/null
+++ b/write-go/stdout.log
@@ -0,0 +1,3 @@
+time=2026-06-01T16:55:29.042+08:00 level=INFO msg="WriteServer starting" svc=writeserver
+[write-server] listening on 0.0.0.0:8016
+time=2026-06-01T16:55:31.055+08:00 level=INFO msg="bot-go already running" svc=writeserver port=8017
diff --git a/write-go/writeserver.exe~ b/write-go/writeserver.exe~
new file mode 100644
index 00000000..66804004
Binary files /dev/null and b/write-go/writeserver.exe~ differ
diff --git a/write-go/writeserver_known_good.exe b/write-go/writeserver_known_good.exe
new file mode 100644
index 00000000..697aa1f6
Binary files /dev/null and b/write-go/writeserver_known_good.exe differ
diff --git a/write-go/writeserver_patched.exe b/write-go/writeserver_patched.exe
new file mode 100644
index 00000000..e8ac7297
Binary files /dev/null and b/write-go/writeserver_patched.exe differ
diff --git a/write/.recycle/meta/2026-06-01-测试.json b/write/.recycle/meta/2026-06-01-测试.json
new file mode 100644
index 00000000..317fadcc
--- /dev/null
+++ b/write/.recycle/meta/2026-06-01-测试.json
@@ -0,0 +1,7 @@
+{
+ "slug": "2026-06-01-测试",
+ "title": "测试",
+ "deletedAt": "2026-06-01T06:05:06.023Z",
+ "originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-测试",
+ "expiresAt": "2026-07-01T06:05:06.023Z"
+}
\ 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..cee93401
--- /dev/null
+++ b/write/.recycle/posts/2026-06-01-测试/index.md
@@ -0,0 +1,13 @@
+---
+title: 测试
+date: '2026-06-01'
+slug: 2026-06-01-测试
+draft: false
+categories: []
+tags: []
+author: qunlin
+layout: post
+---
+测试
+
+
diff --git a/write/src/app/comments/page.tsx b/write/src/app/comments/page.tsx
index 6e1eee44..04a06ba2 100644
--- a/write/src/app/comments/page.tsx
+++ b/write/src/app/comments/page.tsx
@@ -389,8 +389,8 @@ export default function CommentsPage() {
// --- Login ---
if (!loggedIn) {
return (
-
-
+
+
@@ -430,9 +430,9 @@ export default function CommentsPage() {
// --- Main ---
return (
-
+
{/* Top bar */}
-
+
评论管理