This commit is contained in:
Alexander 2024-08-21 22:24:20 -04:00
commit f6f900b4e2
8 changed files with 553 additions and 0 deletions

38
README.md Normal file
View file

@ -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

8
app/Dockerfile Normal file
View file

@ -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"]

199
app/custom.py Normal file
View file

@ -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))

19
app/login-to-x.py Normal file
View file

@ -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()

259
app/main.py Normal file
View file

@ -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()

5
app/requirements.txt Normal file
View file

@ -0,0 +1,5 @@
httpx
telethon
python-dotenv
apscheduler
tweepy

13
app/sample.env Normal file
View file

@ -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=

12
docker-compose.yml Normal file
View file

@ -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