snapshot before merge
This commit is contained in:
1 parent
06a5f3da4d
commit
671871e0b2
145 files changed
+9786
-59
No files matched your search
@@ -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 = `<!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>WriteBot</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#111827;--card:#1f2937;--text:#f3f4f6;--muted:#9ca3af;--accent:#60a5fa;--accent-hover:#93bbfd;--danger:#fca5a5;--success:#86efac;--line:#374151}
|
||||
*{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:6px}
|
||||
.sub{font-size:13px;color:var(--muted);margin-bottom:20px}
|
||||
.status-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}
|
||||
.dot.running{background:var(--success)}
|
||||
.dot.starting{background:#fbbf24}
|
||||
.dot.stopped{background:var(--muted)}
|
||||
.status-text{font-size:14px;font-weight:500}
|
||||
.status-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}
|
||||
.btn-primary{background:var(--accent)}
|
||||
.btn-primary:hover{background:var(--accent-hover)}
|
||||
.btn-primary:disabled{opacity:0.4;cursor:not-allowed}
|
||||
.btn-secondary{background:transparent;border:1px solid var(--line);color:var(--text)}
|
||||
.btn-secondary:hover{background:rgba(255,255,255,0.05)}
|
||||
.btn-danger{background:transparent;border:1px solid var(--danger);color:var(--danger)}
|
||||
.btn-danger:hover{background:rgba(252,165,165,0.1)}
|
||||
.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}
|
||||
.quick-links{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.ql-btn{padding:6px 14px;border-radius:6px;background:rgba(255,255,255,0.05);border:1px solid var(--line);color:var(--accent);text-decoration:none;font-size:13px;font-weight:500;transition:all 0.15s}
|
||||
.ql-btn:hover{background:rgba(255,255,255,0.1)}
|
||||
.footer{margin-top:20px;font-size:11px;color:var(--muted);text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>WriteBot</h1>
|
||||
<div class="sub">Ctrl+D bookmark this page</div>
|
||||
<div class="quick-links">
|
||||
<a href="http://localhost:1313/posts/" class="ql-btn" target="_blank">Posts</a>
|
||||
<a href="http://localhost:WRITE_PORT/" class="ql-btn" target="_blank">Write</a>
|
||||
</div>
|
||||
<div class="sub" id="sub">Loading...</div>
|
||||
<div class="status-row">
|
||||
<div class="dot" id="dot"></div>
|
||||
<div>
|
||||
<div class="status-text" id="statusText">-</div>
|
||||
<div class="status-ip" id="statusIp"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="btnStart" onclick="action('start')">Start Write</button>
|
||||
<button class="btn btn-secondary" id="btnOpen" onclick="openWrite()">Open</button>
|
||||
<button class="btn btn-danger" id="btnStop" onclick="action('stop')">Stop</button>
|
||||
</div>
|
||||
<div class="link-row" id="linkRow" style="display:none">
|
||||
Copy: <a href="#" id="lanLink" onclick="copyLink(event)"></a>
|
||||
</div>
|
||||
<div class="footer"><a href="https://usj.cc" style="color:var(--muted)">usj.cc</a></div>
|
||||
</div>
|
||||
<script>
|
||||
var port=WRITE_PORT;
|
||||
var ipCache="";
|
||||
function updateUI(s){var d=document.getElementById("dot"),st=document.getElementById("statusText"),si=document.getElementById("statusIp"),bs=document.getElementById("btnStart"),bo=document.getElementById("btnOpen"),lr=document.getElementById("linkRow"),ll=document.getElementById("lanLink");d.className="dot";if(s.running){d.classList.add("running");st.textContent="Running";if(s.ip)ipCache=s.ip;si.textContent="http://"+ipCache+":"+s.port;bs.disabled=true;bo.disabled=false;lr.style.display="block";ll.href="http://"+ipCache+":"+s.port;ll.textContent="http://"+ipCache+":"+s.port;document.getElementById("sub").textContent="Write is serving at"}else if(s.starting){d.classList.add("starting");st.textContent="Starting...";si.textContent="";bs.disabled=true;bo.disabled=true;lr.style.display="none";document.getElementById("sub").textContent="Please wait..."}else{d.classList.add("stopped");st.textContent="Stopped";si.textContent="";bs.disabled=false;bo.disabled=true;lr.style.display="none";document.getElementById("sub").textContent="Click Start to launch Write"}}
|
||||
async function fetchStatus(){try{var r=await fetch("/status");var s=await r.json();updateUI(s)}catch(e){document.getElementById("sub").textContent="Cannot reach API"}}
|
||||
async function action(a){document.getElementById("sub").textContent="Working...";await fetch("/"+a,{method:"POST"});fetchStatus()}
|
||||
function openWrite(){window.open("http://"+(ipCache||"localhost:WRITE_PORT"))}
|
||||
function copyLink(e){e.preventDefault();navigator.clipboard.writeText(document.getElementById("lanLink").textContent).catch(function(){})}
|
||||
fetchStatus();
|
||||
setInterval(fetchStatus,5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(`<!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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("📝 <b>%s</b>", 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, " · ")
|
||||
}
|
||||
@@ -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("📝 <b>%s</b>", 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, " · ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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("📋 <b>文章列表</b> 共%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 <code>/edit_%s</code> %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("✅ <b>Write 已就绪</b>\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{"<b>Write 状态</b>: " + 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": "<b>🤖 WriteBot 命令</b>\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("📝 <b>%s</b>\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("✏️ <b>%s</b>\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, "🗑 <b>"+p.FrontMatter.Title+"</b> 已移入回收站(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 <b>%s</b>\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, "✅ 标题已更新:<b>"+arg+"</b>", 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, "✅ 分类:<b>"+strings.Join(s.Categories, "、")+"</b>", 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, "✅ 标签:<b>"+strings.Join(s.Tags, "、")+"</b>", 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, "✅ 作者:<b>"+arg+"</b>", 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, "🚀 <b>"+s.Title+"</b> 已发布!", 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, "📄 <b>"+s.Title+"</b> 已存为草稿。", 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("🔗 <b>友链</b> 共%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("📡 <b>订阅源</b> 共%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("📊 <b>网站数据</b>\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)
|
||||
}
|
||||
}
|
||||
@@ -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, `<h2>$1</h2>`)
|
||||
|
||||
boldRE := regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||
html = boldRE.ReplaceAllString(html, `<strong>$1</strong>`)
|
||||
|
||||
html = imgRE.ReplaceAllString(html, `<img src="$1" />`)
|
||||
|
||||
linkRE := regexp.MustCompile(`\[(.+?)\]\((.+?)\)`)
|
||||
html = linkRE.ReplaceAllString(html, `<a href="$2">$1</a>`)
|
||||
|
||||
html = `<div>` + html + `</div>`
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user