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

WriteApp

Next.js frontend is starting...

Open Admin Panel

`)) } 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![%s](%s)\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
Open Editor API Stats
`, a.cfg.WritePort, helpers.GetLanIP(a.cfg.PreferIface)) }