feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3

This commit is contained in:
RhenCloud 2026-08-13 17:43:33 +08:00
parent f4959eebf8
commit b139712a91
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
166 changed files with 19790 additions and 5539 deletions

View file

@ -0,0 +1,247 @@
import { log } from "../../lib/log";
import {
getOAuthURL,
commentAsUser,
mergePullRequestAsUser,
closePullRequestAsUser,
} from "../../github/oauth";
import { getTelegramLink, removeTelegramLink } from "../../github/store";
import type { Env } from "../../types";
import { sendMessage } from "./rest";
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
const GITHUB_PR_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/;
interface TelegramMessage {
message_id?: number;
text?: string;
from?: { id?: number; first_name?: string; username?: string };
chat?: { id?: number; type?: string; title?: string };
date?: number;
message_thread_id?: number;
entities?: Array<{ type?: string; url?: string; offset?: number; length?: number }>;
reply_to_message?: TelegramMessage;
}
interface Target {
owner: string;
repo: string;
number: number;
}
function chatIdOf(msg: TelegramMessage): string | null {
return msg.chat?.id != null ? String(msg.chat.id) : null;
}
function userIdOf(msg: TelegramMessage): string | null {
return msg.from?.id != null ? String(msg.from.id) : null;
}
/** Extract a GitHub issue/PR link from a message (entities text_link or raw text). */
function extractTarget(msg: TelegramMessage, prOnly = false): Target | null {
const urls: string[] = [];
for (const ent of msg.entities ?? []) {
if (ent.type === "text_link" && ent.url) urls.push(ent.url);
}
if (msg.text) {
for (const u of msg.text.match(/https?:\/\/github\.com\/[^\s]+/g) ?? []) urls.push(u);
}
for (const url of urls) {
const re = prOnly ? GITHUB_PR_RE : GITHUB_ISSUE_RE;
const m = url.match(re);
if (m && m[1] && m[2] && m[3]) return { owner: m[1], repo: m[2], number: Number(m[3]) };
}
return null;
}
async function reply(
env: Env,
chatId: string,
topicId: string | undefined,
text: string,
): Promise<void> {
await sendMessage(env.TELEGRAM_TOKEN ?? "", chatId, text, topicId);
}
function errText(err: unknown): string {
const t = err instanceof Error ? err.message : String(err);
if (t === "GITHUB_TOKEN_EXPIRED") return "GitHub 授权已过期或无效,请重新使用 /gh login 绑定。";
if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限。";
if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能已删除或仓库不可访问)。";
return `操作失败:${t}`;
}
async function cmdLogin(env: Env, msg: TelegramMessage): Promise<void> {
const chatId = chatIdOf(msg);
const telegramUserId = userIdOf(msg);
if (!chatId || !telegramUserId) return;
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
const clientId = env.GITHUB_CLIENT_ID;
if (!clientId)
return reply(env, chatId, topicId, "服务器未配置 GitHub OAuthGITHUB_CLIENT_ID。");
const state = crypto.randomUUID().replace(/-/g, "");
await env.KV.put(
`state:${state}`,
JSON.stringify({
redirectTo: "/",
telegramUserId,
telegramChatId: chatId,
expiresAt: Date.now() + 600_000,
}),
{ expirationTtl: 600 },
);
const url = getOAuthURL(clientId, state);
await reply(
env,
chatId,
topicId,
`点击链接授权 GitHub即可用**本人身份**评论10 分钟内有效):\n${url}`,
);
}
async function cmdLogout(env: Env, msg: TelegramMessage): Promise<void> {
const chatId = chatIdOf(msg);
const telegramUserId = userIdOf(msg);
if (!chatId || !telegramUserId) return;
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
await removeTelegramLink(env.DB, telegramUserId);
await reply(env, chatId, topicId, "已解绑你的 GitHub 账号。");
}
async function cmdComment(env: Env, msg: TelegramMessage, body: string): Promise<void> {
const chatId = chatIdOf(msg);
const telegramUserId = userIdOf(msg);
if (!chatId || !telegramUserId) return;
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
const githubUserId = await getTelegramLink(env.DB, telegramUserId);
if (!githubUserId) {
return reply(env, chatId, topicId, "你还没有绑定 GitHub 账号,请先使用 /gh login。");
}
const source = msg.reply_to_message;
const target = source ? extractTarget(source) : null;
if (!target) {
return reply(
env,
chatId,
topicId,
"找不到 issue / PR 链接,请在对应的 GitHub 通知消息上回复 /gh comment。",
);
}
try {
const { htmlUrl, login } = await commentAsUser(
env.KV,
githubUserId,
target.owner,
target.repo,
target.number,
body,
);
await reply(env, chatId, topicId, `已以 **@${login}** 身份评论:${htmlUrl}`);
} catch (err) {
await reply(env, chatId, topicId, errText(err));
}
}
async function cmdMergeClose(env: Env, msg: TelegramMessage, op: "merge" | "close"): Promise<void> {
const chatId = chatIdOf(msg);
const telegramUserId = userIdOf(msg);
if (!chatId || !telegramUserId) return;
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
const githubUserId = await getTelegramLink(env.DB, telegramUserId);
if (!githubUserId) {
return reply(env, chatId, topicId, "你还没有绑定 GitHub 账号,请先使用 /gh login。");
}
const source = msg.reply_to_message;
const target = source ? extractTarget(source, true) : null;
if (!target) {
return reply(
env,
chatId,
topicId,
"找不到 PR 链接,请在对应的 GitHub PR 通知消息上回复 /gh merge 或 /gh close。",
);
}
try {
if (op === "merge") {
await mergePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
} else {
await closePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
}
const label = op === "merge" ? "合并" : "关闭";
await reply(
env,
chatId,
topicId,
`✅ 已${label} PR ${target.owner}/${target.repo}#${target.number}`,
);
} catch (err) {
await reply(env, chatId, topicId, errText(err));
}
}
/** Register the Telegram webhook to point at this worker (called from cron). */
export async function syncTelegramWebhook(env: Env): Promise<void> {
const token = env.TELEGRAM_TOKEN;
if (!token) return;
const baseUrl = env.BASE_URL;
if (!baseUrl) return;
const secret = env.TELEGRAM_WEBHOOK_SECRET;
const res = await fetch(`https://api.telegram.org/bot${token}/setWebhook`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: `${baseUrl.replace(/\/$/, "")}/telegram/webhook`,
secret_token: secret || undefined,
allowed_updates: ["message"],
}),
});
if (!res.ok) {
const err = await res.text();
log.error({ status: res.status, err }, "Failed to set Telegram webhook");
}
}
/** Handle a single Telegram update (message). Called from the webhook route. */
export async function handleTelegramUpdate(env: Env, update: unknown): Promise<void> {
const message = (update as { message?: TelegramMessage })?.message;
if (!message?.text) return;
const text = message.text.trim();
const m = text.match(/^\/gh(?:\s+|$)(.*)$/s);
if (!m) return;
const rest = (m[1] ?? "").trim();
const [sub, ...args] = rest.split(/\s+/);
const body = args.join(" ").trim();
switch (sub) {
case "login":
return cmdLogin(env, message);
case "logout":
return cmdLogout(env, message);
case "comment":
if (!body) {
const chatId = chatIdOf(message);
const topicId =
message.message_thread_id != null ? String(message.message_thread_id) : undefined;
if (chatId) await reply(env, chatId, topicId, "请附上评论内容:/gh comment 你的评论");
return;
}
return cmdComment(env, message, body);
case "merge":
return cmdMergeClose(env, message, "merge");
case "close":
return cmdMergeClose(env, message, "close");
default:
log.info({ text }, "Unhandled /gh command from Telegram");
}
}

View file

@ -0,0 +1,64 @@
import type { RouteTarget, Env, NeutralMessage } from "../../types";
import type { PlatformDriver, SendResult } from "../types";
import { sendMessage, sendPhoto, editMessageText, editMessageCaption } from "./rest";
import { renderNeutralMessage } from "./render";
function smallAvatar(url: string): string {
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}s=64`;
}
function richHeaderUrl(message: NeutralMessage, avatar: string, host: string): string {
const params = new URLSearchParams();
if (message.author?.name) params.set("title", message.author.name);
if (message.title) params.set("content", message.title);
params.set("avatar", avatar);
return `${host.replace(/\/+$/, "")}/api/richheader?${params.toString()}`;
}
export class TelegramDriver implements PlatformDriver {
readonly id = "telegram";
async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult> {
const chatId = target.chatId ?? "";
if (!chatId) {
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
}
const token = env.TELEGRAM_TOKEN ?? "";
const text = renderNeutralMessage(message);
const avatar = message.author?.iconUrl;
const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL;
if (avatar && richHeaderHost) {
const rhUrl = richHeaderUrl(message, avatar, richHeaderHost);
return sendMessage(token, chatId, text, target.topicId, rhUrl);
}
if (avatar) {
return sendPhoto(token, chatId, smallAvatar(avatar), text, target.topicId);
}
return sendMessage(token, chatId, text, target.topicId);
}
async edit(
message: NeutralMessage,
target: RouteTarget,
env: Env,
messageId: string,
): Promise<SendResult> {
const chatId = target.chatId ?? "";
if (!chatId) {
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
}
const token = env.TELEGRAM_TOKEN ?? "";
const text = renderNeutralMessage(message);
const avatar = message.author?.iconUrl;
const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL;
if (avatar && richHeaderHost) {
const rhUrl = richHeaderUrl(message, avatar, richHeaderHost);
return editMessageText(token, chatId, messageId, text, target.topicId, rhUrl);
}
if (avatar) {
return editMessageCaption(token, chatId, messageId, text, target.topicId);
}
return editMessageText(token, chatId, messageId, text, target.topicId);
}
}

View file

@ -0,0 +1,63 @@
import type { NeutralMessage } from "../../types";
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function mdToHtml(s: string): string {
let out = esc(s);
out = out.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_m, label, url) => `<a href="${esc(url)}">${label}</a>`,
);
out = out.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1<i>$2</i>");
out = out.replace(/~~([^~]+)~~/g, "<s>$1</s>");
return out;
}
function formatTimestamp(ts?: string): string {
if (!ts) return "";
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
const pad = (n: number): string => String(n).padStart(2, "0");
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
}
export function renderNeutralMessage(message: NeutralMessage): string {
const parts: string[] = [];
const title = message.url
? `<a href="${esc(message.url)}">${mdToHtml(message.title)}</a>`
: mdToHtml(message.title);
parts.push(`<b>${title}</b>`);
if (message.author) {
const name = mdToHtml(message.author.name);
const author = message.author.url ? `<a href="${esc(message.author.url)}">${name}</a>` : name;
parts.push(`👤 ${author}`);
}
if (message.description) {
parts.push(mdToHtml(message.description));
}
for (const field of message.fields ?? []) {
parts.push(`<b>${mdToHtml(field.name)}</b>: ${mdToHtml(field.value)}`);
}
const meta: string[] = [];
if (message.footer) meta.push(esc(message.footer));
const ts = formatTimestamp(message.timestamp);
if (ts) meta.push(ts);
if (meta.length > 0) {
parts.push(`<i>${meta.join(" · ")}</i>`);
}
return parts.join("\n");
}

View file

@ -0,0 +1,189 @@
import { log } from "../../lib/log";
import type { SendResult } from "../types";
const TELEGRAM_API = "https://api.telegram.org";
interface TelegramResponse {
ok?: boolean;
description?: string;
result?: { message_id?: number };
}
async function post(
token: string,
method: string,
body: Record<string, unknown>,
chatId: string,
): Promise<SendResult> {
const url = `${TELEGRAM_API}/bot${token}/${method}`;
let lastStatus = 0;
let lastError = "";
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
lastStatus = res.status;
const data = (await res.json().catch(() => null)) as TelegramResponse | null;
if (res.status === 429) {
const retryAfter = (data as { retry_after?: number })?.retry_after ?? 1;
lastError = data?.description ?? `Rate limited (retry_after=${retryAfter})`;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
if (!res.ok) {
lastError = data?.description ?? `HTTP ${res.status}`;
log.error(
{ status: res.status, err: lastError, chatId, attempts: attempt + 1 },
"Telegram API error",
);
return {
ok: false,
error: lastError,
errorCode: res.status >= 500 ? "TELEGRAM_5XX" : "TELEGRAM_ERROR",
status: res.status,
attempts: attempt + 1,
};
}
return {
ok: true,
status: res.status,
messageId: data?.result?.message_id != null ? String(data.result.message_id) : undefined,
attempts: attempt + 1,
};
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
log.error({ err, chatId, attempts: attempt + 1 }, "Failed to send Telegram message");
if (attempt === 2) {
return {
ok: false,
error: lastError,
errorCode: "NETWORK",
status: lastStatus,
attempts: attempt + 1,
};
}
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
}
return {
ok: false,
error: lastError || "Failed to send Telegram message",
errorCode: "RETRIES",
status: lastStatus,
attempts: 3,
};
}
export async function sendMessage(
token: string,
chatId: string,
text: string,
topicId?: string,
linkPreviewUrl?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
text,
parse_mode: "HTML",
disable_web_page_preview: !linkPreviewUrl,
};
if (linkPreviewUrl) {
body.link_preview_options = {
url: linkPreviewUrl,
prefer_small_media: true,
show_above_text: true,
};
}
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "sendMessage", body, chatId);
}
export async function sendPhoto(
token: string,
chatId: string,
photoUrl: string,
caption?: string,
topicId?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
photo: photoUrl,
parse_mode: "HTML",
};
if (caption) {
body.caption = caption;
}
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "sendPhoto", body, chatId);
}
export async function editMessageText(
token: string,
chatId: string,
messageId: string,
text: string,
topicId?: string,
linkPreviewUrl?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
text,
parse_mode: "HTML",
disable_web_page_preview: !linkPreviewUrl,
};
if (linkPreviewUrl) {
body.link_preview_options = {
url: linkPreviewUrl,
prefer_small_media: true,
show_above_text: true,
};
}
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "editMessageText", body, chatId);
}
export async function editMessageCaption(
token: string,
chatId: string,
messageId: string,
caption: string,
topicId?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
caption,
parse_mode: "HTML",
};
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "editMessageCaption", body, chatId);
}

View file

@ -0,0 +1,45 @@
import type { Env } from "../../types";
import { handleTelegramUpdate } from "./commands";
const MAX_BODY_SIZE = 1024 * 1024;
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
/** Handle a POST to the Telegram webhook endpoint. */
export async function handleTelegramWebhookRequest(request: Request, env: Env): Promise<Response> {
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > MAX_BODY_SIZE) {
return new Response("Request too large", { status: 413 });
}
const secret = env.TELEGRAM_WEBHOOK_SECRET;
if (secret) {
const token = request.headers.get("X-Telegram-Bot-Api-Secret-Token");
if (!token || !timingSafeEqual(token, secret)) {
return new Response("Invalid secret", { status: 401 });
}
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_SIZE) {
return new Response("Request too large", { status: 413 });
}
let update: unknown;
try {
update = JSON.parse(rawBody);
} catch {
return new Response("Invalid JSON", { status: 400 });
}
// Telegram expects a quick 200; process commands in the background.
await handleTelegramUpdate(env, update);
return new Response("ok");
}