├── CFTeleTrans ├── admin.md ├── notification.md └── start.md ├── LICENSE ├── README.md ├── _worker.js ├── images ├── nihao.md └── star-chart.png ├── package.json ├── picture ├── 0903f76329b80fc231893abde40b9ab8.png ├── 30d4b767f1c9a050999b8642f164c90c.png ├── 60e43fcf63e7c49148c2df7caff7d176.png └── nihao.md └── scripts └── generate-star-chart.js /CFTeleTrans/admin.md: -------------------------------------------------------------------------------- 1 | 测试 2 | -------------------------------------------------------------------------------- /CFTeleTrans/notification.md: -------------------------------------------------------------------------------- 1 | 🛡️管理员面板按钮功能说明 2 | ================= 3 | ⚙️通过在本群组中回复用户→/admin命令,可打开管理员面板,使用以下功能: 4 | 5 | --- 🔘按钮功能 --- 6 | ➡️拉黑用户: 将用户加入黑名单 7 | ➡️解除拉黑: 将用户从黑名单移除 8 | ➡️关闭验证码: 所有用户去除验证码。 9 | ➡️查询黑名单: 列出所有被禁用户 ID。 10 | ➡️关闭用户Raw:隐藏用户端项目地址。 11 | ➡️GitHub项目: 跳转至 GitHub项目地址 12 | ➡️删除用户: 删除用户数据库信息 13 | -------------------------------------------------------------------------------- /CFTeleTrans/start.md: -------------------------------------------------------------------------------- 1 | 本机器人项目地址: 2 | [GitHub 项目](https://github.com/iawooo/ctt) 3 | 4 | 欢迎 fork,留下你的 star 再走吧! 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 [Copyright (c) 2025 iawooo] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CFTeleTrans (CTT) - Telegram消息转发分组对话机器人(基于Cloudflare) 2 | 3 | 这是一个基于Cloudflare Workers实现的Telegram消息转发分组对话机器人,代号 **CFTeleTrans (CTT)**,专注于将用户消息安全、高效地转发到后台群组,同时充分利用Cloudflare的免费额度(榨干CF大善人!)。该机器人支持用户验证、消息转发、频率限制、管理员管理等功能,适用于客服、社区管理等场景。 4 | ## 2025.05.17更新:优化,添加检测更新功能。 5 | - **特别感谢 [VTEXS](https://vtexs.com/) 赞助本项目,感谢 [VTEXS](https://vtexs.com/)为开源社区提供算力支持!** 6 | - [![image](iframe组件截图图片链接)](https://yxvm.com/) 7 | [NodeSupport](https://github.com/NodeSeekDev/NodeSupport)赞助了本项目 8 | ## 项目截图 9 | 10 | 以下是 CFTeleTrans 项目的截图: 11 | 12 | ![CFTeleTrans 截图](https://awtc.pp.ua/ctt.png) 13 | 14 | ## 特点与亮点 15 | 16 | 1. **充分利用Cloudflare免费额度(榨干CF大善人!)** 17 | - **CFTeleTrans (CTT)** 完全基于Cloudflare Workers部署,利用其免费额度(每天10万次请求,50次/分钟)实现高性能、低成本的机器人运行。 18 | - **使用Cloudflare D1存储用户状态和数据,免费额度(每天10万次读/写)足以支持中小规模用户群。** 19 | - 避免用别人的,受他人控制,(避免了突然植入广告,触不及防)该项目完全开源自己所有,可以自由修改代码的机器人 20 | - 零成本运行,适合个人开发者或小型团队,真正做到“榨干CF大善人”的免费资源! 21 | 22 | 2. **分组对话消息管理** 23 | - 用户消息自动转发到后台群组的子论坛,别人私聊你机器人就如同添加了你好友,。 24 | - 群聊可多个号回复用户(只需将你的号拉进群并设置管理) 25 | - 置顶消息显示用户信息(昵称、用户名、UserID、发起时间)及通知内容 26 | - 每个用户独立一个分组,随时随地想聊就聊! 27 | - 可通过后台群组直接回复用户消息,消息会转发到用户私聊。 28 | 29 | 3. **高效的用户验证机制** 30 | - 支持按钮式验证码验证(简单数学题),防止机器人刷消息。 31 | - 验证状态持久化(1小时有效期),用户验证通过后无需重复验证,除非触发频率限制。 32 | - 删除聊天记录后重新开始,验证码会自动触发,确保用户体验流畅。 33 | 34 | 4. **消息频率限制(防刷保护)** 35 | - 默认每分钟40条消息上限(可通过环境变量调整),超过限制的用户需重新验证。 36 | - 有效防止恶意刷消息,保护后台群组和cf免费额度。 37 | 38 | 5. **管理员面板功能** 39 | - 支持`/admin`呼出管理员面板控制,简单方便。 40 | - 支持拉黑用户,查询黑名单,关闭用户Raw,删除用户等多种功能。 41 | 42 | 6. **轻量级部署** 43 | - 单文件部署(仅需一个`_worker.js`),代码简洁,易于维护。 44 | - 支持Cloudflare Workers和Cloudflare Pages部署,部署过程简单。 45 | 46 | ## 部署教程 47 | 48 | ### 准备工作 49 | 1. **创建Telegram Bot**: 50 | - 在Telegram中找到`@BotFather`,发送`/newbot`创建新机器人。 51 | - 按照提示设置机器人名称和用户名,获取Bot Token(例如`123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)。 52 | - 发送`/setinline`切换内联模式。 53 | ![CFTeleTrans 截图](picture/0903f76329b80fc231893abde40b9ab8.png) 54 | 55 | 2. **创建后台群组**: 56 | - 创建一个Telegram群组(按需设置是否公开), 57 | - 群组的“话题功能”打开。 58 | - 添加机器人为管理员,建议权限全给(消息管理,话题管理) 59 | - 获取群组的Chat ID(例如`-100123456789`),可以通过`@getidsbot`获取(拉它进群)。 60 | 61 | ### 部署到Cloudflare Workers 62 | 63 | #### 步骤 1:创建D1 SQL数据库 64 | 1. 登录[Cloudflare仪表板](https://dash.cloudflare.com/)。 65 | 2. 导航到 **存储和数据库 > D1 SQL数据库**,输入一个名称(例如`cfteletrans-db`),点击 **创建**。 66 | 67 | #### 步骤 2:创建Workers项目 68 | 1. 登录[Cloudflare仪表板](https://dash.cloudflare.com/)。 69 | 2. 导航到 **Workers和Pages > Workers和Pages**,点击 **创建**。 70 | 3. 点击 **Hello world**,输入一个名称(例如`cfteletrans`),再点击 **部署** 71 | 72 | #### 步骤 3:配置环境变量 73 | 1. 在创建的Workers项目 **设置 > 变量和机密** 中,添加以下变量: 74 | - `BOT_TOKEN_ENV`:您的Telegram Bot Token(例如`123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)。 75 | - `GROUP_ID_ENV`:后台群组的Chat ID(例如`-100123456789`)。 76 | - `MAX_MESSAGES_PER_MINUTE_ENV`:消息频率限制(例如`40`)。 77 | 78 | #### 步骤 4:绑定D1 SQL数据库 79 | 1. 在创建的Workers项目 **设置 > 绑定** 中,绑定数据库: 80 | - 添加-选择D1数据库 81 | - 变量名称 `D1` 82 | - D1 数据库 选择刚建的数据库(例如`cfteletrans-db`), 83 | - 点击 **编辑代码**,把原来的代码用本项目中的_worker.js代码替换后部署 84 | 85 | #### 步骤 5:测试 86 | 1. 在Telegram中找到您的机器人,发送`/start`。 87 | 2. 确认收到“你好,欢迎使用私聊机器人!”并触发验证码。 88 | 3. 完成验证,确认收到合并消息,例如: 89 | 4. 发送消息,确认消息转发到后台群组的子论坛。 90 | 91 | 92 | ## 需要在 Cloudflare 绑定的变量表 93 | 94 | 以下是项目中需要在 Cloudflare 环境中绑定的变量及其说明: 95 | 96 | | **变量名** | **类型** | **描述** | **默认值/示例** | 97 | |-----------------------------|------------|--------------------------------------------------------------------------|----------------------------| 98 | | `BOT_TOKEN_ENV` | 环境变量 | Telegram Bot 的 Token,用于与 Telegram API 通信。 | `your-telegram-bot-token` | 99 | | `GROUP_ID_ENV` | 环境变量 | Telegram 群组的 ID,用于消息转发和客服回复。 | `-123456789` | 100 | | `MAX_MESSAGES_PER_MINUTE_ENV` | 环境变量 | 每分钟允许的最大消息数,用于限制用户发送频率。 | `40` | 101 | | `D1` | D1 绑定 | Cloudflare D1 数据库绑定,用于存储用户状态、消息频率和群组映射。 | `cfteletrans-db` | 102 | 103 | 104 | ### 部署到Cloudflare pages 105 | 106 | #### **fork本项目**!!! 107 | 108 | #### 步骤 1:创建pages项目 109 | 1. 登录[Cloudflare仪表板](https://dash.cloudflare.com/)。 110 | 2. 导航到 **Workers和Pages > Workers和Pages**,选择pages,点击 **创建**。 111 | 3. 连接GitHub部署(或者下载本项目zip部署) 112 | 113 | #### 步骤 2:填写变量后重试部署 114 | ![变量截图](picture/30d4b767f1c9a050999b8642f164c90c.png) 115 | 116 | 117 | ## 灵感来源 118 | 本项目的灵感来源于 Telegram-interactive-bot(部署在服务器) 119 | 120 | - [Telegram-interactive-bot](https://github.com/MiHaKun/Telegram-interactive-bot) 121 | 122 | ## 参考文献 123 | 124 | 在开发过程中,以下资源提供了宝贵的参考和指导: 125 | 126 | - [NodeSeek 帖子](https://www.nodeseek.com/post-237769-1) 127 | 128 | ## 致谢 129 | - 特别感谢 [VTEXS](https://vtexs.com/) 赞助本项目,感谢 [VTEXS](https://vtexs.com/)为开源社区提供算力支持! 130 | - 特别感谢 [xAI](https://x.ai/) 提供的支持和灵感,帮助我完成了本项目的开发和优化! 131 | - 特别感谢 [cloud flare](https://www.cloudflare.com/) 大善人! 132 | - 再次感谢所有测试者、贡献者和社区支持! 133 | 134 | ## 贡献 135 | 136 | 欢迎提交 Issue 或 Pull Request!如果您有任何改进建议或新功能需求,请随时联系我。 137 | 138 | ![Star 增长趋势](https://raw.githubusercontent.com/iawooo/StarCharts/refs/heads/main/images/ctt_star_chart.png) 139 | 140 | ## 许可证 141 | 142 | 本项目采用 MIT 许可证,详情请见 [LICENSE](LICENSE) 文件。 143 | 144 | ## 声明 145 | 146 | - **尊重原创,转载须知** 147 | 如需转载,请务必注明出处,感谢支持!严禁将本项目用于任何违法犯罪行为。 148 | - **二次修改与发布** 149 | 欢迎基于本项目进行二次开发,但请在发布时注明原始出处,共同维护开源社区的良好氛围。 150 | -------------------------------------------------------------------------------- /_worker.js: -------------------------------------------------------------------------------- 1 | let BOT_TOKEN; 2 | let GROUP_ID; 3 | let MAX_MESSAGES_PER_MINUTE; 4 | 5 | let lastCleanupTime = 0; 6 | const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 小时 7 | let isInitialized = false; 8 | const processedMessages = new Set(); 9 | const processedCallbacks = new Set(); 10 | 11 | const topicCreationLocks = new Map(); 12 | 13 | const settingsCache = new Map([ 14 | ['verification_enabled', null], 15 | ['user_raw_enabled', null] 16 | ]); 17 | 18 | class LRUCache { 19 | constructor(maxSize) { 20 | this.maxSize = maxSize; 21 | this.cache = new Map(); 22 | } 23 | get(key) { 24 | const value = this.cache.get(key); 25 | if (value !== undefined) { 26 | this.cache.delete(key); 27 | this.cache.set(key, value); 28 | } 29 | return value; 30 | } 31 | set(key, value) { 32 | if (this.cache.size >= this.maxSize) { 33 | const firstKey = this.cache.keys().next().value; 34 | this.cache.delete(firstKey); 35 | } 36 | this.cache.set(key, value); 37 | } 38 | clear() { 39 | this.cache.clear(); 40 | } 41 | } 42 | 43 | const userInfoCache = new LRUCache(1000); 44 | const topicIdCache = new LRUCache(1000); 45 | const userStateCache = new LRUCache(1000); 46 | const messageRateCache = new LRUCache(1000); 47 | 48 | export default { 49 | async fetch(request, env) { 50 | BOT_TOKEN = env.BOT_TOKEN_ENV || null; 51 | GROUP_ID = env.GROUP_ID_ENV || null; 52 | MAX_MESSAGES_PER_MINUTE = env.MAX_MESSAGES_PER_MINUTE_ENV ? parseInt(env.MAX_MESSAGES_PER_MINUTE_ENV) : 40; 53 | 54 | if (!env.D1) { 55 | return new Response('Server configuration error: D1 database is not bound', { status: 500 }); 56 | } 57 | 58 | if (!isInitialized) { 59 | await initialize(env.D1, request); 60 | isInitialized = true; 61 | } 62 | 63 | async function handleRequest(request) { 64 | if (!BOT_TOKEN || !GROUP_ID) { 65 | return new Response('Server configuration error: Missing required environment variables', { status: 500 }); 66 | } 67 | 68 | const url = new URL(request.url); 69 | if (url.pathname === '/webhook') { 70 | try { 71 | const update = await request.json(); 72 | await handleUpdate(update); 73 | return new Response('OK'); 74 | } catch (error) { 75 | return new Response('Bad Request', { status: 400 }); 76 | } 77 | } else if (url.pathname === '/registerWebhook') { 78 | return await registerWebhook(request); 79 | } else if (url.pathname === '/unRegisterWebhook') { 80 | return await unRegisterWebhook(); 81 | } else if (url.pathname === '/checkTables') { 82 | await checkAndRepairTables(env.D1); 83 | return new Response('Database tables checked and repaired', { status: 200 }); 84 | } 85 | return new Response('Not Found', { status: 404 }); 86 | } 87 | 88 | async function initialize(d1, request) { 89 | await Promise.all([ 90 | checkAndRepairTables(d1), 91 | autoRegisterWebhook(request), 92 | checkBotPermissions(), 93 | cleanExpiredVerificationCodes(d1) 94 | ]); 95 | } 96 | 97 | async function autoRegisterWebhook(request) { 98 | const webhookUrl = `${new URL(request.url).origin}/webhook`; 99 | await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/setWebhook`, { 100 | method: 'POST', 101 | headers: { 'Content-Type': 'application/json' }, 102 | body: JSON.stringify({ url: webhookUrl }), 103 | }); 104 | } 105 | 106 | async function checkBotPermissions() { 107 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/getChat`, { 108 | method: 'POST', 109 | headers: { 'Content-Type': 'application/json' }, 110 | body: JSON.stringify({ chat_id: GROUP_ID }) 111 | }); 112 | const data = await response.json(); 113 | if (!data.ok) { 114 | throw new Error(`Failed to access group: ${data.description}`); 115 | } 116 | 117 | const memberResponse = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/getChatMember`, { 118 | method: 'POST', 119 | headers: { 'Content-Type': 'application/json' }, 120 | body: JSON.stringify({ 121 | chat_id: GROUP_ID, 122 | user_id: (await getBotId()) 123 | }) 124 | }); 125 | const memberData = await memberResponse.json(); 126 | if (!memberData.ok) { 127 | throw new Error(`Failed to get bot member status: ${memberData.description}`); 128 | } 129 | } 130 | 131 | async function getBotId() { 132 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/getMe`, { 133 | method: 'POST', 134 | headers: { 'Content-Type': 'application/json' }, 135 | body: JSON.stringify({}) 136 | }); 137 | const data = await response.json(); 138 | if (!data.ok) throw new Error(`Failed to get bot ID: ${data.description}`); 139 | return data.result.id; 140 | } 141 | 142 | async function checkAndRepairTables(d1) { 143 | const expectedTables = { 144 | user_states: { 145 | columns: { 146 | chat_id: 'TEXT PRIMARY KEY', 147 | is_blocked: 'BOOLEAN DEFAULT FALSE', 148 | is_verified: 'BOOLEAN DEFAULT FALSE', 149 | verified_expiry: 'INTEGER', 150 | verification_code: 'TEXT', 151 | code_expiry: 'INTEGER', 152 | last_verification_message_id: 'TEXT', 153 | is_first_verification: 'BOOLEAN DEFAULT TRUE', 154 | is_rate_limited: 'BOOLEAN DEFAULT FALSE', 155 | is_verifying: 'BOOLEAN DEFAULT FALSE' 156 | } 157 | }, 158 | message_rates: { 159 | columns: { 160 | chat_id: 'TEXT PRIMARY KEY', 161 | message_count: 'INTEGER DEFAULT 0', 162 | window_start: 'INTEGER', 163 | start_count: 'INTEGER DEFAULT 0', 164 | start_window_start: 'INTEGER' 165 | } 166 | }, 167 | chat_topic_mappings: { 168 | columns: { 169 | chat_id: 'TEXT PRIMARY KEY', 170 | topic_id: 'TEXT NOT NULL' 171 | } 172 | }, 173 | settings: { 174 | columns: { 175 | key: 'TEXT PRIMARY KEY', 176 | value: 'TEXT' 177 | } 178 | } 179 | }; 180 | 181 | for (const [tableName, structure] of Object.entries(expectedTables)) { 182 | const tableInfo = await d1.prepare( 183 | `SELECT sql FROM sqlite_master WHERE type='table' AND name=?` 184 | ).bind(tableName).first(); 185 | 186 | if (!tableInfo) { 187 | await createTable(d1, tableName, structure); 188 | continue; 189 | } 190 | 191 | const columnsResult = await d1.prepare( 192 | `PRAGMA table_info(${tableName})` 193 | ).all(); 194 | 195 | const currentColumns = new Map( 196 | columnsResult.results.map(col => [col.name, { 197 | type: col.type, 198 | notnull: col.notnull, 199 | dflt_value: col.dflt_value 200 | }]) 201 | ); 202 | 203 | for (const [colName, colDef] of Object.entries(structure.columns)) { 204 | if (!currentColumns.has(colName)) { 205 | const columnParts = colDef.split(' '); 206 | const addColumnSQL = `ALTER TABLE ${tableName} ADD COLUMN ${colName} ${columnParts.slice(1).join(' ')}`; 207 | await d1.exec(addColumnSQL); 208 | } 209 | } 210 | 211 | if (tableName === 'settings') { 212 | await d1.exec('CREATE INDEX IF NOT EXISTS idx_settings_key ON settings (key)'); 213 | } 214 | } 215 | 216 | await Promise.all([ 217 | d1.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)') 218 | .bind('verification_enabled', 'true').run(), 219 | d1.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)') 220 | .bind('user_raw_enabled', 'true').run() 221 | ]); 222 | 223 | settingsCache.set('verification_enabled', (await getSetting('verification_enabled', d1)) === 'true'); 224 | settingsCache.set('user_raw_enabled', (await getSetting('user_raw_enabled', d1)) === 'true'); 225 | } 226 | 227 | async function createTable(d1, tableName, structure) { 228 | const columnsDef = Object.entries(structure.columns) 229 | .map(([name, def]) => `${name} ${def}`) 230 | .join(', '); 231 | const createSQL = `CREATE TABLE ${tableName} (${columnsDef})`; 232 | await d1.exec(createSQL); 233 | } 234 | 235 | async function cleanExpiredVerificationCodes(d1) { 236 | const now = Date.now(); 237 | if (now - lastCleanupTime < CLEANUP_INTERVAL) { 238 | return; 239 | } 240 | 241 | const nowSeconds = Math.floor(now / 1000); 242 | const expiredCodes = await d1.prepare( 243 | 'SELECT chat_id FROM user_states WHERE code_expiry IS NOT NULL AND code_expiry < ?' 244 | ).bind(nowSeconds).all(); 245 | 246 | if (expiredCodes.results.length > 0) { 247 | await d1.batch( 248 | expiredCodes.results.map(({ chat_id }) => 249 | d1.prepare( 250 | 'UPDATE user_states SET verification_code = NULL, code_expiry = NULL, is_verifying = FALSE WHERE chat_id = ?' 251 | ).bind(chat_id) 252 | ) 253 | ); 254 | } 255 | lastCleanupTime = now; 256 | } 257 | 258 | async function handleUpdate(update) { 259 | if (update.message) { 260 | const messageId = update.message.message_id.toString(); 261 | const chatId = update.message.chat.id.toString(); 262 | const messageKey = `${chatId}:${messageId}`; 263 | 264 | if (processedMessages.has(messageKey)) { 265 | return; 266 | } 267 | processedMessages.add(messageKey); 268 | 269 | if (processedMessages.size > 10000) { 270 | processedMessages.clear(); 271 | } 272 | 273 | await onMessage(update.message); 274 | } else if (update.callback_query) { 275 | await onCallbackQuery(update.callback_query); 276 | } 277 | } 278 | 279 | async function onMessage(message) { 280 | const chatId = message.chat.id.toString(); 281 | const text = message.text || ''; 282 | const messageId = message.message_id; 283 | 284 | if (chatId === GROUP_ID) { 285 | const topicId = message.message_thread_id; 286 | if (topicId) { 287 | const privateChatId = await getPrivateChatId(topicId); 288 | if (privateChatId && text === '/admin') { 289 | await sendAdminPanel(chatId, topicId, privateChatId, messageId); 290 | return; 291 | } 292 | if (privateChatId && text.startsWith('/reset_user')) { 293 | await handleResetUser(chatId, topicId, text); 294 | return; 295 | } 296 | if (privateChatId) { 297 | await forwardMessageToPrivateChat(privateChatId, message); 298 | } 299 | } 300 | return; 301 | } 302 | 303 | let userState = userStateCache.get(chatId); 304 | if (userState === undefined) { 305 | userState = await env.D1.prepare('SELECT is_blocked, is_first_verification, is_verified, verified_expiry, is_verifying FROM user_states WHERE chat_id = ?') 306 | .bind(chatId) 307 | .first(); 308 | if (!userState) { 309 | userState = { is_blocked: false, is_first_verification: true, is_verified: false, verified_expiry: null, is_verifying: false }; 310 | await env.D1.prepare('INSERT INTO user_states (chat_id, is_blocked, is_first_verification, is_verified, is_verifying) VALUES (?, ?, ?, ?, ?)') 311 | .bind(chatId, false, true, false, false) 312 | .run(); 313 | } 314 | userStateCache.set(chatId, userState); 315 | } 316 | 317 | if (userState.is_blocked) { 318 | await sendMessageToUser(chatId, "您已被拉黑,无法发送消息。请联系管理员解除拉黑。"); 319 | return; 320 | } 321 | 322 | const verificationEnabled = (await getSetting('verification_enabled', env.D1)) === 'true'; 323 | 324 | if (!verificationEnabled) { 325 | // 验证码关闭时,所有用户都可以直接发送消息 326 | } else { 327 | const nowSeconds = Math.floor(Date.now() / 1000); 328 | const isVerified = userState.is_verified && userState.verified_expiry && nowSeconds < userState.verified_expiry; 329 | const isFirstVerification = userState.is_first_verification; 330 | const isRateLimited = await checkMessageRate(chatId); 331 | const isVerifying = userState.is_verifying || false; 332 | 333 | if (!isVerified || (isRateLimited && !isFirstVerification)) { 334 | if (isVerifying) { 335 | // 检查验证码是否已过期 336 | const storedCode = await env.D1.prepare('SELECT verification_code, code_expiry FROM user_states WHERE chat_id = ?') 337 | .bind(chatId) 338 | .first(); 339 | 340 | const nowSeconds = Math.floor(Date.now() / 1000); 341 | const isCodeExpired = !storedCode?.verification_code || !storedCode?.code_expiry || nowSeconds > storedCode.code_expiry; 342 | 343 | if (isCodeExpired) { 344 | // 如果验证码已过期,重新发送验证码 345 | await sendMessageToUser(chatId, '验证码已过期,正在为您发送新的验证码...'); 346 | await env.D1.prepare('UPDATE user_states SET verification_code = NULL, code_expiry = NULL, is_verifying = FALSE WHERE chat_id = ?') 347 | .bind(chatId) 348 | .run(); 349 | userStateCache.set(chatId, { ...userState, verification_code: null, code_expiry: null, is_verifying: false }); 350 | 351 | // 删除旧的验证消息(如果存在) 352 | try { 353 | const lastVerification = await env.D1.prepare('SELECT last_verification_message_id FROM user_states WHERE chat_id = ?') 354 | .bind(chatId) 355 | .first(); 356 | 357 | if (lastVerification?.last_verification_message_id) { 358 | try { 359 | await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/deleteMessage`, { 360 | method: 'POST', 361 | headers: { 'Content-Type': 'application/json' }, 362 | body: JSON.stringify({ 363 | chat_id: chatId, 364 | message_id: lastVerification.last_verification_message_id 365 | }) 366 | }); 367 | } catch (deleteError) { 368 | console.log(`删除旧验证消息失败: ${deleteError.message}`); 369 | // 删除失败仍继续处理 370 | } 371 | 372 | await env.D1.prepare('UPDATE user_states SET last_verification_message_id = NULL WHERE chat_id = ?') 373 | .bind(chatId) 374 | .run(); 375 | } 376 | } catch (error) { 377 | console.log(`查询旧验证消息失败: ${error.message}`); 378 | // 即使出错也继续处理 379 | } 380 | 381 | // 立即发送新的验证码 382 | try { 383 | await handleVerification(chatId, 0); 384 | } catch (verificationError) { 385 | console.error(`发送新验证码失败: ${verificationError.message}`); 386 | // 如果发送验证码失败,则再次尝试 387 | setTimeout(async () => { 388 | try { 389 | await handleVerification(chatId, 0); 390 | } catch (retryError) { 391 | console.error(`重试发送验证码仍失败: ${retryError.message}`); 392 | await sendMessageToUser(chatId, '发送验证码失败,请发送任意消息重试'); 393 | } 394 | }, 1000); 395 | } 396 | return; 397 | } else { 398 | await sendMessageToUser(chatId, `请完成验证后发送消息"${text || '您的具体信息'}"。`); 399 | } 400 | return; 401 | } 402 | await sendMessageToUser(chatId, `请完成验证后发送消息"${text || '您的具体信息'}"。`); 403 | await handleVerification(chatId, messageId); 404 | return; 405 | } 406 | } 407 | 408 | if (text === '/start') { 409 | if (await checkStartCommandRate(chatId)) { 410 | await sendMessageToUser(chatId, "您发送 /start 命令过于频繁,请稍后再试!"); 411 | return; 412 | } 413 | 414 | const successMessage = await getVerificationSuccessMessage(); 415 | await sendMessageToUser(chatId, `${successMessage}\n你好,欢迎使用私聊机器人,现在发送信息吧!`); 416 | const userInfo = await getUserInfo(chatId); 417 | await ensureUserTopic(chatId, userInfo); 418 | return; 419 | } 420 | 421 | const userInfo = await getUserInfo(chatId); 422 | if (!userInfo) { 423 | await sendMessageToUser(chatId, "无法获取用户信息,请稍后再试或联系管理员。"); 424 | return; 425 | } 426 | 427 | let topicId = await ensureUserTopic(chatId, userInfo); 428 | if (!topicId) { 429 | await sendMessageToUser(chatId, "无法创建话题,请稍后再试或联系管理员。"); 430 | return; 431 | } 432 | 433 | const isTopicValid = await validateTopic(topicId); 434 | if (!isTopicValid) { 435 | await env.D1.prepare('DELETE FROM chat_topic_mappings WHERE chat_id = ?').bind(chatId).run(); 436 | topicIdCache.set(chatId, undefined); 437 | topicId = await ensureUserTopic(chatId, userInfo); 438 | if (!topicId) { 439 | await sendMessageToUser(chatId, "无法重新创建话题,请稍后再试或联系管理员。"); 440 | return; 441 | } 442 | } 443 | 444 | const userName = userInfo.username || `User_${chatId}`; 445 | const nickname = userInfo.nickname || userName; 446 | 447 | if (text) { 448 | const formattedMessage = `${nickname}:\n${text}`; 449 | await sendMessageToTopic(topicId, formattedMessage); 450 | } else { 451 | await copyMessageToTopic(topicId, message); 452 | } 453 | } 454 | 455 | async function validateTopic(topicId) { 456 | try { 457 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, { 458 | method: 'POST', 459 | headers: { 'Content-Type': 'application/json' }, 460 | body: JSON.stringify({ 461 | chat_id: GROUP_ID, 462 | message_thread_id: topicId, 463 | text: "您有新消息!", 464 | disable_notification: true 465 | }) 466 | }); 467 | const data = await response.json(); 468 | if (data.ok) { 469 | await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/deleteMessage`, { 470 | method: 'POST', 471 | headers: { 'Content-Type': 'application/json' }, 472 | body: JSON.stringify({ 473 | chat_id: GROUP_ID, 474 | message_id: data.result.message_id 475 | }) 476 | }); 477 | return true; 478 | } 479 | return false; 480 | } catch (error) { 481 | return false; 482 | } 483 | } 484 | 485 | async function ensureUserTopic(chatId, userInfo) { 486 | let lock = topicCreationLocks.get(chatId); 487 | if (!lock) { 488 | lock = Promise.resolve(); 489 | topicCreationLocks.set(chatId, lock); 490 | } 491 | 492 | try { 493 | await lock; 494 | 495 | let topicId = await getExistingTopicId(chatId); 496 | if (topicId) { 497 | return topicId; 498 | } 499 | 500 | const newLock = (async () => { 501 | const userName = userInfo.username || `User_${chatId}`; 502 | const nickname = userInfo.nickname || userName; 503 | topicId = await createForumTopic(nickname, userName, nickname, userInfo.id || chatId); 504 | await saveTopicId(chatId, topicId); 505 | return topicId; 506 | })(); 507 | 508 | topicCreationLocks.set(chatId, newLock); 509 | return await newLock; 510 | } finally { 511 | if (topicCreationLocks.get(chatId) === lock) { 512 | topicCreationLocks.delete(chatId); 513 | } 514 | } 515 | } 516 | 517 | async function handleResetUser(chatId, topicId, text) { 518 | const senderId = chatId; 519 | const isAdmin = await checkIfAdmin(senderId); 520 | if (!isAdmin) { 521 | await sendMessageToTopic(topicId, '只有管理员可以使用此功能。'); 522 | return; 523 | } 524 | 525 | const parts = text.split(' '); 526 | if (parts.length !== 2) { 527 | await sendMessageToTopic(topicId, '用法:/reset_user '); 528 | return; 529 | } 530 | 531 | const targetChatId = parts[1]; 532 | await env.D1.batch([ 533 | env.D1.prepare('DELETE FROM user_states WHERE chat_id = ?').bind(targetChatId), 534 | env.D1.prepare('DELETE FROM message_rates WHERE chat_id = ?').bind(targetChatId), 535 | env.D1.prepare('DELETE FROM chat_topic_mappings WHERE chat_id = ?').bind(targetChatId) 536 | ]); 537 | userStateCache.set(targetChatId, undefined); 538 | messageRateCache.set(targetChatId, undefined); 539 | topicIdCache.set(targetChatId, undefined); 540 | await sendMessageToTopic(topicId, `用户 ${targetChatId} 的状态已重置。`); 541 | } 542 | 543 | async function sendAdminPanel(chatId, topicId, privateChatId, messageId) { 544 | const verificationEnabled = (await getSetting('verification_enabled', env.D1)) === 'true'; 545 | const userRawEnabled = (await getSetting('user_raw_enabled', env.D1)) === 'true'; 546 | 547 | const buttons = [ 548 | [ 549 | { text: '拉黑用户', callback_data: `block_${privateChatId}` }, 550 | { text: '解除拉黑', callback_data: `unblock_${privateChatId}` } 551 | ], 552 | [ 553 | { text: verificationEnabled ? '关闭验证码' : '开启验证码', callback_data: `toggle_verification_${privateChatId}` }, 554 | { text: '查询黑名单', callback_data: `check_blocklist_${privateChatId}` } 555 | ], 556 | [ 557 | { text: userRawEnabled ? '关闭用户Raw' : '开启用户Raw', callback_data: `toggle_user_raw_${privateChatId}` }, 558 | { text: 'GitHub项目', url: 'https://github.com/iawooo/ctt' } 559 | ], 560 | [ 561 | { text: '删除用户', callback_data: `delete_user_${privateChatId}` } 562 | ] 563 | ]; 564 | 565 | const adminMessage = '管理员面板:请选择操作'; 566 | await Promise.all([ 567 | fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, { 568 | method: 'POST', 569 | headers: { 'Content-Type': 'application/json' }, 570 | body: JSON.stringify({ 571 | chat_id: chatId, 572 | message_thread_id: topicId, 573 | text: adminMessage, 574 | reply_markup: { inline_keyboard: buttons } 575 | }) 576 | }), 577 | fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/deleteMessage`, { 578 | method: 'POST', 579 | headers: { 'Content-Type': 'application/json' }, 580 | body: JSON.stringify({ 581 | chat_id: chatId, 582 | message_id: messageId 583 | }) 584 | }) 585 | ]); 586 | } 587 | 588 | async function getVerificationSuccessMessage() { 589 | const userRawEnabled = (await getSetting('user_raw_enabled', env.D1)) === 'true'; 590 | if (!userRawEnabled) return '验证成功!您现在可以与我聊天。'; 591 | 592 | const response = await fetch('https://raw.githubusercontent.com/iawooo/ctt/refs/heads/main/CFTeleTrans/start.md'); 593 | if (!response.ok) return '验证成功!您现在可以与我聊天。'; 594 | const message = await response.text(); 595 | return message.trim() || '验证成功!您现在可以与我聊天。'; 596 | } 597 | 598 | async function getNotificationContent() { 599 | const response = await fetch('https://raw.githubusercontent.com/iawooo/ctt/refs/heads/main/CFTeleTrans/notification.md'); 600 | if (!response.ok) return ''; 601 | const content = await response.text(); 602 | return content.trim() || ''; 603 | } 604 | 605 | async function checkStartCommandRate(chatId) { 606 | const now = Date.now(); 607 | const window = 5 * 60 * 1000; 608 | const maxStartsPerWindow = 1; 609 | 610 | let data = messageRateCache.get(chatId); 611 | if (data === undefined) { 612 | data = await env.D1.prepare('SELECT start_count, start_window_start FROM message_rates WHERE chat_id = ?') 613 | .bind(chatId) 614 | .first(); 615 | if (!data) { 616 | data = { start_count: 0, start_window_start: now }; 617 | await env.D1.prepare('INSERT INTO message_rates (chat_id, start_count, start_window_start) VALUES (?, ?, ?)') 618 | .bind(chatId, data.start_count, data.start_window_start) 619 | .run(); 620 | } 621 | messageRateCache.set(chatId, data); 622 | } 623 | 624 | if (now - data.start_window_start > window) { 625 | data.start_count = 1; 626 | data.start_window_start = now; 627 | await env.D1.prepare('UPDATE message_rates SET start_count = ?, start_window_start = ? WHERE chat_id = ?') 628 | .bind(data.start_count, data.start_window_start, chatId) 629 | .run(); 630 | } else { 631 | data.start_count += 1; 632 | await env.D1.prepare('UPDATE message_rates SET start_count = ? WHERE chat_id = ?') 633 | .bind(data.start_count, chatId) 634 | .run(); 635 | } 636 | 637 | messageRateCache.set(chatId, data); 638 | return data.start_count > maxStartsPerWindow; 639 | } 640 | 641 | async function checkMessageRate(chatId) { 642 | const now = Date.now(); 643 | const window = 60 * 1000; 644 | 645 | let data = messageRateCache.get(chatId); 646 | if (data === undefined) { 647 | data = await env.D1.prepare('SELECT message_count, window_start FROM message_rates WHERE chat_id = ?') 648 | .bind(chatId) 649 | .first(); 650 | if (!data) { 651 | data = { message_count: 0, window_start: now }; 652 | await env.D1.prepare('INSERT INTO message_rates (chat_id, message_count, window_start) VALUES (?, ?, ?)') 653 | .bind(chatId, data.message_count, data.window_start) 654 | .run(); 655 | } 656 | messageRateCache.set(chatId, data); 657 | } 658 | 659 | if (now - data.window_start > window) { 660 | data.message_count = 1; 661 | data.window_start = now; 662 | } else { 663 | data.message_count += 1; 664 | } 665 | 666 | messageRateCache.set(chatId, data); 667 | await env.D1.prepare('UPDATE message_rates SET message_count = ?, window_start = ? WHERE chat_id = ?') 668 | .bind(data.message_count, data.window_start, chatId) 669 | .run(); 670 | return data.message_count > MAX_MESSAGES_PER_MINUTE; 671 | } 672 | 673 | async function getSetting(key, d1) { 674 | const result = await d1.prepare('SELECT value FROM settings WHERE key = ?') 675 | .bind(key) 676 | .first(); 677 | return result?.value || null; 678 | } 679 | 680 | async function setSetting(key, value) { 681 | await env.D1.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') 682 | .bind(key, value) 683 | .run(); 684 | if (key === 'verification_enabled') { 685 | settingsCache.set('verification_enabled', value === 'true'); 686 | if (value === 'false') { 687 | const nowSeconds = Math.floor(Date.now() / 1000); 688 | const verifiedExpiry = nowSeconds + 3600 * 24; 689 | await env.D1.prepare('UPDATE user_states SET is_verified = ?, verified_expiry = ?, is_verifying = ?, verification_code = NULL, code_expiry = NULL, is_first_verification = ? WHERE chat_id NOT IN (SELECT chat_id FROM user_states WHERE is_blocked = TRUE)') 690 | .bind(true, verifiedExpiry, false, false) 691 | .run(); 692 | userStateCache.clear(); 693 | } 694 | } else if (key === 'user_raw_enabled') { 695 | settingsCache.set('user_raw_enabled', value === 'true'); 696 | } 697 | } 698 | 699 | async function onCallbackQuery(callbackQuery) { 700 | const chatId = callbackQuery.message.chat.id.toString(); 701 | const topicId = callbackQuery.message.message_thread_id; 702 | const data = callbackQuery.data; 703 | const messageId = callbackQuery.message.message_id; 704 | const callbackKey = `${chatId}:${callbackQuery.id}`; 705 | 706 | if (processedCallbacks.has(callbackKey)) { 707 | return; 708 | } 709 | processedCallbacks.add(callbackKey); 710 | 711 | const parts = data.split('_'); 712 | let action; 713 | let privateChatId; 714 | 715 | if (data.startsWith('verify_')) { 716 | action = 'verify'; 717 | privateChatId = parts[1]; 718 | } else if (data.startsWith('toggle_verification_')) { 719 | action = 'toggle_verification'; 720 | privateChatId = parts.slice(2).join('_'); 721 | } else if (data.startsWith('toggle_user_raw_')) { 722 | action = 'toggle_user_raw'; 723 | privateChatId = parts.slice(3).join('_'); 724 | } else if (data.startsWith('check_blocklist_')) { 725 | action = 'check_blocklist'; 726 | privateChatId = parts.slice(2).join('_'); 727 | } else if (data.startsWith('block_')) { 728 | action = 'block'; 729 | privateChatId = parts.slice(1).join('_'); 730 | } else if (data.startsWith('unblock_')) { 731 | action = 'unblock'; 732 | privateChatId = parts.slice(1).join('_'); 733 | } else if (data.startsWith('delete_user_')) { 734 | action = 'delete_user'; 735 | privateChatId = parts.slice(2).join('_'); 736 | } else { 737 | action = data; 738 | privateChatId = ''; 739 | } 740 | 741 | if (action === 'verify') { 742 | const [, userChatId, selectedAnswer, result] = data.split('_'); 743 | if (userChatId !== chatId) { 744 | return; 745 | } 746 | 747 | let verificationState = userStateCache.get(chatId); 748 | if (verificationState === undefined) { 749 | verificationState = await env.D1.prepare('SELECT verification_code, code_expiry, is_verifying FROM user_states WHERE chat_id = ?') 750 | .bind(chatId) 751 | .first(); 752 | if (!verificationState) { 753 | verificationState = { verification_code: null, code_expiry: null, is_verifying: false }; 754 | } 755 | userStateCache.set(chatId, verificationState); 756 | } 757 | 758 | const storedCode = verificationState.verification_code; 759 | const codeExpiry = verificationState.code_expiry; 760 | const nowSeconds = Math.floor(Date.now() / 1000); 761 | 762 | if (!storedCode || (codeExpiry && nowSeconds > codeExpiry)) { 763 | await sendMessageToUser(chatId, '验证码已过期,正在为您发送新的验证码...'); 764 | await env.D1.prepare('UPDATE user_states SET verification_code = NULL, code_expiry = NULL, is_verifying = FALSE WHERE chat_id = ?') 765 | .bind(chatId) 766 | .run(); 767 | userStateCache.set(chatId, { ...verificationState, verification_code: null, code_expiry: null, is_verifying: false }); 768 | 769 | // 删除旧的验证消息 770 | try { 771 | await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/deleteMessage`, { 772 | method: 'POST', 773 | headers: { 'Content-Type': 'application/json' }, 774 | body: JSON.stringify({ 775 | chat_id: chatId, 776 | message_id: messageId 777 | }) 778 | }); 779 | } catch (error) { 780 | console.log(`删除过期验证按钮失败: ${error.message}`); 781 | // 即使删除失败也继续处理 782 | } 783 | 784 | // 立即发送新的验证码 785 | try { 786 | await handleVerification(chatId, 0); 787 | } catch (verificationError) { 788 | console.error(`发送新验证码失败: ${verificationError.message}`); 789 | // 如果发送验证码失败,则再次尝试 790 | setTimeout(async () => { 791 | try { 792 | await handleVerification(chatId, 0); 793 | } catch (retryError) { 794 | console.error(`重试发送验证码仍失败: ${retryError.message}`); 795 | await sendMessageToUser(chatId, '发送验证码失败,请发送任意消息重试'); 796 | } 797 | }, 1000); 798 | } 799 | return; 800 | } 801 | 802 | if (result === 'correct') { 803 | const verifiedExpiry = nowSeconds + 3600 * 24; 804 | await env.D1.prepare('UPDATE user_states SET is_verified = ?, verified_expiry = ?, verification_code = NULL, code_expiry = NULL, last_verification_message_id = NULL, is_first_verification = ?, is_verifying = ? WHERE chat_id = ?') 805 | .bind(true, verifiedExpiry, false, false, chatId) 806 | .run(); 807 | verificationState = await env.D1.prepare('SELECT is_verified, verified_expiry, verification_code, code_expiry, last_verification_message_id, is_first_verification, is_verifying FROM user_states WHERE chat_id = ?') 808 | .bind(chatId) 809 | .first(); 810 | userStateCache.set(chatId, verificationState); 811 | 812 | let rateData = await env.D1.prepare('SELECT message_count, window_start FROM message_rates WHERE chat_id = ?') 813 | .bind(chatId) 814 | .first() || { message_count: 0, window_start: nowSeconds * 1000 }; 815 | rateData.message_count = 0; 816 | rateData.window_start = nowSeconds * 1000; 817 | messageRateCache.set(chatId, rateData); 818 | await env.D1.prepare('UPDATE message_rates SET message_count = ?, window_start = ? WHERE chat_id = ?') 819 | .bind(0, nowSeconds * 1000, chatId) 820 | .run(); 821 | 822 | const successMessage = await getVerificationSuccessMessage(); 823 | await sendMessageToUser(chatId, `${successMessage}\n你好,欢迎使用私聊机器人!现在可以发送消息了。`); 824 | const userInfo = await getUserInfo(chatId); 825 | await ensureUserTopic(chatId, userInfo); 826 | } else { 827 | await sendMessageToUser(chatId, '验证失败,请重新尝试。'); 828 | await handleVerification(chatId, messageId); 829 | } 830 | 831 | await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/deleteMessage`, { 832 | method: 'POST', 833 | headers: { 'Content-Type': 'application/json' }, 834 | body: JSON.stringify({ 835 | chat_id: chatId, 836 | message_id: messageId 837 | }) 838 | }); 839 | } else { 840 | const senderId = callbackQuery.from.id.toString(); 841 | const isAdmin = await checkIfAdmin(senderId); 842 | if (!isAdmin) { 843 | await sendMessageToTopic(topicId, '只有管理员可以使用此功能。'); 844 | await sendAdminPanel(chatId, topicId, privateChatId, messageId); 845 | return; 846 | } 847 | 848 | if (action === 'block') { 849 | let state = userStateCache.get(privateChatId); 850 | if (state === undefined) { 851 | state = await env.D1.prepare('SELECT is_blocked FROM user_states WHERE chat_id = ?') 852 | .bind(privateChatId) 853 | .first() || { is_blocked: false }; 854 | } 855 | state.is_blocked = true; 856 | userStateCache.set(privateChatId, state); 857 | await env.D1.prepare('INSERT OR REPLACE INTO user_states (chat_id, is_blocked) VALUES (?, ?)') 858 | .bind(privateChatId, true) 859 | .run(); 860 | await sendMessageToTopic(topicId, `用户 ${privateChatId} 已被拉黑,消息将不再转发。`); 861 | } else if (action === 'unblock') { 862 | let state = userStateCache.get(privateChatId); 863 | if (state === undefined) { 864 | state = await env.D1.prepare('SELECT is_blocked, is_first_verification FROM user_states WHERE chat_id = ?') 865 | .bind(privateChatId) 866 | .first() || { is_blocked: false, is_first_verification: true }; 867 | } 868 | state.is_blocked = false; 869 | state.is_first_verification = true; 870 | userStateCache.set(privateChatId, state); 871 | await env.D1.prepare('INSERT OR REPLACE INTO user_states (chat_id, is_blocked, is_first_verification) VALUES (?, ?, ?)') 872 | .bind(privateChatId, false, true) 873 | .run(); 874 | await sendMessageToTopic(topicId, `用户 ${privateChatId} 已解除拉黑,消息将继续转发。`); 875 | } else if (action === 'toggle_verification') { 876 | const currentState = (await getSetting('verification_enabled', env.D1)) === 'true'; 877 | const newState = !currentState; 878 | await setSetting('verification_enabled', newState.toString()); 879 | await sendMessageToTopic(topicId, `验证码功能已${newState ? '开启' : '关闭'}。`); 880 | } else if (action === 'check_blocklist') { 881 | const blockedUsers = await env.D1.prepare('SELECT chat_id FROM user_states WHERE is_blocked = ?') 882 | .bind(true) 883 | .all(); 884 | const blockList = blockedUsers.results.length > 0 885 | ? blockedUsers.results.map(row => row.chat_id).join('\n') 886 | : '当前没有被拉黑的用户。'; 887 | await sendMessageToTopic(topicId, `黑名单列表:\n${blockList}`); 888 | } else if (action === 'toggle_user_raw') { 889 | const currentState = (await getSetting('user_raw_enabled', env.D1)) === 'true'; 890 | const newState = !currentState; 891 | await setSetting('user_raw_enabled', newState.toString()); 892 | await sendMessageToTopic(topicId, `用户端 Raw 链接已${newState ? '开启' : '关闭'}。`); 893 | } else if (action === 'delete_user') { 894 | userStateCache.set(privateChatId, undefined); 895 | messageRateCache.set(privateChatId, undefined); 896 | topicIdCache.set(privateChatId, undefined); 897 | await env.D1.batch([ 898 | env.D1.prepare('DELETE FROM user_states WHERE chat_id = ?').bind(privateChatId), 899 | env.D1.prepare('DELETE FROM message_rates WHERE chat_id = ?').bind(privateChatId), 900 | env.D1.prepare('DELETE FROM chat_topic_mappings WHERE chat_id = ?').bind(privateChatId) 901 | ]); 902 | await sendMessageToTopic(topicId, `用户 ${privateChatId} 的状态、消息记录和话题映射已删除,用户需重新发起会话。`); 903 | } else { 904 | await sendMessageToTopic(topicId, `未知操作:${action}`); 905 | } 906 | 907 | await sendAdminPanel(chatId, topicId, privateChatId, messageId); 908 | } 909 | 910 | await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/answerCallbackQuery`, { 911 | method: 'POST', 912 | headers: { 'Content-Type': 'application/json' }, 913 | body: JSON.stringify({ 914 | callback_query_id: callbackQuery.id 915 | }) 916 | }); 917 | } 918 | 919 | async function handleVerification(chatId, messageId) { 920 | try { 921 | let userState = userStateCache.get(chatId); 922 | if (userState === undefined) { 923 | userState = await env.D1.prepare('SELECT is_blocked, is_first_verification, is_verified, verified_expiry, is_verifying FROM user_states WHERE chat_id = ?') 924 | .bind(chatId) 925 | .first(); 926 | if (!userState) { 927 | userState = { is_blocked: false, is_first_verification: true, is_verified: false, verified_expiry: null, is_verifying: false }; 928 | } 929 | userStateCache.set(chatId, userState); 930 | } 931 | 932 | userState.verification_code = null; 933 | userState.code_expiry = null; 934 | userState.is_verifying = true; 935 | userStateCache.set(chatId, userState); 936 | await env.D1.prepare('UPDATE user_states SET verification_code = NULL, code_expiry = NULL, is_verifying = ? WHERE chat_id = ?') 937 | .bind(true, chatId) 938 | .run(); 939 | 940 | const lastVerification = userState.last_verification_message_id || (await env.D1.prepare('SELECT last_verification_message_id FROM user_states WHERE chat_id = ?') 941 | .bind(chatId) 942 | .first())?.last_verification_message_id; 943 | 944 | if (lastVerification) { 945 | try { 946 | await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/deleteMessage`, { 947 | method: 'POST', 948 | headers: { 'Content-Type': 'application/json' }, 949 | body: JSON.stringify({ 950 | chat_id: chatId, 951 | message_id: lastVerification 952 | }) 953 | }); 954 | } catch (deleteError) { 955 | console.log(`删除上一条验证消息失败: ${deleteError.message}`); 956 | // 继续处理,即使删除失败 957 | } 958 | 959 | userState.last_verification_message_id = null; 960 | userStateCache.set(chatId, userState); 961 | await env.D1.prepare('UPDATE user_states SET last_verification_message_id = NULL WHERE chat_id = ?') 962 | .bind(chatId) 963 | .run(); 964 | } 965 | 966 | // 确保发送验证码 967 | await sendVerification(chatId); 968 | } catch (error) { 969 | console.error(`处理验证过程失败: ${error.message}`); 970 | // 重置用户状态以防卡住 971 | try { 972 | await env.D1.prepare('UPDATE user_states SET is_verifying = FALSE WHERE chat_id = ?') 973 | .bind(chatId) 974 | .run(); 975 | let currentState = userStateCache.get(chatId); 976 | if (currentState) { 977 | currentState.is_verifying = false; 978 | userStateCache.set(chatId, currentState); 979 | } 980 | } catch (resetError) { 981 | console.error(`重置用户验证状态失败: ${resetError.message}`); 982 | } 983 | throw error; // 向上传递错误以便调用方处理 984 | } 985 | } 986 | 987 | async function sendVerification(chatId) { 988 | try { 989 | const num1 = Math.floor(Math.random() * 10); 990 | const num2 = Math.floor(Math.random() * 10); 991 | const operation = Math.random() > 0.5 ? '+' : '-'; 992 | const correctResult = operation === '+' ? num1 + num2 : num1 - num2; 993 | 994 | const options = new Set([correctResult]); 995 | while (options.size < 4) { 996 | const wrongResult = correctResult + Math.floor(Math.random() * 5) - 2; 997 | if (wrongResult !== correctResult) options.add(wrongResult); 998 | } 999 | const optionArray = Array.from(options).sort(() => Math.random() - 0.5); 1000 | 1001 | const buttons = optionArray.map(option => ({ 1002 | text: `(${option})`, 1003 | callback_data: `verify_${chatId}_${option}_${option === correctResult ? 'correct' : 'wrong'}` 1004 | })); 1005 | 1006 | const question = `请计算:${num1} ${operation} ${num2} = ?(点击下方按钮完成验证)`; 1007 | const nowSeconds = Math.floor(Date.now() / 1000); 1008 | const codeExpiry = nowSeconds + 300; 1009 | 1010 | let userState = userStateCache.get(chatId); 1011 | if (userState === undefined) { 1012 | userState = { verification_code: correctResult.toString(), code_expiry: codeExpiry, last_verification_message_id: null, is_verifying: true }; 1013 | } else { 1014 | userState.verification_code = correctResult.toString(); 1015 | userState.code_expiry = codeExpiry; 1016 | userState.last_verification_message_id = null; 1017 | userState.is_verifying = true; 1018 | } 1019 | userStateCache.set(chatId, userState); 1020 | 1021 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, { 1022 | method: 'POST', 1023 | headers: { 'Content-Type': 'application/json' }, 1024 | body: JSON.stringify({ 1025 | chat_id: chatId, 1026 | text: question, 1027 | reply_markup: { inline_keyboard: [buttons] } 1028 | }) 1029 | }); 1030 | const data = await response.json(); 1031 | if (data.ok) { 1032 | userState.last_verification_message_id = data.result.message_id.toString(); 1033 | userStateCache.set(chatId, userState); 1034 | await env.D1.prepare('UPDATE user_states SET verification_code = ?, code_expiry = ?, last_verification_message_id = ?, is_verifying = ? WHERE chat_id = ?') 1035 | .bind(correctResult.toString(), codeExpiry, data.result.message_id.toString(), true, chatId) 1036 | .run(); 1037 | } else { 1038 | throw new Error(`Telegram API 返回错误: ${data.description || '未知错误'}`); 1039 | } 1040 | } catch (error) { 1041 | console.error(`发送验证码失败: ${error.message}`); 1042 | throw error; // 向上传递错误以便调用方处理 1043 | } 1044 | } 1045 | 1046 | async function checkIfAdmin(userId) { 1047 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/getChatMember`, { 1048 | method: 'POST', 1049 | headers: { 'Content-Type': 'application/json' }, 1050 | body: JSON.stringify({ 1051 | chat_id: GROUP_ID, 1052 | user_id: userId 1053 | }) 1054 | }); 1055 | const data = await response.json(); 1056 | return data.ok && (data.result.status === 'administrator' || data.result.status === 'creator'); 1057 | } 1058 | 1059 | async function getUserInfo(chatId) { 1060 | let userInfo = userInfoCache.get(chatId); 1061 | if (userInfo !== undefined) { 1062 | return userInfo; 1063 | } 1064 | 1065 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/getChat`, { 1066 | method: 'POST', 1067 | headers: { 'Content-Type': 'application/json' }, 1068 | body: JSON.stringify({ chat_id: chatId }) 1069 | }); 1070 | const data = await response.json(); 1071 | if (!data.ok) { 1072 | userInfo = { 1073 | id: chatId, 1074 | username: `User_${chatId}`, 1075 | nickname: `User_${chatId}` 1076 | }; 1077 | } else { 1078 | const result = data.result; 1079 | const nickname = result.first_name 1080 | ? `${result.first_name}${result.last_name ? ` ${result.last_name}` : ''}`.trim() 1081 | : result.username || `User_${chatId}`; 1082 | userInfo = { 1083 | id: result.id || chatId, 1084 | username: result.username || `User_${chatId}`, 1085 | nickname: nickname 1086 | }; 1087 | } 1088 | 1089 | userInfoCache.set(chatId, userInfo); 1090 | return userInfo; 1091 | } 1092 | 1093 | async function getExistingTopicId(chatId) { 1094 | let topicId = topicIdCache.get(chatId); 1095 | if (topicId !== undefined) { 1096 | return topicId; 1097 | } 1098 | 1099 | const result = await env.D1.prepare('SELECT topic_id FROM chat_topic_mappings WHERE chat_id = ?') 1100 | .bind(chatId) 1101 | .first(); 1102 | topicId = result?.topic_id || null; 1103 | if (topicId) { 1104 | topicIdCache.set(chatId, topicId); 1105 | } 1106 | return topicId; 1107 | } 1108 | 1109 | async function createForumTopic(topicName, userName, nickname, userId) { 1110 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/createForumTopic`, { 1111 | method: 'POST', 1112 | headers: { 'Content-Type': 'application/json' }, 1113 | body: JSON.stringify({ chat_id: GROUP_ID, name: `${nickname}` }) 1114 | }); 1115 | const data = await response.json(); 1116 | if (!data.ok) throw new Error(`Failed to create forum topic: ${data.description}`); 1117 | const topicId = data.result.message_thread_id; 1118 | 1119 | const now = new Date(); 1120 | const formattedTime = now.toISOString().replace('T', ' ').substring(0, 19); 1121 | const notificationContent = await getNotificationContent(); 1122 | const pinnedMessage = `昵称: ${nickname}\n用户名: @${userName}\nUserID: ${userId}\n发起时间: ${formattedTime}\n\n${notificationContent}`; 1123 | const messageResponse = await sendMessageToTopic(topicId, pinnedMessage); 1124 | const messageId = messageResponse.result.message_id; 1125 | await pinMessage(topicId, messageId); 1126 | 1127 | return topicId; 1128 | } 1129 | 1130 | async function saveTopicId(chatId, topicId) { 1131 | await env.D1.prepare('INSERT OR REPLACE INTO chat_topic_mappings (chat_id, topic_id) VALUES (?, ?)') 1132 | .bind(chatId, topicId) 1133 | .run(); 1134 | topicIdCache.set(chatId, topicId); 1135 | } 1136 | 1137 | async function getPrivateChatId(topicId) { 1138 | for (const [chatId, tid] of topicIdCache.cache) if (tid === topicId) return chatId; 1139 | const mapping = await env.D1.prepare('SELECT chat_id FROM chat_topic_mappings WHERE topic_id = ?') 1140 | .bind(topicId) 1141 | .first(); 1142 | return mapping?.chat_id || null; 1143 | } 1144 | 1145 | async function sendMessageToTopic(topicId, text) { 1146 | if (!text.trim()) { 1147 | throw new Error('Message text is empty'); 1148 | } 1149 | 1150 | const requestBody = { 1151 | chat_id: GROUP_ID, 1152 | text: text, 1153 | message_thread_id: topicId 1154 | }; 1155 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, { 1156 | method: 'POST', 1157 | headers: { 'Content-Type': 'application/json' }, 1158 | body: JSON.stringify(requestBody) 1159 | }); 1160 | const data = await response.json(); 1161 | if (!data.ok) { 1162 | throw new Error(`Failed to send message to topic ${topicId}: ${data.description}`); 1163 | } 1164 | return data; 1165 | } 1166 | 1167 | async function copyMessageToTopic(topicId, message) { 1168 | const requestBody = { 1169 | chat_id: GROUP_ID, 1170 | from_chat_id: message.chat.id, 1171 | message_id: message.message_id, 1172 | message_thread_id: topicId, 1173 | disable_notification: true 1174 | }; 1175 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/copyMessage`, { 1176 | method: 'POST', 1177 | headers: { 'Content-Type': 'application/json' }, 1178 | body: JSON.stringify(requestBody) 1179 | }); 1180 | const data = await response.json(); 1181 | if (!data.ok) { 1182 | throw new Error(`Failed to copy message to topic ${topicId}: ${data.description}`); 1183 | } 1184 | } 1185 | 1186 | async function pinMessage(topicId, messageId) { 1187 | const requestBody = { 1188 | chat_id: GROUP_ID, 1189 | message_id: messageId, 1190 | message_thread_id: topicId 1191 | }; 1192 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/pinChatMessage`, { 1193 | method: 'POST', 1194 | headers: { 'Content-Type': 'application/json' }, 1195 | body: JSON.stringify(requestBody) 1196 | }); 1197 | const data = await response.json(); 1198 | if (!data.ok) { 1199 | throw new Error(`Failed to pin message: ${data.description}`); 1200 | } 1201 | } 1202 | 1203 | async function forwardMessageToPrivateChat(privateChatId, message) { 1204 | const requestBody = { 1205 | chat_id: privateChatId, 1206 | from_chat_id: message.chat.id, 1207 | message_id: message.message_id, 1208 | disable_notification: true 1209 | }; 1210 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/copyMessage`, { 1211 | method: 'POST', 1212 | headers: { 'Content-Type': 'application/json' }, 1213 | body: JSON.stringify(requestBody) 1214 | }); 1215 | const data = await response.json(); 1216 | if (!data.ok) { 1217 | throw new Error(`Failed to forward message to private chat: ${data.description}`); 1218 | } 1219 | } 1220 | 1221 | async function sendMessageToUser(chatId, text) { 1222 | const requestBody = { chat_id: chatId, text: text }; 1223 | const response = await fetchWithRetry(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, { 1224 | method: 'POST', 1225 | headers: { 'Content-Type': 'application/json' }, 1226 | body: JSON.stringify(requestBody) 1227 | }); 1228 | const data = await response.json(); 1229 | if (!data.ok) { 1230 | throw new Error(`Failed to send message to user: ${data.description}`); 1231 | } 1232 | } 1233 | 1234 | async function fetchWithRetry(url, options, retries = 3, backoff = 1000) { 1235 | for (let i = 0; i < retries; i++) { 1236 | try { 1237 | const controller = new AbortController(); 1238 | const timeoutId = setTimeout(() => controller.abort(), 5000); 1239 | const response = await fetch(url, { ...options, signal: controller.signal }); 1240 | clearTimeout(timeoutId); 1241 | 1242 | if (response.ok) { 1243 | return response; 1244 | } 1245 | if (response.status === 429) { 1246 | const retryAfter = response.headers.get('Retry-After') || 5; 1247 | const delay = parseInt(retryAfter) * 1000; 1248 | await new Promise(resolve => setTimeout(resolve, delay)); 1249 | continue; 1250 | } 1251 | throw new Error(`Request failed with status ${response.status}: ${await response.text()}`); 1252 | } catch (error) { 1253 | if (i === retries - 1) throw error; 1254 | await new Promise(resolve => setTimeout(resolve, backoff * Math.pow(2, i))); 1255 | } 1256 | } 1257 | throw new Error(`Failed to fetch ${url} after ${retries} retries`); 1258 | } 1259 | 1260 | async function registerWebhook(request) { 1261 | const webhookUrl = `${new URL(request.url).origin}/webhook`; 1262 | const response = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/setWebhook`, { 1263 | method: 'POST', 1264 | headers: { 'Content-Type': 'application/json' }, 1265 | body: JSON.stringify({ url: webhookUrl }) 1266 | }).then(r => r.json()); 1267 | return new Response(response.ok ? 'Webhook set successfully' : JSON.stringify(response, null, 2)); 1268 | } 1269 | 1270 | async function unRegisterWebhook() { 1271 | const response = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/setWebhook`, { 1272 | method: 'POST', 1273 | headers: { 'Content-Type': 'application/json' }, 1274 | body: JSON.stringify({ url: '' }) 1275 | }).then(r => r.json()); 1276 | return new Response(response.ok ? 'Webhook removed' : JSON.stringify(response, null, 2)); 1277 | } 1278 | 1279 | try { 1280 | return await handleRequest(request); 1281 | } catch (error) { 1282 | return new Response('Internal Server Error', { status: 500 }); 1283 | } 1284 | } 1285 | }; 1286 | -------------------------------------------------------------------------------- /images/nihao.md: -------------------------------------------------------------------------------- 1 | 你好 2 | -------------------------------------------------------------------------------- /images/star-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iawooo/ctt/9ffaa0c3768a6822c4697051b6ecfc37aa02b367/images/star-chart.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ctt", 3 | "version": "1.0.0", 4 | "description": "CFTeleTrans - Telegram message forwarding bot", 5 | "main": "index.js", 6 | "scripts": { 7 | "generate-star-chart": "node scripts/generate-star-chart.js" 8 | }, 9 | "keywords": ["telegram", "bot", "cloudflare"], 10 | "author": "iawooo", 11 | "license": "ISC", 12 | "dependencies": { 13 | "node-fetch": "^2.7.0", 14 | "chartjs-node-canvas": "^4.1.6", 15 | "chart.js": "^3.5.1", 16 | "canvas": "^2.11.2", 17 | "chartjs-plugin-datalabels": "^2.2.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /picture/0903f76329b80fc231893abde40b9ab8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iawooo/ctt/9ffaa0c3768a6822c4697051b6ecfc37aa02b367/picture/0903f76329b80fc231893abde40b9ab8.png -------------------------------------------------------------------------------- /picture/30d4b767f1c9a050999b8642f164c90c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iawooo/ctt/9ffaa0c3768a6822c4697051b6ecfc37aa02b367/picture/30d4b767f1c9a050999b8642f164c90c.png -------------------------------------------------------------------------------- /picture/60e43fcf63e7c49148c2df7caff7d176.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iawooo/ctt/9ffaa0c3768a6822c4697051b6ecfc37aa02b367/picture/60e43fcf63e7c49148c2df7caff7d176.png -------------------------------------------------------------------------------- /picture/nihao.md: -------------------------------------------------------------------------------- 1 | 你好 2 | -------------------------------------------------------------------------------- /scripts/generate-star-chart.js: -------------------------------------------------------------------------------- 1 | const { ChartJSNodeCanvas } = require('chartjs-node-canvas'); 2 | const ChartDataLabels = require('chartjs-plugin-datalabels'); 3 | const fs = require('fs'); 4 | const fetch = require('node-fetch'); 5 | 6 | // 获取星标数据,支持分页 7 | async function fetchStargazers() { 8 | const token = process.env.GITHUB_TOKEN; 9 | if (!token) { 10 | console.error('❌ 缺少 GITHUB_TOKEN 环境变量,请设置后再运行!'); 11 | return []; 12 | } 13 | 14 | let allStargazers = []; 15 | let page = 1; 16 | const perPage = 100; 17 | 18 | while (true) { 19 | console.log(`📡 正在获取第 ${page} 页星标数据...`); 20 | const response = await fetch(`https://api.github.com/repos/iawooo/ctt/stargazers?per_page=${perPage}&page=${page}`, { 21 | headers: { 22 | 'Authorization': `token ${token}`, 23 | 'Accept': 'application/vnd.github.v3.star+json', 24 | 'User-Agent': 'CFTeleTrans' 25 | } 26 | }); 27 | 28 | if (!response.ok) { 29 | const errorText = await response.text(); 30 | console.error(`❌ GitHub API 请求失败: ${response.status} ${response.statusText} - ${errorText}`); 31 | return []; 32 | } 33 | 34 | const stargazers = await response.json(); 35 | allStargazers = allStargazers.concat(stargazers); 36 | 37 | if (stargazers.length < perPage) break; 38 | page++; 39 | } 40 | 41 | console.log(`✅ 成功获取 ${allStargazers.length} 条星标数据`); 42 | console.log('最近的星标:', allStargazers.slice(-5).map(star => star.starred_at)); 43 | return allStargazers; 44 | } 45 | 46 | // 计算日期的周数(ISO 8601 周编号) 47 | function getWeekNumber(date) { 48 | const d = new Date(date); 49 | d.setHours(0, 0, 0, 0); 50 | d.setDate(d.getDate() + 4 - (d.getDay() || 7)); 51 | const yearStart = new Date(d.getFullYear(), 0, 1); 52 | const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); 53 | return `${d.getFullYear()}-W${weekNo.toString().padStart(2, '0')}`; 54 | } 55 | 56 | // 动态选择显示单位并生成图表 57 | async function generateChart() { 58 | const stargazers = await fetchStargazers(); 59 | if (stargazers.length === 0) { 60 | console.error('❌ 没有获取到星标数据,无法生成图表'); 61 | return; 62 | } 63 | 64 | const starDates = stargazers.map(star => new Date(star.starred_at)); 65 | const earliestDate = new Date(Math.min(...starDates)); 66 | const now = new Date(); 67 | 68 | // 计算总天数 69 | const totalDays = Math.ceil((now - earliestDate) / (1000 * 60 * 60 * 24)); 70 | console.log(`总天数: ${totalDays}`); 71 | 72 | // 根据时间跨度选择显示单位 73 | let unit; 74 | let labels = []; 75 | let starCounts = []; 76 | 77 | if (totalDays > 0 && totalDays < 30) { 78 | // 使用“天”作为单位(0 天 < 时间跨度 < 30 天) 79 | unit = 'day'; 80 | const daysDiff = totalDays; 81 | starCounts = Array(daysDiff).fill(0); 82 | for (let i = daysDiff - 1; i >= 0; i--) { 83 | const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i); 84 | const dayStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; 85 | labels.push(dayStr); 86 | const count = stargazers.filter(star => { 87 | const starDate = new Date(star.starred_at); 88 | return starDate.toDateString() === date.toDateString(); 89 | }).length; 90 | starCounts[daysDiff - 1 - i] = count; 91 | } 92 | } else if (totalDays >= 30 && totalDays < 180) { 93 | // 使用“周”作为单位(30 天 <= 时间跨度 < 180 天) 94 | unit = 'week'; 95 | const weeksDiff = Math.ceil(totalDays / 7); 96 | starCounts = Array(weeksDiff).fill(0); 97 | for (let i = weeksDiff - 1; i >= 0; i--) { 98 | const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i * 7); 99 | const weekStr = getWeekNumber(date); 100 | labels.push(weekStr); 101 | const startOfWeek = new Date(date); 102 | startOfWeek.setDate(date.getDate() - (date.getDay() || 7) + 1); 103 | const endOfWeek = new Date(startOfWeek); 104 | endOfWeek.setDate(startOfWeek.getDate() + 6); 105 | const count = stargazers.filter(star => { 106 | const starDate = new Date(star.starred_at); 107 | return starDate >= startOfWeek && starDate <= endOfWeek; 108 | }).length; 109 | starCounts[weeksDiff - 1 - i] = count; 110 | } 111 | } else if (totalDays >= 180 && totalDays < 1000) { 112 | // 使用“月”作为单位(180 天 <= 时间跨度 < 1000 天) 113 | unit = 'month'; 114 | const monthsDiff = (now.getFullYear() - earliestDate.getFullYear()) * 12 + (now.getMonth() - earliestDate.getMonth()) + 1; 115 | starCounts = Array(monthsDiff).fill(0); 116 | for (let i = monthsDiff - 1; i >= 0; i--) { 117 | const date = new Date(now.getFullYear(), now.getMonth() - i, 1); 118 | const monthStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`; 119 | labels.push(monthStr); 120 | const count = stargazers.filter(star => { 121 | const starDate = new Date(star.starred_at); 122 | return starDate.getFullYear() === date.getFullYear() && starDate.getMonth() === date.getMonth(); 123 | }).length; 124 | starCounts[monthsDiff - 1 - i] = count; 125 | } 126 | } else if (totalDays >= 1000 && totalDays < 9999999) { 127 | // 使用“年”作为单位(1000 天 <= 时间跨度 < 9999999 天) 128 | unit = 'year'; 129 | const yearsDiff = now.getFullYear() - earliestDate.getFullYear() + 1; 130 | starCounts = Array(yearsDiff).fill(0); 131 | for (let i = yearsDiff - 1; i >= 0; i--) { 132 | const year = now.getFullYear() - i; 133 | labels.push(year.toString()); 134 | const count = stargazers.filter(star => { 135 | const starDate = new Date(star.starred_at); 136 | return starDate.getFullYear() === year; 137 | }).length; 138 | starCounts[yearsDiff - 1 - i] = count; 139 | } 140 | } else { 141 | console.error('❌ 时间跨度超出预期范围,无法生成图表'); 142 | return; 143 | } 144 | 145 | // 累加星标数量,生成趋势数据 146 | for (let i = 1; i < starCounts.length; i++) { 147 | starCounts[i] += starCounts[i - 1]; 148 | } 149 | 150 | console.log(`选择的显示单位: ${unit}`); 151 | console.log('横坐标标签:', labels); 152 | console.log('星标数量:', starCounts); 153 | console.log(`总星标数: ${starCounts[starCounts.length - 1]}`); 154 | 155 | // 创建 images 目录 156 | if (!fs.existsSync('images')) { 157 | console.log('📁 创建 images 目录...'); 158 | fs.mkdirSync('images'); 159 | } 160 | 161 | // 配置图表 162 | const width = 800; 163 | const height = 400; 164 | const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height }); 165 | 166 | const configuration = { 167 | type: 'line', 168 | data: { 169 | labels: labels, 170 | datasets: [{ 171 | label: 'Star 数量', 172 | data: starCounts, 173 | borderColor: 'rgba(75, 192, 192, 1)', 174 | fill: true, 175 | backgroundColor: 'rgba(75, 192, 192, 0.2)', 176 | tension: 0.3 177 | }] 178 | }, 179 | options: { 180 | scales: { 181 | y: { 182 | beginAtZero: true, 183 | title: { 184 | display: true, 185 | text: 'Star 数量', 186 | font: { size: 14 } 187 | }, 188 | ticks: { font: { size: 12 } } 189 | }, 190 | x: { 191 | title: { 192 | display: true, 193 | text: unit === 'day' ? '日期' : unit === 'week' ? '周' : unit === 'month' ? '月份' : '年份', 194 | font: { size: 14 } 195 | }, 196 | ticks: { 197 | font: { size: 12 }, 198 | maxRotation: 45, // 旋转标签以避免重叠 199 | minRotation: 45 200 | } 201 | } 202 | }, 203 | plugins: { 204 | legend: { 205 | labels: { 206 | font: { size: 14 } 207 | } 208 | }, 209 | datalabels: { 210 | display: true, 211 | align: 'top', 212 | color: '#666', 213 | font: { size: 12 }, 214 | formatter: (value) => value 215 | } 216 | } 217 | }, 218 | plugins: [ChartDataLabels] 219 | }; 220 | 221 | const image = await chartJSNodeCanvas.renderToBuffer(configuration); 222 | fs.writeFileSync('images/star-chart.png', image); 223 | console.log('✅ Star chart 生成成功: images/star-chart.png'); 224 | } 225 | 226 | // 运行脚本 227 | generateChart().catch(err => { 228 | console.error('❌ 生成图表时发生错误:', err); 229 | process.exit(1); 230 | }); 231 | --------------------------------------------------------------------------------