├── .eslintrc.js ├── .gitignore ├── .vscode └── launch.json ├── LICENSE.md ├── README.md ├── aria.sh.example ├── contrib └── mirror-bot.service ├── package.json ├── src ├── .constants.js.example ├── bot_utils │ ├── event_regex.ts │ ├── msg-tools.ts │ └── reg_exps.ts ├── dl_model │ ├── detail.ts │ └── dl-manager.ts ├── download_tools │ ├── aria-tools.ts │ ├── filename-utils.ts │ └── utils.ts ├── drive │ ├── drive-auth.ts │ ├── drive-list.ts │ ├── drive-upload.ts │ ├── drive-utils.ts │ ├── tar.ts │ └── upload-file.ts ├── fs-walk.ts └── index.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:@typescript-eslint/recommended' 8 | ], 9 | plugins: ['@typescript-eslint'], 10 | parserOptions: { 11 | ecmaVersion: 2018, 12 | sourceType: 'module' 13 | }, 14 | rules: { 15 | '@typescript-eslint/explicit-function-return-type': [ 16 | 'warn', 17 | { 18 | allowExpressions: true 19 | } 20 | ], 21 | '@typescript-eslint/no-use-before-define': 'off', 22 | '@typescript-eslint/camelcase': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | '@typescript-eslint/explicit-member-accessibility': [ 25 | 'warn', 26 | { 27 | accessibility: 'no-public' 28 | } 29 | ], 30 | '@typescript-eslint/no-parameter-properties': 'off', 31 | '@typescript-eslint/no-var-requires': 'off', 32 | semi: 'off', 33 | '@typescript-eslint/semi': ['error'], 34 | '@typescript-eslint/indent': 'off' 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/.constants.js 2 | aria*.sh 3 | node_modules/ 4 | downloads/ 5 | client_secret.json 6 | credentials.json 7 | npm-debug.log 8 | out/ 9 | package-lock.json 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/out/index.js", 12 | "preLaunchTask": "tsc: build - tsconfig.json", 13 | "outFiles": [ 14 | "${workspaceFolder}/out/**/*.js" 15 | ] 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018 Ritayan Chakraborty 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aria-telegram-mirror-bot 2 | 3 | This is a Telegram bot that uses [aria2](https://github.com/aria2/aria2) to download files over BitTorrent / HTTP(S) and uploads them to your Google Drive. This can be useful for downloading from slow servers. Parallel downloading and download queuing are supported. There are some features to try to reduce piracy. 4 | 5 | ## Limitations 6 | 7 | This bot is meant to be used in small, closed groups. So, once deployed, it only works in whitelisted groups. 8 | 9 | ## Warning 10 | 11 | There is very little preventing users from using this to mirror pirated content. Hence, make sure that only trusted groups are whitelisted in `AUTHORIZED_CHATS`. 12 | 13 | ## Bot commands 14 | 15 | * `/mirror `: Download from the given URL and upload it to Google Drive. can be HTTP(S), a BitTorrent magnet, or a HTTP(S) url to a BitTorrent .torrent file. A status message will be shown and updated while downloading. 16 | * `/mirrorTar `: Same as `/mirror`, but archive multiple files into a tar before uploading it. 17 | * `/mirrorStatus`: Send a status message about all active and queued downloads. 18 | * `/cancelMirror`: Cancel a particular mirroring task. To use this, send it as a reply to the message that started the download that you want to cancel. Only the person who started the task, SUDO_USERS, and chat admins can use this command. 19 | * `/cancelAll`: Cancel all mirroring tasks in all chats if a [SUDO_USERS](#Constants-description) member uses it, or cancel all mirroring tasks for a particular chat if one of that chat's admins use it. No one else can use this command. 20 | * `/list ` : Send links to downloads with the `filename` substring in the name. In case of too many downloads, only show the most recent few. 21 | * `/getfolder` : Send link of drive mirror folder. 22 | 23 | #### Notes 24 | 25 | * **All commands except** `list` **can have the bot's username appended to them. See** `COMMANDS_USE_BOT_NAME` **under [constants description](#Constants-description).** This is useful if you have multiple instances of this bot in the same group. 26 | 27 | * While creating a Telegram bot in the [pre-installation](#Pre-installation]) section below, you might want to add the above commands to your new bot by using `/setcommand` in BotFather, make sure all the commands are in lower case. This will cause a list of available bot commands to pop up in chats when you type `/`, and you can long press one of them to select it instead of typing out the entire command. 28 | 29 | ## Migrating from v1.0.0 30 | 31 | Aria-telegram-mirror-bot is now written in TypeScript. If you are migrating from v1.0.0, move your existing `.constants.js` to `src/.constants.js`, and re-read the [installation section](#Installation) and the [section on updating](#Updating), as some steps have changed. 32 | 33 | ## Pre-installation 34 | 35 | 1. [Create a new bot](https://core.telegram.org/bots#3-how-do-i-create-a-bot) using Telegram's BotFather and copy your TOKEN. 36 | 37 | 2. Add the bot to your groups and optionally, give it the permission to delete messages. This permission is used to clean up status request messages from users. Not granting it will quickly fill the chat with useless messages from users. 38 | 39 | 3. Install [aria2](https://github.com/aria2/aria2). 40 | * For Ubuntu: 41 | `sudo apt install aria2` 42 | 43 | 4. Get Drive folder ID: 44 | 45 | * Visit [Google Drive](https://drive.google.com). 46 | * Create a new folder. The bot will upload files inside this folder. 47 | * Open the folder. 48 | * The URL will be something like `https://drive.google.com/drive/u/0/folders/012a_345bcdefghijk`. Copy the part after `folders/` (`012a_345bcdefghijk`). This is the `GDRIVE_PARENT_DIR_ID` that you'll need in step 5 of the Installation section. 49 | 50 | ## Installation 51 | 52 | 1. Clone the repo: 53 | 54 | ```bash 55 | git clone https://github.com/out386/aria-telegram-mirror-bot 56 | cd aria-telegram-mirror-bot 57 | ``` 58 | 59 | 3. Run `npm install` 60 | 61 | 4. Copy the example files: 62 | 63 | ```bash 64 | cp src/.constants.js.example src/.constants.js 65 | cp aria.sh.example aria.sh 66 | ``` 67 | 68 | 5. Configure the aria2 startup script: 69 | 70 | * `nano aria.sh` 71 | * `ARIA_RPC_SECRET` is the secret (password) used to connect to aria2. Set this to whatever you want, and save the file with `ctrl + x`. 72 | * `MAX_CONCURRENT_DOWNLOADS` is the number of download jobs that can be active at the same time. Note that this does not affect the number of concurrent uploads. There is currently no limit for the number of concurrent uploads. 73 | 74 | 6. Configure the bot: 75 | 76 | * `nano src/.constants.js` 77 | * Now replace the placeholder values in this file with your values. Use the [Constants description](#Constants-description) section below for reference. 78 | 79 | 8. Set up OAuth: 80 | 81 | * Visit the [Google Cloud Console](https://console.developers.google.com/apis/credentials) 82 | * Go to the OAuth Consent tab, fill it, and save. 83 | * Go to the Credentials tab and click Create Credentials -> OAuth Client ID 84 | * Choose Other and Create. 85 | * Use the download button to download your credentials. 86 | * Move that file to the root of aria-telegram-mirror-bot, and rename it to `client_secret.json` 87 | 88 | 9. Enable the Drive API: 89 | 90 | * Visit the [Google API Library](https://console.developers.google.com/apis/library) page. 91 | * Search for Drive. 92 | * Make sure that it's enabled. Enable it if not. 93 | 94 | 10. Start aria2 with `./aria.sh` 95 | 96 | 11. Start the bot with `npm start` 97 | 98 | 12. Open Telegram, and send `/mirror https://raw.githubusercontent.com/out386/aria-telegram-mirror-bot/master/README.md` to the bot. 99 | 100 | 11. In the terminal, it'll ask you to visit an authentication URL. Visit it, grant access, copy the code on that page, and paste it in the terminal. 101 | 102 | That's it. 103 | 104 | ## Constants description 105 | 106 | This is a description of the fields in src/.constants.js: 107 | 108 | * `TOKEN`: This is the Telegram bot token that you will get from Botfather in step 1 of Pre-installation. 109 | * `ARIA_SECRET`: This is the password used to connect to the aria2 RPC. You will get this from step 4 of Installation. 110 | * `ARIA_DOWNLOAD_LOCATION`: This is the directory that aria2 will download files into, before uploading them. Make sure that there is no trailing "/" in this path. The suggested path is `/path/to/aria-telegram-mirror-bot/downloads` 111 | * `ARIA_DOWNLOAD_LOCATION_ROOT`: This is the mountpoint that contains ARIA_DOWNLOAD_LOCATION. This is used internally to calculate the space available before downloading. 112 | * `ARIA_FILTERED_DOMAINS`: The bot will refuse to download files from these domains. Can be an empty list. 113 | * `ARIA_FILTERED_FILENAMES`: The bot will refuse to completely download (or if already downloaded, then upload) files with any of these substrings in the file/top level directory name. Can be an empty list or left undefined. 114 | * `ARIA_PORT`: The port for the Aria2c RPC server. If you change this, make sure to update your aria.sh as well. Safe to leave this at the default value unless something else on your system is using that port. 115 | * `GDRIVE_PARENT_DIR_ID`: This is the ID of the Google Drive folder that files will be uploaded into. You will get this from step 4 of Pre-installation. 116 | * `SUDO_USERS`: This is a list of Telegram user IDs. These users can use the bot in any chat. Can be an empty list, if AUTHORIZED_CHATS is not empty. 117 | * `AUTHORIZED_CHATS`: This is a list of Telegram Chat IDs. Anyone in these chats can use the bot in that particular chat. Anyone not in one of these chats and not in SUDO_USERS cannot use the bot. Someone in one of the chats in this list can use the bot only in that chat, not elsewhere. Can be an empty list, if SUDO_USERS is not empty. 118 | * `STATUS_UPDATE_INTERVAL_MS`: Set the time in milliseconds between status updates. A smaller number will update status messages faster, but Telegram will rate limit the bot if it sends/edits more than around 20 messages/minute/chat. As that quota includes messages other than status updates, do not decrease this number if you get rate limit messages in the logs. 119 | * `DRIVE_FILE_PRIVATE`: Files uploaded can either be visible to everyone (public), or be private. 120 | * `ENABLED`: Set this to `true` to make the uploaded files private. `false` makes uploaded files public. 121 | * `EMAILS`: An array of email addresses that read access will be granted to. Set this to `[]` to grant access only to the Drive user the bot is set up with. 122 | * `DOWNLOAD_NOTIFY_TARGET`: The fields here are used to notify an external web server once a download is complete. See the [section on notifications below](#Notifying-an-external-webserver-on-download-completion) for details. 123 | * `enabled`: Set this to `true` to enable this feature. 124 | * `host`: The address of the web server to notify. 125 | * `port`: The server port ¯\\\_(ツ)\_/¯ 126 | * `path`: The server path ¯\\\_(ツ)\_/¯ 127 | * `COMMANDS_USE_BOT_NAME`: The fields here decide whether to append the bot's usename to the end of commands or not. This works only for group chats, and gets ignored if you PM the bot. 128 | * `ENABLED`: If `true`, all bot commands have to have the bot's username (as below) appended to them. For example, `/mirror https://someweb.site/resource.tar` will become `/mirror@botName_bot https://someweb.site/resource.tar`. The only exception to this is the `/list` command, which will not have the bot's name appended. This allows having multiple non-conflicting mirror bots in the same group, and have them all reply to `/list`. 129 | * `NAME`: The username of the bot, as given in BotFather. Include the leading "@". 130 | * `IS_TEAM_DRIVE`: Set to `true` if you are mirroring to a Shared Drive. 131 | 132 | ## Starting after installation 133 | 134 | After the initial installation, use these instructions to (re)start the bot. 135 | 136 | ### Using tmux 137 | 138 | 1. Start aria2 by running `./aria.sh` 139 | 2. Start a new tmux session with `tmux new -s tgbot`, or connect to an existing session with `tmux a -t tgbot`. Running the bot inside tmux will let you disconnect from the server without terminating the bot. You can also use nohup instead. 140 | 3. Start the bot with `npm start` 141 | 142 | ### Using systemd 143 | 144 | 1. Install the systemd unit file `sudo cp -v contrib/mirror-bot.service /etc/systemd/system/` 145 | 2. Open `/etc/systemd/system/mirror-bot.service` with an editor of your choice and modify the path and user as per your environment. 146 | 3. Reload the systemctl daemon so it can see your new systemd unit `sudo systemctl daemon-reload` 147 | 4. Start the service `sudo systemctl start mirror-bot` 148 | 5. If you want the bot to automatically start on boot, run `sudo systemctl enable mirror-bot` 149 | 150 | ## Notifying an external webserver on download completion 151 | 152 | This bot can make an HTTP request to an external web server once a download is complete. This can be when a download fails to start, fails to download, is cancelled, or completes successfully. See the section [on constants](#Constants-description) for details on how to configure it. 153 | 154 | Your web server should listen for a POST request containing the following JSON data: 155 | 156 | ``` 157 | { 158 | 'successful': boolean, 159 | 'file': { 160 | 'name': string, 161 | 'driveURL': string, 162 | 'size': string 163 | }, 164 | originGroup: number 165 | } 166 | ``` 167 | 168 | * `successful`: `true` if the download completed successfully, `false` otherwise 169 | * `file`: Details about the file. 170 | * `name`: The name of the file. Might or might not be present if `successful` is `false`. 171 | * `driveURL`: The Google Drive download link to the downloaded file. Might or might not be present if `successful` is `false`. 172 | * `size`: A human-readable file size. Might or might not be present if `successful` is `false`. 173 | * `originGroup`: The Telegram chat ID for the chat the download was started in 174 | 175 | If `successful` is false, any or all of the fields of `file` might be absent. However, if present, they are correct/reliable. 176 | 177 | ## Updating 178 | 179 | Run `git pull`, then run `tsc`. After compilation has finished, you can start the bot as described in [the above section](#Starting-after-installation). 180 | 181 | ## Common issues 182 | 183 | * **`tsc` silently dies, says, "Killed", or stays stuck forever:** Your machine does not have enough RAM. tsc needs at least 1GB. Increase your RAM if running on the cloud, or try setting up a [swap](https://www.digitalocean.com/community/tutorials/how-to-add-swap-space-on-ubuntu-16-04) with a high swappiness. 184 | 185 | * **Trying to download anything gives a "Failed to start the download. Unauthorized" message:** [See #38](https://github.com/out386/aria-telegram-mirror-bot/issues/38). If it still doesn't work, something else might be running an aria2 RPC at the same port as the bot. Change [`ARIA_PORT`](#Constants-description) and try [#38](https://github.com/out386/aria-telegram-mirror-bot/issues/38) again. 186 | 187 | * **`tsc` gives errors like `Property 'SOMETHING' does not exist on type<...>` with red lines under `constants.<...>`:** Some new configs were added to [constants](#Constants-description) after you set up the bot, but your existing `./src/.constants.js` does not have them. Re-read [the constants section](#Constants-description), and add whatever property was added. Usually, you can also just ignore these particular errors and keep using the bot, because `tsc` will compile anyway, and there are default options that are used if you did not update your `.constants.js`. 188 | 189 | * **Cannot get public links for folders if using Shared Drives**: Shared Drives do not support sharing folders to non members. The download link the bot gives only works for members of the Shared drive. If you need public links, use `/mirrorTar` to mirror the folder as a single file instead. 190 | This feature is planned. See [upcoming releases](https://support.google.com/a/table/7539891) (search for "Folder sharing in shared drives"). 191 | 192 | ## License 193 | The MIT License (MIT) 194 | 195 | Copyright © 2020 out386 196 | -------------------------------------------------------------------------------- /aria.sh.example: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## Set the variable below to your Aria password 4 | ARIA_RPC_SECRET="some long key" 5 | ## This is the maximum number of download jobs that will be active at a time. Note that this does not affect the number of concurrent *uploads* 6 | MAX_CONCURRENT_DOWNLOADS=3 7 | ## The port that RPC will listen on 8 | RPC_LISTEN_PORT=8210 9 | aria2c --enable-rpc --rpc-listen-all=false --rpc-listen-port $RPC_LISTEN_PORT --max-concurrent-downloads=$MAX_CONCURRENT_DOWNLOADS --max-connection-per-server=10 --rpc-max-request-size=1024M --seed-time=0.01 --min-split-size=10M --follow-torrent=mem --split=10 --rpc-secret=$ARIA_RPC_SECRET --max-overall-upload-limit=1 --daemon=true 10 | echo "Aria2c daemon started" 11 | -------------------------------------------------------------------------------- /contrib/mirror-bot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=aria2 mirror Telegram Bot service 3 | After=network.target 4 | 5 | [Service] 6 | Restart=on-abort 7 | Type=simple 8 | User=bots 9 | WorkingDirectory=/home/bots/aria-telegram-mirror-bot 10 | ExecStartPre=/usr/bin/env bash aria.sh 11 | ExecStart=/usr/bin/npm start 12 | ExecReload=/bin/kill -USR1 $MAINPID 13 | 14 | ; Use graceful shutdown with a reasonable timeout 15 | KillMode=mixed 16 | KillSignal=SIGQUIT 17 | TimeoutStopSec=5s 18 | 19 | # Security 20 | PrivateTmp=true 21 | ProtectSystem=full 22 | NoNewPrivileges=true 23 | ProtectControlGroups=true 24 | ProtectKernelModules=true 25 | ProtectKernelTunables=true 26 | PrivateDevices=true 27 | RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK 28 | RestrictNamespaces=true 29 | RestrictRealtime=true 30 | SystemCallArchitectures=native 31 | 32 | [Install] 33 | WantedBy=multi-user.target 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mirror-bot", 3 | "version": "2.0.0", 4 | "description": "A telegram bot to mirror files to Google Drive", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npx typescript && NTBA_FIX_319=1 node --max_old_space_size=128 ./out/index.js" 8 | }, 9 | "author": "Ritayan Chakraborty", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@types/fs-extra": "^7.0.0", 13 | "@types/mime-types": "^2.1.0", 14 | "@types/node-telegram-bot-api": "^0.31.0", 15 | "@types/tar": "^4.0.0", 16 | "@types/uuid": "^3.4.4", 17 | "aria2": "^3.0.1", 18 | "diskspace": "^2.0.0", 19 | "fs-extra": "^7.0.0", 20 | "googleapis": "^41.0.1", 21 | "http-range-parse": "^1.0.0", 22 | "node-telegram-bot-api": "^0.30.0", 23 | "tar": "^4.4.4", 24 | "typescript": "^3.5.3", 25 | "uuid": "^3.3.2" 26 | }, 27 | "devDependencies": { 28 | "@typescript-eslint/eslint-plugin": "^1.12.0", 29 | "@typescript-eslint/parser": "^1.12.0", 30 | "eslint": "^6.0.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/.constants.js.example: -------------------------------------------------------------------------------- 1 | module.exports = Object.freeze({ 2 | TOKEN: 'bot_token', 3 | ARIA_SECRET: 'aria2_secret', 4 | ARIA_DOWNLOAD_LOCATION: '/home/user/path/to/download/dir (no trailing "/")', 5 | ARIA_DOWNLOAD_LOCATION_ROOT: '/', //The mountpoint that contains ARIA_DOWNLOAD_LOCATION 6 | ARIA_FILTERED_DOMAINS: ['yts', 'YTS', 'cruzing.xyz', 'eztv.ag', 'YIFY'], // Prevent downloading from URLs containing these substrings 7 | ARIA_FILTERED_FILENAMES: ['YIFY'], // Files/top level directories with these substrings in the filename won't be downloaded 8 | ARIA_PORT: 8210, // Port for aria2c RPC server, if you change this here, make sure to update aria.sh as well 9 | GDRIVE_PARENT_DIR_ID: 'id_of_Drive_folder_to_upload_into', 10 | SUDO_USERS: [012, 345], // Telegram user IDs. These users can use the bot in any chat. 11 | AUTHORIZED_CHATS: [678, 901], // Telegram chat IDs. Anyone in these chats can use the bot. 12 | STATUS_UPDATE_INTERVAL_MS: 12000, // A smaller number will update faster, but might cause rate limiting 13 | DRIVE_FILE_PRIVATE: { 14 | ENABLED: false, 15 | EMAILS: ['someMail@gmail.com', 'someOtherMail@gmail.com'] 16 | }, 17 | DOWNLOAD_NOTIFY_TARGET: { // Information about the web service to notify on download completion. 18 | enabled: false, // Set this to true to use the notify functionality 19 | host: 'hostname.domain', 20 | port: 80, 21 | path: '/botNotify' 22 | }, 23 | COMMANDS_USE_BOT_NAME: { 24 | ENABLED: false, // If true, all commands except '/list' has to have the bot username after the command 25 | NAME: "@nameOf_bot" 26 | }, 27 | IS_TEAM_DRIVE: false 28 | }); -------------------------------------------------------------------------------- /src/bot_utils/event_regex.ts: -------------------------------------------------------------------------------- 1 | import constants = require('../.constants'); 2 | import regexps = require('./reg_exps'); 3 | 4 | export class EventRegex { 5 | 6 | readonly commandsRegex: regexps.RegExps; 7 | readonly commandsRegexNoName: regexps.RegExps; 8 | 9 | constructor() { 10 | var commands = ['^/start', '^/mirrorTar', '^/mirror', '^/mirrorStatus', '^/list', '^/getFolder', '^/cancelMirror', '^/cancelAll', '^/disk']; 11 | var commandsNoName: string[] = []; 12 | var commandAfter = ['$', ' (.+)', ' (.+)', '$', ' (.+)', '$', '$', '$', '$']; 13 | 14 | if (constants.COMMANDS_USE_BOT_NAME && constants.COMMANDS_USE_BOT_NAME.ENABLED) { 15 | commands.forEach((command, i) => { 16 | if (command === '^/list') { 17 | // In case of more than one of these bots in the same group, we want all of them to respond to /list 18 | commands[i] = command + commandAfter[i]; 19 | } else { 20 | commands[i] = command + constants.COMMANDS_USE_BOT_NAME.NAME + commandAfter[i]; 21 | } 22 | commandsNoName.push(this.getNamelessCommand(command, commandAfter[i])); 23 | }); 24 | } else { 25 | commands.forEach((command, i) => { 26 | commands[i] = command + commandAfter[i]; 27 | commandsNoName.push(this.getNamelessCommand(command, commandAfter[i])); 28 | }); 29 | } 30 | 31 | this.commandsRegex = new regexps.RegExps(commands); 32 | this.commandsRegexNoName = new regexps.RegExps(commandsNoName); 33 | } 34 | 35 | private getNamelessCommand(command: string, after: string): string { 36 | return `(${command}|${command}@[\\S]+)${after}`; 37 | } 38 | } -------------------------------------------------------------------------------- /src/bot_utils/msg-tools.ts: -------------------------------------------------------------------------------- 1 | import constants = require('../.constants'); 2 | import http = require('http'); 3 | import ariaTools = require('../download_tools/aria-tools'); 4 | import TelegramBot = require('node-telegram-bot-api'); 5 | import details = require('../dl_model/detail'); 6 | import dlm = require('../dl_model/dl-manager'); 7 | var dlManager = dlm.DlManager.getInstance(); 8 | 9 | export async function deleteMsg(bot: TelegramBot, msg: TelegramBot.Message, delay?: number): Promise { 10 | if (delay) await sleep(delay); 11 | 12 | bot.deleteMessage(msg.chat.id, msg.message_id.toString()) 13 | .catch(err => { 14 | console.log(`Failed to delete message. Does the bot have message delete permissions for this chat? ${err.message}`); 15 | }); 16 | } 17 | 18 | export function editMessage(bot: TelegramBot, msg: TelegramBot.Message, text: string): Promise { 19 | if (msg && msg.chat && msg.chat.id && msg.message_id) { 20 | return bot.editMessageText(text, { 21 | chat_id: msg.chat.id, 22 | message_id: msg.message_id, 23 | parse_mode: 'HTML' 24 | }); 25 | } else { 26 | return Promise.resolve(); 27 | } 28 | } 29 | 30 | export function sendMessage(bot: TelegramBot, msg: TelegramBot.Message, text: string, delay?: number, 31 | callback?: (res: TelegramBot.Message) => void, quickDeleteOriginal?: boolean): void { 32 | if (!delay) delay = 10000; 33 | bot.sendMessage(msg.chat.id, text, { 34 | reply_to_message_id: msg.message_id, 35 | parse_mode: 'HTML' 36 | }) 37 | .then((res) => { 38 | if (callback) callback(res); 39 | if (delay > -1) { 40 | deleteMsg(bot, res, delay); 41 | if (quickDeleteOriginal) { 42 | deleteMsg(bot, msg); 43 | } else { 44 | deleteMsg(bot, msg, delay); 45 | } 46 | } 47 | }) 48 | .catch((err) => { 49 | console.error(`sendMessage error: ${err.message}`); 50 | }); 51 | } 52 | 53 | export function sendUnauthorizedMessage(bot: TelegramBot, msg: TelegramBot.Message): void { 54 | sendMessage(bot, msg, `You aren't authorized to use this bot here.`); 55 | } 56 | 57 | export function sendMessageReplyOriginal(bot: TelegramBot, dlDetails: details.DlVars, message: string): Promise { 58 | return bot.sendMessage(dlDetails.tgChatId, message, { 59 | reply_to_message_id: dlDetails.tgMessageId, 60 | parse_mode: 'HTML' 61 | }); 62 | } 63 | 64 | export function sleep(ms: number): Promise { 65 | return new Promise(resolve => setTimeout(resolve, ms)); 66 | } 67 | 68 | export function isAuthorized(msg: TelegramBot.Message, skipDlOwner?: boolean): number { 69 | for (var i = 0; i < constants.SUDO_USERS.length; i++) { 70 | if (constants.SUDO_USERS[i] === msg.from.id) return 0; 71 | } 72 | if (!skipDlOwner && msg.reply_to_message) { 73 | var dlDetails = dlManager.getDownloadByMsgId(msg.reply_to_message); 74 | if (dlDetails && msg.from.id === dlDetails.tgFromId) return 1; 75 | } 76 | if (constants.AUTHORIZED_CHATS.indexOf(msg.chat.id) > -1 && 77 | msg.chat.all_members_are_administrators) return 2; 78 | if (constants.AUTHORIZED_CHATS.indexOf(msg.chat.id) > -1) return 3; 79 | return -1; 80 | } 81 | 82 | export function isAdmin(bot: TelegramBot, msg: TelegramBot.Message, callback: (err: string, isAdmin: boolean) => void): void { 83 | bot.getChatAdministrators(msg.chat.id) 84 | .then(members => { 85 | for (var i = 0; i < members.length; i++) { 86 | if (members[i].user.id === msg.from.id) { 87 | callback(null, true); 88 | return; 89 | } 90 | } 91 | callback(null, false); 92 | }) 93 | .catch(() => { 94 | callback(null, false); 95 | }); 96 | } 97 | 98 | /** 99 | * Notifies an external webserver once a download is complete. 100 | * @param {boolean} successful True is the download completed successfully 101 | * @param {string} gid The GID of the downloaded file 102 | * @param {number} originGroup The Telegram chat ID of the group where the download started 103 | * @param {string} driveURL The URL of the uploaded file 104 | */ 105 | export function notifyExternal(dlDetails: details.DlVars, successful: boolean, gid: string, originGroup: number, driveURL?: string): void { 106 | if (!constants.DOWNLOAD_NOTIFY_TARGET || !constants.DOWNLOAD_NOTIFY_TARGET.enabled) return; 107 | ariaTools.getStatus(dlDetails, (err, message, filename, filesize) => { 108 | var name; 109 | var size; 110 | if (!err) { 111 | if (filename !== 'Metadata') name = filename; 112 | if (filesize !== '0B') size = filesize; 113 | } 114 | 115 | // TODO: Check which vars are undefined and make those null 116 | const data = JSON.stringify({ 117 | successful: successful, 118 | file: { 119 | name: name, 120 | driveURL: driveURL, 121 | size: size 122 | }, 123 | originGroup: originGroup 124 | }); 125 | 126 | const options = { 127 | host: constants.DOWNLOAD_NOTIFY_TARGET.host, 128 | port: constants.DOWNLOAD_NOTIFY_TARGET.port, 129 | path: constants.DOWNLOAD_NOTIFY_TARGET.path, 130 | method: 'POST', 131 | headers: { 132 | 'Content-Type': 'application/json', 133 | 'Content-Length': Buffer.byteLength(data) 134 | } 135 | }; 136 | 137 | var req = http.request(options); 138 | req.on('error', (e) => { 139 | console.error(`notifyExternal failed: ${e.message}`); 140 | }); 141 | req.write(data); 142 | req.end(); 143 | }); 144 | } 145 | -------------------------------------------------------------------------------- /src/bot_utils/reg_exps.ts: -------------------------------------------------------------------------------- 1 | export class RegExps { 2 | readonly start: RegExp; 3 | readonly mirrorTar: RegExp; 4 | readonly mirror: RegExp; 5 | readonly mirrorStatus: RegExp; 6 | readonly list: RegExp; 7 | readonly getFolder: RegExp; 8 | readonly cancelMirror: RegExp; 9 | readonly cancelAll: RegExp; 10 | readonly disk: RegExp; 11 | 12 | constructor(commands: string[]) { 13 | this.start = new RegExp(commands[0], 'i'); 14 | this.mirrorTar = new RegExp(commands[1], 'i'); 15 | this.mirror = new RegExp(commands[2], 'i'); 16 | this.mirrorStatus = new RegExp(commands[3], 'i'); 17 | this.list = new RegExp(commands[4], 'i'); 18 | this.getFolder = new RegExp(commands[5], 'i'); 19 | this.cancelMirror = new RegExp(commands[6], 'i'); 20 | this.cancelAll = new RegExp(commands[7], 'i'); 21 | this.disk = new RegExp(commands[8], 'i'); 22 | } 23 | } -------------------------------------------------------------------------------- /src/dl_model/detail.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot = require('node-telegram-bot-api'); 2 | export class DlVars { 3 | isUploading: boolean; 4 | uploadedBytes: number; 5 | uploadedBytesLast: number; 6 | lastUploadCheckTimestamp: number; 7 | isDownloadAllowed: number; 8 | isDownloading: boolean; 9 | gid: string; 10 | readonly tgFromId: number; 11 | readonly tgUsername: string; 12 | readonly tgRepliedUsername: string; 13 | readonly tgChatId: number; 14 | readonly tgMessageId: number; 15 | readonly startTime: number; 16 | /** 17 | * A subdirectory of 'constants.ARIA_DOWNLOAD_LOCATION.length', where this download 18 | * will be downloaded. This directory should always have a 36 character name. 19 | */ 20 | readonly downloadDir: string; 21 | 22 | constructor(gid: string, msg: TelegramBot.Message, readonly isTar: boolean, downloadDir: string) { 23 | this.tgUsername = getUsername(msg); 24 | if (msg.reply_to_message) { 25 | this.tgRepliedUsername = getUsername(msg.reply_to_message); 26 | } 27 | 28 | this.gid = gid; 29 | this.downloadDir = downloadDir; 30 | this.tgFromId = msg.from.id; 31 | this.tgChatId = msg.chat.id; 32 | this.tgMessageId = msg.message_id; 33 | this.startTime = new Date().getTime(); 34 | this.uploadedBytes = 0; 35 | this.uploadedBytesLast = 0; 36 | } 37 | } 38 | 39 | function getUsername(msg: TelegramBot.Message): string { 40 | if (msg.from.username) { 41 | return `@${msg.from.username}`; 42 | } else { 43 | return `${msg.from.first_name}`; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/dl_model/dl-manager.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot = require('node-telegram-bot-api'); 2 | import dlDetails = require('./detail'); 3 | 4 | export class DlManager { 5 | private static instance: DlManager; 6 | 7 | private allDls: any = {}; 8 | private activeDls: any = {}; 9 | 10 | /** 11 | * Stores all general status messages. General status messages show the status 12 | * of all downloads. Each chat can have at most 1 general status message. 13 | * Key: Chat ID: number 14 | * Value: Status message: TelegramBot.Message 15 | */ 16 | private statusAll: any = {}; 17 | private statusLock: any = {}; 18 | 19 | private cancelledMessages: any = {}; 20 | private cancelledDls: any = {}; 21 | 22 | private constructor() { 23 | } 24 | 25 | static getInstance(): DlManager { 26 | if (!DlManager.instance) { 27 | DlManager.instance = new DlManager(); 28 | } 29 | return DlManager.instance; 30 | } 31 | 32 | addDownload(gid: string, dlDir: string, msg: TelegramBot.Message, isTar: boolean): void { 33 | var detail = new dlDetails.DlVars(gid, msg, isTar, dlDir); 34 | this.allDls[gid] = detail; 35 | } 36 | 37 | getDownloadByGid(gid: string): dlDetails.DlVars { 38 | return this.allDls[gid]; 39 | } 40 | 41 | /** 42 | * Mark a download as active, once Aria2 starts downloading it. 43 | * @param dlDetails The details for the download 44 | */ 45 | moveDownloadToActive(dlDetails: dlDetails.DlVars): void { 46 | dlDetails.isDownloading = true; 47 | dlDetails.isUploading = false; 48 | this.activeDls[dlDetails.gid] = dlDetails; 49 | } 50 | 51 | /** 52 | * Update the GID of a download. This is needed if a download causes Aria2c to start 53 | * another download, for example, in the case of BitTorrents. This function also 54 | * marks the download as inactive, because we only find out about the new GID when 55 | * Aria2c calls onDownloadComplete, at which point, the metadata download has been 56 | * completed, but the files download hasn't yet started. 57 | * @param oldGid The GID of the original download (the download metadata) 58 | * @param newGid The GID of the new download (the files specified in the metadata) 59 | */ 60 | changeDownloadGid(oldGid: string, newGid: string): void { 61 | var dlDetails = this.getDownloadByGid(oldGid); 62 | this.deleteDownload(oldGid); 63 | dlDetails.gid = newGid; 64 | dlDetails.isDownloading = false; 65 | this.allDls[newGid] = dlDetails; 66 | } 67 | 68 | /** 69 | * Gets a download by the download command message, or the original reply 70 | * to the download command message. 71 | * @param msg The download command message 72 | */ 73 | getDownloadByMsgId(msg: TelegramBot.Message): dlDetails.DlVars { 74 | for (var dl of Object.keys(this.allDls)) { 75 | var download: dlDetails.DlVars = this.allDls[dl]; 76 | if (download.tgChatId === msg.chat.id && 77 | (download.tgMessageId === msg.message_id)) { 78 | return download; 79 | } 80 | } 81 | return null; 82 | } 83 | 84 | deleteDownload(gid: string): void { 85 | delete this.allDls[gid]; 86 | delete this.activeDls[gid]; 87 | } 88 | 89 | /** 90 | * Call the callback function for each download. 91 | * @param callback 92 | */ 93 | forEachDownload(callback: (dlDetails: dlDetails.DlVars) => void): void { 94 | for (var key of Object.keys(this.allDls)) { 95 | var details = this.allDls[key]; 96 | callback(details); 97 | } 98 | } 99 | 100 | deleteStatus(chatId: number): void { 101 | delete this.statusAll[chatId]; 102 | } 103 | 104 | /** 105 | * Returns the general status message for a target chat. 106 | * @param chatId The chat ID of the target chat 107 | * @returns {TelegramBot.Message} The status message for the target group 108 | */ 109 | getStatus(chatId: number): StatusAll { 110 | return this.statusAll[chatId]; 111 | } 112 | 113 | addStatus(msg: TelegramBot.Message, lastStatus: string): void { 114 | this.statusAll[msg.chat.id] = { 115 | msg: msg, 116 | lastStatus: lastStatus 117 | }; 118 | } 119 | 120 | /** 121 | * Call the callback function for each general status message. 122 | * @param callback 123 | */ 124 | forEachStatus(callback: (status: StatusAll) => void): void { 125 | for (var key of Object.keys(this.statusAll)) { 126 | callback(this.statusAll[key]); 127 | } 128 | } 129 | 130 | /** 131 | * Prevents race conditions when multiple status messages are sent in a short time. 132 | * Makes sure that a status message has been properly sent before allowing the next one. 133 | * @param msg The Telegram message that caused this status update 134 | * @param toCall The function to call to perform the status update 135 | */ 136 | setStatusLock(msg: TelegramBot.Message, toCall: (msg: TelegramBot.Message, keep: boolean) => Promise): void { 137 | if (!this.statusLock[msg.chat.id]) { 138 | this.statusLock[msg.chat.id] = Promise.resolve(); 139 | } 140 | 141 | this.statusLock[msg.chat.id] = this.statusLock[msg.chat.id].then(() => { 142 | return toCall(msg, true); 143 | }); 144 | } 145 | 146 | addCancelled(dlDetails: dlDetails.DlVars): void { 147 | this.cancelledDls[dlDetails.gid] = dlDetails; 148 | var message: string[] = this.cancelledMessages[dlDetails.tgChatId]; 149 | if (message) { 150 | if (this.checkUnique(dlDetails.tgUsername, message)) { 151 | message.push(dlDetails.tgUsername); 152 | } 153 | } else { 154 | message = [dlDetails.tgUsername]; 155 | } 156 | this.cancelledMessages[dlDetails.tgChatId] = message; 157 | } 158 | 159 | forEachCancelledDl(callback: (dlDetails: dlDetails.DlVars) => void): void { 160 | for (var key of Object.keys(this.cancelledDls)) { 161 | callback(this.cancelledDls[key]); 162 | } 163 | } 164 | 165 | forEachCancelledChat(callback: (usernames: string[], tgChat: string) => void): void { 166 | for (var key of Object.keys(this.cancelledMessages)) { 167 | callback(this.cancelledMessages[key], key); 168 | } 169 | } 170 | 171 | removeCancelledMessage(chatId: string): void { 172 | delete this.cancelledMessages[chatId]; 173 | } 174 | 175 | removeCancelledDls(gid: string): void { 176 | delete this.cancelledDls[gid]; 177 | } 178 | 179 | private checkUnique(toFind: string, src: string[]): boolean { 180 | for (var item of src) { 181 | if (item === toFind) { 182 | return false; 183 | } 184 | } 185 | return true; 186 | } 187 | 188 | } 189 | 190 | interface StatusAll { 191 | msg: TelegramBot.Message; 192 | lastStatus: string; 193 | } -------------------------------------------------------------------------------- /src/download_tools/aria-tools.ts: -------------------------------------------------------------------------------- 1 | import downloadUtils = require('./utils'); 2 | import drive = require('../fs-walk'); 3 | const Aria2 = require('aria2'); 4 | import constants = require('../.constants'); 5 | import tar = require('../drive/tar'); 6 | const diskspace = require('diskspace'); 7 | import filenameUtils = require('./filename-utils'); 8 | import { DlVars } from '../dl_model/detail'; 9 | 10 | const ariaOptions = { 11 | host: 'localhost', 12 | port: constants.ARIA_PORT ? constants.ARIA_PORT : 8210, 13 | secure: false, 14 | secret: constants.ARIA_SECRET, 15 | path: '/jsonrpc' 16 | }; 17 | const aria2 = new Aria2(ariaOptions); 18 | 19 | export function openWebsocket(callback: (err: string) => void): void { 20 | aria2.open() 21 | .then(() => { 22 | callback(null); 23 | }) 24 | .catch((err: string) => { 25 | callback(err); 26 | }); 27 | } 28 | 29 | export function setOnDownloadStart(callback: (gid: string, retry: number) => void): void { 30 | aria2.onDownloadStart = (keys: any) => { 31 | callback(keys.gid, 1); 32 | }; 33 | } 34 | 35 | export function setOnDownloadStop(callback: (gid: string, retry: number) => void): void { 36 | aria2.onDownloadStop = (keys: any) => { 37 | callback(keys.gid, 1); 38 | }; 39 | } 40 | 41 | export function setOnDownloadComplete(callback: (gid: string, retry: number) => void): void { 42 | aria2.onDownloadComplete = (keys: any) => { 43 | callback(keys.gid, 1); 44 | }; 45 | } 46 | 47 | export function setOnDownloadError(callback: (gid: string, retry: number) => void): void { 48 | aria2.onDownloadError = (keys: any) => { 49 | callback(keys.gid, 1); 50 | }; 51 | } 52 | 53 | export function getAriaFilePath(gid: string, callback: (err: string, file: string) => void): void { 54 | aria2.getFiles(gid, (err: any, files: any[]) => { 55 | if (err) { 56 | callback(err.message, null); 57 | } else { 58 | var filePath = filenameUtils.findAriaFilePath(files); 59 | if (filePath) { 60 | callback(null, filePath.path); 61 | } else { 62 | callback(null, null); 63 | } 64 | } 65 | }); 66 | } 67 | 68 | /** 69 | * Get a human-readable message about the status of the given download. Uses 70 | * HTML markup. Filename and filesize is always present if the download exists, 71 | * message is only present if the download is active. 72 | * @param {string} gid The Aria2 GID of the download 73 | * @param {function} callback The function to call on completion. (err, message, filename, filesize). 74 | */ 75 | export function getStatus(dlDetails: DlVars, 76 | callback: (err: string, message: string, filename: string, filesizeStr: string) => void): void { 77 | aria2.tellStatus(dlDetails.gid, 78 | ['status', 'totalLength', 'completedLength', 'downloadSpeed', 'files'], 79 | (err: any, res: any) => { 80 | if (err) { 81 | callback(err.message, null, null, null); 82 | } else if (res.status === 'active') { 83 | var statusMessage = downloadUtils.generateStatusMessage(parseFloat(res.totalLength), 84 | parseFloat(res.completedLength), parseFloat(res.downloadSpeed), res.files, false); 85 | callback(null, statusMessage.message, statusMessage.filename, statusMessage.filesize); 86 | } else if (dlDetails.isUploading) { 87 | var downloadSpeed: number; 88 | var time = new Date().getTime(); 89 | if (!dlDetails.lastUploadCheckTimestamp) { 90 | downloadSpeed = 0; 91 | } else { 92 | downloadSpeed = (dlDetails.uploadedBytes - dlDetails.uploadedBytesLast) 93 | / ((time - dlDetails.lastUploadCheckTimestamp) / 1000); 94 | } 95 | dlDetails.uploadedBytesLast = dlDetails.uploadedBytes; 96 | dlDetails.lastUploadCheckTimestamp = time; 97 | 98 | var statusMessage = downloadUtils.generateStatusMessage(parseFloat(res.totalLength), 99 | dlDetails.uploadedBytes, downloadSpeed, res.files, true); 100 | callback(null, statusMessage.message, statusMessage.filename, statusMessage.filesize); 101 | } else { 102 | var filePath = filenameUtils.findAriaFilePath(res['files']); 103 | var filename = filenameUtils.getFileNameFromPath(filePath.path, filePath.inputPath, filePath.downloadUri); 104 | var message; 105 | if (res.status === 'waiting') { 106 | message = `${filename} - Queued`; 107 | } else { 108 | message = `${filename} - ${res.status}`; 109 | } 110 | callback(null, message, filename, '0B'); 111 | } 112 | }); 113 | } 114 | 115 | export function getError(gid: string, callback: (err: string, message: string) => void): void { 116 | aria2.tellStatus(gid, ['errorMessage'], (err: any, res: any) => { 117 | if (err) { 118 | callback(err.message, null); 119 | } else { 120 | callback(null, res.errorMessage); 121 | } 122 | }); 123 | } 124 | 125 | export function isDownloadMetadata(gid: string, callback: (err: string, isMetadata: boolean, newGid: string) => void): void { 126 | aria2.tellStatus(gid, ['followedBy'], (err: any, res: any) => { 127 | if (err) { 128 | callback(err.message, null, null); 129 | } else { 130 | if (res.followedBy) { 131 | callback(null, true, res.followedBy[0]); 132 | } else { 133 | callback(null, false, null); 134 | } 135 | } 136 | }); 137 | } 138 | 139 | export function getFileSize(gid: string, callback: (err: string, fileSize: number) => void): void { 140 | aria2.tellStatus(gid, 141 | ['totalLength'], 142 | (err: any, res: any) => { 143 | if (err) { 144 | callback(err.message, res); 145 | } else { 146 | callback(null, res['totalLength']); 147 | } 148 | }); 149 | } 150 | 151 | interface DriveUploadCompleteCallback { 152 | (err: string, gid: string, url: string, filePath: string, fileName: string, fileSize: number, isFolder: boolean): void; 153 | } 154 | 155 | /** 156 | * Sets the upload flag, uploads the given path to Google Drive, then calls the callback, 157 | * cleans up the download directory, and unsets the download and upload flags. 158 | * If a directory is given, and isTar is set in vars, archives the directory to a tar 159 | * before uploading. Archival fails if fileSize is equal to or more than the free space on disk. 160 | * @param {dlVars.DlVars} dlDetails The dlownload details for the current download 161 | * @param {string} filePath The path of the file or directory to upload 162 | * @param {number} fileSize The size of the file 163 | * @param {function} callback The function to call with the link to the uploaded file 164 | */ 165 | export function uploadFile(dlDetails: DlVars, filePath: string, fileSize: number, callback: DriveUploadCompleteCallback): void { 166 | 167 | dlDetails.isUploading = true; 168 | var fileName = filenameUtils.getFileNameFromPath(filePath, null); 169 | var realFilePath = filenameUtils.getActualDownloadPath(filePath); 170 | if (dlDetails.isTar) { 171 | if (filePath === realFilePath) { 172 | // If there is only one file, do not archive 173 | driveUploadFile(dlDetails, realFilePath, fileName, fileSize, callback); 174 | } else { 175 | diskspace.check(constants.ARIA_DOWNLOAD_LOCATION_ROOT, (err: string, res: any) => { 176 | if (err) { 177 | console.log('uploadFile: diskspace: ' + err); 178 | // Could not archive, so upload normally 179 | driveUploadFile(dlDetails, realFilePath, fileName, fileSize, callback); 180 | return; 181 | } 182 | if (res['free'] > fileSize) { 183 | console.log('Starting archival'); 184 | var destName = fileName + '.tar'; 185 | tar.archive(realFilePath, destName, (err: string, size: number) => { 186 | if (err) { 187 | callback(err, dlDetails.gid, null, null, null, null, false); 188 | } else { 189 | console.log('Archive complete'); 190 | driveUploadFile(dlDetails, realFilePath + '.tar', destName, size, callback); 191 | } 192 | }); 193 | } else { 194 | console.log('uploadFile: Not enough space, uploading without archiving'); 195 | driveUploadFile(dlDetails, realFilePath, fileName, fileSize, callback); 196 | } 197 | }); 198 | } 199 | } else { 200 | driveUploadFile(dlDetails, realFilePath, fileName, fileSize, callback); 201 | } 202 | } 203 | 204 | function driveUploadFile(dlDetails: DlVars, filePath: string, fileName: string, fileSize: number, callback: DriveUploadCompleteCallback): void { 205 | drive.uploadRecursive(dlDetails, 206 | filePath, 207 | constants.GDRIVE_PARENT_DIR_ID, 208 | (err: string, url: string, isFolder: boolean) => { 209 | callback(err, dlDetails.gid, url, filePath, fileName, fileSize, isFolder); 210 | }); 211 | } 212 | 213 | export function stopDownload(gid: string, callback: () => void): void { 214 | aria2.remove(gid, callback); 215 | } 216 | 217 | export function addUri(uri: string, dlDir: string, callback: (err: any, gid: string) => void): void { 218 | aria2.addUri([uri], { dir: `${constants.ARIA_DOWNLOAD_LOCATION}/${dlDir}` }) 219 | .then((gid: string) => { 220 | callback(null, gid); 221 | }) 222 | .catch((err: any) => { 223 | callback(err, null); 224 | }); 225 | } 226 | -------------------------------------------------------------------------------- /src/download_tools/filename-utils.ts: -------------------------------------------------------------------------------- 1 | import constants = require('../.constants'); 2 | 3 | const TYPE_METADATA = 'Metadata'; 4 | 5 | interface FilePath { 6 | path: string, 7 | /** 8 | * The path extracted from the files array returned by aria2c. 9 | * It is present even for metadata, unlike 'path' 10 | */ 11 | inputPath: string, 12 | downloadUri: string 13 | } 14 | 15 | /** 16 | * Finds the path of the file/torrent that Aria2 is downloading from a list of 17 | * files returned by Aria2. 18 | * @param {Object[]} files The list of files returned by Aria2 19 | * @returns {string} The name of the download, or null if it is a torrent metadata. 20 | */ 21 | export function findAriaFilePath(files: any[]): FilePath { 22 | var filePath = files[0]['path']; 23 | var uri = files[0].uris[0] ? files[0].uris[0].uri : null; 24 | 25 | if (filePath.startsWith(constants.ARIA_DOWNLOAD_LOCATION)) { 26 | if (filePath.substring(filePath.lastIndexOf('.') + 1) !== 'torrent') { 27 | // This is not a torrent's metadata 28 | return { path: filePath, inputPath: filePath, downloadUri: uri }; 29 | } else { 30 | return { path: null, inputPath: filePath, downloadUri: uri }; 31 | } 32 | } else { 33 | return { path: null, inputPath: filePath, downloadUri: uri }; 34 | } 35 | } 36 | 37 | /** 38 | * Given the path to a file in the download directory, returns the name of the 39 | * file. If the file is in a subdirectory of the download directory, returns 40 | * the name of that subdirectory. Assumes that the file is a direct or indirect 41 | * child of a subdirectory of ARIA_DOWNLOAD_LOCATION, with a 36 character name. 42 | * If the path is missing, it tries to return an unreliable name from the URI 43 | * of the download. If the URI is also missing, returns TYPE_METADATA. 44 | * @param {string} filePath The name of a file that was downloaded 45 | * @returns {string} The name of the file or directory that was downloaded 46 | */ 47 | export function getFileNameFromPath(filePath: string, inputPath: string, downloadUri?: string): string { 48 | if (!filePath) { 49 | return getFilenameFromUri(inputPath, downloadUri); 50 | } 51 | 52 | // +2 because there are two /'s, after ARIA_DOWNLOAD_LOCATION and after the 36 character subdir 53 | var baseDirLength = constants.ARIA_DOWNLOAD_LOCATION.length + 38; 54 | var nameEndIndex = filePath.indexOf('/', baseDirLength); 55 | if (nameEndIndex === -1) { 56 | nameEndIndex = filePath.length; 57 | } 58 | var fileName = filePath.substring(baseDirLength, nameEndIndex); 59 | 60 | if (!fileName) {// This really shouldn't be possible 61 | return getFilenameFromUri(inputPath, downloadUri); 62 | } 63 | return fileName; 64 | } 65 | 66 | /** 67 | * Given the path to a file in the download, returns the path of the top-level 68 | * directory of the download. If there is only one file in the download, returns 69 | * the path of that file. Assumes that the file is a direct or indirect child of 70 | * a subdirectory of ARIA_DOWNLOAD_LOCATION, with a 36 character name. 71 | * @param {string} filePath The name of a file that was downloaded 72 | * @returns {string} The name of the file or directory that was downloaded 73 | */ 74 | export function getActualDownloadPath(filePath: string): string { 75 | // +2 because there are two /'s, after ARIA_DOWNLOAD_LOCATION and after the 36 character subdir 76 | var baseDirLength = constants.ARIA_DOWNLOAD_LOCATION.length + 38; 77 | var nameEndIndex = filePath.indexOf('/', baseDirLength); 78 | if (nameEndIndex === -1) { 79 | nameEndIndex = filePath.length; 80 | } 81 | var fileName = filePath.substring(0, nameEndIndex); 82 | return fileName; 83 | } 84 | 85 | /** 86 | * Returns the file name in the torrent metadata, or embedded in the URI. The 87 | * file name in the URI might not be the actual name of the downloaded file. 88 | * Use this function only to show messages to the user, and that too, only if 89 | * aria2c doesn't give a list of files (which happens before the download starts). 90 | * @param uri The URI of the download 91 | */ 92 | function getFilenameFromUri(path: string, uri: string): string { 93 | if (path) { 94 | if (path.startsWith('[METADATA]')) { 95 | return path.substring(10); 96 | } else { 97 | return TYPE_METADATA; 98 | } 99 | } else { 100 | if (uri) { 101 | return uri.replace(/#.*$|\/\?.*$|\?.*$/, "").replace(/^.*\//, ""); 102 | } else { 103 | return TYPE_METADATA; 104 | } 105 | } 106 | 107 | } 108 | 109 | export function isFilenameAllowed(filename: string): number { 110 | if (!constants.ARIA_FILTERED_FILENAMES) return 1; 111 | if (filename === TYPE_METADATA) return -1; 112 | 113 | for (var i = 0; i < constants.ARIA_FILTERED_FILENAMES.length; i++) { 114 | if (filename.indexOf(constants.ARIA_FILTERED_FILENAMES[i]) > -1) return 0; 115 | } 116 | return 1; 117 | } -------------------------------------------------------------------------------- /src/download_tools/utils.ts: -------------------------------------------------------------------------------- 1 | import fs = require('fs-extra'); 2 | import filenameUtils = require('./filename-utils'); 3 | import constants = require('../.constants'); 4 | import ariaTools = require('./aria-tools.js'); 5 | import msgTools = require('../bot_utils/msg-tools.js'); 6 | import TelegramBot = require('node-telegram-bot-api'); 7 | import details = require('../dl_model/detail'); 8 | import dlm = require('../dl_model/dl-manager'); 9 | var dlManager = dlm.DlManager.getInstance(); 10 | 11 | const PROGRESS_MAX_SIZE = Math.floor(100 / 8); 12 | const PROGRESS_INCOMPLETE = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']; 13 | 14 | export function deleteDownloadedFile(subdirName: string): void { 15 | fs.remove(`${constants.ARIA_DOWNLOAD_LOCATION}/${subdirName}`) 16 | .then(() => { 17 | console.log(`cleanup: Deleted ${subdirName}\n`); 18 | }) 19 | .catch((err) => { 20 | console.error(`cleanup: Failed to delete ${subdirName}: ${err.message}\n`); 21 | }); 22 | } 23 | 24 | function downloadETA(totalLength: number, completedLength: number, speed: number): string { 25 | if (speed === 0) 26 | return '-'; 27 | var time = (totalLength - completedLength) / speed; 28 | var seconds = Math.floor(time % 60); 29 | var minutes = Math.floor((time / 60) % 60); 30 | var hours = Math.floor(time / 3600); 31 | 32 | if (hours === 0) { 33 | if (minutes === 0) { 34 | return `${seconds}s`; 35 | } else { 36 | return `${minutes}m ${seconds}s`; 37 | } 38 | } else { 39 | return `${hours}h ${minutes}m ${seconds}s`; 40 | } 41 | } 42 | 43 | interface StatusSingle { 44 | message: string; 45 | filename?: string; 46 | dlDetails?: details.DlVars; 47 | } 48 | 49 | function getSingleStatus(dlDetails: details.DlVars, msg?: TelegramBot.Message): Promise { 50 | return new Promise(resolve => { 51 | var authorizedCode; 52 | if (msg) { 53 | authorizedCode = msgTools.isAuthorized(msg); 54 | } else { 55 | authorizedCode = 1; 56 | } 57 | 58 | if (authorizedCode > -1) { 59 | ariaTools.getStatus(dlDetails, (err, message, filename) => { 60 | if (err) { 61 | resolve({ 62 | message: `Error: ${dlDetails.gid} - ${err}` 63 | }); 64 | } else { 65 | resolve({ 66 | message: message, 67 | filename: filename, 68 | dlDetails: dlDetails 69 | }); 70 | } 71 | }); 72 | } else { 73 | resolve({ message: `You aren't authorized to use this bot here.` }); 74 | } 75 | }); 76 | } 77 | 78 | interface StatusAll { 79 | message: string; 80 | totalDownloadCount: number; 81 | singleStatuses?: StatusSingle[]; 82 | } 83 | 84 | /** 85 | * Get a single status message for all active and queued downloads. 86 | */ 87 | export function getStatusMessage(): Promise { 88 | var singleStatusArr: Promise[] = []; 89 | 90 | dlManager.forEachDownload(dlDetails => { 91 | singleStatusArr.push(getSingleStatus(dlDetails)); 92 | }); 93 | 94 | var result: Promise = Promise.all(singleStatusArr) 95 | .then(statusArr => { 96 | if (statusArr && statusArr.length > 0) { 97 | var message: string; 98 | statusArr.sort((a, b) => (a.dlDetails && b.dlDetails) ? (a.dlDetails.startTime - b.dlDetails.startTime) : 1) 99 | .forEach((value, index) => { 100 | if (index > 0) { 101 | message = `${message}\n\n${value.message}`; 102 | } else { 103 | message = value.message; 104 | } 105 | }); 106 | 107 | return { 108 | message: message, 109 | totalDownloadCount: statusArr.length, 110 | singleStatuses: statusArr 111 | }; 112 | } else { 113 | return { 114 | message: 'No active or queued downloads', 115 | totalDownloadCount: 0 116 | }; 117 | } 118 | }) 119 | .catch(error => { 120 | console.log(`getStatusMessage: ${error}`); 121 | return error; 122 | }); 123 | return result; 124 | } 125 | 126 | /** 127 | * Generates a human-readable message for the status of the given download 128 | * @param {number} totalLength The total size of the download 129 | * @param {number} completedLength The downloaded length 130 | * @param {number} speed The speed of the download in B/s 131 | * @param {any[]} files The list of files in the download 132 | * @returns {StatusMessage} An object containing a printable status message and the file name 133 | */ 134 | export function generateStatusMessage(totalLength: number, completedLength: number, speed: number, 135 | files: any[], isUploading: boolean): StatusMessage { 136 | var filePath = filenameUtils.findAriaFilePath(files); 137 | var fileName = filenameUtils.getFileNameFromPath(filePath.path, filePath.inputPath, filePath.downloadUri); 138 | var progress; 139 | if (totalLength === 0) { 140 | progress = 0; 141 | } else { 142 | progress = Math.round(completedLength * 100 / totalLength); 143 | } 144 | var totalLengthStr = formatSize(totalLength); 145 | var progressString = generateProgress(progress); 146 | var speedStr = formatSize(speed); 147 | var eta = downloadETA(totalLength, completedLength, speed); 148 | var type = isUploading ? 'Uploading' : 'Filename'; 149 | var message = `${type}: ${fileName}\nSize: ${totalLengthStr}\nProgress: ${progressString}\nSpeed: ${speedStr}ps\nETA: ${eta}`; 150 | var status = { 151 | message: message, 152 | filename: fileName, 153 | filesize: totalLengthStr 154 | }; 155 | return status; 156 | } 157 | 158 | export interface StatusMessage { 159 | message: string; 160 | filename: string; 161 | filesize: string; 162 | } 163 | 164 | function generateProgress(p: number): string { 165 | p = Math.min(Math.max(p, 0), 100); 166 | var str = '['; 167 | var cFull = Math.floor(p / 8); 168 | var cPart = p % 8 - 1; 169 | str += '█'.repeat(cFull); 170 | if (cPart >= 0) { 171 | str += PROGRESS_INCOMPLETE[cPart]; 172 | } 173 | str += ' '.repeat(PROGRESS_MAX_SIZE - cFull); 174 | str = `${str}] ${p}%`; 175 | 176 | return str; 177 | } 178 | 179 | export function formatSize(size: number): string { 180 | if (size < 1000) { 181 | return formatNumber(size) + 'B'; 182 | } 183 | if (size < 1024000) { 184 | return formatNumber(size / 1024) + 'KB'; 185 | } 186 | if (size < 1048576000) { 187 | return formatNumber(size / 1048576) + 'MB'; 188 | } 189 | return formatNumber(size / 1073741824) + 'GB'; 190 | } 191 | 192 | function formatNumber(n: number): number { 193 | return Math.round(n * 100) / 100; 194 | } 195 | 196 | export function isDownloadAllowed(url: string): boolean { 197 | for (var i = 0; i < constants.ARIA_FILTERED_DOMAINS.length; i++) { 198 | if (url.indexOf(constants.ARIA_FILTERED_DOMAINS[i]) > -1) return false; 199 | } 200 | return true; 201 | } 202 | -------------------------------------------------------------------------------- /src/drive/drive-auth.ts: -------------------------------------------------------------------------------- 1 | import fs = require('fs'); 2 | import readline = require('readline'); 3 | import { google } from 'googleapis'; 4 | import { OAuth2Client } from 'google-auth-library'; 5 | 6 | const SCOPE = 'https://www.googleapis.com/auth/drive'; 7 | const TOKEN_PATH = './credentials.json'; 8 | 9 | /** 10 | * Authorize a client with credentials, then call the Google Drive API. 11 | * @param {function} callback The callback to call with the authorized client. 12 | */ 13 | export function call(callback: (err: string, client: OAuth2Client) => void): void { 14 | // Load client secrets from a local file. 15 | fs.readFile('./client_secret.json', 'utf8', (err, content) => { 16 | if (err) { 17 | console.log('Error loading client secret file:', err.message); 18 | callback(err.message, null); 19 | } else { 20 | authorize(JSON.parse(content), callback); 21 | } 22 | }); 23 | } 24 | 25 | /** 26 | * Create an OAuth2 client with the given credentials, and then execute the 27 | * given callback function. 28 | * @param {Object} credentials The authorization client credentials. 29 | * @param {function} callback The callback to call with the authorized client. 30 | */ 31 | function authorize(credentials: any, callback: (err: string, client: OAuth2Client) => void): void { 32 | const clientSecret = credentials.installed.client_secret; 33 | const clientId = credentials.installed.client_id; 34 | const redirectUris = credentials.installed.redirect_uris; 35 | const oAuth2Client = new google.auth.OAuth2( 36 | clientId, clientSecret, redirectUris[0]); 37 | 38 | // Check if we have previously stored a token. 39 | fs.readFile(TOKEN_PATH, 'utf8', (err, token) => { 40 | if (err) return getAccessToken(oAuth2Client, callback); 41 | oAuth2Client.setCredentials(JSON.parse(token)); 42 | callback(null, oAuth2Client); 43 | }); 44 | } 45 | 46 | /** 47 | * Get and store new token after prompting for user authorization, and then 48 | * execute the given callback with the authorized OAuth2 client. 49 | * @param {google.auth.OAuth2} oAuth2Client The OAuth2 client to get token for. 50 | * @param {getEventsCallback} callback The callback for the authorized client. 51 | */ 52 | function getAccessToken(oAuth2Client: OAuth2Client, callback: (err: string, client: OAuth2Client) => void): void { 53 | const authUrl = oAuth2Client.generateAuthUrl({ 54 | access_type: 'offline', 55 | scope: SCOPE 56 | }); 57 | console.log('Authorize this app by visiting this url:', authUrl); 58 | const rl = readline.createInterface({ 59 | input: process.stdin, 60 | output: process.stdout 61 | }); 62 | rl.question('Enter the code from that page here: ', (code) => { 63 | rl.close(); 64 | oAuth2Client.getToken(code, (err, token) => { 65 | if (err) return callback(err.message, null); 66 | oAuth2Client.setCredentials(token); 67 | // Store the token to disk for later program executions 68 | fs.writeFile(TOKEN_PATH, JSON.stringify(token), (err) => { 69 | if (err) console.error(err); 70 | console.log('Token stored to', TOKEN_PATH); 71 | }); 72 | callback(null, oAuth2Client); 73 | }); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /src/drive/drive-list.ts: -------------------------------------------------------------------------------- 1 | import constants = require('../.constants.js'); 2 | import driveAuth = require('./drive-auth.js'); 3 | import {google} from 'googleapis'; 4 | import utils = require('./drive-utils'); 5 | import dlUtils = require('../download_tools/utils'); 6 | 7 | /** 8 | * Searches for a given file on Google Drive. Only search the subfolders and files 9 | * of the folder that files are uploaded into. This function only performs performs 10 | * prefix matching, though it tries some common variations. 11 | * @param {string} fileName The name of the file to search for 12 | * @param {function} callback A function to call with an error, or a human-readable message 13 | */ 14 | export function listFiles (fileName:string, callback:(err:string, message:string)=> void): void { 15 | // Uncommenting the below line will prevent users from asking to list all files 16 | // if (fileName === '' || fileName ==='*' || fileName === '%') return; 17 | 18 | driveAuth.call((err, auth) => { 19 | if (err) { 20 | callback(err, null); 21 | return; 22 | } 23 | const drive = google.drive({version: 'v3', auth}); 24 | 25 | drive.files.list({ 26 | // @ts-ignore Unknown property error 27 | fields: 'files(id, name, mimeType, size)', 28 | q: generateSearchQuery(fileName, constants.GDRIVE_PARENT_DIR_ID), 29 | orderBy: 'modifiedTime desc', 30 | pageSize: 20, 31 | supportsAllDrives: true, 32 | includeItemsFromAllDrives: true 33 | }, 34 | (err:Error, res:any) => { 35 | if (err) { 36 | callback(err.message, null); 37 | } else { 38 | res = res['data']['files']; 39 | getMultipleFileLinks(res); 40 | callback(null, generateFilesListMessage(res)); 41 | } 42 | }); 43 | }); 44 | } 45 | 46 | function generateSearchQuery (fileName:string, parent:string): string { 47 | var q = '\'' + parent + '\' in parents and ('; 48 | if (fileName.indexOf(' ') > -1) { 49 | for (var i = 0; i < 4; i++) { 50 | q += 'name contains \'' + fileName + '\' '; 51 | switch (i) { 52 | case 0: 53 | fileName = fileName.replace(/ /g, '.'); 54 | q += 'or '; 55 | break; 56 | case 1: 57 | fileName = fileName.replace(/\./g, '-'); 58 | q += 'or '; 59 | break; 60 | case 2: 61 | fileName = fileName.replace(/-/g, '_'); 62 | q += 'or '; 63 | break; 64 | } 65 | } 66 | } else { 67 | q += 'name contains \'' + fileName + '\''; 68 | } 69 | q += ')'; 70 | return q; 71 | } 72 | 73 | function getMultipleFileLinks (files:any[]): void { 74 | for (var i = 0; i < files.length; i++) { 75 | files[i]['url'] = utils.getFileLink( 76 | files[i]['id'], 77 | files[i]['mimeType'] === 'application/vnd.google-apps.folder' 78 | ); 79 | } 80 | } 81 | 82 | function generateFilesListMessage (files:any[]): string { 83 | var message = ''; 84 | if (files.length > 0) { 85 | for (var i = 0; i < files.length; i++) { 86 | message += '' + files[i]['name'] + ''; 87 | if (files[i]['size']) 88 | message += ' (' + dlUtils.formatSize(files[i]['size']) + ')\n'; 89 | else if (files[i]['mimeType'] === 'application/vnd.google-apps.folder') 90 | message += ' (folder)\n'; 91 | else 92 | message += '\n'; 93 | 94 | } 95 | } else { 96 | message = 'There are no files matching your parameters'; 97 | } 98 | return message; 99 | } 100 | -------------------------------------------------------------------------------- /src/drive/drive-upload.ts: -------------------------------------------------------------------------------- 1 | import driveAuth = require('./drive-auth'); 2 | import driveFile = require('./upload-file'); 3 | import utils = require('./drive-utils'); 4 | import { google, drive_v3 } from 'googleapis'; 5 | import constants = require('../.constants.js'); 6 | import { GaxiosResponse } from 'gaxios'; 7 | import { DlVars } from '../dl_model/detail'; 8 | 9 | 10 | export function uploadFileOrFolder(dlDetails: DlVars, filePath: string, mime: string, parent: string, size: number, callback: (err: string, id: string) => void): void { 11 | driveAuth.call((err, auth) => { 12 | if (err) { 13 | callback(err, null); 14 | return; 15 | } 16 | const drive = google.drive({ version: 'v3', auth }); 17 | 18 | if (mime === 'application/vnd.google-apps.folder' || size === 0) { 19 | createFolderOrEmpty(drive, filePath, parent, mime, callback); 20 | } else { 21 | driveFile.uploadGoogleDriveFile(dlDetails, parent, { 22 | filePath: filePath, 23 | mimeType: mime 24 | }) 25 | .then(id => callback(null, id)) 26 | .catch(err => callback(err.message, null)); 27 | } 28 | }); 29 | } 30 | 31 | function createFolderOrEmpty(drive: drive_v3.Drive, filePath: string, parent: string, mime: string, 32 | callback: (err: string, id: string) => void): void { 33 | drive.files.create({ 34 | // @ts-ignore Unknown property error 35 | fields: 'id', 36 | supportsAllDrives: true, 37 | requestBody: { 38 | mimeType: mime, 39 | name: filePath.substring(filePath.lastIndexOf('/') + 1), 40 | parents: [parent] 41 | } 42 | }, 43 | (err: Error, res: any) => { 44 | if (err) { 45 | callback(err.message, null); 46 | } else { 47 | callback(null, res.data.id); 48 | } 49 | }); 50 | } 51 | 52 | export function getSharableLink(fileId: string, isFolder: boolean, 53 | callback: (err: string, url: string, isFolder: boolean) => void): void { 54 | 55 | if (!constants.IS_TEAM_DRIVE || (constants.IS_TEAM_DRIVE && !isFolder)) { 56 | driveAuth.call((err, auth) => { 57 | if (err) { 58 | callback(err, null, false); 59 | return; 60 | } 61 | const drive = google.drive({ version: 'v3', auth }); 62 | createPermissions(drive, fileId) 63 | .then(() => { 64 | callback(null, utils.getFileLink(fileId, isFolder), isFolder); 65 | }) 66 | .catch(err => { 67 | callback(err.message, null, false); 68 | }); 69 | }); 70 | } else { 71 | callback(null, utils.getFileLink(fileId, isFolder), isFolder); 72 | } 73 | } 74 | 75 | async function createPermissions(drive: drive_v3.Drive, fileId: string): Promise { 76 | if (constants.DRIVE_FILE_PRIVATE && constants.DRIVE_FILE_PRIVATE.ENABLED) { 77 | var req: GaxiosResponse[] = []; 78 | 79 | for (var email of constants.DRIVE_FILE_PRIVATE.EMAILS) { 80 | var perm = await drive.permissions.create({ 81 | fileId: fileId, 82 | supportsAllDrives: true, 83 | requestBody: { 84 | role: 'reader', 85 | type: 'user', 86 | emailAddress: email 87 | } 88 | }); 89 | req.push(perm); 90 | } 91 | return Promise.all(req); 92 | } else { 93 | return drive.permissions.create({ 94 | fileId: fileId, 95 | supportsAllDrives: true, 96 | requestBody: { 97 | role: 'reader', 98 | type: 'anyone' 99 | } 100 | }); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/drive/drive-utils.ts: -------------------------------------------------------------------------------- 1 | export function getFileLink(fileId: string, isFolder: boolean): string { 2 | if (isFolder) { 3 | return 'https://drive.google.com/drive/folders/' + fileId; 4 | } else { 5 | return 'https://drive.google.com/uc?id=' + fileId + '&export=download'; 6 | } 7 | } 8 | 9 | export function getPublicUrlRequestHeaders(size: number, mimeType: string, token: string, fileName: string, parent: string): any { 10 | return { 11 | method: 'POST', 12 | url: 'https://www.googleapis.com/upload/drive/v3/files', 13 | qs: { 14 | uploadType: 'resumable', 15 | supportsAllDrives: true 16 | }, 17 | headers: 18 | { 19 | 'Postman-Token': '1d58fdd0-0408-45fa-a45d-fc703bff724a', 20 | 'Cache-Control': 'no-cache', 21 | 'X-Upload-Content-Length': size, 22 | 'X-Upload-Content-Type': mimeType, 23 | 'Content-Type': 'application/json', 24 | 'Authorization': `Bearer ${token}` 25 | }, 26 | body: { 27 | name: fileName, 28 | mimeType: mimeType, 29 | parents: [parent] 30 | }, 31 | json: true 32 | }; 33 | } -------------------------------------------------------------------------------- /src/drive/tar.ts: -------------------------------------------------------------------------------- 1 | import tar = require('tar'); 2 | import fs = require('fs'); 3 | 4 | export function archive(srcPath: string, destName: string, callback: (err: string, size: number) => void): void { 5 | var dlDirPath = srcPath.substring(0, srcPath.lastIndexOf('/')); 6 | var writeStream = fs.createWriteStream(`${dlDirPath}/${destName}`); 7 | var targetDirName = `${srcPath.substring(srcPath.lastIndexOf('/') + 1)}`; 8 | var size = 0; 9 | writeStream.on('close', () => callback(null, size)); 10 | writeStream.on('error', (err: Error) => callback(err.message, size)); 11 | 12 | var stream = tar.c( 13 | { 14 | // @ts-ignore Unknown property error 15 | maxReadSize: 163840, 16 | jobs: 1, 17 | cwd: dlDirPath 18 | }, 19 | [targetDirName] 20 | ); 21 | 22 | stream.on('error', (err: Error) => callback(err.message, size)); 23 | stream.on('data', (chunk:any) => { 24 | size += chunk.length; 25 | }); 26 | 27 | stream.pipe(writeStream); 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/drive/upload-file.ts: -------------------------------------------------------------------------------- 1 | /* Copyright seedceo */ 2 | 3 | const parseRange = require('http-range-parse'); 4 | import request = require('request'); 5 | import fs = require('fs'); 6 | import driveAuth = require('./drive-auth'); 7 | import driveUtils = require('./drive-utils'); 8 | import { DlVars } from '../dl_model/detail'; 9 | 10 | interface Chunk { 11 | bstart: number; 12 | bend: number; 13 | cr: string; 14 | clen: number; 15 | stime: number; 16 | } 17 | 18 | /** 19 | * Divide the file to multi path for upload 20 | * @returns {array} array of chunk info 21 | */ 22 | function getChunks(filePath: string, start: number): Chunk[] { 23 | var allsize = fs.statSync(filePath).size; 24 | var sep = allsize < (20 * 1024 * 1024) ? allsize : (20 * 1024 * 1024) - 1; 25 | var ar = []; 26 | for (var i = start; i < allsize; i += sep) { 27 | var bstart = i; 28 | var bend = i + sep - 1 < allsize ? i + sep - 1 : allsize - 1; 29 | var cr = 'bytes ' + bstart + '-' + bend + '/' + allsize; 30 | var clen = bend != allsize - 1 ? sep : allsize - i; 31 | var stime = allsize < (20 * 1024 * 1024) ? 5000 : 10000; 32 | ar.push({ 33 | bstart: bstart, 34 | bend: bend, 35 | cr: cr, 36 | clen: clen, 37 | stime: stime 38 | }); 39 | } 40 | return ar; 41 | } 42 | 43 | /** 44 | * Upload one chunk to the server 45 | * @returns {string} file id if any 46 | */ 47 | function uploadChunk(filePath: string, chunk: Chunk, mimeType: string, uploadUrl: string): Promise { 48 | return new Promise((resolve, reject) => { 49 | request.put({ 50 | url: uploadUrl, 51 | headers: { 52 | 'Content-Length': chunk.clen, 53 | 'Content-Range': chunk.cr, 54 | 'Content-Type': mimeType 55 | }, 56 | body: fs.createReadStream(filePath, { 57 | encoding: null, 58 | start: chunk.bstart, 59 | end: chunk.bend + 1 60 | }) 61 | }, function (error, response, body) { 62 | if (error) { 63 | console.log(`Upload chunk failed, Error from request module: ${error.message}`); 64 | return reject(error); 65 | } 66 | 67 | let headers = response.headers; 68 | if (headers && headers.range) { 69 | let range: any = parseRange(headers.range); 70 | if (range && range.last != chunk.bend) { 71 | // range is diff, need to return to recreate chunks 72 | return resolve(range); 73 | } 74 | } 75 | 76 | if (!body) { 77 | console.log(`Upload chunk return empty body.`); 78 | return resolve(null); 79 | } 80 | 81 | try { 82 | body = JSON.parse(body); 83 | } catch (e) { 84 | // TODO: So far `body` has been 1 liners here. If large `body` is noticed, change this 85 | // to dump `body` to a file instead. 86 | console.log(body); 87 | return resolve(null); 88 | } 89 | if (body && body.id) { 90 | return resolve(body.id); 91 | } else { 92 | console.log(`Got file id null`); 93 | // Yes, I know this should be a reject, but meh, why bother changing what works 94 | return resolve(null); 95 | } 96 | }); 97 | }); 98 | } 99 | 100 | export function uploadGoogleDriveFile(dlDetails: DlVars, parent: string, file: { filePath: string; mimeType: string }): Promise { 101 | var fileName = file.filePath.substring(file.filePath.lastIndexOf('/') + 1); 102 | return new Promise((resolve, reject) => { 103 | var size = fs.statSync(file.filePath).size; 104 | driveAuth.call((err, auth) => { 105 | if (err) { 106 | return reject(new Error('Failed to get OAuth client')); 107 | } 108 | auth.getAccessToken().then(tokenResponse => { 109 | var token = tokenResponse.token; 110 | var options = driveUtils.getPublicUrlRequestHeaders(size, file.mimeType, token, fileName, parent); 111 | 112 | request(options, async function (error: Error, response: request.Response) { 113 | if (error) { 114 | return reject(error); 115 | } 116 | 117 | if (!response) { 118 | return reject(new Error(`Get drive resumable url return undefined headers`)); 119 | } 120 | 121 | if (!response.headers || !response.headers.location || response.headers.location.length <= 0) { 122 | return reject(new Error(`Get drive resumable url return invalid headers: ${JSON.stringify(response.headers, null, 2)}`)); 123 | } 124 | 125 | let chunks = getChunks(file.filePath, 0); 126 | let fileId = null; 127 | try { 128 | let i = 0; 129 | let lastUploadedBytes = 0; 130 | while (i < chunks.length) { 131 | // last chunk will return the file id 132 | fileId = await uploadChunk(file.filePath, chunks[i], file.mimeType, response.headers.location); 133 | if ((typeof fileId === 'object') && (fileId !== null)) { 134 | chunks = getChunks(file.filePath, fileId.last); 135 | i = 0; 136 | dlDetails.uploadedBytes = dlDetails.uploadedBytes - lastUploadedBytes + fileId.last; 137 | lastUploadedBytes = fileId.last; 138 | } else { 139 | dlDetails.uploadedBytes = dlDetails.uploadedBytes - lastUploadedBytes + chunks[i].bend; 140 | lastUploadedBytes = chunks[i].bend; 141 | i++; 142 | } 143 | } 144 | 145 | if (fileId && fileId.length > 0) { 146 | return resolve(fileId); 147 | } else { 148 | return reject(new Error('Uploaded and got invalid id for file ' + fileName)); 149 | } 150 | } catch (er) { 151 | console.log(`Uploading chunks for file ${fileName} failed: ${er.message}`); 152 | return reject(er); 153 | } 154 | }); 155 | }).catch(err => { 156 | console.log('Sending request to get resumable url: ' + err.message); 157 | return reject(err); 158 | }); 159 | }); 160 | }); 161 | } 162 | -------------------------------------------------------------------------------- /src/fs-walk.ts: -------------------------------------------------------------------------------- 1 | import fs = require('fs'); 2 | import mime = require('mime-types'); 3 | import gdrive = require('./drive/drive-upload'); 4 | import { DlVars } from './dl_model/detail'; 5 | 6 | /** 7 | * Recursively uploads a directory or a file to Google Drive. Also makes this upload 8 | * visible to everyone on Drive, then calls a callback with the public link to this upload. 9 | * @param {string} path The path of the file or directory to upload 10 | * @param {string} parent The ID of the Drive folder to upload into 11 | * @param {function} callback A function to call with an error or the public Drive link 12 | */ 13 | export function uploadRecursive(dlDetails: DlVars, path: string, parent: string, callback: (err: string, url: string, isFolder: boolean) => void): void { 14 | fs.stat(path, (err, stat) => { 15 | if (err) { 16 | callback(err.message, null, false); 17 | return; 18 | } 19 | if (stat.isDirectory()) { 20 | gdrive.uploadFileOrFolder(dlDetails, path, 'application/vnd.google-apps.folder', parent, 0, 21 | (err, fileId) => { 22 | if (err) { 23 | callback(err, null, false); 24 | } else { 25 | walkSubPath(dlDetails, path, fileId, (err) => { 26 | if (err) { 27 | callback(err, null, false); 28 | } else { 29 | gdrive.getSharableLink(fileId, true, callback); 30 | } 31 | }); 32 | } 33 | }); 34 | } else { 35 | processFileOrDir(dlDetails, path, parent, (err: string, fileId: string) => { 36 | if (err) { 37 | callback(err, null, false); 38 | } else { 39 | gdrive.getSharableLink(fileId, false, callback); 40 | } 41 | }); 42 | } 43 | }); 44 | } 45 | 46 | function walkSubPath(dlDetails: DlVars, path: string, parent: string, callback: (err: string) => void): void { 47 | fs.readdir(path, (err, files) => { 48 | if (err) { 49 | callback(err.message); 50 | } else { 51 | walkSingleDir(dlDetails, path, files, parent, callback); 52 | } 53 | }); 54 | } 55 | 56 | function walkSingleDir(dlDetails: DlVars, path: string, files: string[], parent: string, callback: (err: string) => void): void { 57 | if (files.length === 0) { 58 | callback(null); 59 | return; 60 | } 61 | 62 | var uploadNext = function (position: number): void { 63 | processFileOrDir(dlDetails, path + '/' + files[position], parent, (err: string) => { 64 | if (err) { 65 | callback(err); 66 | } else { 67 | if (++position < files.length) { 68 | uploadNext(position); 69 | } else { 70 | callback(null); 71 | } 72 | } 73 | }); 74 | }; 75 | uploadNext(0); 76 | } 77 | 78 | function processFileOrDir(dlDetails: DlVars, path: string, parent: string, callback: (err: string, fileId?: string) => void): void { 79 | fs.stat(path, (err, stat) => { 80 | if (err) { 81 | callback(err.message); 82 | return; 83 | } 84 | if (stat.isDirectory()) { 85 | // path is a directory. Do not call the callback until the path has been completely traversed. 86 | gdrive.uploadFileOrFolder(dlDetails, path, 'application/vnd.google-apps.folder', parent, 0, (err: string, fileId: string) => { 87 | if (err) { 88 | callback(err); 89 | } else { 90 | walkSubPath(dlDetails, path, fileId, callback); 91 | } 92 | }); 93 | } else { 94 | var mimeType = mime.lookup(path); 95 | if (!mimeType) { 96 | mimeType = 'application/octet-stream'; 97 | } 98 | gdrive.uploadFileOrFolder(dlDetails, path, mimeType, parent, stat.size, (err: string, fileId: string) => { 99 | if (err) { 100 | callback(err); 101 | } else { 102 | callback(null, fileId); 103 | } 104 | }); 105 | } 106 | }); 107 | } 108 | 109 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot = require('node-telegram-bot-api'); 2 | import uuid = require('uuid/v4'); 3 | import downloadUtils = require('./download_tools/utils'); 4 | import ariaTools = require('./download_tools/aria-tools.js'); 5 | import constants = require('./.constants.js'); 6 | import msgTools = require('./bot_utils/msg-tools.js'); 7 | import dlm = require('./dl_model/dl-manager'); 8 | import driveList = require('./drive/drive-list.js'); 9 | import driveUtils = require('./drive/drive-utils.js'); 10 | import details = require('./dl_model/detail'); 11 | import filenameUtils = require('./download_tools/filename-utils'); 12 | import { EventRegex } from './bot_utils/event_regex'; 13 | import { exec } from 'child_process'; 14 | 15 | const eventRegex = new EventRegex(); 16 | const bot = new TelegramBot(constants.TOKEN, { polling: true }); 17 | var websocketOpened = false; 18 | var statusInterval: NodeJS.Timeout; 19 | var dlManager = dlm.DlManager.getInstance(); 20 | 21 | initAria2(); 22 | 23 | bot.on("polling_error", msg => console.error(msg.message)); 24 | 25 | function setEventCallback(regexp: RegExp, regexpNoName: RegExp, 26 | callback: ((msg: TelegramBot.Message, match?: RegExpExecArray) => void)): void { 27 | bot.onText(regexpNoName, (msg, match) => { 28 | // Return if the command didn't have the bot name for non PMs ("Bot name" could be blank depending on config) 29 | if (msg.chat.type !== 'private' && !match[0].match(regexp)) 30 | return; 31 | callback(msg, match); 32 | }); 33 | } 34 | 35 | setEventCallback(eventRegex.commandsRegex.start, eventRegex.commandsRegexNoName.start, (msg) => { 36 | if (msgTools.isAuthorized(msg) < 0) { 37 | msgTools.sendUnauthorizedMessage(bot, msg); 38 | } else { 39 | msgTools.sendMessage(bot, msg, 'You should know the commands already. Happy mirroring.', -1); 40 | } 41 | }); 42 | 43 | setEventCallback(eventRegex.commandsRegex.mirrorTar, eventRegex.commandsRegexNoName.mirrorTar, (msg, match) => { 44 | if (msgTools.isAuthorized(msg) < 0) { 45 | msgTools.sendUnauthorizedMessage(bot, msg); 46 | } else { 47 | mirror(msg, match, true); 48 | } 49 | }); 50 | 51 | setEventCallback(eventRegex.commandsRegex.mirror, eventRegex.commandsRegexNoName.mirror, (msg, match) => { 52 | if (msgTools.isAuthorized(msg) < 0) { 53 | msgTools.sendUnauthorizedMessage(bot, msg); 54 | } else { 55 | mirror(msg, match); 56 | } 57 | }); 58 | 59 | setEventCallback(eventRegex.commandsRegex.disk, eventRegex.commandsRegexNoName.disk, (msg) => { 60 | if (msgTools.isAuthorized(msg) < 0) { 61 | msgTools.sendUnauthorizedMessage(bot, msg); 62 | } else { 63 | exec(`df --output="size,used,avail" -h "${constants.ARIA_DOWNLOAD_LOCATION_ROOT}" | tail -n1`, 64 | (err, res) => { 65 | var disk = res.trim().split(/\s+/); 66 | msgTools.sendMessage(bot, msg, `Total space: ${disk[0]}B\nUsed: ${disk[1]}B\nAvailable: ${disk[2]}B`); 67 | } 68 | ); 69 | } 70 | }); 71 | 72 | /** 73 | * Start a new download operation. Make sure that this is triggered by an 74 | * authorized user, because this function itself does not check for that. 75 | * @param {Object} msg The Message that triggered the download 76 | * @param {Array} match Message matches 77 | * @param {boolean} isTar Decides if this download should be archived before upload 78 | */ 79 | function mirror(msg: TelegramBot.Message, match: RegExpExecArray, isTar?: boolean): void { 80 | if (websocketOpened) { 81 | if (downloadUtils.isDownloadAllowed(match[2])) { 82 | prepDownload(msg, match[2], isTar); 83 | } else { 84 | msgTools.sendMessage(bot, msg, `Download failed. Blacklisted URL.`); 85 | } 86 | } else { 87 | msgTools.sendMessage(bot, msg, `Websocket isn't open. Can't download`); 88 | } 89 | } 90 | 91 | setEventCallback(eventRegex.commandsRegex.mirrorStatus, eventRegex.commandsRegexNoName.mirrorStatus, (msg) => { 92 | if (msgTools.isAuthorized(msg) < 0) { 93 | msgTools.sendUnauthorizedMessage(bot, msg); 94 | } else { 95 | sendStatusMessage(msg); 96 | } 97 | }); 98 | 99 | setEventCallback(eventRegex.commandsRegex.list, eventRegex.commandsRegexNoName.list, (msg, match) => { 100 | if (msgTools.isAuthorized(msg) < 0) { 101 | msgTools.sendUnauthorizedMessage(bot, msg); 102 | } else { 103 | driveList.listFiles(match[2], (err, res) => { 104 | if (err) { 105 | msgTools.sendMessage(bot, msg, 'Failed to fetch the list of files'); 106 | } else { 107 | msgTools.sendMessage(bot, msg, res, 60000); 108 | } 109 | }); 110 | } 111 | }); 112 | 113 | setEventCallback(eventRegex.commandsRegex.getFolder, eventRegex.commandsRegexNoName.getFolder, (msg) => { 114 | if (msgTools.isAuthorized(msg) < 0) { 115 | msgTools.sendUnauthorizedMessage(bot, msg); 116 | } else { 117 | msgTools.sendMessage(bot, msg, 118 | 'Drive mirror folder', 119 | 60000); 120 | } 121 | }); 122 | 123 | setEventCallback(eventRegex.commandsRegex.cancelMirror, eventRegex.commandsRegexNoName.cancelMirror, (msg) => { 124 | var authorizedCode = msgTools.isAuthorized(msg); 125 | if (msg.reply_to_message) { 126 | var dlDetails = dlManager.getDownloadByMsgId(msg.reply_to_message); 127 | if (dlDetails) { 128 | if (authorizedCode > -1 && authorizedCode < 3) { 129 | cancelMirror(dlDetails, msg); 130 | } else if (authorizedCode === 3) { 131 | msgTools.isAdmin(bot, msg, (e, res) => { 132 | if (res) { 133 | cancelMirror(dlDetails, msg); 134 | } else { 135 | msgTools.sendMessage(bot, msg, 'You do not have permission to do that.'); 136 | } 137 | }); 138 | } else { 139 | msgTools.sendUnauthorizedMessage(bot, msg); 140 | } 141 | } else { 142 | msgTools.sendMessage(bot, msg, `Reply to the command message for the download that you want to cancel.` + 143 | ` Also make sure that the download is even active.`); 144 | } 145 | } else { 146 | msgTools.sendMessage(bot, msg, `Reply to the command message for the download that you want to cancel.`); 147 | } 148 | }); 149 | 150 | setEventCallback(eventRegex.commandsRegex.cancelAll, eventRegex.commandsRegexNoName.cancelAll, (msg) => { 151 | var authorizedCode = msgTools.isAuthorized(msg, true); 152 | if (authorizedCode === 0) { 153 | // One of SUDO_USERS. Cancel all downloads 154 | dlManager.forEachDownload(dlDetails => { 155 | dlManager.addCancelled(dlDetails); 156 | }); 157 | cancelMultipleMirrors(msg); 158 | 159 | } else if (authorizedCode === 2) { 160 | // Chat admin, but not sudo. Cancel all downloads only from that chat. 161 | dlManager.forEachDownload(dlDetails => { 162 | if (msg.chat.id === dlDetails.tgChatId) { 163 | dlManager.addCancelled(dlDetails); 164 | } 165 | }); 166 | cancelMultipleMirrors(msg); 167 | 168 | } else if (authorizedCode === 3) { 169 | msgTools.isAdmin(bot, msg, (e, res) => { 170 | if (res) { 171 | dlManager.forEachDownload(dlDetails => { 172 | if (msg.chat.id === dlDetails.tgChatId) { 173 | dlManager.addCancelled(dlDetails); 174 | } 175 | }); 176 | cancelMultipleMirrors(msg); 177 | } else { 178 | msgTools.sendMessage(bot, msg, 'You do not have permission to do that.'); 179 | } 180 | }); 181 | } else { 182 | msgTools.sendUnauthorizedMessage(bot, msg); 183 | } 184 | }); 185 | 186 | function cancelMultipleMirrors(msg: TelegramBot.Message): void { 187 | var count = 0; 188 | dlManager.forEachCancelledDl(dl => { 189 | if (cancelMirror(dl)) { 190 | count++; 191 | } 192 | }); 193 | 194 | if (count > 0) { 195 | msgTools.sendMessage(bot, msg, `${count} downloads cancelled.`, -1); 196 | sendCancelledMessages(); 197 | } else { 198 | msgTools.sendMessage(bot, msg, 'No downloads to cancel'); 199 | } 200 | } 201 | 202 | function sendCancelledMessages(): void { 203 | dlManager.forEachCancelledChat((usernames, tgChat) => { 204 | var message = usernames.reduce((prev, cur, i) => (i > 0) ? `${prev}${cur}, ` : `${cur}, `, 205 | usernames[0]); 206 | message += 'your downloads have been manually cancelled.'; 207 | bot.sendMessage(tgChat, message, { parse_mode: 'HTML' }) 208 | .then(() => { 209 | dlManager.removeCancelledMessage(tgChat); 210 | }) 211 | .catch((err) => { 212 | dlManager.removeCancelledMessage(tgChat); 213 | console.error(`sendMessage error: ${err.message}`); 214 | }); 215 | }); 216 | } 217 | 218 | function cancelMirror(dlDetails: details.DlVars, cancelMsg?: TelegramBot.Message): boolean { 219 | if (dlDetails.isUploading) { 220 | if (cancelMsg) { 221 | msgTools.sendMessage(bot, cancelMsg, 'Upload in progress. Cannot cancel.'); 222 | } 223 | return false; 224 | } else { 225 | ariaTools.stopDownload(dlDetails.gid, () => { 226 | // Not sending a message here, because a cancel will fire 227 | // the onDownloadStop notification, which will notify the 228 | // person who started the download 229 | 230 | if (cancelMsg && dlDetails.tgChatId !== cancelMsg.chat.id) { 231 | // Notify if this is not the chat the download started in 232 | msgTools.sendMessage(bot, cancelMsg, 'The download was canceled.'); 233 | } 234 | if (!dlDetails.isDownloading) { 235 | // onDownloadStopped does not fire for downloads that haven't started yet 236 | // So calling this here 237 | ariaOnDownloadStop(dlDetails.gid, 1); 238 | } 239 | }); 240 | return true; 241 | } 242 | } 243 | 244 | /** 245 | * Cancels the download if its filename contains a string from 246 | * constants.ARIA_FILTERED_FILENAMES. Call this on every status message update, 247 | * because the file name might not become visible for the first few status 248 | * updates, for example, in case of BitTorrents. 249 | * 250 | * @param {String} filename The name of the downloaded file/top level directory 251 | * @returns {boolean} False if file name is disallowed, true otherwise, 252 | * or if undetermined 253 | */ 254 | function handleDisallowedFilename(dlDetails: details.DlVars, filename: string): boolean { 255 | if (dlDetails) { 256 | if (dlDetails.isDownloadAllowed === 0) return false; 257 | if (dlDetails.isDownloadAllowed === 1) return true; 258 | if (!filename) return true; 259 | 260 | var isAllowed = filenameUtils.isFilenameAllowed(filename); 261 | if (isAllowed === 0) { 262 | dlDetails.isDownloadAllowed = 0; 263 | if (!dlDetails.isUploading) { 264 | cancelMirror(dlDetails); 265 | } 266 | return false; 267 | } else if (isAllowed === 1) { 268 | dlDetails.isDownloadAllowed = 1; 269 | } 270 | } 271 | return true; 272 | } 273 | 274 | function prepDownload(msg: TelegramBot.Message, match: string, isTar: boolean): void { 275 | var dlDir = uuid(); 276 | ariaTools.addUri(match, dlDir, (err, gid) => { 277 | dlManager.addDownload(gid, dlDir, msg, isTar); 278 | if (err) { 279 | var message = `Failed to start the download. ${err.message}`; 280 | console.error(message); 281 | cleanupDownload(gid, message); 282 | } else { 283 | console.log(`gid: ${gid} download:${match}`); 284 | // Wait a second to give aria2 enough time to queue the download 285 | setTimeout(() => { 286 | dlManager.setStatusLock(msg, sendStatusMessage); 287 | }, 1000); 288 | } 289 | }); 290 | 291 | } 292 | 293 | /** 294 | * Sends a single status message for all active and queued downloads. 295 | */ 296 | function sendStatusMessage(msg: TelegramBot.Message, keepForever?: boolean): Promise { 297 | var lastStatus = dlManager.getStatus(msg.chat.id); 298 | 299 | if (lastStatus) { 300 | msgTools.deleteMsg(bot, lastStatus.msg); 301 | dlManager.deleteStatus(msg.chat.id); 302 | } 303 | 304 | return new Promise(resolve => { 305 | downloadUtils.getStatusMessage() 306 | .then(res => { 307 | if (keepForever) { 308 | msgTools.sendMessage(bot, msg, res.message, -1, message => { 309 | dlManager.addStatus(message, res.message); 310 | resolve(); 311 | }); 312 | } else { 313 | var ttl = 60000; 314 | msgTools.sendMessage(bot, msg, res.message, ttl, message => { 315 | dlManager.addStatus(message, res.message); 316 | setTimeout(() => { 317 | dlManager.deleteStatus(msg.chat.id); 318 | }, ttl); 319 | resolve(); 320 | }, true); 321 | } 322 | }) 323 | .catch(resolve); 324 | }); 325 | } 326 | 327 | /** 328 | * Updates all status messages 329 | */ 330 | function updateAllStatus(): void { 331 | downloadUtils.getStatusMessage() 332 | .then(res => { 333 | var staleStatusReply = 'ETELEGRAM: 400 Bad Request: message to edit not found'; 334 | 335 | if (res.singleStatuses) { 336 | res.singleStatuses.forEach(status => { 337 | if (status.dlDetails) { 338 | handleDisallowedFilename(status.dlDetails, status.filename); 339 | } 340 | }); 341 | } 342 | 343 | dlManager.forEachStatus(status => { 344 | // Do not update the status if the message remains the same. 345 | // Otherwise, the Telegram API starts complaining. 346 | if (res.message !== status.lastStatus) { 347 | msgTools.editMessage(bot, status.msg, res.message) 348 | .catch(err => { 349 | if (err.message === staleStatusReply) { 350 | dlManager.deleteStatus(status.msg.chat.id); 351 | } else { 352 | console.log(`updateAllStatus: Failed to edit message: ${err.message}`); 353 | } 354 | }); 355 | status.lastStatus = res.message; 356 | } 357 | }); 358 | 359 | if (res.totalDownloadCount === 0) { 360 | // No more active or queued downloads, let's stop the status refresh timer 361 | clearInterval(statusInterval); 362 | statusInterval = null; 363 | deleteAllStatus(); 364 | } 365 | }).catch(); 366 | } 367 | 368 | function deleteAllStatus(): void { 369 | dlManager.forEachStatus(statusMessage => { 370 | msgTools.deleteMsg(bot, statusMessage.msg, 10000); 371 | dlManager.deleteStatus(statusMessage.msg.chat.id); 372 | }); 373 | } 374 | 375 | /** 376 | * After a download is complete (failed or otherwise), call this to clean up. 377 | * @param gid The gid for the download that just finished 378 | * @param message The message to send as the Telegram download complete message 379 | * @param url The public Google Drive URL for the uploaded file 380 | */ 381 | function cleanupDownload(gid: string, message: string, url?: string, dlDetails?: details.DlVars): void { 382 | if (!dlDetails) { 383 | dlDetails = dlManager.getDownloadByGid(gid); 384 | } 385 | if (dlDetails) { 386 | var wasCancelAlled = false; 387 | dlManager.forEachCancelledDl(dlDetails => { 388 | if (dlDetails.gid === gid) { 389 | wasCancelAlled = true; 390 | } 391 | }); 392 | if (!wasCancelAlled) { 393 | // If the dl was stopped with a cancelAll command, a message has already been sent to the chat. 394 | // Do not send another one. 395 | if (dlDetails.tgRepliedUsername) { 396 | message += `\ncc: ${dlDetails.tgRepliedUsername}`; 397 | } 398 | msgTools.sendMessageReplyOriginal(bot, dlDetails, message) 399 | .catch((err) => { 400 | console.error(`cleanupDownload sendMessage error: ${err.message}`); 401 | }); 402 | } 403 | 404 | if (url) { 405 | msgTools.notifyExternal(dlDetails, true, gid, dlDetails.tgChatId, url); 406 | } else { 407 | msgTools.notifyExternal(dlDetails, false, gid, dlDetails.tgChatId); 408 | } 409 | dlManager.removeCancelledDls(gid); 410 | dlManager.deleteDownload(gid); 411 | updateAllStatus(); 412 | downloadUtils.deleteDownloadedFile(dlDetails.downloadDir); 413 | } else { 414 | // Why is this message so calm? We should be SCREAMING at this point! 415 | console.error(`cleanupDownload: Could not get dlDetails for ${gid}`); 416 | } 417 | } 418 | 419 | function ariaOnDownloadStart(gid: string, retry: number): void { 420 | var dlDetails = dlManager.getDownloadByGid(gid); 421 | if (dlDetails) { 422 | dlManager.moveDownloadToActive(dlDetails); 423 | console.log(`${gid}: Started. Dir: ${dlDetails.downloadDir}.`); 424 | updateAllStatus(); 425 | 426 | ariaTools.getStatus(dlDetails, (err, message, filename) => { 427 | if (!err) { 428 | handleDisallowedFilename(dlDetails, filename); 429 | } 430 | }); 431 | 432 | if (!statusInterval) { 433 | statusInterval = setInterval(updateAllStatus, 434 | constants.STATUS_UPDATE_INTERVAL_MS ? constants.STATUS_UPDATE_INTERVAL_MS : 12000); 435 | } 436 | } else if (retry <= 8) { 437 | // OnDownloadStart probably got called before prepDownload's startDownload callback. Fairly common. Retry. 438 | setTimeout(() => ariaOnDownloadStart(gid, retry + 1), 500); 439 | } else { 440 | console.error(`onDownloadStart: DlDetails still empty for ${gid}. Giving up.`); 441 | } 442 | } 443 | 444 | function ariaOnDownloadStop(gid: string, retry: number): void { 445 | var dlDetails = dlManager.getDownloadByGid(gid); 446 | if (dlDetails) { 447 | console.log(`${gid}: Stopped`); 448 | var message = 'Download stopped.'; 449 | if (dlDetails.isDownloadAllowed === 0) { 450 | message += ' Blacklisted file name.'; 451 | } 452 | cleanupDownload(gid, message); 453 | } else if (retry <= 8) { 454 | // OnDownloadStop probably got called before prepDownload's startDownload callback. Unlikely. Retry. 455 | setTimeout(() => ariaOnDownloadStop(gid, retry + 1), 500); 456 | } else { 457 | console.error(`onDownloadStop: DlDetails still empty for ${gid}. Giving up.`); 458 | } 459 | } 460 | 461 | function ariaOnDownloadComplete(gid: string, retry: number): void { 462 | var dlDetails = dlManager.getDownloadByGid(gid); 463 | if (dlDetails) { 464 | 465 | ariaTools.getAriaFilePath(gid, (err, file) => { 466 | if (err) { 467 | console.error(`onDownloadComplete: Error getting file path for ${gid}. ${err}`); 468 | var message = 'Upload failed. Could not get downloaded files.'; 469 | cleanupDownload(gid, message); 470 | return; 471 | } 472 | 473 | if (file) { 474 | ariaTools.getFileSize(gid, (err, size) => { 475 | if (err) { 476 | console.error(`onDownloadComplete: Error getting file size for ${gid}. ${err}`); 477 | var message = 'Upload failed. Could not get file size.'; 478 | cleanupDownload(gid, message); 479 | return; 480 | } 481 | 482 | var filename = filenameUtils.getFileNameFromPath(file, null); 483 | dlDetails.isUploading = true; 484 | if (handleDisallowedFilename(dlDetails, filename)) { 485 | console.log(`${gid}: Completed. Filename: ${filename}. Starting upload.`); 486 | ariaTools.uploadFile(dlDetails, file, size, driveUploadCompleteCallback); 487 | } else { 488 | var reason = 'Upload failed. Blacklisted file name.'; 489 | console.log(`${gid}: Blacklisted. Filename: ${filename}.`); 490 | cleanupDownload(gid, reason); 491 | } 492 | }); 493 | } else { 494 | ariaTools.isDownloadMetadata(gid, (err, isMetadata, newGid) => { 495 | if (err) { 496 | console.error(`${gid}: onDownloadComplete: Failed to check if it was a metadata download: ${err}`); 497 | var message = 'Upload failed. Could not check if the file is metadata.'; 498 | cleanupDownload(gid, message); 499 | } else if (isMetadata) { 500 | console.log(`${gid} Changed to ${newGid}`); 501 | dlManager.changeDownloadGid(gid, newGid); 502 | } else { 503 | console.error('onDownloadComplete: No files - not metadata.'); 504 | var reason = 'Upload failed. Could not get files.'; 505 | cleanupDownload(gid, reason); 506 | } 507 | }); 508 | } 509 | }); 510 | } else if (retry <= 8) { 511 | // OnDownloadComplete probably got called before prepDownload's startDownload callback. Highly unlikely. Retry. 512 | setTimeout(() => ariaOnDownloadComplete(gid, retry + 1), 500); 513 | } else { 514 | console.error(`${gid}: onDownloadComplete: DlDetails still empty. Giving up.`); 515 | } 516 | } 517 | 518 | function ariaOnDownloadError(gid: string, retry: number): void { 519 | var dlDetails = dlManager.getDownloadByGid(gid); 520 | if (dlDetails) { 521 | ariaTools.getError(gid, (err, res) => { 522 | var message: string; 523 | if (err) { 524 | message = 'Failed to download.'; 525 | console.error(`${gid}: failed. Failed to get the error message. ${err}`); 526 | } else { 527 | message = `Failed to download. ${res}`; 528 | console.error(`${gid}: failed. ${res}`); 529 | } 530 | cleanupDownload(gid, message, null, dlDetails); 531 | }); 532 | } else if (retry <= 8) { 533 | // OnDownloadError probably got called before prepDownload's startDownload callback, 534 | // or gid refers to a torrent files download, and onDownloadComplete for the torrent's 535 | // metadata hasn't been called yet. Fairly likely. Retry. 536 | setTimeout(() => ariaOnDownloadError(gid, retry + 1), 500); 537 | } else { 538 | console.error(`${gid}: onDownloadError: DlDetails still empty. Giving up.`); 539 | } 540 | } 541 | 542 | function initAria2(): void { 543 | ariaTools.openWebsocket((err) => { 544 | if (err) { 545 | console.error('A2C: Failed to open websocket. Run aria.sh first. Exiting.'); 546 | process.exit(); 547 | } else { 548 | websocketOpened = true; 549 | console.log('A2C: Websocket opened. Bot ready.'); 550 | } 551 | }); 552 | 553 | ariaTools.setOnDownloadStart(ariaOnDownloadStart); 554 | ariaTools.setOnDownloadStop(ariaOnDownloadStop); 555 | ariaTools.setOnDownloadComplete(ariaOnDownloadComplete); 556 | ariaTools.setOnDownloadError(ariaOnDownloadError); 557 | } 558 | 559 | 560 | function driveUploadCompleteCallback(err: string, gid: string, url: string, filePath: string, 561 | fileName: string, fileSize: number, isFolder: boolean): void { 562 | 563 | var finalMessage; 564 | if (err) { 565 | var message = err; 566 | console.error(`${gid}: Failed to upload - ${filePath}: ${message}`); 567 | finalMessage = `Failed to upload ${fileName} to Drive. ${message}`; 568 | cleanupDownload(gid, finalMessage); 569 | } else { 570 | console.log(`${gid}: Uploaded `); 571 | if (fileSize) { 572 | var fileSizeStr = downloadUtils.formatSize(fileSize); 573 | finalMessage = `${fileName} (${fileSizeStr})`; 574 | } else { 575 | finalMessage = `${fileName}`; 576 | } 577 | if (constants.IS_TEAM_DRIVE && isFolder) { 578 | finalMessage += '\n\nFolders in Shared Drives can only be shared with members of the drive. Mirror as an archive if you need public links.'; 579 | } 580 | cleanupDownload(gid, finalMessage, url); 581 | } 582 | } 583 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./out", 4 | "target": "es6", 5 | "module": "commonjs", 6 | "noImplicitAny": true, 7 | "allowJs": true, 8 | "sourceMap": false 9 | }, 10 | "include": [ 11 | "./src/**/*" 12 | ] 13 | } 14 | --------------------------------------------------------------------------------