snapshot before merge

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

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
// 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 = `<!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('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=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>`.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 };
+21
View File
@@ -0,0 +1,21 @@
// config.js - Environment and constants
const { existsSync, readFileSync } = require("fs");
const { resolve } = require("path");
const envPath = resolve(__dirname, ".env");
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}
}
module.exports = {
TG_TOKEN: process.env.TG_BOT_TOKEN || "",
ALLOWED_IDS: (process.env.TG_ALLOWED_CHAT_IDS || "")
.split(",").map((s) => s.trim()).filter(Boolean).map(Number),
WRITE_PORT: parseInt(process.env.WRITE_PORT || "8016", 10),
CONTROL_PORT: parseInt(process.env.BOT_PORT || "8017", 10),
PREFER_IFACE: process.env.PREFER_IFACE || "WLAN",
WRITE_DIR: resolve(__dirname, "..", "write"),
};
+76
View File
@@ -0,0 +1,76 @@
// helpers.js - Utility functions
const os = require("os");
const net = require("net");
const config = require("./config");
function getLanIP() {
const now = Date.now();
if (getLanIP._cache && getLanIP._cache.ip && now - getLanIP._cache.ts < 30000)
return getLanIP._cache.ip;
let ip = "0.0.0.0";
try {
const ifs = os.networkInterfaces();
const candidates = [];
for (const [name, addrs] of Object.entries(ifs)) {
if (!addrs) continue;
for (const a of addrs) {
if (a.family !== "IPv4" || a.internal) continue;
if (a.address.startsWith("169.254.")) continue;
candidates.push({ name, address: a.address });
}
}
const pref = candidates.find((c) =>
c.name.toLowerCase().includes(config.PREFER_IFACE.toLowerCase())
);
if (pref) ip = pref.address;
else {
const real = candidates.find((c) => {
const n = c.name.toLowerCase();
return !n.includes("vmware") && !n.includes("virtual") && !n.includes("vethernet");
});
if (real) ip = real.address;
else if (candidates[0]) ip = candidates[0].address;
}
} catch {}
getLanIP._cache = { ip, ts: now };
return ip;
}
function checkPort(port) {
return new Promise((resolve) => {
const s = net.createConnection(port, "127.0.0.1", () => { s.destroy(); resolve(true); });
s.on("error", () => { s.destroy(); resolve(false); });
s.setTimeout(1500, () => { s.destroy(); resolve(false); });
});
}
async function waitForReady(port, timeoutMs) {
const start = Date.now();
while (Date.now() - start < (timeoutMs || 30000)) {
if (await checkPort(port)) return true;
await new Promise((r) => setTimeout(r, 1500));
}
return false;
}
function makeSlug(title) {
const dateStr = new Date().toISOString().slice(0, 10);
const slugPart = (title || "untitled")
.replace(/[^\w\u4e00-\u9fff]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase() || "post";
return dateStr + "-" + slugPart;
}
function sessionSummary(s) {
const lines = s.content ? s.content.split("\\n").filter(Boolean).length : 0;
const chars = s.content.length;
return [
"\\uD83D\\uDCDD <b>" + (s.title || "\\u672A\\u547D\\u540D") + "</b>",
s.categories.length ? "\\uD83D\\uDCC2 " + s.categories.join("\\u3001") : "",
s.tags.length ? "\\uD83C\\uDFF7 " + s.tags.join("\\u3001") : "",
chars ? "\\u270D " + lines + " \\u884C " + chars + " \\u5B57" : "",
].filter(Boolean).join(" \\u00B7 ");
}
module.exports = { getLanIP, checkPort, waitForReady, makeSlug, sessionSummary };
+59
View File
@@ -0,0 +1,59 @@
// sessions.js - Per-chat writing sessions
const config = require("./config");
const { makeSlug } = require("./helpers");
const sessions = new Map();
function getSession(chatId) {
if (!sessions.has(chatId)) {
sessions.set(chatId, {
title: "",
slug: "",
content: "",
categories: [],
tags: [],
author: "qunlin",
draft: true,
});
}
return sessions.get(chatId);
}
function clearSession(chatId) {
sessions.delete(chatId);
}
// Save session via Write API
async function saveViaAPI(s, isDraft) {
const body = JSON.stringify({
frontMatter: {
title: s.title,
slug: s.slug,
date: new Date().toISOString().slice(0, 10),
draft: isDraft,
categories: s.categories,
tags: s.tags,
author: s.author,
},
content: s.content,
});
const checkRes = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts/" + s.slug);
if (checkRes.ok) {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts/" + s.slug, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
});
if (!res.ok) throw new Error((await res.json()).error || "API error (PUT)");
} else {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
if (!res.ok) throw new Error((await res.json()).error || "API error (POST)");
}
}
module.exports = { sessions, getSession, clearSession, saveViaAPI };
+54
View File
@@ -0,0 +1,54 @@
// tg-core.js - Telegram API helpers
const config = require("./config");
const API = config.TG_TOKEN ? "https://api.telegram.org/bot" + config.TG_TOKEN : "";
async function tg(method, body) {
const res = await fetch(API + "/" + method, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
}
function allowed(chatId) {
if (config.ALLOWED_IDS.length === 0) return true;
return config.ALLOWED_IDS.includes(chatId);
}
async function sendMessage(chatId, text, opts) {
const safeText = String(text || "");
try {
return await tg("sendMessage", Object.assign(
{ chat_id: chatId, text: safeText, parse_mode: "HTML", disable_web_page_preview: true },
opts || {}
));
} catch (e) {
if (e.message && (e.message.includes("parse") || e.message.includes("HTML"))) {
return await tg("sendMessage", Object.assign(
{ chat_id: chatId, text: safeText },
opts || {}
));
}
throw e;
}
}
async function handleCallback(chatId, queryId, data, handlers) {
if (!data) return;
const parts = data.split("_");
const cmd = parts[0];
const page = parseInt(parts[1]) || 1;
if (cmd === "list" && handlers.list) {
await handlers.list(chatId, String(page));
} else if (cmd === "links" && handlers.links) {
await handlers.links(chatId, String(page));
} else if (cmd === "feeds" && handlers.feeds) {
await handlers.feeds(chatId, String(page));
}
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch {}
}
module.exports = { tg, allowed, sendMessage, handleCallback };
+413
View File
@@ -0,0 +1,413 @@
// tg-handlers.js - All Telegram /command handlers
const config = require("./config");
const { getLanIP, makeSlug, sessionSummary } = require("./helpers");
const { getSession, clearSession, sessions, saveViaAPI } = require("./sessions");
const { getWriteStatus, ensureWriteReady } = require("./write-server");
const { sendMessage } = require("./tg-core");
const CMD_HANDLERS = {};
// ---------- System ----------
CMD_HANDLERS.start = async function (chatId) {
const ip = getLanIP();
const ready = await ensureWriteReady(chatId, sendMessage);
if (ready) {
return sendMessage(chatId,
"\\u2705 <b>Write \\u5DF2\\u542F\\u52A8</b>\\n\\n" +
"\\uD83D\\uDCF1 \\u624B\\u673A: http://" + ip + ":8016/\\n" +
"\\uD83D\\uDCDD \\u6587\\u7AE0: http://" + ip + ":1313/posts/\\n" +
"\\uD83D\\uDCBB \\u7535\\u8111: http://localhost:8016/\\n\\n" +
"/help \\u67E5\\u770B\\u5168\\u90E8\\u547D\\u4EE4"
);
}
return sendMessage(chatId, "\\u274C \\u542F\\u52A8\\u8D85\\u65F6\\uFF0C\\u67E5\\u770B\\u65E5\\u5FD7 http://127.0.0.1:" + config.CONTROL_PORT + "/log");
};
CMD_HANDLERS.stop = async function (chatId) {
const { stopWriteServer } = require("./write-server");
if (stopWriteServer()) {
return sendMessage(chatId, "\\u26A1 Write stopped.");
}
return sendMessage(chatId, "\\u23F8 Write is not running.");
};
CMD_HANDLERS.status = async function (chatId) {
const s = getWriteStatus();
const status = s.running ? "\\u2705 Running" : s.starting ? "\\u23F3 Starting..." : "\\u23F8 Stopped";
const lines = ["Status: " + status];
if (s.running) {
const ip = getLanIP();
lines.push("Local: http://localhost:" + config.WRITE_PORT);
lines.push("Phone: http://" + ip + ":" + config.WRITE_PORT);
}
return sendMessage(chatId, lines.join("\\n"));
};
CMD_HANDLERS.ip = async function (chatId) {
const ip = getLanIP();
return sendMessage(chatId, "Phone: http://" + ip + ":" + config.WRITE_PORT + " (" + config.PREFER_IFACE + ")");
};
CMD_HANDLERS.help = async function (chatId) {
if (sessions.has(chatId)) {
return sendMessage(chatId,
"\\u270D \\u6B63\\u5728\\u5199\\u4F5C\\u4E2D\\n\\n" +
"\\u26A1 /publish \\u53D1\\u5E03 \\uD83D\\uDCC4 /draft \\u8349\\u7A3F \\uD83D\\uDD0D /preview \\u9884\\u89C8\\n" +
"\\u270F /title /categories /tags /author\\n" +
"\\u274C /cancel \\u4E22\\u5F03"
);
}
return sendMessage(chatId,
"\\uD83E\\uDD16 <b>WriteBot \\u547D\\u4EE4</b>\\n\\n" +
"\\uD83D\\uDCDD \\u5199\\u4F5C:\\n" +
" /new \\u6807\\u9898 - \\u5F00\\u59CB\\u5199\\u4E00\\u7BC7\\n" +
" /edit slug - \\u7F16\\u8F91\\u5DF2\\u6709\\u6587\\u7AE0\\n" +
" /delete slug - \\u5220\\u9664\\u6587\\u7AE0\\n" +
" /list - \\u6587\\u7AE0\\u5217\\u8868\\n\\n" +
"\\uD83D\\uDD17 \\u53CB\\u94FE:\\n" +
" /links - \\u67E5\\u770B\\u53CB\\u94FE\\n" +
" /link_add url name - \\u6DFB\\u52A0\\u53CB\\u94FE\\n" +
" /link_del url - \\u5220\\u9664\\u53CB\\u94FE\\n" +
" /link_toggle url - \\u663E\\u793A/\\u9690\\u85CF\\n\\n" +
"\\uD83D\\uDCE1 \\u8BA2\\u9605:\\n" +
" /feeds - \\u67E5\\u770B\\u8BA2\\u9605\\u6E90\\n" +
" /feed_add url - \\u6DFB\\u52A0\\u8BA2\\u9605\\u6E90\\n" +
" /feed_del url - \\u5220\\u9664\\u8BA2\\u9605\\u6E90\\n\\n" +
"\\uD83D\\uDCCA /stats \\u7F51\\u7AD9\\u6570\\u636E"
);
};
// ---------- Articles ----------
CMD_HANDLERS.new = async function (chatId, title) {
if (!title) return sendMessage(chatId, "\\u683C\\u5F0F\\uFF1A\\u5199\\u4E00\\u7BC7 \\u6807\\u9898");
const s = getSession(chatId);
s.title = title;
s.slug = makeSlug(title);
s.content = "";
s.categories = [];
s.tags = [];
s.draft = true;
s.author = "qunlin";
return sendMessage(chatId,
"\\uD83D\\uDCDD \\u5F00\\u59CB\\u5199\\uFF1A<b>" + title + "</b>\\n\\n" +
"\\u76F4\\u63A5\\u53D1\\u6D88\\u606F\\u5199\\u4F5C\\uFF0C\\u5B8C\\u6210\\u540E /publish\\n" +
"\\u968F\\u65F6 /preview \\u67E5\\u770B\\uFF0C/help \\u5168\\u90E8\\u547D\\u4EE4"
);
};
CMD_HANDLERS.edit = async function (chatId, slug) {
if (!slug) return sendMessage(chatId, "\\u683C\\u5F0F\\uFF1A/edit slug\\n\\u5148\\u7528 /list \\u67E5\\u770B\\u6587\\u7AE0 slug\\u3002");
try {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts/" + slug);
if (!res.ok) throw new Error("\\u672A\\u627E\\u5230\\u8BE5\\u6587\\u7AE0");
const post = await res.json();
const s = getSession(chatId);
s.title = post.frontMatter.title;
s.slug = post.frontMatter.slug;
s.content = post.content || "";
s.categories = post.frontMatter.categories || [];
s.tags = post.frontMatter.tags || [];
s.author = post.frontMatter.author || "qunlin";
s.draft = post.frontMatter.draft;
return sendMessage(chatId, "\\u270F \\u7F16\\u8F91\\uFF1A<b>" + s.title + "</b>\\n\\n" + sessionSummary(s) + "\\n\\n\\u76F4\\u63A5\\u53D1\\u6D88\\u606F\\u8FFD\\u52A0\\u5185\\u5BB9\\uFF0C\\u5199\\u5B8C\\u53D1\\u300C\\u53D1\\u5E03\\u300D\\u3002");
} catch (e) {
return sendMessage(chatId, "\\u274C " + e.message);
}
};
CMD_HANDLERS.delete = async function (chatId, slug) {
if (!slug) return sendMessage(chatId, "Usage: /delete <slug>\\nFind slugs with /list\\n\\nArticles are moved to recycle bin (30-day retention).");
try {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts/" + slug, { method: "DELETE" });
if (!res.ok) throw new Error((await res.json()).error || "Delete failed");
return sendMessage(chatId, "\\u2702 Moved to recycle bin: " + slug + "\\nRecoverable within 30 days.");
} catch (e) {
return sendMessage(chatId, "\\u274C " + e.message);
}
};
CMD_HANDLERS.view = async function (chatId, slug) {
if (!slug) return sendMessage(chatId, "Usage: /view <slug>\\nFind slugs with /list");
const ip = getLanIP();
const url = "http://" + ip + ":1313/" + slug + ".html";
return sendMessage(chatId, "\\uD83D\\uDCC9 <b>Preview</b>\\n\\n" + url + "\\n\\n\\uD83D\\uDCBB http://localhost:1313/" + slug + ".html");
};
CMD_HANDLERS.title = async function (chatId, newTitle) {
const s = getSession(chatId);
if (!newTitle) return sendMessage(chatId, "\\u683C\\u5F0F\\uFF1A\\u6807\\u9898 \\u65B0\\u6807\\u9898");
s.title = newTitle;
s.slug = makeSlug(newTitle);
return sendMessage(chatId, "\\u270F \\u6807\\u9898\\u5DF2\\u66F4\\u65B0\\uFF1A<b>" + newTitle + "</b>");
};
CMD_HANDLERS.categories = async function (chatId, cats) {
const s = getSession(chatId);
s.categories = cats ? cats.split(/[,;\"\\",\\"\\uFF0C\\u3001]+/).map(function(c){return c.trim();}).filter(Boolean) : [];
return sendMessage(chatId, "\\uD83D\\uDCC2 \\u5206\\u7C7B\\uFF1A" + (s.categories.join("\\u3001") || "\\u5DF2\\u6E05\\u7A7A"));
};
CMD_HANDLERS.tags = async function (chatId, tags) {
const s = getSession(chatId);
s.tags = tags ? tags.split(/[,;\"\\",\\"\\uFF0C\\u3001]+/).map(function(t){return t.trim();}).filter(Boolean) : [];
return sendMessage(chatId, "\\uD83C\\uDFF7 \\u6807\\u7B7E\\uFF1A" + (s.tags.join("\\u3001") || "\\u5DF2\\u6E05\\u7A7A"));
};
CMD_HANDLERS.author = async function (chatId, author) {
const s = getSession(chatId);
s.author = author || "qunlin";
return sendMessage(chatId, "\\u270D \\u4F5C\\u8005\\uFF1A" + s.author);
};
CMD_HANDLERS.preview = async function (chatId) {
const s = sessions.get(chatId);
if (!s || !s.title) return sendMessage(chatId, "\\u8FD8\\u6CA1\\u5F00\\u59CB\\u5199\\u3002\\u53D1\\u300C\\u5199\\u4E00\\u7BC7\\u300D\\u5F00\\u59CB\\u3002");
if (!s.content) return sendMessage(chatId, "\\u6B63\\u6587\\u4E3A\\u7A7A\\u3002");
const maxLen = 3800;
const preview = s.content.slice(0, maxLen);
return sendMessage(chatId, "\\uD83D\\uDCDD <b>" + s.title + "</b>\\n\\n" + preview + (s.content.length > maxLen ? "\\n\\n... \\u524D " + maxLen + " \\u5B57" : ""));
};
CMD_HANDLERS.draft = async function (chatId) {
const s = sessions.get(chatId);
if (!s || !s.title) return sendMessage(chatId, "\\u8FD8\\u6CA1\\u5F00\\u59CB\\u5199\\u3002\\u53D1\\u300C\\u5199\\u4E00\\u7BC7\\u300D\\u5F00\\u59CB\\u3002");
if (!s.content) return sendMessage(chatId, "\\u6B63\\u6587\\u4E3A\\u7A7A\\uFF0C\\u5148\\u5199\\u70B9\\u5185\\u5BB9\\u518D\\u4FDD\\u5B58\\u3002");
try {
await saveViaAPI(s, true);
clearSession(chatId);
return sendMessage(chatId, "\\uD83D\\uDCC4 \\u8349\\u7A3F\\u5DF2\\u4FDD\\u5B58\\uFF1A<b>" + s.title + "</b>");
} catch (e) {
return sendMessage(chatId, "\\u274C \\u4FDD\\u5B58\\u5931\\u8D25\\uFF1A" + e.message + "\\n\\n\\u8BF7\\u5148 /start \\u542F\\u52A8\\u540E\\u53F0\\u670D\\u52A1\\u3002");
}
};
CMD_HANDLERS.publish = async function (chatId) {
const s = sessions.get(chatId);
if (!s || !s.title) return sendMessage(chatId, "\\u8FD8\\u6CA1\\u5F00\\u59CB\\u5199\\u3002\\u53D1\\u300C\\u5199\\u4E00\\u7BC7\\u300D\\u5F00\\u59CB\\u3002");
if (!s.content) return sendMessage(chatId, "\\u6B63\\u6587\\u4E3A\\u7A7A\\uFF0C\\u5148\\u5199\\u70B9\\u5185\\u5BB9\\u518D\\u53D1\\u5E03\\u3002");
try {
await saveViaAPI(s, false);
const slug = s.slug;
clearSession(chatId);
const ip = getLanIP();
return sendMessage(chatId, "\\u2705 \\u5DF2\\u53D1\\u5E03\\uFF01\\n\\n\\uD83D\\uDCDD <b>" + s.title + "</b>\\n\\u9884\\u89C8\\uFF1Ahttp://localhost:1313/" + slug + ".html\\n\\u7EBF\\u4E0A\\uFF1Ahttps://usj.cc/" + slug + ".html");
} catch (e) {
return sendMessage(chatId, "\\u274C \\u53D1\\u5E03\\u5931\\u8D25\\uFF1A" + e.message + "\\n\\n\\u8BF7\\u5148 /start \\u542F\\u52A8\\u540E\\u53F0\\u670D\\u52A1\\u3002");
}
};
CMD_HANDLERS.cancel = async function (chatId) {
const s = sessions.get(chatId);
if (!s || !s.title) return sendMessage(chatId, "\\u6CA1\\u6709\\u6D3B\\u52A8\\u7684\\u8349\\u7A3F\\u3002");
clearSession(chatId);
return sendMessage(chatId, "\\u2702 \\u8349\\u7A3F\\u5DF2\\u4E22\\u5F03\\u3002");
};
CMD_HANDLERS.list = async function (chatId, pageStr) {
const ip = getLanIP();
return sendMessage(chatId,
"\\uD83D\\uDCD1 <b>\\u6587\\u7AE0\\u5217\\u8868</b>\\n\\n" +
"\\uD83D\\uDCF1 \\u624B\\u673A\\u6253\\u5F00: http://" + ip + ":1313/posts/\\n" +
"\\uD83D\\uDCBB \\u7535\\u8111\\u6253\\u5F00: http://localhost:1313/posts/\\n\\n" +
"\\u2705 \\u65B0\\u5EFA: /new \\u300A\\u6807\\u9898\\u300B\\n" +
"\\uD83D\\uDCD1 \\u7F16\\u8F91: /edit slug\\n" +
"\\uD83D\\uDCC4 \\u8349\\u7A3F: /draft\\n" +
"\\uD83D\\uDE80 \\u53D1\\u5E03: /publish"
);
};
// ---------- Links (友链) ----------
CMD_HANDLERS.links = async function (chatId, pageStr) {
const page = parseInt(pageStr) || 1;
const perPage = 10;
try {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links?all=1");
const data = await res.json();
const allLinks = data.links || [];
if (allLinks.length === 0) return sendMessage(chatId, "No links found.");
const totalPages = Math.ceil(allLinks.length / perPage);
const p = page > totalPages ? totalPages : page;
const start = (p - 1) * perPage;
const pageLinks = allLinks.slice(start, start + perPage);
const lines = ["\\uD83D\\uDD17 <b>\\u53CB\\u94FE</b> \\u5171" + allLinks.length + " \\u4E2A \\u00B7 \\u7B2C" + p + "/" + totalPages + " \\u9875"];
pageLinks.forEach(function(l) {
const icon = l.hidden ? "\\uD83D\\uDD12" : "\\uD83C\\uDF10";
const name = (l.name || l.url).slice(0, 40);
lines.push(icon + " " + name + " " + l.url);
});
const keyboard = { inline_keyboard: [] };
const navRow = [];
if (p > 1) navRow.push({ text: "\\u25C0 \\u4E0A\\u4E00\\u9875", callback_data: "links_" + (p - 1) });
if (p < totalPages) navRow.push({ text: "\\u4E0B\\u4E00\\u9875 \\u25B6", callback_data: "links_" + (p + 1) });
if (navRow.length) keyboard.inline_keyboard.push(navRow);
const payload = {
chat_id: chatId,
text: lines.join("\\n"),
parse_mode: "HTML",
reply_markup: navRow.length ? keyboard : undefined,
disable_web_page_preview: true,
};
const { tg } = require("./tg-core");
return tg("sendMessage", payload);
} catch (e) {
return sendMessage(chatId, "Please /start Write first.");
}
};
CMD_HANDLERS.link_add = async function (chatId, arg) {
if (!arg) return sendMessage(chatId, "Usage: /link_add <url> <name>\\nExample: /link_add https://example.com My Blog");
const parts = arg.split(/\\s+/);
let url = parts[0];
const name = parts.slice(1).join(" ") || url;
if (!url.startsWith("http")) url = "https://" + url;
try {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, name }),
});
if (!res.ok) throw new Error((await res.json()).error || "Add failed");
return sendMessage(chatId, "\\u2705 Link added: " + name + "\\n" + url);
} catch (e) {
return sendMessage(chatId, "\\u274C " + e.message);
}
};
CMD_HANDLERS.link_del = async function (chatId, url) {
if (!url) return sendMessage(chatId, "Usage: /link_del <url>\\nUse /links to find the URL.");
try {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links?url=" + encodeURIComponent(url), {
method: "DELETE",
});
if (!res.ok) throw new Error((await res.json()).error || "Delete failed");
return sendMessage(chatId, "\\u2705 Link deleted: " + url);
} catch (e) {
return sendMessage(chatId, "\\u274C " + e.message);
}
};
CMD_HANDLERS.link_toggle = async function (chatId, url) {
if (!url) return sendMessage(chatId, "Usage: /link_toggle <url>\\nToggle visibility of a link.");
try {
const listRes = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links?all=1");
const listData = await listRes.json();
const link = (listData.links || []).find(function(l) { return l.url === url; });
if (!link) return sendMessage(chatId, "\\u274C Link not found: " + url);
const newHidden = !link.hidden;
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oldUrl: url, hidden: newHidden }),
});
if (!res.ok) throw new Error((await res.json()).error || "Toggle failed");
const label = newHidden ? "\\uD83D\\uDD12 Hidden" : "\\uD83C\\uDF10 Visible";
return sendMessage(chatId, label + ": " + url);
} catch (e) {
return sendMessage(chatId, "\\u274C " + e.message);
}
};
// ---------- Feeds (订阅) ----------
CMD_HANDLERS.feeds = async function (chatId, pageStr) {
const page = parseInt(pageStr) || 1;
const perPage = 10;
try {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/feeds");
const data = await res.json();
const allFeeds = data.feeds || [];
if (allFeeds.length === 0) return sendMessage(chatId, "No feeds configured.");
const totalPages = Math.ceil(allFeeds.length / perPage);
const p = page > totalPages ? totalPages : page;
const start = (p - 1) * perPage;
const pageFeeds = allFeeds.slice(start, start + perPage);
const lines = ["\\uD83D\\uDCE1 <b>\\u8BA2\\u9605\\u6E90</b> \\u5171" + allFeeds.length + " \\u4E2A \\u00B7 \\u7B2C" + p + "/" + totalPages + " \\u9875"];
pageFeeds.forEach(function(f) {
const title = (f.feedTitle || f.url).slice(0, 50);
lines.push("\\uD83D\\uDCDC " + title + "\\n " + f.url);
});
const keyboard = { inline_keyboard: [] };
const navRow = [];
if (p > 1) navRow.push({ text: "\\u25C0 \\u4E0A\\u4E00\\u9875", callback_data: "feeds_" + (p - 1) });
if (p < totalPages) navRow.push({ text: "\\u4E0B\\u4E00\\u9875 \\u25B6", callback_data: "feeds_" + (p + 1) });
if (navRow.length) keyboard.inline_keyboard.push(navRow);
const { tg } = require("./tg-core");
return tg("sendMessage", {
chat_id: chatId,
text: lines.join("\\n"),
parse_mode: "HTML",
reply_markup: navRow.length ? keyboard : undefined,
disable_web_page_preview: true,
});
} catch (e) {
return sendMessage(chatId, "Please /start Write first.");
}
};
CMD_HANDLERS.feed_add = async function (chatId, arg) {
if (!arg) return sendMessage(chatId, "Usage: /feed_add <url>\\nExample: /feed_add https://example.com/rss.xml");
const parts = arg.split(/\\s+/);
const url = parts[0];
const feedTitle = parts.slice(1).join(" ") || "";
try {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/feeds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, feedTitle }),
});
if (!res.ok) throw new Error((await res.json()).error || "Add failed");
return sendMessage(chatId, "\\u2705 Feed added: " + url);
} catch (e) {
return sendMessage(chatId, "\\u274C " + e.message);
}
};
CMD_HANDLERS.feed_del = async function (chatId, url) {
if (!url) return sendMessage(chatId, "Usage: /feed_del <url>\\nUse /feeds to find the URL.");
try {
const res = await fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/feeds?url=" + encodeURIComponent(url), {
method: "DELETE",
});
if (!res.ok) throw new Error((await res.json()).error || "Delete failed");
return sendMessage(chatId, "\\u2705 Feed deleted: " + url);
} catch (e) {
return sendMessage(chatId, "\\u274C " + e.message);
}
};
// ---------- Stats ----------
CMD_HANDLERS.stats = async function (chatId) {
try {
const [resPosts, resLinks, resFeeds] = await Promise.all([
fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/posts?perPage=1"),
fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/links?all=1"),
fetch("http://127.0.0.1:" + config.WRITE_PORT + "/api/rss/feeds"),
]);
const postsData = await resPosts.json();
const linksData = await resLinks.json();
const feedsData = await resFeeds.json();
const totalPosts = postsData.totalCount || postsData.total || 0;
const totalLinks = (linksData.links || []).filter(function(l) { return !l.hidden; }).length;
const totalFeeds = (feedsData.feeds || []).length;
return sendMessage(chatId,
"\\uD83D\\uDCCA <b>\\u7F51\\u7AD9\\u6570\\u636E</b>\\n\\n" +
"\\uD83D\\uDCDD \\u6587\\u7AE0\\uFF1A" + totalPosts + " \\u7BC7\\n" +
"\\uD83D\\uDD17 \\u53CB\\u94FE\\uFF1A" + totalLinks + " \\u4E2A\\n" +
"\\uD83D\\uDCE1 \\u8BA2\\u9605\\u6E90\\uFF1A" + totalFeeds + " \\u4E2A\\n\\n" +
"\\uD83C\\uDF10 usj.cc"
);
} catch (e) {
return sendMessage(chatId, "\\u8BF7\\u5148 /start \\u542F\\u52A8\\u540E\\u53F0\\u670D\\u52A1\\u3002");
}
};
module.exports = CMD_HANDLERS;
+103
View File
@@ -0,0 +1,103 @@
// tg-poll.js - Telegram polling loop and message dispatch
const config = require("./config");
const { tg, allowed, sendMessage, handleCallback } = require("./tg-core");
const { sessions, getSession } = require("./sessions");
const CMD_HANDLERS = require("./tg-handlers");
let lastOffset = 0;
// Commands that accept an argument
const ARG_COMMANDS = new Set([
"new", "title", "categories", "tags", "author",
"edit", "view", "delete",
"link_add", "link_del", "link_toggle",
"feed_add", "feed_del",
"list", "links", "feeds",
]);
async function tgPoll() {
if (!config.TG_TOKEN) return;
try {
const data = await tg("getUpdates", {
offset: lastOffset + 1,
timeout: 30,
allowed_updates: ["message", "callback_query"],
});
if (data.ok && data.result) {
for (const update of data.result) {
lastOffset = update.update_id;
// Handle callback queries (inline buttons)
if (update.callback_query) {
const cb = update.callback_query;
const cbChatId = cb.message ? cb.message.chat.id : (cb.from ? cb.from.id : 0);
handleCallback(cbChatId, cb.id, cb.data, CMD_HANDLERS);
continue;
}
const msg = update.message;
const chatId = msg.chat.id;
if (!allowed(chatId)) continue;
// Handle photo in writing session - append as Markdown image
if (msg.photo && sessions.has(chatId)) {
try {
const s = getSession(chatId);
const largestPhoto = msg.photo[msg.photo.length - 1];
const fileRes = await tg("getFile", { file_id: largestPhoto.file_id });
if (fileRes.ok) {
const filePath = fileRes.result.file_path;
const caption = msg.caption || "";
const imgUrl = "https://api.telegram.org/file/bot" + config.TG_TOKEN + "/" + filePath;
s.content += "\\n![" + (caption || "image") + "](" + imgUrl + ")\\n";
}
} catch (e) {
console.error("[photo]", e.message);
}
continue;
}
if (!msg?.text) continue;
const fullText = msg.text ? msg.text.trim() : (msg.caption || "").trim();
const parts = fullText.split("@")[0].split(/\\s+/);
const slashCmd = parts[0].toLowerCase().slice(1);
const slashArg = parts.slice(1).join(" ");
const hasSession = sessions.has(chatId);
// 1) If in writing session and NOT a slash command, append text silently
if (hasSession && !fullText.startsWith("/")) {
try {
const s = getSession(chatId);
s.content += fullText + "\\n";
} catch (e) {
console.error("[append]", e.message);
}
continue;
}
// 2) Slash commands - each wrapped in try/catch
try {
if (ARG_COMMANDS.has(slashCmd)) {
const handler = CMD_HANDLERS[slashCmd];
if (handler) await handler(chatId, slashArg);
else if (slashCmd) await sendMessage(chatId, "\\u274C Unknown: /" + slashCmd);
} else {
const handler = CMD_HANDLERS[slashCmd];
if (handler) await handler(chatId);
else if (slashCmd) await sendMessage(chatId, "\\u274C Unknown: /" + slashCmd);
}
} catch (e) {
console.error("[handler " + slashCmd + "]", e.message);
try { await sendMessage(chatId, "\\u274C " + (e.message || "error").slice(0, 200)); } catch {}
}
}
}
} catch (e) {
console.error("[tg poll]", e.message);
await new Promise((r) => setTimeout(r, 3000));
}
setImmediate(tgPoll);
}
module.exports = { tgPoll };
+118
View File
@@ -0,0 +1,118 @@
// write-server.js - Manage the Write (Next.js) and Hugo processes
const { spawn, execSync } = require("child_process");
const config = require("./config");
const { waitForReady } = require("./helpers");
let writeProcess = null;
let writeReady = false;
let hugoProcess = null;
function startWriteServer() {
if (writeProcess) {
return { started: false, reason: writeReady ? "already_running" : "starting" };
}
writeReady = false;
const isWin = process.platform === "win32";
writeProcess = spawn(
isWin ? "npm.cmd" : "npm",
["run", "dev", "--", "-p", String(config.WRITE_PORT), "-H", "0.0.0.0"],
{ cwd: config.WRITE_DIR, shell: isWin, stdio: "pipe", windowsHide: true }
);
writeProcess.stdout?.on("data", (d) => process.stdout.write(d));
writeProcess.stderr?.on("data", (d) => process.stderr.write(d));
writeProcess.on("exit", (code) => {
console.log("[write] exited " + code);
writeProcess = null;
writeReady = false;
});
return { started: true };
}
function stopWriteServer() {
if (hugoProcess) {
try {
const hpid = hugoProcess.pid;
if (process.platform === "win32" && hpid) {
execSync("taskkill /PID " + hpid + " /T /F", { stdio: "ignore" });
} else {
hugoProcess.kill();
}
} catch {}
hugoProcess = null;
}
if (!writeProcess) return false;
const pid = writeProcess.pid;
try {
if (process.platform === "win32" && pid) {
execSync("taskkill /PID " + pid + " /T /F", { stdio: "ignore" });
} else {
writeProcess.kill();
}
} catch {}
writeProcess = null;
writeReady = false;
return true;
}
function startHugoIfNeeded() {
if (hugoProcess) return;
try {
hugoProcess = spawn("hugo", ["server", "--bind", "0.0.0.0", "--port", "1313", "--disableFastRender"], {
cwd: config.WRITE_DIR + "/..",
shell: true,
stdio: "pipe",
windowsHide: true,
});
hugoProcess.on("exit", (code) => {
console.log("[hugo] exited " + code);
hugoProcess = null;
});
console.log("[hugo] started on 0.0.0.0:1313");
} catch (e) {
console.error("[hugo]", e.message);
}
}
function getWriteStatus() {
return {
running: !!(writeProcess && writeReady),
starting: !!(writeProcess && !writeReady),
};
}
async function ensureWriteReady(chatId, sendMessage) {
if (writeProcess && writeReady) {
startHugoIfNeeded();
return true;
}
if (writeProcess && !writeReady) {
await sendMessage(chatId, "\\u231B Write \\u6B63\\u5728\\u542F\\u52A8\\u4E2D...");
const ready = await waitForReady(config.WRITE_PORT);
if (ready) {
writeReady = true;
startHugoIfNeeded();
return true;
}
return false;
}
const result = startWriteServer();
if (!result.started) return false;
await sendMessage(chatId, "\\u231B \\u6B63\\u5728\\u542F\\u52A8 Write...");
const ready = await waitForReady(config.WRITE_PORT);
if (ready) {
writeReady = true;
startHugoIfNeeded();
return true;
}
return false;
}
module.exports = {
get writeReady() { return writeReady; },
get writePort() { return config.WRITE_PORT; },
startWriteServer,
stopWriteServer,
startHugoIfNeeded,
getWriteStatus,
ensureWriteReady,
};