snapshot before merge

This commit is contained in:
zqlit committed 2026-06-01 17:57:22 +08:00
1 parent 2b3b2850f9
commit 21fc65b3ae
145 files changed
+9786 -59

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
writebot.exe
.env
+7
View File
@@ -0,0 +1,7 @@
p = r"E:\GitHub\blog\bot-go\cmd\writebot\main.go"
with open(p, "r", encoding="utf-8") as f:
c = f.read()
c = c.replace('"net/http"\n', '')
with open(p, "w", encoding="utf-8", newline="\n") as f:
f.write(c)
print("fixed")
+14
View File
@@ -0,0 +1,14 @@
p = r"E:\GitHub\blog\bot-go\cmd\writebot\main.go"
with open(p, "r", encoding="utf-8") as f:
c = f.read()
# Replace logutil.Init + log = logutil.Logger with inline
c = c.replace(
'logutil.Init("writebot")\n\tlog = logutil.Logger',
'log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writebot")'
)
c = c.replace('\t"writebot/internal/logutil"\n', '')
with open(p, "w", encoding="utf-8", newline="\n") as f:
f.write(c)
print("Fixed bot-go inline slog")
+11
View File
@@ -0,0 +1,11 @@
p = r"E:\GitHub\blog\bot-go\cmd\writebot\main.go"
with open(p, "r", encoding="utf-8") as f:
c = f.read()
# Fix: move log usage after logutil.Init
c = c.replace("logutil.Init(\"writebot\")\n\tlog = logutil.Logger\n\n\tcfg := config.Load()",
"cfg := config.Load()\n\tlogutil.Init(\"writebot\")\n\tlog = logutil.Logger")
with open(p, "w", encoding="utf-8", newline="\n") as f:
f.write(c)
print("Fixed bot-go logger init order")
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"writebot/internal/botcfg"
"writebot/internal/sessions"
"writebot/internal/tgcore"
"writebot/internal/tghandlers"
"write-server/pkg/posts"
)
var log *slog.Logger
func main() {
cfg := botcfg.Load()
posts.Init()
tgcore.Init(cfg.TGToken)
log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writebot")
log.Info("WriteBot starting")
if cfg.TGToken == "" {
log.Error("no TG_BOT_TOKEN configured")
os.Exit(1)
}
// Register bot
me, err := tgcore.TG("getMe", map[string]interface{}{})
if err == nil {
if ok, _ := me["ok"].(bool); ok {
if result, ok := me["result"].(map[string]interface{}); ok {
log.Info("telegram connected", "username", tgcore.GetString(result, "username"))
}
}
}
tgcore.TG("setMyCommands", map[string]interface{}{
"commands": []map[string]string{
{"command": "start", "description": "启动"},
{"command": "new", "description": "写新文章"},
{"command": "list", "description": "文章列表"},
{"command": "edit", "description": "编辑文章"},
{"command": "save", "description": "保存草稿"},
{"command": "publish", "description": "发布到 GitHub"},
{"command": "stats", "description": "统计"},
{"command": "help", "description": "帮助"},
},
})
h := tghandlers.New(&tghandlers.Config{
WritePort: cfg.WritePort,
ControlPort: cfg.ControlPort,
PreferIface: cfg.PreferIface,
})
argCommands := map[string]bool{
"new": true, "title": true, "categories": true, "tags": true, "author": true,
"edit": true, "view": true, "delete": true,
"link_add": true, "link_del": true, "link_toggle": true,
"feed_add": true, "feed_del": true,
"list": true, "links": true, "feeds": true,
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
var lastOffset int64
for {
data, err := tgcore.TG("getUpdates", map[string]interface{}{
"offset": lastOffset + 1, "timeout": 30,
"allowed_updates": []string{"message", "callback_query"},
})
if err != nil {
log.Warn("tg poll error", "err", err)
time.Sleep(3 * time.Second)
continue
}
ok, _ := data["ok"].(bool)
if !ok { continue }
results, _ := data["result"].([]interface{})
for _, updateRaw := range results {
update, _ := updateRaw.(map[string]interface{})
if updateID, ok := update["update_id"].(float64); ok { lastOffset = int64(updateID) }
if cbRaw, ok := update["callback_query"]; ok {
cb, _ := cbRaw.(map[string]interface{})
cbMsg, _ := cb["message"].(map[string]interface{})
cbChat, _ := cbMsg["chat"].(map[string]interface{})
cbFrom, _ := cb["from"].(map[string]interface{})
var cbChatID int64
if cbChat != nil { cbChatID = tgcore.GetInt64(cbChat, "id") }
if cbChatID == 0 && cbFrom != nil { cbChatID = tgcore.GetInt64(cbFrom, "id") }
queryID := tgcore.GetString(cb, "id")
cbData := tgcore.GetString(cb, "data")
tgcore.HandleCallback(cbChatID, queryID, cbData, h)
continue
}
msg, _ := update["message"].(map[string]interface{})
if msg == nil { continue }
chat, _ := msg["chat"].(map[string]interface{})
if chat == nil { continue }
chatID := tgcore.GetInt64(chat, "id")
if !tgcore.Allowed(chatID, cfg.AllowedIDs) { continue }
if photos, ok := msg["photo"]; ok {
photoArr, _ := photos.([]interface{})
if len(photoArr) > 0 && sessions.HasSession(chatID) {
s := sessions.GetSession(chatID)
largest := photoArr[len(photoArr)-1].(map[string]interface{})
fileID := tgcore.GetString(largest, "file_id")
fileRes, err := tgcore.TG("getFile", map[string]interface{}{"file_id": fileID})
if err == nil && fileRes["ok"] == true {
if fileResult, ok := fileRes["result"].(map[string]interface{}); ok {
filePath := tgcore.GetString(fileResult, "file_path")
caption := tgcore.GetString(msg, "caption")
if caption == "" { caption = "image" }
imgURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", cfg.TGToken, filePath)
s.Content += "
![" + caption + "](" + imgURL + ")
"
}
}
}
continue
}
text := tgcore.GetString(msg, "text")
if text == "" { continue }
fullText := strings.TrimSpace(text)
if idx := strings.Index(fullText, "@"); idx >= 0 {
afterAt := fullText[idx:]
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 { afterAt = afterAt[spaceIdx:] } else { afterAt = "" }
fullText = strings.TrimSpace(fullText[:idx] + afterAt)
}
parts := strings.SplitN(fullText, " ", 2)
slashCmd := ""; slashArg := ""
if strings.HasPrefix(parts[0], "/") {
slashCmd = strings.ToLower(parts[0][1:])
if len(parts) > 1 { slashArg = parts[1] }
}
if sessions.HasSession(chatID) && !strings.HasPrefix(fullText, "/") {
s := sessions.GetSession(chatID)
s.Content += fullText + "
"
continue
}
if slashCmd == "" { continue }
log.Info("tg command", "chat", chatID, "cmd", slashCmd)
handleCmd(h, chatID, slashCmd, slashArg, argCommands)
}
}
}()
sig := <-sigCh
log.Info("shutting down", "signal", sig.String())
}
func handleCmd(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) {
defer func() { if r := recover(); r != nil { log.Error("handler panic", "cmd", cmd, "panic", r) } }()
if argCmds[cmd] {
switch cmd {
case "new": h.New(chatID, arg)
case "title": h.Title(chatID, arg)
case "categories": h.Categories(chatID, arg)
case "tags": h.Tags(chatID, arg)
case "author": h.Author(chatID, arg)
case "edit": h.Edit(chatID, arg)
case "view": h.View(chatID, arg)
case "delete": h.Delete(chatID, arg)
case "link_add": h.LinkAdd(chatID, arg)
case "link_del": h.LinkDel(chatID, arg)
case "link_toggle": h.LinkToggle(chatID, arg)
case "feed_add": h.FeedAdd(chatID, arg)
case "feed_del": h.FeedDel(chatID, arg)
case "list": h.List(chatID, arg)
case "links": h.Links(chatID, arg)
case "feeds": h.Feeds(chatID, arg)
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
}
} else {
switch cmd {
case "start": h.Start(chatID)
case "help": h.Help(chatID)
case "save": h.Save(chatID)
case "publish": h.Publish(chatID)
case "draft": h.Draft(chatID)
case "preview": h.Preview(chatID)
case "cancel": h.Cancel(chatID)
case "stats": h.Stats(chatID)
case "status": h.Status(chatID)
case "ip": h.IP(chatID)
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
TGToken string
AllowedIDs []int64
WritePort int
ControlPort int
PreferIface string
WriteDir string
BlogDir string // parent of WriteDir, for hugo
}
func LoadConfig() *Config {
c := &Config{
WritePort: 8016,
ControlPort: 8017,
PreferIface: "WLAN",
}
// Try .env next to executable first, then fallback to cwd
exe, _ := os.Executable()
envPath := filepath.Join(filepath.Dir(exe), ".env")
if _, err := os.Stat(envPath); os.IsNotExist(err) {
envPath = ".env"
}
f, err := os.Open(envPath)
if err != nil {
return c
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
switch key {
case "TG_BOT_TOKEN":
c.TGToken = val
case "TG_ALLOWED_CHAT_IDS":
for _, s := range strings.Split(val, ",") {
s = strings.TrimSpace(s)
if id, err := strconv.ParseInt(s, 10, 64); err == nil {
c.AllowedIDs = append(c.AllowedIDs, id)
}
}
case "WRITE_PORT":
if p, err := strconv.Atoi(val); err == nil {
c.WritePort = p
}
case "BOT_PORT":
if p, err := strconv.Atoi(val); err == nil {
c.ControlPort = p
}
case "PREFER_IFACE":
c.PreferIface = val
}
}
// WriteDir and BlogDir derived from executable location
exeDir := filepath.Dir(exe)
writeDir := filepath.Join(exeDir, "..", "write")
blogDir := filepath.Join(exeDir, "..")
if abs, err := filepath.Abs(writeDir); err == nil {
c.WriteDir = abs
}
if abs, err := filepath.Abs(blogDir); err == nil {
c.BlogDir = abs
}
return c
}
+3
View File
@@ -0,0 +1,3 @@
module writebot
go 1.26.3
+158
View File
@@ -0,0 +1,158 @@
package adminpanel
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"writebot/internal/helpers"
"writebot/internal/writeserver"
)
var (
writePort int
controlPort int
)
func Init(wp, cp int) {
writePort = wp
controlPort = cp
}
const adminHTML = `<!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(&#39;start&#39;)">Start Write</button>
<button class="btn btn-secondary" id="btnOpen" onclick="openWrite()">Open</button>
<button class="btn btn-danger" id="btnStop" onclick="action(&#39;stop&#39;)">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
}
+72
View File
@@ -0,0 +1,72 @@
package botcfg
import (
"bufio"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
type Config struct {
TGToken string
AllowedIDs []int64
WritePort int
ControlPort int
PreferIface string
}
func Load() *Config {
cfg := &Config{
WritePort: 8016,
ControlPort: 8017,
PreferIface: "WLAN",
}
_, filename, _, _ := runtime.Caller(0)
srcDir := filepath.Dir(filename)
rootDir := filepath.Dir(filepath.Dir(filepath.Dir(srcDir)))
envPaths := []string{
filepath.Join(rootDir, "bot-go", ".env"),
filepath.Join(rootDir, ".env"),
}
var f *os.File
var err error
for _, envPath := range envPaths {
f, err = os.Open(envPath)
if err == nil { break }
}
if err != nil { return cfg }
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") { continue }
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 { continue }
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if os.Getenv(key) == "" { os.Setenv(key, val) }
}
cfg.TGToken = os.Getenv("TG_BOT_TOKEN")
allowedStr := os.Getenv("TG_ALLOWED_CHAT_IDS")
if allowedStr != "" {
for _, s := range strings.Split(allowedStr, ",") {
s = strings.TrimSpace(s)
if id, err := strconv.ParseInt(s, 10, 64); err == nil { cfg.AllowedIDs = append(cfg.AllowedIDs, id) }
}
}
if p := os.Getenv("WRITE_PORT"); p != "" {
if port, err := strconv.Atoi(p); err == nil { cfg.WritePort = port }
}
if p := os.Getenv("BOT_PORT"); p != "" {
if port, err := strconv.Atoi(p); err == nil { cfg.ControlPort = port }
}
if p := os.Getenv("PREFER_IFACE"); p != "" { cfg.PreferIface = p }
return cfg
}
+99
View File
@@ -0,0 +1,99 @@
package config
import (
"bufio"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
type Config struct {
TGToken string
AllowedIDs []int64
WritePort int
ControlPort int
PreferIface string
WriteDir string
}
func Load() *Config {
cfg := &Config{
WritePort: 8016,
ControlPort: 8017,
PreferIface: "WLAN",
}
_, filename, _, _ := runtime.Caller(0)
srcDir := filepath.Dir(filename)
rootDir := filepath.Dir(filepath.Dir(filepath.Dir(srcDir)))
// Try multiple .env locations: bot-go/.env first, then blog/.env
envPaths := []string{
filepath.Join(rootDir, "bot-go", ".env"),
filepath.Join(rootDir, ".env"),
filepath.Join(filepath.Dir(rootDir), "bot", ".env"),
}
var f *os.File
var err error
for _, envPath := range envPaths {
f, err = os.Open(envPath)
if err == nil {
break
}
}
if err != nil {
return cfg
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if existing := os.Getenv(key); existing != "" {
continue
}
os.Setenv(key, val)
}
cfg.TGToken = os.Getenv("TG_BOT_TOKEN")
allowedStr := os.Getenv("TG_ALLOWED_CHAT_IDS")
if allowedStr != "" {
for _, s := range strings.Split(allowedStr, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if id, err := strconv.ParseInt(s, 10, 64); err == nil {
cfg.AllowedIDs = append(cfg.AllowedIDs, id)
}
}
}
if p := os.Getenv("WRITE_PORT"); p != "" {
if port, err := strconv.Atoi(p); err == nil {
cfg.WritePort = port
}
}
if p := os.Getenv("BOT_PORT"); p != "" {
if port, err := strconv.Atoi(p); err == nil {
cfg.ControlPort = port
}
}
if p := os.Getenv("PREFER_IFACE"); p != "" {
cfg.PreferIface = p
}
exe, _ := os.Executable(); exeDir := filepath.Dir(exe); cfg.WriteDir = filepath.Join(filepath.Dir(exeDir), "write-go"); if _, err := os.Stat(cfg.WriteDir); os.IsNotExist(err) { cfg.WriteDir = filepath.Join(filepath.Dir(rootDir), "write-go") }
return cfg
}
+159
View File
@@ -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, " · ")
}
+14
View File
@@ -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)
}
+71
View File
@@ -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
}
+301
View File
@@ -0,0 +1,301 @@
package tgcore
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
var API string
func Init(token string) {
API = "https://api.telegram.org/bot" + token
}
func TG(method string, body interface{}) (map[string]interface{}, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
resp, err := http.Post(API+"/"+method, "application/json", bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
func Allowed(chatID int64, allowedIDs []int64) bool {
if len(allowedIDs) == 0 {
return true
}
for _, id := range allowedIDs {
if id == chatID {
return true
}
}
return false
}
func SendMessage(chatID int64, text string, opts map[string]interface{}) error {
safeText := text
body := map[string]interface{}{
"chat_id": chatID,
"text": safeText,
"parse_mode": "HTML",
"disable_web_page_preview": true,
}
if opts != nil {
for k, v := range opts {
body[k] = v
}
}
_, err := TG("sendMessage", body)
if err != nil && (strings.Contains(err.Error(), "parse") || strings.Contains(err.Error(), "HTML")) {
delete(body, "parse_mode")
_, err = TG("sendMessage", body)
}
return err
}
// TgHandler is the callback handler interface
type TgHandler interface {
List(chatID int64, page string)
Links(chatID int64, page string)
Feeds(chatID int64, page string)
// Session callbacks
SessionCallback(chatID int64, action string)
MenuCallback(chatID int64, action string)
}
func HandleCallback(chatID int64, queryID string, data string, h TgHandler) {
if data == "" {
return
}
// Session callbacks
if strings.HasPrefix(data, "session_") {
action := strings.TrimPrefix(data, "session_")
if h != nil {
h.SessionCallback(chatID, action)
}
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
return
}
// Menu callbacks
if strings.HasPrefix(data, "menu_") {
action := strings.TrimPrefix(data, "menu_")
if h != nil {
h.MenuCallback(chatID, action)
}
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
return
}
// Pagination callbacks: list_N, links_N, feeds_N
parts := strings.SplitN(data, "_", 2)
if len(parts) == 2 {
cmd := parts[0]
page := parts[1]
if h != nil {
switch cmd {
case "list":
h.List(chatID, page)
case "links":
h.Links(chatID, page)
case "feeds":
h.Feeds(chatID, page)
}
}
}
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
}
func GetInt64(m map[string]interface{}, key string) int64 {
if v, ok := m[key]; ok {
switch n := v.(type) {
case float64:
return int64(n)
case int64:
return n
}
}
return 0
}
func GetString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// === Inlined from helpers ===
var lanIPCache struct {
ip string
ts time.Time
}
func GetLanIP(preferIface string) string {
if lanIPCache.ip != "" && time.Since(lanIPCache.ts) < 30*time.Second {
return lanIPCache.ip
}
ip := "0.0.0.0"
ifaces, err := net.Interfaces()
if err != nil {
lanIPCache.ip = ip
lanIPCache.ts = time.Now()
return ip
}
type candidate struct {
name string
address string
}
var candidates []candidate
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil {
continue
}
if ip4.IsLoopback() {
continue
}
ipStr := ip4.String()
if strings.HasPrefix(ipStr, "169.254.") {
continue
}
candidates = append(candidates, candidate{name: iface.Name, address: ipStr})
}
}
prefLower := strings.ToLower(preferIface)
for _, c := range candidates {
if strings.Contains(strings.ToLower(c.name), prefLower) {
ip = c.address
lanIPCache.ip = ip
lanIPCache.ts = time.Now()
return ip
}
}
for _, c := range candidates {
n := strings.ToLower(c.name)
if !strings.Contains(n, "vmware") && !strings.Contains(n, "virtual") && !strings.Contains(n, "vethernet") {
ip = c.address
lanIPCache.ip = ip
lanIPCache.ts = time.Now()
return ip
}
}
if len(candidates) > 0 {
ip = candidates[0].address
}
lanIPCache.ip = ip
lanIPCache.ts = time.Now()
return ip
}
func CheckPort(port int) bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 1500*time.Millisecond)
if err != nil {
return false
}
conn.Close()
return true
}
func WaitForReady(port int, timeoutMs int) bool {
if timeoutMs <= 0 {
timeoutMs = 30000
}
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
for time.Now().Before(deadline) {
if CheckPort(port) {
return true
}
time.Sleep(1500 * time.Millisecond)
}
return false
}
func MakeSlug(title string) string {
dateStr := time.Now().Format("2006-01-02")
slugPart := title
// Replace non-word, non-CJK chars with -
slugPart = strings.Map(func(r rune) rune {
if (r >= 0x4e00 && r <= 0x9fff) || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return '-'
}, slugPart)
slugPart = strings.Trim(slugPart, "-")
slugPart = strings.ToLower(slugPart)
if slugPart == "" {
slugPart = "post"
}
return dateStr + "-" + slugPart
}
func SessionSummary(title string, categories, tags []string, content string) string {
lines := 0
chars := len([]rune(content))
for _, ch := range content {
if ch == '\n' {
lines++
}
}
if content != "" {
lines++
}
displayTitle := title
if displayTitle == "" {
displayTitle = "未命名"
}
parts := []string{
fmt.Sprintf("📝 <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, " · ")
}
+622
View File
@@ -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)
}
}
+156
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
title WriteBot
cd /d E:\GitHub\blog\bot-go
echo [%date% %time%] WriteBot starting...
writebot.exe
Binary file not shown.