commit af2b8758e4b519bd0d127260886cc31c155361ba Author: Dr.Caduceus Date: Sun Nov 5 22:02:58 2023 +0530 Initial commit. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ea692a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11 + +WORKDIR /app +COPY . /app + +RUN pip install -r requirements.txt + +CMD ["python", "-m", "bot"] \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..6d64a27 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: python -m bot \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd6fc19 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +

🌐File Stream Bot

+An open-source Python Telegram bot to transmit Telegram files over HTTP. + +Demo Bot +

+ +## **πŸ“‘ INDEX** + +* [**βš™οΈ Installation**](#installation) + * [Python & Git](#i-1) + * [Download](#i-2) + * [Requirements](#i-3) +* [**πŸ“ Variables**](#variables) +* [**πŸ•Ή Deployment**](#deployment) + * [Locally](#d-1) + * [Docker](#d-2) +* [**⛑️ Need help!**](#help) +* [**❀️ Credits & Thanks**](#credits) + + + +## βš™οΈ Installation + + + +**1.Install Python & Git:** + +For Windows: +``` +winget install Python.Python.3.11 +winget install Git.Git +``` +For Linux: +``` +sudo apt-get update && sudo apt-get install -y python3.11 git pip +``` +For macOS: +``` +brew install python@3.11 git +``` +For Termux: +``` +pkg install python -y +pkg install git -y +``` + + + +**2.Download repository:** +``` +git clone https://github.com/TheCaduceus/FileStreamBot.git +``` + +**3.Change Directory:** + +``` +cd FileStreamBot +``` + + + +**4.Install requirements:** + +``` +pip install -r requirements.txt +``` + + + +## πŸ“ Variables +**The variables provided below should either be completed within the [config.py](https://github.com/TheCaduceus/FileStreamBot/blob/main/bot/config.py) file or configured as environment variables.** +* `API_ID`|`TELEGRAM_API_ID`: API ID of your Telegram account, can be obtained from [My Telegram](https://my.telegram.org). `int` +* `API_HASH`|`TELEGRAM_API_HASH`: API hash of your Telegram account, can be obtained from [My Telegram](https://my.telegram.org). `str` +* `OWNER_ID`: ID of your Telegram account, can be obtained by sending **/info** to [@DrFileStreamBot](https://t.me/DrFileStreamBot). `int` +* `ALLOWED_USER_IDS`: A list of Telegram account IDs (separated by spaces) that are permitted to use the bot. Leave this field empty to allow anyone to use it. `str` +* `BOT_USERNAME`|`TELEGRAM_BOT_USERNAME`: Username of your Telegram bot, create one using [@BotFather](https://t.me/BotFather). `str` +* `BOT_TOKEN`|`TELEGRAM_BOT_TOKEN`: Telegram API token of your bot, can be obtained from [@BotFather](https://t.me/BotFather). `str` +* `CHANNEL_ID`|`TELEGRAM_CHANNEL_ID`: ID of the channel where bot will forward all files received from users, can be obtained by forwarding any message from channel to [@ShowJsonBot](https://t.me/ShowJsonBot) and then looking from `forward_from_chat` key. `int` +* `BOT_WORKERS`: Number of updates bot should process from Telegram at once, by default to 10 updates. `int` +* `SECRET_CODE_LENGTH`: Number of characters that file code should contain, by default to 12 characters. `int` +* `BASE_URL`: Base URL that bot should use while generating file links, can be FQDN and by default to `127.0.0.1`. `str` +* `BIND_ADDRESS`: Bind address for web server, by default to `0.0.0.0` to run on all possible addresses. `str` +* `PORT`: Port for web server to run on, by default to `8080`. `int` + +## πŸ•Ή Deployment + + + +**1.Running locally:** +``` +python -m bot +``` + + + +**2.Using Docker:** *(Recommended)* +* Build own Docker image: +``` +docker build -t file-stream-bot . +``` +* Run the Docker container: +``` +docker run -p 8080:8080 file-stream-bot +``` + + + +## ⛑️ Need help! +- Ask questions or doubts [here](https://t.me/DrDiscussion). + + + +## ❀️ Credits & Thanks + +[**Dr.Caduceus**](https://github.com/TheCaduceus): Owner & developer of Microsoft E5 Auto Renewal Tool.
\ No newline at end of file diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..da55106 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,19 @@ +from pyrogram import Client +from logging import getLogger +from logging.config import dictConfig +from .config import Telegram, LOGGER_CONFIG_JSON + +dictConfig(LOGGER_CONFIG_JSON) + +version = 1.0 +logger = getLogger('bot') + +TelegramBot = Client( + name="bot", + api_id = Telegram.API_ID, + api_hash = Telegram.API_HASH, + bot_token = Telegram.BOT_TOKEN, + plugins={"root": "bot/plugins"}, + workers = Telegram.BOT_WORKERS, + max_concurrent_transmissions=1000 +) \ No newline at end of file diff --git a/bot/__main__.py b/bot/__main__.py new file mode 100644 index 0000000..86c5c32 --- /dev/null +++ b/bot/__main__.py @@ -0,0 +1,7 @@ +from bot import TelegramBot, logger +from bot.server import server + +if __name__ == '__main__': + logger.info('Initializing...') + TelegramBot.loop.create_task(server.serve()) + TelegramBot.run() \ No newline at end of file diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..774ffa0 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,53 @@ +from os import environ as env + +class Telegram: + API_ID = env.get("TELEGRAM_API_ID", 1234) + API_HASH = env.get("TELEGRAM_API_HASH", "xyz") + OWNER_ID = int(env.get("OWNER_ID", 1234567890)) + ALLOWED_USER_IDS = env.get("ALLOWED_USER_IDS", "").split() + BOT_USERNAME = env.get("TELEGRAM_BOT_USERNAME", "BotFather") + BOT_TOKEN = env.get("TELEGRAM_BOT_TOKEN", "1234:abcd") + CHANNEL_ID = int(env.get("TELEGRAM_CHANNEL_ID", -1001234567890)) + BOT_WORKERS = int(env.get("BOT_WORKERS", 10)) + SECRET_CODE_LENGTH = int(env.get("SECRET_CODE_LENGTH", 12)) + +class Server: + BASE_URL = env.get("BASE_URL", "http://127.0.0.1:8080") + BIND_ADDRESS = env.get("BIND_ADDRESS", "0.0.0.0") + PORT = int(env.get("PORT", 8080)) + +# LOGGING CONFIGURATION +LOGGER_CONFIG_JSON = { + 'version': 1, + 'formatters': { + 'default': { + 'format': '[%(asctime)s][%(name)s][%(levelname)s] -> %(message)s', + 'datefmt': '%d/%m/%Y %H:%M:%S' + }, + }, + 'handlers': { + 'file_handler': { + 'class': 'logging.FileHandler', + 'filename': 'event-log.txt', + 'formatter': 'default' + }, + 'stream_handler': { + 'class': 'logging.StreamHandler', + 'formatter': 'default' + } + }, + 'loggers': { + 'uvicorn': { + 'level': 'INFO', + 'handlers': ['file_handler', 'stream_handler'] + }, + 'uvicorn.error': { + 'level': 'WARNING', + 'handlers': ['file_handler', 'stream_handler'] + }, + 'bot': { + 'level': 'INFO', + 'handlers': ['file_handler', 'stream_handler'] + } + } +} \ No newline at end of file diff --git a/bot/modules/decorators.py b/bot/modules/decorators.py new file mode 100644 index 0000000..0a7d17f --- /dev/null +++ b/bot/modules/decorators.py @@ -0,0 +1,16 @@ +from pyrogram import Client +from pyrogram.types import Message, CallbackQuery +from typing import Union, Callable +from functools import wraps +from bot.config import Telegram + +def verify_user(func: Callable): + + @wraps(func) + async def decorator(client: Client, update: Union[Message, CallbackQuery]): + user_id = str(update.from_user.id) + + if not Telegram.ALLOWED_USER_IDS or user_id in Telegram.ALLOWED_USER_IDS: + return await func(client, update) + + return decorator \ No newline at end of file diff --git a/bot/modules/static.py b/bot/modules/static.py new file mode 100644 index 0000000..f3347e6 --- /dev/null +++ b/bot/modules/static.py @@ -0,0 +1,48 @@ +WelcomeText = \ +""" +Hi **%(first_name)s**, send me a file or add me as an admin to any channel to instantly generate file links. + +Add me to your channel to instantly generate links for any downloadable media. Once received, I will automatically attach appropriate buttons to the post containing the URL. If you want me to ignore a given post, you can insert `#pass` in the post. + +- /start to get this message. +- /info to get user info. +- /log to get bot logs. (admin only!) +""" + +FileLinksText = \ +""" +**Download Link:** +`%(dl_link)s` +**Telegram File:** +`%(tg_link)s` +""" + +MediaLinksText = \ +""" +**Download Link:** +`%(dl_link)s` +**Stream Link:** +`%(stream_link)s` +**Telegram File:** +`%(tg_link)s` +""" + +InvalidQueryText = \ +""" +Query data mismatched. +""" + +MessageNotExist = \ +""" +File revoked or not exist. +""" + +LinkRevokedText = \ +""" +The link has been revoked. It may take some time for the changes to take effect. +""" + +InvalidPayloadText = \ +""" +Invalid payload. +""" \ No newline at end of file diff --git a/bot/modules/telegram.py b/bot/modules/telegram.py new file mode 100644 index 0000000..fe2506d --- /dev/null +++ b/bot/modules/telegram.py @@ -0,0 +1,56 @@ +from pyrogram.types import Message +from datetime import datetime +from mimetypes import guess_type +from bot import TelegramBot +from bot.config import Telegram +from bot.server.error import abort + + +async def get_message(message_id: int): + message = None + + try: + message = await TelegramBot.get_messages( + chat_id=Telegram.CHANNEL_ID, + message_ids=message_id + ) + if message.empty: message = None + except Exception: + pass + + return message + +async def get_file_properties(msg: Message): + attributes = ( + 'document', + 'video', + 'audio', + 'voice', + 'photo', + 'video_note' + ) + + for attribute in attributes: + media = getattr(msg, attribute, None) + if media: + file_type = attribute + break + + if not media: abort(400, 'Unknown file type.') + + file_name = getattr(media, 'file_name', None) + + if not file_name: + file_format = { + 'video': 'mp4', + 'audio': 'mp3', + 'voice': 'ogg', + 'photo': 'jpg', + 'video_note': 'mp4' + }.get(attribute) + date = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + file_name = f'{file_type}-{date}.{file_format}' + + mime_type = guess_type(file_name)[0] or 'application/octet-stream' + + return file_name, mime_type \ No newline at end of file diff --git a/bot/plugins/callback.py b/bot/plugins/callback.py new file mode 100644 index 0000000..007ec9b --- /dev/null +++ b/bot/plugins/callback.py @@ -0,0 +1,27 @@ +from pyrogram.types import CallbackQuery +from bot import TelegramBot +from bot.modules.static import * +from bot.modules.decorators import verify_user +from bot.modules.telegram import get_message + +@TelegramBot.on_callback_query() +@verify_user +async def manage_callback(bot, q: CallbackQuery): + query = q.data + if query.startswith('rm_'): + sq = query.split('_') + + if len(sq) != 3: + return await q.answer(InvalidQueryText, show_alert=True) + + message = await get_message(int(sq[1])) + + if not message: + return await q.answer(MessageNotExist, show_alert=True) + if sq[2] != message.caption: + return await q.answer(InvalidQueryText, show_alert=True) + + await message.delete() + await q.answer(LinkRevokedText, show_alert=True) + else: + await q.answer(InvalidQueryText, show_alert=True) \ No newline at end of file diff --git a/bot/plugins/commands.py b/bot/plugins/commands.py new file mode 100644 index 0000000..0eaf037 --- /dev/null +++ b/bot/plugins/commands.py @@ -0,0 +1,45 @@ +from pyrogram import filters +from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton +from aiofiles import open as async_open +from aiofiles.os import remove as async_rm +from bot import TelegramBot, logger +from bot.config import Telegram +from bot.modules.static import * +from .deeplinks import deeplinks +from bot.modules.decorators import verify_user + +@TelegramBot.on_message(filters.command('start') & filters.private) +@verify_user +async def start(_, msg: Message): + if len(msg.command) != 1: + return await deeplinks(msg, msg.command[1]) + + await msg.reply( + text=WelcomeText % {'first_name': msg.from_user.first_name}, + quote=True, + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton('Add to Channel', url=f'https://t.me/{Telegram.BOT_USERNAME}?startchannel&admin=post_messages+edit_messages+delete_messages') + ] + ] + ) + ) + +@TelegramBot.on_message(filters.command('info') & filters.private) +@verify_user +async def user_info(_, msg: Message): + await msg.reply(text=f'`{msg.from_user}`', quote=True) + + filename = f'{msg.from_user.id}.json' + async with async_open(filename, "w") as file: + await file.write(f'{msg.from_user}') + + await msg.reply_document(filename) + await async_rm(filename) + +@TelegramBot.on_message(filters.private & filters.command('log') & filters.user(Telegram.OWNER_ID)) +async def send_log(_, msg: Message): + await msg.reply_document('event-log.txt', quote=True) + +logger.info('Bot is now started!') \ No newline at end of file diff --git a/bot/plugins/deeplinks.py b/bot/plugins/deeplinks.py new file mode 100644 index 0000000..bdfd3e7 --- /dev/null +++ b/bot/plugins/deeplinks.py @@ -0,0 +1,21 @@ +from pyrogram.types import Message +from bot.modules.static import * +from bot.modules.telegram import get_message + +async def deeplinks(msg: Message, payload: str): + if payload.startswith('file_'): + sp = payload.split('_') + + if len(sp) != 3: + return await msg.reply(InvalidPayloadText, quote=True) + + message = await get_message(int(sp[1])) + + if not message: + return await msg.reply(MessageNotExist) + if sp[2] != message.caption: + return await msg.reply(InvalidPayloadText, quote=True) + + await message.copy(chat_id=msg.from_user.id, caption="") + else: + await msg.reply(InvalidPayloadText, quote=True) \ No newline at end of file diff --git a/bot/plugins/files.py b/bot/plugins/files.py new file mode 100644 index 0000000..6cb8aa4 --- /dev/null +++ b/bot/plugins/files.py @@ -0,0 +1,126 @@ +from pyrogram import filters, errors +from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton +from secrets import token_hex +from bot import TelegramBot +from bot.config import Telegram, Server +from bot.modules.decorators import verify_user +from bot.modules.static import * + +@TelegramBot.on_message( + filters.private + & ( + filters.document + | filters.video + | filters.video_note + | filters.audio + | filters.voice + | filters.photo + ) +) +@verify_user +async def handle_user_file(_, msg: Message): + secret_code = token_hex(Telegram.SECRET_CODE_LENGTH) + file = await msg.copy( + chat_id=Telegram.CHANNEL_ID, + caption=f'`{secret_code}`' + ) + file_id = file.id + + dl_link = f'{Server.BASE_URL}/dl/{file_id}?code={secret_code}' + tg_link = f'{Server.BASE_URL}/file/{file_id}?code={secret_code}' + deep_link = f'https://t.me/{Telegram.BOT_USERNAME}?start=file_{file_id}_{secret_code}' + + if (msg.document and 'video' in msg.document.mime_type) or msg.video: + stream_link = f'{Server.BASE_URL}/stream/{file_id}?code={secret_code}' + await msg.reply( + text=MediaLinksText % {'dl_link': dl_link, 'tg_link': tg_link, 'stream_link': stream_link}, + quote=True, + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton('Download', url=dl_link), + InlineKeyboardButton('Stream', url=stream_link) + ], + [ + InlineKeyboardButton('Get File', url=deep_link), + InlineKeyboardButton('Revoke', callback_data=f'rm_{file_id}_{secret_code}') + ] + ] + ) + ) + else: + await msg.reply( + text=FileLinksText % {'dl_link': dl_link, 'tg_link': tg_link}, + quote=True, + reply_markup=InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton('Download', url=dl_link), + InlineKeyboardButton('Get File', url=deep_link) + ], + [ + InlineKeyboardButton('Revoke', callback_data=f'rm_{file_id}_{secret_code}') + ] + ] + ) + ) + +@TelegramBot.on_message( + filters.channel + & ~filters.forwarded + & ~filters.media_group + & ( + filters.document + | filters.video + | filters.video_note + | filters.audio + | filters.voice + | filters.photo + ) +) +@verify_user +async def handle_channel_file(_, msg: Message): + if msg.caption and '#pass' in msg.caption: + return + + secret_code = token_hex(Telegram.SECRET_CODE_LENGTH) + + try: + file = await msg.copy( + chat_id=Telegram.CHANNEL_ID, + caption=f'`{secret_code}`' + ) + except (errors.ChatForwardsRestricted, errors.MessageIdInvalid, errors.ChannelPrivate): + return + + file_id = file.id + + dl_link = f'{Server.BASE_URL}/dl/{file_id}?code={secret_code}' + tg_link = f'{Server.BASE_URL}/file/{file_id}?code={secret_code}' + + if (msg.document and 'video' in msg.document.mime_type) or msg.video: + stream_link = f'{Server.BASE_URL}/stream/{file_id}?code={secret_code}' + await msg.edit_reply_markup( + InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton('Download', url=dl_link), + InlineKeyboardButton('Stream', url=stream_link) + ], + [ + InlineKeyboardButton('Get File', url=tg_link) + ] + ] + ) + ) + else: + await msg.edit_reply_markup( + InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton('Download', url=dl_link), + InlineKeyboardButton('Get File', url=tg_link) + ] + ] + ) + ) \ No newline at end of file diff --git a/bot/server/__init__.py b/bot/server/__init__.py new file mode 100644 index 0000000..f60047a --- /dev/null +++ b/bot/server/__init__.py @@ -0,0 +1,30 @@ +from quart import Quart +from uvicorn import Config, Server as UvicornServer +from logging import getLogger +from bot.config import Server, LOGGER_CONFIG_JSON + +from . import main, error + +logger = getLogger('uvicorn') +instance = Quart(__name__) + +@instance.before_serving +async def before_serve(): + logger.info('Web server is started!') + logger.info(f'Server running on {Server.BIND_ADDRESS}:{Server.PORT}') + +instance.register_blueprint(main.bp) + +instance.register_error_handler(400, error.invalid_request) +instance.register_error_handler(404, error.not_found) +instance.register_error_handler(405, error.invalid_method) +instance.register_error_handler(error.HTTPError, error.http_error) + +server = UvicornServer( + Config( + app = instance, + host = Server.BIND_ADDRESS, + port = Server.PORT, + log_config = LOGGER_CONFIG_JSON + ) +) \ No newline at end of file diff --git a/bot/server/error.py b/bot/server/error.py new file mode 100644 index 0000000..1462b6a --- /dev/null +++ b/bot/server/error.py @@ -0,0 +1,31 @@ +class HTTPError(Exception): + status_code:int = None + description:str = None + def __init__(self, status_code, description): + self.status_code = status_code + self.description = description + super().__init__(self.status_code, self.description) + +error_messages = { + 400: 'Invalid request.', + 401: 'File code is required to download the file.', + 403: 'Invalid file code.', + 404: 'File not found.', + 500: 'Internal server error.' +} + +async def invalid_request(_): + return 'Invalid request.', 400 + +async def not_found(_): + return 'Resource not found.', 404 + +async def invalid_method(_): + return 'Invalid request method.', 405 + +async def http_error(error: HTTPError): + error_message = error_messages.get(error.status_code) + return error.description or error_message, error.status_code + +def abort(status_code: int = 500, description: str = None): + raise HTTPError(status_code, description) \ No newline at end of file diff --git a/bot/server/main.py b/bot/server/main.py new file mode 100644 index 0000000..e715347 --- /dev/null +++ b/bot/server/main.py @@ -0,0 +1,41 @@ +from quart import Blueprint, Response, request, render_template, redirect +from .error import abort +from bot import version, TelegramBot +from bot.config import Telegram, Server +from bot.modules.telegram import get_message, get_file_properties + +bp = Blueprint('main', __name__) + +@bp.route('/') +async def home(): + return redirect(f'https://t.me/{Telegram.BOT_USERNAME}') + +@bp.route('/dl/') +async def transmit_file(file_id): + file = await get_message(int(file_id)) or abort(404) + code = request.args.get('code') or abort(401) + + if code != file.caption: + abort(403) + + file_name, mime_type = await get_file_properties(file) + headers = { + 'Content-Type': mime_type, + 'Content-Disposition': f'attachment; filename="{file_name}"' + } + + file_stream = TelegramBot.stream_media(file) + + return Response(file_stream, headers=headers) + +@bp.route('/stream/') +async def stream_file(file_id): + code = request.args.get('code') or abort(401) + + return await render_template('player.html', mediaLink=f'{Server.BASE_URL}/dl/{file_id}?code={code}') + +@bp.route('/file/') +async def file_deeplink(file_id): + code = request.args.get('code') or abort(401) + + return redirect(f'https://t.me/{Telegram.BOT_USERNAME}?start=file_{file_id}_{code}') \ No newline at end of file diff --git a/bot/server/templates/player.html b/bot/server/templates/player.html new file mode 100644 index 0000000..93bd1e3 --- /dev/null +++ b/bot/server/templates/player.html @@ -0,0 +1,155 @@ + + + + + Play Files + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ec82f95 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyrogram +tgcrypto +quart +uvicorn +aiofiles \ No newline at end of file diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..dfe813b --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.11.6 \ No newline at end of file