diff --git a/README.md b/README.md index 9050e8c..1a8df9a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Script Collection -一个面向日常自动化的小型脚本集合,当前包含 Homebrew 升级管理、Sub-Store 节点重命名处理、Telegram Bot Worker 三类脚本。 +一个面向日常自动化的小型脚本集合,当前包含 Homebrew 升级管理、Sub-Store 节点重命名处理、Cloudflare Worker 脚本。 ## 目录 @@ -12,8 +12,9 @@ script/ ├── substore/ │ ├── README.md │ └── sntp-rename.js -├── telegram/ +├── cf_worker/ │ ├── README.md +│ ├── lsky-telegram-worker.js │ └── tg-bot.js ├── LICENSE └── README.md @@ -25,7 +26,8 @@ script/ | --- | --- | --- | --- | | `homebrew/` | `brew-upgrade-manager.sh` | 自动更新 Homebrew、升级 Formula/Cask、清理缓存 | macOS、Bash、Homebrew | | `substore/` | `sntp-rename.js` | 守候网络节点命名专用,在 Sub-Store 代理节点名称中注入线路标签 | Sub-Store 脚本处理器 | -| `telegram/` | `tg-bot.js` | 基于 Cloudflare Workers 的 Telegram 私聊转发与管理后台 | Cloudflare Workers、D1、Telegram Bot | +| `cf_worker/` | `lsky-telegram-worker.js` | Telegram 收图后上传到 Lsky Pro,并返回 Markdown 图片链接代码块 | Cloudflare Workers、D1、Telegram Bot、Lsky Pro | +| `cf_worker/` | `tg-bot.js` | 基于 Cloudflare Workers 的 Telegram 私聊转发与管理后台 | Cloudflare Workers、D1、Telegram Bot | ## 快速开始 @@ -52,18 +54,21 @@ HB_TERMINAL_WIDTH=130 ./brew-upgrade-manager.sh 详细规则见 [substore/README.md](substore/README.md)。 -### Telegram Bot Worker +### Cloudflare Worker 脚本 -把 [telegram/tg-bot.js](telegram/tg-bot.js) 部署为 Cloudflare Worker,并绑定 Telegram Bot Token、管理员 ID、管理群 ID 和 D1 数据库。Worker 根路径用于健康检查,Telegram Webhook POST 到 Worker 地址。 +`cf_worker/` 目录包含两个 Worker: -详细部署说明见 [telegram/README.md](telegram/README.md)。 +- [cf_worker/lsky-telegram-worker.js](cf_worker/lsky-telegram-worker.js):接收 Telegram 图片,上传到 Lsky Pro,返回 Markdown 图片链接代码块。默认单文件上限 `500.00 MB`,上传队列最多 `500` 张。 +- [cf_worker/tg-bot.js](cf_worker/tg-bot.js):Telegram 私聊转发与管理后台,绑定 Telegram Bot Token、管理员 ID、管理群 ID 和 D1 数据库。 + +详细部署说明见 [cf_worker/README.md](cf_worker/README.md)。 ## 安全提示 - 不要把 Bot Token、Cloudflare 密钥、Telegram 群组 ID、管理员 ID 或 sudo 密码提交到公开仓库。 - `homebrew/brew-upgrade-manager.sh` 当前包含 `SUDO_PWD` 占位变量,若确需使用自动输入 sudo 密码,请只在本机私有副本中填写。 -- Telegram Worker 会保存用户状态、消息映射和配置项到 D1,请按实际隐私要求控制数据库访问权限。 +- Cloudflare Worker 脚本会保存用户状态、消息映射、上传队列或配置项到 D1,请按实际隐私要求控制数据库访问权限。 ## 许可证 -本项目使用 GNU Affero General Public License v3.0,详见 [LICENSE](LICENSE)。 +本项目使用 GNU Affero General Public License v3.0,详见 [LICENSE](LICENSE)。 \ No newline at end of file diff --git a/telegram/README.md b/cf_worker/README.md similarity index 84% rename from telegram/README.md rename to cf_worker/README.md index 8ecd34d..a03c7f4 100644 --- a/telegram/README.md +++ b/cf_worker/README.md @@ -1,4 +1,64 @@ -yi'xia# Telegram Bot Worker +# Cloudflare Worker Scripts + +这个目录保存 Cloudflare Workers 相关脚本: + +| 脚本 | 用途 | +| --- | --- | +| `lsky-telegram-worker.js` | Telegram 收图后上传到 Lsky Pro,并返回 Markdown 图片链接代码块 | +| `tg-bot.js` | Telegram 双向私聊中继 Bot,包含管理面板、验证、过滤、封禁、备份等功能 | + +## Lsky Telegram Upload Worker + +`lsky-telegram-worker.js` 用于把 Telegram 私聊发送的图片或图片文件上传到 Lsky Pro。上传成功后,Bot 会回复 Markdown 图片链接,并使用 Telegram HTML `
` 代码块渲染,便于 Android 客户端点击复制。 + +### 功能 + +- 支持 Telegram `photo` 和图片类 `document`。 +- 支持 Telegram `media_group_id` 相册收集后批量上传。 +- 默认单文件大小限制为 `500.00 MB`,可用 `MAX_FILE_MB` 覆盖。 +- 默认上传队列最多收 `500` 张,可用 `MAX_UPLOAD_QUEUE_IMAGES` 覆盖。 +- 返回格式为 ``,并关闭网页预览。 +- 通过 `ALLOWED_TELEGRAM_USER_IDS` 限制可用用户。 + +### 必需绑定和环境变量 + +| 名称 | 说明 | +| --- | --- | +| `DB` | Cloudflare D1 数据库绑定名,用于暂存相册队列 | +| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | +| `TELEGRAM_WEBHOOK_SECRET` | Telegram Webhook secret token,同时用于 `/init` 初始化鉴权 | +| `ALLOWED_TELEGRAM_USER_IDS` | 允许使用的 Telegram user id,多个用英文逗号分隔 | +| `LSKY_API_URL` | Lsky Pro 上传 API 地址 | +| `LSKY_TOKEN` | Lsky Pro API token | + +### 可选环境变量 + +| 名称 | 默认值 | 说明 | +| --- | --- | --- | +| `MAX_FILE_MB` | `500.00` | 单个 Telegram 图片/文件大小上限,单位 MB | +| `MAX_UPLOAD_QUEUE_IMAGES` | `500` | 单个上传队列最多暂存图片数量 | +| `ALBUM_WAIT_SECONDS` | `5` | 相册收集等待秒数 | +| `LSKY_PERMISSION` | `1` | 传给 Lsky Pro 的 `permission` | +| `LSKY_STRATEGY_ID` | 空 | 传给 Lsky Pro 的 `strategy_id` | + +### 部署流程 + +1. 创建 Telegram Bot,拿到 `TELEGRAM_BOT_TOKEN`。 +2. 创建 Cloudflare D1 数据库,并在 Worker 中绑定为 `DB`。 +3. 创建 Worker,把 [lsky-telegram-worker.js](lsky-telegram-worker.js) 作为 Worker 代码。 +4. 配置上方环境变量。 +5. 访问 `https:///init?secret= ` 初始化 D1 表。 +6. 设置 Telegram Webhook: + +```bash +curl -X POST "https://api.telegram.org/bot /setWebhook" \ + -d "url= /webhook" \ + -d "secret_token= " +``` + +7. 访问 Worker 根路径,若返回 `Lsky Telegram Worker OK - code-block-max500mb-queue500-2026-05-27`,说明 Worker 基本可用。 + +## Telegram Bot Worker `tg-bot.js` 是一个部署在 Cloudflare Workers 上的 Telegram 双向私聊中继 Bot(版本 v3.68)。它会把用户私聊消息转发到管理员论坛群的独立 topic 中,管理员在对应 topic 内回复即可把消息发回用户,同时提供验证、过滤、封禁、备注、自动回复、备份和 Telegram 内联管理面板。 diff --git a/cf_worker/lsky-telegram-worker.js b/cf_worker/lsky-telegram-worker.js new file mode 100644 index 0000000..8738f49 --- /dev/null +++ b/cf_worker/lsky-telegram-worker.js @@ -0,0 +1,415 @@ +const DEFAULT_MAX_FILE_MB = 500; +const DEFAULT_MAX_UPLOAD_QUEUE_IMAGES = 500; + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + + if (request.method === "GET" && url.pathname === "/init") { + if (url.searchParams.get("secret") !== env.TELEGRAM_WEBHOOK_SECRET) { + return new Response("Unauthorized", { status: 401 }); + } + + await initDb(env); + return new Response("DB initialized"); + } + + if (request.method === "GET") { + return new Response("Lsky Telegram Worker OK - code-block-max500mb-queue500-2026-05-27"); + } + + if (url.pathname !== "/webhook" || request.method !== "POST") { + return new Response("Not Found", { status: 404 }); + } + + const secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token"); + if (secret !== env.TELEGRAM_WEBHOOK_SECRET) { + return new Response("Unauthorized", { status: 401 }); + } + + const update = await request.json(); + ctx.waitUntil(handleUpdate(update, env)); + return new Response("OK"); + }, +}; + +async function initDb(env) { + await env.DB.prepare(` + CREATE TABLE IF NOT EXISTS album_groups ( + group_key TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + reply_to INTEGER NOT NULL, + status TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `).run(); + + await env.DB.prepare(` + CREATE TABLE IF NOT EXISTS album_messages ( + group_key TEXT NOT NULL, + message_id INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (group_key, message_id) + ) + `).run(); +} + +async function handleUpdate(update, env) { + const message = update.message; + if (!message) return; + + const chatId = message.chat?.id; + const userId = message.from?.id; + const messageId = message.message_id; + + if (!isAllowedUser(userId, env.ALLOWED_TELEGRAM_USER_IDS)) { + await sendTelegramText(env, chatId, "未授权。", messageId); + return; + } + + if (message.text?.startsWith("/start")) { + await sendTelegramText(env, chatId, "发图片或图片文件给我,我会上传到 Lsky Pro。", messageId); + return; + } + + if (message.media_group_id) { + const groupKey = `${chatId}:${message.media_group_id}`; + const now = Date.now(); + + await env.DB.prepare(` + INSERT OR IGNORE INTO album_groups (group_key, chat_id, reply_to, status, updated_at) + VALUES (?, ?, ?, 'collecting', ?) + `).bind(groupKey, String(chatId), messageId, now).run(); + + await env.DB.prepare(` + UPDATE album_groups + SET updated_at = ? + WHERE group_key = ? AND status = 'collecting' + `).bind(now, groupKey).run(); + + const maxQueueImages = getMaxUploadQueueImages(env); + const queued = await env.DB.prepare(` + SELECT COUNT(*) AS count + FROM album_messages + WHERE group_key = ? + `).bind(groupKey).first(); + + if (Number(queued?.count || 0) >= maxQueueImages) { + await sendTelegramText(env, chatId, `上传队列已满,最多 ${maxQueueImages} 张。`, messageId); + return; + } + + await env.DB.prepare(` + INSERT OR IGNORE INTO album_messages (group_key, message_id, payload) + VALUES (?, ?, ?) + `).bind(groupKey, messageId, JSON.stringify(message)).run(); + + await sleep((Number(env.ALBUM_WAIT_SECONDS || "5") + 1) * 1000); + await processAlbum(env, groupKey); + return; + } + + try { + const image = await uploadTelegramMessageToLsky(env, message); + await sendTelegramText(env, chatId, toTelegramCodeBlock(toMarkdownImage(image.url, image.filename)), messageId, "HTML"); + } catch (err) { + await sendTelegramText(env, chatId, `上传失败:${err.message}`, messageId); + } +} + +async function processAlbum(env, groupKey) { + const waitMs = Number(env.ALBUM_WAIT_SECONDS || "5") * 1000; + const group = await env.DB.prepare(` + SELECT * FROM album_groups WHERE group_key = ? + `).bind(groupKey).first(); + + if (!group || group.status !== "collecting") return; + if (Date.now() - group.updated_at < waitMs) return; + + const lock = await env.DB.prepare(` + UPDATE album_groups + SET status = 'processing' + WHERE group_key = ? AND status = 'collecting' + `).bind(groupKey).run(); + + if (!lock.meta || lock.meta.changes !== 1) return; + + const rows = await env.DB.prepare(` + SELECT message_id, payload + FROM album_messages + WHERE group_key = ? + ORDER BY message_id ASC + `).bind(groupKey).all(); + + const messages = rows.results.map((row) => JSON.parse(row.payload)); + const images = []; + const errors = []; + + for (let i = 0; i < messages.length; i++) { + try { + images.push(await uploadTelegramMessageToLsky(env, messages[i])); + } catch (err) { + errors.push(`${i + 1}. ${err.message}`); + } + } + + let text = images.length + ? "上传完成:\n\n" + images.map((image, i) => `${i + 1}.\n${toTelegramCodeBlock(toMarkdownImage(image.url, image.filename))}`).join("\n") + : "这一组图片全部上传失败。"; + + if (errors.length) { + text += "\n\n失败:\n" + errors.join("\n"); + } + + await sendTelegramLongText(env, group.chat_id, text, group.reply_to, images.length ? "HTML" : undefined); + + await env.DB.prepare(`DELETE FROM album_messages WHERE group_key = ?`).bind(groupKey).run(); + await env.DB.prepare(`DELETE FROM album_groups WHERE group_key = ?`).bind(groupKey).run(); +} + +function isAllowedUser(userId, allowList) { + return String(allowList || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean) + .includes(String(userId)); +} + +function toMarkdownImage(url, filename = "image") { + return ``; +} + +function escapeMarkdownAlt(value) { + return String(value || "image") + .replaceAll("\\", "\\\\") + .replaceAll("]", "\\]"); +} + +function toTelegramCodeBlock(text) { + return ` ${escapeHtml(text)}`; +} + +function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function getMaxFileMb(env) { + const value = Number(env.MAX_FILE_MB || DEFAULT_MAX_FILE_MB); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_MAX_FILE_MB; +} + +function formatFileMb(value) { + return value.toFixed(2); +} + +function getMaxUploadQueueImages(env) { + const value = Number(env.MAX_UPLOAD_QUEUE_IMAGES || DEFAULT_MAX_UPLOAD_QUEUE_IMAGES); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_MAX_UPLOAD_QUEUE_IMAGES; +} + +async function uploadTelegramMessageToLsky(env, message) { + const image = extractImage(message, env); + + const fileInfo = await telegramApi(env, "getFile", { + file_id: image.fileId, + }); + + const fileUrl = `https://api.telegram.org/file/bot${env.TELEGRAM_BOT_TOKEN}/${fileInfo.file_path}`; + const fileResp = await fetch(fileUrl); + + if (!fileResp.ok) { + throw new Error(`下载 Telegram 文件失败: HTTP ${fileResp.status}`); + } + + const bytes = await fileResp.arrayBuffer(); + const filename = image.filename || guessFilename(fileInfo.file_path, image.mimeType); + const url = await uploadToLsky(env, bytes, filename, image.mimeType); + + return { url, filename }; +} + +function extractImage(message, env) { + const maxFileMb = getMaxFileMb(env); + const maxBytes = maxFileMb * 1024 * 1024; + + if (message.photo?.length) { + const photo = message.photo[message.photo.length - 1]; + + if (photo.file_size && photo.file_size > maxBytes) { + throw new Error(`图片超过 ${formatFileMb(maxFileMb)} MB`); + } + + return { + fileId: photo.file_id, + filename: `${photo.file_unique_id || photo.file_id}.jpg`, + mimeType: "image/jpeg", + }; + } + + if (message.document) { + const doc = message.document; + const mimeType = doc.mime_type || "application/octet-stream"; + + if (!mimeType.startsWith("image/")) { + throw new Error("请发送图片,或以文件形式发送图片"); + } + + if (doc.file_size && doc.file_size > maxBytes) { + throw new Error(`文件超过 ${formatFileMb(maxFileMb)} MB`); + } + + return { + fileId: doc.file_id, + filename: doc.file_name || `${doc.file_unique_id || doc.file_id}`, + mimeType, + }; + } + + throw new Error("请发送图片,或以文件形式发送图片"); +} + +async function uploadToLsky(env, bytes, filename, mimeType) { + const form = new FormData(); + form.append("file", new File([bytes], filename, { type: mimeType })); + form.append("permission", env.LSKY_PERMISSION || "1"); + + if (env.LSKY_STRATEGY_ID) { + form.append("strategy_id", env.LSKY_STRATEGY_ID); + } + + const resp = await fetch(env.LSKY_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${env.LSKY_TOKEN}`, + Accept: "application/json", + }, + body: form, + }); + + const raw = await resp.text(); + let result; + + try { + result = JSON.parse(raw); + } catch { + throw new Error(`Lsky 返回非 JSON: HTTP ${resp.status} ${raw.slice(0, 200)}`); + } + + if (!resp.ok || result.status !== true) { + throw new Error(`Lsky 上传失败: HTTP ${resp.status} ${JSON.stringify(result)}`); + } + + const data = result.data || {}; + const links = data.links || {}; + const url = links.url || data.url || data.pathname || data.path; + + if (!url) { + throw new Error(`Lsky 返回中找不到图片链接`); + } + + return url; +} + +async function telegramApi(env, method, payload) { + const resp = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload || {}), + }); + + const raw = await resp.text(); + const result = JSON.parse(raw); + + if (!resp.ok || result.ok !== true) { + throw new Error(`Telegram API ${method} 失败: ${JSON.stringify(result)}`); + } + + return result.result; +} + +async function sendTelegramText(env, chatId, text, replyToMessageId, parseMode) { + if (!chatId) return; + + const payload = { + chat_id: chatId, + text, + disable_web_page_preview: true, + }; + + if (parseMode) { + payload.parse_mode = parseMode; + } + + if (replyToMessageId) { + payload.reply_to_message_id = replyToMessageId; + } + + await telegramApi(env, "sendMessage", payload); +} + +async function sendTelegramLongText(env, chatId, text, replyToMessageId, parseMode) { + const limit = 3900; + const chunks = splitTelegramText(text, limit); + + for (let i = 0; i < chunks.length; i++) { + await sendTelegramText( + env, + chatId, + chunks[i], + i === 0 ? replyToMessageId : undefined, + parseMode + ); + } +} + +function splitTelegramText(text, limit) { + const chunks = []; + let current = ""; + + for (const line of String(text || "").split("\n")) { + const next = current ? `${current}\n${line}` : line; + + if (next.length <= limit) { + current = next; + continue; + } + + if (current) { + chunks.push(current); + current = ""; + } + + if (line.length <= limit) { + current = line; + continue; + } + + for (let i = 0; i < line.length; i += limit) { + chunks.push(line.slice(i, i + limit)); + } + } + + if (current) { + chunks.push(current); + } + + return chunks.length ? chunks : [""]; +} + +function guessFilename(filePath, mimeType) { + const base = filePath.split("/").pop() || "image"; + + if (base.includes(".")) return base; + if (mimeType === "image/png") return `${base}.png`; + if (mimeType === "image/webp") return `${base}.webp`; + if (mimeType === "image/gif") return `${base}.gif`; + + return `${base}.jpg`; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/telegram/tg-bot.js b/cf_worker/tg-bot.js similarity index 100% rename from telegram/tg-bot.js rename to cf_worker/tg-bot.js