├── .prettierrc ├── .editorconfig ├── src ├── oss │ ├── interface.ts │ ├── cloudflareR2.ts │ └── backblazeB2.ts ├── utils.ts ├── index.ts └── bot.ts ├── tsconfig.json ├── wrangler.example.toml ├── worker-configuration.d.ts ├── package.json ├── .github └── workflows │ └── deploy-CloudflareWorkers.yml ├── .gitignore ├── README-zh_CN.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /src/oss/interface.ts: -------------------------------------------------------------------------------- 1 | export interface OSSProvider { 2 | uploadImage(data: any, fileName: string, fileType: string, customPath?: string): Promise; 3 | getURL(filePath: string): string; 4 | checkFileExists(filePath: string): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "lib": [ 9 | "ESNext" 10 | ], 11 | "types": [ 12 | "@cloudflare/workers-types/2023-07-01" 13 | ], 14 | "jsx": "react-jsx", 15 | "jsxImportSource": "hono/jsx" 16 | }, 17 | } -------------------------------------------------------------------------------- /wrangler.example.toml: -------------------------------------------------------------------------------- 1 | name = "img-mom" 2 | compatibility_date = "2024-08-10" 3 | 4 | account_id = "" 5 | 6 | [vars] 7 | TG_BOT_TOKEN = "" 8 | TG_WEBHOOK_SECRET_TOKEN = "" 9 | TG_BOT_OWNER_USERNAME = "" 10 | TG_BOT_ALLOW_ANYONE = "" 11 | 12 | R2_CUSTOM_DOMAIN = "" 13 | B2_KEY_ID = "" 14 | B2_SECRET_KEY = "" 15 | B2_ENDPOINT = "" 16 | B2_BUCKET = "" 17 | B2_CUSTOM_DOMAIN = "" 18 | 19 | [[kv_namespaces]] 20 | binding = "KV_IMG_MOM" 21 | id = "" 22 | 23 | [[r2_buckets]] 24 | binding = "R2_IMG_MOM" 25 | bucket_name = "" 26 | -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | interface Env { 2 | R2_IMG_MOM: R2Bucket; 3 | R2_CUSTOM_DOMAIN: string; 4 | B2_KEY_ID: string; 5 | B2_SECRET_KEY: string; 6 | B2_ENDPOINT: string; 7 | B2_BUCKET: string; 8 | B2_CUSTOM_DOMAIN: string; 9 | KV_IMG_MOM: KVNamespace; 10 | TG_BOT_TOKEN: string; 11 | TG_BOT_OWNER_USERNAME: string; 12 | TG_BOT_ALLOW_ANYONE: boolean | string; 13 | TG_WEBHOOK_SECRET_TOKEN: string; 14 | ENVIRONMENT?: 'production' 15 | } 16 | 17 | interface ServiceWorkerGlobalScope extends Env { 18 | host: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "grammy/types"; 2 | 3 | export const genDateDirName = () => { 4 | const date = new Date(); 5 | const t = (val: number) => { 6 | if (val < 10) { 7 | return `0${val}`; 8 | } 9 | return String(val); 10 | } 11 | return `${date.getUTCFullYear()}${t(date.getUTCMonth() + 1)}${t(date.getUTCDate())}`; 12 | } 13 | 14 | export const isOwner = (username?: string) => { 15 | return username === self.TG_BOT_OWNER_USERNAME; 16 | } 17 | 18 | export const isInPrivateChat = (message: Message) => { 19 | const chatType = message.chat.type; 20 | return chatType === 'private' 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "img-mom", 3 | "version": "0.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=20.0.0" 7 | }, 8 | "scripts": { 9 | "dev": "wrangler dev src/index.ts", 10 | "tunnel": "cloudflared tunnel --url http://127.0.0.1:8787", 11 | "deploy": "wrangler deploy --minify src/index.ts" 12 | }, 13 | "devDependencies": { 14 | "@cloudflare/workers-types": "^4.20240806.0", 15 | "cloudflared": "^0.5.3", 16 | "typescript": "^5.5.2", 17 | "wrangler": "^3.60.3" 18 | }, 19 | "dependencies": { 20 | "@aws-sdk/client-s3": "^3.627.0", 21 | "file-type": "^19.5.0", 22 | "grammy": "^1.28.0", 23 | "hono": "^4.5.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/oss/cloudflareR2.ts: -------------------------------------------------------------------------------- 1 | import { genDateDirName } from "../utils"; 2 | import { OSSProvider } from "./interface"; 3 | 4 | class CloudflareR2 implements OSSProvider { 5 | async uploadImage(data: any, fileName: string, fileType: string, customPath?: string) { 6 | const filePath = customPath || `${genDateDirName()}/${fileName}.${fileType}`; 7 | await self.R2_IMG_MOM.put(filePath, data); 8 | return filePath; 9 | } 10 | 11 | async checkFileExists(filePath: string) { 12 | const obj = await self.R2_IMG_MOM.head(filePath); 13 | return obj !== null; 14 | } 15 | 16 | getURL(filePath: string) { 17 | if (self.R2_CUSTOM_DOMAIN) { 18 | return `https://${self.R2_CUSTOM_DOMAIN}/${filePath}`; 19 | } 20 | 21 | return filePath; 22 | } 23 | } 24 | 25 | export default CloudflareR2; 26 | -------------------------------------------------------------------------------- /src/oss/backblazeB2.ts: -------------------------------------------------------------------------------- 1 | import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3' 2 | import { OSSProvider } from './interface'; 3 | import { genDateDirName } from '../utils'; 4 | 5 | class BackblazeB2 implements OSSProvider { 6 | private client: S3Client; 7 | 8 | constructor() { 9 | this.client = new S3Client({ 10 | endpoint: `https://${self.B2_ENDPOINT}`, 11 | region: self.B2_ENDPOINT.split('.')[1], 12 | credentials: { 13 | accessKeyId: self.B2_KEY_ID || '', 14 | secretAccessKey: self.B2_SECRET_KEY || '', 15 | } 16 | }); 17 | } 18 | 19 | async uploadImage(data: any, fileName: string, fileType: string, customPath?: string) { 20 | const filePath = customPath || `${genDateDirName()}/${fileName}.${fileType}`; 21 | await this.client.send(new PutObjectCommand({ 22 | Bucket: self.B2_BUCKET, 23 | Key: filePath, 24 | Body: data, 25 | ContentType: `image/${fileType}` 26 | })); 27 | return filePath; 28 | } 29 | 30 | async checkFileExists(filePath: string) { 31 | try { 32 | await this.client.send(new HeadObjectCommand({ 33 | Bucket: self.B2_BUCKET, 34 | Key: filePath, 35 | })); 36 | return true; 37 | } catch (error) { 38 | return false; 39 | } 40 | } 41 | 42 | getURL(filePath: string) { 43 | if (self.B2_CUSTOM_DOMAIN) { 44 | return `https://${self.B2_CUSTOM_DOMAIN}/${filePath}`; 45 | } 46 | return `https://${self.B2_BUCKET}.${self.B2_ENDPOINT}/${filePath}`; 47 | } 48 | } 49 | 50 | export default BackblazeB2; 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { webhookCallback } from 'grammy/web'; 3 | import bot from './bot'; 4 | import { fileTypeFromBuffer } from 'file-type' 5 | 6 | const app = new Hono(); 7 | 8 | app.post('/bot', async (ctx, next) => { 9 | self.host = new URL(ctx.req.url).host; 10 | return next(); 11 | }, webhookCallback(bot, 'hono', { 12 | secretToken: self.TG_WEBHOOK_SECRET_TOKEN 13 | })); 14 | 15 | app.get('/setup', async (ctx) => { 16 | const webhookHost = await self.KV_IMG_MOM.get('webhookHost'); 17 | if (!webhookHost) { 18 | const host = new URL(ctx.req.url).host; 19 | const botUrl = `https://${host}/bot`; 20 | await bot.api.setWebhook(botUrl, { 21 | secret_token: self.TG_WEBHOOK_SECRET_TOKEN, 22 | }); 23 | await bot.api.setMyCommands([{ 24 | command: '/settings', 25 | description: 'Setting up the bot', 26 | }]); 27 | await self.KV_IMG_MOM.put('webhookHost', host); 28 | return ctx.text(`Webhook(${botUrl}) setup successful`); 29 | } 30 | return ctx.text('401 Unauthorized. Please visit ImgMom docs (https://github.com/beilunyang/img-mom)', 401) 31 | }); 32 | 33 | app.get('/img/:fileId', async (ctx) => { 34 | const fileId = ctx.req.param('fileId'); 35 | const file = await bot.api.getFile(fileId) 36 | const res = await fetch(`https://api.telegram.org/file/bot${self.TG_BOT_TOKEN}/${file.file_path}`); 37 | if (!res.ok) { 38 | return ctx.text('404 Not Found. Please visit ImgMom docs (https://github.com/beilunyang/img-mom)', 404); 39 | } 40 | 41 | const bf = await res.arrayBuffer() 42 | 43 | const fileType = await fileTypeFromBuffer(bf) 44 | 45 | return ctx.body(bf, 200, { 46 | 'Content-Type': fileType?.mime ?? '', 47 | }); 48 | }); 49 | 50 | app.get('/', (ctx) => ctx.text('Hello ImgMom (https://github.com/beilunyang/img-mom)')); 51 | 52 | app.fire() 53 | -------------------------------------------------------------------------------- /.github/workflows/deploy-CloudflareWorkers.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Cloudflare Workers 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | deploy_worker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - run: | 13 | cp wrangler.example.toml wrangler.toml 14 | sed -i 's/account_id = ""/account_id = "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"/' wrangler.toml 15 | sed -i 's/TG_BOT_TOKEN = ""/TG_BOT_TOKEN = "${{ secrets.TG_BOT_TOKEN }}"/' wrangler.toml 16 | sed -i 's/TG_WEBHOOK_SECRET_TOKEN = ""/TG_WEBHOOK_SECRET_TOKEN = "${{ secrets.TG_WEBHOOK_SECRET_TOKEN }}"/' wrangler.toml 17 | sed -i 's/TG_BOT_OWNER_USERNAME = ""/TG_BOT_OWNER_USERNAME = "${{ secrets.TG_BOT_OWNER_USERNAME }}"/' wrangler.toml 18 | sed -i 's/TG_BOT_ALLOW_ANYONE = ""/TG_BOT_ALLOW_ANYONE = "${{ secrets.TG_BOT_ALLOW_ANYONE }}"/' wrangler.toml 19 | sed -i 's/R2_CUSTOM_DOMAIN = ""/R2_CUSTOM_DOMAIN = "${{ secrets.R2_CUSTOM_DOMAIN }}"/' wrangler.toml 20 | sed -i 's/id = ""/id = "${{ secrets.CLOUDFLARE_KV_NAMESPACE_ID }}"/' wrangler.toml 21 | sed -i 's/bucket_name = ""/bucket_name = "${{ secrets.CLOUDFLARE_BUCKET_NAME }}"/' wrangler.toml 22 | sed -i 's/B2_KEY_ID = ""/B2_KEY_ID = "${{ secrets.B2_KEY_ID }}"/' wrangler.toml 23 | sed -i 's/B2_SECRET_KEY = ""/B2_SECRET_KEY = "${{ secrets.B2_SECRET_KEY }}"/' wrangler.toml 24 | sed -i 's/B2_ENDPOINT = ""/B2_ENDPOINT = "${{ secrets.B2_ENDPOINT }}"/' wrangler.toml 25 | sed -i 's/B2_BUCKET = ""/B2_BUCKET = "${{ secrets.B2_BUCKET }}"/' wrangler.toml 26 | sed -i 's/B2_CUSTOM_DOMAIN = ""/B2_CUSTOM_DOMAIN = "${{ secrets.B2_CUSTOM_DOMAIN }}"/' wrangler.toml 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version-file: package.json 30 | - run: npm install 31 | - uses: cloudflare/wrangler-action@v3 32 | with: 33 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 34 | command: deploy --var VERSION:${{ github.sha }} --minify src/index.ts 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | wrangler.toml 174 | -------------------------------------------------------------------------------- /README-zh_CN.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 |

img-mom | 图片老妈

4 |

5 | Telegram 图片上传机器人,轻松上传图片到指定图床并获取外链地址 6 |

7 | 8 | [![twitter](https://img.shields.io/twitter/follow/beilunyang.svg?label=beilunyang 9 | )](https://x.com/beilunyang) 10 | ![wechat2](https://img.shields.io/badge/微信公众号-悖论的技术小屋-brightgreen?style=flat-square) 11 | 12 | [English](./README.md) . 中文 13 |
14 | 15 | 16 | ## 特性 17 | - 基于 Cloudflare Workers 运行时构建, 轻量使用完全免费 18 | - 支持多种图床(Telegram/Cloudfalre R2/Backblaze B2, 更多图床正在支持中) 19 | - 快速部署。使用 Wrangler 可快速实现自部署 20 | 21 | ## 使用方式 22 | 1. 将图片通过私聊发送给 Bot, Bot 默认将只回复对应的 Telegram 图床链接 23 | 2. 发送 `/settings` 指令,可以指定额外的图床,设置完成后,将图片通过私聊发送给 Bot, Bot 将回复你指定的额外图床链接 24 | 3. 可使用 https://t.me/img_mom_bot , 进行在线体验 25 | ![img-mom](https://pic.otaku.ren/20240212/AQADQ7oxGx0pUVZ9.jpg) 26 | 27 | ## 自定义URL路径 28 | 当上传到额外图床时,您可以指定完整的URL路径(域名部分除外): 29 | 30 | 1. 发送图片时添加说明文字(caption)来指定路径(例如:`path/to/image.jpg`) 31 | 2. 如果指定的路径已存在文件,机器人会询问是否要覆盖现有图片 32 | 3. 您可以确认或取消上传 33 | 34 | ## 自部署 35 | ### 前置条件 36 | - 该项目使用 NodeJS + TypeScript 开发,需要你的本地环境安装 NodeJS (推荐 NodeJS 20.11.0 以上版本) 37 | - 该项目最终会部署并运行在 Cloudflare Workers 上,所以需要你拥有一个 Cloudflare 账户。具体可去 Cloudflare 官网注册 38 | - 该项目是 Telegram 机器人的服务端,所以需要你创建一个 Telegram 机器人。具体如何创建见 Telegram 官方文档 https://core.telegram.org/bots/features#creating-a-new-bot 39 | 40 | ### 部署 41 | 1. 克隆该项目 42 | ```bash 43 | git@github.com:beilunyang/img-mom.git 44 | cd img-mom 45 | ``` 46 | 2. 安装项目依赖 47 | ```bash 48 | npm install 49 | ``` 50 | 3. 创建 wrangler 配置文件 51 | ``` 52 | cp wrangler.example.toml wrangler.toml 53 | ``` 54 | 4. 编辑新创建的 wrangler.toml 文件 55 | 5. 当编辑完成后,运行 56 | ```bash 57 | npm run deploy 58 | ``` 59 | 等待项目编译完成并自动部署到 Cloudflare Workers 60 | 61 | 6. 浏览器打开 `https://<域名>/setup`, 完成必要的 Webhook 初始化 62 | 63 | ### Wrangler.toml 待配置项说明 64 | ```toml 65 | # 你的 Cloudflare 账户ID 66 | # 必填 67 | account_id = "" 68 | 69 | [vars] 70 | # 填写你创建的 Telegram Bot Token 71 | # 必填 72 | TG_BOT_TOKEN = "" 73 | # 长度为 1-256 字符,字符集为 A-Z,a-z,0-9,_,- 的字符串,用于防止 Webhook 接口被除当前Telegram Bot以外的应用恶意调用 74 | # 非必填,但强烈推荐填写 75 | TG_WEBHOOK_SECRET_TOKEN = "" 76 | # Telegram Bot 的所有者用户名 77 | # 必填 78 | TG_BOT_OWNER_USERNAME = "" 79 | # 是否允许非 Owner 用户使用 Telegram 图床 (注意:即使设置为 true, 非 Owner 用户也不能使用 CloudflareR2/BackblazeB2 图床) 80 | # 非必填 81 | TG_BOT_ALLOW_ANYONE = "" 82 | 83 | # Cloudflare R2 自定义域名 84 | # 启用 Cloudflare R2 图床时,必填 85 | R2_CUSTOM_DOMAIN = "" 86 | # Backblaze B2 keyID 87 | # 启用 Backblaze B2 图床时,必填 88 | B2_KEY_ID = "" 89 | # Backblaze B2 secretKey 90 | # 启用 Backblaze B2 图床时,必填 91 | B2_SECRET_KEY = "" 92 | # Backblaze B2 Endpoint 93 | # 启用 Backblaze B2 图床时,必填 94 | B2_ENDPOINT = "" 95 | # Backblaze B2 Bucket名 96 | # 启用 Backblaze B2 图床时,必填 97 | B2_BUCKET = "" 98 | # Backblaze B2 自定义域名 99 | # 非必填 100 | B2_CUSTOM_DOMAIN = "" 101 | 102 | [[kv_namespaces]] 103 | # kv ID, 详见 https://developers.cloudflare.com/workers/runtime-apis/kv 104 | # 必填 105 | id = "" 106 | 107 | [[r2_buckets]] 108 | # r2 Bucket名,详见 https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ 109 | # 必填 110 | bucket_name = "" 111 | 112 | ``` 113 | 提示: 114 | - Backblaze B2 KeyId/SecretKey 可前往 https://secure.backblaze.com/app_keys.htm 获取 115 | - Backblaze B2 Endpoint/Bucket 可前往 https://secure.backblaze.com/b2_buckets.htm 获取 116 | 117 | ## GitHub Action 自动部署 118 | 119 | 要使用 GitHub Actions 部署 Cloudflare Workers,需要在 GitHub 仓库中设置以下 Secrets: 120 | 121 | - `CLOUDFLARE_ACCOUNT_ID`: 你的 Cloudflare 账户 ID。 122 | - `CLOUDFLARE_API_TOKEN`: 你的 Cloudflare API 令牌。 123 | - `CLOUDFLARE_KV_NAMESPACE_ID`: 你的 Cloudflare KV 存储的命名空间 ID。 124 | - `CLOUDFLARE_BUCKET_NAME`: 你的 Cloudflare 存储的桶名称。 125 | - `R2_CUSTOM_DOMAIN`: 你的 Cloudflare R2 存储的自定义域名。 126 | - `TG_BOT_TOKEN`: 你的 Telegram 机器人令牌。 127 | - `TG_WEBHOOK_SECRET_TOKEN`: Telegram webhook 的密钥令牌。 128 | - `TG_BOT_OWNER_USERNAME`: Telegram 机器人所有者的用户名。 129 | - `TG_BOT_ALLOW_ANYONE`: 允许任何人使用 Telegram 机器人的配置。 130 | - `B2_KEY_ID`: 你的 Backblaze B2 密钥 ID。 131 | - `B2_SECRET_KEY`: 你的 Backblaze B2 密钥。 132 | - `B2_ENDPOINT`: 你的 Backblaze B2 存储的端点。 133 | - `B2_BUCKET`: 你的 Backblaze B2 存储的桶名称。 134 | - `B2_CUSTOM_DOMAIN`: 你的 Backblaze B2 存储的自定义域名。 135 | 136 | ### 如何设置 Secrets 137 | 138 | 1. 访问您的 GitHub 仓库。 139 | 2. 点击 `Settings` > `Secrets and variables` > `Actions` > `New repository secret`(新建仓库密钥)。 140 | 3. 按照上面列出的密钥添加每个 Name 和 Secret 并填入相应的值。 141 | 142 | 请确保替换为实际的 Cloudflare 和 Backblaze B2 账户详情。 143 | 144 | 配置好后,当推送代码到 master 分支,GitHub Actions 工作流将会使用这些 Secrets 动态生成 wrangler.toml 配置文件并自动部署您的 Cloudflare Worker。 145 | 146 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/img-mom) 147 | 148 | 149 | ## 贡献 150 | 1. fork 该项目 151 | 2. 基于 master 分支 checkout 你的开发分支 152 | 3. 配置 wrangler.toml 153 | 4. 安装依赖并本地运行 154 | ```bash 155 | npm install 156 | npm run dev 157 | # 使用 Cloudflare tunnels 进行内网穿透 158 | npm run tunnel 159 | ``` 160 | 注意:由于该项目是作为 Telegram Bot 的 Webhook 后端,本地运行该项目无法让 Telegram Bot 访问,所以需要进行内网穿透,让本地运行的服务能够通过外网访问。(推荐使用 Cloudflare tunnels 进行免费内网穿透,当然也可以使用 ngrok 等服务) 161 | 5. 浏览器打开 `https://<域名>/setup`, 完成必要的 Webhook 初始化 162 | 6. 编写代码并测试 163 | 7. 发送 PR, 等待合并 164 | 165 | ## 赞助 166 | 167 |
168 | Buy Me A Coffee 169 | 170 | ## License 171 | MIT License. 172 | -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | import { Bot, InlineKeyboard } from 'grammy/web'; 2 | import BackblazeB2 from './oss/backblazeB2'; 3 | import CloudflareR2 from './oss/cloudflareR2'; 4 | import { isInPrivateChat, isOwner } from './utils'; 5 | import { OSSProvider } from './oss/interface'; 6 | 7 | 8 | const supportProviders: Record OSSProvider> = { 9 | BackblazeB2, 10 | CloudflareR2, 11 | }; 12 | 13 | const bot = new Bot(self.TG_BOT_TOKEN); 14 | 15 | bot.use((ctx, next) => { 16 | console.log(JSON.stringify(ctx.update, null, 2)); 17 | return next(); 18 | }); 19 | 20 | bot.command('start', (ctx) => ctx.reply('Welcome to use ImgMom')); 21 | 22 | bot.command('help', async (ctx) => { 23 | const commands = await ctx.api.getMyCommands(); 24 | const info = commands.reduce((acc, val) => `${acc}/${val.command} - ${val.description}\n`, ''); 25 | return ctx.reply(info); 26 | }); 27 | 28 | bot.use(async (ctx, next) => { 29 | if (['true', true].includes(self.TG_BOT_ALLOW_ANYONE)) { 30 | // allow anyone upload image 31 | if (ctx.message?.photo || ctx.message?.document) { 32 | return next(); 33 | } 34 | } 35 | const userName = ctx.from?.username; 36 | if (!isOwner(userName)) { 37 | return ctx.reply("You don't have the relevant permissions, the img-mom bot code is open source and you can self-deploy it. \nVisit: https://github.com/beilunyang/img-mom"); 38 | } 39 | await next(); 40 | }) 41 | 42 | bot.command('settings', async (ctx) => { 43 | const buttons = [ 44 | ...Object.keys(supportProviders).map(provider => InlineKeyboard.text(provider, provider)), 45 | InlineKeyboard.text('None', 'None') 46 | ] 47 | 48 | const keyboard = InlineKeyboard.from([buttons]); 49 | 50 | return ctx.reply('Setting up additional OSS provider', { 51 | reply_markup: keyboard, 52 | }); 53 | }); 54 | 55 | bot.on('callback_query:data', async (ctx) => { 56 | const data = (ctx.callbackQuery as any).data; 57 | switch (data) { 58 | case 'confirm_overwrite': { 59 | const key = `pending_upload_${ctx.callbackQuery.from.username}`; 60 | const pendingUpload = await self.KV_IMG_MOM.get(key); 61 | if (!pendingUpload) { 62 | return ctx.reply('No pending upload found.'); 63 | } 64 | const { providerName, filePath, fileName, fileType, customPath, tgImgUrl } = JSON.parse(pendingUpload); 65 | const providerClass = supportProviders[providerName]; 66 | if (!providerClass) { 67 | return ctx.reply('Invalid provider.'); 68 | } 69 | const provider = new providerClass(); 70 | try { 71 | const res = await fetch(`https://api.telegram.org/file/bot${self.TG_BOT_TOKEN}/${filePath}`); 72 | if (!res.ok) { 73 | return ctx.reply('Failed to fetch file from Telegram'); 74 | } 75 | const fileData = await res.arrayBuffer(); 76 | 77 | const uploadedPath = await provider.uploadImage(fileData, fileName, fileType, customPath); 78 | const fullUrl = provider.getURL(uploadedPath); 79 | await self.KV_IMG_MOM.delete(key); 80 | return ctx.reply( 81 | `Successfully uploaded image with overwrite!\nTelegram:\n${tgImgUrl}\n${providerName}:\n${fullUrl}` 82 | ); 83 | } catch (err: any) { 84 | console.error(err); 85 | return ctx.reply('Failed to upload file: ' + err.message); 86 | } 87 | break; 88 | } 89 | case 'cancel_overwrite': { 90 | const key = `pending_upload_${ctx.callbackQuery.from.username}`; 91 | await self.KV_IMG_MOM.delete(key); 92 | return ctx.reply('Upload cancelled.'); 93 | } 94 | default: { 95 | const provider = data; 96 | const key = `oss_provider_${ctx.callbackQuery.from.username}`; 97 | 98 | if (provider === 'None') { 99 | self.KV_IMG_MOM.delete(key); 100 | } else { 101 | self.KV_IMG_MOM.put(key, provider); 102 | } 103 | 104 | return ctx.reply(`Ok. Successfully set oss provider (${provider})`); 105 | } 106 | } 107 | }) 108 | 109 | bot.on(['message:photo', 'message:document'], async (ctx) => { 110 | if (!isInPrivateChat(ctx.message)) { 111 | console.log('Can only be used in private chat'); 112 | return; 113 | } 114 | 115 | const file = await ctx.getFile(); 116 | const caption = ctx.message.caption?.startsWith('/') ? ctx.message.caption.slice(1) : ctx.message.caption; 117 | const tgImgUrl = `https://${self.host}/img/${file.file_id}`; 118 | 119 | if (!isOwner(ctx.message.from.username)) { 120 | return ctx.reply( 121 | `Successfully uploaded image!\nTelegram:\n${tgImgUrl}` 122 | ); 123 | } 124 | 125 | let provider; 126 | const providerName = await self.KV_IMG_MOM.get(`oss_provider_${ctx.message.from.username}`) ?? ''; 127 | const providerClass = supportProviders[providerName]; 128 | if (providerClass) { 129 | provider = new providerClass(); 130 | } 131 | 132 | if (!provider) { 133 | return ctx.reply( 134 | `Successfully uploaded image!\nTelegram:\n${tgImgUrl}` 135 | ); 136 | } 137 | 138 | const res = await fetch(`https://api.telegram.org/file/bot${self.TG_BOT_TOKEN}/${file.file_path}`); 139 | 140 | if (!res.ok) { 141 | return ctx.reply('Failed to upload file'); 142 | } 143 | 144 | const fileType = file.file_path?.split('.').pop() || ''; 145 | const fileData = await res.arrayBuffer(); 146 | 147 | try { 148 | if (caption) { 149 | const exists = await provider.checkFileExists(caption); 150 | if (exists) { 151 | const pendingUpload = { 152 | providerName, 153 | filePath: file.file_path, 154 | fileName: file.file_unique_id, 155 | fileType, 156 | customPath: caption, 157 | tgImgUrl 158 | }; 159 | await self.KV_IMG_MOM.put( 160 | `pending_upload_${ctx.message.from.username}`, 161 | JSON.stringify(pendingUpload), 162 | { expirationTtl: 300 } 163 | ); 164 | 165 | const keyboard = InlineKeyboard.from([ 166 | [ 167 | InlineKeyboard.text('Yes', 'confirm_overwrite'), 168 | InlineKeyboard.text('No', 'cancel_overwrite') 169 | ] 170 | ]); 171 | return ctx.reply( 172 | `File already exists at path "${caption}". Do you want to overwrite it?`, 173 | { reply_markup: keyboard } 174 | ); 175 | } 176 | const filePath = await provider.uploadImage(fileData, file.file_unique_id, fileType, caption); 177 | const fullUrl = provider.getURL(filePath); 178 | return ctx.reply( 179 | `Successfully uploaded image!\nTelegram:\n${tgImgUrl}\n${providerName}:\n${fullUrl}` 180 | ); 181 | } else { 182 | const filePath = await provider.uploadImage(fileData, file.file_unique_id, fileType); 183 | const fullUrl = provider.getURL(filePath); 184 | return ctx.reply( 185 | `Successfully uploaded image!\nTelegram:\n${tgImgUrl}\n${providerName}:\n${fullUrl}` 186 | ); 187 | } 188 | } catch (err: any) { 189 | console.error(err); 190 | return ctx.reply('Failed to upload file: ' + err.message); 191 | } 192 | }); 193 | 194 | export default bot; 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 |

img-mom

4 |

5 | Telegram bot for uploading images to image hosting services and getting direct links 6 |

7 | 8 | [![twitter](https://img.shields.io/twitter/follow/beilunyang.svg?label=beilunyang 9 | )](https://x.com/beilunyang) 10 | ![wechat2](https://img.shields.io/badge/微信公众号-悖论的技术小屋-brightgreen?style=flat-square) 11 | 12 | English . [中文](./README-zh_CN.md) 13 |
14 | 15 | ## Features 16 | - Built on Cloudflare Workers runtime, lightweight and completely free to use 17 | - Supports multiple image hosting services (Telegram/Cloudflare R2/Backblaze B2, with more coming soon) 18 | - Quick deployment using Wrangler 19 | 20 | ## How to Use 21 | 1. Send images to the Bot via private chat. By default, the Bot will reply with a Telegram image hosting link 22 | 2. Send the `/settings` command to specify additional image hosting services. After setup, the Bot will reply with links to your specified services when you send images 23 | 3. Try it online at https://t.me/img_mom_bot 24 | ![img-mom](https://pic.otaku.ren/20240212/AQADQ7oxGx0pUVZ9.jpg) 25 | 26 | ## Custom URL Path 27 | When uploading to additional image hosting services, you can specify a complete URL path (excluding the domain part): 28 | 29 | 1. Send an image with a caption text to specify the path (e.g., `path/to/image.jpg`) 30 | 2. If the specified path already exists, the bot will ask if you want to overwrite the existing image 31 | 3. You can confirm or cancel the upload 32 | 33 | ## Self-Deployment 34 | ### Prerequisites 35 | - This project is developed with NodeJS + TypeScript. You need NodeJS installed locally (NodeJS 20.11.0+ recommended) 36 | - This project runs on Cloudflare Workers, so you need a Cloudflare account (register at Cloudflare's website) 37 | - This project serves as a Telegram bot backend, so you need to create a Telegram bot. See the Telegram official documentation: https://core.telegram.org/bots/features#creating-a-new-bot 38 | 39 | ### Deployment 40 | 1. Clone the project 41 | ```bash 42 | git@github.com:beilunyang/img-mom.git 43 | cd img-mom 44 | ``` 45 | 2. Install dependencies 46 | ```bash 47 | npm install 48 | ``` 49 | 3. Create a wrangler configuration file 50 | ``` 51 | cp wrangler.example.toml wrangler.toml 52 | ``` 53 | 4. Edit the newly created wrangler.toml file 54 | 5. After editing, run 55 | ```bash 56 | npm run deploy 57 | ``` 58 | Wait for the project to compile and deploy to Cloudflare Workers 59 | 60 | 6. Open `https:///setup` in your browser to complete the necessary Webhook initialization 61 | 62 | ### Wrangler.toml Configuration 63 | ```toml 64 | # Your Cloudflare account ID 65 | # Required 66 | account_id = "" 67 | 68 | [vars] 69 | # Your Telegram Bot Token 70 | # Required 71 | TG_BOT_TOKEN = "" 72 | # A string of length 1-256 with characters A-Z,a-z,0-9,_,- to prevent malicious calls to the Webhook interface 73 | # Optional but strongly recommended 74 | TG_WEBHOOK_SECRET_TOKEN = "" 75 | # Telegram Bot owner's username 76 | # Required 77 | TG_BOT_OWNER_USERNAME = "" 78 | # Whether to allow non-owner users to use the Telegram image hosting (Note: even if set to true, non-owner users cannot use CloudflareR2/BackblazeB2) 79 | # Optional 80 | TG_BOT_ALLOW_ANYONE = "" 81 | 82 | # Cloudflare R2 custom domain 83 | # Required when enabling Cloudflare R2 84 | R2_CUSTOM_DOMAIN = "" 85 | # Backblaze B2 keyID 86 | # Required when enabling Backblaze B2 87 | B2_KEY_ID = "" 88 | # Backblaze B2 secretKey 89 | # Required when enabling Backblaze B2 90 | B2_SECRET_KEY = "" 91 | # Backblaze B2 Endpoint 92 | # Required when enabling Backblaze B2 93 | B2_ENDPOINT = "" 94 | # Backblaze B2 Bucket name 95 | # Required when enabling Backblaze B2 96 | B2_BUCKET = "" 97 | # Backblaze B2 custom domain 98 | # Optional 99 | B2_CUSTOM_DOMAIN = "" 100 | 101 | [[kv_namespaces]] 102 | # kv ID, see https://developers.cloudflare.com/workers/runtime-apis/kv 103 | # Required 104 | id = "" 105 | 106 | [[r2_buckets]] 107 | # r2 Bucket name, see https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ 108 | # Required 109 | bucket_name = "" 110 | ``` 111 | 112 | Tips: 113 | - Backblaze B2 KeyId/SecretKey can be obtained from https://secure.backblaze.com/app_keys.htm 114 | - Backblaze B2 Endpoint/Bucket can be obtained from https://secure.backblaze.com/b2_buckets.htm 115 | 116 | ## GitHub Action Automatic Deployment 117 | 118 | To deploy Cloudflare Workers using GitHub Actions, set up the following Secrets in your GitHub repository: 119 | 120 | - `CLOUDFLARE_ACCOUNT_ID`: Your Cloudflare account ID. 121 | - `CLOUDFLARE_API_TOKEN`: Your Cloudflare API token. 122 | - `CLOUDFLARE_KV_NAMESPACE_ID`: The namespace ID for your Cloudflare KV storage. 123 | - `CLOUDFLARE_BUCKET_NAME`: The bucket name for your Cloudflare storage. 124 | - `R2_CUSTOM_DOMAIN`: Custom domain for your Cloudflare R2 storage. 125 | - `TG_BOT_TOKEN`: Your Telegram bot token. 126 | - `TG_WEBHOOK_SECRET_TOKEN`: A secret token for the Telegram webhook. 127 | - `TG_BOT_OWNER_USERNAME`: The username of the Telegram bot owner. 128 | - `TG_BOT_ALLOW_ANYONE`: Configuration to allow anyone to use the Telegram bot. 129 | - `B2_KEY_ID`: Your Backblaze B2 key ID. 130 | - `B2_SECRET_KEY`: Your Backblaze B2 secret key. 131 | - `B2_ENDPOINT`: The endpoint for your Backblaze B2 storage. 132 | - `B2_BUCKET`: The bucket name for your Backblaze B2 storage. 133 | - `B2_CUSTOM_DOMAIN`: Custom domain for your Backblaze B2 storage. 134 | 135 | ### How to Set Up Secrets 136 | 137 | 1. Go to your GitHub repository. 138 | 2. Click `Settings` > `Secrets and variables` > `Actions` > `New repository secret`. 139 | 3. Add each Name and Secret as listed above with the appropriate values. 140 | 141 | Make sure to replace with your actual Cloudflare and Backblaze B2 account details. 142 | 143 | Once configured, when code is pushed to the master branch, the GitHub Actions workflow will use these Secrets to dynamically generate the wrangler.toml configuration file and automatically deploy your Cloudflare Worker. 144 | 145 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/img-mom) 146 | 147 | 148 | ## Contributing 149 | 1. Fork this project 150 | 2. Checkout your development branch based on master 151 | 3. Configure wrangler.toml 152 | 4. Install dependencies and run locally 153 | ```bash 154 | npm install 155 | npm run dev 156 | # Use Cloudflare tunnels for network tunneling 157 | npm run tunnel 158 | ``` 159 | Note: Since this project serves as a Telegram Bot Webhook backend, running locally doesn't allow Telegram Bot access, so you need network tunneling to make your locally running service accessible via the internet. (Cloudflare tunnels is recommended for free network tunneling, but you can also use services like ngrok) 160 | 5. Open `https:///setup` in your browser to complete the necessary Webhook initialization 161 | 6. Write code and test 162 | 7. Submit a PR and wait for merging 163 | 164 | ## Sponsorship 165 | 166 |
167 | Buy Me A Coffee 168 | 169 | ## License 170 | MIT License. 171 | --------------------------------------------------------------------------------