commit f6f900b4e2d64cb03c2bd19c2fe04b3020701ed1 Author: Alexander Date: Wed Aug 21 22:24:20 2024 -0400 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb3041f --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# affection-x-bot + +Creates posts on X from messages on Telegram. Supports images and video but limited to 512mb and 140 seconds. + +## Getting Started + +- Clone the repo +- Open the `app` folder +- Rename `sample.env` to `.env` +- Set your Telegram Admin ID. This is an integer value representing your main account. +- Enter your Telegram bot token from [@botfather](https://t.me/botfather) +- Enter your Telegram App ID and App Hash from [my.telegram.org](https://my.telegram.org) +- Enter your X username and access/consumer/client credentials from the [Developer Portal](https://developer.twitter.com/en/portal/dashboard) + +### Bare Metal + +- Type `pip install -r requirements.txt` to install dependencies +- Follow the instructions below to authenticate with X +- Run by typing `python main.py` + +### Docker + +- Run by typing `docker compose up -d` in the repo's root folder +- Enter the docker container by typing `docker exec -it affection-x-bot bash` +- Follow the instructions below to authenticate with X +- Press CTRL+D to exit the container + +### X +You will need both v1 and v2 API credentials from the [Developer Portal](https://developer.twitter.com/en/portal/dashboard) + +- Run `python login-to-x.py` to authorize your X account with the bot +- Copy the the URL and authorize your X account using a browser +- It will redirect you to a localhost URL. Copy/paste that URL back into the auth script and press enter. It will save your X credentials to the data folder + +### Telegram +This bot only works for supergroups (public). You will need a bot token from [@botfather](https://t.me/botfather) + a custom app created using [my.telegram.org](https://my.telegram.org) + +- Add the bot to your supergroup as admin \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..38179ec --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12 +RUN apt update +RUN apt upgrade -y +RUN apt install -y build-essential +WORKDIR /app +COPY . . +RUN pip install -r requirements.txt +CMD ["sh", "-c", "python main.py"] diff --git a/app/custom.py b/app/custom.py new file mode 100644 index 0000000..e06d444 --- /dev/null +++ b/app/custom.py @@ -0,0 +1,199 @@ +import datetime +import json +import logging +import os +import re +import time +from json import JSONDecodeError + +import tweepy +from dotenv import load_dotenv + +load_dotenv() + + +def get_name_from_user(user): + if user.username: + name = "@{}".format(user.username) + else: + name = [] + if user.first_name: + name.append(user.first_name) + if user.last_name: + name.append(user.last_name) + name = ' '.join(name) + return name if name else 'ser' + + +def validate_x_url(url): + _url = url.split("//") + if not _url[1].startswith("x.com/") and not _url[1].startswith("twitter.com/"): + return False + match = re.search(r"\.com/([a-zA-Z0-9_]+)/status/(.*)", url) + return match.group(1), match.group(2) + + +def load_x_v1_api(): + x_api_v1 = tweepy.OAuth1UserHandler( + os.getenv('X_CONSUMER_KEY'), + os.getenv('X_CONSUMER_SECRET'), + os.getenv('X_ACCESS_TOKEN'), + os.getenv('X_ACCESS_SECRET') + ) + return tweepy.API(x_api_v1) + + +def load_x_v2_api(): + try: + access_token = json.load(open("{}/creds.json".format(os.getenv('DATA_FOLDER')), "r")) + except (JSONDecodeError, FileNotFoundError): + raise Exception("Invalid X credentials") + else: + fields = ["token_type", "access_token", "scope", "refresh_token", "expires_in", "expires_at"] + if [f for f in fields if f in access_token] != fields: + raise Exception("Invalid X credentials") + else: + refresh_x_oauth2_token() + client = tweepy.Client(access_token=os.getenv('X_ACCESS_TOKEN'), access_token_secret=os.getenv('X_ACCESS_SECRET'), consumer_key=os.getenv('X_CONSUMER_KEY'), consumer_secret=os.getenv('X_CONSUMER_SECRET')) + return client + + +def load_x_v2_api_oauth2_handler(): + return tweepy.OAuth2UserHandler( + client_id=os.getenv('X_CLIENT_ID'), + client_secret=os.getenv('X_CLIENT_SECRET'), + redirect_uri="https://localhost/", + scope=["tweet.read", "tweet.write", "users.read", "offline.access"] + ) + + +async def create_tweet(content, media_ids=None): + client = load_x_v2_api() + post = client.create_tweet(text=content, media_ids=media_ids) + if post.data: + open("{}/created.txt".format(os.getenv('DATA_FOLDER')), "a+").write("{}\n".format(post.data['id'])) + return post.data + + +async def delete_tweet(post_id): + deleted_ids_file = "{}/deleted.txt".format(os.getenv('DATA_FOLDER')) + try: + with open(deleted_ids_file, 'r') as f: + for line in f.readlines(): + if post_id == line.strip(): + return False + except FileNotFoundError: + open(deleted_ids_file, "w").write('') + client = load_x_v2_api() + result = client.delete_tweet(post_id) + if result.data['deleted'] is True: + open(deleted_ids_file, "a").write("{}\n".format(post_id)) + return result + + +async def find_text_and_download_media(bot, message): + os.makedirs(media_folder := "{}/media".format(os.getenv('DATA_FOLDER')), exist_ok=True) + text = message.message + media_path, media_size = None, None + media_channel_id = message.peer_id.channel_id + # everything else + if hasattr(message.media, 'document'): + try: + # check video length + if message.document.attributes[0].duration > 140: + raise Exception("Video is too long") + except (AttributeError, IndexError): + pass + mime_type = message.document.mime_type.split('/') + media_id = message.media.document.id + media_path = "{}/{}.{}".format(media_folder, media_id, mime_type[1]) + media_size = message.media.document.size + # compressed pictures + elif hasattr(message.media, 'photo'): + media_id = message.media.photo.id + media_path = "{}/{}.{}".format(media_folder, media_id, 'jpg') + media_size = message.media.photo.size if hasattr(message.media.photo, 'size') else message.media.photo.sizes[-1].sizes[-1] + # check filesize + if media_size > 536870912: + raise Exception("File is too big") + # create a download progress file + os.makedirs(progress_folder := "{}/progress".format(os.getenv('DATA_FOLDER')), exist_ok=True) + media_progress_file = "{}/{}-{}.json".format(progress_folder, media_channel_id, message.id) + progress_data = {"last_update": time.time(), "percent": 0, "file_path": media_path, "cancelled": False, "id": None} + if media_path: + # get local media size + if os.path.exists(media_path): + _media_size = os.path.getsize(media_path) + else: + _media_size = 0 + # check if sizes match + if media_size > _media_size: + progress_message = await message.reply("Downloading from TG... 0%") + progress_data['id'] = progress_message.id + # deleting existing file + if os.path.exists(media_path): + os.remove(media_path) + open(media_progress_file, "w").write(json.dumps(progress_data)) + + # callback to update the download progress + async def progress_callback(current, total): + progress = json.loads(open(media_progress_file, "r").read()) + percent = round(current / total, 2) + if progress['cancelled'] is True: + await bot.edit_message(progress_message, "Downloading from TG... cancelled!!") + raise Exception('Cancelled') + if float(progress['last_update']) + 3 < time.time(): + try: + await bot.edit_message(progress_message, "Downloading from TG... {}%".format(round(percent * 100, 1))) + except Exception as e: + logging.error(e) + else: + progress['last_update'] = time.time() + progress['percent'] = percent + open(media_progress_file, "w").write(json.dumps(progress)) + + # download a new copy of the media + await bot.download_media(message, media_path, progress_callback=progress_callback) + # show it's completed + await bot.edit_message(progress_message, "Downloading from TG... 100%") + + open(media_progress_file, "w").write(json.dumps(progress_data)) + return text, media_path + + +async def upload_media(filename): + api = load_x_v1_api() + media = api.media_upload(filename) + if hasattr(media, 'image') or media.processing_info['state'] == 'succeeded': + return media.media_id + else: + return False + + +def refresh_x_oauth2_token(): + try: + access_token = json.load(open("{}/creds.json".format(os.getenv('DATA_FOLDER')), "r")) + except JSONDecodeError: + logging.debug("Failed to refresh access token") + return False + else: + if access_token["expires_at"] - 900 < time.time(): + x_api_v2 = load_x_v2_api_oauth2_handler() + access_token = x_api_v2.refresh_token("https://api.twitter.com/2/oauth2/token", refresh_token=access_token["refresh_token"]) + open("{}/creds.json".format(os.getenv('DATA_FOLDER')), "w").write(json.dumps(access_token, indent=4)) + logging.debug("Refreshed access token") + else: + logging.debug("Access token is still valid") + return access_token + + +def delete_old_media(): + days = int(os.getenv('DELETE_OLD_MEDIA_IN_DAYS')) + os.makedirs("{}/media".format(os.getenv('DATA_FOLDER')), exist_ok=True) + for file in os.listdir("{}/media".format(os.getenv('DATA_FOLDER'))): + file_path = os.path.join("{}/media".format(os.getenv('DATA_FOLDER'), file)) + if os.path.isfile(file_path): + mod_time = os.path.getmtime(file_path) + if (datetime.datetime.now() - datetime.datetime.fromtimestamp(mod_time)).days > days: + os.remove(file_path) + logging.debug("Deleted old media ({} days): {}".format(days, file)) diff --git a/app/login-to-x.py b/app/login-to-x.py new file mode 100644 index 0000000..ae46226 --- /dev/null +++ b/app/login-to-x.py @@ -0,0 +1,19 @@ +import json +import os +from dotenv import load_dotenv + +from custom import load_x_v2_api_oauth2_handler + + +def gen_x_oauth2_token(): + x_api_v2 = load_x_v2_api_oauth2_handler() + url = x_api_v2.get_authorization_url() + print("Authorization URL: {}".format(url)) + response_url = input("Enter response URL: ") + access_token = x_api_v2.fetch_token(response_url) + open("{}/creds.json".format(os.getenv('DATA_FOLDER')), "w").write(json.dumps(access_token, indent=4)) + print("Generated new access token") + + +load_dotenv('./app/.env') +gen_x_oauth2_token() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2332bcd --- /dev/null +++ b/app/main.py @@ -0,0 +1,259 @@ +import sys + +from apscheduler.schedulers.background import BackgroundScheduler +from telethon import TelegramClient +from telethon.sync import events +from telethon.tl.types import MessageReplyStoryHeader + +from custom import * + +log_file = '{}/app.log'.format(os.getenv('DATA_FOLDER')) +logging.basicConfig( + format='%(asctime)s %(name)s %(levelname)s %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO, + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler(sys.stdout) + ] +) +for package in (silence_packages := ( + 'apscheduler', + 'telethon' +)): + logging.getLogger(package).setLevel(logging.ERROR) +bot = TelegramClient('bot', int(os.getenv('TELEGRAM_APP_ID')), os.getenv('TELEGRAM_APP_HASH')).start(bot_token=os.getenv('TELEGRAM_BOT_TOKEN')) + + +@bot.on(events.NewMessage) +async def echo(event): + user_input = event.text + # skip if bot or no one sent a command + if (event.sender and event.sender.bot) or not user_input: + return + user_input = user_input.split(" ") + chat_id = event.chat_id + channel_id = event.message.peer_id.channel_id + + # get reply from the message + reply_to_message, reply_to_user = None, None + reply_user_id, reply_user_name, reply_message_id, reply_top_id = None, None, None, None + if event.is_reply: + # skip if replied to a story + if type(event.reply_to) is MessageReplyStoryHeader: + return + reply_to_message = await event.get_reply_message() + reply_to_user = await reply_to_message.get_sender() + reply_user_id = reply_to_user.id + reply_user_name = get_name_from_user(reply_to_user) + reply_message_id = reply_to_message.id + if event.reply_to.reply_to_top_id: + reply_message_id = event.reply_to.reply_to_top_id + + # get message + message_id = event.message.id + user = await event.message.get_sender() + user_id = user.id + user_name = get_name_from_user(user) + + # load perms file or create one + permissions_file = "{}/permissions.json".format(os.getenv('DATA_FOLDER')) + try: + permissions = json.load(open(permissions_file)) + except (JSONDecodeError, FileNotFoundError): + permissions = {"allowed": [], "disallowed": []} + open(permissions_file, "w").write(json.dumps(permissions, indent=4)) + + # check if user is allowed to tweet + if user_id not in permissions["allowed"] \ + and str(user_id) != os.getenv('TELEGRAM_ADMIN_ID'): + return + + # stage progress file to track the download process + media_progress_file = "{}/progress/{}-{}.json".format(os.getenv('DATA_FOLDER'), channel_id, reply_message_id) + + # allow/disallow users from reposting to x + if user_input[0] in ('/approve', '/disapprove',): + # check if user is admin + if str(user_id) != str(os.getenv('TELEGRAM_ADMIN_ID')): + return + # swap user id between lists + match user_input[0]: + case '/approve': + # add user to allow list + if reply_user_id not in permissions['allowed']: + permissions['allowed'].append(reply_user_id) + # remove user from disallow list + if reply_user_id in permissions['disallowed']: + permissions['disallowed'].remove(reply_user_id) + await event.reply("Approved {}".format(reply_user_name)) + case '/disapprove': + # add user to disallow list + if reply_user_id not in permissions['disallowed']: + permissions['disallowed'].append(reply_user_id) + # remove user from allow list + if reply_user_id in permissions['allowed']: + permissions['allowed'].remove(reply_user_id) + await event.reply("Disapproved {}".format(reply_user_name)) + # save users file + open(permissions_file, "w").write(json.dumps(permissions, indent=4)) + + # new tweet + elif user_input[0] in ('/tweet',): + # check if post is in progress + if os.path.exists(media_progress_file): + return await event.reply("This post is already in progress") + + # tweet replied to messages to x + if reply_to_message: + record_path = "{}/posts/forward_{}_{}.json".format(os.getenv('DATA_FOLDER'), chat_id, message_id) + # check if already posted + if os.path.exists(record_path): + return await event.reply("Already posted") + + # tweet any text and replies with the url + else: + user_name = get_name_from_user(event.message.from_user) + record_path = "{}/posts/tweet_{}_{}.json".format(os.getenv('DATA_FOLDER'), chat_id, message_id) + + # get text, switch to caption if media is attached + try: + text, media_path = await find_text_and_download_media(bot, reply_to_message or event.message) + except Exception as e: + if os.path.exists(media_progress_file): + os.remove(media_progress_file) + if "Cancelled" in str(e): + logging.debug("Cancelled media download") + return + logging.error(e) + if "File is too big" in str(e): + return await event.reply("Error: file is too big for X") + elif "Video is too long" in str(e): + return await event.reply("Error: video is too long for X") + else: + return await event.reply("Error: unknown TG download error") + else: + logging.info("{} ({}) Downloaded file: {}".format(user_name, user_id, media_path)) + media_progress = json.loads(open(media_progress_file, "r").read()) + if media_progress['id']: + await bot.edit_message(channel_id, media_progress['id'], "Uploading to X...") + else: + progress_message = await event.reply("Uploading to X...") + media_progress['id'] = progress_message.id + open(media_progress_file, "w").write(json.dumps(media_progress)) + + text = text.replace(user_input[0], '').strip() if text else '' + + # check if tweet is empty + if len(user_input) == 1 and not media_path and not text: + return await event.reply("Not enough parameters") + # upload media + try: + media_id = await upload_media(media_path) if media_path else None + except Exception as e: + logging.error(e) + if "maxFileSizeExceeded" in str(e): + return await event.reply("Error: max file size exceeded for X") + else: + return await event.reply("Error: unknown X upload error") + else: + if media_id: + logging.info("{} ({}) Uploaded file: {}".format(user_name, user_id, media_path)) + elif media_id is False: + logging.info("{} ({}) Failed to uploaded file: {}".format(user_name, user_id, media_path)) + finally: + if os.path.exists(media_progress_file): + media_progress = json.loads(open(media_progress_file, "r").read()) + await bot.delete_messages(channel_id, media_progress['id']) + os.remove(media_progress_file) + + # send tweet + try: + post = await create_tweet(text, [media_id] if media_id else None) + except Exception as e: + logging.error(e) + if "include either text or media" in str(e): + return await event.reply("Error: no text or media") + elif "duplicate content" in str(e): + return await event.reply("Error: duplicate content") + elif "Invalid X credentials" in str(e): + return await event.reply("Error: not authenticated on X") + else: + return await event.reply("Error: unknown X posting error") + # send the tweet url to telegram + tweet_url = "https://x.com/{}/status/{}".format(os.getenv('X_USER'), post['id']) + await event.reply(message := "Posted: {}".format(tweet_url)) + logging.info("{} ({}) {}".format(user_name, user_id, message)) + # form a record of this tweet + record = { + "chat_id": chat_id, + "origin_message_id": reply_message_id, + "origin_user_id": reply_user_id, + "origin_user_name": reply_user_name, + "user_id": user_id, + "user_name": user_name, + "text": text, + "tweet_id": post['id'], + "tweet_url": tweet_url + } + # save a record of this tweet + os.makedirs("{}/posts".format(os.getenv('DATA_FOLDER')), exist_ok=True) + open(record_path, "w").write(json.dumps(record, indent=4)) + + # delete tweet + elif user_input[0] in ('/untweet',): + # nothing to delete + if len(user_input) == 1 and not reply_to_message: + return await event.reply("Not enough parameters") + # get the tweet id from the message + post_id = user_input[1] if len(user_input) > 1 else None + # get text from the telegram replied to message + text = " ".join(user_input[1:]) + if reply_to_message: + text = reply_to_message.text + if type(post_id) is not int: + _, post_id = validate_x_url(text) + # delete tweet + try: + result = await delete_tweet(post_id) + except Exception as e: + logging.error(e) + if "Invalid X credentials" in str(e): + return await event.reply("Error: not authenticated on X") + else: + return await event.reply("Error: unknown X error") + else: + # mark as deleted + if not result: + return await event.reply("Already deleted") + # send the tweet url to telegram + else: + await event.reply(message := "Deleted: https://x.com/{}/status/{}".format(os.getenv('X_USER'), post_id)) + logging.info("{} ({}) {}".format(user_name, user_id, message)) + + # cancel tweet if downloading is in progress + elif user_input[0] in ('/cancel', '/stop',): + # ignore if download progress file doesn't exist for this message + if not os.path.exists(media_progress_file): + return await event.reply("Nothing to cancel") + try: + # load download progress if there's one that matches + media_progress = json.loads(open(media_progress_file, "r").read()) + except (JSONDecodeError, FileNotFoundError) as e: + logging.error(e) + return await event.reply("Error: failed to cancel") + else: + # set download progress file as cancelled + media_progress['cancelled'] = True + open(media_progress_file, "w").write(json.dumps(media_progress, indent=4)) + + +if __name__ == '__main__': + logging.info("-" * 50) + logging.info("Affection TG X Bot") + logging.info("-" * 50) + scheduler = BackgroundScheduler() + scheduler.add_job(refresh_x_oauth2_token, 'interval', minutes=1) + scheduler.add_job(delete_old_media, 'interval', minutes=1) + scheduler.start() + bot.run_until_disconnected() diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..4445259 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,5 @@ +httpx +telethon +python-dotenv +apscheduler +tweepy \ No newline at end of file diff --git a/app/sample.env b/app/sample.env new file mode 100644 index 0000000..5180db5 --- /dev/null +++ b/app/sample.env @@ -0,0 +1,13 @@ +DATA_FOLDER=../data +DELETE_OLD_MEDIA_IN_DAYS=3 +TELEGRAM_ADMIN_ID= +TELEGRAM_APP_ID= +TELEGRAM_APP_HASH= +TELEGRAM_BOT_TOKEN= +X_USER= +X_ACCESS_TOKEN= +X_ACCESS_SECRET= +X_CONSUMER_KEY= +X_CONSUMER_SECRET= +X_CLIENT_ID= +X_CLIENT_SECRET= \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5c7b0a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.1" +services: + app: + container_name: affection-x-bot + build: app + restart: unless-stopped + volumes: + - "./app:/app" + - "./data:/data" + command: ['python', 'main.py'] + env_file: + - ./app/.env \ No newline at end of file