├── .env.example ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bot.ts └── preview.gif /.env.example: -------------------------------------------------------------------------------- 1 | BOT_TOKEN="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" 2 | ADMIN_ID=123456789 3 | API_ROOT="http://localhost:8081" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | test.ts -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": false 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 dcdunkan 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 |
2 | 3 | # File/Folder Upload Bot 4 | 5 |
6 | 7 |
8 | 9 | A dead simple Telegram bot to upload your local files by file path or folder 10 | path. Can be used as a backup helper. Originally, I created this bot to automate 11 | backing up my files to Telegram while I change or reinstall my Linux distro. For 12 | example, if you have a directory of videos, you can use this bot to upload them 13 | for you. With some slight modifications, you can make it upload as you want. 14 | With a [local bot API server](https://github.com/tdlib/telegram-bot-api) you can 15 | also make it support files upto 2GB in size. 16 | 17 |
18 | 19 | 20 | 21 |
22 | 23 | ### Demo usage preview 24 | 25 |
26 | 27 | It's a bit older preview, but it still almost the same thing. The command 28 | `/folder` has been changed to `/upload` after adding the support for both files 29 | and folders.
30 | 31 |
32 | 33 |
34 | 35 | - [x] It would be cool to have index messages of the uploaded files. (in groups 36 | and channels) 37 | - [ ] ZIP/RAR/TAR support? 38 | 39 | ## Setup 40 | 41 | You need [Deno](https://deno.land/) to run this bot. I created this bot on 42 | v1.18.2. Also, I recommend setting up a 43 | [local bot API server](https://github.com/tdlib/telegram-bot-api) and increasing 44 | your file size limit. 45 | 46 | **1. Clone the repository** 47 | 48 | ```bash 49 | git clone https://github.com/dcdunkan/folder-upload-bot 50 | cd folder-upload-bot 51 | ``` 52 | 53 | **2. Configure the `.env` variables** 54 | 55 | You can either set them in a `.env` file in the root of this repo folder, or you 56 | can set them using `export ADMIN_ID=1234567890` in your terminal. 57 | 58 | - `BOT_TOKEN`: Telegram Bot token. Get yours from https://t.me/BotFather. 59 | - `ADMIN_ID`: The user ID of the owner. Because- you know, you don't want other 60 | people downloading your private files, right? 61 | - `API_ROOT`: Set this to the URL of your local api server, if you have one. 62 | 63 | **3. Run `bot.ts`** 64 | 65 | ```bash 66 | deno run --allow-net --allow-read --allow-env bot.ts 67 | ``` 68 | 69 | - `--allow-net`: For internet access. 70 | - `--allow-env`: For accessing required ENV variables. 71 | - `--allow-read`: To read files from your machine. 72 | 73 | If you have everything done right, your bot should be running, and you should 74 | see a message `" started."` in your console. 75 | 76 | But if you're still having issues, please open an issue 77 | [here](https://github.com/dcdunkan/file-upload-bot/issues) :) 78 | -------------------------------------------------------------------------------- /bot.ts: -------------------------------------------------------------------------------- 1 | import "https://deno.land/x/dotenv@v3.2.0/load.ts"; 2 | import { Bot, InputFile } from "https://deno.land/x/grammy@v1.7.0/mod.ts"; 3 | import { basename, join } from "https://deno.land/std@0.128.0/path/mod.ts"; 4 | import { prettyBytes as bytes } from "https://deno.land/std@0.128.0/fmt/bytes.ts"; 5 | 6 | // 20 messages per minute to same group. 60000 / 20 = 3000 7 | const GROUP_WAITING_TIME = 3000; // 3 seconds delay. 8 | const PRIVATE_WAITING_TIME = 25; // A small delay. 9 | 10 | const BOT_TOKEN = Deno.env.get("BOT_TOKEN") ?? ""; 11 | const ADMIN_ID = parseInt(Deno.env.get("ADMIN_ID") as string); 12 | const API_ROOT = Deno.env.get("API_ROOT") ?? "https://api.telegram.org"; 13 | 14 | const bot = new Bot(BOT_TOKEN, { 15 | client: { apiRoot: API_ROOT }, 16 | }); 17 | 18 | // Making the bot only accessible to the owner. 19 | bot.use(async (ctx, next) => 20 | ctx.from?.id === ADMIN_ID ? await next() : undefined 21 | ); 22 | 23 | bot.command("start", async (ctx) => { 24 | await ctx.reply( 25 | "Hello! I can help you upload your local files to here." + 26 | "\nSyntax: /upload " + 27 | "\nExamples: /upload /home/user/Pictures/" + 28 | "\nOr /upload /home/user/Pictures/image.jpg" + 29 | "\n\nRepository: github.com/dcdunkan/file-upload-bot", 30 | { disable_web_page_preview: true }, 31 | ); 32 | }); 33 | 34 | interface UploadedFileList { 35 | name: string; 36 | link: string; 37 | } 38 | 39 | bot.command(["upload", "to"], async (ctx) => { 40 | if (!ctx.match) { 41 | return await ctx.reply( 42 | "Please provide a file/folder path." + 43 | "\nSyntax: /upload " + 44 | "\nSyntax: /to ", 45 | ); 46 | } 47 | 48 | let path = ctx.match as string; 49 | if (ctx.message?.text?.startsWith("/to")) { 50 | if (path.split(" ").length < 2) { 51 | return await ctx.reply( 52 | "Please provide a file/folder path and the destination." + 53 | "\nSyntax: /to ", 54 | ); 55 | } 56 | ctx.chat.id = parseInt(path.split(" ")[0]); 57 | await ctx.api.getChat(ctx.chat.id).catch(async () => { 58 | return await ctx.reply( 59 | `Could'nt find the target chat ${ctx.chat.id}.` + 60 | `\nMake sure the chat exists and the bot has permission to send messages to it.`, 61 | ); 62 | }); 63 | path = path.split(" ").slice(1).join(" "); 64 | } 65 | 66 | const { message_id } = await ctx.reply("Reading path..."); 67 | 68 | const exists = await fileExists(path); 69 | if (!exists) return await ctx.reply("File/Folder not found."); 70 | 71 | // Single file upload. 72 | if (exists.isFile) { 73 | const filename = basename(path); 74 | await ctx.api.editMessageText( 75 | ctx.chat.id, 76 | message_id, 77 | `Uploading ${sanitize(filename)} from ${ 78 | sanitize(path) 79 | }`, 80 | { parse_mode: "HTML" }, 81 | ); 82 | 83 | await ctx.replyWithDocument(new InputFile(path, filename), { 84 | caption: `Filename: ${sanitize(filename)}` + 85 | `\nPath: ${sanitize(path)}` + 86 | `\nSize: ${bytes(exists.size)}` + 87 | `\nCreated at: ${exists.birthtime?.toUTCString()}`, 88 | parse_mode: "HTML", 89 | }); 90 | 91 | return await ctx.api.deleteMessage(ctx.chat.id, message_id); 92 | } 93 | 94 | // Directory upload. 95 | const files = await getFileList(path); 96 | if (files.length === 0) return await ctx.reply("No files found."); 97 | 98 | await ctx.api.editMessageText( 99 | ctx.chat.id, 100 | message_id, 101 | `Uploading ${files.length} file${files.length > 1 ? "s" : ""} from ${ 102 | sanitize(path) 103 | }`, 104 | { parse_mode: "HTML" }, 105 | ); 106 | 107 | await ctx 108 | .pinChatMessage(message_id, { disable_notification: true }) 109 | .catch((e) => e); 110 | 111 | const uploadedFiles: UploadedFileList[] = []; 112 | 113 | for (let i = 0; i < files.length; i++) { 114 | const file = files[i]; 115 | 116 | // Double check. 117 | if (!await fileExists(file.path)) { 118 | await ctx.reply( 119 | `'${ 120 | sanitize(file.name) 121 | }' not found. Skipping.\nPath: ${ 122 | sanitize(file.path) 123 | }`, 124 | { parse_mode: "HTML" }, 125 | ); 126 | continue; 127 | } 128 | 129 | // Update progress message. 130 | if (ctx.chat.type === "private") { 131 | await ctx.api.editMessageText( 132 | ctx.chat.id, 133 | message_id, 134 | `📤 [${i + 1}/${files.length}] ${ 135 | sanitize(files[i].name) 136 | } from ${sanitize(path)}`, 137 | { parse_mode: "HTML" }, 138 | ); 139 | } 140 | 141 | try { 142 | const { message_id: fileMsgId } = await ctx.replyWithDocument( 143 | new InputFile(file.path, file.name), 144 | { 145 | caption: `Filename: ${sanitize(file.name)}` + 146 | `\nPath: ${sanitize(file.path)}` + 147 | `\nSize: ${bytes(file.size)}` + 148 | `\nCreated at: ${file.created_at}`, 149 | parse_mode: "HTML", 150 | }, 151 | ); 152 | 153 | // Why not private? There's no such link to messages in private chats. 154 | if (ctx.chat.type !== "private") { 155 | uploadedFiles.push({ 156 | name: sanitize(file.name), 157 | link: `https://t.me/c/${ 158 | ctx.chat.id.toString().startsWith("-100") 159 | ? ctx.chat.id.toString().substring(4) 160 | : ctx.chat.id 161 | }/${fileMsgId}`, 162 | }); 163 | } 164 | // Pause for a bit. 165 | await pause(ctx.chat.type); 166 | } catch (error) { 167 | await ctx.reply(`Failed to upload ${sanitize(file.name)}.`); 168 | await pause(ctx.chat.type); 169 | console.error(error); 170 | continue; 171 | } 172 | } 173 | 174 | await ctx.api.editMessageText(ctx.chat.id, message_id, "Uploaded!"); 175 | await ctx.unpinChatMessage(message_id).catch((e) => e); 176 | 177 | await ctx.reply( 178 | `Successfully uploaded ${files.length} file${ 179 | files.length > 1 ? "s" : "" 180 | } from ${sanitize(path)}`, 181 | { parse_mode: "HTML" }, 182 | ); 183 | 184 | if (uploadedFiles.length < 1) return; 185 | const indexMessages = createIndexMessages(uploadedFiles); 186 | 187 | for (let i = 0; i < indexMessages.length; i++) { 188 | const { message_id: idxMsgId } = await ctx.reply( 189 | indexMessages[i], 190 | { parse_mode: "HTML" }, 191 | ); 192 | await pause(ctx.chat.type); 193 | if (i !== 0) continue; 194 | await ctx.api.editMessageText( 195 | ctx.chat.id, 196 | message_id, 197 | `Uploaded! See index`, 202 | { parse_mode: "HTML" }, 203 | ); 204 | } 205 | }); 206 | 207 | function createIndexMessages(fileList: UploadedFileList[]): string[] { 208 | const messages: string[] = [""]; 209 | let index = 0; 210 | for (let i = 0; i < fileList.length; i++) { 211 | const file = fileList[i]; 212 | const text = `\n${i + 1}. ${file.name}`; 213 | const length = messages[index].length + text.length; 214 | 215 | if (length > 4096) index++; 216 | 217 | if (messages[index] === undefined) messages[index] = ""; 218 | messages[index] += `\n${i + 1}. ${file.name}`; 219 | } 220 | return messages; 221 | } 222 | 223 | interface FileList { 224 | name: string; 225 | path: string; 226 | size: number; 227 | created_at: string; 228 | } 229 | 230 | async function getFileList(path: string): Promise { 231 | const files: FileList[] = []; 232 | for await (const file of Deno.readDir(path)) { 233 | if (file.name === ".git") continue; 234 | if (file.isDirectory) { 235 | const children = await getFileList(join(path, file.name)); 236 | files.push(...children); 237 | } else { 238 | const stat = await fileExists(join(path, file.name)); 239 | if (!stat) continue; 240 | // 2147483648 (2GB) is the max file size in bytes. I didn't checked is 241 | // that the exact limit in bytes though. 242 | const sizeLimit = Deno.env.get("API_ROOT") === "https://api.telegram.org" 243 | ? 52428800 244 | : 2147483648; 245 | if (stat.size === 0 || stat.size > sizeLimit) continue; 246 | files.push({ 247 | name: file.name, 248 | path: join(path, file.name), 249 | size: stat.size, 250 | created_at: stat.birthtime?.toUTCString() ?? "", 251 | }); 252 | } 253 | } 254 | 255 | return files.sort((a, b) => { 256 | if (a.path > b.path) return 1; 257 | if (b.path > a.path) return -1; 258 | return 0; 259 | }); 260 | } 261 | 262 | async function fileExists(name: string): Promise { 263 | try { 264 | return await Deno.stat(name); 265 | } catch (error) { 266 | return error instanceof Deno.errors.NotFound 267 | ? false 268 | : Promise.reject(error); 269 | } 270 | } 271 | 272 | function pause( 273 | chatType: "channel" | "group" | "private" | "supergroup", 274 | ): Promise { 275 | const ms = chatType === "private" ? PRIVATE_WAITING_TIME : GROUP_WAITING_TIME; 276 | return new Promise((resolve) => setTimeout(resolve, ms)); 277 | } 278 | 279 | function sanitize(html: string): string { 280 | return html 281 | .replace(//g, ">") 283 | .replace(/&/g, "&"); 284 | } 285 | 286 | bot.catch(console.error); 287 | bot.start({ 288 | drop_pending_updates: true, 289 | onStart: ({ username }) => console.log(`${username} started.`), 290 | }); 291 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcdunkan/file-upload-bot/e69fc0920705f3c9f446acdbb03e23e5aa23502d/preview.gif --------------------------------------------------------------------------------