6.2
This commit is contained in:
1 parent
03e6b62bd8
commit
99b9234571
60 files changed
+3184
-2129
No files matched your search
Generated
-5
@@ -1,5 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/blog.iml" filepath="$PROJECT_DIR$/.idea/blog.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1 +0,0 @@
|
||||
[{"id":"10338","rid":"0","content":"我还以为是你自己的主题,哈哈 ::bilibili:re_daka:: ","ua":"Mozilla/5.0 (Linux; Android 11; M2006J10C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.66 Mobile Safari/537.36","ip":"122.192.15.110","is_collapsed":"false","is_pending":"false","is_pinned":"false","vote_up":"0","vote_down":"0","created_at":"2021-09-01 19:51:25 +0800 +0800","updated_at":"2026-01-20 17:00:53.979411543 +0800 +0800","nick":"北笙","email":"3049333936@qq.com","link":"","badge_name":"","badge_color":"","page_key":"/2.html","page_title":"Twitter主题加入加载耗时,访问总量功能","page_admin_only":"false","site_name":"优世界","site_urls":"https://usj.cc"},{"id":"10339","rid":"10338","content":"哈哈,没那个本事,用的油油作者的 ::face:hanxiao:: ","ua":"Mozilla/5.0 (Linux; Android 10; HarmonyOS; YAL-AL00; HMSCore 6.0.1.306; GMSCore 20.15.16) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.93 HuaweiBrowser/11.1.4.301 Mobile Safari/537.36","ip":"27.29.159.138","is_collapsed":"false","is_pending":"false","is_pinned":"false","vote_up":"0","vote_down":"0","created_at":"2021-09-01 19:54:51 +0800 +0800","updated_at":"2026-01-20 17:00:53.988484011 +0800 +0800","nick":"群林","email":"6@5b2.cn","link":"https://usj.cc","badge_name":"","badge_color":"","page_key":"/2.html","page_title":"Twitter主题加入加载耗时,访问总量功能","page_admin_only":"false","site_name":"优世界","site_urls":"https://usj.cc"},{"id":"10340","rid":"0","content":"强啊!","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36","ip":"117.30.230.198","is_collapsed":"false","is_pending":"false","is_pinned":"false","vote_up":"0","vote_down":"0","created_at":"2021-09-02 22:11:47 +0800 +0800","updated_at":"2026-01-20 17:00:53.99580242 +0800 +0800","nick":"叶开楗","email":"qq@qq.md","link":"","badge_name":"","badge_color":"","page_key":"/2.html","page_title":"Twitter主题加入加载耗时,访问总量功能","page_admin_only":"false","site_name":"优世界","site_urls":"https://usj.cc"},{"id":"10341","rid":"10340","content":"好久不见,叶开","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36","ip":"27.29.157.140","is_collapsed":"false","is_pending":"false","is_pinned":"false","vote_up":"0","vote_down":"0","created_at":"2021-09-02 22:12:30 +0800 +0800","updated_at":"2026-01-20 17:00:54.003354037 +0800 +0800","nick":"群林","email":"6@5b2.cn","link":"https://usj.cc","badge_name":"","badge_color":"","page_key":"/2.html","page_title":"Twitter主题加入加载耗时,访问总量功能","page_admin_only":"false","site_name":"优世界","site_urls":"https://usj.cc"},{"id":"10342","rid":"0","content":"支持你!","ua":"Mozilla/5.0 (Linux; Android 11; M2007J3SC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Mobile Safari/537.36","ip":"120.243.244.141","is_collapsed":"false","is_pending":"false","is_pinned":"false","vote_up":"0","vote_down":"0","created_at":"2021-09-03 01:32:34 +0800 +0800","updated_at":"2026-01-20 17:00:54.012311262 +0800 +0800","nick":"11","email":"i@sunjianjian.com","link":"","badge_name":"","badge_color":"","page_key":"/2.html","page_title":"Twitter主题加入加载耗时,访问总量功能","page_admin_only":"false","site_name":"优世界","site_urls":"https://usj.cc"},{"id":"10343","rid":"10342","content":"嘻嘻,有空换个友链哈 ::face:biye:: ","ua":"Mozilla/5.0 (Linux; Android 10; HarmonyOS; YAL-AL00; HMSCore 6.0.1.306; GMSCore 20.15.16) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.93 HuaweiBrowser/11.1.4.301 Mobile Safari/537.36","ip":"116.208.96.5","is_collapsed":"false","is_pending":"false","is_pinned":"false","vote_up":"0","vote_down":"0","created_at":"2021-09-03 07:22:34 +0800 +0800","updated_at":"2026-01-20 17:00:54.022752625 +0800 +0800","nick":"群林","email":"6@5b2.cn","link":"https://usj.cc","badge_name":"","badge_color":"","page_key":"/2.html","page_title":"Twitter主题加入加载耗时,访问总量功能","page_admin_only":"false","site_name":"优世界","site_urls":"https://usj.cc"},{"id":"10344","rid":"0","content":"哈哈哈哈这种图片加视频的记录形式可真不错,为什么自习室的灯光红红绿绿的,我现在也没考驾照,我不喜欢开车,如果以后有车就找个司机(99.99%不可能)现在考驾照得花费多少钱呐","ua":"Mozilla/5.0 (Android 11; Mobile; rv:91.0) Gecko/91.0 Firefox/91.0","ip":"112.96.173.216","is_collapsed":"false","is_pending":"false","is_pinned":"false","vote_up":"0","vote_down":"0","created_at":"2021-09-04 11:25:10 +0800 +0800","updated_at":"2026-01-20 17:01:00.923365856 +0800 +0800","nick":"潘先生","email":"pan@5b2.cn","link":"https://panyaoxuan.cn","badge_name":"","badge_color":"","page_key":"/4.html","page_title":"2021暑假总结,记录一下这个充实的暑假","page_admiLine truncated
|
||||
@@ -1,2 +0,0 @@
|
||||
writebot.exe
|
||||
.env
|
||||
@@ -1,7 +0,0 @@
|
||||
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")
|
||||
@@ -1,14 +0,0 @@
|
||||
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")
|
||||
@@ -1,11 +0,0 @@
|
||||
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")
|
||||
@@ -1,204 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"writebot/internal/botcfg"
|
||||
"writebot/internal/sessions"
|
||||
"writebot/internal/tgcore"
|
||||
"writebot/internal/tghandlers"
|
||||
|
||||
"write-server/pkg/posts"
|
||||
)
|
||||
|
||||
var log *slog.Logger
|
||||
|
||||
func main() {
|
||||
cfg := botcfg.Load()
|
||||
posts.Init()
|
||||
tgcore.Init(cfg.TGToken)
|
||||
|
||||
log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writebot")
|
||||
log.Info("WriteBot starting")
|
||||
|
||||
if cfg.TGToken == "" {
|
||||
log.Error("no TG_BOT_TOKEN configured")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Register bot
|
||||
me, err := tgcore.TG("getMe", map[string]interface{}{})
|
||||
if err == nil {
|
||||
if ok, _ := me["ok"].(bool); ok {
|
||||
if result, ok := me["result"].(map[string]interface{}); ok {
|
||||
log.Info("telegram connected", "username", tgcore.GetString(result, "username"))
|
||||
}
|
||||
}
|
||||
}
|
||||
tgcore.TG("setMyCommands", map[string]interface{}{
|
||||
"commands": []map[string]string{
|
||||
{"command": "start", "description": "启动"},
|
||||
{"command": "new", "description": "写新文章"},
|
||||
{"command": "list", "description": "文章列表"},
|
||||
{"command": "edit", "description": "编辑文章"},
|
||||
{"command": "save", "description": "保存草稿"},
|
||||
{"command": "publish", "description": "发布到 GitHub"},
|
||||
{"command": "stats", "description": "统计"},
|
||||
{"command": "help", "description": "帮助"},
|
||||
},
|
||||
})
|
||||
|
||||
h := tghandlers.New(&tghandlers.Config{
|
||||
WritePort: cfg.WritePort,
|
||||
ControlPort: cfg.ControlPort,
|
||||
PreferIface: cfg.PreferIface,
|
||||
})
|
||||
|
||||
argCommands := map[string]bool{
|
||||
"new": true, "title": true, "categories": true, "tags": true, "author": true,
|
||||
"edit": true, "view": true, "delete": true,
|
||||
"link_add": true, "link_del": true, "link_toggle": true,
|
||||
"feed_add": true, "feed_del": true,
|
||||
"list": true, "links": true, "feeds": true,
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
var lastOffset int64
|
||||
for {
|
||||
data, err := tgcore.TG("getUpdates", map[string]interface{}{
|
||||
"offset": lastOffset + 1, "timeout": 30,
|
||||
"allowed_updates": []string{"message", "callback_query"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("tg poll error", "err", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
ok, _ := data["ok"].(bool)
|
||||
if !ok { continue }
|
||||
results, _ := data["result"].([]interface{})
|
||||
for _, updateRaw := range results {
|
||||
update, _ := updateRaw.(map[string]interface{})
|
||||
if updateID, ok := update["update_id"].(float64); ok { lastOffset = int64(updateID) }
|
||||
|
||||
if cbRaw, ok := update["callback_query"]; ok {
|
||||
cb, _ := cbRaw.(map[string]interface{})
|
||||
cbMsg, _ := cb["message"].(map[string]interface{})
|
||||
cbChat, _ := cbMsg["chat"].(map[string]interface{})
|
||||
cbFrom, _ := cb["from"].(map[string]interface{})
|
||||
var cbChatID int64
|
||||
if cbChat != nil { cbChatID = tgcore.GetInt64(cbChat, "id") }
|
||||
if cbChatID == 0 && cbFrom != nil { cbChatID = tgcore.GetInt64(cbFrom, "id") }
|
||||
queryID := tgcore.GetString(cb, "id")
|
||||
cbData := tgcore.GetString(cb, "data")
|
||||
tgcore.HandleCallback(cbChatID, queryID, cbData, h)
|
||||
continue
|
||||
}
|
||||
|
||||
msg, _ := update["message"].(map[string]interface{})
|
||||
if msg == nil { continue }
|
||||
chat, _ := msg["chat"].(map[string]interface{})
|
||||
if chat == nil { continue }
|
||||
chatID := tgcore.GetInt64(chat, "id")
|
||||
if !tgcore.Allowed(chatID, cfg.AllowedIDs) { continue }
|
||||
|
||||
if photos, ok := msg["photo"]; ok {
|
||||
photoArr, _ := photos.([]interface{})
|
||||
if len(photoArr) > 0 && sessions.HasSession(chatID) {
|
||||
s := sessions.GetSession(chatID)
|
||||
largest := photoArr[len(photoArr)-1].(map[string]interface{})
|
||||
fileID := tgcore.GetString(largest, "file_id")
|
||||
fileRes, err := tgcore.TG("getFile", map[string]interface{}{"file_id": fileID})
|
||||
if err == nil && fileRes["ok"] == true {
|
||||
if fileResult, ok := fileRes["result"].(map[string]interface{}); ok {
|
||||
filePath := tgcore.GetString(fileResult, "file_path")
|
||||
caption := tgcore.GetString(msg, "caption")
|
||||
if caption == "" { caption = "image" }
|
||||
imgURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", cfg.TGToken, filePath)
|
||||
s.Content += "
|
||||

|
||||
"
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
text := tgcore.GetString(msg, "text")
|
||||
if text == "" { continue }
|
||||
fullText := strings.TrimSpace(text)
|
||||
if idx := strings.Index(fullText, "@"); idx >= 0 {
|
||||
afterAt := fullText[idx:]
|
||||
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 { afterAt = afterAt[spaceIdx:] } else { afterAt = "" }
|
||||
fullText = strings.TrimSpace(fullText[:idx] + afterAt)
|
||||
}
|
||||
parts := strings.SplitN(fullText, " ", 2)
|
||||
slashCmd := ""; slashArg := ""
|
||||
if strings.HasPrefix(parts[0], "/") {
|
||||
slashCmd = strings.ToLower(parts[0][1:])
|
||||
if len(parts) > 1 { slashArg = parts[1] }
|
||||
}
|
||||
if sessions.HasSession(chatID) && !strings.HasPrefix(fullText, "/") {
|
||||
s := sessions.GetSession(chatID)
|
||||
s.Content += fullText + "
|
||||
"
|
||||
continue
|
||||
}
|
||||
if slashCmd == "" { continue }
|
||||
log.Info("tg command", "chat", chatID, "cmd", slashCmd)
|
||||
handleCmd(h, chatID, slashCmd, slashArg, argCommands)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
sig := <-sigCh
|
||||
log.Info("shutting down", "signal", sig.String())
|
||||
}
|
||||
|
||||
func handleCmd(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) {
|
||||
defer func() { if r := recover(); r != nil { log.Error("handler panic", "cmd", cmd, "panic", r) } }()
|
||||
if argCmds[cmd] {
|
||||
switch cmd {
|
||||
case "new": h.New(chatID, arg)
|
||||
case "title": h.Title(chatID, arg)
|
||||
case "categories": h.Categories(chatID, arg)
|
||||
case "tags": h.Tags(chatID, arg)
|
||||
case "author": h.Author(chatID, arg)
|
||||
case "edit": h.Edit(chatID, arg)
|
||||
case "view": h.View(chatID, arg)
|
||||
case "delete": h.Delete(chatID, arg)
|
||||
case "link_add": h.LinkAdd(chatID, arg)
|
||||
case "link_del": h.LinkDel(chatID, arg)
|
||||
case "link_toggle": h.LinkToggle(chatID, arg)
|
||||
case "feed_add": h.FeedAdd(chatID, arg)
|
||||
case "feed_del": h.FeedDel(chatID, arg)
|
||||
case "list": h.List(chatID, arg)
|
||||
case "links": h.Links(chatID, arg)
|
||||
case "feeds": h.Feeds(chatID, arg)
|
||||
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
|
||||
}
|
||||
} else {
|
||||
switch cmd {
|
||||
case "start": h.Start(chatID)
|
||||
case "help": h.Help(chatID)
|
||||
case "save": h.Save(chatID)
|
||||
case "publish": h.Publish(chatID)
|
||||
case "draft": h.Draft(chatID)
|
||||
case "preview": h.Preview(chatID)
|
||||
case "cancel": h.Cancel(chatID)
|
||||
case "stats": h.Stats(chatID)
|
||||
case "status": h.Status(chatID)
|
||||
case "ip": h.IP(chatID)
|
||||
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module writebot
|
||||
|
||||
go 1.26.3
|
||||
@@ -1,158 +0,0 @@
|
||||
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('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
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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, " · ")
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
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, " · ")
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@echo off
|
||||
title WriteBot
|
||||
cd /d E:\GitHub\blog\bot-go
|
||||
echo [%date% %time%] WriteBot starting...
|
||||
writebot.exe
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"slug": "2026-06-01-深圳真的好热",
|
||||
"title": "深圳真的好热",
|
||||
"deletedAt": "2026-06-01T16:21:45.062Z",
|
||||
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-深圳真的好热",
|
||||
"expiresAt": "2026-07-01T16:21:45.062Z"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"slug": "2026-06-01-深圳真的热",
|
||||
"title": "深圳真的热",
|
||||
"deletedAt": "2026-06-01T14:59:57.582Z",
|
||||
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-深圳真的热",
|
||||
"expiresAt": "2026-07-01T14:59:57.582Z"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"slug": "202605311204",
|
||||
"title": "周末我把手机系统降级了一个大版本",
|
||||
"deletedAt": "2026-06-01T15:06:56.071Z",
|
||||
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-05-31-周末我把手机系统降级了一个大版本",
|
||||
"expiresAt": "2026-07-01T15:06:56.071Z"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: 深圳真的好热
|
||||
date: '2026-06-01T00:00:00.000Z'
|
||||
slug: 2026-06-01-深圳真的好热
|
||||
draft: false
|
||||
categories:
|
||||
- 技术
|
||||
tags:
|
||||
- 测试
|
||||
author: 小赵
|
||||
layout: post
|
||||
---
|
||||
你好
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
今天的天气不错
|
||||
|
||||
你觉得呢
|
||||
|
||||
哈哈哈哈
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: 深圳真的热
|
||||
date: '2026-06-01'
|
||||
slug: 2026-06-01-深圳真的热
|
||||
draft: false
|
||||
categories:
|
||||
- 技术
|
||||
tags:
|
||||
- 深圳
|
||||
author: 小赵
|
||||
layout: post
|
||||
---
|
||||
好热啊
|
||||
超级热
|
||||
|
||||

|
||||
|
||||
哈哈哈
|
||||
此文章使用 telegram 撰写
|
||||
哈哈哈
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: 周末我把手机系统降级了一个大版本
|
||||
date: '2026-05-31T00:00:00.000Z'
|
||||
slug: '202605311204'
|
||||
draft: false
|
||||
categories:
|
||||
- 生活
|
||||
tags:
|
||||
- OPPO;ColorOS
|
||||
author: 小赵
|
||||
layout: post
|
||||
ai_comment: >-
|
||||
从技术角度看,ColorOS 16 的 AI
|
||||
功能堆砌是典型的版本迭代陷阱。厂商为追求差异化强行注入AI模块,却忽略了系统层级的功耗平衡优化。建议关闭非必要的AI服务,这就像给过度兴奋的CPU降频——有时候减法比加法更能体现工程智慧。续航劣化可能源于AI进程的常驻唤醒,等待后续热修复补丁或许是更理性的选择。
|
||||
---
|
||||
从 iPhone 16E 换到 OPPO Find X8s 之后,它就一直是主力机,没再变过。
|
||||
|
||||
说实话,OPPO 这套系统我用着挺满意,功能到位,稳定性也没得挑。
|
||||
|
||||
之所以彻底放弃 iPhone 当主力,理由其实很充分很现实:微信双开和通话录音。这两样,iPhone 都给不了,而我的工作又偏偏离不开。
|
||||
|
||||
中间也动过双持的念头,两台手机分工协作,听起来挺美。但真用起来,麻烦比便利多。光是琢磨哪个 App 装在哪台手机上,就够头疼的。用了一阵子发现,我根本没有那份“区分使用”的需求,反而更想要一个干净利落的统一。
|
||||
|
||||
手机到手时,预装的是 ColorOS 15。我就是被它的宣传语打动的——“流畅稳定,德芙一样丝滑”。用下来,确实名副其实,那种跟手和舒心,让我觉得自己选对了。
|
||||
|
||||
所以 ColorOS 16 一推送,我毫不犹豫就更了。我的手机一直是默认自动更新,总相信新的一定更好。
|
||||
|
||||
但很快我就发现,厂商的劲头,好像全往一个方向使了:AI。
|
||||
|
||||
到处都是 AI。系统里那些曾经让我安心的“本分”感,开始变了味。
|
||||
|
||||
带给我实际的体验就是,耗电和发热更厉害了,手机续航也来到了一天两充,看着手机里的电池健康,实际上还是百分之百
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
# WriteBot 模块设计文档
|
||||
|
||||
## 一、架构
|
||||
|
||||
Bot 作为 Next.js 的 sidecar 模块运行,通过 `instrumentation.ts` 在服务端启动时自动拉起。
|
||||
|
||||
```
|
||||
write/src/lib/bot/
|
||||
├── index.ts # 启动入口
|
||||
├── config.ts # Bot 专用配置
|
||||
├── tg.ts # Telegram API 封装
|
||||
├── sessions.ts # 写作会话状态管理
|
||||
├── handlers.ts # 全部命令处理器
|
||||
├── poll.ts # 长轮询循环(带指数退避)
|
||||
└── helpers.ts # 工具函数
|
||||
```
|
||||
|
||||
Bot 不再通过 HTTP 调 write API,直接调用 `src/lib/posts.ts`、`src/lib/rssapi.ts` 等 lib 函数。
|
||||
|
||||
---
|
||||
|
||||
## 二、交互方式
|
||||
|
||||
### 2.1 Telegram 原生组件使用规则
|
||||
|
||||
| 组件 | 用途 | 使用场景 |
|
||||
|------|------|---------|
|
||||
| ReplyKeyboard(底部键盘) | 常驻操作按钮 | 所有文本回复都带,分主键盘和写作键盘两套 |
|
||||
| InlineKeyboard(消息内按钮) | 列表操作、确认、分页 | 文章列表编辑/删除、删除确认、分页导航、打开编辑器 |
|
||||
| ForceReply(强制回复) | 引导输入 | 新建标题、设置分类/标签、添加友链/订阅源 |
|
||||
| Web App | 内置浏览器打开编辑器 | /start 时提供 |
|
||||
|
||||
**关键规则:ReplyKeyboard 和 InlineKeyboard 不能混在同一个 reply_markup 里。**
|
||||
|
||||
### 2.2 ReplyKeyboard 布局
|
||||
|
||||
主键盘(非写作状态):
|
||||
```
|
||||
[/new 写文章] [/list 文章列表]
|
||||
[/links 友链] [/feeds 订阅]
|
||||
[/stats 数据] [/help 帮助]
|
||||
```
|
||||
|
||||
写作键盘(写作会话中):
|
||||
```
|
||||
[/preview 预览] [/publish 发布]
|
||||
[/draft 草稿] [/cancel 取消]
|
||||
[/title 标题] [/categories 分类]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、命令清单
|
||||
|
||||
### 3.1 系统命令
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `/start` | 无 | 启动 Hugo,显示地址,发 Web App 编辑器按钮 |
|
||||
| `/stop` | 无 | 停止 Hugo |
|
||||
| `/status` | 无 | 显示 Hugo 运行状态和地址 |
|
||||
| `/ip` | 无 | 显示局域网 IP |
|
||||
| `/help` | 无 | 显示命令帮助(根据是否在写作中显示不同内容) |
|
||||
|
||||
### 3.2 文章命令
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `/new` | 标题(可选) | 新建文章,见写作流程 |
|
||||
| `/edit` | slug | 编辑已有文章,见编辑流程 |
|
||||
| `/delete` | slug | 删除文章(二次确认),移入回收站 |
|
||||
| `/list` | 页码(可选) | 文章列表,纯文本 + 分页 |
|
||||
| `/view` | slug | 显示预览链接 |
|
||||
| `/preview` | 无 | 预览当前写作内容 |
|
||||
| `/publish` | 无 | 保存到本地(draft: false),不触发 git push |
|
||||
| `/draft` | 无 | 保存到本地(draft: true) |
|
||||
| `/deploy` | 无 | Git add → commit → pull --rebase → push |
|
||||
| `/sync` | 无 | Git pull --rebase,同步远程 |
|
||||
| `/undo` | 无 | 撤销最后一次追加的内容,自动预览 |
|
||||
| `/cancel` | 无 | 丢弃当前写作会话 |
|
||||
| `/title` | 新标题 | 修改文章标题(ForceReply 引导) |
|
||||
| `/categories` | 分类 | 设置分类(逗号分隔) |
|
||||
| `/tags` | 标签 | 设置标签(逗号分隔) |
|
||||
| `/author` | 作者名 | 修改作者 |
|
||||
|
||||
### 3.3 友链命令
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `/links` | 页码(可选) | 友链列表,纯文本 + 分页 + 命令提示 |
|
||||
| `/link_add` | url 名称 | 添加友链(ForceReply 引导) |
|
||||
| `/link_del` | url | 删除友链 |
|
||||
| `/link_toggle` | url | 切换显示/隐藏 |
|
||||
|
||||
### 3.4 订阅命令
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `/feeds` | 页码(可选) | 订阅源列表,纯文本 + 分页 + 命令提示 |
|
||||
| `/feed_add` | url | 添加订阅源(ForceReply 引导) |
|
||||
| `/feed_del` | url | 删除订阅源 |
|
||||
|
||||
### 3.5 数据命令
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `/stats` | 无 | 显示文章数、友链数、订阅源数 |
|
||||
|
||||
---
|
||||
|
||||
## 四、核心流程
|
||||
|
||||
### 4.1 新建文章 `/new`
|
||||
|
||||
```
|
||||
用户: /new 标题
|
||||
↓ (如果没有标题,ForceReply 引导输入)
|
||||
Bot: 📝 开始写:《标题》
|
||||
📂 设置分类(可选)
|
||||
[跳过] [设置分类] ← InlineKeyboard
|
||||
↓
|
||||
用户: 点 [跳过] → 直接进写作
|
||||
或: 点 [设置分类] → ForceReply 引导输入分类
|
||||
→ 然后同样问标签 [跳过] [设置标签]
|
||||
↓
|
||||
Bot: 切换写作键盘
|
||||
"直接发消息写正文,完成后点「发布」"
|
||||
↓
|
||||
用户: 发消息 → 追加到 content
|
||||
用户: 发照片 → 下载到文章文件夹,追加 markdown 引用
|
||||
用户: /undo → 撤销最后一次追加,自动预览
|
||||
↓
|
||||
用户: /publish
|
||||
↓
|
||||
Bot: 保存到本地文件(draft: false)
|
||||
"✅ 已保存,发 /deploy 推送到线上"
|
||||
↓
|
||||
用户: /deploy
|
||||
↓
|
||||
Bot: git add → commit → pull --rebase → push
|
||||
"🚀 已发布并部署!"
|
||||
```
|
||||
|
||||
Session 初始状态:
|
||||
```typescript
|
||||
{
|
||||
title: "标题",
|
||||
slug: "2026-06-01-标题",
|
||||
content: "",
|
||||
categories: [],
|
||||
tags: [],
|
||||
author: "小赵", // DEFAULT_AUTHOR 环境变量可覆盖
|
||||
draft: true,
|
||||
chunks: [], // 用于 /undo,记录每次追加的内容
|
||||
mode: "write", // write | edit
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 编辑文章 `/edit`(方案 B)
|
||||
|
||||
```
|
||||
用户: /edit slug
|
||||
↓
|
||||
Bot: 加载文章,显示原文内容(分条,每条 ≤3800 字)
|
||||
清空 session.content(保留 title、slug、categories、tags、author)
|
||||
切换写作键盘
|
||||
"原文已显示,现在重新写正文,写完点「发布」"
|
||||
↓
|
||||
用户: 发消息 → 追加到新 content(和 /new 一样)
|
||||
↓
|
||||
后续流程同新建
|
||||
```
|
||||
|
||||
关键区别:`/new` 的 slug 是新生成的,`/edit` 保留原文章的 slug。保存时调用 `updatePost` 而不是 `createPost`。
|
||||
|
||||
### 4.3 删除文章 `/delete`
|
||||
|
||||
```
|
||||
用户: /delete slug
|
||||
↓
|
||||
Bot: "确认删除《标题》?"
|
||||
[确认删除] [取消] ← InlineKeyboard
|
||||
↓
|
||||
用户: 点 [确认删除]
|
||||
→ moveToRecycle(slug, dirPath, title)
|
||||
→ "✂ 已移入回收站:《标题》\n30天内可恢复。"
|
||||
↓
|
||||
用户: 点 [取消]
|
||||
→ "已取消"
|
||||
```
|
||||
|
||||
poll.ts callback 处理新增 `confirm_del` 前缀:
|
||||
```typescript
|
||||
const CALLBACK_PREFIXES = {
|
||||
edit: "edit",
|
||||
confirm_del: "delete", // 确认删除
|
||||
cancel_del: "noop", // 取消删除
|
||||
list: "list",
|
||||
links: "links",
|
||||
feeds: "feeds",
|
||||
skip_cat: "skip_categories", // 跳过分类
|
||||
skip_tag: "skip_tags", // 跳过标签
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 文章列表 `/list`
|
||||
|
||||
纯文本显示,无 InlineKeyboard 操作按钮:
|
||||
```
|
||||
📖 文章列表 共 46 篇 · 第 1/10 页
|
||||
|
||||
我的第一篇文章
|
||||
2026-06-01-my-first-post
|
||||
另一篇文章 📝(草稿)
|
||||
2026-05-28-another-post
|
||||
|
||||
◀ 上一页 下一页 ▶ ← InlineKeyboard(仅分页)
|
||||
```
|
||||
|
||||
用户通过 slug 操作:`/edit slug`、`/delete slug`、`/view slug`。
|
||||
|
||||
### 4.5 友链列表 `/links`
|
||||
|
||||
纯文本 + 分页 + 命令提示:
|
||||
```
|
||||
🔗 友链 共 46 个 · 第 1/10 页
|
||||
|
||||
🌐 强仔博客
|
||||
🌐 七栀
|
||||
🔒 若志随笔(已隐藏)
|
||||
|
||||
操作命令:
|
||||
/link_add url 名称 — 添加
|
||||
/link_del url — 删除
|
||||
/link_toggle url — 显示/隐藏
|
||||
|
||||
◀ 上一页 下一页 ▶
|
||||
```
|
||||
|
||||
### 4.6 订阅列表 `/feeds`
|
||||
|
||||
同友链,纯文本 + 分页 + 命令提示。
|
||||
|
||||
---
|
||||
|
||||
## 五、图片处理(Hugo Page Bundle)
|
||||
|
||||
### 5.1 存储策略
|
||||
|
||||
图片存到文章自己的文件夹里,符合 Hugo page bundle 规范:
|
||||
|
||||
```
|
||||
content/post/2026/2026-06-01-文章标题/
|
||||
├── index.md
|
||||
├── photo1.jpg
|
||||
└── photo2.jpg
|
||||
```
|
||||
|
||||
正文引用:``(相对路径)
|
||||
|
||||
### 5.2 实现逻辑
|
||||
|
||||
写作中收到照片时:
|
||||
1. 根据 session 的 slug 计算文章文件夹路径
|
||||
- slug: `2026-06-01-文章标题`
|
||||
- 文件夹: `content/post/2026/2026-06-01-文章标题/`
|
||||
2. 文件夹不存在则创建
|
||||
3. 下载图片到文件夹内
|
||||
4. content 追加 ``
|
||||
|
||||
保存时(draft/publish):
|
||||
1. index.md 写到同一个文件夹
|
||||
2. 图片已经在里面,无需移动
|
||||
|
||||
取消时:
|
||||
1. 文件夹里只有图片没有 index.md
|
||||
2. Hugo 不会渲染,无害
|
||||
|
||||
---
|
||||
|
||||
## 六、撤销 `/undo`
|
||||
|
||||
### 6.1 实现机制
|
||||
|
||||
Session 新增 `chunks: string[]` 字段:
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
// ... 其他字段
|
||||
chunks: string[]; // 每次追加的内容记录
|
||||
}
|
||||
```
|
||||
|
||||
每次用户发消息追加到 content 时,同时 push 到 chunks:
|
||||
```typescript
|
||||
s.chunks.push(fullText + "\n");
|
||||
s.content += fullText + "\n";
|
||||
```
|
||||
|
||||
### 6.2 `/undo` 流程
|
||||
|
||||
```
|
||||
用户: /undo
|
||||
↓
|
||||
如果 chunks 为空: "没有可撤销的内容"
|
||||
↓
|
||||
否则:
|
||||
lastChunk = chunks.pop()
|
||||
content = content 去掉 lastChunk
|
||||
→ 自动调用 preview 逻辑,显示当前文章内容
|
||||
→ "↩ 已撤销最后一段(上方为当前内容)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、发布与部署(分离设计)
|
||||
|
||||
### 7.1 与网页端保持一致
|
||||
|
||||
| 操作 | 命令 | 效果 |
|
||||
|------|------|------|
|
||||
| 保存草稿 | `/draft` | 保存到本地,draft: true |
|
||||
| 发布 | `/publish` | 保存到本地,draft: false,不触发 git |
|
||||
| 部署 | `/deploy` | git add → commit → pull --rebase → push |
|
||||
| 同步 | `/sync` | git pull --rebase,拉取远程最新 |
|
||||
|
||||
### 7.2 Git 冲突处理
|
||||
|
||||
`/deploy` 执行流程:
|
||||
1. `git add content/post static`
|
||||
2. `git diff --cached --quiet` → 有变更则 `git commit`
|
||||
3. `git pull --rebase --autostash origin main`
|
||||
- 成功 → 继续
|
||||
- 冲突 → `git rebase --abort` → 回复"远程有冲突,已自动放弃合并。请先 /sync 同步,手动解决后再 deploy"
|
||||
4. `git push`
|
||||
|
||||
---
|
||||
|
||||
## 八、配置
|
||||
|
||||
### 8.1 环境变量(write/.env)
|
||||
|
||||
```env
|
||||
# Bot 配置
|
||||
TG_BOT_TOKEN=xxx
|
||||
TG_ALLOWED_CHAT_IDS=xxx # 留空允许所有人
|
||||
PREFER_IFACE=WLAN
|
||||
DEFAULT_AUTHOR=小赵 # 新增:默认作者名
|
||||
```
|
||||
|
||||
### 8.2 config.ts 新增
|
||||
|
||||
```typescript
|
||||
export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、命令解析
|
||||
|
||||
### 9.1 ReplyKeyboard 按钮文字处理
|
||||
|
||||
按钮发出的文字如 `/new 写文章`,解析规则:
|
||||
1. `split("@")[0]` 去掉 bot mention
|
||||
2. `split(/\s+/)` 拆词
|
||||
3. 第一个词去掉 `/` 前缀作为命令名
|
||||
4. 其余词作为参数
|
||||
|
||||
`/new 写文章` → 命令 `new`,参数 `写文章`
|
||||
但 `new` 命令的参数是标题,所以实际行为:`/new 写文章` 会创建一篇标题为"写文章"的文章。
|
||||
|
||||
需要特殊处理:如果参数和命令的中文描述匹配(如 `new` 的参数是 `写文章`),则忽略该参数。
|
||||
或者更简单的方案:ReplyKeyboard 按钮只发命令,不带中文。
|
||||
|
||||
**最终方案:ReplyKeyboard 按钮文字改为纯命令:**
|
||||
```
|
||||
[/new] [/list]
|
||||
[/links] [/feeds]
|
||||
[/stats] [/help]
|
||||
```
|
||||
|
||||
### 9.2 ForceReply 回复识别
|
||||
|
||||
poll.ts 通过 `reply_to_message.text` 匹配关键词来识别 ForceReply 回复:
|
||||
|
||||
| 关键词 | 路由到 |
|
||||
|--------|--------|
|
||||
| "请输入文章标题" | `handleCommand(chatId, "new", text)` |
|
||||
| "请输入新标题" | `handleCommand(chatId, "title", text)` |
|
||||
| "请输入友链信息" | `handleCommand(chatId, "link_add", text)` |
|
||||
| "请输入订阅源 URL" | `handleCommand(chatId, "feed_add", text)` |
|
||||
| "请输入分类" | `handleCommand(chatId, "categories", text)` |
|
||||
| "请输入标签" | `handleCommand(chatId, "tags", text)` |
|
||||
|
||||
---
|
||||
|
||||
## 十、会话状态机
|
||||
|
||||
Session 有一个 `state` 字段,控制消息处理行为:
|
||||
|
||||
```
|
||||
idle → 非写作状态,普通命令处理
|
||||
writing_title → 等待 ForceReply 标题输入
|
||||
writing_cat → 等待 ForceReply 分类输入(或 InlineKeyboard 跳过)
|
||||
writing_tag → 等待 ForceReply 标签输入(或 InlineKeyboard 跳过)
|
||||
writing → 写作中,消息追加到 content
|
||||
confirm_del → 等待删除确认(InlineKeyboard)
|
||||
```
|
||||
|
||||
状态转换:
|
||||
```
|
||||
idle → ( /new ) → writing_title → ( 输入标题 ) → writing_cat
|
||||
→ ( 跳过/输入 ) → writing_tag → ( 跳过/输入 ) → writing → idle
|
||||
|
||||
idle → ( /edit slug ) → 显示原文 → 清空 content → writing → idle
|
||||
|
||||
idle → ( /delete slug ) → confirm_del → ( 确认 ) → idle
|
||||
→ ( 取消 ) → idle
|
||||
```
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 十一、友链模块
|
||||
|
||||
### 11.1 命令清单
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `/links` | 页码(可选) | 友链列表,纯文本 + 分页 |
|
||||
| `/link_add` | url 名称 | ForceReply 引导输入,添加友链 |
|
||||
| `/link_edit` | url | ForceReply 引导输入新名称,通过 oldUrl 编辑 |
|
||||
| `/link_del` | url | 删除友链 |
|
||||
| `/link_toggle` | url | 切换显示/隐藏 |
|
||||
|
||||
### 11.2 列表显示格式
|
||||
|
||||
```
|
||||
🔗 友链 共 46 个 · 第 1/10 页
|
||||
|
||||
🌐 吾柯 https://blog.keepke.com
|
||||
🌐 强仔博客 https://loseu.cc
|
||||
🔒 七栀 https://blog.qydzz.cn
|
||||
|
||||
操作:/link_add /link_edit /link_del /link_toggle
|
||||
|
||||
◀ 上一页 下一页 ▶
|
||||
```
|
||||
|
||||
每条显示:图标(🌐 正常 / 🔒 隐藏)+ 名称 + URL。
|
||||
每页 10 条。底部 InlineKeyboard 仅分页按钮。
|
||||
底部文字提示可用操作命令。
|
||||
|
||||
### 11.3 `/link_edit` 流程
|
||||
|
||||
```
|
||||
用户: /link_edit url
|
||||
↓
|
||||
Bot: 查找该友链,显示当前信息
|
||||
"请输入新名称(留空保持不变):" ← ForceReply
|
||||
↓
|
||||
用户: 输入新名称(或发"跳过")
|
||||
↓
|
||||
Bot: 调用 POST /api/links { oldUrl, name: newName }
|
||||
"✅ 已更新:新名称"
|
||||
```
|
||||
|
||||
### 11.4 API 调用
|
||||
|
||||
- 列表:`GET /api/links?all=1`
|
||||
- 添加:`POST /api/links` body: `{ name, url }`
|
||||
- 编辑:`POST /api/links` body: `{ oldUrl, name, ... }`
|
||||
- 删除:`DELETE /api/links?url=xxx`
|
||||
- 切换:先 GET 获取 hidden 状态,再 POST `{ url, hidden: !current }`
|
||||
|
||||
---
|
||||
|
||||
## 十二、订阅模块
|
||||
|
||||
### 12.1 命令清单
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `/feeds` | 页码(可选) | 订阅源列表,纯文本 + 分页 |
|
||||
| `/feed_add` | url 名称 | ForceReply 引导输入 URL 和名称 |
|
||||
| `/feed_edit` | url | ForceReply 引导编辑,通过 oldUrl |
|
||||
| `/feed_del` | url | 删除订阅源 |
|
||||
| `/feed_health` | 无 | 检测全部订阅源健康状态 |
|
||||
|
||||
### 12.2 列表显示格式
|
||||
|
||||
```
|
||||
📡 订阅源 共 52 个 · 第 1/10 页
|
||||
|
||||
📰 吾柯 https://blog.keepke.com
|
||||
📰 强仔博客 https://loseu.cc
|
||||
|
||||
操作:/feed_add /feed_edit /feed_del /feed_health
|
||||
|
||||
◀ 上一页 下一页 ▶
|
||||
```
|
||||
|
||||
每页 10 条。
|
||||
|
||||
### 12.3 `/feed_health` 流程
|
||||
|
||||
调用 `GET /api/health`(不传 url 参数,检测全部订阅源)。
|
||||
|
||||
```
|
||||
用户: /feed_health
|
||||
↓
|
||||
Bot: "⏳ 正在检测 52 个订阅源..."
|
||||
↓
|
||||
Bot:
|
||||
📊 订阅源健康报告
|
||||
✅ 存活:49 ❌ 异常:3
|
||||
|
||||
❌ 异常站点:
|
||||
🔴 dead-blog.com — timeout
|
||||
🔴 another.com — 500
|
||||
🔴 third.com — 连接失败
|
||||
```
|
||||
|
||||
只列出异常站点,正常的不逐一显示。
|
||||
|
||||
### 12.4 `/feed_edit` 流程
|
||||
|
||||
同友链编辑,通过 oldUrl 参数调用 POST /api/feeds。
|
||||
|
||||
### 12.5 API 调用
|
||||
|
||||
- 列表:`GET /api/feeds`
|
||||
- 添加:`POST /api/feeds` body: `{ url, feedTitle }`
|
||||
- 编辑:`POST /api/feeds` body: `{ oldUrl, url, feedTitle }`
|
||||
- 删除:`DELETE /api/feeds?url=xxx`
|
||||
- 健康:`GET /api/health`
|
||||
|
||||
---
|
||||
|
||||
## 十三、新增 `/read` 命令
|
||||
|
||||
利用 API 的 `GET /api/articles` 接口,获取友链博客的最新文章。
|
||||
|
||||
### 13.1 命令
|
||||
|
||||
| 命令 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `/read` | 数量(可选,默认 10) | 友链最新文章 |
|
||||
|
||||
### 13.2 显示格式
|
||||
|
||||
```
|
||||
📰 友链最新文章(10 篇)
|
||||
|
||||
吾柯 — 如何在 Hugo 中实现友链
|
||||
https://blog.keepke.com/xxx
|
||||
|
||||
强仔博客 — 2026 年度总结
|
||||
https://loseu.cc/xxx
|
||||
|
||||
七栀 — 我的第一次旅行
|
||||
https://blog.qydzz.cn/xxx
|
||||
```
|
||||
|
||||
每条显示:博客名 — 文章标题 + 链接。
|
||||
Telegram 自动将 URL 转为可点击链接。
|
||||
|
||||
### 13.3 API 调用
|
||||
|
||||
`GET /api/articles?limit=10`
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"total": 380,
|
||||
"articles": [
|
||||
{
|
||||
"title": "...",
|
||||
"link": "...",
|
||||
"pubDate": "...",
|
||||
"feedName": "吾柯",
|
||||
"siteUrl": "...",
|
||||
"favicon": "...",
|
||||
"author": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、完整命令注册列表
|
||||
|
||||
更新 `setMyCommands` 注册的命令:
|
||||
|
||||
```typescript
|
||||
commands: [
|
||||
{ command: "new", description: "写一篇文章" },
|
||||
{ command: "edit", description: "编辑文章" },
|
||||
{ command: "delete", description: "删除文章" },
|
||||
{ command: "list", description: "文章列表" },
|
||||
{ command: "publish", description: "保存发布" },
|
||||
{ command: "draft", description: "保存草稿" },
|
||||
{ command: "deploy", description: "推送到线上" },
|
||||
{ command: "sync", description: "同步远程" },
|
||||
{ command: "undo", description: "撤销最后一段" },
|
||||
{ command: "preview", description: "预览" },
|
||||
{ command: "links", description: "友链管理" },
|
||||
{ command: "link_add", description: "添加友链" },
|
||||
{ command: "link_edit", description: "编辑友链" },
|
||||
{ command: "link_del", description: "删除友链" },
|
||||
{ command: "link_toggle", description: "显示/隐藏友链" },
|
||||
{ command: "feeds", description: "订阅管理" },
|
||||
{ command: "feed_add", description: "添加订阅源" },
|
||||
{ command: "feed_edit", description: "编辑订阅源" },
|
||||
{ command: "feed_del", description: "删除订阅源" },
|
||||
{ command: "feed_health", description: "订阅源健康检查" },
|
||||
{ command: "read", description: "友链最新文章" },
|
||||
{ command: "stats", description: "网站数据" },
|
||||
{ command: "help", description: "帮助" },
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十五、其他可考虑的功能
|
||||
|
||||
### 15.1 `/random` — 随机图片
|
||||
|
||||
利用 API 的 `GET /api/random-image` 接口,可以做一个随机壁纸/图片功能。
|
||||
适合在聊天中发一张随机图片,增加趣味性。优先级低。
|
||||
|
||||
### 15.2 `/greeting` — 问候语
|
||||
|
||||
利用 API 的 `GET /api/ai/greeting` 接口,获取 AI 生成的问候语。
|
||||
适合每天早上发一条问候。可以和定时任务结合。优先级低。
|
||||
|
||||
### 15.3 公众号同步 `/wechat`
|
||||
|
||||
利用 API 的 `POST /api/wechat-material` 接口,在 bot 中直接把文章同步到微信公众号草稿箱。
|
||||
当前 write 已有公众号页面,bot 可以加一个 `/wechat slug` 命令。
|
||||
优先级中——如果经常在手机上写文章,同步到公众号是个高频操作。
|
||||
|
||||
### 15.4 定时任务
|
||||
|
||||
利用 Cowork 的 scheduled-tasks 功能,可以设置:
|
||||
- 每天早上 9 点发一条问候语 + 友链最新文章摘要
|
||||
- 每周检测一次订阅源健康状态
|
||||
- 每天自动部署未发布的草稿(可选)
|
||||
|
||||
这些功能当前不急,后续按需添加。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 十六、确认的补充项
|
||||
|
||||
### 16.1 写作键盘更新
|
||||
|
||||
加入 `/undo` 和 `/deploy`:
|
||||
|
||||
```
|
||||
[/preview] [/undo]
|
||||
[/publish] [/deploy]
|
||||
[/draft] [/cancel]
|
||||
[/title] [/categories]
|
||||
```
|
||||
|
||||
### 16.2 主键盘更新
|
||||
|
||||
按钮文字改为纯命令:
|
||||
|
||||
```
|
||||
[/new] [/list]
|
||||
[/links] [/feeds]
|
||||
[/stats] [/help]
|
||||
```
|
||||
|
||||
### 16.3 /stats 增强
|
||||
|
||||
显示本地数据 + API 数据:
|
||||
|
||||
```
|
||||
📊 网站数据
|
||||
|
||||
📝 本地文章:128 篇
|
||||
🔗 友链:46 个
|
||||
📡 订阅源:52 个
|
||||
|
||||
📰 友链文章:380 篇(来自 52 个博客)
|
||||
🌐 usj.cc
|
||||
```
|
||||
|
||||
### 16.4 API 不可用降级
|
||||
|
||||
所有调用 `api.usj.cc` 的操作统一处理:
|
||||
- 超时:10 秒
|
||||
- 失败回复:"⚠️ API 暂时不可用,请稍后再试"
|
||||
- 涉及命令:/links、/link_add、/link_edit、/link_del、/link_toggle、/feeds、/feed_add、/feed_edit、/feed_del、/feed_health、/read、/stats(API 部分)
|
||||
|
||||
---
|
||||
|
||||
## 十七、完整的 poll.ts 命令解析更新
|
||||
|
||||
### 17.1 ARG_COMMANDS 更新
|
||||
|
||||
```typescript
|
||||
const ARG_COMMANDS = new Set([
|
||||
"new", "title", "categories", "tags", "author",
|
||||
"edit", "view", "delete",
|
||||
"link_add", "link_edit", "link_del", "link_toggle",
|
||||
"feed_add", "feed_edit", "feed_del",
|
||||
"list", "links", "feeds", "read",
|
||||
]);
|
||||
```
|
||||
|
||||
### 17.2 CALLBACK_PREFIXES 更新
|
||||
|
||||
```typescript
|
||||
const CALLBACK_PREFIXES: Record<string, string> = {
|
||||
edit: "edit",
|
||||
confirm_del: "delete",
|
||||
cancel_del: "noop",
|
||||
list: "list",
|
||||
links: "links",
|
||||
feeds: "feeds",
|
||||
skip_cat: "skip_categories",
|
||||
skip_tag: "skip_tags",
|
||||
};
|
||||
```
|
||||
|
||||
### 17.3 ForceReply 关键词更新
|
||||
|
||||
```typescript
|
||||
if (original.includes("请输入文章标题")) → new
|
||||
if (original.includes("请输入新标题")) → title
|
||||
if (original.includes("请输入分类")) → categories
|
||||
if (original.includes("请输入标签")) → tags
|
||||
if (original.includes("请输入友链信息")) → link_add
|
||||
if (original.includes("请输入新名称")) → link_edit
|
||||
if (original.includes("请输入订阅源 URL")) → feed_add
|
||||
if (original.includes("请输入新的订阅信息")) → feed_edit
|
||||
```
|
||||
|
||||
### 17.4 InlineKeyboard callback 处理
|
||||
|
||||
新增 `confirm_del` 和 `cancel_del` 处理:
|
||||
- `confirm_del_slug` → 执行删除
|
||||
- `cancel_del_slug` → 取消,回复"已取消"
|
||||
|
||||
新增 `skip_cat` 和 `skip_tag` 处理:
|
||||
- `skip_cat` → 跳过分类设置
|
||||
- `skip_tag` → 跳过标签设置
|
||||
|
||||
---
|
||||
|
||||
## 十八、sessions.ts 更新
|
||||
|
||||
### 18.1 Session 接口
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
author: string;
|
||||
draft: boolean;
|
||||
chunks: string[]; // 每次追加的内容,用于 /undo
|
||||
originalSlug?: string; // /edit 时保留原 slug
|
||||
}
|
||||
```
|
||||
|
||||
### 18.2 默认作者
|
||||
|
||||
从 `DEFAULT_AUTHOR` 环境变量读取,默认 "小赵"。
|
||||
|
||||
---
|
||||
|
||||
## 十九、helpers.ts 更新
|
||||
|
||||
### 19.1 makeSlug 保持不变
|
||||
|
||||
`2026-06-01-标题拼音`
|
||||
|
||||
### 19.2 新增 getArticleDirPath
|
||||
|
||||
根据 slug 计算文章文件夹路径:
|
||||
|
||||
```typescript
|
||||
function getArticleDirPath(slug: string): string {
|
||||
// slug: "2026-06-01-文章标题"
|
||||
// 从 slug 提取年份前四位
|
||||
const year = slug.slice(0, 4);
|
||||
return path.join(CONTENT_DIR, year, slug);
|
||||
}
|
||||
```
|
||||
|
||||
### 19.3 新增 truncateText
|
||||
|
||||
截断文本到指定长度,用于 Telegram 消息:
|
||||
|
||||
```typescript
|
||||
function truncateText(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二十、config.ts 更新
|
||||
|
||||
新增环境变量:
|
||||
|
||||
```typescript
|
||||
export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";
|
||||
export const UAPI_BASE = process.env.RSS_API_BASE || "https://api.usj.cc";
|
||||
export const UAPI_TOKEN = process.env.RSS_API_TOKEN || "";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二十一、rssapi.ts 新增函数
|
||||
|
||||
### 21.1 getArticles
|
||||
|
||||
```typescript
|
||||
export async function getArticles(limit = 10): Promise<Article[]> {
|
||||
const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`);
|
||||
return data.articles || [];
|
||||
}
|
||||
```
|
||||
|
||||
### 21.2 checkHealth
|
||||
|
||||
```typescript
|
||||
export async function checkHealth(): Promise<HealthResult> {
|
||||
return rssFetch("/api/health");
|
||||
}
|
||||
```
|
||||
|
||||
### 21.3 updateLink 和 updateFeed 已存在
|
||||
|
||||
rssapi.ts 已有 `updateLink(oldUrl, data)` 函数,用于友链编辑。
|
||||
需要新增 `updateFeed(oldUrl, data)` 函数用于订阅编辑。
|
||||
|
||||
---
|
||||
|
||||
## 二十二、git.ts 更新
|
||||
|
||||
### 22.1 GitDeployQueue 修改
|
||||
|
||||
`enqueue` 方法不再自动 add static 目录,改为只 add content:
|
||||
|
||||
```typescript
|
||||
const addResult = await this.runGit(["add", "content"]);
|
||||
```
|
||||
|
||||
### 22.2 新增 sync 方法
|
||||
|
||||
```typescript
|
||||
async sync(): Promise<DeployResult> {
|
||||
try {
|
||||
const result = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
|
||||
return { success: true, output: result.stdout + result.stderr };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (this.isRebaseConflict(msg)) {
|
||||
await this.runGit(["rebase", "--abort"], true);
|
||||
return { success: false, conflict: true, error: "远程冲突,已放弃合并" };
|
||||
}
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二十三、实施清单
|
||||
|
||||
按依赖顺序:
|
||||
|
||||
### 第一步:基础设施修改
|
||||
1. config.ts — 新增 DEFAULT_AUTHOR、UAPI_BASE、UAPI_TOKEN
|
||||
2. git.ts — 修改 add 路径、新增 sync 方法
|
||||
3. rssapi.ts — 新增 getArticles、checkHealth、updateFeed
|
||||
4. helpers.ts — 新增 getArticleDirPath、truncateText
|
||||
5. sessions.ts — 新增 chunks、originalSlug 字段、默认作者从 config 读取
|
||||
|
||||
### 第二步:poll.ts 重写
|
||||
6. 更新 ARG_COMMANDS
|
||||
7. 更新 CALLBACK_PREFIXES
|
||||
8. 更新 ForceReply 关键词路由
|
||||
9. 新增 confirm_del / cancel_del / skip_cat / skip_tag 处理
|
||||
|
||||
### 第三步:handlers.ts 重写
|
||||
10. 系统命令(start/stop/status/ip/help)
|
||||
11. 文章命令(new/edit/delete/list/view/title/categories/tags/author)
|
||||
12. 写作命令(preview/publish/draft/deploy/sync/undo/cancel)
|
||||
13. 友链命令(links/link_add/link_edit/link_del/link_toggle)
|
||||
14. 订阅命令(feeds/feed_add/feed_edit/feed_del/feed_health)
|
||||
15. 数据命令(stats/read)
|
||||
|
||||
### 第四步:键盘和命令注册
|
||||
16. mainKeyboard — 纯命令
|
||||
17. writingKeyboard — 加 /undo /deploy
|
||||
18. setMyCommands — 完整命令列表
|
||||
|
||||
### 第五步:验证
|
||||
19. TypeScript 类型检查
|
||||
20. 本地 npm run dev 测试
|
||||
@@ -0,0 +1,477 @@
|
||||
# Bot + Write 整合架构方案
|
||||
|
||||
## 目标
|
||||
|
||||
将 `bot/`(Telegram Bot)的全部功能合并进 `write/`(Next.js 16)项目,消除双进程问题,统一文件操作、Hugo 管理、Git 部署。
|
||||
|
||||
---
|
||||
|
||||
## 一、整合后的目录结构
|
||||
|
||||
```
|
||||
write/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js 页面 + API 路由(现有)
|
||||
│ ├── components/ # React 组件(现有)
|
||||
│ ├── lib/
|
||||
│ │ ├── config.ts # 统一配置(合并 bot 的 .env 字段)
|
||||
│ │ ├── posts.ts # 文章 CRUD(现有,改异步)
|
||||
│ │ ├── ai.ts # AI 功能(现有)
|
||||
│ │ ├── artalk.ts # 评论管理(现有)
|
||||
│ │ ├── recycle.ts # 回收站(现有)
|
||||
│ │ ├── rssapi.ts # 友链/订阅 API(现有)
|
||||
│ │ ├── hugo.ts # ★ 新增:Hugo 进程单例管理器
|
||||
│ │ ├── git.ts # ★ 新增:Git 操作队列
|
||||
│ │ └── bot/ # ★ 新增:Telegram Bot 模块
|
||||
│ │ ├── index.ts # bot 启动入口,导出 startBot()
|
||||
│ │ ├── config.ts # bot 专用配置(TG_TOKEN, ALLOWED_IDS)
|
||||
│ │ ├── tg.ts # Telegram API 封装(tg(), sendMessage(), handleCallback())
|
||||
│ │ ├── sessions.ts # 写作会话状态管理
|
||||
│ │ ├── handlers.ts # 全部 /command 处理器
|
||||
│ │ ├── poll.ts # 长轮询循环(带指数退避)
|
||||
│ │ └── helpers.ts # 工具函数(getLanIP, makeSlug, sessionSummary)
|
||||
│ └── instrumentation.ts # ★ 新增:Next.js 服务端启动钩子,启动 bot
|
||||
├── .env # 合并后的环境变量
|
||||
├── start.bat # 启动脚本(不变)
|
||||
└── ...(其余不变)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、三个核心共享模块
|
||||
|
||||
### 2.1 Hugo 进程管理器 — `src/lib/hugo.ts`
|
||||
|
||||
**解决的问题:** 当前 bot 的 `write-server.js` 和 write 的 `hugo/route.ts` 各自管理 Hugo 进程,互不知道对方状态,可能互相杀进程。
|
||||
|
||||
```typescript
|
||||
// src/lib/hugo.ts
|
||||
|
||||
class HugoManager {
|
||||
private process: ChildProcess | null = null;
|
||||
private ready = false;
|
||||
private starting = false;
|
||||
private healthTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** 启动 Hugo server,如果已在运行则跳过 */
|
||||
async start(): Promise<{ ok: boolean; alreadyRunning?: boolean }> { ... }
|
||||
|
||||
/** 优雅停止 Hugo */
|
||||
stop(): void { ... }
|
||||
|
||||
/** 当前状态 */
|
||||
status(): { running: boolean; starting: boolean } { ... }
|
||||
|
||||
/** 确保 Hugo 在运行,不在则启动并等待就绪 */
|
||||
async ensureRunning(): Promise<boolean> { ... }
|
||||
|
||||
/** 每 30 秒检查一次 Hugo 是否还活着,死了自动重启 */
|
||||
private startHealthCheck(): void { ... }
|
||||
|
||||
/** 用 HTTP 请求检查 Hugo 是否响应 */
|
||||
private async ping(): Promise<boolean> { ... }
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
export const hugo = new HugoManager();
|
||||
```
|
||||
|
||||
关键设计点:
|
||||
- `start()` 内部有互斥锁,两个并发调用不会重复启动
|
||||
- 健康检查每 30 秒 ping 一次 Hugo 的 localhost:1313,失败则标记 ready=false
|
||||
- Web 端的 `/api/hugo/route.ts` 改为调用 `hugo.start()` / `hugo.stop()` / `hugo.status()`
|
||||
- Bot 的 `/start` 命令改为调用 `hugo.ensureRunning()`
|
||||
- 启动参数统一为 `hugo server --bind 0.0.0.0 --port 1313 --disableFastRender --noHTTPCache`
|
||||
|
||||
### 2.2 Git 部署队列 — `src/lib/git.ts`
|
||||
|
||||
**解决的问题:** 当前 deploy route 没有并发控制,两个同时触发的 deploy 会导致 rebase 冲突或仓库损坏。
|
||||
|
||||
```typescript
|
||||
// src/lib/git.ts
|
||||
|
||||
class GitDeployQueue {
|
||||
private queue: Array<{
|
||||
message: string;
|
||||
resolve: (result: DeployResult) => void;
|
||||
reject: (err: Error) => void;
|
||||
}> = [];
|
||||
private running = false;
|
||||
|
||||
/** 入队一次部署任务,返回 Promise 等待结果 */
|
||||
async enqueue(commitMessage: string): Promise<DeployResult> { ... }
|
||||
|
||||
/** 实际执行 git add → commit → pull --rebase → push */
|
||||
private async execute(message: string): Promise<DeployResult> { ... }
|
||||
|
||||
/** 处理队列中的下一个任务 */
|
||||
private async next(): Promise<void> { ... }
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
export const deployQueue = new GitDeployQueue();
|
||||
```
|
||||
|
||||
关键设计点:
|
||||
- 同一时间只有一个 git 操作在执行,其余排队等待
|
||||
- Web 端的 `/api/deploy/route.ts` 改为调用 `deployQueue.enqueue(title)`
|
||||
- Bot 的 `/publish` 保存文章后也调用 `deployQueue.enqueue()` 触发自动部署
|
||||
- 返回 `{ conflict: true }` 时前端提示用户手动解决
|
||||
|
||||
### 2.3 Bot 模块 — `src/lib/bot/`
|
||||
|
||||
**解决的问题:** bot 作为独立进程通过 HTTP 调 write API,引入了不必要的网络依赖和状态不一致。
|
||||
|
||||
整合后 bot 是 Next.js 进程内的一个模块,handler 直接调用 `src/lib/posts.ts`、`src/lib/rssapi.ts` 等函数,不再走 HTTP。
|
||||
|
||||
---
|
||||
|
||||
## 三、Bot 模块详细设计
|
||||
|
||||
### 3.1 启动方式 — `src/instrumentation.ts`
|
||||
|
||||
Next.js 13+ 支持 `instrumentation.ts`,在服务端进程启动时执行一次:
|
||||
|
||||
```typescript
|
||||
// src/instrumentation.ts
|
||||
export async function register() {
|
||||
// 仅在 Node.js 运行时执行(不是 Edge Runtime)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { startBot } = await import("@/lib/bot");
|
||||
startBot();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
需要在 `next.config.ts` 中启用:
|
||||
|
||||
```typescript
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
experimental: {
|
||||
instrumentationHook: true, // Next.js 15+ 已默认开启,16 可能不需要
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Telegram API 封装 — `src/lib/bot/tg.ts`
|
||||
|
||||
从 bot 的 `tg-core.js` 移植,改为 TypeScript:
|
||||
|
||||
```typescript
|
||||
const API = `https://api.telegram.org/bot${TG_TOKEN}`;
|
||||
|
||||
export async function tg<T = unknown>(method: string, body: Record<string, unknown>): Promise<TgResponse<T>> {
|
||||
const res = await fetch(`${API}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function allowed(chatId: number): boolean {
|
||||
if (ALLOWED_IDS.length === 0) return true;
|
||||
return ALLOWED_IDS.includes(chatId);
|
||||
}
|
||||
|
||||
export async function sendMessage(chatId: number, text: string, opts?: Record<string, unknown>): Promise<void> {
|
||||
// 先尝试 HTML 解析,失败则降级纯文本
|
||||
try {
|
||||
await tg("sendMessage", { chat_id: chatId, text, parse_mode: "HTML", disable_web_page_preview: true, ...opts });
|
||||
} catch {
|
||||
await tg("sendMessage", { chat_id: chatId, text, ...opts });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 写作会话 — `src/lib/bot/sessions.ts`
|
||||
|
||||
从 bot 的 `sessions.js` 移植,改为直接调用 `src/lib/posts.ts`:
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
author: string;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
const sessions = new Map<number, Session>();
|
||||
|
||||
export function getSession(chatId: number): Session { ... }
|
||||
export function clearSession(chatId: number): void { ... }
|
||||
|
||||
// 关键变化:直接调用 lib/posts.ts,不再走 HTTP
|
||||
export async function saveSession(s: Session, isDraft: boolean): Promise<void> {
|
||||
const { createPost, updatePost, getPost } = await import("@/lib/posts");
|
||||
const existing = getPost(s.slug);
|
||||
if (existing) {
|
||||
updatePost(s.slug, {
|
||||
title: s.title,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
draft: isDraft,
|
||||
categories: s.categories,
|
||||
tags: s.tags,
|
||||
author: s.author,
|
||||
}, s.content);
|
||||
} else {
|
||||
createPost({
|
||||
title: s.title,
|
||||
slug: s.slug,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
draft: isDraft,
|
||||
categories: s.categories,
|
||||
tags: s.tags,
|
||||
author: s.author,
|
||||
}, s.content);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 命令处理器 — `src/lib/bot/handlers.ts`
|
||||
|
||||
从 bot 的 `tg-handlers.js` 移植,核心变化:
|
||||
|
||||
| 原来(HTTP 调 write API) | 现在(直接调 lib) |
|
||||
|---|---|
|
||||
| `fetch("http://127.0.0.1:8016/api/posts/" + slug)` | `getPost(slug)` |
|
||||
| `fetch("http://127.0.0.1:8016/api/posts", { method: "POST" })` | `createPost(frontMatter, content)` |
|
||||
| `fetch("http://127.0.0.1:8016/api/posts/" + slug, { method: "DELETE" })` | `moveToRecycle(slug, dirPath, title)` |
|
||||
| `fetch("http://127.0.0.1:8016/api/rss/links")` | `getLinks(true)` |
|
||||
| `fetch("http://127.0.0.1:8016/api/rss/feeds")` | `getFeeds()` |
|
||||
| `fetch("http://127.0.0.1:8016/api/deploy", ...)` | `deployQueue.enqueue(title)` |
|
||||
|
||||
另外,`/publish` 命令现在会自动触发 git 部署:
|
||||
|
||||
```typescript
|
||||
// /publish handler
|
||||
async function handlePublish(chatId: number) {
|
||||
const s = sessions.get(chatId);
|
||||
await saveSession(s, false); // 保存文章
|
||||
const result = await deployQueue.enqueue(`发布: ${s.title}`); // 自动部署
|
||||
if (result.success) {
|
||||
sendMessage(chatId, `✅ 已发布并部署: ${s.title}`);
|
||||
} else if (result.conflict) {
|
||||
sendMessage(chatId, `⚠️ 已保存但部署冲突,请手动解决`);
|
||||
} else {
|
||||
sendMessage(chatId, `❌ 部署失败: ${result.error}`);
|
||||
}
|
||||
clearSession(chatId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 长轮询 — `src/lib/bot/poll.ts`
|
||||
|
||||
从 bot 的 `tg-poll.js` 移植,修复两个问题:
|
||||
|
||||
1. **添加指数退避**:出错后等待 3s → 6s → 12s → 最大 60s,避免 Telegram API 限流
|
||||
2. **添加正常间隔**:`getUpdates` 返回后等待 100ms 再发起下一次,避免空转
|
||||
|
||||
```typescript
|
||||
export async function startPolling(): Promise<void> {
|
||||
let lastOffset = 0;
|
||||
let backoff = 3000;
|
||||
|
||||
async function poll() {
|
||||
if (!TG_TOKEN) return;
|
||||
try {
|
||||
const data = await tg("getUpdates", {
|
||||
offset: lastOffset + 1,
|
||||
timeout: 30,
|
||||
allowed_updates: ["message", "callback_query"],
|
||||
});
|
||||
if (data.ok && data.result) {
|
||||
for (const update of data.result) {
|
||||
lastOffset = update.update_id;
|
||||
// ... 处理消息(逻辑不变)
|
||||
}
|
||||
}
|
||||
backoff = 3000; // 成功则重置退避
|
||||
} catch (e) {
|
||||
console.error("[bot:poll]", e.message);
|
||||
await sleep(backoff);
|
||||
backoff = Math.min(backoff * 2, 60000);
|
||||
}
|
||||
await sleep(100); // 正常间隔
|
||||
setImmediate(poll);
|
||||
}
|
||||
|
||||
poll();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 图片处理改进
|
||||
|
||||
当前 bot 直接引用 Telegram 临时 URL,改为下载到本地:
|
||||
|
||||
```typescript
|
||||
// 在 poll.ts 中处理 photo 消息
|
||||
if (msg.photo && sessions.has(chatId)) {
|
||||
const s = getSession(chatId);
|
||||
const largestPhoto = msg.photo[msg.photo.length - 1];
|
||||
const fileRes = await tg("getFile", { file_id: largestPhoto.file_id });
|
||||
if (fileRes.ok) {
|
||||
const filePath = fileRes.result.file_path;
|
||||
const fileUrl = `https://api.telegram.org/file/bot${TG_TOKEN}/${filePath}`;
|
||||
const imgBuffer = await fetch(fileUrl).then(r => r.arrayBuffer());
|
||||
|
||||
// 保存到 static/upload/ 目录
|
||||
const filename = `${Date.now()}-${largestPhoto.file_id.slice(0, 8)}.jpg`;
|
||||
const savePath = path.join(STATIC_DIR, "upload", filename);
|
||||
fs.writeFileSync(savePath, Buffer.from(imgBuffer));
|
||||
|
||||
// 引用本地路径
|
||||
const caption = msg.caption || "";
|
||||
s.content += `\n\n`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 端 API 路由适配
|
||||
|
||||
### 4.1 `/api/hugo/route.ts` — 改用 HugoManager
|
||||
|
||||
```typescript
|
||||
import { hugo } from "@/lib/hugo";
|
||||
|
||||
export async function GET() {
|
||||
const result = await hugo.ensureRunning();
|
||||
return NextResponse.json({ running: result, url: "http://localhost:1313" });
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `/api/deploy/route.ts` — 改用 GitDeployQueue
|
||||
|
||||
```typescript
|
||||
import { deployQueue } from "@/lib/git";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { title } = await request.json();
|
||||
const result = await deployQueue.enqueue(`发布: ${title}`);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、配置合并
|
||||
|
||||
将 bot 的 `.env` 字段合入 write 的 `.env`:
|
||||
|
||||
```env
|
||||
# ---- Write 原有 ----
|
||||
RSS_API_BASE=https://api.usj.cc
|
||||
RSS_API_TOKEN=xxx
|
||||
WECHAT_APP_ID=xxx
|
||||
WECHAT_APP_SECRET=xxx
|
||||
DEEPSEEK_API_KEY=xxx
|
||||
ARTALK_SERVER=https://artalk.usj.cc
|
||||
|
||||
# ---- Bot 合并过来 ----
|
||||
TG_BOT_TOKEN=xxx
|
||||
TG_ALLOWED_CHAT_IDS=xxx
|
||||
PREFER_IFACE=WLAN
|
||||
```
|
||||
|
||||
`src/lib/config.ts` 新增:
|
||||
|
||||
```typescript
|
||||
export const TG_BOT_TOKEN = process.env.TG_BOT_TOKEN || "";
|
||||
export const TG_ALLOWED_IDS = (process.env.TG_ALLOWED_CHAT_IDS || "")
|
||||
.split(",").map(s => s.trim()).filter(Boolean).map(Number);
|
||||
export const PREFER_IFACE = process.env.PREFER_IFACE || "WLAN";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、Bot 管理页面(替代 admin-panel)
|
||||
|
||||
将 bot 的 `admin-panel.js`(独立 HTTP 服务器)改为 Next.js 页面 `/bot/page.tsx`:
|
||||
|
||||
- 显示 Telegram Bot 连接状态(是否在轮询、最后收到消息的时间)
|
||||
- 显示 Hugo 进程状态
|
||||
- 显示最近的 bot 操作日志(最近 10 条)
|
||||
- 提供重启 bot 的按钮
|
||||
|
||||
对应 API 路由 `/api/bot/status/route.ts` 和 `/api/bot/restart/route.ts`。
|
||||
|
||||
这样 bot 的管理界面和 write 的其他页面共用同一个 Next.js 服务器,不再需要单独的 8017 端口。
|
||||
|
||||
---
|
||||
|
||||
## 七、实施步骤
|
||||
|
||||
按依赖关系排序:
|
||||
|
||||
### 第一步:基础设施(无现有代码改动)
|
||||
1. 创建 `src/lib/hugo.ts` — HugoManager 单例
|
||||
2. 创建 `src/lib/git.ts` — GitDeployQueue
|
||||
3. 更新 `src/lib/config.ts` — 合并 bot 环境变量
|
||||
4. 更新 `.env` — 合并 bot 配置项
|
||||
|
||||
### 第二步:移植 Bot 模块
|
||||
5. 创建 `src/lib/bot/helpers.ts` — 工具函数
|
||||
6. 创建 `src/lib/bot/config.ts` — bot 专用配置
|
||||
7. 创建 `src/lib/bot/tg.ts` — Telegram API 封装
|
||||
8. 创建 `src/lib/bot/sessions.ts` — 会话管理
|
||||
9. 创建 `src/lib/bot/handlers.ts` — 命令处理器(直接调 lib)
|
||||
10. 创建 `src/lib/bot/poll.ts` — 长轮询(带退避)
|
||||
11. 创建 `src/lib/bot/index.ts` — 启动入口
|
||||
|
||||
### 第三步:Next.js 集成
|
||||
12. 创建 `src/instrumentation.ts` — 服务端启动钩子
|
||||
13. 更新 `next.config.ts` — 确保 instrumentation 启用
|
||||
|
||||
### 第四步:适配现有 API 路由
|
||||
14. 改造 `src/app/api/hugo/route.ts` — 使用 HugoManager
|
||||
15. 改造 `src/app/api/deploy/route.ts` — 使用 GitDeployQueue
|
||||
|
||||
### 第五步:新增 Bot 管理页面
|
||||
16. 创建 `src/app/api/bot/status/route.ts`
|
||||
17. 创建 `src/app/api/bot/restart/route.ts`
|
||||
18. 创建 `src/app/bot/page.tsx` — Bot 管理 UI
|
||||
|
||||
### 第六步:清理
|
||||
19. 删除 `bot/` 目录(或归档)
|
||||
20. 更新 `start.bat` — 不再需要分别启动两个进程
|
||||
21. 更新 `.gitignore` — 移除 bot 相关条目
|
||||
|
||||
---
|
||||
|
||||
## 八、解决的问题清单
|
||||
|
||||
| 原问题 | 解决方式 |
|
||||
|---|---|
|
||||
| 两个进程各自操作同一份文件系统 | 合并为一个进程,所有文件操作走同一层 lib |
|
||||
| Hugo 两边各管各的 | HugoManager 单例,一个进程只启动一个 Hugo |
|
||||
| Git deploy 无并发控制 | GitDeployQueue 串行执行 |
|
||||
| bot 调 write API 走 HTTP 网络 | 直接调 lib 函数,零网络开销 |
|
||||
| bot 的 writeReady 不会自动恢复 | HugoManager 带健康检查 + 自动重启 |
|
||||
| 长轮询无退避策略 | 指数退避 3s→60s |
|
||||
| Telegram 图片用临时 URL | 下载到 static/upload/ 本地引用 |
|
||||
| bot 的 admin-panel 单独占一个端口 | 改为 Next.js 内页面 |
|
||||
|
||||
---
|
||||
|
||||
## 九、不变的部分
|
||||
|
||||
以下保持不变,不做改动:
|
||||
- `src/app/` 下所有页面(前端 UI)
|
||||
- `src/components/` 下所有组件
|
||||
- `src/lib/posts.ts` 的核心逻辑(仅改为在 handler 中直接调用)
|
||||
- `src/lib/ai.ts`、`src/lib/artalk.ts`、`src/lib/recycle.ts`、`src/lib/rssapi.ts`
|
||||
- `hugo.toml`、`content/`、`themes/`、`static/`、`data/`
|
||||
- GitHub Actions workflows 和 `scripts/` 目录
|
||||
- `package.json` 的根目录脚本依赖
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
set "STARTUP=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup"
|
||||
set "VBS=E:\GitHub\blog\write\startup.vbs"
|
||||
|
||||
REM Create shortcut in Startup folder
|
||||
echo Set WshShell = CreateObject("WScript.Shell") > "%STARTUP%\WriteBot.vbs"
|
||||
echo WshShell.Run Chr(34) ^& "E:\GitHub\blog\write\start-silent.bat" ^& Chr(34), 0, False >> "%STARTUP%\WriteBot.vbs"
|
||||
echo Set WshShell = Nothing >> "%STARTUP%\WriteBot.vbs"
|
||||
|
||||
echo Done! WriteBot will start silently on Windows login.
|
||||
echo Startup folder: %STARTUP%
|
||||
pause
|
||||
@@ -0,0 +1,21 @@
|
||||
# Get Startup folder path
|
||||
$startup = [Environment]::GetFolderPath('Startup')
|
||||
$batPath = "E:\GitHub\blog\write\start-silent.bat"
|
||||
$vbsPath = Join-Path $startup "WriteBot.vbs"
|
||||
|
||||
# Create VBS wrapper for silent execution (no cmd window)
|
||||
$vbs = @"
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
WshShell.Run Chr(34) & "$batPath" & Chr(34), 0, False
|
||||
Set WshShell = Nothing
|
||||
"@
|
||||
|
||||
$vbs | Out-File -FilePath $vbsPath -Encoding ASCII
|
||||
|
||||
# Verify
|
||||
if (Test-Path $vbsPath) {
|
||||
Write-Host "WriteBot startup script installed to: $vbsPath"
|
||||
Write-Host "It will start silently on Windows login."
|
||||
} else {
|
||||
Write-Host "Failed to install startup script."
|
||||
}
|
||||
Generated
+13
@@ -11,6 +11,7 @@
|
||||
"date-fns": "^4.2.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^18.0.4",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
@@ -5020,6 +5021,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
|
||||
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"date-fns": "^4.2.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^18.0.4",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo WriteBot Auto-Start Setup
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Check for admin rights
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo This script requires Administrator privileges.
|
||||
echo Right-click this file and select "Run as administrator".
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Creating scheduled task...
|
||||
schtasks /create /tn "WriteBot" /tr "cmd /c cd /d E:\GitHub\blog\write && npm run dev -- -p 8016" /sc onlogon /rl highest /f
|
||||
|
||||
if %errorlevel%==0 (
|
||||
echo.
|
||||
echo [OK] WriteBot scheduled task created.
|
||||
echo.
|
||||
echo Verify: schtasks /query /tn "WriteBot"
|
||||
echo Remove: schtasks /delete /tn "WriteBot" /f
|
||||
) else (
|
||||
echo.
|
||||
echo [FAIL] Could not create task.
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { hugo } from "@/lib/hugo";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
hugo.stop();
|
||||
const result = await hugo.start();
|
||||
return NextResponse.json({ ok: result.ok });
|
||||
} catch (error: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { hugo } from "@/lib/hugo";
|
||||
import { TG_BOT_TOKEN } from "@/lib/config";
|
||||
|
||||
export async function GET() {
|
||||
const hugoStatus = hugo.status();
|
||||
return NextResponse.json({
|
||||
bot: {
|
||||
enabled: !!TG_BOT_TOKEN,
|
||||
polling: !!TG_BOT_TOKEN,
|
||||
},
|
||||
hugo: hugoStatus,
|
||||
});
|
||||
}
|
||||
@@ -1,39 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { BLOG_ROOT } from "@/lib/config";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type GitExecError = Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
};
|
||||
|
||||
async function runGit(args: string[], allowFailure = false) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("git", args, {
|
||||
cwd: BLOG_ROOT,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return { stdout, stderr, code: 0 };
|
||||
} catch (error) {
|
||||
const err = error as GitExecError;
|
||||
const result = {
|
||||
stdout: err.stdout || "",
|
||||
stderr: err.stderr || err.message,
|
||||
code: typeof err.code === "number" ? err.code : 1,
|
||||
};
|
||||
if (allowFailure) return result;
|
||||
throw new Error((result.stderr || result.stdout || err.message).trim());
|
||||
}
|
||||
}
|
||||
|
||||
function isRebaseConflict(message: string) {
|
||||
return /conflict|CONFLICT|Resolve all conflicts|Automatic merge failed|could not apply|rebase/i.test(message);
|
||||
}
|
||||
import { deployQueue } from "@/lib/git";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -44,40 +10,23 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Title is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let output = "";
|
||||
const result = await deployQueue.enqueue(`发布: ${title}`);
|
||||
|
||||
const addResult = await runGit(["add", "content/post", "static"]);
|
||||
output += addResult.stdout + addResult.stderr;
|
||||
|
||||
const diffResult = await runGit(["diff", "--cached", "--quiet"], true);
|
||||
if (diffResult.code === 1) {
|
||||
const commitResult = await runGit(["commit", "-m", `发布: ${title}`]);
|
||||
output += commitResult.stdout + commitResult.stderr;
|
||||
} else if (diffResult.code !== 0) {
|
||||
throw new Error((diffResult.stderr || diffResult.stdout || "检查暂存区失败").trim());
|
||||
if (result.success) {
|
||||
return NextResponse.json({ success: true, output: result.output });
|
||||
}
|
||||
|
||||
try {
|
||||
const pullResult = await runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
|
||||
output += pullResult.stdout + pullResult.stderr;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isRebaseConflict(message)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "远程仓库有新内容,自动合并失败。请在终端解决冲突后执行 git rebase --continue,再重新发布。",
|
||||
detail: message.slice(0, 1000),
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
if (result.conflict) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error,
|
||||
detail: result.output,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const pushResult = await runGit(["push"]);
|
||||
output += pushResult.stdout + pushResult.stderr;
|
||||
|
||||
return NextResponse.json({ success: true, output: output.slice(0, 1000) });
|
||||
return NextResponse.json({ error: result.error }, { status: 500 });
|
||||
} catch (error: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Deploy failed" },
|
||||
|
||||
@@ -1,50 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { BLOG_ROOT, HUGO_PREVIEW } from "@/lib/config";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function isHugoRunning(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(HUGO_PREVIEW, { signal: AbortSignal.timeout(1500) });
|
||||
return res.ok || res.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let starting = false;
|
||||
import { hugo } from "@/lib/hugo";
|
||||
import { HUGO_PREVIEW } from "@/lib/config";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const running = await isHugoRunning();
|
||||
if (running) {
|
||||
const result = await hugo.start();
|
||||
if (result.alreadyRunning || result.ok) {
|
||||
return NextResponse.json({ running: true, url: HUGO_PREVIEW });
|
||||
}
|
||||
|
||||
if (starting) {
|
||||
return NextResponse.json({ running: false, starting: true, url: HUGO_PREVIEW });
|
||||
}
|
||||
|
||||
starting = true;
|
||||
// Kill existing process on port 1313, then start hugo serve
|
||||
const killCmd = `for /f "tokens=5" %a in ('netstat -ano ^| findstr :1313 ^| findstr LISTENING') do taskkill /F /PID %a 2>nul`;
|
||||
const startCmd = `cd "${BLOG_ROOT}" && hugo serve --port 1313 --noHTTPCache`;
|
||||
|
||||
try { await execAsync(killCmd); } catch { /* nothing to kill */ }
|
||||
exec(startCmd, (err) => {
|
||||
if (err) console.error("Hugo start error:", err.message);
|
||||
});
|
||||
|
||||
// Wait a moment for Hugo to start
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
starting = false;
|
||||
|
||||
const started = await isHugoRunning();
|
||||
return NextResponse.json({ running: started, url: HUGO_PREVIEW });
|
||||
return NextResponse.json({ running: false, url: HUGO_PREVIEW });
|
||||
} catch (error: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
hugo.stop();
|
||||
return NextResponse.json({ running: false });
|
||||
} catch (error: unknown) {
|
||||
starting = false;
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed" },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bot, RefreshCw, Server } from "lucide-react";
|
||||
|
||||
interface BotStatus {
|
||||
bot: { enabled: boolean; polling: boolean };
|
||||
hugo: { running: boolean; starting: boolean };
|
||||
}
|
||||
|
||||
export default function BotPage() {
|
||||
const [status, setStatus] = useState<BotStatus | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/bot/status");
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch {
|
||||
setStatus(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function restartHugo() {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await fetch("/api/bot/restart", { method: "POST" });
|
||||
} catch { /* ignore */ }
|
||||
setTimeout(async () => {
|
||||
await fetchStatus();
|
||||
setRestarting(false);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
const timer = setInterval(fetchStatus, 10000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold">Bot 管理</h1>
|
||||
|
||||
{/* Bot Status */}
|
||||
<div className="rounded-lg border border-[var(--border)] p-5 space-y-4">
|
||||
<h2 className="font-semibold text-lg flex items-center gap-2">
|
||||
<Bot size={20} /> Telegram Bot
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
status?.bot.enabled
|
||||
? "bg-green-500"
|
||||
: "bg-gray-400"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{status?.bot.enabled
|
||||
? "已配置,随服务自动启动"
|
||||
: "未配置 TG_BOT_TOKEN"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hugo Status */}
|
||||
<div className="rounded-lg border border-[var(--border)] p-5 space-y-4">
|
||||
<h2 className="font-semibold text-lg flex items-center gap-2">
|
||||
<Server size={20} /> Hugo Server
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
status?.hugo.running
|
||||
? "bg-green-500"
|
||||
: status?.hugo.starting
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-400"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{status?.hugo.running
|
||||
? "运行中 — localhost:1313"
|
||||
: status?.hugo.starting
|
||||
? "启动中..."
|
||||
: "已停止"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={restartHugo}
|
||||
disabled={restarting}
|
||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={14} className={restarting ? "animate-spin" : ""} />
|
||||
{restarting ? "重启中..." : "重启 Hugo"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X } from "lucide-react";
|
||||
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X, Bot } from "lucide-react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
const navItems = [
|
||||
@@ -15,6 +15,7 @@ const navItems = [
|
||||
{ href: "/images", label: "图片", icon: Image },
|
||||
{ href: "/wechat", label: "公众号", icon: MessageCircle },
|
||||
{ href: "/recycle", label: "回收站", icon: Trash2 },
|
||||
{ href: "/bot", label: "Bot", icon: Bot },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { startBot } = await import("@/lib/bot");
|
||||
startBot().catch((e) => {
|
||||
console.error("[instrumentation] bot startup failed:", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
export function getCached<T>(key: string): T | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data as T;
|
||||
}
|
||||
|
||||
export function setCache<T>(key: string, data: T, ttlMs: number): void {
|
||||
cache.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
export function invalidateCache(key: string): void {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
export function invalidateByPrefix(prefix: string): void {
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(prefix)) cache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TG_BOT_TOKEN, TG_ALLOWED_IDS } from "@/lib/config";
|
||||
|
||||
export const TG_TOKEN = TG_BOT_TOKEN;
|
||||
export const ALLOWED_IDS = TG_ALLOWED_IDS;
|
||||
export const API = TG_TOKEN ? `https://api.telegram.org/bot${TG_TOKEN}` : "";
|
||||
@@ -0,0 +1,654 @@
|
||||
import { getPost, listPosts } from "@/lib/posts";
|
||||
import { getLinks, addLink, updateLink, deleteLink, getFeeds, addFeed, updateFeed, deleteFeed, getFeedsHealth, getArticles } from "@/lib/rssapi";
|
||||
import { moveToRecycle } from "@/lib/recycle";
|
||||
import { deployQueue } from "@/lib/git";
|
||||
import { hugo } from "@/lib/hugo";
|
||||
import { getLanIP, DEFAULT_AUTHOR } from "@/lib/config";
|
||||
import { sendMessage, tg } from "./tg";
|
||||
import { getSession, hasSession, clearSession, saveSession, undoLastChunk } from "./sessions";
|
||||
import { makeSlug, sessionSummary, truncateText } from "./helpers";
|
||||
import { getCached, setCache, invalidateByPrefix } from "./cache";
|
||||
|
||||
type Handler = (chatId: number, arg?: string) => Promise<void>;
|
||||
|
||||
const handlers: Record<string, Handler> = {};
|
||||
|
||||
// ---- Cached API wrappers ----
|
||||
|
||||
const CACHE_TTL_LINKS = 5 * 60 * 1000;
|
||||
const CACHE_TTL_FEEDS = 5 * 60 * 1000;
|
||||
const CACHE_TTL_ARTICLES = 3 * 60 * 1000;
|
||||
const CACHE_TTL_HEALTH = 10 * 60 * 1000;
|
||||
|
||||
async function cachedLinks(all = true) {
|
||||
const key = "links_" + (all ? "all" : "visible");
|
||||
const hit = getCached<ReturnType<typeof getLinks> extends Promise<infer R> ? R : never>(key);
|
||||
if (hit) return hit;
|
||||
const data = await getLinks(all);
|
||||
setCache(key, data, CACHE_TTL_LINKS);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function cachedFeeds() {
|
||||
const key = "feeds";
|
||||
const hit = getCached<ReturnType<typeof getFeeds> extends Promise<infer R> ? R : never>(key);
|
||||
if (hit) return hit;
|
||||
const data = await getFeeds();
|
||||
setCache(key, data, CACHE_TTL_FEEDS);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function cachedArticles(limit: number) {
|
||||
const key = "articles_" + limit;
|
||||
const hit = getCached<ReturnType<typeof getArticles> extends Promise<infer R> ? R : never>(key);
|
||||
if (hit) return hit;
|
||||
const data = await getArticles(limit);
|
||||
setCache(key, data, CACHE_TTL_ARTICLES);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function cachedHealth() {
|
||||
const key = "health";
|
||||
const hit = getCached<ReturnType<typeof getFeedsHealth> extends Promise<infer R> ? R : never>(key);
|
||||
if (hit) return hit;
|
||||
const data = await getFeedsHealth();
|
||||
setCache(key, data, CACHE_TTL_HEALTH);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- 键盘 ----
|
||||
|
||||
function mainKeyboard() {
|
||||
return {
|
||||
keyboard: [
|
||||
[{ text: "/new" }, { text: "/list" }],
|
||||
[{ text: "/links" }, { text: "/feeds" }],
|
||||
[{ text: "/stats" }, { text: "/help" }],
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: false,
|
||||
};
|
||||
}
|
||||
|
||||
function writingKeyboard() {
|
||||
return {
|
||||
keyboard: [
|
||||
[{ text: "/preview" }, { text: "/undo" }],
|
||||
[{ text: "/publish" }, { text: "/deploy" }],
|
||||
[{ text: "/draft" }, { text: "/cancel" }],
|
||||
[{ text: "/title" }, { text: "/categories" }],
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMain(chatId: number, text: string) {
|
||||
await sendMessage(chatId, text, { reply_markup: mainKeyboard() });
|
||||
}
|
||||
|
||||
async function sendWrite(chatId: number, text: string) {
|
||||
await sendMessage(chatId, text, { reply_markup: writingKeyboard() });
|
||||
}
|
||||
|
||||
// ---- System ----
|
||||
|
||||
handlers.start = async (chatId) => {
|
||||
const ok = await hugo.ensureRunning();
|
||||
const ip = getLanIP();
|
||||
await sendMain(chatId, "✅ <b>已启动</b>\n\n📱 手机: http://" + ip + ":8016/\n💻 电脑: http://localhost:8016/");
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "点下方按钮打开编辑器:",
|
||||
reply_markup: { inline_keyboard: [[{ text: "🌐 打开编辑器", web_app: { url: "http://" + ip + ":8016/" } }]] },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.stop = async (chatId) => { hugo.stop(); await sendMain(chatId, "⚡ Hugo 已停止。"); };
|
||||
|
||||
handlers.status = async (chatId) => {
|
||||
const s = hugo.status();
|
||||
const status = s.running ? "✅ 运行中" : s.starting ? "⏳ 启动中..." : "⏸ 已停止";
|
||||
const lines = ["<b>Hugo:</b> " + status];
|
||||
if (s.running) { const ip = getLanIP(); lines.push("📱 http://" + ip + ":8016", "💻 http://localhost:8016"); }
|
||||
await sendMain(chatId, lines.join("\n"));
|
||||
};
|
||||
|
||||
handlers.ip = async (chatId) => { await sendMain(chatId, "Phone: http://" + getLanIP() + ":8016"); };
|
||||
|
||||
handlers.shutdown = async (chatId) => {
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "⚠️ 确认关机?\n\n电脑将在确认后 10 秒关机。",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "✅ 确认关机", callback_data: "confirm_shutdown" },
|
||||
{ text: "❌ 取消", callback_data: "cancel_shutdown" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
handlers.confirm_shutdown = async (chatId) => {
|
||||
await sendMessage(chatId, "⏻ 电脑将在 10 秒后关机...");
|
||||
const { exec } = await import("child_process");
|
||||
exec("shutdown /s /t 10", (err) => {
|
||||
if (err) console.error("[shutdown]", err.message);
|
||||
});
|
||||
};
|
||||
|
||||
handlers.cancel_shutdown = async (chatId) => {
|
||||
const { exec } = await import("child_process");
|
||||
exec("shutdown /a", () => {});
|
||||
await sendMain(chatId, "已取消关机。");
|
||||
};
|
||||
|
||||
handlers.reboot = async (chatId) => {
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "⚠️ 确认重启?\n\n电脑将在确认后 10 秒重启。",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "✅ 确认重启", callback_data: "confirm_reboot" },
|
||||
{ text: "❌ 取消", callback_data: "cancel_reboot" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
handlers.confirm_reboot = async (chatId) => {
|
||||
await sendMessage(chatId, "🔄 电脑将在 10 秒后重启...");
|
||||
const { exec } = await import("child_process");
|
||||
exec("shutdown /r /t 10", (err) => {
|
||||
if (err) console.error("[reboot]", err.message);
|
||||
});
|
||||
};
|
||||
|
||||
handlers.cancel_reboot = async (chatId) => {
|
||||
const { exec } = await import("child_process");
|
||||
exec("shutdown /a", () => {});
|
||||
await sendMain(chatId, "已取消重启。");
|
||||
};
|
||||
|
||||
handlers.help = async (chatId) => {
|
||||
if (hasSession(chatId)) {
|
||||
await sendWrite(chatId,
|
||||
"✍ <b>写作中</b>\n\n" +
|
||||
"/publish 保存 /draft 草稿 /deploy 部署\n" +
|
||||
"/preview 预览 /undo 撤销 /cancel 取消\n" +
|
||||
"/title 标题 /categories 分类 /tags 标签\n\n" +
|
||||
"直接发消息追加正文,发照片追加图片"
|
||||
);
|
||||
} else {
|
||||
await sendMain(chatId,
|
||||
"🤖 <b>WriteBot 命令</b>\n\n" +
|
||||
"📝 <b>文章</b>\n" +
|
||||
"/new 标题 — 新建\n" +
|
||||
"/edit slug — 编辑\n" +
|
||||
"/delete slug — 删除\n" +
|
||||
"/list — 文章列表\n\n" +
|
||||
"🔗 <b>友链</b>\n" +
|
||||
"/links — 列表\n" +
|
||||
"/link_add url 名称 — 添加\n" +
|
||||
"/link_edit url — 编辑\n" +
|
||||
"/link_del url — 删除\n" +
|
||||
"/link_toggle url — 显示/隐藏\n\n" +
|
||||
"📡 <b>订阅</b>\n" +
|
||||
"/feeds — 列表\n" +
|
||||
"/feed_add url — 添加\n" +
|
||||
"/feed_edit url — 编辑\n" +
|
||||
"/feed_del url — 删除\n" +
|
||||
"/feed_health — 健康检查\n\n" +
|
||||
"📰 /read — 友链最新文章\n" +
|
||||
"📊 /stats — 网站数据\n\n" +
|
||||
"⏻ /shutdown — 关机 🔄 /reboot — 重启"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Articles: New ----
|
||||
|
||||
handlers.new = async (chatId, title) => {
|
||||
if (title) {
|
||||
const s = getSession(chatId);
|
||||
s.title = title;
|
||||
s.slug = makeSlug(title);
|
||||
s.content = "";
|
||||
s.categories = [];
|
||||
s.tags = [];
|
||||
s.draft = true;
|
||||
s.author = DEFAULT_AUTHOR;
|
||||
s.chunks = [];
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "📝 开始写:<b>" + title + "</b>\n\n📂 设置分类(可选)",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "跳过", callback_data: "skip_cat" },
|
||||
{ text: "设置分类", callback_data: "skip_cat" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "请输入分类(逗号分隔,或发「跳过」):",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "技术、生活" },
|
||||
});
|
||||
} else {
|
||||
await tg("sendMessage", { chat_id: chatId, text: "📝 请输入文章标题:", reply_markup: { force_reply: true, input_field_placeholder: "在这里输入标题..." } });
|
||||
}
|
||||
};
|
||||
|
||||
handlers.skip_categories = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s.title) return;
|
||||
s.categories = [];
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "🏷 设置标签(可选)",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "跳过", callback_data: "skip_tag" },
|
||||
{ text: "设置标签", callback_data: "skip_tag" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "请输入标签(逗号分隔,或发「跳过」):",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "Hugo、博客" },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.skip_tags = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s.title) return;
|
||||
s.tags = [];
|
||||
await sendWrite(chatId, "✅ 准备就绪,开始写正文吧!\n\n📝 <b>" + s.title + "</b>\n📂 " + (s.categories.join("、") || "无分类") + "\n🏷 " + (s.tags.join("、") || "无标签"));
|
||||
};
|
||||
|
||||
handlers.categories = async (chatId, cats) => {
|
||||
const s = getSession(chatId);
|
||||
s.categories = cats ? cats.split(/[,;",、]+/).map((c) => c.trim()).filter(Boolean) : [];
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "📂 分类:" + (s.categories.join("、") || "无") + "\n\n🏷 设置标签(可选)",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "跳过", callback_data: "skip_tag" },
|
||||
{ text: "设置标签", callback_data: "skip_tag" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "请输入标签(逗号分隔,或发「跳过」):",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "Hugo、博客" },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.tags = async (chatId, tags) => {
|
||||
const s = getSession(chatId);
|
||||
s.tags = tags ? tags.split(/[,;",、]+/).map((t) => t.trim()).filter(Boolean) : [];
|
||||
await sendWrite(chatId, "✅ 准备就绪,开始写正文吧!\n\n📝 <b>" + s.title + "</b>\n📂 " + (s.categories.join("、") || "无分类") + "\n🏷 " + (s.tags.join("、") || "无标签"));
|
||||
};
|
||||
|
||||
// ---- Articles: Edit (方案 B) ----
|
||||
|
||||
handlers.edit = async (chatId, slug) => {
|
||||
if (!slug) { await sendMain(chatId, "格式:/edit slug\n先用 /list 查看文章。"); return; }
|
||||
const post = getPost(slug);
|
||||
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
|
||||
|
||||
const originalContent = post.content || "";
|
||||
if (originalContent) {
|
||||
const maxLen = 3800;
|
||||
for (let i = 0; i < originalContent.length; i += maxLen) {
|
||||
const chunk = originalContent.slice(i, i + maxLen);
|
||||
const prefix = originalContent.length > maxLen ? "(" + (Math.floor(i / maxLen) + 1) + "/" + Math.ceil(originalContent.length / maxLen) + ")\n" : "";
|
||||
await sendMessage(chatId, prefix + chunk);
|
||||
}
|
||||
}
|
||||
|
||||
const s = getSession(chatId);
|
||||
s.title = post.frontMatter.title;
|
||||
s.slug = post.frontMatter.slug;
|
||||
s.originalSlug = post.frontMatter.slug;
|
||||
s.content = "";
|
||||
s.chunks = [];
|
||||
s.categories = (post.frontMatter.categories as string[]) || [];
|
||||
s.tags = (post.frontMatter.tags as string[]) || [];
|
||||
s.author = post.frontMatter.author || DEFAULT_AUTHOR;
|
||||
s.draft = post.frontMatter.draft ?? true;
|
||||
|
||||
await sendWrite(chatId,
|
||||
"✏ 编辑:<b>" + s.title + "</b>\n\n" +
|
||||
"原文已显示,现在重新写正文\n" +
|
||||
"📂 " + (s.categories.join("、") || "无分类") + "\n" +
|
||||
"🏷 " + (s.tags.join("、") || "无标签") + "\n\n" +
|
||||
"直接发消息写正文,完成后点「发布」"
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Articles: Delete (二次确认) ----
|
||||
|
||||
handlers.delete = async (chatId, slug) => {
|
||||
if (!slug) { await sendMain(chatId, "格式:/delete slug\n先用 /list 查看文章。"); return; }
|
||||
const post = getPost(slug);
|
||||
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "⚠️ 确认删除《" + post.frontMatter.title + "》?\n\n删除后移入回收站,30天内可恢复。",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "✅ 确认删除", callback_data: "confirm_del_" + slug },
|
||||
{ text: "❌ 取消", callback_data: "cancel_del_" + slug },
|
||||
]],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
handlers.confirm_del = async (chatId, slug) => {
|
||||
if (!slug) return;
|
||||
const post = getPost(slug);
|
||||
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
|
||||
moveToRecycle(slug, post.dirPath, post.frontMatter.title);
|
||||
await sendMain(chatId, "✂ 已移入回收站:" + post.frontMatter.title + "\n30天内可恢复。");
|
||||
};
|
||||
|
||||
handlers.cancel_del = async (chatId) => {
|
||||
await sendMain(chatId, "已取消删除。");
|
||||
};
|
||||
|
||||
// ---- Articles: List ----
|
||||
|
||||
handlers.list = async (chatId, pageStr) => {
|
||||
const page = parseInt(pageStr || "1") || 1;
|
||||
const perPage = 10;
|
||||
try {
|
||||
const { posts, total, totalPages } = listPosts({ page, perPage });
|
||||
if (total === 0) { await sendMain(chatId, "📖 还没有文章,发 /new 开始写。"); return; }
|
||||
const lines = ["📖 <b>文章列表</b> 共 " + total + " 篇 · 第 " + page + "/" + totalPages + " 页\n"];
|
||||
for (const p of posts) {
|
||||
const draft = p.draft ? " 📝" : "";
|
||||
lines.push(p.title + draft + "\n <code>" + p.slug + "</code>");
|
||||
}
|
||||
const navRow: { text: string; callback_data: string }[] = [];
|
||||
if (page > 1) navRow.push({ text: "◀ 上一页", callback_data: "list_" + (page - 1) });
|
||||
if (page < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "list_" + (page + 1) });
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
|
||||
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
|
||||
});
|
||||
} catch (e: unknown) { await sendMain(chatId, "❌ " + (e instanceof Error ? e.message : String(e))); }
|
||||
};
|
||||
|
||||
// ---- Articles: View / Title / Author / Preview ----
|
||||
|
||||
handlers.view = async (chatId, slug) => {
|
||||
if (!slug) { await sendMain(chatId, "格式:/view slug"); return; }
|
||||
const ip = getLanIP();
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "🔖 <b>" + slug + "</b>\n\n📱 http://" + ip + ":1313/" + slug + ".html\n💻 http://localhost:1313/" + slug + ".html",
|
||||
parse_mode: "HTML", disable_web_page_preview: true,
|
||||
reply_markup: { inline_keyboard: [[{ text: "🌐 浏览器打开", url: "http://localhost:1313/" + slug + ".html" }]] },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.title = async (chatId, newTitle) => {
|
||||
if (!newTitle) { await tg("sendMessage", { chat_id: chatId, text: "请输入新标题:", reply_markup: { force_reply: true, input_field_placeholder: "新标题..." } }); return; }
|
||||
const s = getSession(chatId);
|
||||
s.title = newTitle;
|
||||
await sendWrite(chatId, "✏ 标题已更新:<b>" + newTitle + "</b>");
|
||||
};
|
||||
|
||||
handlers.author = async (chatId, author) => {
|
||||
getSession(chatId).author = author || DEFAULT_AUTHOR;
|
||||
await sendWrite(chatId, "✍ 作者:" + getSession(chatId).author);
|
||||
};
|
||||
|
||||
handlers.preview = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
|
||||
if (!s.content) { await sendWrite(chatId, "正文为空。"); return; }
|
||||
await sendWrite(chatId, "📝 <b>" + s.title + "</b>\n\n" + truncateText(s.content, 3800));
|
||||
};
|
||||
|
||||
// ---- Writing: Publish / Draft / Deploy / Sync / Undo / Cancel ----
|
||||
|
||||
handlers.publish = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
|
||||
if (!s.content) { await sendWrite(chatId, "正文为空,先写点内容再发布。"); return; }
|
||||
try {
|
||||
const title = s.title;
|
||||
await saveSession(s, false);
|
||||
clearSession(chatId);
|
||||
await sendMain(chatId, "✅ 已保存:<b>" + title + "</b>\n\n发 /deploy 推送到线上");
|
||||
} catch (e: unknown) { await sendWrite(chatId, "❌ 保存失败:" + (e instanceof Error ? e.message : String(e))); }
|
||||
};
|
||||
|
||||
handlers.draft = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
|
||||
if (!s.content) { await sendWrite(chatId, "正文为空,先写点内容再保存。"); return; }
|
||||
try {
|
||||
const title = s.title;
|
||||
await saveSession(s, true);
|
||||
clearSession(chatId);
|
||||
await sendMain(chatId, "📄 草稿已保存:<b>" + title + "</b>");
|
||||
} catch (e: unknown) { await sendWrite(chatId, "❌ 保存失败:" + (e instanceof Error ? e.message : String(e))); }
|
||||
};
|
||||
|
||||
handlers.deploy = async (chatId) => {
|
||||
await sendMessage(chatId, "⏳ 正在部署...", { reply_markup: { remove_keyboard: true } });
|
||||
const result = await deployQueue.enqueue("发布文章");
|
||||
if (result.success) await sendMain(chatId, "🚀 <b>已部署!</b>\n\n🌐 https://usj.cc");
|
||||
else if (result.conflict) await sendMain(chatId, "⚠️ " + (result.error || "远程冲突"));
|
||||
else await sendMain(chatId, "❌ 部署失败:" + (result.error || "未知错误"));
|
||||
};
|
||||
|
||||
handlers.sync = async (chatId) => {
|
||||
await sendMessage(chatId, "⏳ 正在同步远程...");
|
||||
const result = await deployQueue.sync();
|
||||
if (result.success) await sendMain(chatId, "✅ 同步完成\n\n" + (result.output || "").slice(0, 500));
|
||||
else await sendMain(chatId, "❌ " + (result.error || "同步失败"));
|
||||
};
|
||||
|
||||
handlers.undo = async (chatId) => {
|
||||
if (!hasSession(chatId)) { await sendMain(chatId, "没有活动的草稿。"); return; }
|
||||
const ok = undoLastChunk(chatId);
|
||||
if (!ok) { await sendWrite(chatId, "没有可撤销的内容。"); return; }
|
||||
const s = getSession(chatId);
|
||||
await sendWrite(chatId, "↩ 已撤销\n\n📝 <b>" + s.title + "</b>\n\n" + (s.content ? truncateText(s.content, 3800) : "(正文为空)"));
|
||||
};
|
||||
|
||||
handlers.cancel = async (chatId) => {
|
||||
if (!hasSession(chatId)) { await sendMain(chatId, "没有活动的草稿。"); return; }
|
||||
clearSession(chatId);
|
||||
await sendMain(chatId, "✂ 草稿已丢弃。");
|
||||
};
|
||||
|
||||
// ---- Links ----
|
||||
|
||||
handlers.links = async (chatId, pageStr) => {
|
||||
const page = parseInt(pageStr || "1") || 1;
|
||||
const perPage = 10;
|
||||
try {
|
||||
const allLinks = await cachedLinks(true);
|
||||
if (allLinks.length === 0) { await sendMain(chatId, "还没有友链。\n\n添加:/link_add url 名称"); return; }
|
||||
const totalPages = Math.ceil(allLinks.length / perPage);
|
||||
const p = Math.min(page, totalPages);
|
||||
const start = (p - 1) * perPage;
|
||||
const pageLinks = allLinks.slice(start, start + perPage);
|
||||
const lines = ["🔗 <b>友链</b> 共 " + allLinks.length + " 个 · 第 " + p + "/" + totalPages + " 页\n"];
|
||||
for (const l of pageLinks) {
|
||||
const icon = l.hidden ? "🔒" : "🌐";
|
||||
lines.push(icon + " " + (l.name || l.url) + "\n " + l.url);
|
||||
}
|
||||
lines.push("\n/link_add /link_edit /link_del /link_toggle");
|
||||
const navRow: { text: string; callback_data: string }[] = [];
|
||||
if (p > 1) navRow.push({ text: "◀ 上一页", callback_data: "links_" + (p - 1) });
|
||||
if (p < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "links_" + (p + 1) });
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
|
||||
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
|
||||
});
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.link_add = async (chatId, arg) => {
|
||||
if (!arg) { await tg("sendMessage", { chat_id: chatId, text: "请输入友链信息:\n格式:url 名称", reply_markup: { force_reply: true, input_field_placeholder: "url 名称" } }); return; }
|
||||
const parts = arg.split(/\s+/);
|
||||
let url = parts[0];
|
||||
const name = parts.slice(1).join(" ") || url;
|
||||
if (!url.startsWith("http")) url = "https://" + url;
|
||||
try { await addLink({ name, url }); invalidateByPrefix("links"); await sendMain(chatId, "✅ 友链已添加:" + name); }
|
||||
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.link_edit = async (chatId, arg) => {
|
||||
if (!arg) { await sendMain(chatId, "格式:/link_edit url\n用 /links 查看友链。"); return; }
|
||||
const s = getSession(chatId);
|
||||
s.originalSlug = arg;
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "当前 URL:" + arg + "\n请输入新名称(空则保持不变):",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "新名称" },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.link_del = async (chatId, url) => {
|
||||
if (!url) { await sendMain(chatId, "格式:/link_del url"); return; }
|
||||
try { await deleteLink(url); invalidateByPrefix("links"); await sendMain(chatId, "✅ 已删除:" + url); }
|
||||
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.link_toggle = async (chatId, url) => {
|
||||
if (!url) { await sendMain(chatId, "格式:/link_toggle url"); return; }
|
||||
try {
|
||||
const allLinks = await cachedLinks(true);
|
||||
const link = allLinks.find((l) => l.url === url);
|
||||
if (!link) { await sendMain(chatId, "❌ 未找到:" + url); return; }
|
||||
await updateLink(url, { hidden: !link.hidden });
|
||||
invalidateByPrefix("links");
|
||||
await sendMain(chatId, (!link.hidden ? "🔒 已隐藏" : "🌐 已显示") + ":" + (link.name || url));
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
// ---- Feeds ----
|
||||
|
||||
handlers.feeds = async (chatId, pageStr) => {
|
||||
const page = parseInt(pageStr || "1") || 1;
|
||||
const perPage = 10;
|
||||
try {
|
||||
const allFeeds = await cachedFeeds();
|
||||
if (allFeeds.length === 0) { await sendMain(chatId, "还没有订阅源。\n\n添加:/feed_add url"); return; }
|
||||
const totalPages = Math.ceil(allFeeds.length / perPage);
|
||||
const p = Math.min(page, totalPages);
|
||||
const start = (p - 1) * perPage;
|
||||
const pageFeeds = allFeeds.slice(start, start + perPage);
|
||||
const lines = ["📡 <b>订阅源</b> 共 " + allFeeds.length + " 个 · 第 " + p + "/" + totalPages + " 页\n"];
|
||||
for (const f of pageFeeds) {
|
||||
lines.push("📰 " + (f.feedTitle || f.url) + "\n " + f.url);
|
||||
}
|
||||
lines.push("\n/feed_add /feed_edit /feed_del /feed_health");
|
||||
const navRow: { text: string; callback_data: string }[] = [];
|
||||
if (p > 1) navRow.push({ text: "◀ 上一页", callback_data: "feeds_" + (p - 1) });
|
||||
if (p < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "feeds_" + (p + 1) });
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
|
||||
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
|
||||
});
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.feed_add = async (chatId, arg) => {
|
||||
if (!arg) { await tg("sendMessage", { chat_id: chatId, text: "请输入订阅源 URL:", reply_markup: { force_reply: true, input_field_placeholder: "https://example.com/rss.xml" } }); return; }
|
||||
const parts = arg.split(/\s+/);
|
||||
const url = parts[0];
|
||||
const feedTitle = parts.slice(1).join(" ") || "";
|
||||
try { await addFeed(url, feedTitle); invalidateByPrefix("feeds"); await sendMain(chatId, "✅ 订阅源已添加:" + url); }
|
||||
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.feed_edit = async (chatId, arg) => {
|
||||
if (!arg) { await sendMain(chatId, "格式:/feed_edit url\n用 /feeds 查看。"); return; }
|
||||
const s = getSession(chatId);
|
||||
s.originalSlug = arg;
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "当前 URL:" + arg + "\n请输入新的订阅信息:\n格式:新URL 新名称",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "新URL 新名称" },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.feed_del = async (chatId, url) => {
|
||||
if (!url) { await sendMain(chatId, "格式:/feed_del url"); return; }
|
||||
try { await deleteFeed(url); invalidateByPrefix("feeds"); await sendMain(chatId, "✅ 已删除:" + url); }
|
||||
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.feed_health = async (chatId) => {
|
||||
try {
|
||||
await sendMessage(chatId, "⏳ 正在检测订阅源...");
|
||||
const result = await cachedHealth();
|
||||
const lines = ["📊 <b>订阅源健康报告</b>\n", "✅ 存活:" + result.alive + " ❌ 异常:" + result.dead];
|
||||
const dead = (result.results || []).filter((r) => !r.ok);
|
||||
if (dead.length > 0) {
|
||||
lines.push("\n❌ 异常站点:");
|
||||
for (const d of dead.slice(0, 10)) {
|
||||
lines.push("🔴 " + d.url + " — " + (d.error || String(d.status)));
|
||||
}
|
||||
}
|
||||
await sendMain(chatId, lines.join("\n"));
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
// ---- Data ----
|
||||
|
||||
handlers.stats = async (chatId) => {
|
||||
try {
|
||||
const { total } = listPosts({ perPage: 1 });
|
||||
const allLinks = await cachedLinks(true);
|
||||
const allFeeds = await cachedFeeds();
|
||||
const visibleLinks = allLinks.filter((l) => !l.hidden).length;
|
||||
|
||||
let extra = "";
|
||||
try {
|
||||
const healthRes = await cachedHealth();
|
||||
extra = "\n\n📰 友链博客:" + healthRes.total + " 个";
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
await sendMain(chatId,
|
||||
"📊 <b>网站数据</b>\n\n" +
|
||||
"📝 本地文章:" + total + " 篇\n" +
|
||||
"🔗 友链:" + visibleLinks + " 个\n" +
|
||||
"📡 订阅源:" + allFeeds.length + " 个" +
|
||||
extra + "\n\n🌐 usj.cc"
|
||||
);
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.read = async (chatId, limitStr) => {
|
||||
const limit = parseInt(limitStr || "10") || 10;
|
||||
try {
|
||||
const articles = await cachedArticles(Math.min(limit, 20));
|
||||
if (articles.length === 0) { await sendMain(chatId, "暂无最新文章。"); return; }
|
||||
const lines = ["📰 <b>友链最新文章</b>(" + articles.length + " 篇)\n"];
|
||||
for (const a of articles) {
|
||||
lines.push((a.feedName || "未知") + " — " + a.title + "\n " + a.link);
|
||||
}
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
|
||||
});
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
export default handlers;
|
||||
@@ -0,0 +1,37 @@
|
||||
import path from "path";
|
||||
import { CONTENT_DIR } from "@/lib/config";
|
||||
|
||||
export function makeSlug(title: string): string {
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const slugPart = (title || "untitled")
|
||||
.replace(/[^\w一-鿿㐀-䶿]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase() || "post";
|
||||
return dateStr + "-" + slugPart;
|
||||
}
|
||||
|
||||
export function sessionSummary(s: {
|
||||
title: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
content: string;
|
||||
}): string {
|
||||
const lines = s.content ? s.content.split("\n").filter(Boolean).length : 0;
|
||||
const chars = s.content.length;
|
||||
const parts: string[] = [];
|
||||
parts.push(`\u{1f4dd} <b>${s.title || "未命名"}</b>`);
|
||||
if (s.categories.length) parts.push(`\u{1f4c2} ${s.categories.join("、")}`);
|
||||
if (s.tags.length) parts.push(`\u{1f3f7} ${s.tags.join("、")}`);
|
||||
if (chars) parts.push(`✍ ${lines} 行 ${chars} 字`);
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
export function getArticleDirPath(slug: string): string {
|
||||
const year = slug.slice(0, 4);
|
||||
return path.join(CONTENT_DIR, year, slug);
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { startPolling } from "./poll";
|
||||
import { TG_TOKEN } from "./config";
|
||||
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
export async function startBot(): Promise<void> {
|
||||
if (g.__botStarted) return;
|
||||
g.__botStarted = true;
|
||||
|
||||
if (!TG_TOKEN) {
|
||||
console.log("[bot] TG_BOT_TOKEN not configured, bot disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[bot] initializing...");
|
||||
await startPolling();
|
||||
}
|
||||
|
||||
export { TG_TOKEN } from "./config";
|
||||
export { sendMessage } from "./tg";
|
||||
export { getSession, hasSession, clearSession } from "./sessions";
|
||||
@@ -0,0 +1,246 @@
|
||||
import { TG_TOKEN } from "./config";
|
||||
import { tg, allowed, sendMessage } from "./tg";
|
||||
import { hasSession, getSession, appendContent } from "./sessions";
|
||||
import handlers from "./handlers";
|
||||
|
||||
const ARG_COMMANDS = new Set([
|
||||
"new", "title", "categories", "tags", "author",
|
||||
"edit", "view", "delete",
|
||||
"link_add", "link_edit", "link_del", "link_toggle",
|
||||
"feed_add", "feed_edit", "feed_del",
|
||||
"list", "links", "feeds", "read",
|
||||
]);
|
||||
|
||||
const CALLBACK_PREFIXES: Record<string, string> = {
|
||||
edit: "edit",
|
||||
confirm_del: "delete",
|
||||
cancel_del: "noop",
|
||||
list: "list",
|
||||
links: "links",
|
||||
feeds: "feeds",
|
||||
skip_cat: "skip_categories",
|
||||
skip_tag: "skip_tags",
|
||||
confirm_shutdown: "confirm_shutdown",
|
||||
cancel_shutdown: "cancel_shutdown",
|
||||
confirm_reboot: "confirm_reboot",
|
||||
cancel_reboot: "cancel_reboot",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export async function startPolling(): Promise<void> {
|
||||
if (!TG_TOKEN) {
|
||||
console.log("[bot] TG_BOT_TOKEN not set, skipping bot startup");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[bot] starting Telegram polling...");
|
||||
|
||||
let lastOffset = 0;
|
||||
let backoff = 3000;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const data = await tg<Record<string, unknown>[]>("getUpdates", {
|
||||
offset: lastOffset + 1,
|
||||
timeout: 30,
|
||||
allowed_updates: ["message", "callback_query"],
|
||||
});
|
||||
|
||||
if (data.ok && data.result) {
|
||||
for (const update of data.result) {
|
||||
lastOffset = update.update_id as number;
|
||||
|
||||
if (update.callback_query) {
|
||||
const cb = update.callback_query as {
|
||||
message?: { chat?: { id: number } };
|
||||
from?: { id: number };
|
||||
id: string;
|
||||
data?: string;
|
||||
};
|
||||
const cbChatId = cb.message?.chat?.id ?? cb.from?.id ?? 0;
|
||||
if (cb.data && allowed(cbChatId)) {
|
||||
await handleInlineCallback(cbChatId, cb.id, cb.data);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const msg = update.message as {
|
||||
chat?: { id: number };
|
||||
photo?: { file_id: string }[];
|
||||
text?: string;
|
||||
caption?: string;
|
||||
reply_to_message?: { text?: string };
|
||||
} | undefined;
|
||||
if (!msg?.chat?.id) continue;
|
||||
const chatId = msg.chat.id;
|
||||
if (!allowed(chatId)) continue;
|
||||
|
||||
if (msg.photo && hasSession(chatId)) {
|
||||
await handlePhoto(chatId, msg.photo, msg.caption);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!msg.text) continue;
|
||||
const fullText = msg.text.trim();
|
||||
|
||||
// ForceReply routing
|
||||
if (msg.reply_to_message?.text) {
|
||||
const original = msg.reply_to_message.text;
|
||||
if (original.includes("请输入文章标题")) { await handleCommand(chatId, "new", fullText); continue; }
|
||||
if (original.includes("请输入新标题")) { await handleCommand(chatId, "title", fullText); continue; }
|
||||
if (original.includes("请输入分类")) { await handleCommand(chatId, "categories", fullText); continue; }
|
||||
if (original.includes("请输入标签")) { await handleCommand(chatId, "tags", fullText); continue; }
|
||||
if (original.includes("请输入友链信息")) { await handleCommand(chatId, "link_add", fullText); continue; }
|
||||
if (original.includes("请输入新名称")) { await handleCommand(chatId, "link_edit", fullText); continue; }
|
||||
if (original.includes("请输入订阅源 URL")) { await handleCommand(chatId, "feed_add", fullText); continue; }
|
||||
if (original.includes("请输入新的订阅信息")) { await handleCommand(chatId, "feed_edit", fullText); continue; }
|
||||
}
|
||||
|
||||
const parts = fullText.split("@")[0].split(/\s+/);
|
||||
const cmdRaw = parts[0].toLowerCase();
|
||||
const slashCmd = cmdRaw.startsWith("/") ? cmdRaw.slice(1) : "";
|
||||
const slashArg = parts.slice(1).join(" ");
|
||||
|
||||
// In writing session, non-slash text appends to content
|
||||
if (hasSession(chatId) && !cmdRaw.startsWith("/")) {
|
||||
try { appendContent(chatId, fullText); }
|
||||
catch (e) { console.error("[bot:append]", e instanceof Error ? e.message : String(e)); }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmdRaw.startsWith("/")) {
|
||||
await handleCommand(chatId, slashCmd, slashArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backoff = 3000;
|
||||
} catch (e) {
|
||||
console.error("[bot:poll]", e instanceof Error ? e.message : String(e));
|
||||
await sleep(backoff);
|
||||
backoff = Math.min(backoff * 2, 60000);
|
||||
}
|
||||
|
||||
await sleep(100);
|
||||
setImmediate(poll);
|
||||
}
|
||||
|
||||
// Register commands with Telegram
|
||||
try {
|
||||
await tg("setMyCommands", {
|
||||
commands: [
|
||||
{ command: "new", description: "写一篇文章" },
|
||||
{ command: "edit", description: "编辑文章" },
|
||||
{ command: "delete", description: "删除文章" },
|
||||
{ command: "list", description: "文章列表" },
|
||||
{ command: "publish", description: "保存发布" },
|
||||
{ command: "draft", description: "保存草稿" },
|
||||
{ command: "deploy", description: "推送到线上" },
|
||||
{ command: "sync", description: "同步远程" },
|
||||
{ command: "undo", description: "撤销最后一段" },
|
||||
{ command: "preview", description: "预览" },
|
||||
{ command: "links", description: "友链管理" },
|
||||
{ command: "link_add", description: "添加友链" },
|
||||
{ command: "link_edit", description: "编辑友链" },
|
||||
{ command: "link_del", description: "删除友链" },
|
||||
{ command: "link_toggle", description: "显示/隐藏友链" },
|
||||
{ command: "feeds", description: "订阅管理" },
|
||||
{ command: "feed_add", description: "添加订阅源" },
|
||||
{ command: "feed_edit", description: "编辑订阅源" },
|
||||
{ command: "feed_del", description: "删除订阅源" },
|
||||
{ command: "feed_health", description: "订阅源健康检查" },
|
||||
{ command: "read", description: "友链最新文章" },
|
||||
{ command: "stats", description: "网站数据" },
|
||||
{ command: "shutdown", description: "关机" },
|
||||
{ command: "reboot", description: "重启" },
|
||||
{ command: "help", description: "帮助" },
|
||||
],
|
||||
});
|
||||
console.log("[bot] commands registered");
|
||||
} catch (e) {
|
||||
console.error("[bot] failed to register commands:", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handlePhoto(chatId: number, photos: { file_id: string }[], caption?: string) {
|
||||
try {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const { CONTENT_DIR } = await import("@/lib/config");
|
||||
const { getSession } = await import("./sessions");
|
||||
const { getArticleDirPath } = await import("./helpers");
|
||||
|
||||
const s = getSession(chatId);
|
||||
const largestPhoto = photos[photos.length - 1];
|
||||
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: largestPhoto.file_id });
|
||||
if (!fileRes.ok || !fileRes.result?.file_path) return;
|
||||
|
||||
const filePath = fileRes.result.file_path;
|
||||
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
|
||||
const imgBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer());
|
||||
|
||||
// Save to article's own folder (Hugo page bundle)
|
||||
const articleDir = getArticleDirPath(s.slug);
|
||||
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
|
||||
|
||||
const ext = filePath.split(".").pop() || "jpg";
|
||||
const filename = Date.now() + "-" + largestPhoto.file_id.slice(0, 8) + "." + ext;
|
||||
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(imgBuffer));
|
||||
|
||||
const imgCaption = caption || "image";
|
||||
appendContent(chatId, `\n\n`);
|
||||
await sendMessage(chatId, `📷 图片已保存:${filename}`);
|
||||
} catch (e) {
|
||||
console.error("[bot:photo]", e instanceof Error ? e.message : String(e));
|
||||
await sendMessage(chatId, "❌ 图片保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommand(chatId: number, cmd: string, arg?: string) {
|
||||
try {
|
||||
if (cmd === "noop") return;
|
||||
if (ARG_COMMANDS.has(cmd)) {
|
||||
const handler = handlers[cmd];
|
||||
if (handler) await handler(chatId, arg);
|
||||
else await sendMessage(chatId, "❌ 未知命令:/" + cmd);
|
||||
} else {
|
||||
const handler = handlers[cmd];
|
||||
if (handler) await handler(chatId);
|
||||
else if (cmd) await sendMessage(chatId, "❌ 未知命令:/" + cmd);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[bot:" + cmd + "]", e instanceof Error ? e.message : String(e));
|
||||
try {
|
||||
await sendMessage(chatId, "❌ " + (e instanceof Error ? e.message : "error").slice(0, 200));
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInlineCallback(chatId: number, queryId: string, data: string) {
|
||||
let prefix = "";
|
||||
let arg = "";
|
||||
// First try prefix match (e.g. "edit_slug", "list_2")
|
||||
for (const p of Object.keys(CALLBACK_PREFIXES)) {
|
||||
if (data.startsWith(p + "_")) {
|
||||
prefix = p;
|
||||
arg = data.slice(p.length + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Then try exact match (e.g. "confirm_shutdown", "skip_cat")
|
||||
if (!prefix && CALLBACK_PREFIXES[data]) {
|
||||
prefix = data;
|
||||
}
|
||||
if (!prefix) {
|
||||
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
|
||||
return;
|
||||
}
|
||||
const cmd = CALLBACK_PREFIXES[prefix];
|
||||
await handleCommand(chatId, cmd, arg);
|
||||
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createPost, updatePost, getPost } from "@/lib/posts";
|
||||
import { DEFAULT_AUTHOR } from "@/lib/config";
|
||||
|
||||
export interface Session {
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
author: string;
|
||||
draft: boolean;
|
||||
chunks: string[];
|
||||
originalSlug?: string;
|
||||
}
|
||||
|
||||
const sessions = new Map<number, Session>();
|
||||
|
||||
export function getSession(chatId: number): Session {
|
||||
if (!sessions.has(chatId)) {
|
||||
sessions.set(chatId, {
|
||||
title: "",
|
||||
slug: "",
|
||||
content: "",
|
||||
categories: [],
|
||||
tags: [],
|
||||
author: DEFAULT_AUTHOR,
|
||||
draft: true,
|
||||
chunks: [],
|
||||
});
|
||||
}
|
||||
return sessions.get(chatId)!;
|
||||
}
|
||||
|
||||
export function hasSession(chatId: number): boolean {
|
||||
return sessions.has(chatId);
|
||||
}
|
||||
|
||||
export function clearSession(chatId: number): void {
|
||||
sessions.delete(chatId);
|
||||
}
|
||||
|
||||
export function appendContent(chatId: number, text: string): void {
|
||||
const s = getSession(chatId);
|
||||
s.chunks.push(text + "\n\n");
|
||||
s.content += text + "\n\n";
|
||||
}
|
||||
|
||||
export function undoLastChunk(chatId: number): boolean {
|
||||
const s = getSession(chatId);
|
||||
if (s.chunks.length === 0) return false;
|
||||
const lastChunk = s.chunks.pop()!;
|
||||
const idx = s.content.lastIndexOf(lastChunk);
|
||||
if (idx >= 0) {
|
||||
s.content = s.content.slice(0, idx) + s.content.slice(idx + lastChunk.length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function saveSession(s: Session, isDraft: boolean): Promise<void> {
|
||||
const frontMatter = {
|
||||
title: s.title,
|
||||
slug: s.slug,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
draft: isDraft,
|
||||
categories: s.categories,
|
||||
tags: s.tags,
|
||||
author: s.author,
|
||||
};
|
||||
|
||||
const existing = getPost(s.slug);
|
||||
if (existing) {
|
||||
const result = updatePost(s.slug, frontMatter, s.content);
|
||||
if (!result.success) throw new Error("更新文章失败");
|
||||
} else {
|
||||
createPost(frontMatter, s.content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { API, ALLOWED_IDS } from "./config";
|
||||
|
||||
interface TgResponse<T = unknown> {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function tg<T = unknown>(
|
||||
method: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<TgResponse<T>> {
|
||||
const res = await fetch(`${API}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json() as Promise<TgResponse<T>>;
|
||||
}
|
||||
|
||||
export function allowed(chatId: number): boolean {
|
||||
if (ALLOWED_IDS.length === 0) return true;
|
||||
return ALLOWED_IDS.includes(chatId);
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
chatId: number,
|
||||
text: string,
|
||||
opts?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const safeText = String(text || "");
|
||||
try {
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: safeText,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
...opts,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("parse") || msg.includes("HTML")) {
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: safeText,
|
||||
...opts,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
export const BLOG_ROOT = path.resolve(process.cwd(), "..");
|
||||
export const CONTENT_DIR = path.join(BLOG_ROOT, "content", "post");
|
||||
export const STATIC_DIR = path.join(BLOG_ROOT, "static");
|
||||
export const RECYCLE_DIR = path.join(process.cwd(), ".recycle");
|
||||
export const RECYCLE_POSTS_DIR = path.join(RECYCLE_DIR, "posts");
|
||||
export const RECYCLE_META_DIR = path.join(RECYCLE_DIR, "meta");
|
||||
@@ -14,3 +16,33 @@ export const RSS_API_BASE = process.env.RSS_API_BASE || "https://api.usj.cc";
|
||||
export const RSS_API_TOKEN = process.env.RSS_API_TOKEN || "";
|
||||
export const WECHAT_APP_ID = process.env.WECHAT_APP_ID || "";
|
||||
export const WECHAT_APP_SECRET = process.env.WECHAT_APP_SECRET || "";
|
||||
|
||||
// Bot 配置
|
||||
export const TG_BOT_TOKEN = process.env.TG_BOT_TOKEN || "";
|
||||
export const TG_ALLOWED_IDS: number[] = (process.env.TG_ALLOWED_CHAT_IDS || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean).map(Number);
|
||||
export const PREFER_IFACE = process.env.PREFER_IFACE || "WLAN";
|
||||
export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";
|
||||
|
||||
export function getLanIP(): string {
|
||||
const ifs = os.networkInterfaces();
|
||||
const candidates: { name: string; address: string }[] = [];
|
||||
for (const [name, addrs] of Object.entries(ifs)) {
|
||||
if (!addrs) continue;
|
||||
for (const a of addrs) {
|
||||
if (a.family !== "IPv4" || a.internal) continue;
|
||||
if (a.address.startsWith("169.254.")) continue;
|
||||
candidates.push({ name, address: a.address });
|
||||
}
|
||||
}
|
||||
const pref = candidates.find((c) =>
|
||||
c.name.toLowerCase().includes(PREFER_IFACE.toLowerCase())
|
||||
);
|
||||
if (pref) return pref.address;
|
||||
const real = candidates.find((c) => {
|
||||
const n = c.name.toLowerCase();
|
||||
return !n.includes("vmware") && !n.includes("virtual") && !n.includes("vethernet");
|
||||
});
|
||||
if (real) return real.address;
|
||||
return candidates[0]?.address || "0.0.0.0";
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { BLOG_ROOT } from "./config";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type GitExecError = Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
};
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
message: string;
|
||||
resolve: (result: DeployResult) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
class GitDeployQueue {
|
||||
private queue: QueueItem[] = [];
|
||||
private running = false;
|
||||
|
||||
async enqueue(commitMessage: string): Promise<DeployResult> {
|
||||
return new Promise<DeployResult>((resolve, reject) => {
|
||||
this.queue.push({ message: commitMessage, resolve, reject });
|
||||
if (!this.running) this.next();
|
||||
});
|
||||
}
|
||||
|
||||
async sync(): Promise<DeployResult> {
|
||||
try {
|
||||
const result = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
|
||||
return { success: true, output: result.stdout + result.stderr };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (this.isRebaseConflict(msg)) {
|
||||
await this.runGit(["rebase", "--abort"], true);
|
||||
return { success: false, conflict: true, error: "远程冲突,已放弃合并" };
|
||||
}
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async next(): Promise<void> {
|
||||
const item = this.queue.shift();
|
||||
if (!item) {
|
||||
this.running = false;
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
const result = await this.execute(item.message);
|
||||
item.resolve(result);
|
||||
} catch (e) {
|
||||
item.reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
this.next();
|
||||
}
|
||||
|
||||
private async runGit(args: string[], allowFailure = false) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("git", args, {
|
||||
cwd: BLOG_ROOT,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return { stdout, stderr, code: 0 };
|
||||
} catch (error) {
|
||||
const err = error as GitExecError;
|
||||
const result = {
|
||||
stdout: err.stdout || "",
|
||||
stderr: err.stderr || err.message,
|
||||
code: typeof err.code === "number" ? err.code : 1,
|
||||
};
|
||||
if (allowFailure) return result;
|
||||
throw new Error((result.stderr || result.stdout || err.message).trim());
|
||||
}
|
||||
}
|
||||
|
||||
private isRebaseConflict(message: string): boolean {
|
||||
return /conflict|CONFLICT|Resolve all conflicts|Automatic merge failed|could not apply|rebase/i.test(message);
|
||||
}
|
||||
|
||||
private async execute(message: string): Promise<DeployResult> {
|
||||
let output = "";
|
||||
|
||||
// git add
|
||||
const addResult = await this.runGit(["add", "content"]);
|
||||
output += addResult.stdout + addResult.stderr;
|
||||
|
||||
// git diff --cached --quiet (exit 1 = staged changes exist)
|
||||
const diffResult = await this.runGit(["diff", "--cached", "--quiet"], true);
|
||||
if (diffResult.code === 1) {
|
||||
const commitResult = await this.runGit(["commit", "-m", message]);
|
||||
output += commitResult.stdout + commitResult.stderr;
|
||||
} else if (diffResult.code !== 0) {
|
||||
return { success: false, error: (diffResult.stderr || diffResult.stdout || "检查暂存区失败").trim() };
|
||||
}
|
||||
|
||||
// git pull --rebase --autostash
|
||||
try {
|
||||
const pullResult = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
|
||||
output += pullResult.stdout + pullResult.stderr;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (this.isRebaseConflict(msg)) {
|
||||
await this.runGit(["rebase", "--abort"], true);
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
error: "远程有冲突,已自动放弃合并。请先 /sync 同步,手动解决后再 deploy。",
|
||||
output: msg.slice(0, 1000),
|
||||
};
|
||||
}
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
|
||||
// git push
|
||||
try {
|
||||
const pushResult = await this.runGit(["push"]);
|
||||
output += pushResult.stdout + pushResult.stderr;
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
return { success: true, output: output.slice(0, 1000) };
|
||||
}
|
||||
}
|
||||
|
||||
export const deployQueue = new GitDeployQueue();
|
||||
@@ -0,0 +1,114 @@
|
||||
import { spawn, execSync, type ChildProcess } from "child_process";
|
||||
import { BLOG_ROOT } from "./config";
|
||||
|
||||
class HugoManager {
|
||||
private process: ChildProcess | null = null;
|
||||
private ready = false;
|
||||
private starting = false;
|
||||
private healthTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
async start(): Promise<{ ok: boolean; alreadyRunning?: boolean }> {
|
||||
if (this.process && this.ready) {
|
||||
return { ok: true, alreadyRunning: true };
|
||||
}
|
||||
if (this.starting) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
this.starting = true;
|
||||
this.ready = false;
|
||||
|
||||
this.process = spawn("hugo", ["server", "--bind", "0.0.0.0", "--port", "1313", "--disableFastRender", "--noHTTPCache"], {
|
||||
cwd: BLOG_ROOT,
|
||||
shell: true,
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.process.stdout?.on("data", (d: Buffer) => process.stdout.write(`[hugo] ${d}`));
|
||||
this.process.stderr?.on("data", (d: Buffer) => process.stderr.write(`[hugo] ${d}`));
|
||||
|
||||
this.process.on("exit", (code) => {
|
||||
console.log(`[hugo] exited ${code}`);
|
||||
this.process = null;
|
||||
this.ready = false;
|
||||
this.starting = false;
|
||||
});
|
||||
|
||||
// 等待 Hugo 就绪(最多 15 秒)
|
||||
const ok = await this.waitForReady(15000);
|
||||
this.starting = false;
|
||||
if (ok) {
|
||||
this.ready = true;
|
||||
this.startHealthCheck();
|
||||
console.log("[hugo] ready on 0.0.0.0:1313");
|
||||
}
|
||||
return { ok };
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopHealthCheck();
|
||||
if (!this.process) return;
|
||||
try {
|
||||
const pid = this.process.pid;
|
||||
if (process.platform === "win32" && pid) {
|
||||
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
|
||||
} else {
|
||||
this.process.kill();
|
||||
}
|
||||
} catch (_e) { /* nothing to kill */ }
|
||||
this.process = null;
|
||||
this.ready = false;
|
||||
this.starting = false;
|
||||
}
|
||||
|
||||
status(): { running: boolean; starting: boolean } {
|
||||
return { running: !!(this.process && this.ready), starting: this.starting };
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<boolean> {
|
||||
if (this.process && this.ready) return true;
|
||||
const result = await this.start();
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
private async waitForReady(timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.ping()) return true;
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async ping(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("http://127.0.0.1:1313", { signal: AbortSignal.timeout(1500) });
|
||||
return res.ok || res.status < 500;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
this.stopHealthCheck();
|
||||
this.healthTimer = setInterval(async () => {
|
||||
if (!this.process) return;
|
||||
const alive = await this.ping();
|
||||
if (!alive) {
|
||||
console.warn("[hugo] health check failed, marking as not ready");
|
||||
this.ready = false;
|
||||
this.stopHealthCheck();
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
private stopHealthCheck(): void {
|
||||
if (this.healthTimer) {
|
||||
clearInterval(this.healthTimer);
|
||||
this.healthTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hugo = new HugoManager();
|
||||
@@ -164,7 +164,7 @@ export function createPost(frontMatter: PostFrontMatter, content: string): strin
|
||||
const dirName = `${dateStr}-${titlePart}`;
|
||||
const dirPath = path.join(CONTENT_DIR, year, dirName);
|
||||
|
||||
if (fs.existsSync(dirPath)) {
|
||||
if (fs.existsSync(path.join(dirPath, "index.md"))) {
|
||||
throw new Error(`Post already exists at ${dirPath}`);
|
||||
}
|
||||
|
||||
|
||||
+25
-4
@@ -48,6 +48,23 @@ export interface LinkHealth {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
feedName: string;
|
||||
siteUrl: string;
|
||||
favicon: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface HealthResult {
|
||||
total: number;
|
||||
alive: number;
|
||||
dead: number;
|
||||
results: FeedHealth[];
|
||||
}
|
||||
|
||||
// ---- Feeds API ----
|
||||
|
||||
export async function getFeeds(): Promise<Feed[]> {
|
||||
@@ -80,13 +97,17 @@ export async function deleteFeed(url: string): Promise<void> {
|
||||
await rssFetch(`/api/feeds?${qs}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getFeedsHealth(): Promise<{
|
||||
total: number; alive: number; dead: number;
|
||||
results: FeedHealth[];
|
||||
}> {
|
||||
export async function getFeedsHealth(): Promise<HealthResult> {
|
||||
return rssFetch("/api/health");
|
||||
}
|
||||
|
||||
// ---- Articles API ----
|
||||
|
||||
export async function getArticles(limit = 10): Promise<Article[]> {
|
||||
const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`);
|
||||
return data.articles || [];
|
||||
}
|
||||
|
||||
// ---- Links API ----
|
||||
|
||||
export async function getLinks(all = false): Promise<Link[]> {
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "marked" {
|
||||
export function marked(src: string): string;
|
||||
export function parse(src: string): string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0write"
|
||||
start /b npm run dev -- -p 8016
|
||||
@@ -0,0 +1,3 @@
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
WshShell.Run Chr(34) & "E:\GitHub\blog\write\start-silent.bat" & Chr(34), 0, False
|
||||
Set WshShell = Nothing
|
||||
Reference in new issue
Block a user