added:增加Linux-server
This commit is contained in:
1 parent
6fe4e9d23f
commit
d78f3f811b
90 files changed
+16784
-1
No files matched your search
@@ -0,0 +1,69 @@
|
||||
const DEEPSEEK_BASE = process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com";
|
||||
const DEEPSEEK_KEY = process.env.DEEPSEEK_API_KEY || "";
|
||||
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
||||
const DEEPSEEK_ANTHROPIC = process.env.DEEPSEEK_ANTHROPIC === "true";
|
||||
|
||||
export async function aiChat(
|
||||
systemPrompt: string,
|
||||
userMessage: string
|
||||
): Promise<string> {
|
||||
if (!DEEPSEEK_KEY) throw new Error("DeepSeek API key not configured");
|
||||
|
||||
let url: string;
|
||||
let headers: Record<string, string>;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (DEEPSEEK_ANTHROPIC) {
|
||||
// Anthropic Messages API
|
||||
url = `${DEEPSEEK_BASE}/v1/messages`;
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": DEEPSEEK_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
};
|
||||
body = {
|
||||
model: DEEPSEEK_MODEL,
|
||||
max_tokens: 2048,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: "user", content: userMessage }],
|
||||
};
|
||||
} else {
|
||||
// OpenAI-compatible API (default)
|
||||
url = `${DEEPSEEK_BASE}/v1/chat/completions`;
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${DEEPSEEK_KEY}`,
|
||||
};
|
||||
body = {
|
||||
model: DEEPSEEK_MODEL,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: { message?: string } }).error?.message || `API error: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// Anthropic format or OpenAI format
|
||||
const text = data?.content?.[0]?.text || data?.choices?.[0]?.message?.content || "";
|
||||
if (!text) throw new Error("AI returned empty response");
|
||||
return text;
|
||||
}
|
||||
|
||||
export const AI_PROMPTS = {
|
||||
polish: "你是一个文字润色助手。请润色以下文字,使其更流畅、更有文采,但保持原意和语气不变。直接返回润色后的文字,不要加任何解释。",
|
||||
expand: "你是一个写作助手。请扩写以下文字,增加更多细节和论述,使其更充实。保持原风格和语气。直接返回扩写后的文字,不要加任何解释。",
|
||||
shorten: "你是一个写作助手。请缩写以下文字,保留核心信息,删除冗余。直接返回缩写后的文字,不要加任何解释。",
|
||||
fix: "你是一个文字校对助手。请修正以下文字中的错别字、语法错误和标点问题。直接返回修正后的文字,不要加任何解释。",
|
||||
summary: "根据以下文章标题和内容,生成一个简短的摘要(100字以内)。直接返回摘要,不要加任何解释。\n\n标题:{title}\n\n内容片段:{content}",
|
||||
tags: "根据以下文章标题和内容,提取3-5个中文标签(用顿号分隔)。只返回标签,不要加解释。\n\n标题:{title}\n\n内容片段:{content}",
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ARTALK_SERVER, ARTALK_SITE } from "./config";
|
||||
|
||||
// Artalk API v2 types
|
||||
export interface ArtalkComment {
|
||||
id: number;
|
||||
content: string;
|
||||
nick: string;
|
||||
email: string;
|
||||
page_key: string;
|
||||
page_title: string;
|
||||
user_id: number;
|
||||
ua: string;
|
||||
ip: string;
|
||||
is_collapsed: boolean;
|
||||
is_pending: boolean;
|
||||
is_pinned: boolean;
|
||||
vote_up: number;
|
||||
vote_down: number;
|
||||
rid: number;
|
||||
date: string;
|
||||
updated_at: string;
|
||||
badge_word?: string;
|
||||
is_allow_reply?: boolean;
|
||||
children?: ArtalkComment[];
|
||||
}
|
||||
|
||||
export interface ArtalkPage {
|
||||
id: number;
|
||||
admin_only: boolean;
|
||||
key: string;
|
||||
title: string;
|
||||
url: string;
|
||||
view: number;
|
||||
comment_count: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let authToken: string | null = null;
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token;
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ token: string; user: { name: string; email: string } }> {
|
||||
const url = `${ARTALK_SERVER}/api/v2/auth/email/login`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!json || !json.token) {
|
||||
throw new Error(json.msg || "登录失败");
|
||||
}
|
||||
|
||||
authToken = json.token as string;
|
||||
return { token: authToken, user: json.user };
|
||||
}
|
||||
|
||||
export async function getPages(options?: {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
type?: "all" | "admin_only";
|
||||
}): Promise<{ pages: ArtalkPage[]; total: number }> {
|
||||
const { offset = 0, limit = 50, type = "all" } = options || {};
|
||||
|
||||
const params = new URLSearchParams({
|
||||
site_name: ARTALK_SITE,
|
||||
offset: offset.toString(),
|
||||
limit: limit.toString(),
|
||||
type,
|
||||
});
|
||||
|
||||
const url = `${ARTALK_SERVER}/api/v2/pages?${params.toString()}`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(url, { headers });
|
||||
const json = await res.json();
|
||||
|
||||
if (json.msg && !json.pages && !json.count) {
|
||||
throw new Error(json.msg);
|
||||
}
|
||||
|
||||
return { pages: json.pages || json.data || [], total: json.count || json.total || 0 };
|
||||
}
|
||||
|
||||
export async function getComments(
|
||||
pageKey: string,
|
||||
options?: { offset?: number; limit?: number }
|
||||
): Promise<{ comments: ArtalkComment[]; total: number }> {
|
||||
const { offset = 0, limit = 100 } = options || {};
|
||||
|
||||
const params = new URLSearchParams({
|
||||
site_name: ARTALK_SITE,
|
||||
page_key: pageKey,
|
||||
offset: offset.toString(),
|
||||
limit: limit.toString(),
|
||||
flat_mode: "true",
|
||||
sort_by: "date_desc",
|
||||
});
|
||||
|
||||
const url = `${ARTALK_SERVER}/api/v2/comments?${params.toString()}`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(url, { headers });
|
||||
const json = await res.json();
|
||||
|
||||
if (json.msg && !json.comments && !json.count) {
|
||||
throw new Error(json.msg);
|
||||
}
|
||||
|
||||
return { comments: json.comments || json.data || [], total: json.count || json.total || 0 };
|
||||
}
|
||||
|
||||
async function artalkMutate<T>(endpoint: string, method: string, body: Record<string, unknown>): Promise<T> {
|
||||
const url = `${ARTALK_SERVER}${endpoint}`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(url, { method, headers, body: JSON.stringify(body) });
|
||||
const json = await res.json();
|
||||
|
||||
if (json.msg) {
|
||||
throw new Error(json.msg);
|
||||
}
|
||||
return (json.data || json) as T;
|
||||
}
|
||||
|
||||
export async function updateComment(
|
||||
id: number,
|
||||
data: {
|
||||
content?: string;
|
||||
is_collapsed?: boolean;
|
||||
is_pending?: boolean;
|
||||
is_pinned?: boolean;
|
||||
rid?: number;
|
||||
}
|
||||
): Promise<ArtalkComment> {
|
||||
return artalkMutate<ArtalkComment>(`/api/v2/comments/${id}`, "PUT", { ...data, site_name: ARTALK_SITE });
|
||||
}
|
||||
|
||||
export async function createComment(data: {
|
||||
page_key: string;
|
||||
content: string;
|
||||
name: string;
|
||||
email: string;
|
||||
rid?: number;
|
||||
}): Promise<ArtalkComment> {
|
||||
return artalkMutate<ArtalkComment>("/api/v2/comments", "POST", { ...data, site_name: ARTALK_SITE });
|
||||
}
|
||||
|
||||
export async function deleteComment(id: number): Promise<void> {
|
||||
await artalkMutate<void>(`/api/v2/comments/${id}`, "DELETE", { site_name: ARTALK_SITE });
|
||||
}
|
||||
|
||||
export async function updatePage(
|
||||
id: number,
|
||||
data: { key: string; title: string; admin_only?: boolean }
|
||||
): Promise<ArtalkPage> {
|
||||
return artalkMutate<ArtalkPage>(`/api/v2/pages/${id}`, "PUT", {
|
||||
key: data.key,
|
||||
title: data.title,
|
||||
admin_only: data.admin_only ?? false,
|
||||
site_name: ARTALK_SITE,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
export function getCached<T>(key: string): T | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data as T;
|
||||
}
|
||||
|
||||
export function setCache<T>(key: string, data: T, ttlMs: number): void {
|
||||
cache.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
export function invalidateCache(key: string): void {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
export function invalidateByPrefix(prefix: string): void {
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(prefix)) cache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TG_BOT_TOKEN, TG_ALLOWED_IDS } from "@/lib/config";
|
||||
|
||||
export const TG_TOKEN = TG_BOT_TOKEN;
|
||||
export const ALLOWED_IDS = TG_ALLOWED_IDS;
|
||||
export const API = TG_TOKEN ? `https://api.telegram.org/bot${TG_TOKEN}` : "";
|
||||
@@ -0,0 +1,656 @@
|
||||
import { getPost, listPosts } from "@/lib/posts";
|
||||
import { getLinks, addLink, updateLink, deleteLink, getFeeds, addFeed, updateFeed, deleteFeed, getFeedsHealth, getArticles } from "@/lib/rssapi";
|
||||
import { moveToRecycle } from "@/lib/recycle";
|
||||
import { deployQueue } from "@/lib/git";
|
||||
import { hugo } from "@/lib/hugo";
|
||||
import { getLanIP, DEFAULT_AUTHOR } from "@/lib/config";
|
||||
import { sendMessage, tg } from "./tg";
|
||||
import { getSession, hasSession, clearSession, saveSession, undoLastChunk, initSessionDir } from "./sessions";
|
||||
import { makeSlug, sessionSummary, truncateText } from "./helpers";
|
||||
import { getCached, setCache, invalidateByPrefix } from "./cache";
|
||||
|
||||
type Handler = (chatId: number, arg?: string) => Promise<void>;
|
||||
|
||||
const handlers: Record<string, Handler> = {};
|
||||
|
||||
// ---- Cached API wrappers ----
|
||||
|
||||
const CACHE_TTL_LINKS = 5 * 60 * 1000;
|
||||
const CACHE_TTL_FEEDS = 5 * 60 * 1000;
|
||||
const CACHE_TTL_ARTICLES = 3 * 60 * 1000;
|
||||
const CACHE_TTL_HEALTH = 10 * 60 * 1000;
|
||||
|
||||
async function cachedLinks(all = true) {
|
||||
const key = "links_" + (all ? "all" : "visible");
|
||||
const hit = getCached<ReturnType<typeof getLinks> extends Promise<infer R> ? R : never>(key);
|
||||
if (hit) return hit;
|
||||
const data = await getLinks(all);
|
||||
setCache(key, data, CACHE_TTL_LINKS);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function cachedFeeds() {
|
||||
const key = "feeds";
|
||||
const hit = getCached<ReturnType<typeof getFeeds> extends Promise<infer R> ? R : never>(key);
|
||||
if (hit) return hit;
|
||||
const data = await getFeeds();
|
||||
setCache(key, data, CACHE_TTL_FEEDS);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function cachedArticles(limit: number) {
|
||||
const key = "articles_" + limit;
|
||||
const hit = getCached<ReturnType<typeof getArticles> extends Promise<infer R> ? R : never>(key);
|
||||
if (hit) return hit;
|
||||
const data = await getArticles(limit);
|
||||
setCache(key, data, CACHE_TTL_ARTICLES);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function cachedHealth() {
|
||||
const key = "health";
|
||||
const hit = getCached<ReturnType<typeof getFeedsHealth> extends Promise<infer R> ? R : never>(key);
|
||||
if (hit) return hit;
|
||||
const data = await getFeedsHealth();
|
||||
setCache(key, data, CACHE_TTL_HEALTH);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- 键盘 ----
|
||||
|
||||
function mainKeyboard() {
|
||||
return {
|
||||
keyboard: [
|
||||
[{ text: "/new" }, { text: "/list" }],
|
||||
[{ text: "/links" }, { text: "/feeds" }],
|
||||
[{ text: "/stats" }, { text: "/help" }],
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: false,
|
||||
};
|
||||
}
|
||||
|
||||
function writingKeyboard() {
|
||||
return {
|
||||
keyboard: [
|
||||
[{ text: "/preview" }, { text: "/undo" }],
|
||||
[{ text: "/publish" }, { text: "/deploy" }],
|
||||
[{ text: "/draft" }, { text: "/cancel" }],
|
||||
[{ text: "/title" }, { text: "/categories" }],
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMain(chatId: number, text: string) {
|
||||
await sendMessage(chatId, text, { reply_markup: mainKeyboard() });
|
||||
}
|
||||
|
||||
async function sendWrite(chatId: number, text: string) {
|
||||
await sendMessage(chatId, text, { reply_markup: writingKeyboard() });
|
||||
}
|
||||
|
||||
// ---- System ----
|
||||
|
||||
handlers.start = async (chatId) => {
|
||||
const ok = await hugo.ensureRunning();
|
||||
const ip = getLanIP();
|
||||
await sendMain(chatId, "✅ <b>已启动</b>\n\n📱 手机: http://" + ip + ":8016/\n💻 电脑: http://localhost:8016/");
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "点下方按钮打开编辑器:",
|
||||
reply_markup: { inline_keyboard: [[{ text: "🌐 打开编辑器", web_app: { url: "http://" + ip + ":8016/" } }]] },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.stop = async (chatId) => { hugo.stop(); await sendMain(chatId, "⚡ Hugo 已停止。"); };
|
||||
|
||||
handlers.status = async (chatId) => {
|
||||
const s = hugo.status();
|
||||
const status = s.running ? "✅ 运行中" : s.starting ? "⏳ 启动中..." : "⏸ 已停止";
|
||||
const lines = ["<b>Hugo:</b> " + status];
|
||||
if (s.running) { const ip = getLanIP(); lines.push("📱 http://" + ip + ":8016", "💻 http://localhost:8016"); }
|
||||
await sendMain(chatId, lines.join("\n"));
|
||||
};
|
||||
|
||||
handlers.ip = async (chatId) => { await sendMain(chatId, "Phone: http://" + getLanIP() + ":8016"); };
|
||||
|
||||
handlers.shutdown = async (chatId) => {
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "⚠️ 确认关机?\n\n电脑将在确认后 10 秒关机。",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "✅ 确认关机", callback_data: "confirm_shutdown" },
|
||||
{ text: "❌ 取消", callback_data: "cancel_shutdown" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
handlers.confirm_shutdown = async (chatId) => {
|
||||
await sendMessage(chatId, "⏻ 电脑将在 10 秒后关机...");
|
||||
const { exec } = await import("child_process");
|
||||
exec("shutdown /s /t 10", (err) => {
|
||||
if (err) console.error("[shutdown]", err.message);
|
||||
});
|
||||
};
|
||||
|
||||
handlers.cancel_shutdown = async (chatId) => {
|
||||
const { exec } = await import("child_process");
|
||||
exec("shutdown /a", () => {});
|
||||
await sendMain(chatId, "已取消关机。");
|
||||
};
|
||||
|
||||
handlers.reboot = async (chatId) => {
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "⚠️ 确认重启?\n\n电脑将在确认后 10 秒重启。",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "✅ 确认重启", callback_data: "confirm_reboot" },
|
||||
{ text: "❌ 取消", callback_data: "cancel_reboot" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
handlers.confirm_reboot = async (chatId) => {
|
||||
await sendMessage(chatId, "🔄 电脑将在 10 秒后重启...");
|
||||
const { exec } = await import("child_process");
|
||||
exec("shutdown /r /t 10", (err) => {
|
||||
if (err) console.error("[reboot]", err.message);
|
||||
});
|
||||
};
|
||||
|
||||
handlers.cancel_reboot = async (chatId) => {
|
||||
const { exec } = await import("child_process");
|
||||
exec("shutdown /a", () => {});
|
||||
await sendMain(chatId, "已取消重启。");
|
||||
};
|
||||
|
||||
handlers.help = async (chatId) => {
|
||||
if (hasSession(chatId)) {
|
||||
await sendWrite(chatId,
|
||||
"✍ <b>写作中</b>\n\n" +
|
||||
"/publish 保存 /draft 草稿 /deploy 部署\n" +
|
||||
"/preview 预览 /undo 撤销 /cancel 取消\n" +
|
||||
"/title 标题 /categories 分类 /tags 标签\n\n" +
|
||||
"直接发消息追加正文,发照片追加图片"
|
||||
);
|
||||
} else {
|
||||
await sendMain(chatId,
|
||||
"🤖 <b>WriteBot 命令</b>\n\n" +
|
||||
"📝 <b>文章</b>\n" +
|
||||
"/new 标题 — 新建\n" +
|
||||
"/edit slug — 编辑\n" +
|
||||
"/delete slug — 删除\n" +
|
||||
"/list — 文章列表\n\n" +
|
||||
"🔗 <b>友链</b>\n" +
|
||||
"/links — 列表\n" +
|
||||
"/link_add url 名称 — 添加\n" +
|
||||
"/link_edit url — 编辑\n" +
|
||||
"/link_del url — 删除\n" +
|
||||
"/link_toggle url — 显示/隐藏\n\n" +
|
||||
"📡 <b>订阅</b>\n" +
|
||||
"/feeds — 列表\n" +
|
||||
"/feed_add url — 添加\n" +
|
||||
"/feed_edit url — 编辑\n" +
|
||||
"/feed_del url — 删除\n" +
|
||||
"/feed_health — 健康检查\n\n" +
|
||||
"📰 /read — 友链最新文章\n" +
|
||||
"📊 /stats — 网站数据\n\n" +
|
||||
"⏻ /shutdown — 关机 🔄 /reboot — 重启"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Articles: New ----
|
||||
|
||||
handlers.new = async (chatId, title) => {
|
||||
if (title) {
|
||||
const s = getSession(chatId);
|
||||
s.title = title;
|
||||
s.slug = makeSlug(title);
|
||||
s.content = "";
|
||||
s.categories = [];
|
||||
s.tags = [];
|
||||
s.draft = true;
|
||||
s.author = DEFAULT_AUTHOR;
|
||||
s.chunks = [];
|
||||
initSessionDir(chatId, title);
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "📝 开始写:<b>" + title + "</b>\n\n📂 设置分类(可选)",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "跳过", callback_data: "skip_cat" },
|
||||
{ text: "设置分类", callback_data: "skip_cat" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "请输入分类(逗号分隔,或发「跳过」):",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "技术、生活" },
|
||||
});
|
||||
} else {
|
||||
await tg("sendMessage", { chat_id: chatId, text: "📝 请输入文章标题:", reply_markup: { force_reply: true, input_field_placeholder: "在这里输入标题..." } });
|
||||
}
|
||||
};
|
||||
|
||||
handlers.skip_categories = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s.title) return;
|
||||
s.categories = [];
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "🏷 设置标签(可选)",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "跳过", callback_data: "skip_tag" },
|
||||
{ text: "设置标签", callback_data: "skip_tag" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "请输入标签(逗号分隔,或发「跳过」):",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "Hugo、博客" },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.skip_tags = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s.title) return;
|
||||
s.tags = [];
|
||||
await sendWrite(chatId, "✅ 准备就绪,开始写正文吧!\n\n📝 <b>" + s.title + "</b>\n📂 " + (s.categories.join("、") || "无分类") + "\n🏷 " + (s.tags.join("、") || "无标签"));
|
||||
};
|
||||
|
||||
handlers.categories = async (chatId, cats) => {
|
||||
const s = getSession(chatId);
|
||||
s.categories = cats ? cats.split(/[,;",、]+/).map((c) => c.trim()).filter(Boolean) : [];
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "📂 分类:" + (s.categories.join("、") || "无") + "\n\n🏷 设置标签(可选)",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "跳过", callback_data: "skip_tag" },
|
||||
{ text: "设置标签", callback_data: "skip_tag" },
|
||||
]],
|
||||
},
|
||||
});
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "请输入标签(逗号分隔,或发「跳过」):",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "Hugo、博客" },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.tags = async (chatId, tags) => {
|
||||
const s = getSession(chatId);
|
||||
s.tags = tags ? tags.split(/[,;",、]+/).map((t) => t.trim()).filter(Boolean) : [];
|
||||
await sendWrite(chatId, "✅ 准备就绪,开始写正文吧!\n\n📝 <b>" + s.title + "</b>\n📂 " + (s.categories.join("、") || "无分类") + "\n🏷 " + (s.tags.join("、") || "无标签"));
|
||||
};
|
||||
|
||||
// ---- Articles: Edit (方案 B) ----
|
||||
|
||||
handlers.edit = async (chatId, slug) => {
|
||||
if (!slug) { await sendMain(chatId, "格式:/edit slug\n先用 /list 查看文章。"); return; }
|
||||
const post = getPost(slug);
|
||||
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
|
||||
|
||||
const originalContent = post.content || "";
|
||||
if (originalContent) {
|
||||
const maxLen = 3800;
|
||||
for (let i = 0; i < originalContent.length; i += maxLen) {
|
||||
const chunk = originalContent.slice(i, i + maxLen);
|
||||
const prefix = originalContent.length > maxLen ? "(" + (Math.floor(i / maxLen) + 1) + "/" + Math.ceil(originalContent.length / maxLen) + ")\n" : "";
|
||||
await sendMessage(chatId, prefix + chunk);
|
||||
}
|
||||
}
|
||||
|
||||
const s = getSession(chatId);
|
||||
s.title = post.frontMatter.title;
|
||||
s.slug = post.frontMatter.slug;
|
||||
s.originalSlug = post.frontMatter.slug;
|
||||
s.content = "";
|
||||
s.chunks = [];
|
||||
s.dirPath = post.dirPath;
|
||||
s.categories = (post.frontMatter.categories as string[]) || [];
|
||||
s.tags = (post.frontMatter.tags as string[]) || [];
|
||||
s.author = post.frontMatter.author || DEFAULT_AUTHOR;
|
||||
s.draft = post.frontMatter.draft ?? true;
|
||||
|
||||
await sendWrite(chatId,
|
||||
"✏ 编辑:<b>" + s.title + "</b>\n\n" +
|
||||
"原文已显示,现在重新写正文\n" +
|
||||
"📂 " + (s.categories.join("、") || "无分类") + "\n" +
|
||||
"🏷 " + (s.tags.join("、") || "无标签") + "\n\n" +
|
||||
"直接发消息写正文,完成后点「发布」"
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Articles: Delete (二次确认) ----
|
||||
|
||||
handlers.delete = async (chatId, slug) => {
|
||||
if (!slug) { await sendMain(chatId, "格式:/delete slug\n先用 /list 查看文章。"); return; }
|
||||
const post = getPost(slug);
|
||||
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "⚠️ 确认删除《" + post.frontMatter.title + "》?\n\n删除后移入回收站,30天内可恢复。",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: "✅ 确认删除", callback_data: "confirm_del_" + slug },
|
||||
{ text: "❌ 取消", callback_data: "cancel_del_" + slug },
|
||||
]],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
handlers.confirm_del = async (chatId, slug) => {
|
||||
if (!slug) return;
|
||||
const post = getPost(slug);
|
||||
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
|
||||
moveToRecycle(slug, post.dirPath, post.frontMatter.title);
|
||||
await sendMain(chatId, "✂ 已移入回收站:" + post.frontMatter.title + "\n30天内可恢复。");
|
||||
};
|
||||
|
||||
handlers.cancel_del = async (chatId) => {
|
||||
await sendMain(chatId, "已取消删除。");
|
||||
};
|
||||
|
||||
// ---- Articles: List ----
|
||||
|
||||
handlers.list = async (chatId, pageStr) => {
|
||||
const page = parseInt(pageStr || "1") || 1;
|
||||
const perPage = 10;
|
||||
try {
|
||||
const { posts, total, totalPages } = listPosts({ page, perPage });
|
||||
if (total === 0) { await sendMain(chatId, "📖 还没有文章,发 /new 开始写。"); return; }
|
||||
const lines = ["📖 <b>文章列表</b> 共 " + total + " 篇 · 第 " + page + "/" + totalPages + " 页\n"];
|
||||
for (const p of posts) {
|
||||
const draft = p.draft ? " 📝" : "";
|
||||
lines.push(p.title + draft + "\n <code>" + p.slug + "</code>");
|
||||
}
|
||||
const navRow: { text: string; callback_data: string }[] = [];
|
||||
if (page > 1) navRow.push({ text: "◀ 上一页", callback_data: "list_" + (page - 1) });
|
||||
if (page < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "list_" + (page + 1) });
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
|
||||
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
|
||||
});
|
||||
} catch (e: unknown) { await sendMain(chatId, "❌ " + (e instanceof Error ? e.message : String(e))); }
|
||||
};
|
||||
|
||||
// ---- Articles: View / Title / Author / Preview ----
|
||||
|
||||
handlers.view = async (chatId, slug) => {
|
||||
if (!slug) { await sendMain(chatId, "格式:/view slug"); return; }
|
||||
const ip = getLanIP();
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "🔖 <b>" + slug + "</b>\n\n📱 http://" + ip + ":1313/" + slug + ".html\n💻 http://localhost:1313/" + slug + ".html",
|
||||
parse_mode: "HTML", disable_web_page_preview: true,
|
||||
reply_markup: { inline_keyboard: [[{ text: "🌐 浏览器打开", url: "http://localhost:1313/" + slug + ".html" }]] },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.title = async (chatId, newTitle) => {
|
||||
if (!newTitle) { await tg("sendMessage", { chat_id: chatId, text: "请输入新标题:", reply_markup: { force_reply: true, input_field_placeholder: "新标题..." } }); return; }
|
||||
const s = getSession(chatId);
|
||||
s.title = newTitle;
|
||||
await sendWrite(chatId, "✏ 标题已更新:<b>" + newTitle + "</b>");
|
||||
};
|
||||
|
||||
handlers.author = async (chatId, author) => {
|
||||
getSession(chatId).author = author || DEFAULT_AUTHOR;
|
||||
await sendWrite(chatId, "✍ 作者:" + getSession(chatId).author);
|
||||
};
|
||||
|
||||
handlers.preview = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
|
||||
if (!s.content) { await sendWrite(chatId, "正文为空。"); return; }
|
||||
await sendWrite(chatId, "📝 <b>" + s.title + "</b>\n\n" + truncateText(s.content, 3800));
|
||||
};
|
||||
|
||||
// ---- Writing: Publish / Draft / Deploy / Sync / Undo / Cancel ----
|
||||
|
||||
handlers.publish = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
|
||||
if (!s.content) { await sendWrite(chatId, "正文为空,先写点内容再发布。"); return; }
|
||||
try {
|
||||
const title = s.title;
|
||||
await saveSession(s, false);
|
||||
clearSession(chatId);
|
||||
await sendMain(chatId, "✅ 已保存:<b>" + title + "</b>\n\n发 /deploy 推送到线上");
|
||||
} catch (e: unknown) { await sendWrite(chatId, "❌ 保存失败:" + (e instanceof Error ? e.message : String(e))); }
|
||||
};
|
||||
|
||||
handlers.draft = async (chatId) => {
|
||||
const s = getSession(chatId);
|
||||
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
|
||||
if (!s.content) { await sendWrite(chatId, "正文为空,先写点内容再保存。"); return; }
|
||||
try {
|
||||
const title = s.title;
|
||||
await saveSession(s, true);
|
||||
clearSession(chatId);
|
||||
await sendMain(chatId, "📄 草稿已保存:<b>" + title + "</b>");
|
||||
} catch (e: unknown) { await sendWrite(chatId, "❌ 保存失败:" + (e instanceof Error ? e.message : String(e))); }
|
||||
};
|
||||
|
||||
handlers.deploy = async (chatId) => {
|
||||
await sendMessage(chatId, "⏳ 正在部署...", { reply_markup: { remove_keyboard: true } });
|
||||
const result = await deployQueue.enqueue("发布文章");
|
||||
if (result.success) await sendMain(chatId, "🚀 <b>已部署!</b>\n\n🌐 https://usj.cc");
|
||||
else if (result.conflict) await sendMain(chatId, "⚠️ " + (result.error || "远程冲突"));
|
||||
else await sendMain(chatId, "❌ 部署失败:" + (result.error || "未知错误"));
|
||||
};
|
||||
|
||||
handlers.sync = async (chatId) => {
|
||||
await sendMessage(chatId, "⏳ 正在同步远程...");
|
||||
const result = await deployQueue.sync();
|
||||
if (result.success) await sendMain(chatId, "✅ 同步完成\n\n" + (result.output || "").slice(0, 500));
|
||||
else await sendMain(chatId, "❌ " + (result.error || "同步失败"));
|
||||
};
|
||||
|
||||
handlers.undo = async (chatId) => {
|
||||
if (!hasSession(chatId)) { await sendMain(chatId, "没有活动的草稿。"); return; }
|
||||
const ok = undoLastChunk(chatId);
|
||||
if (!ok) { await sendWrite(chatId, "没有可撤销的内容。"); return; }
|
||||
const s = getSession(chatId);
|
||||
await sendWrite(chatId, "↩ 已撤销\n\n📝 <b>" + s.title + "</b>\n\n" + (s.content ? truncateText(s.content, 3800) : "(正文为空)"));
|
||||
};
|
||||
|
||||
handlers.cancel = async (chatId) => {
|
||||
if (!hasSession(chatId)) { await sendMain(chatId, "没有活动的草稿。"); return; }
|
||||
clearSession(chatId);
|
||||
await sendMain(chatId, "✂ 草稿已丢弃。");
|
||||
};
|
||||
|
||||
// ---- Links ----
|
||||
|
||||
handlers.links = async (chatId, pageStr) => {
|
||||
const page = parseInt(pageStr || "1") || 1;
|
||||
const perPage = 10;
|
||||
try {
|
||||
const allLinks = await cachedLinks(true);
|
||||
if (allLinks.length === 0) { await sendMain(chatId, "还没有友链。\n\n添加:/link_add url 名称"); return; }
|
||||
const totalPages = Math.ceil(allLinks.length / perPage);
|
||||
const p = Math.min(page, totalPages);
|
||||
const start = (p - 1) * perPage;
|
||||
const pageLinks = allLinks.slice(start, start + perPage);
|
||||
const lines = ["🔗 <b>友链</b> 共 " + allLinks.length + " 个 · 第 " + p + "/" + totalPages + " 页\n"];
|
||||
for (const l of pageLinks) {
|
||||
const icon = l.hidden ? "🔒" : "🌐";
|
||||
lines.push(icon + " " + (l.name || l.url) + "\n " + l.url);
|
||||
}
|
||||
lines.push("\n/link_add /link_edit /link_del /link_toggle");
|
||||
const navRow: { text: string; callback_data: string }[] = [];
|
||||
if (p > 1) navRow.push({ text: "◀ 上一页", callback_data: "links_" + (p - 1) });
|
||||
if (p < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "links_" + (p + 1) });
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
|
||||
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
|
||||
});
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.link_add = async (chatId, arg) => {
|
||||
if (!arg) { await tg("sendMessage", { chat_id: chatId, text: "请输入友链信息:\n格式:url 名称", reply_markup: { force_reply: true, input_field_placeholder: "url 名称" } }); return; }
|
||||
const parts = arg.split(/\s+/);
|
||||
let url = parts[0];
|
||||
const name = parts.slice(1).join(" ") || url;
|
||||
if (!url.startsWith("http")) url = "https://" + url;
|
||||
try { await addLink({ name, url }); invalidateByPrefix("links"); await sendMain(chatId, "✅ 友链已添加:" + name); }
|
||||
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.link_edit = async (chatId, arg) => {
|
||||
if (!arg) { await sendMain(chatId, "格式:/link_edit url\n用 /links 查看友链。"); return; }
|
||||
const s = getSession(chatId);
|
||||
s.originalSlug = arg;
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "当前 URL:" + arg + "\n请输入新名称(空则保持不变):",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "新名称" },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.link_del = async (chatId, url) => {
|
||||
if (!url) { await sendMain(chatId, "格式:/link_del url"); return; }
|
||||
try { await deleteLink(url); invalidateByPrefix("links"); await sendMain(chatId, "✅ 已删除:" + url); }
|
||||
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.link_toggle = async (chatId, url) => {
|
||||
if (!url) { await sendMain(chatId, "格式:/link_toggle url"); return; }
|
||||
try {
|
||||
const allLinks = await cachedLinks(true);
|
||||
const link = allLinks.find((l) => l.url === url);
|
||||
if (!link) { await sendMain(chatId, "❌ 未找到:" + url); return; }
|
||||
await updateLink(url, { hidden: !link.hidden });
|
||||
invalidateByPrefix("links");
|
||||
await sendMain(chatId, (!link.hidden ? "🔒 已隐藏" : "🌐 已显示") + ":" + (link.name || url));
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
// ---- Feeds ----
|
||||
|
||||
handlers.feeds = async (chatId, pageStr) => {
|
||||
const page = parseInt(pageStr || "1") || 1;
|
||||
const perPage = 10;
|
||||
try {
|
||||
const allFeeds = await cachedFeeds();
|
||||
if (allFeeds.length === 0) { await sendMain(chatId, "还没有订阅源。\n\n添加:/feed_add url"); return; }
|
||||
const totalPages = Math.ceil(allFeeds.length / perPage);
|
||||
const p = Math.min(page, totalPages);
|
||||
const start = (p - 1) * perPage;
|
||||
const pageFeeds = allFeeds.slice(start, start + perPage);
|
||||
const lines = ["📡 <b>订阅源</b> 共 " + allFeeds.length + " 个 · 第 " + p + "/" + totalPages + " 页\n"];
|
||||
for (const f of pageFeeds) {
|
||||
lines.push("📰 " + (f.feedTitle || f.url) + "\n " + f.url);
|
||||
}
|
||||
lines.push("\n/feed_add /feed_edit /feed_del /feed_health");
|
||||
const navRow: { text: string; callback_data: string }[] = [];
|
||||
if (p > 1) navRow.push({ text: "◀ 上一页", callback_data: "feeds_" + (p - 1) });
|
||||
if (p < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "feeds_" + (p + 1) });
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
|
||||
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
|
||||
});
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.feed_add = async (chatId, arg) => {
|
||||
if (!arg) { await tg("sendMessage", { chat_id: chatId, text: "请输入订阅源 URL:", reply_markup: { force_reply: true, input_field_placeholder: "https://example.com/rss.xml" } }); return; }
|
||||
const parts = arg.split(/\s+/);
|
||||
const url = parts[0];
|
||||
const feedTitle = parts.slice(1).join(" ") || "";
|
||||
try { await addFeed(url, feedTitle); invalidateByPrefix("feeds"); await sendMain(chatId, "✅ 订阅源已添加:" + url); }
|
||||
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.feed_edit = async (chatId, arg) => {
|
||||
if (!arg) { await sendMain(chatId, "格式:/feed_edit url\n用 /feeds 查看。"); return; }
|
||||
const s = getSession(chatId);
|
||||
s.originalSlug = arg;
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "当前 URL:" + arg + "\n请输入新的订阅信息:\n格式:新URL 新名称",
|
||||
reply_markup: { force_reply: true, input_field_placeholder: "新URL 新名称" },
|
||||
});
|
||||
};
|
||||
|
||||
handlers.feed_del = async (chatId, url) => {
|
||||
if (!url) { await sendMain(chatId, "格式:/feed_del url"); return; }
|
||||
try { await deleteFeed(url); invalidateByPrefix("feeds"); await sendMain(chatId, "✅ 已删除:" + url); }
|
||||
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.feed_health = async (chatId) => {
|
||||
try {
|
||||
await sendMessage(chatId, "⏳ 正在检测订阅源...");
|
||||
const result = await cachedHealth();
|
||||
const lines = ["📊 <b>订阅源健康报告</b>\n", "✅ 存活:" + result.alive + " ❌ 异常:" + result.dead];
|
||||
const dead = (result.results || []).filter((r) => !r.ok);
|
||||
if (dead.length > 0) {
|
||||
lines.push("\n❌ 异常站点:");
|
||||
for (const d of dead.slice(0, 10)) {
|
||||
lines.push("🔴 " + d.url + " — " + (d.error || String(d.status)));
|
||||
}
|
||||
}
|
||||
await sendMain(chatId, lines.join("\n"));
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
// ---- Data ----
|
||||
|
||||
handlers.stats = async (chatId) => {
|
||||
try {
|
||||
const { total } = listPosts({ perPage: 1 });
|
||||
const allLinks = await cachedLinks(true);
|
||||
const allFeeds = await cachedFeeds();
|
||||
const visibleLinks = allLinks.filter((l) => !l.hidden).length;
|
||||
|
||||
let extra = "";
|
||||
try {
|
||||
const healthRes = await cachedHealth();
|
||||
extra = "\n\n📰 友链博客:" + healthRes.total + " 个";
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
await sendMain(chatId,
|
||||
"📊 <b>网站数据</b>\n\n" +
|
||||
"📝 本地文章:" + total + " 篇\n" +
|
||||
"🔗 友链:" + visibleLinks + " 个\n" +
|
||||
"📡 订阅源:" + allFeeds.length + " 个" +
|
||||
extra + "\n\n🌐 usj.cc"
|
||||
);
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
handlers.read = async (chatId, limitStr) => {
|
||||
const limit = parseInt(limitStr || "10") || 10;
|
||||
try {
|
||||
const articles = await cachedArticles(Math.min(limit, 20));
|
||||
if (articles.length === 0) { await sendMain(chatId, "暂无最新文章。"); return; }
|
||||
const lines = ["📰 <b>友链最新文章</b>(" + articles.length + " 篇)\n"];
|
||||
for (const a of articles) {
|
||||
lines.push((a.feedName || "未知") + " — " + a.title + "\n " + a.link);
|
||||
}
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
|
||||
});
|
||||
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
|
||||
};
|
||||
|
||||
export default handlers;
|
||||
@@ -0,0 +1,31 @@
|
||||
export function makeSlug(title: string): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
const h = String(now.getHours()).padStart(2, "0");
|
||||
const mi = String(now.getMinutes()).padStart(2, "0");
|
||||
const s = String(now.getSeconds()).padStart(2, "0");
|
||||
return `${y}${m}${d}${h}${mi}${s}`;
|
||||
}
|
||||
|
||||
export function sessionSummary(s: {
|
||||
title: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
content: string;
|
||||
}): string {
|
||||
const lines = s.content ? s.content.split("\n").filter(Boolean).length : 0;
|
||||
const chars = s.content.length;
|
||||
const parts: string[] = [];
|
||||
parts.push(`\u{1f4dd} <b>${s.title || "未命名"}</b>`);
|
||||
if (s.categories.length) parts.push(`\u{1f4c2} ${s.categories.join("、")}`);
|
||||
if (s.tags.length) parts.push(`\u{1f3f7} ${s.tags.join("、")}`);
|
||||
if (chars) parts.push(`✍ ${lines} 行 ${chars} 字`);
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { startPolling } from "./poll";
|
||||
import { TG_TOKEN } from "./config";
|
||||
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
export async function startBot(): Promise<void> {
|
||||
if (g.__botStarted) return;
|
||||
g.__botStarted = true;
|
||||
|
||||
if (!TG_TOKEN) {
|
||||
console.log("[bot] TG_BOT_TOKEN not configured, bot disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[bot] initializing...");
|
||||
await startPolling();
|
||||
}
|
||||
|
||||
export { TG_TOKEN } from "./config";
|
||||
export { sendMessage } from "./tg";
|
||||
export { getSession, hasSession, clearSession } from "./sessions";
|
||||
@@ -0,0 +1,249 @@
|
||||
import { TG_TOKEN } from "./config";
|
||||
import { tg, allowed, sendMessage } from "./tg";
|
||||
import { hasSession, getSession, appendContent } from "./sessions";
|
||||
import handlers from "./handlers";
|
||||
|
||||
const ARG_COMMANDS = new Set([
|
||||
"new", "title", "categories", "tags", "author",
|
||||
"edit", "view", "delete",
|
||||
"link_add", "link_edit", "link_del", "link_toggle",
|
||||
"feed_add", "feed_edit", "feed_del",
|
||||
"list", "links", "feeds", "read",
|
||||
]);
|
||||
|
||||
const CALLBACK_PREFIXES: Record<string, string> = {
|
||||
edit: "edit",
|
||||
confirm_del: "delete",
|
||||
cancel_del: "noop",
|
||||
list: "list",
|
||||
links: "links",
|
||||
feeds: "feeds",
|
||||
skip_cat: "skip_categories",
|
||||
skip_tag: "skip_tags",
|
||||
confirm_shutdown: "confirm_shutdown",
|
||||
cancel_shutdown: "cancel_shutdown",
|
||||
confirm_reboot: "confirm_reboot",
|
||||
cancel_reboot: "cancel_reboot",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export async function startPolling(): Promise<void> {
|
||||
if (!TG_TOKEN) {
|
||||
console.log("[bot] TG_BOT_TOKEN not set, skipping bot startup");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[bot] starting Telegram polling...");
|
||||
|
||||
let lastOffset = 0;
|
||||
let backoff = 3000;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const data = await tg<Record<string, unknown>[]>("getUpdates", {
|
||||
offset: lastOffset + 1,
|
||||
timeout: 30,
|
||||
allowed_updates: ["message", "callback_query"],
|
||||
});
|
||||
|
||||
if (data.ok && data.result) {
|
||||
for (const update of data.result) {
|
||||
lastOffset = update.update_id as number;
|
||||
|
||||
if (update.callback_query) {
|
||||
const cb = update.callback_query as {
|
||||
message?: { chat?: { id: number } };
|
||||
from?: { id: number };
|
||||
id: string;
|
||||
data?: string;
|
||||
};
|
||||
const cbChatId = cb.message?.chat?.id ?? cb.from?.id ?? 0;
|
||||
if (cb.data && allowed(cbChatId)) {
|
||||
await handleInlineCallback(cbChatId, cb.id, cb.data);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const msg = update.message as {
|
||||
chat?: { id: number };
|
||||
photo?: { file_id: string }[];
|
||||
text?: string;
|
||||
caption?: string;
|
||||
reply_to_message?: { text?: string };
|
||||
} | undefined;
|
||||
if (!msg?.chat?.id) continue;
|
||||
const chatId = msg.chat.id;
|
||||
if (!allowed(chatId)) continue;
|
||||
|
||||
if (msg.photo && hasSession(chatId)) {
|
||||
await handlePhoto(chatId, msg.photo, msg.caption);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!msg.text) continue;
|
||||
const fullText = msg.text.trim();
|
||||
|
||||
// ForceReply routing
|
||||
if (msg.reply_to_message?.text) {
|
||||
const original = msg.reply_to_message.text;
|
||||
if (original.includes("请输入文章标题")) { await handleCommand(chatId, "new", fullText); continue; }
|
||||
if (original.includes("请输入新标题")) { await handleCommand(chatId, "title", fullText); continue; }
|
||||
if (original.includes("请输入分类")) { await handleCommand(chatId, "categories", fullText); continue; }
|
||||
if (original.includes("请输入标签")) { await handleCommand(chatId, "tags", fullText); continue; }
|
||||
if (original.includes("请输入友链信息")) { await handleCommand(chatId, "link_add", fullText); continue; }
|
||||
if (original.includes("请输入新名称")) { await handleCommand(chatId, "link_edit", fullText); continue; }
|
||||
if (original.includes("请输入订阅源 URL")) { await handleCommand(chatId, "feed_add", fullText); continue; }
|
||||
if (original.includes("请输入新的订阅信息")) { await handleCommand(chatId, "feed_edit", fullText); continue; }
|
||||
}
|
||||
|
||||
const parts = fullText.split("@")[0].split(/\s+/);
|
||||
const cmdRaw = parts[0].toLowerCase();
|
||||
const slashCmd = cmdRaw.startsWith("/") ? cmdRaw.slice(1) : "";
|
||||
const slashArg = parts.slice(1).join(" ");
|
||||
|
||||
// In writing session, non-slash text appends to content
|
||||
if (hasSession(chatId) && !cmdRaw.startsWith("/")) {
|
||||
try { appendContent(chatId, fullText); }
|
||||
catch (e) { console.error("[bot:append]", e instanceof Error ? e.message : String(e)); }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmdRaw.startsWith("/")) {
|
||||
await handleCommand(chatId, slashCmd, slashArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backoff = 3000;
|
||||
} catch (e) {
|
||||
console.error("[bot:poll]", e instanceof Error ? e.message : String(e));
|
||||
await sleep(backoff);
|
||||
backoff = Math.min(backoff * 2, 60000);
|
||||
}
|
||||
|
||||
await sleep(100);
|
||||
setImmediate(poll);
|
||||
}
|
||||
|
||||
// Register commands with Telegram
|
||||
try {
|
||||
await tg("setMyCommands", {
|
||||
commands: [
|
||||
{ command: "new", description: "写一篇文章" },
|
||||
{ command: "edit", description: "编辑文章" },
|
||||
{ command: "delete", description: "删除文章" },
|
||||
{ command: "list", description: "文章列表" },
|
||||
{ command: "publish", description: "保存发布" },
|
||||
{ command: "draft", description: "保存草稿" },
|
||||
{ command: "deploy", description: "推送到线上" },
|
||||
{ command: "sync", description: "同步远程" },
|
||||
{ command: "undo", description: "撤销最后一段" },
|
||||
{ command: "preview", description: "预览" },
|
||||
{ command: "links", description: "友链管理" },
|
||||
{ command: "link_add", description: "添加友链" },
|
||||
{ command: "link_edit", description: "编辑友链" },
|
||||
{ command: "link_del", description: "删除友链" },
|
||||
{ command: "link_toggle", description: "显示/隐藏友链" },
|
||||
{ command: "feeds", description: "订阅管理" },
|
||||
{ command: "feed_add", description: "添加订阅源" },
|
||||
{ command: "feed_edit", description: "编辑订阅源" },
|
||||
{ command: "feed_del", description: "删除订阅源" },
|
||||
{ command: "feed_health", description: "订阅源健康检查" },
|
||||
{ command: "read", description: "友链最新文章" },
|
||||
{ command: "stats", description: "网站数据" },
|
||||
{ command: "shutdown", description: "关机" },
|
||||
{ command: "reboot", description: "重启" },
|
||||
{ command: "help", description: "帮助" },
|
||||
],
|
||||
});
|
||||
console.log("[bot] commands registered");
|
||||
} catch (e) {
|
||||
console.error("[bot] failed to register commands:", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
poll();
|
||||
}
|
||||
|
||||
async function handlePhoto(chatId: number, photos: { file_id: string }[], caption?: string) {
|
||||
try {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const { getSession } = await import("./sessions");
|
||||
|
||||
const s = getSession(chatId);
|
||||
if (!s.dirPath) {
|
||||
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
|
||||
return;
|
||||
}
|
||||
|
||||
const largestPhoto = photos[photos.length - 1];
|
||||
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: largestPhoto.file_id });
|
||||
if (!fileRes.ok || !fileRes.result?.file_path) return;
|
||||
|
||||
const filePath = fileRes.result.file_path;
|
||||
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
|
||||
const imgBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer());
|
||||
|
||||
// Save to article's own folder (Hugo page bundle)
|
||||
const articleDir = s.dirPath;
|
||||
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
|
||||
|
||||
const ext = filePath.split(".").pop() || "jpg";
|
||||
const filename = Date.now() + "-" + largestPhoto.file_id.slice(0, 8) + "." + ext;
|
||||
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(imgBuffer));
|
||||
|
||||
const imgCaption = caption || "image";
|
||||
appendContent(chatId, `\n\n`);
|
||||
await sendMessage(chatId, `📷 图片已保存:${filename}`);
|
||||
} catch (e) {
|
||||
console.error("[bot:photo]", e instanceof Error ? e.message : String(e));
|
||||
await sendMessage(chatId, "❌ 图片保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommand(chatId: number, cmd: string, arg?: string) {
|
||||
try {
|
||||
if (cmd === "noop") return;
|
||||
if (ARG_COMMANDS.has(cmd)) {
|
||||
const handler = handlers[cmd];
|
||||
if (handler) await handler(chatId, arg);
|
||||
else await sendMessage(chatId, "❌ 未知命令:/" + cmd);
|
||||
} else {
|
||||
const handler = handlers[cmd];
|
||||
if (handler) await handler(chatId);
|
||||
else if (cmd) await sendMessage(chatId, "❌ 未知命令:/" + cmd);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[bot:" + cmd + "]", e instanceof Error ? e.message : String(e));
|
||||
try {
|
||||
await sendMessage(chatId, "❌ " + (e instanceof Error ? e.message : "error").slice(0, 200));
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInlineCallback(chatId: number, queryId: string, data: string) {
|
||||
let prefix = "";
|
||||
let arg = "";
|
||||
// First try prefix match (e.g. "edit_slug", "list_2")
|
||||
for (const p of Object.keys(CALLBACK_PREFIXES)) {
|
||||
if (data.startsWith(p + "_")) {
|
||||
prefix = p;
|
||||
arg = data.slice(p.length + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Then try exact match (e.g. "confirm_shutdown", "skip_cat")
|
||||
if (!prefix && CALLBACK_PREFIXES[data]) {
|
||||
prefix = data;
|
||||
}
|
||||
if (!prefix) {
|
||||
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
|
||||
return;
|
||||
}
|
||||
const cmd = CALLBACK_PREFIXES[prefix];
|
||||
await handleCommand(chatId, cmd, arg);
|
||||
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createPost, updatePost, getPost } from "@/lib/posts";
|
||||
import { DEFAULT_AUTHOR, CONTENT_DIR } from "@/lib/config";
|
||||
import path from "path";
|
||||
|
||||
export interface Session {
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
author: string;
|
||||
draft: boolean;
|
||||
chunks: string[];
|
||||
originalSlug?: string;
|
||||
dirPath: string;
|
||||
}
|
||||
|
||||
function computeDirPath(title: string, date?: string): string {
|
||||
const d = date ? new Date(date) : new Date();
|
||||
const year = d.getFullYear().toString();
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
const titlePart = (title || "untitled")
|
||||
.replace(/[^\w一-鿿㐀-䶿]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase() || "post";
|
||||
return path.join(CONTENT_DIR, year, `${dateStr}-${titlePart}`);
|
||||
}
|
||||
|
||||
const sessions = new Map<number, Session>();
|
||||
|
||||
export function getSession(chatId: number): Session {
|
||||
if (!sessions.has(chatId)) {
|
||||
sessions.set(chatId, {
|
||||
title: "",
|
||||
slug: "",
|
||||
content: "",
|
||||
categories: [],
|
||||
tags: [],
|
||||
author: DEFAULT_AUTHOR,
|
||||
draft: true,
|
||||
chunks: [],
|
||||
dirPath: "",
|
||||
});
|
||||
}
|
||||
return sessions.get(chatId)!;
|
||||
}
|
||||
|
||||
export function hasSession(chatId: number): boolean {
|
||||
return sessions.has(chatId);
|
||||
}
|
||||
|
||||
export function clearSession(chatId: number): void {
|
||||
sessions.delete(chatId);
|
||||
}
|
||||
|
||||
export function appendContent(chatId: number, text: string): void {
|
||||
const s = getSession(chatId);
|
||||
s.chunks.push(text + "\n\n");
|
||||
s.content += text + "\n\n";
|
||||
}
|
||||
|
||||
export function undoLastChunk(chatId: number): boolean {
|
||||
const s = getSession(chatId);
|
||||
if (s.chunks.length === 0) return false;
|
||||
const lastChunk = s.chunks.pop()!;
|
||||
const idx = s.content.lastIndexOf(lastChunk);
|
||||
if (idx >= 0) {
|
||||
s.content = s.content.slice(0, idx) + s.content.slice(idx + lastChunk.length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function initSessionDir(chatId: number, title: string, date?: string): void {
|
||||
const s = getSession(chatId);
|
||||
if (!s.dirPath) {
|
||||
s.dirPath = computeDirPath(title, date);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSession(s: Session, isDraft: boolean): Promise<void> {
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const frontMatter = {
|
||||
title: s.title,
|
||||
slug: s.slug,
|
||||
date: dateStr,
|
||||
draft: isDraft,
|
||||
categories: s.categories,
|
||||
tags: s.tags,
|
||||
author: s.author,
|
||||
};
|
||||
|
||||
const existing = getPost(s.slug);
|
||||
if (existing) {
|
||||
const result = updatePost(s.slug, frontMatter, s.content);
|
||||
if (!result.success) throw new Error("更新文章失败");
|
||||
s.dirPath = result.dirPath;
|
||||
} else {
|
||||
const dirPath = createPost(frontMatter, s.content);
|
||||
s.dirPath = dirPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { API, ALLOWED_IDS } from "./config";
|
||||
|
||||
interface TgResponse<T = unknown> {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function tg<T = unknown>(
|
||||
method: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<TgResponse<T>> {
|
||||
const res = await fetch(`${API}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json() as Promise<TgResponse<T>>;
|
||||
}
|
||||
|
||||
export function allowed(chatId: number): boolean {
|
||||
if (ALLOWED_IDS.length === 0) return true;
|
||||
return ALLOWED_IDS.includes(chatId);
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
chatId: number,
|
||||
text: string,
|
||||
opts?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const safeText = String(text || "");
|
||||
try {
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: safeText,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
...opts,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("parse") || msg.includes("HTML")) {
|
||||
await tg("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: safeText,
|
||||
...opts,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
// 支持环境变量配置 BLOG_ROOT(Linux 部署)
|
||||
// Docker 部署时,BLOG_ROOT 通过环境变量传入
|
||||
export const BLOG_ROOT = process.env.BLOG_ROOT
|
||||
? path.resolve(process.env.BLOG_ROOT)
|
||||
: path.resolve(process.cwd(), "..");
|
||||
|
||||
export const CONTENT_DIR = path.join(BLOG_ROOT, "content", "posts");
|
||||
export const STATIC_DIR = path.join(BLOG_ROOT, "static");
|
||||
export const RECYCLE_DIR = path.join(process.cwd(), "recycle");
|
||||
export const RECYCLE_POSTS_DIR = path.join(RECYCLE_DIR, "posts");
|
||||
export const RECYCLE_META_DIR = path.join(RECYCLE_DIR, "meta");
|
||||
export const ARTALK_SERVER = process.env.ARTALK_SERVER || "https://artalk.usj.cc";
|
||||
export const ARTALK_SITE = process.env.ARTALK_SITE || "优世界";
|
||||
export const ITEMS_PER_PAGE = parseInt(process.env.ITEMS_PER_PAGE || "20", 10);
|
||||
export const RECYCLE_DAYS = parseInt(process.env.RECYCLE_DAYS || "30", 10);
|
||||
export const HUGO_PREVIEW = process.env.HUGO_PREVIEW || "http://localhost:1313";
|
||||
export const RSS_API_BASE = process.env.RSS_API_BASE || "https://api.usj.cc";
|
||||
export const RSS_API_TOKEN = process.env.RSS_API_TOKEN || "";
|
||||
export const WECHAT_APP_ID = process.env.WECHAT_APP_ID || "";
|
||||
export const WECHAT_APP_SECRET = process.env.WECHAT_APP_SECRET || "";
|
||||
|
||||
// Bot 配置
|
||||
export const TG_BOT_TOKEN = process.env.TG_BOT_TOKEN || "";
|
||||
export const TG_ALLOWED_IDS: number[] = (process.env.TG_ALLOWED_CHAT_IDS || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean).map(Number);
|
||||
|
||||
// Linux 默认网络接口(eth0, ens33 等),Windows 默认 WLAN
|
||||
export const PREFER_IFACE = process.env.PREFER_IFACE || (process.platform === "linux" ? "eth0" : "WLAN");
|
||||
export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";
|
||||
|
||||
export function getLanIP(): string {
|
||||
const ifs = os.networkInterfaces();
|
||||
const candidates: { name: string; address: string }[] = [];
|
||||
for (const [name, addrs] of Object.entries(ifs)) {
|
||||
if (!addrs) continue;
|
||||
for (const a of addrs) {
|
||||
if (a.family !== "IPv4" || a.internal) continue;
|
||||
if (a.address.startsWith("169.254.")) continue;
|
||||
candidates.push({ name, address: a.address });
|
||||
}
|
||||
}
|
||||
|
||||
// 优先查找首选接口
|
||||
const pref = candidates.find((c) =>
|
||||
c.name.toLowerCase().includes(PREFER_IFACE.toLowerCase())
|
||||
);
|
||||
if (pref) return pref.address;
|
||||
|
||||
// 查找真实网络接口(排除虚拟接口)
|
||||
const real = candidates.find((c) => {
|
||||
const n = c.name.toLowerCase();
|
||||
return !n.includes("vmware") &&
|
||||
!n.includes("virtual") &&
|
||||
!n.includes("vethernet") &&
|
||||
!n.includes("docker") &&
|
||||
!n.includes("br-") &&
|
||||
!n.includes("veth");
|
||||
});
|
||||
if (real) return real.address;
|
||||
|
||||
// 返回第一个可用地址
|
||||
return candidates[0]?.address || "0.0.0.0";
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { BLOG_ROOT } from "./config";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type GitExecError = Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
};
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
message: string;
|
||||
resolve: (result: DeployResult) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
class GitDeployQueue {
|
||||
private queue: QueueItem[] = [];
|
||||
private running = false;
|
||||
|
||||
async enqueue(commitMessage: string): Promise<DeployResult> {
|
||||
return new Promise<DeployResult>((resolve, reject) => {
|
||||
this.queue.push({ message: commitMessage, resolve, reject });
|
||||
if (!this.running) this.next();
|
||||
});
|
||||
}
|
||||
|
||||
async sync(): Promise<DeployResult> {
|
||||
try {
|
||||
const result = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
|
||||
return { success: true, output: result.stdout + result.stderr };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (this.isRebaseConflict(msg)) {
|
||||
await this.runGit(["rebase", "--abort"], true);
|
||||
return { success: false, conflict: true, error: "远程冲突,已放弃合并" };
|
||||
}
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async next(): Promise<void> {
|
||||
const item = this.queue.shift();
|
||||
if (!item) {
|
||||
this.running = false;
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
const result = await this.execute(item.message);
|
||||
item.resolve(result);
|
||||
} catch (e) {
|
||||
item.reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
this.next();
|
||||
}
|
||||
|
||||
private async runGit(args: string[], allowFailure = false) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("git", args, {
|
||||
cwd: BLOG_ROOT,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return { stdout, stderr, code: 0 };
|
||||
} catch (error) {
|
||||
const err = error as GitExecError;
|
||||
const result = {
|
||||
stdout: err.stdout || "",
|
||||
stderr: err.stderr || err.message,
|
||||
code: typeof err.code === "number" ? err.code : 1,
|
||||
};
|
||||
if (allowFailure) return result;
|
||||
throw new Error((result.stderr || result.stdout || err.message).trim());
|
||||
}
|
||||
}
|
||||
|
||||
private isRebaseConflict(message: string): boolean {
|
||||
return /conflict|CONFLICT|Resolve all conflicts|Automatic merge failed|could not apply|rebase/i.test(message);
|
||||
}
|
||||
|
||||
private async execute(message: string): Promise<DeployResult> {
|
||||
let output = "";
|
||||
|
||||
// git add
|
||||
const addResult = await this.runGit(["add", "content"]);
|
||||
output += addResult.stdout + addResult.stderr;
|
||||
|
||||
// git diff --cached --quiet (exit 1 = staged changes exist)
|
||||
const diffResult = await this.runGit(["diff", "--cached", "--quiet"], true);
|
||||
if (diffResult.code === 1) {
|
||||
const commitResult = await this.runGit(["commit", "-m", message]);
|
||||
output += commitResult.stdout + commitResult.stderr;
|
||||
} else if (diffResult.code !== 0) {
|
||||
return { success: false, error: (diffResult.stderr || diffResult.stdout || "检查暂存区失败").trim() };
|
||||
}
|
||||
|
||||
// git pull --rebase --autostash
|
||||
try {
|
||||
const pullResult = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
|
||||
output += pullResult.stdout + pullResult.stderr;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (this.isRebaseConflict(msg)) {
|
||||
await this.runGit(["rebase", "--abort"], true);
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
error: "远程有冲突,已自动放弃合并。请先 /sync 同步,手动解决后再 deploy。",
|
||||
output: msg.slice(0, 1000),
|
||||
};
|
||||
}
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
|
||||
// git push
|
||||
try {
|
||||
const pushResult = await this.runGit(["push"]);
|
||||
output += pushResult.stdout + pushResult.stderr;
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
return { success: true, output: output.slice(0, 1000) };
|
||||
}
|
||||
}
|
||||
|
||||
export const deployQueue = new GitDeployQueue();
|
||||
@@ -0,0 +1,114 @@
|
||||
import { spawn, execSync, type ChildProcess } from "child_process";
|
||||
import { BLOG_ROOT } from "./config";
|
||||
|
||||
class HugoManager {
|
||||
private process: ChildProcess | null = null;
|
||||
private ready = false;
|
||||
private starting = false;
|
||||
private healthTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
async start(): Promise<{ ok: boolean; alreadyRunning?: boolean }> {
|
||||
if (this.process && this.ready) {
|
||||
return { ok: true, alreadyRunning: true };
|
||||
}
|
||||
if (this.starting) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
this.starting = true;
|
||||
this.ready = false;
|
||||
|
||||
this.process = spawn("hugo", ["server", "--bind", "0.0.0.0", "--port", "1313", "--disableFastRender", "--noHTTPCache"], {
|
||||
cwd: BLOG_ROOT,
|
||||
shell: true,
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.process.stdout?.on("data", (d: Buffer) => process.stdout.write(`[hugo] ${d}`));
|
||||
this.process.stderr?.on("data", (d: Buffer) => process.stderr.write(`[hugo] ${d}`));
|
||||
|
||||
this.process.on("exit", (code) => {
|
||||
console.log(`[hugo] exited ${code}`);
|
||||
this.process = null;
|
||||
this.ready = false;
|
||||
this.starting = false;
|
||||
});
|
||||
|
||||
// 等待 Hugo 就绪(最多 15 秒)
|
||||
const ok = await this.waitForReady(15000);
|
||||
this.starting = false;
|
||||
if (ok) {
|
||||
this.ready = true;
|
||||
this.startHealthCheck();
|
||||
console.log("[hugo] ready on 0.0.0.0:1313");
|
||||
}
|
||||
return { ok };
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopHealthCheck();
|
||||
if (!this.process) return;
|
||||
try {
|
||||
const pid = this.process.pid;
|
||||
if (process.platform === "win32" && pid) {
|
||||
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
|
||||
} else {
|
||||
this.process.kill();
|
||||
}
|
||||
} catch (_e) { /* nothing to kill */ }
|
||||
this.process = null;
|
||||
this.ready = false;
|
||||
this.starting = false;
|
||||
}
|
||||
|
||||
status(): { running: boolean; starting: boolean } {
|
||||
return { running: !!(this.process && this.ready), starting: this.starting };
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<boolean> {
|
||||
if (this.process && this.ready) return true;
|
||||
const result = await this.start();
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
private async waitForReady(timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.ping()) return true;
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async ping(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("http://127.0.0.1:1313", { signal: AbortSignal.timeout(1500) });
|
||||
return res.ok || res.status < 500;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
this.stopHealthCheck();
|
||||
this.healthTimer = setInterval(async () => {
|
||||
if (!this.process) return;
|
||||
const alive = await this.ping();
|
||||
if (!alive) {
|
||||
console.warn("[hugo] health check failed, marking as not ready");
|
||||
this.ready = false;
|
||||
this.stopHealthCheck();
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
private stopHealthCheck(): void {
|
||||
if (this.healthTimer) {
|
||||
clearInterval(this.healthTimer);
|
||||
this.healthTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hugo = new HugoManager();
|
||||
@@ -0,0 +1,256 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
import { CONTENT_DIR, ITEMS_PER_PAGE } from "./config";
|
||||
|
||||
export interface PostFrontMatter {
|
||||
pid?: number;
|
||||
title: string;
|
||||
date: string;
|
||||
slug: string;
|
||||
draft: boolean;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
author?: string;
|
||||
layout?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
slug: string;
|
||||
frontMatter: PostFrontMatter;
|
||||
content: string;
|
||||
filePath: string;
|
||||
dirPath: string;
|
||||
}
|
||||
|
||||
export interface PostListItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
draft: boolean;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
author: string;
|
||||
}
|
||||
|
||||
function parseDate(dateStr: string): Date {
|
||||
const d = new Date(dateStr);
|
||||
return isNaN(d.getTime()) ? new Date(0) : d;
|
||||
}
|
||||
|
||||
function findPostDirectories(): string[] {
|
||||
const result: string[] = [];
|
||||
if (!fs.existsSync(CONTENT_DIR)) return result;
|
||||
|
||||
const years = fs.readdirSync(CONTENT_DIR).filter((f) => {
|
||||
const full = path.join(CONTENT_DIR, f);
|
||||
return fs.statSync(full).isDirectory() && /^\d{4}$/.test(f);
|
||||
});
|
||||
|
||||
for (const year of years) {
|
||||
const yearDir = path.join(CONTENT_DIR, year);
|
||||
const posts = fs.readdirSync(yearDir).filter((f) => {
|
||||
return fs.statSync(path.join(yearDir, f)).isDirectory();
|
||||
});
|
||||
for (const post of posts) {
|
||||
result.push(path.join(yearDir, post));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function slugFromDirName(dirName: string): string {
|
||||
// e.g. "2026-01-21-how-to-implement-friend-circle-in-hugo" -> "how-to-implement-friend-circle-in-hugo"
|
||||
const match = dirName.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
|
||||
return match ? match[1] : dirName;
|
||||
}
|
||||
|
||||
function yearFromDirName(dirName: string): string {
|
||||
const match = dirName.match(/^(\d{4})/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
export function listPosts(options?: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
includeDrafts?: boolean;
|
||||
}): { posts: PostListItem[]; total: number; totalPages: number } {
|
||||
const { search = "", page = 1, perPage = ITEMS_PER_PAGE, includeDrafts = true } = options || {};
|
||||
const dirs = findPostDirectories();
|
||||
|
||||
const allPosts: PostListItem[] = [];
|
||||
|
||||
for (const dirPath of dirs) {
|
||||
const indexPath = path.join(dirPath, "index.md");
|
||||
if (!fs.existsSync(indexPath)) continue;
|
||||
|
||||
const raw = fs.readFileSync(indexPath, "utf-8");
|
||||
const { data } = matter(raw);
|
||||
const dirName = path.basename(dirPath);
|
||||
|
||||
const post: PostListItem = {
|
||||
slug: String(data.slug || slugFromDirName(dirName)),
|
||||
title: data.title || "Untitled",
|
||||
date: data.date ? new Date(data.date).toISOString() : "",
|
||||
draft: data.draft === true,
|
||||
categories: Array.isArray(data.categories) ? data.categories : [],
|
||||
tags: Array.isArray(data.tags) ? data.tags : [],
|
||||
author: data.author || "",
|
||||
};
|
||||
|
||||
if (!includeDrafts && post.draft) continue;
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const match =
|
||||
post.title.toLowerCase().includes(q) ||
|
||||
post.slug.toLowerCase().includes(q) ||
|
||||
post.categories.some((c) => c.toLowerCase().includes(q)) ||
|
||||
post.tags.some((t) => t.toLowerCase().includes(q));
|
||||
if (!match) continue;
|
||||
}
|
||||
|
||||
allPosts.push(post);
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
allPosts.sort((a, b) => parseDate(b.date).getTime() - parseDate(a.date).getTime());
|
||||
|
||||
const total = allPosts.length;
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
const start = (page - 1) * perPage;
|
||||
const posts = allPosts.slice(start, start + perPage);
|
||||
|
||||
return { posts, total, totalPages };
|
||||
}
|
||||
|
||||
export function getPost(slug: string): Post | null {
|
||||
const dirs = findPostDirectories();
|
||||
|
||||
for (const dirPath of dirs) {
|
||||
const indexPath = path.join(dirPath, "index.md");
|
||||
if (!fs.existsSync(indexPath)) continue;
|
||||
|
||||
const raw = fs.readFileSync(indexPath, "utf-8");
|
||||
const { data, content } = matter(raw);
|
||||
const existingSlug = String(data.slug || slugFromDirName(path.basename(dirPath)));
|
||||
|
||||
if (existingSlug === slug) {
|
||||
return {
|
||||
slug: existingSlug,
|
||||
frontMatter: data as PostFrontMatter,
|
||||
content,
|
||||
filePath: indexPath,
|
||||
dirPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createPost(frontMatter: PostFrontMatter, content: string): string {
|
||||
const date = new Date(frontMatter.date || new Date());
|
||||
const year = date.getFullYear().toString();
|
||||
const dateStr = date.toISOString().slice(0, 10);
|
||||
// Folder name: date + sanitized title (human-readable on disk)
|
||||
const titlePart = (frontMatter.title || "untitled")
|
||||
.replace(/[^\w一-鿿㐀-䶿]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase() || "post";
|
||||
const dirName = `${dateStr}-${titlePart}`;
|
||||
const dirPath = path.join(CONTENT_DIR, year, dirName);
|
||||
|
||||
if (fs.existsSync(path.join(dirPath, "index.md"))) {
|
||||
throw new Error(`Post already exists at ${dirPath}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
|
||||
const fm: Record<string, unknown> = {
|
||||
title: frontMatter.title,
|
||||
date: frontMatter.date,
|
||||
slug: frontMatter.slug,
|
||||
draft: frontMatter.draft ?? false,
|
||||
categories: frontMatter.categories || [],
|
||||
tags: frontMatter.tags || [],
|
||||
author: frontMatter.author || "",
|
||||
layout: frontMatter.layout || "post",
|
||||
};
|
||||
|
||||
const fileContent = matter.stringify(content, fm);
|
||||
const indexPath = path.join(dirPath, "index.md");
|
||||
fs.writeFileSync(indexPath, fileContent, "utf-8");
|
||||
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
export function updatePost(
|
||||
slug: string,
|
||||
frontMatter: Partial<PostFrontMatter>,
|
||||
content?: string
|
||||
): { success: true; dirPath: string } | { success: false } {
|
||||
const post = getPost(slug);
|
||||
if (!post) return { success: false };
|
||||
|
||||
const merged = { ...post.frontMatter, ...frontMatter };
|
||||
|
||||
// If slug changed, we need to rename the directory
|
||||
if (frontMatter.slug && frontMatter.slug !== post.frontMatter.slug) {
|
||||
const newDate = new Date(merged.date || post.frontMatter.date);
|
||||
const newYear = newDate.getFullYear().toString();
|
||||
const newDateStr = newDate.toISOString().slice(0, 10);
|
||||
const titlePart = (merged.title || "untitled")
|
||||
.replace(/[^\w一-鿿㐀-䶿]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase() || "post";
|
||||
const newDirName = `${newDateStr}-${titlePart}`;
|
||||
const newDirPath = path.join(CONTENT_DIR, newYear, newDirName);
|
||||
|
||||
if (fs.existsSync(newDirPath) && newDirPath !== post.dirPath) {
|
||||
throw new Error(`Target path already exists: ${newDirPath}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(newDirPath), { recursive: true });
|
||||
fs.renameSync(post.dirPath, newDirPath);
|
||||
|
||||
const fm: Record<string, unknown> = {
|
||||
title: merged.title,
|
||||
date: merged.date,
|
||||
slug: frontMatter.slug,
|
||||
draft: merged.draft ?? false,
|
||||
categories: merged.categories || [],
|
||||
tags: merged.tags || [],
|
||||
author: merged.author || "",
|
||||
layout: merged.layout || "post",
|
||||
};
|
||||
|
||||
const fileContent = matter.stringify(content ?? post.content, fm);
|
||||
fs.writeFileSync(path.join(newDirPath, "index.md"), fileContent, "utf-8");
|
||||
return { success: true, dirPath: newDirPath };
|
||||
}
|
||||
|
||||
// Update in place
|
||||
const fm: Record<string, unknown> = {
|
||||
title: merged.title,
|
||||
date: merged.date,
|
||||
slug: merged.slug,
|
||||
draft: merged.draft ?? false,
|
||||
categories: merged.categories || [],
|
||||
tags: merged.tags || [],
|
||||
author: merged.author || "",
|
||||
layout: merged.layout || "post",
|
||||
};
|
||||
|
||||
const fileContent = matter.stringify(content ?? post.content, fm);
|
||||
fs.writeFileSync(post.filePath, fileContent, "utf-8");
|
||||
return { success: true, dirPath: post.dirPath };
|
||||
}
|
||||
|
||||
export function getPostCount(): number {
|
||||
return findPostDirectories().filter((dir) =>
|
||||
fs.existsSync(path.join(dir, "index.md"))
|
||||
).length;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
RECYCLE_DIR,
|
||||
RECYCLE_POSTS_DIR,
|
||||
RECYCLE_META_DIR,
|
||||
RECYCLE_DAYS,
|
||||
} from "./config";
|
||||
|
||||
export interface RecycleItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
deletedAt: string; // ISO date
|
||||
originalPath: string;
|
||||
expiresAt: string; // ISO date
|
||||
daysRemaining: number;
|
||||
}
|
||||
|
||||
export function initRecycleBin(): void {
|
||||
if (!fs.existsSync(RECYCLE_DIR)) {
|
||||
fs.mkdirSync(RECYCLE_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(RECYCLE_POSTS_DIR)) {
|
||||
fs.mkdirSync(RECYCLE_POSTS_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(RECYCLE_META_DIR)) {
|
||||
fs.mkdirSync(RECYCLE_META_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function moveToRecycle(
|
||||
slug: string,
|
||||
sourceDir: string,
|
||||
title: string
|
||||
): void {
|
||||
initRecycleBin();
|
||||
|
||||
const now = new Date();
|
||||
const expires = new Date(now);
|
||||
expires.setDate(expires.getDate() + RECYCLE_DAYS);
|
||||
|
||||
const meta: Omit<RecycleItem, "daysRemaining"> = {
|
||||
slug,
|
||||
title,
|
||||
deletedAt: now.toISOString(),
|
||||
originalPath: sourceDir,
|
||||
expiresAt: expires.toISOString(),
|
||||
};
|
||||
|
||||
// Move post directory to recycle
|
||||
const targetDir = path.join(RECYCLE_POSTS_DIR, slug);
|
||||
if (fs.existsSync(targetDir)) {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(sourceDir, targetDir);
|
||||
|
||||
// Save metadata
|
||||
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
|
||||
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function listRecycle(): RecycleItem[] {
|
||||
initRecycleBin();
|
||||
|
||||
if (!fs.existsSync(RECYCLE_META_DIR)) return [];
|
||||
|
||||
const items: RecycleItem[] = [];
|
||||
const files = fs.readdirSync(RECYCLE_META_DIR).filter((f) => f.endsWith(".json"));
|
||||
|
||||
for (const file of files) {
|
||||
const metaPath = path.join(RECYCLE_META_DIR, file);
|
||||
const raw = fs.readFileSync(metaPath, "utf-8");
|
||||
const meta = JSON.parse(raw);
|
||||
const now = new Date();
|
||||
const expires = new Date(meta.expiresAt);
|
||||
const daysRemaining = Math.max(
|
||||
0,
|
||||
Math.ceil((expires.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
);
|
||||
|
||||
items.push({ ...meta, daysRemaining });
|
||||
}
|
||||
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime()
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function restoreFromRecycle(slug: string): boolean {
|
||||
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
|
||||
if (!fs.existsSync(metaPath)) return false;
|
||||
|
||||
const raw = fs.readFileSync(metaPath, "utf-8");
|
||||
const meta = JSON.parse(raw);
|
||||
const recyclePostPath = path.join(RECYCLE_POSTS_DIR, slug);
|
||||
|
||||
if (!fs.existsSync(recyclePostPath)) return false;
|
||||
|
||||
// Ensure target directory exists
|
||||
const targetDir = path.dirname(meta.originalPath);
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// If target already exists, remove it first
|
||||
if (fs.existsSync(meta.originalPath)) {
|
||||
fs.rmSync(meta.originalPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
fs.renameSync(recyclePostPath, meta.originalPath);
|
||||
fs.unlinkSync(metaPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function permanentDelete(slug: string): boolean {
|
||||
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
|
||||
const recyclePostPath = path.join(RECYCLE_POSTS_DIR, slug);
|
||||
|
||||
let deleted = false;
|
||||
if (fs.existsSync(recyclePostPath)) {
|
||||
fs.rmSync(recyclePostPath, { recursive: true, force: true });
|
||||
deleted = true;
|
||||
}
|
||||
if (fs.existsSync(metaPath)) {
|
||||
fs.unlinkSync(metaPath);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function cleanExpired(): number {
|
||||
const items = listRecycle();
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
if (item.daysRemaining <= 0) {
|
||||
permanentDelete(item.slug);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { RSS_API_BASE, RSS_API_TOKEN, WECHAT_APP_ID, WECHAT_APP_SECRET } from "./config";
|
||||
|
||||
async function rssFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${RSS_API_BASE}${path}`;
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) throw new Error(`RSS API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function withToken(params?: URLSearchParams): string {
|
||||
const p = params || new URLSearchParams();
|
||||
if (RSS_API_TOKEN) p.set("token", RSS_API_TOKEN);
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface Feed {
|
||||
url: string;
|
||||
feedTitle: string;
|
||||
siteUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FeedHealth {
|
||||
url: string;
|
||||
status: number;
|
||||
ok: boolean;
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
hidden?: boolean;
|
||||
rss?: string;
|
||||
addedAt?: string;
|
||||
}
|
||||
|
||||
export interface LinkHealth {
|
||||
url: string;
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
feedName: string;
|
||||
siteUrl: string;
|
||||
favicon: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface HealthResult {
|
||||
total: number;
|
||||
alive: number;
|
||||
dead: number;
|
||||
results: FeedHealth[];
|
||||
}
|
||||
|
||||
// ---- Feeds API ----
|
||||
|
||||
export async function getFeeds(): Promise<Feed[]> {
|
||||
const data = await rssFetch<{ feeds: Feed[] }>("/api/feeds");
|
||||
return data.feeds || [];
|
||||
}
|
||||
|
||||
export async function addFeed(url: string, feedTitle: string): Promise<void> {
|
||||
const qs = withToken();
|
||||
await rssFetch(`/api/feeds?${qs}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, feedTitle }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateFeed(oldUrl: string, newUrl: string, feedTitle: string): Promise<void> {
|
||||
const qs = withToken();
|
||||
const body: Record<string, string> = { url: newUrl, feedTitle };
|
||||
if (oldUrl && oldUrl !== newUrl) body.oldUrl = oldUrl;
|
||||
await rssFetch(`/api/feeds?${qs}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteFeed(url: string): Promise<void> {
|
||||
const qs = withToken(new URLSearchParams({ url, token: RSS_API_TOKEN }));
|
||||
await rssFetch(`/api/feeds?${qs}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getFeedsHealth(): Promise<HealthResult> {
|
||||
return rssFetch("/api/health");
|
||||
}
|
||||
|
||||
// ---- Articles API ----
|
||||
|
||||
export async function getArticles(limit = 10): Promise<Article[]> {
|
||||
const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`);
|
||||
return data.articles || [];
|
||||
}
|
||||
|
||||
// ---- Links API ----
|
||||
|
||||
export async function getLinks(all = false): Promise<Link[]> {
|
||||
const p = new URLSearchParams();
|
||||
if (all) p.set("all", "1");
|
||||
const data = await rssFetch<{ links: Link[] }>(`/api/links?${p.toString()}`);
|
||||
return data.links || [];
|
||||
}
|
||||
|
||||
export async function addLink(link: { name: string; url: string; description?: string; hidden?: boolean }): Promise<void> {
|
||||
const qs = withToken();
|
||||
await rssFetch(`/api/links?${qs}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(link),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLink(oldUrl: string, data: { name?: string; url?: string; description?: string; hidden?: boolean }): Promise<void> {
|
||||
const qs = withToken();
|
||||
const body: Record<string, unknown> = { ...data };
|
||||
if (data.url && data.url !== oldUrl) body.oldUrl = oldUrl;
|
||||
await rssFetch(`/api/links?${qs}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLink(url: string): Promise<void> {
|
||||
const qs = withToken(new URLSearchParams({ url, token: RSS_API_TOKEN }));
|
||||
await rssFetch(`/api/links?${qs}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ---- SCF Proxy ----
|
||||
|
||||
const SCF_PROXY = process.env.SCF_PROXY || "https://scfapi.usj.cc";
|
||||
|
||||
export async function checkLinkHealth(targetUrl: string): Promise<LinkHealth> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 12000);
|
||||
const res = await fetch(targetUrl, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; BlogAdmin/1.0)" },
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return { url: targetUrl, ok: res.ok || res.status < 500, status: res.status, latency: Date.now() - start };
|
||||
} catch (err: unknown) {
|
||||
return { url: targetUrl, ok: false, error: err instanceof Error ? err.message : "timeout", latency: Date.now() - start };
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkLinkViaProxy(targetUrl: string): Promise<LinkHealth> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(SCF_PROXY, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: targetUrl, timeout: 12000 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return {
|
||||
url: targetUrl,
|
||||
ok: data.ok && (data.status < 500),
|
||||
status: data.status,
|
||||
latency: Date.now() - start,
|
||||
error: data.ok ? undefined : data.error,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { url: targetUrl, ok: false, error: err instanceof Error ? err.message : "proxy error", latency: Date.now() - start };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- WeChat Material API ----
|
||||
|
||||
export interface WechatArticle {
|
||||
title: string;
|
||||
thumb_media_url?: string;
|
||||
thumb_media_id?: string;
|
||||
author?: string;
|
||||
digest?: string;
|
||||
show_cover_pic?: number;
|
||||
content: string;
|
||||
content_source_url?: string;
|
||||
content_image_urls?: { original_url: string }[];
|
||||
need_open_comment?: number;
|
||||
only_fans_can_comment?: number;
|
||||
}
|
||||
|
||||
export async function getWechatStatus(): Promise<{ configured: boolean; appId?: string }> {
|
||||
const data = await rssFetch<{ configured: boolean; appId?: string }>("/api/wechat-material");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function syncWechat(articles: Record<string, unknown>[]): Promise<{ ok: boolean; media_id?: string; message?: string }> {
|
||||
const qs = withToken();
|
||||
const body: Record<string, unknown> = { articles };
|
||||
if (WECHAT_APP_ID) body.appId = WECHAT_APP_ID;
|
||||
if (WECHAT_APP_SECRET) body.appSecret = WECHAT_APP_SECRET;
|
||||
const res = await fetch(`${RSS_API_BASE}/api/wechat-material?${qs}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
throw new Error(data.error || data.message || `WeChat API error: ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
Reference in new issue
Block a user