2026.6.1
删除不必要的文件
This commit is contained in:
1 parent
e0e75708a7
commit
8f9fbfb97c
103 files changed
-6926
No files matched your search
@@ -1,105 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const path = "E:/GitHub/blog/bot/index.js";
|
|
||||||
let c = fs.readFileSync(path, "utf8");
|
|
||||||
|
|
||||||
function findBlock(name) {
|
|
||||||
var idx = c.indexOf("CMD_HANDLERS." + name);
|
|
||||||
if (idx === -1) return null;
|
|
||||||
var depth = 0;
|
|
||||||
var i = idx;
|
|
||||||
for (; i < c.length; i++) {
|
|
||||||
if (c[i] === "{") depth++;
|
|
||||||
else if (c[i] === "}") {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { start: idx, end: i + 2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix 2: link_toggle
|
|
||||||
var b = findBlock("link_toggle");
|
|
||||||
if (!b) { console.log("ERR: link_toggle not found"); process.exit(1); }
|
|
||||||
var nb =
|
|
||||||
"CMD_HANDLERS.link_toggle = async function (chatId, url) {\n" +
|
|
||||||
" if (!url) return sendMessage(chatId, \"Usage: /link_toggle <url>\\nToggle visibility of a link.\");\n" +
|
|
||||||
" try {\n" +
|
|
||||||
" // Fetch all links to find current hidden state\n" +
|
|
||||||
" const listRes = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/links?all=1\");\n" +
|
|
||||||
" const listData = await listRes.json();\n" +
|
|
||||||
" var link = (listData.links || []).find(function(l) { return l.url === url; });\n" +
|
|
||||||
" if (!link) return sendMessage(chatId, \"\u274c Link not found: \" + url);\n" +
|
|
||||||
" var newHidden = !link.hidden;\n" +
|
|
||||||
" const res = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/links\", {\n" +
|
|
||||||
" method: \"PUT\",\n" +
|
|
||||||
" headers: { \"Content-Type\": \"application/json\" },\n" +
|
|
||||||
" body: JSON.stringify({ oldUrl: url, hidden: newHidden }),\n" +
|
|
||||||
" });\n" +
|
|
||||||
" if (!res.ok) throw new Error((await res.json()).error || \"Toggle failed\");\n" +
|
|
||||||
" var label = newHidden ? \"\uD83D\uDD12 Hidden\" : \"\uD83C\uDF10 Visible\";\n" +
|
|
||||||
" return sendMessage(chatId, label + \": \" + url);\n" +
|
|
||||||
" } catch (e) {\n" +
|
|
||||||
" return sendMessage(chatId, \"\u274c \" + e.message);\n" +
|
|
||||||
" }\n" +
|
|
||||||
"};\n";
|
|
||||||
c = c.substring(0, b.start) + nb + c.substring(b.end);
|
|
||||||
console.log("2/4: link_toggle fixed");
|
|
||||||
|
|
||||||
// Fix 3: feed_check
|
|
||||||
b = findBlock("feed_check");
|
|
||||||
if (!b) { console.log("ERR: feed_check not found"); process.exit(1); }
|
|
||||||
nb =
|
|
||||||
"CMD_HANDLERS.feed_check = async function (chatId) {\n" +
|
|
||||||
" try {\n" +
|
|
||||||
" // First get all feeds\n" +
|
|
||||||
" const feedsRes = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/feeds\");\n" +
|
|
||||||
" const feedsData = await feedsRes.json();\n" +
|
|
||||||
" var feeds = feedsData.feeds || [];\n" +
|
|
||||||
" if (feeds.length === 0) return sendMessage(chatId, \"No feeds to check.\");\n" +
|
|
||||||
" var results = [];\n" +
|
|
||||||
" for (var i = 0; i < feeds.length; i++) {\n" +
|
|
||||||
" var f = feeds[i];\n" +
|
|
||||||
" try {\n" +
|
|
||||||
" var hRes = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/feeds?health=\" + encodeURIComponent(f.url));\n" +
|
|
||||||
" var hData = await hRes.json();\n" +
|
|
||||||
" results.push({ feedTitle: f.feedTitle, url: f.url, ok: hData.ok, status: hData.status, latency: hData.latency, error: hData.error });\n" +
|
|
||||||
" } catch (e2) {\n" +
|
|
||||||
" results.push({ feedTitle: f.feedTitle, url: f.url, ok: false, error: e2.message });\n" +
|
|
||||||
" }\n" +
|
|
||||||
" }\n" +
|
|
||||||
" var lines = [\"\uD83D\uDCE1 <b>Feed Health</b>\\n\"];\n" +
|
|
||||||
" results.forEach(function(r) {\n" +
|
|
||||||
" var icon = r.ok ? \"\u2705\" : \"\u274c\";\n" +
|
|
||||||
" var label = (r.feedTitle || r.url).slice(0, 40);\n" +
|
|
||||||
" var info = r.ok ? (r.latency || 0) + \"ms\" : (r.error || \"error\");\n" +
|
|
||||||
" lines.push(icon + \" \" + label + \" (\" + info + \")\");\n" +
|
|
||||||
" });\n" +
|
|
||||||
" return sendMessage(chatId, lines.join(\"\\n\"));\n" +
|
|
||||||
" } catch (e) {\n" +
|
|
||||||
" return sendMessage(chatId, \"Please /start Write first.\");\n" +
|
|
||||||
" }\n" +
|
|
||||||
"};\n";
|
|
||||||
c = c.substring(0, b.start) + nb + c.substring(b.end);
|
|
||||||
console.log("3/4: feed_check fixed");
|
|
||||||
|
|
||||||
// Fix 4: feed_del
|
|
||||||
b = findBlock("feed_del");
|
|
||||||
if (!b) { console.log("ERR: feed_del not found"); process.exit(1); }
|
|
||||||
nb =
|
|
||||||
"CMD_HANDLERS.feed_del = async function (chatId, url) {\n" +
|
|
||||||
" if (!url) return sendMessage(chatId, \"Usage: /feed_del <url>\\nUse /feeds to find the URL.\");\n" +
|
|
||||||
" try {\n" +
|
|
||||||
" const res = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/feeds?url=\" + encodeURIComponent(url), {\n" +
|
|
||||||
" method: \"DELETE\",\n" +
|
|
||||||
" });\n" +
|
|
||||||
" if (!res.ok) throw new Error((await res.json()).error || \"Delete failed\");\n" +
|
|
||||||
" return sendMessage(chatId, \"\u2702 Feed deleted: \" + url);\n" +
|
|
||||||
" } catch (e) {\n" +
|
|
||||||
" return sendMessage(chatId, \"\u274c \" + e.message);\n" +
|
|
||||||
" }\n" +
|
|
||||||
"};\n";
|
|
||||||
c = c.substring(0, b.start) + nb + c.substring(b.end);
|
|
||||||
console.log("4/4: feed_del fixed");
|
|
||||||
|
|
||||||
fs.writeFileSync(path, c, "utf8");
|
|
||||||
console.log("All fixes applied successfully!");
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const path = "E:/GitHub/blog/bot/index.js";
|
|
||||||
let c = fs.readFileSync(path, "utf8");
|
|
||||||
|
|
||||||
function findBlock(name) {
|
|
||||||
var idx = c.indexOf("CMD_HANDLERS." + name);
|
|
||||||
if (idx === -1) return null;
|
|
||||||
var depth = 0;
|
|
||||||
var i = idx;
|
|
||||||
for (; i < c.length; i++) {
|
|
||||||
if (c[i] === "{") depth++;
|
|
||||||
else if (c[i] === "}") {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { start: idx, end: i + 2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix 1: link_del (was missed earlier)
|
|
||||||
var b = findBlock("link_del");
|
|
||||||
if (!b) { console.log("ERR: link_del not found"); process.exit(1); }
|
|
||||||
var nb =
|
|
||||||
"CMD_HANDLERS.link_del = async function (chatId, url) {\n" +
|
|
||||||
" if (!url) return sendMessage(chatId, \"Usage: /link_del <url>\\nUse /links to find the URL.\");\n" +
|
|
||||||
" try {\n" +
|
|
||||||
" const res = await fetch(\"http://127.0.0.1:\" + WRITE_PORT + \"/api/rss/links?url=\" + encodeURIComponent(url), {\n" +
|
|
||||||
" method: \"DELETE\",\n" +
|
|
||||||
" });\n" +
|
|
||||||
" if (!res.ok) throw new Error((await res.json()).error || \"Delete failed\");\n" +
|
|
||||||
" return sendMessage(chatId, \"\u2702 Link deleted: \" + url);\n" +
|
|
||||||
" } catch (e) {\n" +
|
|
||||||
" return sendMessage(chatId, \"\u274c \" + e.message);\n" +
|
|
||||||
" }\n" +
|
|
||||||
"};\n";
|
|
||||||
c = c.substring(0, b.start) + nb + c.substring(b.end);
|
|
||||||
console.log("1/1: link_del fixed");
|
|
||||||
|
|
||||||
fs.writeFileSync(path, c, "utf8");
|
|
||||||
console.log("Done!");
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const html = `<!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:8016/" 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(\x27start\x27)">Start Write</button>
|
|
||||||
<button class="btn btn-secondary" id="btnOpen" onclick="openWrite()">Open</button>
|
|
||||||
<button class="btn btn-danger" id="btnStop" onclick="action(\x27stop\x27)">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=${8016};
|
|
||||||
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:"+port))}
|
|
||||||
function copyLink(e){e.preventDefault();navigator.clipboard.writeText(document.getElementById("lanLink").textContent).catch(function(){})}
|
|
||||||
fetchStatus();
|
|
||||||
setInterval(fetchStatus,5000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
const fileContent = `// admin-panel.js - Minimal web admin panel + HTTP control API
|
|
||||||
const http = require("http");
|
|
||||||
const config = require("./config");
|
|
||||||
const { getLanIP } = require("./helpers");
|
|
||||||
const { startWriteServer, stopWriteServer, getWriteStatus } = require("./write-server");
|
|
||||||
|
|
||||||
const ADMIN_HTML = \`${html}\`.replace("var port=8016", "var port=\${config.WRITE_PORT}");
|
|
||||||
|
|
||||||
function createControlServer() {
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
const url = new URL(req.url, "http://127.0.0.1:" + config.CONTROL_PORT);
|
|
||||||
const path = url.pathname;
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
||||||
|
|
||||||
if (path === "/" && req.method === "GET") {
|
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
||||||
res.end(ADMIN_HTML);
|
|
||||||
} else if (path === "/status" && req.method === "GET") {
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
const s = getWriteStatus();
|
|
||||||
s.ip = getLanIP();
|
|
||||||
s.port = config.WRITE_PORT;
|
|
||||||
res.end(JSON.stringify(s));
|
|
||||||
} else if (path === "/start" && req.method === "POST") {
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify(startWriteServer()));
|
|
||||||
} else if (path === "/stop" && req.method === "POST") {
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ ok: stopWriteServer() }));
|
|
||||||
} else if (path === "/ip" && req.method === "GET") {
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ ip: getLanIP(), port: config.WRITE_PORT }));
|
|
||||||
} else if (path === "/health" && req.method === "GET") {
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ ok: true }));
|
|
||||||
} else {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ error: "not found" }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(config.CONTROL_PORT, "127.0.0.1", () => {
|
|
||||||
console.log("[control] http://127.0.0.1:" + config.CONTROL_PORT);
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createControlServer };
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(__dirname, "src", "admin-panel.js"), fileContent, "utf8");
|
|
||||||
console.log("admin-panel.js written (" + fileContent.length + " bytes)");
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
var fs=require("fs");
|
|
||||||
var c=fs.readFileSync("E:/GitHub/blog/bot/index.js","utf8");
|
|
||||||
function fb(n){var i=c.indexOf("CMD_HANDLERS."+n);if(i===-1)return null;var d=0;for(var j=i;j<c.length;j++){if(c[j]==="{")d++;else if(c[j]==="}"){d--;if(d===0)break;}}return{start:i,end:j+2}}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const path = "E:/GitHub/blog/bot/index.js";
|
|
||||||
let c = fs.readFileSync(path, "utf8");
|
|
||||||
|
|
||||||
// Insert start/stop/status/ip handlers before CMD_HANDLERS.view
|
|
||||||
var insertAt = c.indexOf("\nCMD_HANDLERS.view = async function");
|
|
||||||
if (insertAt === -1) { console.log("ERR: view not found"); process.exit(1); }
|
|
||||||
insertAt++; // after the newline
|
|
||||||
|
|
||||||
var handlers = `
|
|
||||||
CMD_HANDLERS.start = async function (chatId) {
|
|
||||||
if (writeProcess && writeReady) {
|
|
||||||
var ip = getLanIP();
|
|
||||||
await sendMessage(chatId, "\u2705 Write is running\n\nDesktop: http://localhost:" + WRITE_PORT + "\nPhone: http://" + ip + ":" + WRITE_PORT);
|
|
||||||
return CMD_HANDLERS.help(chatId);
|
|
||||||
}
|
|
||||||
if (writeProcess && !writeReady) {
|
|
||||||
return sendMessage(chatId, "\u23f3 Write is starting, please wait...");
|
|
||||||
}
|
|
||||||
var result = startWriteServer();
|
|
||||||
if (!result.started) return sendMessage(chatId, "\u274c Failed to start");
|
|
||||||
await sendMessage(chatId, "\u23f3 Starting Write...");
|
|
||||||
var ready = await waitForReady(WRITE_PORT);
|
|
||||||
if (ready) {
|
|
||||||
writeReady = true;
|
|
||||||
var ip = getLanIP();
|
|
||||||
await sendMessage(chatId, "\u2705 Write is ready!\n\nDesktop: http://localhost:" + WRITE_PORT + "\nPhone: http://" + ip + ":" + WRITE_PORT);
|
|
||||||
return CMD_HANDLERS.help(chatId);
|
|
||||||
}
|
|
||||||
return sendMessage(chatId, "\u274c Start timed out, check console log.");
|
|
||||||
},
|
|
||||||
async stop(chatId) {
|
|
||||||
if (!writeProcess) return sendMessage(chatId, "\u23f8 Write is not running.");
|
|
||||||
stopWriteServer();
|
|
||||||
return sendMessage(chatId, "\u26a1 Write stopped.");
|
|
||||||
},
|
|
||||||
async status(chatId) {
|
|
||||||
var s = getWriteStatus();
|
|
||||||
var status = s.running ? "\u2705 Running" : s.starting ? "\u23f3 Starting..." : "\u23f8 Stopped";
|
|
||||||
var lines = ["Status: " + status];
|
|
||||||
if (s.running) {
|
|
||||||
lines.push("Local: http://localhost:" + WRITE_PORT);
|
|
||||||
lines.push("Phone: http://" + s.ip + ":" + WRITE_PORT);
|
|
||||||
}
|
|
||||||
return sendMessage(chatId, lines.join("\n"));
|
|
||||||
},
|
|
||||||
async ip(chatId) {
|
|
||||||
var ip = getLanIP();
|
|
||||||
return sendMessage(chatId, "Phone: http://" + ip + ":" + WRITE_PORT + " (" + PREFER_IFACE + ")");
|
|
||||||
},
|
|
||||||
`;
|
|
||||||
|
|
||||||
c = c.substring(0, insertAt) + handlers + c.substring(insertAt);
|
|
||||||
fs.writeFileSync(path, c, "utf8");
|
|
||||||
console.log("start/stop/status/ip restored");
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const path = "E:/GitHub/blog/bot/index.js";
|
|
||||||
let c = fs.readFileSync(path, "utf8");
|
|
||||||
|
|
||||||
// Fix the broken start/stop/status/ip handlers (newlines got literalized)
|
|
||||||
// Remove the broken block and re-insert correctly
|
|
||||||
|
|
||||||
var brokenStart = c.indexOf("\nCMD_HANDLERS.start = async function (chatId) {");
|
|
||||||
if (brokenStart === -1) { console.log("ERR: no start"); process.exit(1); }
|
|
||||||
// Find end of ip handler (the `},` before CMD_HANDLERS.view)
|
|
||||||
var afterIp = c.indexOf("CMD_HANDLERS.view = async function", brokenStart);
|
|
||||||
if (afterIp === -1) { console.log("ERR: no view after"); process.exit(1); }
|
|
||||||
|
|
||||||
// Remove broken block
|
|
||||||
c = c.substring(0, brokenStart + 1) + c.substring(afterIp);
|
|
||||||
|
|
||||||
// Insert correct handlers
|
|
||||||
var correctHandlers = `
|
|
||||||
CMD_HANDLERS.start = async function (chatId) {
|
|
||||||
if (writeProcess && writeReady) {
|
|
||||||
var ip = getLanIP();
|
|
||||||
await sendMessage(chatId, "\\u2705 Write is running\\n\\nDesktop: http://localhost:" + WRITE_PORT + "\\nPhone: http://" + ip + ":" + WRITE_PORT);
|
|
||||||
return CMD_HANDLERS.help(chatId);
|
|
||||||
}
|
|
||||||
if (writeProcess && !writeReady) {
|
|
||||||
return sendMessage(chatId, "\\u23f3 Write is starting, please wait...");
|
|
||||||
}
|
|
||||||
var result = startWriteServer();
|
|
||||||
if (!result.started) return sendMessage(chatId, "\\u274c Failed to start");
|
|
||||||
await sendMessage(chatId, "\\u23f3 Starting Write...");
|
|
||||||
var ready = await waitForReady(WRITE_PORT);
|
|
||||||
if (ready) {
|
|
||||||
writeReady = true;
|
|
||||||
var ip = getLanIP();
|
|
||||||
await sendMessage(chatId, "\\u2705 Write is ready!\\n\\nDesktop: http://localhost:" + WRITE_PORT + "\\nPhone: http://" + ip + ":" + WRITE_PORT);
|
|
||||||
return CMD_HANDLERS.help(chatId);
|
|
||||||
}
|
|
||||||
return sendMessage(chatId, "\\u274c Start timed out, check console log.");
|
|
||||||
},
|
|
||||||
async stop(chatId) {
|
|
||||||
if (!writeProcess) return sendMessage(chatId, "\\u23f8 Write is not running.");
|
|
||||||
stopWriteServer();
|
|
||||||
return sendMessage(chatId, "\\u26a1 Write stopped.");
|
|
||||||
},
|
|
||||||
async status(chatId) {
|
|
||||||
var s = getWriteStatus();
|
|
||||||
var status = s.running ? "\\u2705 Running" : s.starting ? "\\u23f3 Starting..." : "\\u23f8 Stopped";
|
|
||||||
var lines = ["Status: " + status];
|
|
||||||
if (s.running) {
|
|
||||||
lines.push("Local: http://localhost:" + WRITE_PORT);
|
|
||||||
lines.push("Phone: http://" + s.ip + ":" + WRITE_PORT);
|
|
||||||
}
|
|
||||||
return sendMessage(chatId, lines.join("\\n"));
|
|
||||||
},
|
|
||||||
async ip(chatId) {
|
|
||||||
var ip = getLanIP();
|
|
||||||
return sendMessage(chatId, "Phone: http://" + ip + ":" + WRITE_PORT + " (" + PREFER_IFACE + ")");
|
|
||||||
},
|
|
||||||
`;
|
|
||||||
|
|
||||||
c = c.substring(0, brokenStart + 1) + correctHandlers + c.substring(brokenStart + 1);
|
|
||||||
fs.writeFileSync(path, c, "utf8");
|
|
||||||
console.log("start/stop/status/ip fixed");
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
$e=$null; $t=$null; [void][System.Management.Automation.Language.Parser]::ParseFile('E:\GitHub\blog\bot\tray.ps1',[ref]$t,[ref]$e); if($e -and $e.Count -gt 0){foreach($err in $e){Write-Host $err.Message}}else{Write-Host "tray.ps1: OK"}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
path = 'e:/GitHub/blog/public/js/bundle.min.6c8518518bf5af8d666e1b391ef3cf4caaaca7537fd1961891ed292d86eb3fed.js'
|
|
||||||
with open(path, 'rb') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Find initCodeFold in the binary content
|
|
||||||
marker = 'window.initCodeFold=function'.encode('utf-8')
|
|
||||||
idx = content.find(marker)
|
|
||||||
if idx == -1:
|
|
||||||
# Try latin-1 or other encoding
|
|
||||||
marker = 'window.initCodeFold=function'.encode()
|
|
||||||
idx = content.find(marker)
|
|
||||||
|
|
||||||
if idx == -1:
|
|
||||||
print("initCodeFold definition not found!")
|
|
||||||
else:
|
|
||||||
# Find the span innerHTML part
|
|
||||||
chunk = content[idx:idx+1200]
|
|
||||||
# Find '展开代码' or its bytes
|
|
||||||
# UTF-8 for 展开代码: e5 b1 95 e5 bc 80 e4 bb a3 e7 a0 81
|
|
||||||
# UTF-8 for 收起代码: e6 94 b6 e8 b5 b7 e4 bb a3 e7 a0 81
|
|
||||||
target1 = '展开代码'.encode('utf-8')
|
|
||||||
target2 = '收起代码'.encode('utf-8')
|
|
||||||
|
|
||||||
t1_idx = chunk.find(target1)
|
|
||||||
t2_idx = chunk.find(target2)
|
|
||||||
|
|
||||||
print(f"展开代码 found at offset {t1_idx}: {t1_idx != -1}")
|
|
||||||
print(f"收起代码 found at offset {t2_idx}: {t2_idx != -1}")
|
|
||||||
|
|
||||||
# Also check the raw source file
|
|
||||||
print()
|
|
||||||
print("--- Checking source file ---")
|
|
||||||
with open('e:/GitHub/blog/themes/Ying/assets/js/modules/utils.js', 'rb') as f:
|
|
||||||
src = f.read()
|
|
||||||
t1 = src.find(target1)
|
|
||||||
t2 = src.find(target2)
|
|
||||||
print(f"展开代码 in source: {t1 != -1}")
|
|
||||||
print(f"收起代码 in source: {t2 != -1}")
|
|
||||||
|
|
||||||
# Check file encoding
|
|
||||||
print()
|
|
||||||
print(f"Built JS file size: {len(content)} bytes")
|
|
||||||
|
|
||||||
# Check if file starts with BOM or has other encoding markers
|
|
||||||
if content[:3] == b'\xef\xbb\xbf':
|
|
||||||
print("File has UTF-8 BOM")
|
|
||||||
elif content[:2] == b'\xff\xfe':
|
|
||||||
print("File has UTF-16 LE BOM")
|
|
||||||
elif content[:2] == b'\xfe\xff':
|
|
||||||
print("File has UTF-16 BE BOM")
|
|
||||||
else:
|
|
||||||
print("No BOM detected - likely UTF-8")
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
path = 'e:/GitHub/blog/public/js/bundle.min.6c8518518bf5af8d666e1b391ef3cf4caaaca7537fd1961891ed292d86eb3fed.js'
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Find all occurrences of initCodeFold
|
|
||||||
idx = 0
|
|
||||||
matches = []
|
|
||||||
while True:
|
|
||||||
idx = content.find('initCodeFold', idx)
|
|
||||||
if idx == -1:
|
|
||||||
break
|
|
||||||
start = max(0, idx - 200)
|
|
||||||
end = min(len(content), idx + 500)
|
|
||||||
matches.append((idx, content[start:end]))
|
|
||||||
idx += 1
|
|
||||||
|
|
||||||
print(f"Found {len(matches)} matches of 'initCodeFold'")
|
|
||||||
print(f"File length: {len(content)} chars")
|
|
||||||
print()
|
|
||||||
|
|
||||||
for i, (pos, context) in enumerate(matches):
|
|
||||||
print(f'=== Match {i+1} at position {pos} ===')
|
|
||||||
print(context)
|
|
||||||
print()
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
path = 'e:/GitHub/blog/public/js/bundle.min.6c8518518bf5af8d666e1b391ef3cf4caaaca7537fd1961891ed292d86eb3fed.js'
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Find initCodeFold definition
|
|
||||||
idx = content.find('window.initCodeFold=function')
|
|
||||||
if idx == -1:
|
|
||||||
print("initCodeFold definition not found!")
|
|
||||||
else:
|
|
||||||
# Extract a larger chunk
|
|
||||||
end_idx = content.find('window.initCodeCopy', idx)
|
|
||||||
if end_idx == -1:
|
|
||||||
end_idx = idx + 2000
|
|
||||||
chunk = content[idx:end_idx]
|
|
||||||
print(f"Chunk length: {len(chunk)} chars")
|
|
||||||
print("=" * 60)
|
|
||||||
print(chunk)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import glob
|
|
||||||
|
|
||||||
# Find latest bundle JS file
|
|
||||||
js_files = sorted(Path('e:/GitHub/blog/public/js').glob('bundle.min.*.js'), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
||||||
if not js_files:
|
|
||||||
print("No JS bundle found!")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
path = str(js_files[0])
|
|
||||||
print(f"Checking: {path}")
|
|
||||||
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
idx = content.find('window.initCodeFold=function')
|
|
||||||
if idx == -1:
|
|
||||||
print("NOT FOUND")
|
|
||||||
else:
|
|
||||||
chunk = content[idx:idx+800]
|
|
||||||
print(chunk[:600])
|
|
||||||
print()
|
|
||||||
# Check for the key changes
|
|
||||||
checks = [
|
|
||||||
('parentNode.classList.contains("code-fold-container")', 'early return check'),
|
|
||||||
('pre.scrollHeight', 'pre scrollHeight'),
|
|
||||||
('block.scrollHeight', 'block fallback'),
|
|
||||||
('contentHeight', 'contentHeight var'),
|
|
||||||
]
|
|
||||||
for pattern, desc in checks:
|
|
||||||
found = pattern in chunk
|
|
||||||
print(f" {desc}: {'YES' if found else 'MISSING!'}")
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
# 设置控制台编码为 UTF-8,防止中文乱码
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
|
|
||||||
Write-Host "🔍 正在进行友链健康检查..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# 运行 Node.js 检查脚本
|
|
||||||
node scripts/check_links.js
|
|
||||||
|
|
||||||
# 检查脚本退出代码
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Host "⚠️ 友链检查发现问题或执行失败。" -ForegroundColor Yellow
|
|
||||||
$response = Read-Host "是否继续执行 Hugo 打包?(Y/N)"
|
|
||||||
if ($response -ne 'Y' -and $response -ne 'y') {
|
|
||||||
Write-Host "❌ 已取消打包。" -ForegroundColor Red
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Host "✅ 友链检查通过。" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "🚀 开始执行 Hugo 打包..." -ForegroundColor Cyan
|
|
||||||
hugo --minify
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
Write-Host "🎉 打包成功!" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "❌ 打包失败,请检查错误日志。" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
🚫 监测到 6 个异常友链:
|
|
||||||
- 团团生活志: https://t-t.live (HTTP 404)
|
|
||||||
- Kris Yan: https://blog.krisyan.dev (HTTP 429)
|
|
||||||
- 子舒: https://www.zishu.me (net_exception_timeout)
|
|
||||||
- 北枫: https://beifeng.me (net_exception_connect_timeout)
|
|
||||||
- 永恒末匕: https://likaijian.com (HTTP 404)
|
|
||||||
- Vian: https://vian.top (timeout of 20000ms exceeded)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
$startup = [Environment]::GetFolderPath("Startup")
|
|
||||||
$shortcutPath = Join-Path $startup "WriteBot.lnk"
|
|
||||||
$icoPath = "E:\GitHub\blog\bot\writebot.ico"
|
|
||||||
|
|
||||||
$wsh = New-Object -ComObject WScript.Shell
|
|
||||||
$shortcut = $wsh.CreateShortcut($shortcutPath)
|
|
||||||
$shortcut.TargetPath = "C:\nvm4w\nodejs\node.exe"
|
|
||||||
$shortcut.Arguments = "index.js"
|
|
||||||
$shortcut.WorkingDirectory = "E:\GitHub\blog\bot"
|
|
||||||
$shortcut.WindowStyle = 7
|
|
||||||
$shortcut.Description = "WriteBot"
|
|
||||||
$shortcut.IconLocation = "$icoPath,0"
|
|
||||||
$shortcut.Save()
|
|
||||||
|
|
||||||
Write-Host "Startup shortcut updated: $shortcutPath"
|
|
||||||
Write-Host "Target: node.exe index.js (cwd: E:\GitHub\blog\bot)"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
$startup = [Environment]::GetFolderPath("Startup")
|
|
||||||
$shortcutPath = Join-Path $startup "WriteBot.lnk"
|
|
||||||
$targetPath = "E:\GitHub\blog\bot\start-tray.vbs"
|
|
||||||
|
|
||||||
$wsh = New-Object -ComObject WScript.Shell
|
|
||||||
$shortcut = $wsh.CreateShortcut($shortcutPath)
|
|
||||||
$shortcut.TargetPath = "wscript.exe"
|
|
||||||
$shortcut.Arguments = "`"$targetPath`""
|
|
||||||
$shortcut.WorkingDirectory = "E:\GitHub\blog\bot"
|
|
||||||
$shortcut.WindowStyle = 7 # Minimized
|
|
||||||
$shortcut.Description = "WriteBot tray launcher"
|
|
||||||
$shortcut.IconLocation = "wscript.exe,0"
|
|
||||||
$shortcut.Save()
|
|
||||||
|
|
||||||
Write-Host "Startup folder: $startup"
|
|
||||||
Write-Host "Shortcut created: $shortcutPath"
|
|
||||||
Write-Host ""
|
|
||||||
Get-ChildItem -LiteralPath $shortcutPath | Format-List Name, FullName, LastWriteTime
|
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
# 设置控制台编码为 UTF-8
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
|
|
||||||
Write-Host "==========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host " 📝 新建博客文章脚本 (Hugo) " -ForegroundColor Cyan
|
|
||||||
Write-Host "==========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 1. 获取文章标题
|
|
||||||
do {
|
|
||||||
$title = Read-Host "请输入文章标题 (必填)"
|
|
||||||
} while ([string]::IsNullOrWhiteSpace($title))
|
|
||||||
|
|
||||||
# 2. 获取文件名 Slug (默认为标题)
|
|
||||||
$slug = Read-Host "请输入文件名/Slug (选填,留空默认使用标题,建议使用英文)"
|
|
||||||
if ([string]::IsNullOrWhiteSpace($slug)) {
|
|
||||||
$slug = $title
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. 获取分类
|
|
||||||
$categoriesInput = Read-Host "请输入分类 (选填,多个分类用空格或逗号分隔)"
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($categoriesInput)) {
|
|
||||||
# 处理分隔符:将中文逗号、英文逗号替换为空格,然后分割
|
|
||||||
$categories = $categoriesInput -replace ",", " " -replace ",", " " -split "\s+"
|
|
||||||
# 重新组合成 JSON 数组格式字符串 ["A", "B"]
|
|
||||||
$categoriesStr = '["' + ($categories -join '", "') + '"]'
|
|
||||||
} else {
|
|
||||||
$categoriesStr = '["生活"]' # 默认分类
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4. 获取标签
|
|
||||||
$tagsInput = Read-Host "请输入标签 (选填,多个标签用空格或逗号分隔)"
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($tagsInput)) {
|
|
||||||
$tags = $tagsInput -replace ",", " " -replace ",", " " -split "\s+"
|
|
||||||
$tagsStr = '["' + ($tags -join '", "') + '"]'
|
|
||||||
} else {
|
|
||||||
$tagsStr = '[]'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 5. 获取作者
|
|
||||||
$author = Read-Host "请输入作者 (选填,默认为 '小赵')"
|
|
||||||
if ([string]::IsNullOrWhiteSpace($author)) {
|
|
||||||
$author = "小赵"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 6. 生成文件夹和文件名 (Page Bundles 模式: YYYY/YYYY-MM-DD-Slug/index.md)
|
|
||||||
$date = Get-Date
|
|
||||||
$year = $date.ToString("yyyy")
|
|
||||||
$dateStr = $date.ToString("yyyy-MM-dd")
|
|
||||||
$dateTimeStr = $date.ToString("yyyy-MM-ddTHH:mm:ss+08:00")
|
|
||||||
|
|
||||||
# 移除文件名中的非法字符
|
|
||||||
$safeSlug = $slug -replace '[\\/:*?"<>|]', '' -replace '\s+', '-'
|
|
||||||
$folderName = "$dateStr-$safeSlug"
|
|
||||||
|
|
||||||
# 路径结构:content/post/2024/2024-01-01-Slug/index.md
|
|
||||||
$yearPath = Join-Path "content/post" $year
|
|
||||||
$folderPath = Join-Path $yearPath $folderName
|
|
||||||
$filePath = Join-Path $folderPath "index.md"
|
|
||||||
|
|
||||||
# 7. 生成 Front Matter 内容
|
|
||||||
$content = @"
|
|
||||||
---
|
|
||||||
title: "$title"
|
|
||||||
date: $dateTimeStr
|
|
||||||
slug: $safeSlug
|
|
||||||
draft: false
|
|
||||||
categories: $categoriesStr
|
|
||||||
tags: $tagsStr
|
|
||||||
author: "$author"
|
|
||||||
---
|
|
||||||
|
|
||||||
这里开始写你的精彩内容...
|
|
||||||
|
|
||||||
"@
|
|
||||||
|
|
||||||
# 8. 写入文件
|
|
||||||
try {
|
|
||||||
# 确保父目录存在
|
|
||||||
if (-not (Test-Path "content/post")) {
|
|
||||||
New-Item -ItemType Directory -Path "content/post" | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# 确保年份目录存在
|
|
||||||
if (-not (Test-Path $yearPath)) {
|
|
||||||
New-Item -ItemType Directory -Path $yearPath | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# 创建文章专属目录
|
|
||||||
if (-not (Test-Path $folderPath)) {
|
|
||||||
New-Item -ItemType Directory -Path $folderPath | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# 检查文件是否已存在
|
|
||||||
if (Test-Path $filePath) {
|
|
||||||
Write-Host "⚠️ 文件已存在: $filePath" -ForegroundColor Yellow
|
|
||||||
$overwrite = Read-Host "是否覆盖? (y/n)"
|
|
||||||
if ($overwrite -ne 'y') {
|
|
||||||
Write-Host "❌ 操作已取消" -ForegroundColor Red
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 写入文件 (使用 UTF8 编码)
|
|
||||||
$content | Set-Content -Path $filePath -Encoding UTF8
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "✅ 文章创建成功!" -ForegroundColor Green
|
|
||||||
Write-Host "📂 文件路径: $filePath" -ForegroundColor Gray
|
|
||||||
Write-Host "🔗 文章标题: $title" -ForegroundColor Gray
|
|
||||||
} catch {
|
|
||||||
Write-Host "❌ 创建失败: $_" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
$f = "E:\GitHub\blog\bot\tray.ps1"
|
|
||||||
$s = [System.IO.File]::ReadAllText($f, [System.Text.Encoding]::UTF8)
|
|
||||||
# Find the MessageBox catch and replace it
|
|
||||||
$old = ' [System.Windows.Forms.MessageBox]::Show(' + [char]10 + ' "Node.js' + [char]0x672A + [char]0x5B89 + [char]0x88C5 + [char]0x6216 + [char]0x65E0 + [char]0x6CD5 + [char]0x542F + [char]0x52A8' + [char]0x3002 + '"`n`n$_",'
|
|
||||||
Write-Host "searching..."
|
|
||||||
$idx = $s.IndexOf("catch {`n [System.Windows.Forms.MessageBox]")
|
|
||||||
if ($idx -lt 0) { $idx = $s.IndexOf("MessageBox") }
|
|
||||||
Write-Host "idx: $idx"
|
|
||||||
if ($idx -gt 0) {
|
|
||||||
$before = $s.Substring(0, $idx)
|
|
||||||
$afterMarker = " exit 1`n}`n`n|notifyIcon"
|
|
||||||
# find the closing } after the MessageBox catch
|
|
||||||
$afterIdx = $idx + 200
|
|
||||||
$remaining = $s.Substring($afterIdx)
|
|
||||||
$closePos = $remaining.IndexOf("exit 1") + 7
|
|
||||||
$after = $s.Substring($afterIdx + $closePos)
|
|
||||||
$replacement = "} catch {`n `$log = Join-Path `$BotDir `".tray-error.log`"`n `"tray.ps1 failed: `$_`" | Out-File `$log -Encoding UTF8`n `$notifyIcon.Visible = `$false`n exit 1`n}`n"
|
|
||||||
$s = $before + $replacement + $after
|
|
||||||
[System.IO.File]::WriteAllText($f, $s, [System.Text.UTF8Encoding]::new($true))
|
|
||||||
Write-Host "PATCHED"
|
|
||||||
} else {
|
|
||||||
Write-Host "NOT FOUND"
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/* Background */ .bg { background-color:#f8f8f8; }
|
|
||||||
/* PreWrapper */ .chroma { background-color:#f8f8f8; }
|
|
||||||
/* Other */ .chroma .x { color:#000 }
|
|
||||||
/* Error */ .chroma .err { color:#a40000 }
|
|
||||||
/* CodeLine */ .chroma .cl { }
|
|
||||||
/* LineLink */ .chroma .lnlinks { outline:none;text-decoration:none;color:inherit }
|
|
||||||
/* LineTableTD */ .chroma .lntd { vertical-align:top;padding:0;margin:0;border:0; }
|
|
||||||
/* LineTable */ .chroma .lntable { border-spacing:0;padding:0;margin:0;border:0; }
|
|
||||||
/* LineHighlight */ .chroma .hl { background-color:#dfdfdf }
|
|
||||||
/* LineNumbersTable */ .chroma .lnt { white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f }
|
|
||||||
/* LineNumbers */ .chroma .ln { white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f }
|
|
||||||
/* Line */ .chroma .line { display:flex; }
|
|
||||||
/* Keyword */ .chroma .k { color:#204a87;font-weight:bold }
|
|
||||||
/* KeywordConstant */ .chroma .kc { color:#204a87;font-weight:bold }
|
|
||||||
/* KeywordDeclaration */ .chroma .kd { color:#204a87;font-weight:bold }
|
|
||||||
/* KeywordNamespace */ .chroma .kn { color:#204a87;font-weight:bold }
|
|
||||||
/* KeywordPseudo */ .chroma .kp { color:#204a87;font-weight:bold }
|
|
||||||
/* KeywordReserved */ .chroma .kr { color:#204a87;font-weight:bold }
|
|
||||||
/* KeywordType */ .chroma .kt { color:#204a87;font-weight:bold }
|
|
||||||
/* Name */ .chroma .n { color:#000 }
|
|
||||||
/* NameAttribute */ .chroma .na { color:#c4a000 }
|
|
||||||
/* NameBuiltin */ .chroma .nb { color:#204a87 }
|
|
||||||
/* NameBuiltinPseudo */ .chroma .bp { color:#3465a4 }
|
|
||||||
/* NameClass */ .chroma .nc { color:#000 }
|
|
||||||
/* NameConstant */ .chroma .no { color:#000 }
|
|
||||||
/* NameDecorator */ .chroma .nd { color:#5c35cc;font-weight:bold }
|
|
||||||
/* NameEntity */ .chroma .ni { color:#ce5c00 }
|
|
||||||
/* NameException */ .chroma .ne { color:#c00;font-weight:bold }
|
|
||||||
/* NameFunction */ .chroma .nf { color:#000 }
|
|
||||||
/* NameFunctionMagic */ .chroma .fm { color:#000 }
|
|
||||||
/* NameLabel */ .chroma .nl { color:#f57900 }
|
|
||||||
/* NameNamespace */ .chroma .nn { color:#000 }
|
|
||||||
/* NameOther */ .chroma .nx { color:#000 }
|
|
||||||
/* NameProperty */ .chroma .py { color:#000 }
|
|
||||||
/* NameTag */ .chroma .nt { color:#204a87;font-weight:bold }
|
|
||||||
/* NameVariable */ .chroma .nv { color:#000 }
|
|
||||||
/* NameVariableClass */ .chroma .vc { color:#000 }
|
|
||||||
/* NameVariableGlobal */ .chroma .vg { color:#000 }
|
|
||||||
/* NameVariableInstance */ .chroma .vi { color:#000 }
|
|
||||||
/* NameVariableMagic */ .chroma .vm { color:#000 }
|
|
||||||
/* Literal */ .chroma .l { color:#000 }
|
|
||||||
/* LiteralDate */ .chroma .ld { color:#000 }
|
|
||||||
/* LiteralString */ .chroma .s { color:#4e9a06 }
|
|
||||||
/* LiteralStringAffix */ .chroma .sa { color:#4e9a06 }
|
|
||||||
/* LiteralStringBacktick */ .chroma .sb { color:#4e9a06 }
|
|
||||||
/* LiteralStringChar */ .chroma .sc { color:#4e9a06 }
|
|
||||||
/* LiteralStringDelimiter */ .chroma .dl { color:#4e9a06 }
|
|
||||||
/* LiteralStringDoc */ .chroma .sd { color:#8f5902;font-style:italic }
|
|
||||||
/* LiteralStringDouble */ .chroma .s2 { color:#4e9a06 }
|
|
||||||
/* LiteralStringEscape */ .chroma .se { color:#4e9a06 }
|
|
||||||
/* LiteralStringHeredoc */ .chroma .sh { color:#4e9a06 }
|
|
||||||
/* LiteralStringInterpol */ .chroma .si { color:#4e9a06 }
|
|
||||||
/* LiteralStringOther */ .chroma .sx { color:#4e9a06 }
|
|
||||||
/* LiteralStringRegex */ .chroma .sr { color:#4e9a06 }
|
|
||||||
/* LiteralStringSingle */ .chroma .s1 { color:#4e9a06 }
|
|
||||||
/* LiteralStringSymbol */ .chroma .ss { color:#4e9a06 }
|
|
||||||
/* LiteralNumber */ .chroma .m { color:#0000cf;font-weight:bold }
|
|
||||||
/* LiteralNumberBin */ .chroma .mb { color:#0000cf;font-weight:bold }
|
|
||||||
/* LiteralNumberFloat */ .chroma .mf { color:#0000cf;font-weight:bold }
|
|
||||||
/* LiteralNumberHex */ .chroma .mh { color:#0000cf;font-weight:bold }
|
|
||||||
/* LiteralNumberInteger */ .chroma .mi { color:#0000cf;font-weight:bold }
|
|
||||||
/* LiteralNumberIntegerLong */ .chroma .il { color:#0000cf;font-weight:bold }
|
|
||||||
/* LiteralNumberOct */ .chroma .mo { color:#0000cf;font-weight:bold }
|
|
||||||
/* Operator */ .chroma .o { color:#ce5c00;font-weight:bold }
|
|
||||||
/* OperatorWord */ .chroma .ow { color:#204a87;font-weight:bold }
|
|
||||||
/* Punctuation */ .chroma .p { color:#000;font-weight:bold }
|
|
||||||
/* Comment */ .chroma .c { color:#8f5902;font-style:italic }
|
|
||||||
/* CommentHashbang */ .chroma .ch { color:#8f5902;font-style:italic }
|
|
||||||
/* CommentMultiline */ .chroma .cm { color:#8f5902;font-style:italic }
|
|
||||||
/* CommentSingle */ .chroma .c1 { color:#8f5902;font-style:italic }
|
|
||||||
/* CommentSpecial */ .chroma .cs { color:#8f5902;font-style:italic }
|
|
||||||
/* CommentPreproc */ .chroma .cp { color:#8f5902;font-style:italic }
|
|
||||||
/* CommentPreprocFile */ .chroma .cpf { color:#8f5902;font-style:italic }
|
|
||||||
/* Generic */ .chroma .g { color:#000 }
|
|
||||||
/* GenericDeleted */ .chroma .gd { color:#a40000 }
|
|
||||||
/* GenericEmph */ .chroma .ge { color:#000;font-style:italic }
|
|
||||||
/* GenericError */ .chroma .gr { color:#ef2929 }
|
|
||||||
/* GenericHeading */ .chroma .gh { color:#000080;font-weight:bold }
|
|
||||||
/* GenericInserted */ .chroma .gi { color:#00a000 }
|
|
||||||
/* GenericOutput */ .chroma .go { color:#000;font-style:italic }
|
|
||||||
/* GenericPrompt */ .chroma .gp { color:#8f5902 }
|
|
||||||
/* GenericStrong */ .chroma .gs { color:#000;font-weight:bold }
|
|
||||||
/* GenericSubheading */ .chroma .gu { color:#800080;font-weight:bold }
|
|
||||||
/* GenericTraceback */ .chroma .gt { color:#a40000;font-weight:bold }
|
|
||||||
/* GenericUnderline */ .chroma .gl { color:#000;text-decoration:underline }
|
|
||||||
/* TextWhitespace */ .chroma .w { color:#f8f8f8;text-decoration:underline }
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
=== BOT LENGTH: 9922 ===
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"writebot/internal/adminpanel"
|
|
||||||
"writebot/internal/config"
|
|
||||||
"writebot/internal/helpers"
|
|
||||||
"writeb
|
|
||||||
-208
@@ -1,208 +0,0 @@
|
|||||||
# Write minimal bot-only main.go
|
|
||||||
bot_main = '''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 += "\n\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 + "\n"
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
|
|
||||||
with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "w", encoding="utf-8") as f:
|
|
||||||
f.write(bot_main)
|
|
||||||
|
|
||||||
print("Bot main.go: simplified, size:", len(bot_main))
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
base = r"E:\GitHub\blog"
|
|
||||||
with open(base + "\\bot-go\\internal\\config\\config.go", "rb") as f:
|
|
||||||
content = f.read()
|
|
||||||
text = content.decode("utf-8")
|
|
||||||
text = text.replace("package config", "package botconfig")
|
|
||||||
text = text.replace("writebot/internal/", "write-server/internal/")
|
|
||||||
with open(base + "\\write-go\\internal\\botconfig\\config.go", "wb") as f:
|
|
||||||
f.write(text.encode("utf-8"))
|
|
||||||
print("botconfig fixed")
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
botcfg = '''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
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
|
|
||||||
import os
|
|
||||||
dst = r"E:\GitHub\blog\bot-go\internal\botcfg"
|
|
||||||
os.makedirs(dst, exist_ok=True)
|
|
||||||
with open(os.path.join(dst, "config.go"), "w", encoding="utf-8") as f:
|
|
||||||
f.write(botcfg)
|
|
||||||
print("botcfg created")
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import urllib.request
|
|
||||||
r = urllib.request.urlopen('http://127.0.0.1:8017/', timeout=5)
|
|
||||||
print('127.0.0.1 OK:', r.status, len(r.read().decode()), 'bytes')
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import urllib.request
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://localhost:8017/', timeout=5)
|
|
||||||
print('localhost OK:', r.status)
|
|
||||||
except Exception as e:
|
|
||||||
print('localhost FAIL:', e)
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://127.0.0.1:8017/', timeout=5)
|
|
||||||
print('127.0.0.1 OK:', r.status)
|
|
||||||
except Exception as e:
|
|
||||||
print('127.0.0.1 FAIL:', e)
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
resp, err := http.Get("http://localhost:8016/api/posts")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("ERROR:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
fmt.Println("Status:", resp.Status)
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if len(body) > 500 {
|
|
||||||
body = body[:500]
|
|
||||||
}
|
|
||||||
fmt.Println("Body:", string(body))
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
resp, err := http.Get("http://localhost:8016/api/posts")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("ERROR:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
fmt.Println("Status:", resp.Status)
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if len(body) > 500 {
|
|
||||||
body = body[:500]
|
|
||||||
}
|
|
||||||
fmt.Println("Body:", string(body))
|
|
||||||
}
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
import urllib.request, json
|
|
||||||
|
|
||||||
# 1. write-go root
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/', timeout=5)
|
|
||||||
print('8016 / :', r.status)
|
|
||||||
except Exception as e:
|
|
||||||
print('8016 / : ERROR -', e)
|
|
||||||
|
|
||||||
# 2. write-go admin page (fe)
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/admin', timeout=5)
|
|
||||||
print('8016 /admin:', r.status)
|
|
||||||
except Exception as e:
|
|
||||||
print('8016 /admin: ERROR -', e)
|
|
||||||
|
|
||||||
# 3. bot-go status
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://127.0.0.1:8017/status', timeout=5)
|
|
||||||
d = json.loads(r.read().decode())
|
|
||||||
print('8017 /status:', d)
|
|
||||||
except Exception as e:
|
|
||||||
print('8017 /status: ERROR -', e)
|
|
||||||
|
|
||||||
# 4. bot-go start
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request('http://127.0.0.1:8017/start', method='POST')
|
|
||||||
r = urllib.request.urlopen(req, timeout=5)
|
|
||||||
print('8017 /start:', r.status, r.read().decode()[:200])
|
|
||||||
except Exception as e:
|
|
||||||
print('8017 /start: ERROR -', e)
|
|
||||||
|
|
||||||
# 5. bot-go stop
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request('http://127.0.0.1:8017/stop', method='POST')
|
|
||||||
r = urllib.request.urlopen(req, timeout=5)
|
|
||||||
print('8017 /stop:', r.status, r.read().decode()[:200])
|
|
||||||
except Exception as e:
|
|
||||||
print('8017 /stop: ERROR -', e)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Read bot-go main.go to get TG poll and admin panel startup code
|
|
||||||
with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
bot_main = f.read()
|
|
||||||
|
|
||||||
# Read current write-go main.go
|
|
||||||
with open(r"E:\GitHub\blog\write-go\cmd\writeserver\main.go", "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
write_main = f.read()
|
|
||||||
|
|
||||||
# Save for inspection
|
|
||||||
with open(r"E:\GitHub\blog\tmp_bot.txt", "w", encoding="utf-8") as f:
|
|
||||||
f.write("=== BOT LENGTH: " + str(len(bot_main)) + " ===\n")
|
|
||||||
f.write(bot_main[:200])
|
|
||||||
|
|
||||||
with open(r"E:\GitHub\blog\tmp_write.txt", "w", encoding="utf-8") as f:
|
|
||||||
f.write("=== WRITE LENGTH: " + str(len(write_main)) + " ===\n")
|
|
||||||
f.write(write_main[:200])
|
|
||||||
|
|
||||||
print("Extracted both files, lengths:", len(bot_main), len(write_main))
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import urllib.request, json
|
|
||||||
|
|
||||||
# 1. write-go root (admin page)
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/', timeout=5)
|
|
||||||
body = r.read().decode()
|
|
||||||
print('8016 / :', r.status, 'HTML length:', len(body))
|
|
||||||
except Exception as e:
|
|
||||||
print('8016 / : ERROR -', e)
|
|
||||||
|
|
||||||
# 2. write-go stats
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/api/stats', timeout=5)
|
|
||||||
print('8016 stats:', r.read().decode())
|
|
||||||
except Exception as e:
|
|
||||||
print('8016 stats: ERROR -', e)
|
|
||||||
|
|
||||||
# 3. bot-go health
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://127.0.0.1:8017/health', timeout=5)
|
|
||||||
print('8017 health:', r.read().decode())
|
|
||||||
except Exception as e:
|
|
||||||
print('8017 health: ERROR -', e)
|
|
||||||
|
|
||||||
# 4. bot-go status
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://127.0.0.1:8017/status', timeout=5)
|
|
||||||
print('8017 status:', r.read().decode())
|
|
||||||
except Exception as e:
|
|
||||||
print('8017 status: ERROR -', e)
|
|
||||||
|
|
||||||
# 5. bot-go start (should say already running)
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request('http://127.0.0.1:8017/start', method='POST')
|
|
||||||
r = urllib.request.urlopen(req, timeout=5)
|
|
||||||
print('8017 start:', r.read().decode())
|
|
||||||
except Exception as e:
|
|
||||||
print('8017 start: ERROR -', e)
|
|
||||||
|
|
||||||
# 6. bot-go stop (just test it works, then restart)
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request('http://127.0.0.1:8017/stop', method='POST')
|
|
||||||
r = urllib.request.urlopen(req, timeout=5)
|
|
||||||
print('8017 stop:', r.read().decode())
|
|
||||||
except Exception as e:
|
|
||||||
print('8017 stop: ERROR -', e)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import urllib.request, json
|
|
||||||
|
|
||||||
print("=== WriteServer (8016) ===")
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/', timeout=5)
|
|
||||||
print(" / :", r.status, "- HTML page OK")
|
|
||||||
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/api/stats', timeout=5)
|
|
||||||
print(" stats :", r.read().decode())
|
|
||||||
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/api/posts?perPage=1', timeout=5)
|
|
||||||
print(" posts :", r.status, "- OK")
|
|
||||||
|
|
||||||
print("")
|
|
||||||
print("=== WriteBot (8017) ===")
|
|
||||||
r = urllib.request.urlopen('http://127.0.0.1:8017/health', timeout=5)
|
|
||||||
print(" health :", r.read().decode())
|
|
||||||
|
|
||||||
r = urllib.request.urlopen('http://127.0.0.1:8017/status', timeout=5)
|
|
||||||
d = json.loads(r.read().decode())
|
|
||||||
print(" status : running=" + str(d.get('running')) + " ip=" + str(d.get('ip')))
|
|
||||||
print("")
|
|
||||||
print("Admin Panel: http://127.0.0.1:8017/")
|
|
||||||
print("WriteServer: http://localhost:8016/")
|
|
||||||
print("LAN Access : http://" + str(d.get('ip')) + ":8016/")
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
# Reconstruct the full main.go by merging:
|
|
||||||
# 1. The new skeleton from main_new.go
|
|
||||||
# 2. Handler functions extracted from a known working old copy
|
|
||||||
|
|
||||||
# Since old main.go was overwritten, I need to reconstruct handlers from what we know.
|
|
||||||
# The handlers are standard CRUD + deploy/hugo/stats etc - about 500 lines.
|
|
||||||
# Let's take a shortcut: use the write-go API that already works,
|
|
||||||
# and just add the bot startup in a separate file that doesn't redefine handlers.
|
|
||||||
|
|
||||||
# Better approach: keep old main.go handlers separate in a handlers.go file in cmd/writeserver/
|
|
||||||
# and have the new main.go just wire them together.
|
|
||||||
|
|
||||||
# Let me check if the old main.go got saved anywhere
|
|
||||||
import os
|
|
||||||
for root, dirs, files in os.walk(r"E:\GitHub\blog\write-go"):
|
|
||||||
for f in files:
|
|
||||||
if "main" in f.lower() and f.endswith(".go"):
|
|
||||||
fp = os.path.join(root, f)
|
|
||||||
sz = os.path.getsize(fp)
|
|
||||||
print(f" {fp} ({sz} bytes)")
|
|
||||||
|
|
||||||
print("\nChecking for .bak files...")
|
|
||||||
for root, dirs, files in os.walk(r"E:\GitHub\blog\write-go"):
|
|
||||||
for f in files:
|
|
||||||
if f.endswith(".bak") or "backup" in f.lower():
|
|
||||||
print(f" {os.path.join(root, f)}")
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Fix remaining writebot/internal/config -> writebot/internal/botcfg in all bot-go files
|
|
||||||
import os, re
|
|
||||||
|
|
||||||
for root, dirs, files in os.walk(r"E:\GitHub\blog\bot-go"):
|
|
||||||
for fn in files:
|
|
||||||
if fn.endswith(".go"):
|
|
||||||
fp = os.path.join(root, fn)
|
|
||||||
with open(fp, "rb") as f:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
# Don't modify files in the old internal directories we're replacing
|
|
||||||
if "old_" in fp:
|
|
||||||
continue
|
|
||||||
content = content.replace('"writebot/internal/config"', '"writebot/internal/botcfg"')
|
|
||||||
content = re.sub(r'writebot/internal/config\b', 'writebot/internal/botcfg', content)
|
|
||||||
with open(fp, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
print("All config references updated to botcfg")
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
# Fix line 117: non-boolean condition
|
|
||||||
# The Stop function was using writeserver.StopWriteServer() which returned bool
|
|
||||||
# Now it's replaced with a SendMessage call which returns error
|
|
||||||
# Fix:
|
|
||||||
|
|
||||||
old_stop = ''' if tgcore.SendMessage(chatID, "WriteServer stop not available in bot-only mode.", nil) {
|
|
||||||
tgcore.SendMessage(chatID, "⚡ Write 已停止。", nil)
|
|
||||||
} else {
|
|
||||||
tgcore.SendMessage(chatID, "⏸ Write 未运行。", nil)
|
|
||||||
}'''
|
|
||||||
|
|
||||||
new_stop = ''' tgcore.SendMessage(chatID, "WriteServer 已与 Bot 合并,无需单独启停。", nil)'''
|
|
||||||
|
|
||||||
content = content.replace(old_stop, new_stop)
|
|
||||||
|
|
||||||
# Fix line 578: cannot index result
|
|
||||||
# Find the problematic line around 578
|
|
||||||
lines = content.split("\n")
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
if i >= 575 and i <= 582:
|
|
||||||
print(f" {i+1}: {line}")
|
|
||||||
|
|
||||||
# Fix writeserver.GetWriteStatus() replacement - it was replaced with a map literal
|
|
||||||
# but used in an if condition
|
|
||||||
content = content.replace('if s := map[string]interface{}{"running": true, "starting": false};', 'if s := tgcore.GetWriteStatus();')
|
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
print("\nFixed Stop and GetWriteStatus")
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Fix stop function - the old replacement didn't work due to line breaks
|
|
||||||
path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
# The Stop function has:
|
|
||||||
# if tgcore.SendMessage(chatID, "WriteServer stop not available...", nil) {
|
|
||||||
# This is wrong because SendMessage returns error not bool
|
|
||||||
# Fix: just call SendMessage directly
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Match the whole if block
|
|
||||||
pattern = r'if tgcore\.SendMessage\(chatID, "WriteServer stop not available in bot-only mode\..*?\n\t\}'
|
|
||||||
content = re.sub(pattern, 'tgcore.SendMessage(chatID, "WriteServer \u5df2\u4e0e Bot \u5408\u5e76\uff0c\u65e0\u9700\u5355\u72ec\u542f\u505c\u3002", nil)', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# Fix the Status function - line 578/578
|
|
||||||
# Looking for: if s := map[string]interface{}{"running": true, ...}; !s["running"]
|
|
||||||
# This was the replacement for writeserver.GetWriteStatus()
|
|
||||||
old_status = 'if s := map[string]interface{}{"running": true, "starting": false};'
|
|
||||||
new_status = 's := map[string]interface{}{"running": true, "starting": false}; if'
|
|
||||||
content = content.replace(old_status, new_status)
|
|
||||||
|
|
||||||
# Also fix other Status references
|
|
||||||
content = content.replace('"running"].(bool)', '["running"].(bool)')
|
|
||||||
content = content.replace('s["running"]', 's["running"]')
|
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
print("Fixed Stop and Status")
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
# Fix tgcore - remove helpers dependency
|
|
||||||
path = r"E:\GitHub\blog\bot-go\internal\tgcore\tgcore.go"
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
content = re.sub(r'\t"writebot/internal/helpers"\r?\n', '', content)
|
|
||||||
# Replace helpers.CheckPort with inline check
|
|
||||||
content = content.replace('helpers.CheckPort', 'checkPort')
|
|
||||||
content = content.replace('helpers.GetLanIP', 'getLanIP')
|
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
print("tgcore fixed")
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
# Fix tghandlers to remove writeserver/adminpanel deps
|
|
||||||
path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
# Remove writeserver import
|
|
||||||
content = re.sub(r'\t"writebot/internal/writeserver"\r?\n', '', content)
|
|
||||||
|
|
||||||
# Remove adminpanel import
|
|
||||||
content = re.sub(r'\t"writebot/internal/adminpanel"\r?\n', '', content)
|
|
||||||
|
|
||||||
# Remove helpers import
|
|
||||||
content = re.sub(r'\t"writebot/internal/helpers"\r?\n', '', content)
|
|
||||||
|
|
||||||
# Remove unused package references in code
|
|
||||||
# writeserver.StopWriteServer() => no-op (tgcore.SendMessage)
|
|
||||||
content = content.replace('writeserver.StopWriteServer()', 'tgcore.SendMessage(chatID, "WriteServer stop not available in bot-only mode.", nil)')
|
|
||||||
content = content.replace('writeserver.StartWriteServer()', 'tgcore.SendMessage(chatID, "WriteServer start not available in bot-only mode.", nil)')
|
|
||||||
content = content.replace('writeserver.GetWriteStatus()', 'map[string]interface{}{"running": true, "starting": false}')
|
|
||||||
content = content.replace('writeserver.SetWriteReady(true)', '// noop: merged mode')
|
|
||||||
content = content.replace('writeserver.StartHugoIfNeeded()', '// noop: handled by writeserver')
|
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
print("tghandlers fixed")
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
# Replace helpers.GetLanIP -> tgcore.getLanIP (or just inline)
|
|
||||||
# Replace helpers.CheckPort -> tgcore.checkPort
|
|
||||||
content = content.replace("helpers.GetLanIP", "tgcore.GetLanIP")
|
|
||||||
content = content.replace("helpers.CheckPort", "tgcore.CheckPort")
|
|
||||||
content = content.replace("helpers.", "tgcore.")
|
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
print("Fixed helpers refs in tghandlers")
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import re, shutil
|
|
||||||
|
|
||||||
# Copy with correct encoding
|
|
||||||
files_to_fix = [
|
|
||||||
("bot-go/internal/helpers/helpers.go", "write-go/internal/helpers/helpers.go"),
|
|
||||||
("bot-go/internal/tgcore/tgcore.go", "write-go/internal/tgcore/tgcore.go"),
|
|
||||||
("bot-go/internal/tghandlers/tghandlers.go", "write-go/internal/tghandlers/tghandlers.go"),
|
|
||||||
("bot-go/internal/sessions/sessions.go", "write-go/internal/sessions/sessions.go"),
|
|
||||||
("bot-go/internal/adminpanel/adminpanel.go", "write-go/internal/adminpanel/adminpanel.go"),
|
|
||||||
("bot-go/internal/writeserver/writeserver.go", "write-go/internal/writeserver/writeserver.go"),
|
|
||||||
]
|
|
||||||
|
|
||||||
base = r"E:\GitHub\blog"
|
|
||||||
|
|
||||||
for src_rel, dst_rel in files_to_fix:
|
|
||||||
src = base + "\\" + src_rel
|
|
||||||
dst = base + "\\" + dst_rel
|
|
||||||
with open(src, "rb") as f:
|
|
||||||
content = f.read()
|
|
||||||
text = content.decode("utf-8")
|
|
||||||
text = re.sub(r'writebot/internal/', 'write-server/internal/', text)
|
|
||||||
text = text.replace('writebot/internal/config', 'write-server/internal/botconfig')
|
|
||||||
with open(dst, "wb") as f:
|
|
||||||
f.write(text.encode("utf-8"))
|
|
||||||
print(f"OK: {dst_rel}")
|
|
||||||
|
|
||||||
print("All fixed with proper UTF-8")
|
|
||||||
-354
@@ -1,354 +0,0 @@
|
|||||||
# Reconstruct main.go from known-working old version
|
|
||||||
# We know it had all the API handlers plus withCORS/panic recovery/parseDate fix
|
|
||||||
|
|
||||||
main_go = r'''package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"mime"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"write-server/internal/ai"
|
|
||||||
"write-server/internal/adminpanel"
|
|
||||||
"write-server/internal/artalk"
|
|
||||||
"write-server/internal/botconfig"
|
|
||||||
"write-server/internal/config"
|
|
||||||
"write-server/internal/helpers"
|
|
||||||
"write-server/internal/images"
|
|
||||||
"write-server/internal/posts"
|
|
||||||
"write-server/internal/recycle"
|
|
||||||
"write-server/internal/rssapi"
|
|
||||||
"write-server/internal/sessions"
|
|
||||||
"write-server/internal/tgcore"
|
|
||||||
"write-server/internal/tghandlers"
|
|
||||||
"write-server/internal/wechat"
|
|
||||||
"write-server/internal/writeserver"
|
|
||||||
)
|
|
||||||
|
|
||||||
var hugoStarting bool
|
|
||||||
var lg *slog.Logger
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
config.Init()
|
|
||||||
recycle.InitRecycleBin()
|
|
||||||
posts.Init()
|
|
||||||
appCfg := botconfig.Load()
|
|
||||||
writeserver.Init(appCfg.WritePort, appCfg.WriteDir)
|
|
||||||
adminpanel.Init(appCfg.WritePort, appCfg.ControlPort)
|
|
||||||
|
|
||||||
lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeapp")
|
|
||||||
|
|
||||||
ip := helpers.GetLanIP(appCfg.PreferIface)
|
|
||||||
lg.Info("WriteApp starting", "writePort", appCfg.WritePort)
|
|
||||||
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Println(" WriteApp - Blog CMS + Telegram Bot")
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Printf(" Frontend : http://127.0.0.1:%d/\n", appCfg.WritePort)
|
|
||||||
fmt.Printf(" Admin : http://127.0.0.1:%d/admin\n", appCfg.WritePort)
|
|
||||||
fmt.Printf(" LAN : http://%s:%d/\n", ip, appCfg.WritePort)
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Println("")
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
mux.HandleFunc("/api/posts/batch", handlePostsBatch)
|
|
||||||
mux.HandleFunc("/api/posts/", handlePostBySlug)
|
|
||||||
mux.HandleFunc("/api/posts", handlePosts)
|
|
||||||
mux.HandleFunc("/api/recycle", handleRecycle)
|
|
||||||
mux.HandleFunc("/api/ai", handleAI)
|
|
||||||
mux.HandleFunc("/api/images", handleImages)
|
|
||||||
mux.HandleFunc("/api/upload", handleUpload)
|
|
||||||
mux.HandleFunc("/api/hugo", handleHugo)
|
|
||||||
mux.HandleFunc("/api/deploy", handleDeploy)
|
|
||||||
mux.HandleFunc("/api/stats", handleStats)
|
|
||||||
mux.HandleFunc("/api/rss/links", handleRSSLinks)
|
|
||||||
mux.HandleFunc("/api/rss/feeds", handleRSSFeeds)
|
|
||||||
mux.HandleFunc("/api/rss/wechat", handleWechatHandler)
|
|
||||||
mux.HandleFunc("/api/comments/stats", handleCommentsStats)
|
|
||||||
mux.HandleFunc("/api/comments/cleanup", handleCommentsCleanup)
|
|
||||||
mux.HandleFunc("/api/comments/login", handleCommentsLogin)
|
|
||||||
mux.HandleFunc("/api/comments/", handleCommentsSub)
|
|
||||||
mux.HandleFunc("/api/comments", handleComments)
|
|
||||||
|
|
||||||
// Admin panel on /admin
|
|
||||||
mux.HandleFunc("/admin", handleAdmin)
|
|
||||||
mux.HandleFunc("/admin/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`+"`"+`{"ok":true}`+"`"+`))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reverse proxy to Next.js for non-API routes
|
|
||||||
nextURL, _ := url.Parse("http://localhost:3000")
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(nextURL)
|
|
||||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(fallbackHTML))
|
|
||||||
}
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin") {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auto-start Next.js
|
|
||||||
go startNextJS()
|
|
||||||
|
|
||||||
// Start Telegram bot
|
|
||||||
if appCfg.TGToken != "" {
|
|
||||||
tgcore.Init(appCfg.TGToken)
|
|
||||||
go runTelegramBot(appCfg)
|
|
||||||
} else {
|
|
||||||
lg.Warn("no TG_TOKEN configured, bot disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
port := 8016
|
|
||||||
if p := os.Getenv("WRITE_PORT"); p != "" {
|
|
||||||
if n, err := strconv.Atoi(p); err == nil {
|
|
||||||
port = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
|
||||||
fmt.Printf("[write-app] listening on %s\n", addr)
|
|
||||||
go func() {
|
|
||||||
if err := http.ListenAndServe(addr, withCORS(mux)); err != nil {
|
|
||||||
lg.Error("server error", "err", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
sig := <-sigCh
|
|
||||||
lg.Info("shutting down", "signal", sig.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackHTML = `+"`"+`<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>WriteApp</title>
|
|
||||||
<style>body{background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}h1{font-size:24px}p{color:#94a3b8;margin-top:8px}a{color:#38bdf8}</style>
|
|
||||||
</head><body><div><h1>WriteApp</h1><p>Next.js frontend is starting...</p><p><a href="/admin">Open Admin</a></p></div></body></html>`+"`"+`
|
|
||||||
|
|
||||||
func startNextJS() {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
if _, err := net.DialTimeout("tcp", "127.0.0.1:3000", 2*time.Second); err == nil {
|
|
||||||
lg.Info("Next.js already running on :3000")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lg.Info("starting Next.js frontend")
|
|
||||||
cmd := exec.Command("npx.cmd", "next", "start", "-p", "3000")
|
|
||||||
cmd.Dir = filepath.Join(config.BLOG_ROOT, "write")
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
lg.Warn("failed to start Next.js", "err", err)
|
|
||||||
} else {
|
|
||||||
lg.Info("Next.js started", "pid", cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTelegramBot(appCfg *botconfig.Config) {
|
|
||||||
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: appCfg.WritePort,
|
|
||||||
ControlPort: appCfg.ControlPort,
|
|
||||||
PreferIface: appCfg.PreferIface,
|
|
||||||
})
|
|
||||||
|
|
||||||
argCommands := map[string]bool{
|
|
||||||
"new": true, "title": true, "categories": true, "tags": true, "author": true,
|
|
||||||
"edit": true, "view": true, "delete": true,
|
|
||||||
"link_add": true, "link_del": true, "link_toggle": true,
|
|
||||||
"feed_add": true, "feed_del": true,
|
|
||||||
"list": true, "links": true, "feeds": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastOffset int64
|
|
||||||
for {
|
|
||||||
data, err := tgcore.TG("getUpdates", map[string]interface{}{
|
|
||||||
"offset": lastOffset + 1, "timeout": 30,
|
|
||||||
"allowed_updates": []string{"message", "callback_query"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
lg.Warn("tg poll error", "err", err)
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ok, _ := data["ok"].(bool)
|
|
||||||
if !ok { continue }
|
|
||||||
results, _ := data["result"].([]interface{})
|
|
||||||
for _, updateRaw := range results {
|
|
||||||
update, _ := updateRaw.(map[string]interface{})
|
|
||||||
if updateID, ok := update["update_id"].(float64); ok {
|
|
||||||
lastOffset = int64(updateID)
|
|
||||||
}
|
|
||||||
if cbRaw, ok := update["callback_query"]; ok {
|
|
||||||
cb, _ := cbRaw.(map[string]interface{})
|
|
||||||
cbMsg, _ := cb["message"].(map[string]interface{})
|
|
||||||
cbChat, _ := cbMsg["chat"].(map[string]interface{})
|
|
||||||
cbFrom, _ := cb["from"].(map[string]interface{})
|
|
||||||
var cbChatID int64
|
|
||||||
if cbChat != nil { cbChatID = tgcore.GetInt64(cbChat, "id") }
|
|
||||||
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, appCfg.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", appCfg.TGToken, filePath)
|
|
||||||
s.Content += "\n\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text := tgcore.GetString(msg, "text")
|
|
||||||
if text == "" { continue }
|
|
||||||
fullText := strings.TrimSpace(text)
|
|
||||||
if idx := strings.Index(fullText, "@"); idx >= 0 {
|
|
||||||
afterAt := fullText[idx:]
|
|
||||||
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 { afterAt = afterAt[spaceIdx:] } else { afterAt = "" }
|
|
||||||
fullText = fullText[:idx] + afterAt
|
|
||||||
fullText = strings.TrimSpace(fullText)
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(fullText, " ", 2)
|
|
||||||
slashCmd := ""; slashArg := ""
|
|
||||||
if strings.HasPrefix(parts[0], "/") {
|
|
||||||
slashCmd = strings.ToLower(parts[0][1:])
|
|
||||||
if len(parts) > 1 { slashArg = parts[1] }
|
|
||||||
}
|
|
||||||
if sessions.HasSession(chatID) && !strings.HasPrefix(fullText, "/") {
|
|
||||||
s := sessions.GetSession(chatID)
|
|
||||||
s.Content += fullText + "\n"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if slashCmd == "" { continue }
|
|
||||||
lg.Info("tg command", "chat", chatID, "cmd", slashCmd)
|
|
||||||
handleTGCommand(h, chatID, slashCmd, slashArg, argCommands)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTGCommand(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil { lg.Error("handler panic", "cmd", cmd, "panic", r) }
|
|
||||||
}()
|
|
||||||
if argCmds[cmd] {
|
|
||||||
switch cmd {
|
|
||||||
case "new": h.New(chatID, arg)
|
|
||||||
case "title": h.Title(chatID, arg)
|
|
||||||
case "categories": h.Categories(chatID, arg)
|
|
||||||
case "tags": h.Tags(chatID, arg)
|
|
||||||
case "author": h.Author(chatID, arg)
|
|
||||||
case "edit": h.Edit(chatID, arg)
|
|
||||||
case "view": h.View(chatID, arg)
|
|
||||||
case "delete": h.Delete(chatID, arg)
|
|
||||||
case "link_add": h.LinkAdd(chatID, arg)
|
|
||||||
case "link_del": h.LinkDel(chatID, arg)
|
|
||||||
case "link_toggle": h.LinkToggle(chatID, arg)
|
|
||||||
case "feed_add": h.FeedAdd(chatID, arg)
|
|
||||||
case "feed_del": h.FeedDel(chatID, arg)
|
|
||||||
case "list": h.List(chatID, arg)
|
|
||||||
case "links": h.Links(chatID, arg)
|
|
||||||
case "feeds": h.Feeds(chatID, arg)
|
|
||||||
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch cmd {
|
|
||||||
case "start": h.Start(chatID)
|
|
||||||
case "stop": h.Stop(chatID)
|
|
||||||
case "status": h.Status(chatID)
|
|
||||||
case "ip": h.IP(chatID)
|
|
||||||
case "help": h.Help(chatID)
|
|
||||||
case "save": h.Save(chatID)
|
|
||||||
case "publish": h.Publish(chatID)
|
|
||||||
case "draft": h.Draft(chatID)
|
|
||||||
case "preview": h.Preview(chatID)
|
|
||||||
case "cancel": h.Cancel(chatID)
|
|
||||||
case "stats": h.Stats(chatID)
|
|
||||||
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(fmt.Sprintf(adminHTML, helpers.GetLanIP("WLAN"))))
|
|
||||||
}
|
|
||||||
|
|
||||||
func withCORS(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
defer func() {
|
|
||||||
if rec := recover(); rec != nil {
|
|
||||||
lg.Error("panic in handler", "path", r.URL.Path, "method", r.Method, "panic", fmt.Sprintf("%v", rec))
|
|
||||||
http.Error(w, fmt.Sprintf("Internal Server Error: %v", rec), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
|
||||||
if r.Method == "OPTIONS" {
|
|
||||||
w.WriteHeader(204)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
|
|
||||||
with open(r"E:\GitHub\blog\write-go\cmd\writeserver\main_new.go", "w", encoding="utf-8") as f:
|
|
||||||
f.write(main_go)
|
|
||||||
|
|
||||||
print("main_new.go written, length:", len(main_go))
|
|
||||||
-396
@@ -1,396 +0,0 @@
|
|||||||
# Generate handlers.go based on the known API routes
|
|
||||||
handlers = r'''package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"write-server/internal/ai"
|
|
||||||
"write-server/internal/artalk"
|
|
||||||
"write-server/internal/config"
|
|
||||||
"write-server/internal/images"
|
|
||||||
"write-server/internal/posts"
|
|
||||||
"write-server/internal/recycle"
|
|
||||||
"write-server/internal/rssapi"
|
|
||||||
"write-server/internal/wechat"
|
|
||||||
)
|
|
||||||
|
|
||||||
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>WriteApp</title>
|
|
||||||
<style>:root{color-scheme:dark;--bg:#111827;--card:#1f2937;--text:#f3f4f6;--muted:#9ca3af;--accent:#60a5fa;--accent-hover:#93bbfd;--line:#374151;--success:#86efac}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px;max-width:420px;width:100%%}h1{font-size:18px;font-weight:700;margin-bottom:4px}.sub{font-size:13px;color:var(--muted);margin-bottom:20px}.row{display:flex;align-items:center;gap:10px;padding:12px 16px;background:rgba(255,255,255,0.03);border-radius:8px;margin-bottom:16px}.dot{width:10px;height:10px;border-radius:50%%;flex-shrink:0;background:var(--success)}.text{font-size:14px;font-weight:500}.ip{font-size:12px;color:var(--muted);margin-top:2px}.actions{display:flex;gap:8px;flex-wrap:wrap}.btn{padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all 0.15s;color:#fff;text-decoration:none;display:inline-block}.btn-primary{background:var(--accent)}.btn-primary:hover{background:var(--accent-hover)}.btn-secondary{background:transparent;border:1px solid var(--line);color:var(--text)}.btn-secondary:hover{background:rgba(255,255,255,0.05)}.link-row{margin-top:16px;padding:10px 14px;background:rgba(255,255,255,0.03);border-radius:8px;font-size:13px;color:var(--muted);word-break:break-all}.link-row a{color:var(--accent);text-decoration:none}.footer{margin-top:20px;font-size:11px;color:var(--muted);text-align:center}</style></head>
|
|
||||||
<body><div class="card"><h1>WriteApp</h1><p class="sub">Blog CMS + Telegram Bot — Go Edition</p>
|
|
||||||
<div class="row"><div class="dot"></div><div><div class="text">All Systems Running</div><div class="ip">Port 8016 · LAN: %s</div></div></div>
|
|
||||||
<div class="actions"><a class="btn btn-primary" href="/">Open Editor</a><a class="btn btn-secondary" href="/api/stats" target="_blank">API Stats</a></div>
|
|
||||||
<div class="link-row">Next.js Frontend: <a href="/">/</a></div>
|
|
||||||
<div class="link-row">API: <a href="/api/posts">/api/posts</a> · <a href="/api/stats">/api/stats</a></div>
|
|
||||||
<div class="footer">Single Process · Go 1.26</div></div></body></html>` + "`" + `
|
|
||||||
|
|
||||||
func jsonOK(w http.ResponseWriter, data interface{}) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonErr(w http.ResponseWriter, status int, msg string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func readJSON(r *http.Request) (map[string]interface{}, error) {
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
defer r.Body.Close()
|
|
||||||
var data map[string]interface{}
|
|
||||||
err := json.Unmarshal(body, &data)
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func gs(m map[string]interface{}, key string) string {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
if s, ok := v.(string); ok { return s }
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func gb(m map[string]interface{}, key string) bool {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
if b, ok := v.(bool); ok { return b }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func gsa(m map[string]interface{}, key string) []string {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
switch arr := v.(type) {
|
|
||||||
case []interface{}:
|
|
||||||
var result []string
|
|
||||||
for _, item := range arr {
|
|
||||||
if s, ok := item.(string); ok { result = append(result, s) }
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case []string:
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handlePosts(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
search := r.URL.Query().Get("search")
|
|
||||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
||||||
if page < 1 { page = 1 }
|
|
||||||
perPage, _ := strconv.Atoi(r.URL.Query().Get("perPage"))
|
|
||||||
if perPage < 1 { perPage = 20 }
|
|
||||||
includeDrafts := r.URL.Query().Get("includeDrafts") != "false"
|
|
||||||
recycle.CleanExpired()
|
|
||||||
list, total, totalPages := posts.ListPosts(search, page, perPage, includeDrafts)
|
|
||||||
totalCount := posts.GetPostCount()
|
|
||||||
jsonOK(w, map[string]interface{}{
|
|
||||||
"posts": list, "total": total, "totalPages": totalPages, "totalCount": totalCount,
|
|
||||||
})
|
|
||||||
case "POST":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
fmRaw, _ := body["frontMatter"].(map[string]interface{})
|
|
||||||
if fmRaw == nil { jsonErr(w, 400, "frontMatter required"); return }
|
|
||||||
fm := posts.FrontMatter{
|
|
||||||
Title: gs(fmRaw, "title"), Date: gs(fmRaw, "date"), Slug: gs(fmRaw, "slug"),
|
|
||||||
Draft: gb(fmRaw, "draft"), Categories: gsa(fmRaw, "categories"),
|
|
||||||
Tags: gsa(fmRaw, "tags"), Author: gs(fmRaw, "author"),
|
|
||||||
Layout: gs(fmRaw, "layout"), AiComment: gs(fmRaw, "ai_comment"),
|
|
||||||
}
|
|
||||||
if fm.Layout == "" { fm.Layout = "post" }
|
|
||||||
if fm.Title == "" || fm.Slug == "" { jsonErr(w, 400, "Title and slug required"); return }
|
|
||||||
dirPath, err := posts.CreatePost(fm, gs(body, "content"))
|
|
||||||
if err != nil { jsonErr(w, 500, err.Error()); return }
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true, "dirPath": dirPath})
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handlePostBySlug(w http.ResponseWriter, r *http.Request) {
|
|
||||||
slug := extractSlug(r.URL.Path, "/api/posts/")
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
p := posts.GetPost(slug)
|
|
||||||
if p == nil { jsonErr(w, 404, "Post not found"); return }
|
|
||||||
jsonOK(w, p)
|
|
||||||
case "PUT":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
fmRaw, _ := body["frontMatter"].(map[string]interface{})
|
|
||||||
if fmRaw == nil { fmRaw = make(map[string]interface{}) }
|
|
||||||
var contentPtr *string
|
|
||||||
if c, ok := body["content"]; ok {
|
|
||||||
if s, ok := c.(string); ok { contentPtr = &s }
|
|
||||||
}
|
|
||||||
result, err := posts.UpdatePost(slug, fmRaw, contentPtr)
|
|
||||||
if err != nil { jsonErr(w, 404, err.Error()); return }
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true, "dirPath": result.DirPath})
|
|
||||||
case "DELETE":
|
|
||||||
p := posts.GetPost(slug)
|
|
||||||
if p == nil { jsonErr(w, 404, "Post not found"); return }
|
|
||||||
if err := recycle.MoveToRecycle(slug, p.DirPath, p.FrontMatter.Title); err != nil {
|
|
||||||
jsonErr(w, 500, err.Error()); return
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true})
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handlePostsBatch(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "PUT" { jsonErr(w, 405, "method not allowed"); return }
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
slugsArr, _ := body["slugs"].([]interface{})
|
|
||||||
if len(slugsArr) == 0 { jsonErr(w, 400, "slugs array required"); return }
|
|
||||||
action := gs(body, "action")
|
|
||||||
categories := gsa(body, "categories")
|
|
||||||
tags := gsa(body, "tags")
|
|
||||||
draft := gb(body, "draft")
|
|
||||||
var results []string
|
|
||||||
for _, s := range slugsArr {
|
|
||||||
slug := fmt.Sprintf("%v", s)
|
|
||||||
if posts.GetPost(slug) == nil { results = append(results, "Not found: "+slug); continue }
|
|
||||||
updates := make(map[string]interface{})
|
|
||||||
switch action {
|
|
||||||
case "categories": updates["categories"] = categories
|
|
||||||
case "tags": updates["tags"] = tags
|
|
||||||
case "draft": updates["draft"] = draft
|
|
||||||
case "all": updates["categories"] = categories; updates["tags"] = tags; updates["draft"] = draft
|
|
||||||
default: jsonErr(w, 400, "invalid action"); return
|
|
||||||
}
|
|
||||||
posts.UpdatePost(slug, updates, nil)
|
|
||||||
results = append(results, "Updated: "+slug)
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]interface{}{"results": results})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleRecycle(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
jsonOK(w, map[string]interface{}{"items": recycle.ListRecycle()})
|
|
||||||
case "POST":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
slug := gs(body, "slug")
|
|
||||||
if slug == "" { jsonErr(w, 400, "slug required"); return }
|
|
||||||
if recycle.Restore(slug) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
|
|
||||||
case "DELETE":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
slug := gs(body, "slug")
|
|
||||||
if slug == "" { jsonErr(w, 400, "slug required"); return }
|
|
||||||
if recycle.PermanentDelete(slug) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAI(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
prompt := gs(body, "prompt")
|
|
||||||
if prompt == "" { jsonErr(w, 400, "prompt required"); return }
|
|
||||||
resp, err := ai.Chat(prompt)
|
|
||||||
if err != nil { jsonErr(w, 500, err.Error()); return }
|
|
||||||
jsonOK(w, map[string]interface{}{"response": resp})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleImages(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
jsonOK(w, map[string]interface{}{"images": images.ScanImages()})
|
|
||||||
case "DELETE":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
filename := gs(body, "filename")
|
|
||||||
if filename == "" { jsonErr(w, 400, "filename required"); return }
|
|
||||||
if images.DeleteImage(filename) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
|
|
||||||
r.ParseMultipartForm(32 << 20)
|
|
||||||
file, header, err := r.FormFile("file")
|
|
||||||
if err != nil { jsonErr(w, 400, "file required"); return }
|
|
||||||
defer file.Close()
|
|
||||||
ext := filepath.Ext(header.Filename)
|
|
||||||
filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
|
|
||||||
destPath := filepath.Join(config.BLOG_ROOT, "static", "uploads", filename)
|
|
||||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
|
||||||
dst, err := os.Create(destPath)
|
|
||||||
if err != nil { jsonErr(w, 500, err.Error()); return }
|
|
||||||
defer dst.Close()
|
|
||||||
io.Copy(dst, file)
|
|
||||||
url := "/uploads/" + filename
|
|
||||||
jsonOK(w, map[string]interface{}{"url": url, "success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleHugo(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
|
|
||||||
if hugoStarting { jsonOK(w, map[string]interface{}{"running": false, "starting": true}); return }
|
|
||||||
hugoStarting = true
|
|
||||||
defer func() { hugoStarting = false }()
|
|
||||||
exec.Command("taskkill", "/F", "/IM", "hugo.exe").Run()
|
|
||||||
cmd := exec.Command("hugo", "serve", "--port", "1313", "--noHTTPCache")
|
|
||||||
cmd.Dir = config.BLOG_ROOT
|
|
||||||
if err := cmd.Start(); err != nil { jsonErr(w, 500, err.Error()); return }
|
|
||||||
jsonOK(w, map[string]interface{}{"running": true, "url": "http://localhost:1313"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleDeploy(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
title := gs(body, "title")
|
|
||||||
if title == "" { jsonErr(w, 400, "Title required"); return }
|
|
||||||
runGit := func(args ...string) (string, error) {
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
cmd.Dir = config.BLOG_ROOT
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
return string(out), err
|
|
||||||
}
|
|
||||||
var output string
|
|
||||||
if o, err := runGit("add", "content/post", "static"); err != nil { jsonErr(w, 500, string(o)); return } else { output += o }
|
|
||||||
if _, err := runGit("diff", "--cached", "--quiet"); err != nil {
|
|
||||||
if o, err2 := runGit("commit", "-m", "发布: "+title); err2 != nil { jsonErr(w, 500, string(o)); return } else { output += o }
|
|
||||||
}
|
|
||||||
o, _ := runGit("push", "origin", "main")
|
|
||||||
output += o
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true, "output": output})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
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) }
|
|
||||||
jsonOK(w, map[string]interface{}{"posts": postCount, "feeds": feedCount, "links": linkCount})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleRSSLinks(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
all := r.URL.Query().Get("all") == "1"
|
|
||||||
links, err := rssapi.GetLinks(all)
|
|
||||||
if err != nil { jsonErr(w, 500, err.Error()); return }
|
|
||||||
jsonOK(w, map[string]interface{}{"links": links})
|
|
||||||
case "POST":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
url := gs(body, "url"); title := gs(body, "title"); desc := gs(body, "description")
|
|
||||||
if url == "" || title == "" { jsonErr(w, 400, "url and title required"); return }
|
|
||||||
rssapi.AddLink(url, title, desc)
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true})
|
|
||||||
case "PUT":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
id := gs(body, "id")
|
|
||||||
enabled := gb(body, "enabled")
|
|
||||||
rssapi.ToggleLink(id, enabled)
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true})
|
|
||||||
case "DELETE":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
id := gs(body, "id")
|
|
||||||
rssapi.DeleteLink(id)
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true})
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleRSSFeeds(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
feeds, err := rssapi.GetFeeds()
|
|
||||||
if err != nil { jsonErr(w, 500, err.Error()); return }
|
|
||||||
jsonOK(w, map[string]interface{}{"feeds": feeds})
|
|
||||||
case "POST":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
url := gs(body, "url"); title := gs(body, "title")
|
|
||||||
if url == "" || title == "" { jsonErr(w, 400, "url and title required"); return }
|
|
||||||
rssapi.AddFeed(url, title)
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true})
|
|
||||||
case "DELETE":
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
id := gs(body, "id")
|
|
||||||
rssapi.DeleteFeed(id)
|
|
||||||
jsonOK(w, map[string]interface{}{"success": true})
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleWechatHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
articles, err := wechat.GetArticles()
|
|
||||||
if err != nil { jsonErr(w, 500, err.Error()); return }
|
|
||||||
jsonOK(w, map[string]interface{}{"articles": articles})
|
|
||||||
case "POST":
|
|
||||||
msg := r.URL.Query().Get("msg")
|
|
||||||
jsonOK(w, map[string]interface{}{"ok": true, "msg": msg})
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCommentsStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stats := artalk.GetStats()
|
|
||||||
jsonOK(w, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCommentsCleanup(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
slug := gs(body, "slug")
|
|
||||||
if slug == "" { jsonErr(w, 400, "slug required"); return }
|
|
||||||
result := artalk.CleanupComments(slug)
|
|
||||||
jsonOK(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCommentsLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
name := gs(body, "name"); email := gs(body, "email"); password := gs(body, "password")
|
|
||||||
if name == "" || email == "" || password == "" { jsonErr(w, 400, "name, email, password required"); return }
|
|
||||||
result := artalk.Login(name, email, password)
|
|
||||||
jsonOK(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCommentsSub(w http.ResponseWriter, r *http.Request) {
|
|
||||||
slug := extractSlug(r.URL.Path, "/api/comments/")
|
|
||||||
comments := artalk.GetComments(slug)
|
|
||||||
jsonOK(w, comments)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleComments(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
|
|
||||||
body, _ := readJSON(r)
|
|
||||||
slug := gs(body, "slug"); content := gs(body, "content"); pageKey := gs(body, "page_key")
|
|
||||||
if slug == "" { jsonErr(w, 400, "slug required"); return }
|
|
||||||
result := artalk.PostComment(slug, content, pageKey)
|
|
||||||
jsonOK(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractSlug(path, prefix string) string {
|
|
||||||
p := strings.TrimPrefix(path, prefix)
|
|
||||||
p = strings.TrimSuffix(p, "/")
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
|
|
||||||
import "time" # needed below
|
|
||||||
|
|
||||||
with open(r"E:\GitHub\blog\write-go\cmd\writeserver\handlers.go", "w", encoding="utf-8") as f:
|
|
||||||
f.write(handlers)
|
|
||||||
|
|
||||||
print("handlers.go written, size:", len(handlers))
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
path = r"E:\GitHub\blog\bot-go\internal\tgcore\tgcore.go"
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
old = '''import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)'''
|
|
||||||
new = '''import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)'''
|
|
||||||
content = content.replace(old, new)
|
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
print("Fixed imports properly")
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
path = r"E:\GitHub\blog\bot-go\internal\tgcore\tgcore.go"
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
# Add missing imports
|
|
||||||
old_import = '''import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)'''
|
|
||||||
new_import = '''import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)'''
|
|
||||||
|
|
||||||
content = content.replace(old_import, new_import)
|
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
print("Added net, time imports to tgcore")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Read helpers.go and append its functions to tgcore.go
|
|
||||||
helpers_path = r"E:\GitHub\blog\bot-go\internal\helpers\helpers.go"
|
|
||||||
tgcore_path = r"E:\GitHub\blog\bot-go\internal\tgcore\tgcore.go"
|
|
||||||
|
|
||||||
with open(helpers_path, "rb") as f:
|
|
||||||
helpers = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
with open(tgcore_path, "rb") as f:
|
|
||||||
tgcore = f.read().decode("utf-8")
|
|
||||||
|
|
||||||
# Strip package declaration and imports from helpers
|
|
||||||
helpers = helpers.replace("package helpers\n", "")
|
|
||||||
# Remove import block
|
|
||||||
import_end = helpers.find(")\n")
|
|
||||||
if import_end > 0:
|
|
||||||
helpers = helpers[import_end+2:]
|
|
||||||
|
|
||||||
# Add helper functions to end of tgcore
|
|
||||||
tgcore = tgcore.rstrip() + "\n\n// === Inlined from helpers ===\n" + helpers
|
|
||||||
|
|
||||||
with open(tgcore_path, "wb") as f:
|
|
||||||
f.write(tgcore.encode("utf-8"))
|
|
||||||
print("helpers inlined into tgcore")
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# 1. Remove writeconfig line
|
|
||||||
import re
|
|
||||||
content = re.sub(r'\t+writeconfig "write-server/internal/config"\r?\n', '', content)
|
|
||||||
|
|
||||||
# 2. Replace writeconfig.Init() with posts.Init()
|
|
||||||
content = content.replace('writeconfig.Init()', 'posts.Init()')
|
|
||||||
|
|
||||||
# 3. Add posts import after writeserver
|
|
||||||
content = content.replace(
|
|
||||||
'"writebot/internal/writeserver"',
|
|
||||||
'"writebot/internal/writeserver"\n\t"write-server/pkg/posts"'
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "w", encoding="utf-8", errors="replace") as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
print("Done")
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Fix the import block formatting
|
|
||||||
content = content.replace('"writebot/internal/writeserver"\n\t"write-server/pkg/posts"\n\n)', '"writebot/internal/writeserver"\n\t"write-server/pkg/posts"\n)')
|
|
||||||
|
|
||||||
# Fix the weird indentation on "os"
|
|
||||||
content = content.replace('\t\t"os"', '\t"os"')
|
|
||||||
|
|
||||||
with open(r"E:\GitHub\blog\bot-go\cmd\writebot\main.go", "w", encoding="utf-8", errors="replace") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("Fixed formatting")
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
with open(r"E:\GitHub\blog\write-go\internal\posts\posts.go", "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Simplify Init - just call config.Init, not recycle
|
|
||||||
old_init = '''func Init() {
|
|
||||||
config.Init()
|
|
||||||
recycle.InitRecycleBin()
|
|
||||||
}'''
|
|
||||||
new_init = '''func Init() {
|
|
||||||
config.Init()
|
|
||||||
}'''
|
|
||||||
content = content.replace(old_init, new_init)
|
|
||||||
|
|
||||||
# Remove recycle import
|
|
||||||
content = content.replace('\t"write-server/internal/recycle"\n', '')
|
|
||||||
|
|
||||||
with open(r"E:\GitHub\blog\write-go\internal\posts\posts.go", "w", encoding="utf-8", errors="replace") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("Done")
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
with open(r"E:\GitHub\blog\write-go\cmd\writeserver\main.go", "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Find the root handler and replace it with a reverse proxy to Next.js
|
|
||||||
# Remove the embedded HTML page approach, replace with reverse proxy
|
|
||||||
|
|
||||||
# 1. Remove the embed directive
|
|
||||||
content = content.replace('\n//go:embed admin_page.html\nvar adminPageHTML string\n', '\n')
|
|
||||||
content = content.replace('//go:embed admin_page.html\nvar adminPageHTML string\n', '\n')
|
|
||||||
|
|
||||||
# 2. Remove _ "embed" from imports
|
|
||||||
content = content.replace('\t_ "embed"\n\t"net"\n', '\t"net"\n')
|
|
||||||
content = content.replace('\t_ "embed"\n\t"log/slog"\n', '\t"log/slog"\n')
|
|
||||||
|
|
||||||
# 3. Add net/http/httputil and net/url for reverse proxy
|
|
||||||
content = content.replace(
|
|
||||||
'\t"net/http"\n',
|
|
||||||
'\t"net/http"\n\t"net/http/httputil"\n\t"net/url"\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Replace the root handler with reverse proxy
|
|
||||||
old_root = ''' // Root page - admin dashboard
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(404)
|
|
||||||
w.Write([]byte("{\"error\":\"not found\"}"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(adminPageHTML))
|
|
||||||
})'''
|
|
||||||
|
|
||||||
new_root = ''' // Root and non-API routes -> reverse proxy to Next.js (localhost:3000)
|
|
||||||
nextURL, _ := url.Parse("http://localhost:3000")
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(nextURL)
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
||||||
// Let API handlers deal with it
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
})'''
|
|
||||||
|
|
||||||
content = content.replace(old_root, new_root)
|
|
||||||
|
|
||||||
# 5. Add Next.js auto-start goroutine
|
|
||||||
old_goroutine = 'go autoStartBot()'
|
|
||||||
new_goroutine = '''go autoStartBot()
|
|
||||||
go autoStartNextJS()'''
|
|
||||||
|
|
||||||
content = content.replace(old_goroutine, new_goroutine)
|
|
||||||
|
|
||||||
# 6. Add the autoStartNextJS function before the last }
|
|
||||||
old_end = '\n\taddr := fmt.Sprintf("0.0.0.0:%d", port)'
|
|
||||||
new_end = '''
|
|
||||||
// autoStartNextJS starts Next.js if not running
|
|
||||||
func autoStartNextJS() {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
// Check if Next.js is already running
|
|
||||||
if _, err := net.DialTimeout("tcp", "127.0.0.1:3000", 2*time.Second); err == nil {
|
|
||||||
lg.Info("Next.js already running on :3000")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lg.Info("starting Next.js frontend")
|
|
||||||
cmd := exec.Command("npx.cmd", "next", "start", "-p", "3000")
|
|
||||||
cmd.Dir = filepath.Join(config.BLOG_ROOT, "write")
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
lg.Warn("failed to start Next.js", "err", err)
|
|
||||||
} else {
|
|
||||||
lg.Info("Next.js started", "pid", cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("0.0.0.0:%d", port)'
|
|
||||||
|
|
||||||
content = content.replace(old_end, new_end)
|
|
||||||
|
|
||||||
with open(r"E:\GitHub\blog\write-go\cmd\writeserver\main.go", "w", encoding="utf-8", errors="replace") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("Done")
|
|
||||||
Binary file not shown.
@@ -1,28 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
var lg *log.Logger
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
lg = log.New(os.Stdout, "[TEST] ", log.LstdFlags)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/api/posts/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
lg.Printf("GOT posts/ handler: path=%s method=%s", r.URL.Path, r.Method)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"handler":"posts/","path":"` + r.URL.Path + `"}`))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/api/posts", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
lg.Printf("GOT posts handler: path=%s method=%s", r.URL.Path, r.Method)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"handler":"posts","path":"` + r.URL.Path + `"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
lg.Println("listening on :8701")
|
|
||||||
log.Fatal(http.ListenAndServe(":8701", mux))
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/api/posts/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprintf(w, "GOT posts/ handler: path=%s\n", r.URL.Path)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/api/posts", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprintf(w, "GOT posts handler: path=%s\n", r.URL.Path)
|
|
||||||
})
|
|
||||||
log.Fatal(http.ListenAndServe(":8700", mux))
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
path = r"E:\GitHub\blog\bot-go\internal\tghandlers\tghandlers.go"
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
lines = f.read().decode("utf-8").split("\n")
|
|
||||||
|
|
||||||
# Show lines 115-120
|
|
||||||
for i in range(114, 122):
|
|
||||||
print(f"{i+1}: {repr(lines[i][:80])}")
|
|
||||||
|
|
||||||
# Show lines 575-580
|
|
||||||
for i in range(574, 582):
|
|
||||||
print(f"{i+1}: {repr(lines[i][:80])}")
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import urllib.request, urllib.error
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/api/posts', timeout=5)
|
|
||||||
print('STATUS:', r.status)
|
|
||||||
print(r.read().decode()[:500])
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
print('ERROR STATUS:', e.code)
|
|
||||||
print(e.read().decode()[:500])
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import urllib.request, json
|
|
||||||
|
|
||||||
# Test write-go: create a post endpoint
|
|
||||||
req = urllib.request.Request('http://localhost:8016/api/posts',
|
|
||||||
data=json.dumps({"frontMatter":{"title":"Test Article","slug":"test-article"},"content":"Hello world"}).encode(),
|
|
||||||
headers={"Content-Type":"application/json"},
|
|
||||||
method='POST')
|
|
||||||
try:
|
|
||||||
r = urllib.request.urlopen(req, timeout=5)
|
|
||||||
print('CREATE POST:', r.status, r.read().decode()[:200])
|
|
||||||
except Exception as e:
|
|
||||||
print('CREATE POST ERROR:', e)
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
req2 = urllib.request.Request('http://localhost:8016/api/posts/test-article', method='DELETE')
|
|
||||||
try:
|
|
||||||
r2 = urllib.request.urlopen(req2, timeout=5)
|
|
||||||
print('DELETE POST:', r2.status, r2.read().decode()[:200])
|
|
||||||
except Exception as e:
|
|
||||||
print('DELETE POST ERROR:', e)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import urllib.request, json
|
|
||||||
# Quick check via API
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/api/stats', timeout=5)
|
|
||||||
print('stats:', r.read().decode())
|
|
||||||
|
|
||||||
r = urllib.request.urlopen('http://localhost:8016/api/posts?perPage=3', timeout=5)
|
|
||||||
data = json.loads(r.read().decode())
|
|
||||||
print('posts count:', data['total'])
|
|
||||||
for p in data['posts']:
|
|
||||||
print(' -', p['slug'], p['title'][:30])
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
=== WRITE LENGTH: 24294 ===
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"net"
|
|
||||||
"log/slog"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"mime"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"write-server/internal/a
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
def process_css(filename, scope=''):
|
|
||||||
if not os.path.exists(filename):
|
|
||||||
print(f"File not found: {filename}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
with open(filename, 'r') as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
|
|
||||||
new_lines = []
|
|
||||||
for line in lines:
|
|
||||||
line = line.strip()
|
|
||||||
if not line: continue
|
|
||||||
|
|
||||||
# Split selector and style
|
|
||||||
parts = line.split('{')
|
|
||||||
if len(parts) < 2: continue
|
|
||||||
|
|
||||||
selector_part = parts[0].strip()
|
|
||||||
style_part = '{' + '{'.join(parts[1:])
|
|
||||||
|
|
||||||
# Add scope to each selector in the comma-separated list
|
|
||||||
selectors = [s.strip() for s in selector_part.split(',')]
|
|
||||||
scoped_selectors = []
|
|
||||||
|
|
||||||
for sel in selectors:
|
|
||||||
if scope:
|
|
||||||
# If scope is provided (e.g., [data-theme="dark"])
|
|
||||||
# We want: [data-theme="dark"] .chroma ...
|
|
||||||
if sel.startswith('.bg'):
|
|
||||||
scoped_selectors.append(f'{scope} {sel}')
|
|
||||||
elif sel.startswith('.chroma'):
|
|
||||||
scoped_selectors.append(f'{scope} {sel}')
|
|
||||||
else:
|
|
||||||
# Some styles might not start with .chroma or .bg?
|
|
||||||
# Usually they do in Hugo Chroma output.
|
|
||||||
scoped_selectors.append(f'{scope} {sel}')
|
|
||||||
else:
|
|
||||||
# No scope (Default/Day mode)
|
|
||||||
scoped_selectors.append(sel)
|
|
||||||
|
|
||||||
new_selector_part = ', '.join(scoped_selectors)
|
|
||||||
new_lines.append(new_selector_part + ' ' + style_part + '\n')
|
|
||||||
|
|
||||||
return ''.join(new_lines)
|
|
||||||
|
|
||||||
# Generate styles first
|
|
||||||
# os.system('hugo gen chromastyles --style=friendly > friendly.css')
|
|
||||||
# os.system('hugo gen chromastyles --style=nord > nord.css')
|
|
||||||
|
|
||||||
friendly = process_css('friendly.css', scope='') # Default
|
|
||||||
nord = process_css('nord.css', scope='[data-theme="dark"]') # Night
|
|
||||||
|
|
||||||
with open('chroma_final.css', 'w') as f:
|
|
||||||
f.write('/* Hugo Chroma Syntax Highlighting */\n')
|
|
||||||
f.write('/* Day Mode (Friendly - Vibrant/Bright) */\n')
|
|
||||||
f.write(friendly)
|
|
||||||
f.write('\n/* Night Mode (Nord - Deep/Muted) */\n')
|
|
||||||
f.write(nord)
|
|
||||||
|
|
||||||
print("chroma_final.css created.")
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
writeserver.exe
|
|
||||||
.env
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"deletedAt": "2026-06-01T17:01:39+08:00",
|
|
||||||
"expiresAt": "2026-07-01T17:01:39+08:00",
|
|
||||||
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-test-article",
|
|
||||||
"slug": "test-article",
|
|
||||||
"title": "Test Article"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
title: Test Article
|
|
||||||
date: 2026-06-01
|
|
||||||
slug: test-article
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
Hello world
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
Set WshShell = CreateObject("WScript.Shell")
|
|
||||||
Set lnk = WshShell.CreateShortcut("C:\Users\qunlin\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\WriteBot.lnk")
|
|
||||||
lnk.TargetPath = "E:\GitHub\blog\bot-go\start_bot.bat"
|
|
||||||
lnk.WorkingDirectory = "E:\GitHub\blog\bot-go"
|
|
||||||
lnk.WindowStyle = 7
|
|
||||||
lnk.Description = "WriteBot - Telegram Bot + Admin Panel"
|
|
||||||
lnk.Save
|
|
||||||
|
|
||||||
Set lnk2 = WshShell.CreateShortcut("C:\Users\qunlin\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\WriteServer.lnk")
|
|
||||||
lnk2.TargetPath = "E:\GitHub\blog\write-go\start_write.bat"
|
|
||||||
lnk2.WorkingDirectory = "E:\GitHub\blog\write-go"
|
|
||||||
lnk2.WindowStyle = 7
|
|
||||||
lnk2.Description = "Write Server - Blog CMS API"
|
|
||||||
lnk2.Save
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
panic: runtime error: invalid memory address or nil pointer dereference
|
|
||||||
[signal 0xc0000005 code=0x0 addr=0x0 pc=0x7ff752d05b18]
|
|
||||||
|
|
||||||
goroutine 1 [running]:
|
|
||||||
log/slog.(*Logger).Handler(...)
|
|
||||||
C:/Program Files/Go/src/log/slog/logger.go:121
|
|
||||||
log/slog.(*Logger).Enabled(0x7ff752c1ca98?, {0x7ff752fbae60?, 0x7ff7532f9640?}, 0x30?)
|
|
||||||
C:/Program Files/Go/src/log/slog/logger.go:168 +0x18
|
|
||||||
log/slog.(*Logger).log(0x0, {0x7ff752fbae60?, 0x7ff7532f9640?}, 0x0, {0x7ff752f96aad, 0x14}, {0x0, 0x0, 0x0})
|
|
||||||
C:/Program Files/Go/src/log/slog/logger.go:244 +0x98
|
|
||||||
log/slog.(*Logger).Info(...)
|
|
||||||
C:/Program Files/Go/src/log/slog/logger.go:209
|
|
||||||
main.main()
|
|
||||||
E:/GitHub/blog/write-go/cmd/writeserver/main.go:39 +0x7f
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
|
|
||||||
with open(p, "r", encoding="utf-8") as f:
|
|
||||||
c = f.read()
|
|
||||||
|
|
||||||
# Replace: lg = logutil.Logger
|
|
||||||
# With: lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeserver")
|
|
||||||
old = "lg = logutil.Logger"
|
|
||||||
new = 'lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeserver")'
|
|
||||||
c = c.replace(old, new)
|
|
||||||
|
|
||||||
# Also remove logutil import if present
|
|
||||||
c = c.replace('\t"write-server/internal/logutil"\n', '')
|
|
||||||
|
|
||||||
with open(p, "w", encoding="utf-8", newline="\n") as f:
|
|
||||||
f.write(c)
|
|
||||||
print("Fixed: inline slog init")
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
|
|
||||||
with open(p, "r", encoding="utf-8") as f:
|
|
||||||
c = f.read()
|
|
||||||
|
|
||||||
# Add "var lg *slog.Logger" back at top level (before main)
|
|
||||||
c = c.replace(
|
|
||||||
'func main() {',
|
|
||||||
'var lg *slog.Logger\n\nfunc main() {'
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(p, "w", encoding="utf-8", newline="\n") as f:
|
|
||||||
f.write(c)
|
|
||||||
print("Fixed: added var lg declaration")
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
|
|
||||||
with open(p, "r", encoding="utf-8") as f:
|
|
||||||
c = f.read()
|
|
||||||
|
|
||||||
c = c.replace("var log = logutil.Logger", "var lg = logutil.Logger")
|
|
||||||
c = c.replace("log.Info(", "lg.Info(")
|
|
||||||
c = c.replace("log.Error(", "lg.Error(")
|
|
||||||
|
|
||||||
with open(p, "w", encoding="utf-8", newline="\n") as f:
|
|
||||||
f.write(c)
|
|
||||||
print("Fixed log variable")
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
|
|
||||||
with open(p, "r", encoding="utf-8") as f:
|
|
||||||
c = f.read()
|
|
||||||
|
|
||||||
# Move lg.Info after config.Init() + recycle.InitRecycleBin()
|
|
||||||
old = '''func main() {
|
|
||||||
\tlg.Info("WriteServer starting")
|
|
||||||
\tconfig.Init()
|
|
||||||
\trecycle.InitRecycleBin()
|
|
||||||
|
|
||||||
\tgo autoStartBot()'''
|
|
||||||
|
|
||||||
new = '''func main() {
|
|
||||||
\tconfig.Init()
|
|
||||||
\trecycle.InitRecycleBin()
|
|
||||||
\tlg = logutil.Logger
|
|
||||||
\tlg.Info("WriteServer starting")
|
|
||||||
|
|
||||||
\tgo autoStartBot()'''
|
|
||||||
|
|
||||||
c = c.replace(old, new)
|
|
||||||
|
|
||||||
# Also remove the "var lg = logutil.Logger" at top level (now in main)
|
|
||||||
c = c.replace("var lg = logutil.Logger\n", "")
|
|
||||||
|
|
||||||
with open(p, "w", encoding="utf-8", newline="\n") as f:
|
|
||||||
f.write(c)
|
|
||||||
print("Fixed nil logger: lg.Info moved after config.Init()")
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
|
|
||||||
with open(p, "r", encoding="utf-8") as f:
|
|
||||||
c = f.read()
|
|
||||||
|
|
||||||
# Need "log/slog" import. It was removed when "log" was dropped.
|
|
||||||
c = c.replace(
|
|
||||||
'"encoding/json"',
|
|
||||||
'"log/slog"\n\t"encoding/json"'
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(p, "w", encoding="utf-8", newline="\n") as f:
|
|
||||||
f.write(c)
|
|
||||||
print("Added slog import")
|
|
||||||
Whitespace-only changes.
@@ -1,71 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
p = r"E:\GitHub\blog\write-go\cmd\writeserver\main.go"
|
|
||||||
with open(p, "r", encoding="utf-8") as f:
|
|
||||||
c = f.read()
|
|
||||||
|
|
||||||
# 1. Add logutil import
|
|
||||||
old = '"write-server/internal/images"'
|
|
||||||
new = '"write-server/internal/logutil"\n\t"write-server/internal/images"'
|
|
||||||
c = c.replace(old, new, 1)
|
|
||||||
|
|
||||||
# 2. Replace main start to add logging
|
|
||||||
old_func = 'func main() {\n\tconfig.Init()\n\trecycle.InitRecycleBin()'
|
|
||||||
new_func = '''var log = logutil.Logger
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
\tlog.Info("WriteServer starting")
|
|
||||||
\tconfig.Init()
|
|
||||||
\trecycle.InitRecycleBin()
|
|
||||||
|
|
||||||
\tgo autoStartBot()'''
|
|
||||||
c = c.replace(old_func, new_func, 1)
|
|
||||||
|
|
||||||
# 3. Append autoStartBot and helper at end of file
|
|
||||||
auto_start = '''
|
|
||||||
|
|
||||||
func autoStartBot() {
|
|
||||||
\ttime.Sleep(2 * time.Second)
|
|
||||||
\tbotPort := 8017
|
|
||||||
\tif helpersCheckPort(botPort) {
|
|
||||||
\t\tlog.Info("bot-go already running", "port", botPort)
|
|
||||||
\t\treturn
|
|
||||||
\t}
|
|
||||||
\tlog.Info("bot-go not responding, starting")
|
|
||||||
\tpaths := []string{
|
|
||||||
\t\tfilepath.Join(config.BLOG_ROOT, "bot-go", "writebot.exe"),
|
|
||||||
\t\tfilepath.Join(config.BLOG_ROOT, "write-go", "writebot.exe"),
|
|
||||||
\t}
|
|
||||||
\tfor _, p := range paths {
|
|
||||||
\t\tif _, err := os.Stat(p); err == nil {
|
|
||||||
\t\t\tcmd := exec.Command(p)
|
|
||||||
\t\t\tcmd.Dir = filepath.Dir(p)
|
|
||||||
\t\t\tif err := cmd.Start(); err != nil {
|
|
||||||
\t\t\t\tlog.Error("failed to start bot-go", "err", err)
|
|
||||||
\t\t\t} else {
|
|
||||||
\t\t\t\tlog.Info("bot-go started", "pid", cmd.Process.Pid)
|
|
||||||
\t\t\t}
|
|
||||||
\t\t\treturn
|
|
||||||
\t\t}
|
|
||||||
\t}
|
|
||||||
}
|
|
||||||
|
|
||||||
func helpersCheckPort(port int) bool {
|
|
||||||
\tconn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
|
|
||||||
\tif err != nil {
|
|
||||||
\t\treturn false
|
|
||||||
\t}
|
|
||||||
\tconn.Close()
|
|
||||||
\treturn true
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
c += auto_start
|
|
||||||
|
|
||||||
# 4. Ensure "net" is imported
|
|
||||||
if '"net"' not in c[:c.find("func main")]:
|
|
||||||
c = c.replace('import (\n\t"encoding/json"', 'import (\n\t"net"\n\t"encoding/json"', 1)
|
|
||||||
|
|
||||||
with open(p, "w", encoding="utf-8", newline="\n") as f:
|
|
||||||
f.write(c)
|
|
||||||
print("Patched write-server main.go")
|
|
||||||
print("New file size:", len(c.splitlines()), "lines")
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<!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>WriteServer - Blog CMS</title>
|
|
||||||
<style>
|
|
||||||
:root{color-scheme:dark;--bg:#0f172a;--card:#1e293b;--text:#e2e8f0;--muted:#94a3b8;--accent:#38bdf8;--accent-hover:#7dd3fc;--danger:#fca5a5;--success:#86efac;--line:#334155}
|
|
||||||
*{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;min-height:100vh;padding:24px}
|
|
||||||
.container{max-width:900px;margin:0 auto}
|
|
||||||
h1{font-size:22px;font-weight:700;margin-bottom:4px}
|
|
||||||
.sub{font-size:14px;color:var(--muted);margin-bottom:24px}
|
|
||||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;margin-bottom:24px}
|
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:20px}
|
|
||||||
.card-title{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px}
|
|
||||||
.card-value{font-size:28px;font-weight:700}
|
|
||||||
.card-value.blue{color:var(--accent)}
|
|
||||||
.links{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:24px}
|
|
||||||
a.btn{display:inline-block;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;transition:all 0.15s}
|
|
||||||
a.btn-primary{background:var(--accent);color:#0f172a}
|
|
||||||
a.btn-primary:hover{background:var(--accent-hover)}
|
|
||||||
a.btn-secondary{background:transparent;border:1px solid var(--line);color:var(--text)}
|
|
||||||
a.btn-secondary:hover{background:rgba(255,255,255,0.05)}
|
|
||||||
.status{display:flex;align-items:center;gap:8px;font-size:14px;margin-bottom:16px}
|
|
||||||
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
|
||||||
.dot.green{background:var(--success);box-shadow:0 0 6px var(--success)}
|
|
||||||
.quick{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
|
|
||||||
.ql{padding:5px 12px;border-radius:6px;background:rgba(255,255,255,0.04);border:1px solid var(--line);color:var(--accent);text-decoration:none;font-size:12px;font-weight:500}
|
|
||||||
.ql:hover{background:rgba(255,255,255,0.08)}
|
|
||||||
.footer{margin-top:32px;text-align:center;font-size:12px;color:var(--muted)}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>WriteServer</h1>
|
|
||||||
<p class="sub">Blog CMS - Go Edition</p>
|
|
||||||
<div class="status"><div class="dot green"></div><span>Server running</span></div>
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card"><div class="card-title">Posts</div><div class="card-value blue" id="posts">-</div></div>
|
|
||||||
<div class="card"><div class="card-title">RSS Feeds</div><div class="card-value" id="feeds">-</div></div>
|
|
||||||
<div class="card"><div class="card-title">Links</div><div class="card-value" id="links">-</div></div>
|
|
||||||
<div class="card"><div class="card-title">Recycle Bin</div><div class="card-value" id="recycle">-</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="links">
|
|
||||||
<a class="btn btn-primary" href="http://localhost:8017/">Open Admin Panel</a>
|
|
||||||
<a class="btn btn-secondary" href="/api/posts">API: Posts</a>
|
|
||||||
<a class="btn btn-secondary" href="/api/stats">API: Stats</a>
|
|
||||||
</div>
|
|
||||||
<div class="quick">
|
|
||||||
<a class="ql" href="/api/recycle">Recycle Bin</a>
|
|
||||||
<a class="ql" href="/api/rss/feeds">RSS Feeds</a>
|
|
||||||
<a class="ql" href="/api/rss/links">Links</a>
|
|
||||||
</div>
|
|
||||||
<div class="footer">WriteServer - Go</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
fetch('/api/stats').then(r=>r.json()).then(d=>{
|
|
||||||
document.getElementById('posts').textContent=d.posts||0;
|
|
||||||
document.getElementById('feeds').textContent=d.feeds||0;
|
|
||||||
document.getElementById('links').textContent=d.links||0;
|
|
||||||
});
|
|
||||||
fetch('/api/recycle').then(r=>r.json()).then(d=>{
|
|
||||||
document.getElementById('recycle').textContent=Array.isArray(d.items)?d.items.length:0;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "write-server/internal/app"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
app.New().Run()
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"mime"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"write-server/internal/ai"
|
|
||||||
"write-server/internal/adminpanel"
|
|
||||||
"write-server/internal/artalk"
|
|
||||||
"write-server/internal/botconfig"
|
|
||||||
"write-server/internal/config"
|
|
||||||
"write-server/internal/helpers"
|
|
||||||
"write-server/internal/images"
|
|
||||||
"write-server/internal/posts"
|
|
||||||
"write-server/internal/recycle"
|
|
||||||
"write-server/internal/rssapi"
|
|
||||||
"write-server/internal/sessions"
|
|
||||||
"write-server/internal/tgcore"
|
|
||||||
"write-server/internal/tghandlers"
|
|
||||||
"write-server/internal/wechat"
|
|
||||||
"write-server/internal/writeserver"
|
|
||||||
)
|
|
||||||
|
|
||||||
var hugoStarting bool
|
|
||||||
var lg *slog.Logger
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
config.Init()
|
|
||||||
recycle.InitRecycleBin()
|
|
||||||
posts.Init()
|
|
||||||
appCfg := botconfig.Load()
|
|
||||||
writeserver.Init(appCfg.WritePort, appCfg.WriteDir)
|
|
||||||
adminpanel.Init(appCfg.WritePort, appCfg.ControlPort)
|
|
||||||
|
|
||||||
lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeapp")
|
|
||||||
|
|
||||||
ip := helpers.GetLanIP(appCfg.PreferIface)
|
|
||||||
lg.Info("WriteApp starting", "writePort", appCfg.WritePort)
|
|
||||||
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Println(" WriteApp - Blog CMS + Telegram Bot")
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Printf(" Frontend : http://127.0.0.1:%d/\n", appCfg.WritePort)
|
|
||||||
fmt.Printf(" Admin : http://127.0.0.1:%d/admin\n", appCfg.WritePort)
|
|
||||||
fmt.Printf(" LAN : http://%s:%d/\n", ip, appCfg.WritePort)
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Println("")
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
mux.HandleFunc("/api/posts/batch", handlePostsBatch)
|
|
||||||
mux.HandleFunc("/api/posts/", handlePostBySlug)
|
|
||||||
mux.HandleFunc("/api/posts", handlePosts)
|
|
||||||
mux.HandleFunc("/api/recycle", handleRecycle)
|
|
||||||
mux.HandleFunc("/api/ai", handleAI)
|
|
||||||
mux.HandleFunc("/api/images", handleImages)
|
|
||||||
mux.HandleFunc("/api/upload", handleUpload)
|
|
||||||
mux.HandleFunc("/api/hugo", handleHugo)
|
|
||||||
mux.HandleFunc("/api/deploy", handleDeploy)
|
|
||||||
mux.HandleFunc("/api/stats", handleStats)
|
|
||||||
mux.HandleFunc("/api/rss/links", handleRSSLinks)
|
|
||||||
mux.HandleFunc("/api/rss/feeds", handleRSSFeeds)
|
|
||||||
mux.HandleFunc("/api/rss/wechat", handleWechatHandler)
|
|
||||||
mux.HandleFunc("/api/comments/stats", handleCommentsStats)
|
|
||||||
mux.HandleFunc("/api/comments/cleanup", handleCommentsCleanup)
|
|
||||||
mux.HandleFunc("/api/comments/login", handleCommentsLogin)
|
|
||||||
mux.HandleFunc("/api/comments/", handleCommentsSub)
|
|
||||||
mux.HandleFunc("/api/comments", handleComments)
|
|
||||||
|
|
||||||
// Admin panel on /admin
|
|
||||||
mux.HandleFunc("/admin", handleAdmin)
|
|
||||||
mux.HandleFunc("/admin/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`+"`"+`{"ok":true}`+"`"+`))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reverse proxy to Next.js for non-API routes
|
|
||||||
nextURL, _ := url.Parse("http://localhost:3000")
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(nextURL)
|
|
||||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(fallbackHTML))
|
|
||||||
}
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin") {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auto-start Next.js
|
|
||||||
go startNextJS()
|
|
||||||
|
|
||||||
// Start Telegram bot
|
|
||||||
if appCfg.TGToken != "" {
|
|
||||||
tgcore.Init(appCfg.TGToken)
|
|
||||||
go runTelegramBot(appCfg)
|
|
||||||
} else {
|
|
||||||
lg.Warn("no TG_TOKEN configured, bot disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
port := 8016
|
|
||||||
if p := os.Getenv("WRITE_PORT"); p != "" {
|
|
||||||
if n, err := strconv.Atoi(p); err == nil {
|
|
||||||
port = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
|
||||||
fmt.Printf("[write-app] listening on %s\n", addr)
|
|
||||||
go func() {
|
|
||||||
if err := http.ListenAndServe(addr, withCORS(mux)); err != nil {
|
|
||||||
lg.Error("server error", "err", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
sig := <-sigCh
|
|
||||||
lg.Info("shutting down", "signal", sig.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackHTML = `+"`"+`<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>WriteApp</title>
|
|
||||||
<style>body{background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}h1{font-size:24px}p{color:#94a3b8;margin-top:8px}a{color:#38bdf8}</style>
|
|
||||||
</head><body><div><h1>WriteApp</h1><p>Next.js frontend is starting...</p><p><a href="/admin">Open Admin</a></p></div></body></html>`+"`"+`
|
|
||||||
|
|
||||||
func startNextJS() {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
if _, err := net.DialTimeout("tcp", "127.0.0.1:3000", 2*time.Second); err == nil {
|
|
||||||
lg.Info("Next.js already running on :3000")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lg.Info("starting Next.js frontend")
|
|
||||||
cmd := exec.Command("npx.cmd", "next", "start", "-p", "3000")
|
|
||||||
cmd.Dir = filepath.Join(config.BLOG_ROOT, "write")
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
lg.Warn("failed to start Next.js", "err", err)
|
|
||||||
} else {
|
|
||||||
lg.Info("Next.js started", "pid", cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTelegramBot(appCfg *botconfig.Config) {
|
|
||||||
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: appCfg.WritePort,
|
|
||||||
ControlPort: appCfg.ControlPort,
|
|
||||||
PreferIface: appCfg.PreferIface,
|
|
||||||
})
|
|
||||||
|
|
||||||
argCommands := map[string]bool{
|
|
||||||
"new": true, "title": true, "categories": true, "tags": true, "author": true,
|
|
||||||
"edit": true, "view": true, "delete": true,
|
|
||||||
"link_add": true, "link_del": true, "link_toggle": true,
|
|
||||||
"feed_add": true, "feed_del": true,
|
|
||||||
"list": true, "links": true, "feeds": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastOffset int64
|
|
||||||
for {
|
|
||||||
data, err := tgcore.TG("getUpdates", map[string]interface{}{
|
|
||||||
"offset": lastOffset + 1, "timeout": 30,
|
|
||||||
"allowed_updates": []string{"message", "callback_query"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
lg.Warn("tg poll error", "err", err)
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ok, _ := data["ok"].(bool)
|
|
||||||
if !ok { continue }
|
|
||||||
results, _ := data["result"].([]interface{})
|
|
||||||
for _, updateRaw := range results {
|
|
||||||
update, _ := updateRaw.(map[string]interface{})
|
|
||||||
if updateID, ok := update["update_id"].(float64); ok {
|
|
||||||
lastOffset = int64(updateID)
|
|
||||||
}
|
|
||||||
if cbRaw, ok := update["callback_query"]; ok {
|
|
||||||
cb, _ := cbRaw.(map[string]interface{})
|
|
||||||
cbMsg, _ := cb["message"].(map[string]interface{})
|
|
||||||
cbChat, _ := cbMsg["chat"].(map[string]interface{})
|
|
||||||
cbFrom, _ := cb["from"].(map[string]interface{})
|
|
||||||
var cbChatID int64
|
|
||||||
if cbChat != nil { cbChatID = tgcore.GetInt64(cbChat, "id") }
|
|
||||||
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, appCfg.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", appCfg.TGToken, filePath)
|
|
||||||
s.Content += "\n\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text := tgcore.GetString(msg, "text")
|
|
||||||
if text == "" { continue }
|
|
||||||
fullText := strings.TrimSpace(text)
|
|
||||||
if idx := strings.Index(fullText, "@"); idx >= 0 {
|
|
||||||
afterAt := fullText[idx:]
|
|
||||||
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 { afterAt = afterAt[spaceIdx:] } else { afterAt = "" }
|
|
||||||
fullText = fullText[:idx] + afterAt
|
|
||||||
fullText = strings.TrimSpace(fullText)
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(fullText, " ", 2)
|
|
||||||
slashCmd := ""; slashArg := ""
|
|
||||||
if strings.HasPrefix(parts[0], "/") {
|
|
||||||
slashCmd = strings.ToLower(parts[0][1:])
|
|
||||||
if len(parts) > 1 { slashArg = parts[1] }
|
|
||||||
}
|
|
||||||
if sessions.HasSession(chatID) && !strings.HasPrefix(fullText, "/") {
|
|
||||||
s := sessions.GetSession(chatID)
|
|
||||||
s.Content += fullText + "\n"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if slashCmd == "" { continue }
|
|
||||||
lg.Info("tg command", "chat", chatID, "cmd", slashCmd)
|
|
||||||
handleTGCommand(h, chatID, slashCmd, slashArg, argCommands)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTGCommand(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil { lg.Error("handler panic", "cmd", cmd, "panic", r) }
|
|
||||||
}()
|
|
||||||
if argCmds[cmd] {
|
|
||||||
switch cmd {
|
|
||||||
case "new": h.New(chatID, arg)
|
|
||||||
case "title": h.Title(chatID, arg)
|
|
||||||
case "categories": h.Categories(chatID, arg)
|
|
||||||
case "tags": h.Tags(chatID, arg)
|
|
||||||
case "author": h.Author(chatID, arg)
|
|
||||||
case "edit": h.Edit(chatID, arg)
|
|
||||||
case "view": h.View(chatID, arg)
|
|
||||||
case "delete": h.Delete(chatID, arg)
|
|
||||||
case "link_add": h.LinkAdd(chatID, arg)
|
|
||||||
case "link_del": h.LinkDel(chatID, arg)
|
|
||||||
case "link_toggle": h.LinkToggle(chatID, arg)
|
|
||||||
case "feed_add": h.FeedAdd(chatID, arg)
|
|
||||||
case "feed_del": h.FeedDel(chatID, arg)
|
|
||||||
case "list": h.List(chatID, arg)
|
|
||||||
case "links": h.Links(chatID, arg)
|
|
||||||
case "feeds": h.Feeds(chatID, arg)
|
|
||||||
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch cmd {
|
|
||||||
case "start": h.Start(chatID)
|
|
||||||
case "stop": h.Stop(chatID)
|
|
||||||
case "status": h.Status(chatID)
|
|
||||||
case "ip": h.IP(chatID)
|
|
||||||
case "help": h.Help(chatID)
|
|
||||||
case "save": h.Save(chatID)
|
|
||||||
case "publish": h.Publish(chatID)
|
|
||||||
case "draft": h.Draft(chatID)
|
|
||||||
case "preview": h.Preview(chatID)
|
|
||||||
case "cancel": h.Cancel(chatID)
|
|
||||||
case "stats": h.Stats(chatID)
|
|
||||||
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(fmt.Sprintf(adminHTML, helpers.GetLanIP("WLAN"))))
|
|
||||||
}
|
|
||||||
|
|
||||||
func withCORS(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
defer func() {
|
|
||||||
if rec := recover(); rec != nil {
|
|
||||||
lg.Error("panic in handler", "path", r.URL.Path, "method", r.Method, "panic", fmt.Sprintf("%v", rec))
|
|
||||||
http.Error(w, fmt.Sprintf("Internal Server Error: %v", rec), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
|
||||||
if r.Method == "OPTIONS" {
|
|
||||||
w.WriteHeader(204)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module write-server
|
|
||||||
|
|
||||||
go 1.26.3
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package adminpanel
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"write-server/internal/helpers"
|
|
||||||
"write-server/internal/writeserver"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
writePort int
|
|
||||||
controlPort int
|
|
||||||
)
|
|
||||||
|
|
||||||
func Init(wp, cp int) {
|
|
||||||
writePort = wp
|
|
||||||
controlPort = cp
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
|
||||||
<title>WriteBot</title>
|
|
||||||
<style>
|
|
||||||
:root{color-scheme:dark;--bg:#111827;--card:#1f2937;--text:#f3f4f6;--muted:#9ca3af;--accent:#60a5fa;--accent-hover:#93bbfd;--danger:#fca5a5;--success:#86efac;--line:#374151}
|
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
|
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px;max-width:420px;width:100%}
|
|
||||||
h1{font-size:18px;font-weight:700;margin-bottom:6px}
|
|
||||||
.sub{font-size:13px;color:var(--muted);margin-bottom:20px}
|
|
||||||
.status-row{display:flex;align-items:center;gap:10px;padding:12px 16px;background:rgba(255,255,255,0.03);border-radius:8px;margin-bottom:16px}
|
|
||||||
.dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
|
||||||
.dot.running{background:var(--success)}
|
|
||||||
.dot.starting{background:#fbbf24}
|
|
||||||
.dot.stopped{background:var(--muted)}
|
|
||||||
.status-text{font-size:14px;font-weight:500}
|
|
||||||
.status-ip{font-size:12px;color:var(--muted);margin-top:2px}
|
|
||||||
.actions{display:flex;gap:8px;flex-wrap:wrap}
|
|
||||||
.btn{padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all 0.15s;color:#fff}
|
|
||||||
.btn-primary{background:var(--accent)}
|
|
||||||
.btn-primary:hover{background:var(--accent-hover)}
|
|
||||||
.btn-primary:disabled{opacity:0.4;cursor:not-allowed}
|
|
||||||
.btn-secondary{background:transparent;border:1px solid var(--line);color:var(--text)}
|
|
||||||
.btn-secondary:hover{background:rgba(255,255,255,0.05)}
|
|
||||||
.btn-danger{background:transparent;border:1px solid var(--danger);color:var(--danger)}
|
|
||||||
.btn-danger:hover{background:rgba(252,165,165,0.1)}
|
|
||||||
.link-row{margin-top:16px;padding:10px 14px;background:rgba(255,255,255,0.03);border-radius:8px;font-size:13px;color:var(--muted);word-break:break-all}
|
|
||||||
.link-row a{color:var(--accent);text-decoration:none}
|
|
||||||
.quick-links{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
|
|
||||||
.ql-btn{padding:6px 14px;border-radius:6px;background:rgba(255,255,255,0.05);border:1px solid var(--line);color:var(--accent);text-decoration:none;font-size:13px;font-weight:500;transition:all 0.15s}
|
|
||||||
.ql-btn:hover{background:rgba(255,255,255,0.1)}
|
|
||||||
.footer{margin-top:20px;font-size:11px;color:var(--muted);text-align:center}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<h1>WriteBot</h1>
|
|
||||||
<div class="sub">Ctrl+D bookmark this page</div>
|
|
||||||
<div class="quick-links">
|
|
||||||
<a href="http://localhost:1313/posts/" class="ql-btn" target="_blank">Posts</a>
|
|
||||||
<a href="http://localhost:WRITE_PORT/" class="ql-btn" target="_blank">Write</a>
|
|
||||||
</div>
|
|
||||||
<div class="sub" id="sub">Loading...</div>
|
|
||||||
<div class="status-row">
|
|
||||||
<div class="dot" id="dot"></div>
|
|
||||||
<div>
|
|
||||||
<div class="status-text" id="statusText">-</div>
|
|
||||||
<div class="status-ip" id="statusIp"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-primary" id="btnStart" onclick="action('start')">Start Write</button>
|
|
||||||
<button class="btn btn-secondary" id="btnOpen" onclick="openWrite()">Open</button>
|
|
||||||
<button class="btn btn-danger" id="btnStop" onclick="action('stop')">Stop</button>
|
|
||||||
</div>
|
|
||||||
<div class="link-row" id="linkRow" style="display:none">
|
|
||||||
Copy: <a href="#" id="lanLink" onclick="copyLink(event)"></a>
|
|
||||||
</div>
|
|
||||||
<div class="footer"><a href="https://usj.cc" style="color:var(--muted)">usj.cc</a></div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
var port=WRITE_PORT;
|
|
||||||
var ipCache="";
|
|
||||||
function updateUI(s){var d=document.getElementById("dot"),st=document.getElementById("statusText"),si=document.getElementById("statusIp"),bs=document.getElementById("btnStart"),bo=document.getElementById("btnOpen"),lr=document.getElementById("linkRow"),ll=document.getElementById("lanLink");d.className="dot";if(s.running){d.classList.add("running");st.textContent="Running";if(s.ip)ipCache=s.ip;si.textContent="http://"+ipCache+":"+s.port;bs.disabled=true;bo.disabled=false;lr.style.display="block";ll.href="http://"+ipCache+":"+s.port;ll.textContent="http://"+ipCache+":"+s.port;document.getElementById("sub").textContent="Write is serving at"}else if(s.starting){d.classList.add("starting");st.textContent="Starting...";si.textContent="";bs.disabled=true;bo.disabled=true;lr.style.display="none";document.getElementById("sub").textContent="Please wait..."}else{d.classList.add("stopped");st.textContent="Stopped";si.textContent="";bs.disabled=false;bo.disabled=true;lr.style.display="none";document.getElementById("sub").textContent="Click Start to launch Write"}}
|
|
||||||
async function fetchStatus(){try{var r=await fetch("/status");var s=await r.json();updateUI(s)}catch(e){document.getElementById("sub").textContent="Cannot reach API"}}
|
|
||||||
async function action(a){document.getElementById("sub").textContent="Working...";await fetch("/"+a,{method:"POST"});fetchStatus()}
|
|
||||||
function openWrite(){window.open("http://"+(ipCache||"localhost:WRITE_PORT"))}
|
|
||||||
function copyLink(e){e.preventDefault();navigator.clipboard.writeText(document.getElementById("lanLink").textContent).catch(function(){})}
|
|
||||||
fetchStatus();
|
|
||||||
setInterval(fetchStatus,5000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
func CreateControlServer() *http.Server {
|
|
||||||
html := strings.ReplaceAll(adminHTML, "WRITE_PORT", fmt.Sprintf("%d", writePort))
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := r.URL.Path
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
|
|
||||||
switch path {
|
|
||||||
case "/":
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(html))
|
|
||||||
case "/status":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
s := writeserver.GetWriteStatus()
|
|
||||||
s["ip"] = helpers.GetLanIP("WLAN")
|
|
||||||
s["port"] = writePort
|
|
||||||
json.NewEncoder(w).Encode(s)
|
|
||||||
case "/start":
|
|
||||||
if r.Method == "POST" {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(writeserver.StartWriteServer())
|
|
||||||
} else {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
case "/stop":
|
|
||||||
if r.Method == "POST" {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]bool{"ok": writeserver.StopWriteServer()})
|
|
||||||
} else {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
case "/ip":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"ip": helpers.GetLanIP("WLAN"),
|
|
||||||
"port": writePort,
|
|
||||||
})
|
|
||||||
case "/health":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("127.0.0.1:%d", controlPort)
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: mux,
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[control] http://%s\n", addr)
|
|
||||||
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"write-server/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var AI_PROMPTS = map[string]string{
|
|
||||||
"polish": "\u4f60\u662f\u4e00\u4e2a\u7f16\u8f91\u3002\u6da6\u8272\u4ee5\u4e0b\u6587\u672c\uff0c\u4f7f\u5176\u66f4\u6d41\u7545\u3001\u66f4\u4e13\u4e1a\u3002\u4fdd\u6301\u539f\u610f\u548c\u8bed\u6c14\uff0c\u53ea\u8fd4\u56de\u6da6\u8272\u540e\u7684\u6587\u672c\uff0c\u4e0d\u8981\u89e3\u91ca\u3002",
|
|
||||||
"expand": "\u4f60\u662f\u4e00\u4e2a\u7f16\u8f91\u3002\u6269\u5c55\u4ee5\u4e0b\u6587\u672c\uff0c\u6dfb\u52a0\u66f4\u591a\u7ec6\u8282\u548c\u89e3\u91ca\uff0c\u540c\u65f6\u4fdd\u6301\u6e05\u6670\u548c\u5f15\u4eba\u5165\u80dc\u3002\u53ea\u8fd4\u56de\u6269\u5c55\u540e\u7684\u6587\u672c\uff0c\u4e0d\u8981\u89e3\u91ca\u3002",
|
|
||||||
"shorten": "\u4f60\u662f\u4e00\u4e2a\u7f16\u8f91\u3002\u7f29\u77ed\u4ee5\u4e0b\u6587\u672c\uff0c\u4fdd\u6301\u6838\u5fc3\u610f\u601d\uff0c\u4f7f\u8868\u8fbe\u66f4\u7b80\u6d01\u3002\u53ea\u8fd4\u56de\u7f29\u77ed\u540e\u7684\u6587\u672c\uff0c\u4e0d\u8981\u89e3\u91ca\u3002",
|
|
||||||
"fix": "\u4f60\u662f\u4e00\u4e2a\u7f16\u8f91\u3002\u4fee\u590d\u4ee5\u4e0b\u6587\u672c\u4e2d\u7684\u8bed\u6cd5\u9519\u8bef\u3001\u62fc\u5199\u9519\u8bef\u548c\u4e0d\u81ea\u7136\u8868\u8fbe\uff0c\u4fdd\u6301\u539f\u610f\u3002\u53ea\u8fd4\u56de\u4fee\u6b63\u540e\u7684\u6587\u672c\uff0c\u4e0d\u8981\u89e3\u91ca\u3002",
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatRequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Messages []ChatMessage `json:"messages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatResponse struct {
|
|
||||||
Choices []struct {
|
|
||||||
Message ChatMessage `json:"message"`
|
|
||||||
} `json:"choices"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func AIChat(systemPrompt, userMessage string) (string, error) {
|
|
||||||
if config.DEEPSEEK_API_KEY == "" {
|
|
||||||
return "", fmt.Errorf("DEEPSEEK_API_KEY not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
req := ChatRequest{
|
|
||||||
Model: config.DEEPSEEK_MODEL,
|
|
||||||
Messages: []ChatMessage{
|
|
||||||
{Role: "system", Content: systemPrompt},
|
|
||||||
{Role: "user", Content: userMessage},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
data, _ := json.Marshal(req)
|
|
||||||
httpReq, _ := http.NewRequest("POST", config.DEEPSEEK_BASE_URL+"/chat/completions", bytes.NewReader(data))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+config.DEEPSEEK_API_KEY)
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("AI request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("AI error: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatResp ChatResponse
|
|
||||||
if err := json.Unmarshal(body, &chatResp); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(chatResp.Choices) == 0 {
|
|
||||||
return "", fmt.Errorf("no response from AI")
|
|
||||||
}
|
|
||||||
return chatResp.Choices[0].Message.Content, nil
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"write-server/internal/adminpanel"
|
|
||||||
"write-server/internal/botconfig"
|
|
||||||
"write-server/internal/config"
|
|
||||||
"write-server/internal/helpers"
|
|
||||||
"write-server/internal/sessions"
|
|
||||||
"write-server/internal/tgcore"
|
|
||||||
"write-server/internal/tghandlers"
|
|
||||||
"write-server/internal/writeserver"
|
|
||||||
|
|
||||||
"write-server/pkg/posts"
|
|
||||||
)
|
|
||||||
|
|
||||||
var lg *slog.Logger
|
|
||||||
|
|
||||||
type App struct {
|
|
||||||
cfg *botconfig.Config
|
|
||||||
controlSrv *http.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func New() *App {
|
|
||||||
return &App{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) Run() {
|
|
||||||
// Init
|
|
||||||
config.Init()
|
|
||||||
posts.Init()
|
|
||||||
a.cfg = botconfig.Load()
|
|
||||||
writeserver.Init(a.cfg.WritePort, a.cfg.WriteDir)
|
|
||||||
adminpanel.Init(a.cfg.WritePort, a.cfg.ControlPort)
|
|
||||||
tgcore.Init(a.cfg.TGToken)
|
|
||||||
|
|
||||||
lg = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})).With("svc", "writeapp")
|
|
||||||
lg.Info("WriteApp starting",
|
|
||||||
"writePort", a.cfg.WritePort,
|
|
||||||
"controlPort", a.cfg.ControlPort,
|
|
||||||
)
|
|
||||||
|
|
||||||
ip := helpers.GetLanIP(a.cfg.PreferIface)
|
|
||||||
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Println(" WriteApp - Blog CMS + Telegram Bot")
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Printf(" Frontend : http://127.0.0.1:%d/\n", a.cfg.WritePort)
|
|
||||||
fmt.Printf(" Admin : http://127.0.0.1:%d/admin\n", a.cfg.WritePort)
|
|
||||||
fmt.Printf(" LAN : http://%s:%d/\n", ip, a.cfg.WritePort)
|
|
||||||
fmt.Println(" =========================================")
|
|
||||||
fmt.Println("")
|
|
||||||
|
|
||||||
// Root mux on writePort
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
mux.HandleFunc("/api/posts/batch", handlePostsBatch)
|
|
||||||
mux.HandleFunc("/api/posts/", handlePostBySlug)
|
|
||||||
mux.HandleFunc("/api/posts", handlePosts)
|
|
||||||
mux.HandleFunc("/api/recycle", handleRecycle)
|
|
||||||
mux.HandleFunc("/api/ai", handleAI)
|
|
||||||
mux.HandleFunc("/api/images", handleImages)
|
|
||||||
mux.HandleFunc("/api/upload", handleUpload)
|
|
||||||
mux.HandleFunc("/api/hugo", handleHugo)
|
|
||||||
mux.HandleFunc("/api/deploy", handleDeploy)
|
|
||||||
mux.HandleFunc("/api/stats", handleStats)
|
|
||||||
mux.HandleFunc("/api/rss/links", handleRSSLinks)
|
|
||||||
mux.HandleFunc("/api/rss/feeds", handleRSSFeeds)
|
|
||||||
mux.HandleFunc("/api/rss/wechat", handleWechatHandler)
|
|
||||||
mux.HandleFunc("/api/comments/stats", handleCommentsStats)
|
|
||||||
mux.HandleFunc("/api/comments/cleanup", handleCommentsCleanup)
|
|
||||||
mux.HandleFunc("/api/comments/login", handleCommentsLogin)
|
|
||||||
mux.HandleFunc("/api/comments/", handleCommentsSub)
|
|
||||||
mux.HandleFunc("/api/comments", handleComments)
|
|
||||||
|
|
||||||
// Admin panel route
|
|
||||||
mux.HandleFunc("/admin", a.handleAdmin)
|
|
||||||
mux.HandleFunc("/admin/", a.handleAdmin)
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"ok":true}`))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
fmt.Fprintf(w, `{"running":true,"port":%d,"ip":"%s"}`, a.cfg.WritePort, ip)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reverse proxy to Next.js for everything else
|
|
||||||
nextURL, _ := url.Parse("http://localhost:3000")
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(nextURL)
|
|
||||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>WriteApp</title><style>body{background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}h1{font-size:24px}p{color:#94a3b8;margin-top:8px}a{color:#38bdf8}</style></head><body><div><h1>WriteApp</h1><p>Next.js frontend is starting...</p><p><a href="/admin">Open Admin Panel</a></p></div></body></html>`))
|
|
||||||
}
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/admin" || strings.HasPrefix(r.URL.Path, "/admin/") || r.URL.Path == "/health" || r.URL.Path == "/status" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start Next.js
|
|
||||||
go a.startNextJS()
|
|
||||||
|
|
||||||
// Start Telegram bot
|
|
||||||
go a.startTelegramBot()
|
|
||||||
|
|
||||||
// HTTP server
|
|
||||||
addr := fmt.Sprintf("0.0.0.0:%d", a.cfg.WritePort)
|
|
||||||
go func() {
|
|
||||||
lg.Info("HTTP server listening", "addr", addr)
|
|
||||||
if err := http.ListenAndServe(addr, withCORS(mux)); err != nil {
|
|
||||||
lg.Error("HTTP server error", "err", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for interrupt
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
|
||||||
sig := <-sigCh
|
|
||||||
lg.Info("shutting down", "signal", sig.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) startNextJS() {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
if _, err := net.DialTimeout("tcp", "127.0.0.1:3000", 2*time.Second); err == nil {
|
|
||||||
lg.Info("Next.js already running on :3000")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lg.Info("starting Next.js frontend")
|
|
||||||
cmd := exec.Command("npx.cmd", "next", "start", "-p", "3000")
|
|
||||||
cmd.Dir = filepath.Join(config.BLOG_ROOT, "write")
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
lg.Warn("failed to start Next.js", "err", err)
|
|
||||||
} else {
|
|
||||||
lg.Info("Next.js started", "pid", cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) startTelegramBot() {
|
|
||||||
if a.cfg.TGToken == "" {
|
|
||||||
lg.Warn("no TG_TOKEN configured, bot disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
|
|
||||||
// Register bot
|
|
||||||
me, err := tgcore.TG("getMe", map[string]interface{}{})
|
|
||||||
if err == nil {
|
|
||||||
if ok, _ := me["ok"].(bool); ok {
|
|
||||||
if result, ok := me["result"].(map[string]interface{}); ok {
|
|
||||||
username := tgcore.GetString(result, "username")
|
|
||||||
lg.Info("telegram connected", "username", username)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tgcore.TG("setMyCommands", map[string]interface{}{
|
|
||||||
"commands": []map[string]string{
|
|
||||||
{"command": "start", "description": "启动写作后台"},
|
|
||||||
{"command": "new", "description": "开始写一篇"},
|
|
||||||
{"command": "edit", "description": "编辑已有文章"},
|
|
||||||
{"command": "list", "description": "文章列表"},
|
|
||||||
{"command": "save", "description": "保存草稿到本地"},
|
|
||||||
{"command": "publish", "description": "发布到 GitHub"},
|
|
||||||
{"command": "stats", "description": "网站数据统计"},
|
|
||||||
{"command": "preview", "description": "预览全文"},
|
|
||||||
{"command": "links", "description": "友链列表"},
|
|
||||||
{"command": "feeds", "description": "订阅源列表"},
|
|
||||||
{"command": "help", "description": "全部命令"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
lg.Info("telegram commands registered")
|
|
||||||
} else {
|
|
||||||
lg.Warn("telegram connection failed", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h := tghandlers.New(&tghandlers.Config{
|
|
||||||
WritePort: a.cfg.WritePort,
|
|
||||||
ControlPort: a.cfg.ControlPort,
|
|
||||||
PreferIface: a.cfg.PreferIface,
|
|
||||||
})
|
|
||||||
|
|
||||||
argCommands := map[string]bool{
|
|
||||||
"new": true, "title": true, "categories": true, "tags": true, "author": true,
|
|
||||||
"edit": true, "view": true, "delete": true,
|
|
||||||
"link_add": true, "link_del": true, "link_toggle": true,
|
|
||||||
"feed_add": true, "feed_del": true,
|
|
||||||
"list": true, "links": true, "feeds": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastOffset int64
|
|
||||||
|
|
||||||
for {
|
|
||||||
data, err := tgcore.TG("getUpdates", map[string]interface{}{
|
|
||||||
"offset": lastOffset + 1,
|
|
||||||
"timeout": 30,
|
|
||||||
"allowed_updates": []string{"message", "callback_query"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
lg.Warn("tg poll error", "err", err)
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, _ := data["ok"].(bool)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
results, _ := data["result"].([]interface{})
|
|
||||||
for _, updateRaw := range results {
|
|
||||||
update, _ := updateRaw.(map[string]interface{})
|
|
||||||
|
|
||||||
if updateID, ok := update["update_id"].(float64); ok {
|
|
||||||
lastOffset = int64(updateID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cbRaw, ok := update["callback_query"]; ok {
|
|
||||||
cb, _ := cbRaw.(map[string]interface{})
|
|
||||||
cbMsg, _ := cb["message"].(map[string]interface{})
|
|
||||||
cbChat, _ := cbMsg["chat"].(map[string]interface{})
|
|
||||||
cbFrom, _ := cb["from"].(map[string]interface{})
|
|
||||||
|
|
||||||
var cbChatID int64
|
|
||||||
if cbChat != nil {
|
|
||||||
cbChatID = tgcore.GetInt64(cbChat, "id")
|
|
||||||
} else if cbFrom != nil {
|
|
||||||
cbChatID = tgcore.GetInt64(cbFrom, "id")
|
|
||||||
}
|
|
||||||
|
|
||||||
queryID := tgcore.GetString(cb, "id")
|
|
||||||
cbData := tgcore.GetString(cb, "data")
|
|
||||||
tgcore.HandleCallback(cbChatID, queryID, cbData, h)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, _ := update["message"].(map[string]interface{})
|
|
||||||
if msg == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
chat, _ := msg["chat"].(map[string]interface{})
|
|
||||||
if chat == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
chatID := tgcore.GetInt64(chat, "id")
|
|
||||||
|
|
||||||
if !tgcore.Allowed(chatID, a.cfg.AllowedIDs) {
|
|
||||||
lg.Warn("tg blocked user", "chatID", chatID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle photos
|
|
||||||
if photos, ok := msg["photo"]; ok {
|
|
||||||
photoArr, _ := photos.([]interface{})
|
|
||||||
if len(photoArr) > 0 && sessions.HasSession(chatID) {
|
|
||||||
s := sessions.GetSession(chatID)
|
|
||||||
largest := photoArr[len(photoArr)-1].(map[string]interface{})
|
|
||||||
fileID := tgcore.GetString(largest, "file_id")
|
|
||||||
fileRes, err := tgcore.TG("getFile", map[string]interface{}{"file_id": fileID})
|
|
||||||
if err == nil && fileRes["ok"] == true {
|
|
||||||
if fileResult, ok := fileRes["result"].(map[string]interface{}); ok {
|
|
||||||
filePath := tgcore.GetString(fileResult, "file_path")
|
|
||||||
caption := tgcore.GetString(msg, "caption")
|
|
||||||
if caption == "" {
|
|
||||||
caption = "image"
|
|
||||||
}
|
|
||||||
imgURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", a.cfg.TGToken, filePath)
|
|
||||||
s.Content += fmt.Sprintf("\n\n", caption, imgURL)
|
|
||||||
lg.Info("photo received", "chat", chatID, "url", imgURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
text := tgcore.GetString(msg, "text")
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fullText := strings.TrimSpace(text)
|
|
||||||
if idx := strings.Index(fullText, "@"); idx >= 0 {
|
|
||||||
afterAt := fullText[idx:]
|
|
||||||
if spaceIdx := strings.Index(afterAt, " "); spaceIdx >= 0 {
|
|
||||||
afterAt = afterAt[spaceIdx:]
|
|
||||||
} else {
|
|
||||||
afterAt = ""
|
|
||||||
}
|
|
||||||
fullText = fullText[:idx] + afterAt
|
|
||||||
fullText = strings.TrimSpace(fullText)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(fullText, " ", 2)
|
|
||||||
slashCmd := ""
|
|
||||||
slashArg := ""
|
|
||||||
if strings.HasPrefix(parts[0], "/") {
|
|
||||||
slashCmd = strings.ToLower(parts[0][1:])
|
|
||||||
if len(parts) > 1 {
|
|
||||||
slashArg = parts[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSession := sessions.HasSession(chatID)
|
|
||||||
|
|
||||||
if hasSession && !strings.HasPrefix(fullText, "/") {
|
|
||||||
s := sessions.GetSession(chatID)
|
|
||||||
s.Content += fullText + "\n"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if slashCmd == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lg.Info("tg command", "chat", chatID, "cmd", slashCmd, "arg", slashArg)
|
|
||||||
a.handleCommand(h, chatID, slashCmd, slashArg, argCommands)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleCommand(h *tghandlers.Handlers, chatID int64, cmd, arg string, argCmds map[string]bool) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
lg.Error("handler panic", "cmd", cmd, "panic", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if argCmds[cmd] {
|
|
||||||
switch cmd {
|
|
||||||
case "new": h.New(chatID, arg)
|
|
||||||
case "title": h.Title(chatID, arg)
|
|
||||||
case "categories": h.Categories(chatID, arg)
|
|
||||||
case "tags": h.Tags(chatID, arg)
|
|
||||||
case "author": h.Author(chatID, arg)
|
|
||||||
case "edit": h.Edit(chatID, arg)
|
|
||||||
case "view": h.View(chatID, arg)
|
|
||||||
case "delete": h.Delete(chatID, arg)
|
|
||||||
case "link_add": h.LinkAdd(chatID, arg)
|
|
||||||
case "link_del": h.LinkDel(chatID, arg)
|
|
||||||
case "link_toggle": h.LinkToggle(chatID, arg)
|
|
||||||
case "feed_add": h.FeedAdd(chatID, arg)
|
|
||||||
case "feed_del": h.FeedDel(chatID, arg)
|
|
||||||
case "list": h.List(chatID, arg)
|
|
||||||
case "links": h.Links(chatID, arg)
|
|
||||||
case "feeds": h.Feeds(chatID, arg)
|
|
||||||
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch cmd {
|
|
||||||
case "start": h.Start(chatID)
|
|
||||||
case "stop": h.Stop(chatID)
|
|
||||||
case "status": h.Status(chatID)
|
|
||||||
case "ip": h.IP(chatID)
|
|
||||||
case "help": h.Help(chatID)
|
|
||||||
case "save": h.Save(chatID)
|
|
||||||
case "publish": h.Publish(chatID)
|
|
||||||
case "draft": h.Draft(chatID)
|
|
||||||
case "preview": h.Preview(chatID)
|
|
||||||
case "cancel": h.Cancel(chatID)
|
|
||||||
case "stats": h.Stats(chatID)
|
|
||||||
default: tgcore.SendMessage(chatID, "? Unknown: /"+cmd, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/admin", "/admin/":
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(a.adminHTML()))
|
|
||||||
case "/admin/status":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
fmt.Fprintf(w, `{"running":true,"port":%d,"ip":"%s"}`,
|
|
||||||
a.cfg.WritePort, helpers.GetLanIP(a.cfg.PreferIface))
|
|
||||||
case "/admin/start":
|
|
||||||
if r.Method == "POST" {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"started":true}`))
|
|
||||||
}
|
|
||||||
case "/admin/stop":
|
|
||||||
if r.Method == "POST" {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"ok":false,"reason":"bot runs in-process"}`))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) adminHTML() string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
|
||||||
<title>WriteApp</title>
|
|
||||||
<style>
|
|
||||||
:root{color-scheme:dark;--bg:#111827;--card:#1f2937;--text:#f3f4f6;--muted:#9ca3af;--accent:#60a5fa;--accent-hover:#93bbfd;--line:#374151;--success:#86efac}
|
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
|
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px;max-width:420px;width:100%%}
|
|
||||||
h1{font-size:18px;font-weight:700;margin-bottom:4px}
|
|
||||||
.sub{font-size:13px;color:var(--muted);margin-bottom:20px}
|
|
||||||
.row{display:flex;align-items:center;gap:10px;padding:12px 16px;background:rgba(255,255,255,0.03);border-radius:8px;margin-bottom:16px}
|
|
||||||
.dot{width:10px;height:10px;border-radius:50%%;flex-shrink:0;background:var(--success)}
|
|
||||||
.text{font-size:14px;font-weight:500}
|
|
||||||
.ip{font-size:12px;color:var(--muted);margin-top:2px}
|
|
||||||
.actions{display:flex;gap:8px;flex-wrap:wrap}
|
|
||||||
.btn{padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all 0.15s;color:#fff;text-decoration:none;display:inline-block}
|
|
||||||
.btn-primary{background:var(--accent)}
|
|
||||||
.btn-primary:hover{background:var(--accent-hover)}
|
|
||||||
.btn-secondary{background:transparent;border:1px solid var(--line);color:var(--text)}
|
|
||||||
.btn-secondary:hover{background:rgba(255,255,255,0.05)}
|
|
||||||
.link-row{margin-top:16px;padding:10px 14px;background:rgba(255,255,255,0.03);border-radius:8px;font-size:13px;color:var(--muted);word-break:break-all}
|
|
||||||
.link-row a{color:var(--accent);text-decoration:none}
|
|
||||||
.footer{margin-top:20px;font-size:11px;color:var(--muted);text-align:center}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<h1>WriteApp</h1>
|
|
||||||
<p class="sub">Blog CMS + Telegram Bot</p>
|
|
||||||
<div class="row"><div class="dot"></div><div><div class="text">Server Running</div><div class="ip">Port %d · LAN: %s</div></div></div>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="btn btn-primary" href="/">Open Editor</a>
|
|
||||||
<a class="btn btn-secondary" href="/api/stats" target="_blank">API Stats</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-row">Frontend: <a href="/">/</a></div>
|
|
||||||
<div class="link-row">API: <a href="/api/posts">/api/posts</a> · <a href="/api/stats">/api/stats</a></div>
|
|
||||||
<div class="footer">Go Edition · Single Process</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`, a.cfg.WritePort, helpers.GetLanIP(a.cfg.PreferIface))
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package artalk
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"write-server/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var authToken string
|
|
||||||
|
|
||||||
func SetAuthToken(token string) { authToken = token }
|
|
||||||
func GetAuthToken() string { return authToken }
|
|
||||||
|
|
||||||
type ArtalkPage struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
AdminOnly bool `json:"admin_only"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
View int `json:"view"`
|
|
||||||
CommentCount int `json:"comment_count"`
|
|
||||||
Date string `json:"date"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArtalkComment struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Nick string `json:"nick"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
PageKey string `json:"page_key"`
|
|
||||||
IsCollapsed bool `json:"is_collapsed"`
|
|
||||||
IsPending bool `json:"is_pending"`
|
|
||||||
IsPinned bool `json:"is_pinned"`
|
|
||||||
VoteUp int `json:"vote_up"`
|
|
||||||
VoteDown int `json:"vote_down"`
|
|
||||||
RID int `json:"rid"`
|
|
||||||
Date string `json:"date"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Login(email, password string) (map[string]interface{}, error) {
|
|
||||||
u := config.ARTALK_SERVER + "/api/v2/auth/email/login"
|
|
||||||
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
|
||||||
resp, err := http.Post(u, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.Unmarshal(data, &result)
|
|
||||||
|
|
||||||
if token, ok := result["token"].(string); ok {
|
|
||||||
authToken = token
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func artalkFetch(path string) (map[string]interface{}, error) {
|
|
||||||
u := config.ARTALK_SERVER + path
|
|
||||||
req, _ := http.NewRequest("GET", u, nil)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if authToken != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.Unmarshal(data, &result)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPages(offset, limit int, pageType string) ([]ArtalkPage, int, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("site_name", config.ARTALK_SITE)
|
|
||||||
params.Set("offset", fmt.Sprintf("%d", offset))
|
|
||||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
|
||||||
params.Set("type", pageType)
|
|
||||||
|
|
||||||
result, err := artalkFetch("/api/v2/pages?" + params.Encode())
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var pages []ArtalkPage
|
|
||||||
if raw, ok := result["pages"]; ok {
|
|
||||||
d, _ := json.Marshal(raw)
|
|
||||||
json.Unmarshal(d, &pages)
|
|
||||||
} else if raw, ok := result["data"]; ok {
|
|
||||||
d, _ := json.Marshal(raw)
|
|
||||||
json.Unmarshal(d, &pages)
|
|
||||||
}
|
|
||||||
|
|
||||||
total := 0
|
|
||||||
if v, ok := result["count"]; ok {
|
|
||||||
fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &total)
|
|
||||||
} else if v, ok := result["total"]; ok {
|
|
||||||
fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &total)
|
|
||||||
}
|
|
||||||
return pages, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetComments(pageKey string, offset, limit int) ([]ArtalkComment, int, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("site_name", config.ARTALK_SITE)
|
|
||||||
params.Set("page_key", pageKey)
|
|
||||||
params.Set("offset", fmt.Sprintf("%d", offset))
|
|
||||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
|
||||||
params.Set("flat_mode", "true")
|
|
||||||
params.Set("sort_by", "date_desc")
|
|
||||||
|
|
||||||
result, err := artalkFetch("/api/v2/comments?" + params.Encode())
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var comments []ArtalkComment
|
|
||||||
if raw, ok := result["comments"]; ok {
|
|
||||||
d, _ := json.Marshal(raw)
|
|
||||||
json.Unmarshal(d, &comments)
|
|
||||||
} else if raw, ok := result["data"]; ok {
|
|
||||||
d, _ := json.Marshal(raw)
|
|
||||||
json.Unmarshal(d, &comments)
|
|
||||||
}
|
|
||||||
total := 0
|
|
||||||
if v, ok := result["count"]; ok {
|
|
||||||
fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &total)
|
|
||||||
} else if v, ok := result["total"]; ok {
|
|
||||||
fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &total)
|
|
||||||
}
|
|
||||||
return comments, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateComment(id int, data map[string]interface{}) error {
|
|
||||||
u := config.ARTALK_SERVER + fmt.Sprintf("/api/v2/comments/%d", id)
|
|
||||||
data["site_name"] = config.ARTALK_SITE
|
|
||||||
body, _ := json.Marshal(data)
|
|
||||||
req, _ := http.NewRequest("PUT", u, bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if authToken != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateComment(data map[string]interface{}) (map[string]interface{}, error) {
|
|
||||||
u := config.ARTALK_SERVER + "/api/v2/comments"
|
|
||||||
data["site_name"] = config.ARTALK_SITE
|
|
||||||
body, _ := json.Marshal(data)
|
|
||||||
req, _ := http.NewRequest("POST", u, bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if authToken != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
respData, _ := io.ReadAll(resp.Body)
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.Unmarshal(respData, &result)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteComment(id int) error {
|
|
||||||
u := config.ARTALK_SERVER + fmt.Sprintf("/api/v2/comments/%d", id)
|
|
||||||
req, _ := http.NewRequest("DELETE", u, bytes.NewReader([]byte(`{"site_name":"` + config.ARTALK_SITE + `"}`)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if authToken != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
|
||||||
}
|
|
||||||
_, err := http.DefaultClient.Do(req)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package botconfig
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
TGToken string
|
|
||||||
AllowedIDs []int64
|
|
||||||
WritePort int
|
|
||||||
ControlPort int
|
|
||||||
PreferIface string
|
|
||||||
WriteDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() *Config {
|
|
||||||
cfg := &Config{
|
|
||||||
WritePort: 8016,
|
|
||||||
ControlPort: 8017,
|
|
||||||
PreferIface: "WLAN",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, filename, _, _ := runtime.Caller(0)
|
|
||||||
srcDir := filepath.Dir(filename)
|
|
||||||
rootDir := filepath.Dir(filepath.Dir(filepath.Dir(srcDir)))
|
|
||||||
// Try multiple .env locations: bot-go/.env first, then blog/.env
|
|
||||||
envPaths := []string{
|
|
||||||
filepath.Join(rootDir, "bot-go", ".env"),
|
|
||||||
filepath.Join(rootDir, ".env"),
|
|
||||||
filepath.Join(filepath.Dir(rootDir), "bot", ".env"),
|
|
||||||
}
|
|
||||||
var f *os.File
|
|
||||||
var err error
|
|
||||||
for _, envPath := range envPaths {
|
|
||||||
f, err = os.Open(envPath)
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
val := strings.TrimSpace(parts[1])
|
|
||||||
if existing := os.Getenv(key); existing != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
os.Setenv(key, val)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.TGToken = os.Getenv("TG_BOT_TOKEN")
|
|
||||||
allowedStr := os.Getenv("TG_ALLOWED_CHAT_IDS")
|
|
||||||
if allowedStr != "" {
|
|
||||||
for _, s := range strings.Split(allowedStr, ",") {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if id, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
||||||
cfg.AllowedIDs = append(cfg.AllowedIDs, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if p := os.Getenv("WRITE_PORT"); p != "" {
|
|
||||||
if port, err := strconv.Atoi(p); err == nil {
|
|
||||||
cfg.WritePort = port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p := os.Getenv("BOT_PORT"); p != "" {
|
|
||||||
if port, err := strconv.Atoi(p); err == nil {
|
|
||||||
cfg.ControlPort = port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p := os.Getenv("PREFER_IFACE"); p != "" {
|
|
||||||
cfg.PreferIface = p
|
|
||||||
}
|
|
||||||
|
|
||||||
exe, _ := os.Executable(); exeDir := filepath.Dir(exe); cfg.WriteDir = filepath.Join(filepath.Dir(exeDir), "write-go"); if _, err := os.Stat(cfg.WriteDir); os.IsNotExist(err) { cfg.WriteDir = filepath.Join(filepath.Dir(rootDir), "write-go") }
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
BLOG_ROOT string
|
|
||||||
CONTENT_DIR string
|
|
||||||
RECYCLE_DIR string
|
|
||||||
ARTALK_SERVER = "https://artalk.usj.cc"
|
|
||||||
ARTALK_SITE = "???"
|
|
||||||
ITEMS_PER_PAGE = 20
|
|
||||||
RECYCLE_DAYS = 30
|
|
||||||
HUGO_PREVIEW = "http://localhost:1313"
|
|
||||||
RSS_API_BASE string
|
|
||||||
RSS_API_TOKEN string
|
|
||||||
SCF_PROXY string
|
|
||||||
WECHAT_APP_ID string
|
|
||||||
WECHAT_APP_SECRET string
|
|
||||||
DEEPSEEK_API_KEY string
|
|
||||||
DEEPSEEK_BASE_URL string
|
|
||||||
DEEPSEEK_MODEL string
|
|
||||||
)
|
|
||||||
|
|
||||||
func Init() {
|
|
||||||
cwd, _ := os.Getwd()
|
|
||||||
BLOG_ROOT = filepath.Clean(filepath.Join(cwd, ".."))
|
|
||||||
CONTENT_DIR = filepath.Join(BLOG_ROOT, "content", "post")
|
|
||||||
RECYCLE_DIR = filepath.Join(cwd, ".recycle")
|
|
||||||
|
|
||||||
loadEnv()
|
|
||||||
|
|
||||||
RSS_API_BASE = getEnv("RSS_API_BASE", "https://api.usj.cc")
|
|
||||||
RSS_API_TOKEN = os.Getenv("RSS_API_TOKEN")
|
|
||||||
SCF_PROXY = getEnv("SCF_PROXY", "")
|
|
||||||
WECHAT_APP_ID = os.Getenv("WECHAT_APP_ID")
|
|
||||||
WECHAT_APP_SECRET = os.Getenv("WECHAT_APP_SECRET")
|
|
||||||
DEEPSEEK_API_KEY = os.Getenv("DEEPSEEK_API_KEY")
|
|
||||||
DEEPSEEK_BASE_URL = getEnv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
|
|
||||||
DEEPSEEK_MODEL = getEnv("DEEPSEEK_MODEL", "deepseek-chat")
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadEnv() {
|
|
||||||
envPaths := []string{
|
|
||||||
filepath.Join(BLOG_ROOT, "write-go", ".env"),
|
|
||||||
filepath.Join(BLOG_ROOT, "write", ".env"),
|
|
||||||
}
|
|
||||||
for _, p := range envPaths {
|
|
||||||
f, err := os.Open(p)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
val := strings.TrimSpace(parts[1])
|
|
||||||
if os.Getenv(key) == "" {
|
|
||||||
os.Setenv(key, val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetEnvInt(key string, fallback int) int {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
@@ -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,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,95 +0,0 @@
|
|||||||
package images
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"write-server/internal/config"
|
|
||||||
"write-server/internal/posts"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ImageFile struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Folder string `json:"folder"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
LocalURL string `json:"localUrl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var imageExts = map[string]bool{
|
|
||||||
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
|
|
||||||
".webp": true, ".svg": true, ".bmp": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
func ScanImages() []ImageFile {
|
|
||||||
var images []ImageFile
|
|
||||||
filepath.WalkDir(config.CONTENT_DIR, func(p string, d os.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
if strings.HasPrefix(d.Name(), ".") {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ext := strings.ToLower(filepath.Ext(d.Name()))
|
|
||||||
if !imageExts[ext] {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
info, _ := d.Info()
|
|
||||||
size := int64(0)
|
|
||||||
if info != nil {
|
|
||||||
size = info.Size()
|
|
||||||
}
|
|
||||||
|
|
||||||
rel, _ := filepath.Rel(config.CONTENT_DIR, p)
|
|
||||||
rel = strings.ReplaceAll(rel, "\\", "/")
|
|
||||||
dir := filepath.Dir(p)
|
|
||||||
|
|
||||||
slug := ""
|
|
||||||
indexPath := filepath.Join(dir, "index.md")
|
|
||||||
if data, err := os.ReadFile(indexPath); err == nil {
|
|
||||||
fm, _ := posts.ParseFrontMatter(string(data))
|
|
||||||
slug = fm.Slug
|
|
||||||
}
|
|
||||||
|
|
||||||
u := "/" + rel
|
|
||||||
if slug != "" {
|
|
||||||
u = "/" + slug + "/" + d.Name()
|
|
||||||
}
|
|
||||||
|
|
||||||
images = append(images, ImageFile{
|
|
||||||
Name: d.Name(),
|
|
||||||
Path: rel,
|
|
||||||
Folder: filepath.Dir(rel),
|
|
||||||
Size: size,
|
|
||||||
URL: u,
|
|
||||||
Slug: slug,
|
|
||||||
LocalURL: fmt.Sprintf("/api/images?file=%s", rel),
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by size desc
|
|
||||||
for i := 0; i < len(images); i++ {
|
|
||||||
for j := i + 1; j < len(images); j++ {
|
|
||||||
if images[j].Size > images[i].Size {
|
|
||||||
images[i], images[j] = images[j], images[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return images
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteImage(relPath string) error {
|
|
||||||
fullPath := filepath.Join(config.CONTENT_DIR, relPath)
|
|
||||||
if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(config.CONTENT_DIR)) {
|
|
||||||
return fmt.Errorf("invalid path")
|
|
||||||
}
|
|
||||||
return os.Remove(fullPath)
|
|
||||||
}
|
|
||||||
@@ -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,524 +0,0 @@
|
|||||||
package posts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"write-server/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FrontMatter struct {
|
|
||||||
Pid int `json:"pid,omitempty"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Date string `json:"date"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Draft bool `json:"draft"`
|
|
||||||
Categories []string `json:"categories,omitempty"`
|
|
||||||
Tags []string `json:"tags,omitempty"`
|
|
||||||
Author string `json:"author,omitempty"`
|
|
||||||
Layout string `json:"layout,omitempty"`
|
|
||||||
AiComment string `json:"ai_comment,omitempty"`
|
|
||||||
Extra map[string]interface{} `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Post struct {
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
FrontMatter FrontMatter `json:"frontMatter"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
FilePath string `json:"-"`
|
|
||||||
DirPath string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PostListItem struct {
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Date string `json:"date"`
|
|
||||||
Draft bool `json:"draft"`
|
|
||||||
Categories []string `json:"categories"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var dateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-(.+)$`)
|
|
||||||
var yearRE = regexp.MustCompile(`^(\d{4})`)
|
|
||||||
|
|
||||||
func Init() {
|
|
||||||
config.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func findPostDirs() []string {
|
|
||||||
var result []string
|
|
||||||
if _, err := os.Stat(config.CONTENT_DIR); os.IsNotExist(err) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(config.CONTENT_DIR)
|
|
||||||
if err != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() || !yearRE.MatchString(entry.Name()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
yearDir := filepath.Join(config.CONTENT_DIR, entry.Name())
|
|
||||||
posts, err := os.ReadDir(yearDir)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, post := range posts {
|
|
||||||
if post.IsDir() {
|
|
||||||
result = append(result, filepath.Join(yearDir, post.Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func slugFromDir(dirName string) string {
|
|
||||||
m := dateRE.FindStringSubmatch(dirName)
|
|
||||||
if m != nil {
|
|
||||||
return m[1]
|
|
||||||
}
|
|
||||||
return dirName
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDate(dateStr string) time.Time {
|
|
||||||
if len(dateStr) < 10 {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
t, err := time.Parse("2006-01-02", dateStr[:10])
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseFrontMatter parses YAML-like frontmatter (---...---) and body
|
|
||||||
func ParseFrontMatter(raw string) (FrontMatter, string) {
|
|
||||||
fm := FrontMatter{Layout: "post"}
|
|
||||||
fm.Extra = make(map[string]interface{})
|
|
||||||
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if !strings.HasPrefix(raw, "---") {
|
|
||||||
return fm, raw
|
|
||||||
}
|
|
||||||
|
|
||||||
rest := raw[3:]
|
|
||||||
endIdx := strings.Index(rest, "\n---")
|
|
||||||
if endIdx < 0 {
|
|
||||||
return fm, raw
|
|
||||||
}
|
|
||||||
|
|
||||||
yamlBlock := rest[:endIdx]
|
|
||||||
body := rest[endIdx+4:]
|
|
||||||
|
|
||||||
// Simple YAML-like key: value parser
|
|
||||||
lines := strings.Split(yamlBlock, "\n")
|
|
||||||
var currentListKey string
|
|
||||||
for _, line := range lines {
|
|
||||||
// Handle multi-line YAML list items (- value)
|
|
||||||
if currentListKey != "" && strings.HasPrefix(strings.TrimSpace(line), "- ") {
|
|
||||||
item := strings.Trim(strings.TrimPrefix(strings.TrimSpace(line), "- "), "\"'")
|
|
||||||
switch currentListKey {
|
|
||||||
case "categories":
|
|
||||||
fm.Categories = append(fm.Categories, item)
|
|
||||||
case "tags":
|
|
||||||
fm.Tags = append(fm.Tags, item)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentListKey = ""
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
val := strings.TrimSpace(parts[1])
|
|
||||||
|
|
||||||
// Unquoted string value
|
|
||||||
val = strings.Trim(val, "\"'")
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "title":
|
|
||||||
fm.Title = val
|
|
||||||
case "date":
|
|
||||||
fm.Date = val
|
|
||||||
case "slug":
|
|
||||||
fm.Slug = val
|
|
||||||
case "draft":
|
|
||||||
fm.Draft = val == "true"
|
|
||||||
case "author":
|
|
||||||
fm.Author = val
|
|
||||||
case "layout":
|
|
||||||
fm.Layout = val
|
|
||||||
case "ai_comment":
|
|
||||||
fm.AiComment = val
|
|
||||||
case "categories":
|
|
||||||
if val == "" {
|
|
||||||
currentListKey = "categories"
|
|
||||||
} else if val != "[]" {
|
|
||||||
fm.Categories = parseYAMLList(val, line)
|
|
||||||
}
|
|
||||||
case "tags":
|
|
||||||
if val == "" {
|
|
||||||
currentListKey = "tags"
|
|
||||||
} else if val != "[]" {
|
|
||||||
fm.Tags = parseYAMLList(val, line)
|
|
||||||
}
|
|
||||||
case "pid":
|
|
||||||
fmt.Sscanf(val, "%d", &fm.Pid)
|
|
||||||
default:
|
|
||||||
fm.Extra[key] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fm, body
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseYAMLList(firstVal string, fullLine string) []string {
|
|
||||||
// Try inline: [a, b, c]
|
|
||||||
if strings.HasPrefix(firstVal, "[") && strings.HasSuffix(firstVal, "]") {
|
|
||||||
inner := firstVal[1 : len(firstVal)-1]
|
|
||||||
var items []string
|
|
||||||
for _, item := range strings.Split(inner, ",") {
|
|
||||||
item = strings.TrimSpace(item)
|
|
||||||
item = strings.Trim(item, "\"'")
|
|
||||||
if item != "" {
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
// Single value on same line
|
|
||||||
if firstVal != "" && firstVal != "[]" {
|
|
||||||
return []string{strings.Trim(firstVal, "\"'")}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseYAMLListMultiLine is called for multi-line list items
|
|
||||||
// In our simple parser, multi-line categories/tags aren't handled fully
|
|
||||||
// but this stub exists for testing
|
|
||||||
|
|
||||||
func ListPosts(search string, page, perPage int, includeDrafts bool) ([]PostListItem, int, int) {
|
|
||||||
dirs := findPostDirs()
|
|
||||||
var allPosts []PostListItem
|
|
||||||
|
|
||||||
for _, dirPath := range dirs {
|
|
||||||
indexPath := filepath.Join(dirPath, "index.md")
|
|
||||||
data, err := os.ReadFile(indexPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fm, _ := ParseFrontMatter(string(data))
|
|
||||||
dirName := filepath.Base(dirPath)
|
|
||||||
|
|
||||||
slug := fm.Slug
|
|
||||||
if slug == "" {
|
|
||||||
slug = slugFromDir(dirName)
|
|
||||||
}
|
|
||||||
title := fm.Title
|
|
||||||
if title == "" {
|
|
||||||
title = "Untitled"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !includeDrafts && fm.Draft {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if search != "" {
|
|
||||||
q := strings.ToLower(search)
|
|
||||||
match := strings.Contains(strings.ToLower(title), q) ||
|
|
||||||
strings.Contains(strings.ToLower(slug), q)
|
|
||||||
if !match {
|
|
||||||
for _, c := range fm.Categories {
|
|
||||||
if strings.Contains(strings.ToLower(c), q) {
|
|
||||||
match = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !match {
|
|
||||||
for _, t := range fm.Tags {
|
|
||||||
if strings.Contains(strings.ToLower(t), q) {
|
|
||||||
match = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !match {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allPosts = append(allPosts, PostListItem{
|
|
||||||
Slug: slug,
|
|
||||||
Title: title,
|
|
||||||
Date: fm.Date,
|
|
||||||
Draft: fm.Draft,
|
|
||||||
Categories: fm.Categories,
|
|
||||||
Tags: fm.Tags,
|
|
||||||
Author: fm.Author,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(allPosts, func(i, j int) bool {
|
|
||||||
return parseDate(allPosts[i].Date).After(parseDate(allPosts[j].Date))
|
|
||||||
})
|
|
||||||
|
|
||||||
total := len(allPosts)
|
|
||||||
totalPages := (total + perPage - 1) / perPage
|
|
||||||
if totalPages < 1 {
|
|
||||||
totalPages = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
start := (page - 1) * perPage
|
|
||||||
if start > total {
|
|
||||||
start = total
|
|
||||||
}
|
|
||||||
end := start + perPage
|
|
||||||
if end > total {
|
|
||||||
end = total
|
|
||||||
}
|
|
||||||
|
|
||||||
return allPosts[start:end], total, totalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPost(slug string) *Post {
|
|
||||||
dirs := findPostDirs()
|
|
||||||
for _, dirPath := range dirs {
|
|
||||||
indexPath := filepath.Join(dirPath, "index.md")
|
|
||||||
data, err := os.ReadFile(indexPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fm, body := ParseFrontMatter(string(data))
|
|
||||||
existingSlug := fm.Slug
|
|
||||||
if existingSlug == "" {
|
|
||||||
existingSlug = slugFromDir(filepath.Base(dirPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
if existingSlug == slug {
|
|
||||||
return &Post{
|
|
||||||
Slug: existingSlug,
|
|
||||||
FrontMatter: fm,
|
|
||||||
Content: strings.TrimSpace(body),
|
|
||||||
FilePath: indexPath,
|
|
||||||
DirPath: dirPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreatePost(fm FrontMatter, content string) (string, error) {
|
|
||||||
date := time.Now()
|
|
||||||
if fm.Date != "" {
|
|
||||||
if d, err := time.Parse("2006-01-02", fm.Date[:10]); err == nil {
|
|
||||||
date = d
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fm.Date = date.Format("2006-01-02")
|
|
||||||
}
|
|
||||||
year := fmt.Sprintf("%04d", date.Year())
|
|
||||||
dateStr := date.Format("2006-01-02")
|
|
||||||
|
|
||||||
titlePart := fm.Title
|
|
||||||
if titlePart == "" {
|
|
||||||
titlePart = "untitled"
|
|
||||||
}
|
|
||||||
titlePart = sanitizePath(titlePart)
|
|
||||||
if titlePart == "" {
|
|
||||||
titlePart = "post"
|
|
||||||
}
|
|
||||||
|
|
||||||
dirName := dateStr + "-" + titlePart
|
|
||||||
dirPath := filepath.Join(config.CONTENT_DIR, year, dirName)
|
|
||||||
|
|
||||||
if _, err := os.Stat(dirPath); err == nil {
|
|
||||||
return "", fmt.Errorf("Post already exists at %s", dirPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
fileContent := buildMarkdown(fm, content)
|
|
||||||
indexPath := filepath.Join(dirPath, "index.md")
|
|
||||||
if err := os.WriteFile(indexPath, []byte(fileContent), 0644); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return dirPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdatePost(slug string, fmUpdates map[string]interface{}, content *string) (*Post, error) {
|
|
||||||
post := GetPost(slug)
|
|
||||||
if post == nil {
|
|
||||||
return nil, fmt.Errorf("post not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply updates
|
|
||||||
for k, v := range fmUpdates {
|
|
||||||
switch k {
|
|
||||||
case "title":
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
post.FrontMatter.Title = s
|
|
||||||
}
|
|
||||||
case "date":
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
post.FrontMatter.Date = s
|
|
||||||
}
|
|
||||||
case "draft":
|
|
||||||
if b, ok := v.(bool); ok {
|
|
||||||
post.FrontMatter.Draft = b
|
|
||||||
}
|
|
||||||
case "categories":
|
|
||||||
if arr, ok := v.([]interface{}); ok {
|
|
||||||
post.FrontMatter.Categories = nil
|
|
||||||
for _, item := range arr {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
post.FrontMatter.Categories = append(post.FrontMatter.Categories, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "tags":
|
|
||||||
if arr, ok := v.([]interface{}); ok {
|
|
||||||
post.FrontMatter.Tags = nil
|
|
||||||
for _, item := range arr {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
post.FrontMatter.Tags = append(post.FrontMatter.Tags, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "author":
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
post.FrontMatter.Author = s
|
|
||||||
}
|
|
||||||
case "slug":
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
post.FrontMatter.Slug = s
|
|
||||||
}
|
|
||||||
case "ai_comment":
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
post.FrontMatter.AiComment = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if content != nil {
|
|
||||||
post.Content = *content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle slug rename
|
|
||||||
if newSlug, ok := fmUpdates["slug"].(string); ok && newSlug != slug {
|
|
||||||
date := time.Now()
|
|
||||||
if post.FrontMatter.Date != "" {
|
|
||||||
if d, err := time.Parse("2006-01-02", post.FrontMatter.Date[:10]); err == nil {
|
|
||||||
date = d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
year := fmt.Sprintf("%04d", date.Year())
|
|
||||||
dateStr := date.Format("2006-01-02")
|
|
||||||
newDirName := dateStr + "-" + newSlug
|
|
||||||
newDirPath := filepath.Join(config.CONTENT_DIR, year, newDirName)
|
|
||||||
|
|
||||||
if _, err := os.Stat(newDirPath); err == nil {
|
|
||||||
return nil, fmt.Errorf("Target path already exists: %s", newDirPath)
|
|
||||||
}
|
|
||||||
os.MkdirAll(filepath.Dir(newDirPath), 0755)
|
|
||||||
os.Rename(post.DirPath, newDirPath)
|
|
||||||
post.DirPath = newDirPath
|
|
||||||
post.FilePath = filepath.Join(newDirPath, "index.md")
|
|
||||||
post.Slug = newSlug
|
|
||||||
}
|
|
||||||
|
|
||||||
fileContent := buildMarkdown(post.FrontMatter, post.Content)
|
|
||||||
if err := os.WriteFile(post.FilePath, []byte(fileContent), 0644); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return post, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPostCount() int {
|
|
||||||
count := 0
|
|
||||||
dirs := findPostDirs()
|
|
||||||
for _, dir := range dirs {
|
|
||||||
if _, err := os.Stat(filepath.Join(dir, "index.md")); err == nil {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizePath(s string) string {
|
|
||||||
var result []byte
|
|
||||||
for _, r := range []byte(s) {
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
|
||||||
result = append(result, r)
|
|
||||||
} else if r >= 0x80 {
|
|
||||||
// Allow multi-byte (CJK etc.)
|
|
||||||
result = append(result, r)
|
|
||||||
} else {
|
|
||||||
result = append(result, '-')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := string(result)
|
|
||||||
out = strings.Trim(out, "-")
|
|
||||||
return strings.ToLower(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMarkdown(fm FrontMatter, content string) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("---\n")
|
|
||||||
sb.WriteString(fmt.Sprintf("title: %s\n", quoteYAML(fm.Title)))
|
|
||||||
sb.WriteString(fmt.Sprintf("date: %s\n", fm.Date))
|
|
||||||
sb.WriteString(fmt.Sprintf("slug: %s\n", fm.Slug))
|
|
||||||
sb.WriteString(fmt.Sprintf("draft: %v\n", fm.Draft))
|
|
||||||
if len(fm.Categories) > 0 {
|
|
||||||
sb.WriteString("categories:\n")
|
|
||||||
for _, c := range fm.Categories {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - %s\n", quoteYAML(c)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(fm.Tags) > 0 {
|
|
||||||
sb.WriteString("tags:\n")
|
|
||||||
for _, t := range fm.Tags {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - %s\n", quoteYAML(t)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fm.Author != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("author: %s\n", quoteYAML(fm.Author)))
|
|
||||||
}
|
|
||||||
if fm.Layout != "post" && fm.Layout != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("layout: %s\n", fm.Layout))
|
|
||||||
}
|
|
||||||
if fm.AiComment != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("ai_comment: %s\n", quoteYAML(fm.AiComment)))
|
|
||||||
}
|
|
||||||
if fm.Pid != 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("pid: %d\n", fm.Pid))
|
|
||||||
}
|
|
||||||
sb.WriteString("---\n")
|
|
||||||
sb.WriteString(content)
|
|
||||||
if !strings.HasSuffix(content, "\n") {
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteYAML(s string) string {
|
|
||||||
if strings.ContainsAny(s, ":{}[]&*?|>!%@`'\"#,\n") || s == "" {
|
|
||||||
return "\"" + strings.ReplaceAll(s, "\"", "\\\"") + "\""
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package recycle
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"write-server/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var RECYCLE_POSTS_DIR string
|
|
||||||
var RECYCLE_META_DIR string
|
|
||||||
|
|
||||||
type RecycleItem struct {
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
DeletedAt string `json:"deletedAt"`
|
|
||||||
OriginalPath string `json:"originalPath"`
|
|
||||||
ExpiresAt string `json:"expiresAt"`
|
|
||||||
DaysRemaining int `json:"daysRemaining"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitRecycleBin() {
|
|
||||||
base := config.RECYCLE_DIR
|
|
||||||
RECYCLE_POSTS_DIR = filepath.Join(base, "posts")
|
|
||||||
RECYCLE_META_DIR = filepath.Join(base, "meta")
|
|
||||||
if err := os.MkdirAll(RECYCLE_POSTS_DIR, 0755); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(RECYCLE_META_DIR, 0755); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func MoveToRecycle(slug, sourceDir, title string) error {
|
|
||||||
InitRecycleBin()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
expires := now.AddDate(0, 0, config.RECYCLE_DAYS)
|
|
||||||
|
|
||||||
meta := map[string]interface{}{
|
|
||||||
"slug": slug,
|
|
||||||
"title": title,
|
|
||||||
"deletedAt": now.Format(time.RFC3339),
|
|
||||||
"originalPath": sourceDir,
|
|
||||||
"expiresAt": expires.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDir := filepath.Join(RECYCLE_POSTS_DIR, slug)
|
|
||||||
os.RemoveAll(targetDir)
|
|
||||||
if err := os.Rename(sourceDir, targetDir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
metaPath := filepath.Join(RECYCLE_META_DIR, slug+".json")
|
|
||||||
data, _ := json.MarshalIndent(meta, "", " ")
|
|
||||||
return os.WriteFile(metaPath, data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListRecycle() []RecycleItem {
|
|
||||||
if _, err := os.Stat(RECYCLE_META_DIR); os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(RECYCLE_META_DIR)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var items []RecycleItem
|
|
||||||
now := time.Now()
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !strings.HasSuffix(entry.Name(), ".json") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(filepath.Join(RECYCLE_META_DIR, entry.Name()))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var meta map[string]interface{}
|
|
||||||
if err := json.Unmarshal(data, &meta); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
expiresAt, _ := time.Parse(time.RFC3339, getString(meta, "expiresAt"))
|
|
||||||
daysRemaining := int(math.Ceil(expiresAt.Sub(now).Hours() / 24))
|
|
||||||
if daysRemaining < 0 {
|
|
||||||
daysRemaining = 0
|
|
||||||
}
|
|
||||||
items = append(items, RecycleItem{
|
|
||||||
Slug: getString(meta, "slug"),
|
|
||||||
Title: getString(meta, "title"),
|
|
||||||
DeletedAt: getString(meta, "deletedAt"),
|
|
||||||
OriginalPath: getString(meta, "originalPath"),
|
|
||||||
ExpiresAt: getString(meta, "expiresAt"),
|
|
||||||
DaysRemaining: daysRemaining,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
ti, _ := time.Parse(time.RFC3339, items[i].DeletedAt)
|
|
||||||
tj, _ := time.Parse(time.RFC3339, items[j].DeletedAt)
|
|
||||||
return ti.After(tj)
|
|
||||||
})
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func RestoreFromRecycle(slug string) bool {
|
|
||||||
metaPath := filepath.Join(RECYCLE_META_DIR, slug+".json")
|
|
||||||
data, err := os.ReadFile(metaPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var meta map[string]interface{}
|
|
||||||
json.Unmarshal(data, &meta)
|
|
||||||
|
|
||||||
recyclePostPath := filepath.Join(RECYCLE_POSTS_DIR, slug)
|
|
||||||
if _, err := os.Stat(recyclePostPath); os.IsNotExist(err) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
originalPath := getString(meta, "originalPath")
|
|
||||||
os.MkdirAll(filepath.Dir(originalPath), 0755)
|
|
||||||
os.RemoveAll(originalPath)
|
|
||||||
|
|
||||||
if err := os.Rename(recyclePostPath, originalPath); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
os.Remove(metaPath)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func PermanentDelete(slug string) bool {
|
|
||||||
deleted := false
|
|
||||||
recyclePostPath := filepath.Join(RECYCLE_POSTS_DIR, slug)
|
|
||||||
if _, err := os.Stat(recyclePostPath); err == nil {
|
|
||||||
os.RemoveAll(recyclePostPath)
|
|
||||||
deleted = true
|
|
||||||
}
|
|
||||||
metaPath := filepath.Join(RECYCLE_META_DIR, slug+".json")
|
|
||||||
if _, err := os.Stat(metaPath); err == nil {
|
|
||||||
os.Remove(metaPath)
|
|
||||||
deleted = true
|
|
||||||
}
|
|
||||||
return deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
func CleanExpired() int {
|
|
||||||
items := ListRecycle()
|
|
||||||
count := 0
|
|
||||||
for _, item := range items {
|
|
||||||
if item.DaysRemaining <= 0 {
|
|
||||||
PermanentDelete(item.Slug)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func getString(m map[string]interface{}, key string) string {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
package rssapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"write-server/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Feed struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
FeedTitle string `json:"feedTitle"`
|
|
||||||
SiteURL string `json:"siteUrl,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FeedHealth struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Status int `json:"status"`
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Latency int `json:"latency,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Link struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Image string `json:"image,omitempty"`
|
|
||||||
Hidden bool `json:"hidden,omitempty"`
|
|
||||||
RSS string `json:"rss,omitempty"`
|
|
||||||
AddedAt string `json:"addedAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func rssFetch(path string, method string, body interface{}) (map[string]interface{}, error) {
|
|
||||||
u := config.RSS_API_BASE + path
|
|
||||||
var req *http.Request
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if body != nil && (method == "POST" || method == "PUT") {
|
|
||||||
data, _ := json.Marshal(body)
|
|
||||||
req, err = http.NewRequest(method, u, bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
} else {
|
|
||||||
req, err = http.NewRequest(method, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.RSS_API_TOKEN != "" {
|
|
||||||
q := req.URL.Query()
|
|
||||||
q.Set("token", config.RSS_API_TOKEN)
|
|
||||||
req.URL.RawQuery = q.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
|
||||||
return nil, fmt.Errorf("RSS API error: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFeeds() ([]Feed, error) {
|
|
||||||
result, err := rssFetch("/api/rss/feeds", "GET", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var feeds []Feed
|
|
||||||
if raw, ok := result["feeds"]; ok {
|
|
||||||
data, _ := json.Marshal(raw)
|
|
||||||
json.Unmarshal(data, &feeds)
|
|
||||||
}
|
|
||||||
return feeds, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddFeed(feedURL, feedTitle string) error {
|
|
||||||
_, err := rssFetch("/api/rss/feeds", "POST", map[string]string{
|
|
||||||
"url": feedURL,
|
|
||||||
"feedTitle": feedTitle,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteFeed(feedURL string) error {
|
|
||||||
_, err := rssFetch("/api/rss/feeds?url="+url.QueryEscape(feedURL), "DELETE", nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateFeed(oldURL, newURL, feedTitle string) error {
|
|
||||||
_, err := rssFetch("/api/rss/feeds", "PUT", map[string]string{
|
|
||||||
"oldUrl": oldURL,
|
|
||||||
"url": newURL,
|
|
||||||
"feedTitle": feedTitle,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLinks(all bool) ([]Link, error) {
|
|
||||||
path := "/api/rss/links"
|
|
||||||
if all {
|
|
||||||
path += "?all=1"
|
|
||||||
}
|
|
||||||
result, err := rssFetch(path, "GET", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var links []Link
|
|
||||||
if raw, ok := result["links"]; ok {
|
|
||||||
data, _ := json.Marshal(raw)
|
|
||||||
json.Unmarshal(data, &links)
|
|
||||||
}
|
|
||||||
return links, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddLink(link Link) error {
|
|
||||||
_, err := rssFetch("/api/rss/links", "POST", link)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateLink(oldURL string, data map[string]interface{}) error {
|
|
||||||
body := map[string]interface{}{"oldUrl": oldURL}
|
|
||||||
for k, v := range data {
|
|
||||||
if k != "oldUrl" {
|
|
||||||
body[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err := rssFetch("/api/rss/links", "PUT", body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteLink(linkURL string) error {
|
|
||||||
_, err := rssFetch("/api/rss/links?url="+url.QueryEscape(linkURL), "DELETE", nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckLinkHealth(targetURL string) (map[string]interface{}, error) {
|
|
||||||
return rssFetch("/api/rss/links?health="+url.QueryEscape(targetURL), "GET", nil)
|
|
||||||
}
|
|
||||||
@@ -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,145 +0,0 @@
|
|||||||
package tgcore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var API string
|
|
||||||
|
|
||||||
func Init(token string) {
|
|
||||||
API = "https://api.telegram.org/bot" + token
|
|
||||||
}
|
|
||||||
|
|
||||||
func TG(method string, body interface{}) (map[string]interface{}, error) {
|
|
||||||
jsonBody, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := http.Post(API+"/"+method, "application/json", bytes.NewReader(jsonBody))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Allowed(chatID int64, allowedIDs []int64) bool {
|
|
||||||
if len(allowedIDs) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, id := range allowedIDs {
|
|
||||||
if id == chatID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func SendMessage(chatID int64, text string, opts map[string]interface{}) error {
|
|
||||||
safeText := text
|
|
||||||
body := map[string]interface{}{
|
|
||||||
"chat_id": chatID,
|
|
||||||
"text": safeText,
|
|
||||||
"parse_mode": "HTML",
|
|
||||||
"disable_web_page_preview": true,
|
|
||||||
}
|
|
||||||
if opts != nil {
|
|
||||||
for k, v := range opts {
|
|
||||||
body[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := TG("sendMessage", body)
|
|
||||||
if err != nil && (strings.Contains(err.Error(), "parse") || strings.Contains(err.Error(), "HTML")) {
|
|
||||||
delete(body, "parse_mode")
|
|
||||||
_, err = TG("sendMessage", body)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TgHandler is the callback handler interface
|
|
||||||
type TgHandler interface {
|
|
||||||
List(chatID int64, page string)
|
|
||||||
Links(chatID int64, page string)
|
|
||||||
Feeds(chatID int64, page string)
|
|
||||||
// Session callbacks
|
|
||||||
SessionCallback(chatID int64, action string)
|
|
||||||
MenuCallback(chatID int64, action string)
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleCallback(chatID int64, queryID string, data string, h TgHandler) {
|
|
||||||
if data == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session callbacks
|
|
||||||
if strings.HasPrefix(data, "session_") {
|
|
||||||
action := strings.TrimPrefix(data, "session_")
|
|
||||||
if h != nil {
|
|
||||||
h.SessionCallback(chatID, action)
|
|
||||||
}
|
|
||||||
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu callbacks
|
|
||||||
if strings.HasPrefix(data, "menu_") {
|
|
||||||
action := strings.TrimPrefix(data, "menu_")
|
|
||||||
if h != nil {
|
|
||||||
h.MenuCallback(chatID, action)
|
|
||||||
}
|
|
||||||
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination callbacks: list_N, links_N, feeds_N
|
|
||||||
parts := strings.SplitN(data, "_", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
cmd := parts[0]
|
|
||||||
page := parts[1]
|
|
||||||
if h != nil {
|
|
||||||
switch cmd {
|
|
||||||
case "list":
|
|
||||||
h.List(chatID, page)
|
|
||||||
case "links":
|
|
||||||
h.Links(chatID, page)
|
|
||||||
case "feeds":
|
|
||||||
h.Feeds(chatID, page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetInt64(m map[string]interface{}, key string) int64 {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
switch n := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return int64(n)
|
|
||||||
case int64:
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetString(m map[string]interface{}, key string) string {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package tgcore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var API string
|
|
||||||
|
|
||||||
func Init(token string) {
|
|
||||||
API = "https://api.telegram.org/bot" + token
|
|
||||||
}
|
|
||||||
|
|
||||||
func TG(method string, body interface{}) (map[string]interface{}, error) {
|
|
||||||
jsonBody, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := http.Post(API+"/"+method, "application/json", bytes.NewReader(jsonBody))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Allowed(chatID int64, allowedIDs []int64) bool {
|
|
||||||
if len(allowedIDs) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, id := range allowedIDs {
|
|
||||||
if id == chatID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func SendMessage(chatID int64, text string, opts map[string]interface{}) error {
|
|
||||||
safeText := text
|
|
||||||
body := map[string]interface{}{
|
|
||||||
"chat_id": chatID,
|
|
||||||
"text": safeText,
|
|
||||||
"parse_mode": "HTML",
|
|
||||||
"disable_web_page_preview": true,
|
|
||||||
}
|
|
||||||
if opts != nil {
|
|
||||||
for k, v := range opts {
|
|
||||||
body[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := TG("sendMessage", body)
|
|
||||||
if err != nil && (strings.Contains(err.Error(), "parse") || strings.Contains(err.Error(), "HTML")) {
|
|
||||||
delete(body, "parse_mode")
|
|
||||||
_, err = TG("sendMessage", body)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TgHandler is the callback handler interface
|
|
||||||
type TgHandler interface {
|
|
||||||
List(chatID int64, page string)
|
|
||||||
Links(chatID int64, page string)
|
|
||||||
Feeds(chatID int64, page string)
|
|
||||||
// Session callbacks
|
|
||||||
SessionCallback(chatID int64, action string)
|
|
||||||
MenuCallback(chatID int64, action string)
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleCallback(chatID int64, queryID string, data string, h TgHandler) {
|
|
||||||
if data == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session callbacks
|
|
||||||
if strings.HasPrefix(data, "session_") {
|
|
||||||
action := strings.TrimPrefix(data, "session_")
|
|
||||||
if h != nil {
|
|
||||||
h.SessionCallback(chatID, action)
|
|
||||||
}
|
|
||||||
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu callbacks
|
|
||||||
if strings.HasPrefix(data, "menu_") {
|
|
||||||
action := strings.TrimPrefix(data, "menu_")
|
|
||||||
if h != nil {
|
|
||||||
h.MenuCallback(chatID, action)
|
|
||||||
}
|
|
||||||
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination callbacks: list_N, links_N, feeds_N
|
|
||||||
parts := strings.SplitN(data, "_", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
cmd := parts[0]
|
|
||||||
page := parts[1]
|
|
||||||
if h != nil {
|
|
||||||
switch cmd {
|
|
||||||
case "list":
|
|
||||||
h.List(chatID, page)
|
|
||||||
case "links":
|
|
||||||
h.Links(chatID, page)
|
|
||||||
case "feeds":
|
|
||||||
h.Feeds(chatID, page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TG("answerCallbackQuery", map[string]interface{}{"callback_query_id": queryID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetInt64(m map[string]interface{}, key string) int64 {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
switch n := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return int64(n)
|
|
||||||
case int64:
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetString(m map[string]interface{}, key string) string {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -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,80 +0,0 @@
|
|||||||
package wechat
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"write-server/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WechatStatus struct {
|
|
||||||
Configured bool `json:"configured"`
|
|
||||||
AppID string `json:"appId,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WechatArticle struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ContentSourceURL string `json:"content_source_url,omitempty"`
|
|
||||||
ThumbMediaURL string `json:"thumb_media_url,omitempty"`
|
|
||||||
ShowCoverPic int `json:"show_cover_pic"`
|
|
||||||
NeedOpenComment int `json:"need_open_comment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var imgRE = regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
|
|
||||||
|
|
||||||
func GetWechatStatus() WechatStatus {
|
|
||||||
return WechatStatus{
|
|
||||||
Configured: config.WECHAT_APP_ID != "" && config.WECHAT_APP_SECRET != "",
|
|
||||||
AppID: config.WECHAT_APP_ID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func MDRender(markdown, slug string) (string, []string) {
|
|
||||||
var imageURLs []string
|
|
||||||
matches := imgRE.FindAllStringSubmatch(markdown, -1)
|
|
||||||
for _, m := range matches {
|
|
||||||
if len(m) > 1 {
|
|
||||||
imageURLs = append(imageURLs, m[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html := strings.ReplaceAll(markdown, "\r\n", "\n")
|
|
||||||
|
|
||||||
h2RE := regexp.MustCompile(`(?m)^## (.+)$`)
|
|
||||||
html = h2RE.ReplaceAllString(html, `<h2>$1</h2>`)
|
|
||||||
|
|
||||||
boldRE := regexp.MustCompile(`\*\*(.+?)\*\*`)
|
|
||||||
html = boldRE.ReplaceAllString(html, `<strong>$1</strong>`)
|
|
||||||
|
|
||||||
html = imgRE.ReplaceAllString(html, `<img src="$1" />`)
|
|
||||||
|
|
||||||
linkRE := regexp.MustCompile(`\[(.+?)\]\((.+?)\)`)
|
|
||||||
html = linkRE.ReplaceAllString(html, `<a href="$2">$1</a>`)
|
|
||||||
|
|
||||||
html = `<div>` + html + `</div>`
|
|
||||||
|
|
||||||
return html, imageURLs
|
|
||||||
}
|
|
||||||
|
|
||||||
func SyncWechat(articles []WechatArticle) (map[string]interface{}, error) {
|
|
||||||
if config.SCF_PROXY == "" {
|
|
||||||
return nil, fmt.Errorf("SCF_PROXY not configured")
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(map[string]interface{}{"articles": articles})
|
|
||||||
resp, err := http.Post(config.SCF_PROXY+"/wechat/sync", "application/json", bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.Unmarshal(body, &result)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -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,2 +0,0 @@
|
|||||||
time=2026-06-01T16:58:10.099+08:00 level=INFO msg="WriteServer starting" svc=writeserver
|
|
||||||
[write-server] listening on 0.0.0.0:8016
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
2026/06/01 16:58:10 listen tcp 0.0.0.0:8016: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package posts
|
|
||||||
|
|
||||||
import "write-server/internal/posts"
|
|
||||||
|
|
||||||
type FrontMatter = posts.FrontMatter
|
|
||||||
type Post = posts.Post
|
|
||||||
type PostListItem = posts.PostListItem
|
|
||||||
|
|
||||||
var ListPosts = posts.ListPosts
|
|
||||||
var GetPost = posts.GetPost
|
|
||||||
var CreatePost = posts.CreatePost
|
|
||||||
var UpdatePost = posts.UpdatePost
|
|
||||||
var GetPostCount = posts.GetPostCount
|
|
||||||
var ParseFrontMatter = posts.ParseFrontMatter
|
|
||||||
|
|
||||||
func Init() {
|
|
||||||
posts.Init()
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package recycle
|
|
||||||
|
|
||||||
import "write-server/internal/recycle"
|
|
||||||
|
|
||||||
var InitRecycleBin = recycle.InitRecycleBin
|
|
||||||
var MoveToRecycle = recycle.MoveToRecycle
|
|
||||||
var ListRecycle = recycle.ListRecycle
|
|
||||||
var RestoreFromRecycle = recycle.RestoreFromRecycle
|
|
||||||
var PermanentDelete = recycle.PermanentDelete
|
|
||||||
var CleanExpired = recycle.CleanExpired
|
|
||||||
|
|
||||||
type RecycleItem = recycle.RecycleItem
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package rssapi
|
|
||||||
|
|
||||||
import "write-server/internal/rssapi"
|
|
||||||
|
|
||||||
type Feed = rssapi.Feed
|
|
||||||
type Link = rssapi.Link
|
|
||||||
|
|
||||||
var GetFeeds = rssapi.GetFeeds
|
|
||||||
var AddFeed = rssapi.AddFeed
|
|
||||||
var DeleteFeed = rssapi.DeleteFeed
|
|
||||||
var GetLinks = rssapi.GetLinks
|
|
||||||
var AddLink = rssapi.AddLink
|
|
||||||
var UpdateLink = rssapi.UpdateLink
|
|
||||||
var DeleteLink = rssapi.DeleteLink
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@echo off
|
|
||||||
title WriteServer
|
|
||||||
cd /d E:\GitHub\blog\write-go
|
|
||||||
echo [%date% %time%] WriteServer starting...
|
|
||||||
writeserver.exe
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
2026/06/01 16:55:31 http: panic serving [::1]:50574: runtime error: slice bounds out of range [:10] with length 0
|
|
||||||
goroutine 9 [running]:
|
|
||||||
net/http.(*conn).serve.func1()
|
|
||||||
C:/Program Files/Go/src/net/http/server.go:1907 +0xbd
|
|
||||||
panic({0x7ff6dda1c5c0?, 0x66fdd19e1b0?})
|
|
||||||
C:/Program Files/Go/src/runtime/panic.go:860 +0x13a
|
|
||||||
write-server/internal/posts.parseDate(...)
|
|
||||||
E:/GitHub/blog/write-go/internal/posts/posts.go:86
|
|
||||||
write-server/internal/posts.ListPosts.func1(0x58?, 0x0?)
|
|
||||||
E:/GitHub/blog/write-go/internal/posts/posts.go:268 +0xfa
|
|
||||||
sort.partition_func({0x66fdd19b690?, 0x66fdd320ab0?}, 0x0, 0x74, 0x0?)
|
|
||||||
C:/Program Files/Go/src/sort/zsortfunc.go:142 +0xa9
|
|
||||||
sort.pdqsort_func({0x66fdd19b690?, 0x66fdd320ab0?}, 0x18?, 0x7ff6dd9c5de0?, 0x2?)
|
|
||||||
C:/Program Files/Go/src/sort/zsortfunc.go:114 +0x23d
|
|
||||||
sort.Slice({0x7ff6dd9bb940?, 0x66fdd1c4030?}, 0x66fdd19b690)
|
|
||||||
C:/Program Files/Go/src/sort/slice.go:29 +0xc5
|
|
||||||
write-server/internal/posts.ListPosts({0x0, 0x0}, 0x1, 0x14, 0x1)
|
|
||||||
E:/GitHub/blog/write-go/internal/posts/posts.go:267 +0x869
|
|
||||||
main.handlePosts({0x7ff6dda6cc90, 0x66fdd1b4000}, 0x66fdd192000)
|
|
||||||
E:/GitHub/blog/write-go/cmd/writeserver/main.go:159 +0x415
|
|
||||||
net/http.HandlerFunc.ServeHTTP(0x66fdd05a3c0?, {0x7ff6dda6cc90?, 0x66fdd1b4000?}, 0x7ff6dda4da40?)
|
|
||||||
C:/Program Files/Go/src/net/http/server.go:2286 +0x29
|
|
||||||
net/http.(*ServeMux).ServeHTTP(0x66fdd1820f0?, {0x7ff6dda6cc90, 0x66fdd1b4000}, 0x66fdd192000)
|
|
||||||
C:/Program Files/Go/src/net/http/server.go:2828 +0x1c7
|
|
||||||
main.main.withCORS.func1({0x7ff6dda6cc90, 0x66fdd1b4000}, 0x66fdd192000)
|
|
||||||
E:/GitHub/blog/write-go/cmd/writeserver/main.go:84 +0x10d
|
|
||||||
net/http.HandlerFunc.ServeHTTP(0x7ff6dd6cced9?, {0x7ff6dda6cc90?, 0x66fdd1b4000?}, 0x66fdd19bb68?)
|
|
||||||
C:/Program Files/Go/src/net/http/server.go:2286 +0x29
|
|
||||||
net/http.serverHandler.ServeHTTP({0x66fdd188000?}, {0x7ff6dda6cc90?, 0x66fdd1b4000?}, 0x1?)
|
|
||||||
C:/Program Files/Go/src/net/http/server.go:3311 +0x8e
|
|
||||||
net/http.(*conn).serve(0x66fdd078360, {0x7ff6dda6d3f0, 0x66fdcfa58f0})
|
|
||||||
C:/Program Files/Go/src/net/http/server.go:2073 +0x650
|
|
||||||
created by net/http.(*Server).Serve in goroutine 1
|
|
||||||
C:/Program Files/Go/src/net/http/server.go:3464 +0x485
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
time=2026-06-01T16:55:29.042+08:00 level=INFO msg="WriteServer starting" svc=writeserver
|
|
||||||
[write-server] listening on 0.0.0.0:8016
|
|
||||||
time=2026-06-01T16:55:31.055+08:00 level=INFO msg="bot-go already running" svc=writeserver port=8017
|
|
||||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user