454 lines
15 KiB
Go
454 lines
15 KiB
Go
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(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>WriteApp</title><style>body{background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}h1{font-size:24px}p{color:#94a3b8;margin-top:8px}a{color:#38bdf8}</style></head><body><div><h1>WriteApp</h1><p>Next.js frontend is starting...</p><p><a href="/admin">Open Admin Panel</a></p></div></body></html>`))
|
|
}
|
|
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(`<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
|
<title>WriteApp</title>
|
|
<style>
|
|
:root{color-scheme:dark;--bg:#111827;--card:#1f2937;--text:#f3f4f6;--muted:#9ca3af;--accent:#60a5fa;--accent-hover:#93bbfd;--line:#374151;--success:#86efac}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
|
|
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px;max-width:420px;width:100%%}
|
|
h1{font-size:18px;font-weight:700;margin-bottom:4px}
|
|
.sub{font-size:13px;color:var(--muted);margin-bottom:20px}
|
|
.row{display:flex;align-items:center;gap:10px;padding:12px 16px;background:rgba(255,255,255,0.03);border-radius:8px;margin-bottom:16px}
|
|
.dot{width:10px;height:10px;border-radius:50%%;flex-shrink:0;background:var(--success)}
|
|
.text{font-size:14px;font-weight:500}
|
|
.ip{font-size:12px;color:var(--muted);margin-top:2px}
|
|
.actions{display:flex;gap:8px;flex-wrap:wrap}
|
|
.btn{padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all 0.15s;color:#fff;text-decoration:none;display:inline-block}
|
|
.btn-primary{background:var(--accent)}
|
|
.btn-primary:hover{background:var(--accent-hover)}
|
|
.btn-secondary{background:transparent;border:1px solid var(--line);color:var(--text)}
|
|
.btn-secondary:hover{background:rgba(255,255,255,0.05)}
|
|
.link-row{margin-top:16px;padding:10px 14px;background:rgba(255,255,255,0.03);border-radius:8px;font-size:13px;color:var(--muted);word-break:break-all}
|
|
.link-row a{color:var(--accent);text-decoration:none}
|
|
.footer{margin-top:20px;font-size:11px;color:var(--muted);text-align:center}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>WriteApp</h1>
|
|
<p class="sub">Blog CMS + Telegram Bot</p>
|
|
<div class="row"><div class="dot"></div><div><div class="text">Server Running</div><div class="ip">Port %d · LAN: %s</div></div></div>
|
|
<div class="actions">
|
|
<a class="btn btn-primary" href="/">Open Editor</a>
|
|
<a class="btn btn-secondary" href="/api/stats" target="_blank">API Stats</a>
|
|
</div>
|
|
<div class="link-row">Frontend: <a href="/">/</a></div>
|
|
<div class="link-row">API: <a href="/api/posts">/api/posts</a> · <a href="/api/stats">/api/stats</a></div>
|
|
<div class="footer">Go Edition · Single Process</div>
|
|
</div>
|
|
</body>
|
|
</html>`, a.cfg.WritePort, helpers.GetLanIP(a.cfg.PreferIface))
|
|
}
|