├── runtime.txt ├── Procfile ├── twitter_autobase ├── watermark │ ├── photo.png │ ├── FreeMono.ttf │ ├── filename.jpg │ ├── watermarked.jpg │ ├── README.md │ └── app.py ├── __init__.py ├── xauth.py ├── quick_reply.py ├── clean_dm_autobase.py ├── webhook │ ├── webhook_manager.py │ └── twitivity.py ├── async_upload.py ├── dm_command.py ├── main.py ├── process_dm.py └── twitter.py ├── requirements.txt ├── .github └── dependabot.yml ├── LICENSE ├── app.py ├── .gitignore ├── README.md └── config.py /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.8.9 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: python3 app.py 2 | -------------------------------------------------------------------------------- /twitter_autobase/watermark/photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botaul/botmaster/HEAD/twitter_autobase/watermark/photo.png -------------------------------------------------------------------------------- /twitter_autobase/watermark/FreeMono.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botaul/botmaster/HEAD/twitter_autobase/watermark/FreeMono.ttf -------------------------------------------------------------------------------- /twitter_autobase/watermark/filename.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botaul/botmaster/HEAD/twitter_autobase/watermark/filename.jpg -------------------------------------------------------------------------------- /twitter_autobase/watermark/watermarked.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botaul/botmaster/HEAD/twitter_autobase/watermark/watermarked.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==1.1.2 2 | oauth2==1.9.0.post1 3 | Pillow==8.2.0 4 | pyngrok==5.0.5 5 | requests==2.25.1 6 | tweepy==3.10.0 7 | waitress==2.0.0 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "22:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /twitter_autobase/__init__.py: -------------------------------------------------------------------------------- 1 | # twitter_autobase 2 | # Copyright (c) 2020- Fakhri Catur Rofi 3 | 4 | """ 5 | Twitter Autobase 6 | """ 7 | __version__ = "1.8.0" 8 | __author__ = "Fakhri Catur Rofi" 9 | __license__ = "MIT" 10 | 11 | from .main import Autobase 12 | from .webhook import webhook_manager 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020- Fakhri Catur Rofi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /twitter_autobase/watermark/README.md: -------------------------------------------------------------------------------- 1 | ## Run on main (top-level code execute) 2 | - filename.jpg must be exists on this folder. It's image that will be watermarked.
3 | `python3 app.py text font position watermark_image ratio text_color text_stroke_color output`
4 | text: str
5 | font: str
6 | position: x,y -> tuple, x:left, center, right. y:top, center, bottom
7 | watermark_image: filename(str) or False
8 | ratio: float number under 1
9 | text_color: r,g,b,a -> tuple of RGBA color
10 | text_stroke_color: r,g,b,a -> tuple of RGBA color
11 | output: output filename -> str
12 | example: `python3 app.py 'autobase_reborn' FreeMono.ttf right,bottom photo.png 0.103 100,0,0,1 0,225,225,1 watermarked.jpg` 13 | 14 | ## Change watermark photo or font: 15 | ### Watermark 16 | 1. photo's size must be square 17 | 2. file name of the photo must be 'photo.png' 18 | 3. or edit `watermark_image` function's parameter on app.py, watermark="twitter_autobase/watermark/yourphoto.png" 19 | 20 | ### Font 21 | 1. put in this folder 22 | 2. edit `watermark_image` function's parameter on app.py, font="twitter_autobase/watermark/yourfont.ttf" 23 | 3. you can setting the size & color of the font on `font` object, `draw.textsize` and `draw.text` function 24 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from requests import post 2 | from threading import Thread 3 | from time import sleep 4 | from twitter_autobase import Autobase 5 | from twitter_autobase import webhook_manager as webMan 6 | import config 7 | import logging 8 | logging.basicConfig(level=logging.WARNING, format="%(name)s - %(levelname)s - %(message)s") 9 | 10 | # if you want to run multiple account, create a copy of config.py. example: config2.py , etc. 11 | # then follow these ## template... 12 | # You only need one ngrok auth token 13 | 14 | ## import config2 15 | 16 | User = Autobase(config) 17 | ## User2 = Autobase(config2) 18 | 19 | # SETTING NGROK AND WEBHOOK SERVER 20 | url = webMan.connect_ngrok(config.NGROK_AUTH_TOKEN) 21 | server = webMan.server_config( 22 | url=url+"/listener", 23 | dict_credential={ 24 | User.bot_username : config.CONSUMER_SECRET, 25 | ## User2.bot_username : config2.CONSUMER_SECRET 26 | }, 27 | dict_func={ 28 | User.bot_id : User.webhook_connector, 29 | ## User2.bot_id : User2.webhook_connector 30 | }, 31 | subscribe=[ 32 | 'direct_message_events', 33 | 'follow_events', 34 | ] 35 | ) 36 | webhook = Thread(target=server.listen) 37 | webhook.start() 38 | 39 | # TEST SERVER 40 | while post(url+"/listener/test").status_code != 200: 41 | sleep(1) 42 | 43 | # REGISTER WEBHOOK 44 | webMan.register_webhook(url+"/listener", User.bot_username, config) 45 | ## webMan.register_webhook(url+"/listener", User2.bot_username, config2) 46 | -------------------------------------------------------------------------------- /twitter_autobase/xauth.py: -------------------------------------------------------------------------------- 1 | # Ref: https://gist.github.com/codingjester/3497868 2 | import json 3 | import oauth2 4 | import urllib 5 | 6 | 7 | def get_xauth_access_token(consumer_key, consumer_secret, username, password) -> dict: 8 | ''' 9 | :return: dict of access_key and access_secret 10 | ''' 11 | acces_token_url = 'https://api.twitter.com/oauth/access_token' 12 | consumer = oauth2.Consumer(consumer_key, consumer_secret) 13 | client = oauth2.Client(consumer) 14 | client.add_credentials(username, password) 15 | client.set_signature_method = oauth2.SignatureMethod_HMAC_SHA1() 16 | 17 | resp, token = client.request( 18 | acces_token_url, 19 | method="POST", 20 | body=urllib.parse.urlencode({ 21 | 'x_auth_username' : username, 22 | 'x_auth_password' : password, 23 | 'x_auth_mode' : 'client_auth' 24 | }) 25 | ) 26 | access_token = dict(urllib.parse.parse_qsl(token.decode())) 27 | return dict( 28 | access_key=access_token['oauth_token'], 29 | access_secret=access_token['oauth_token_secret'] 30 | ) 31 | 32 | if __name__ == "__main__": 33 | from getpass import getpass 34 | 35 | consumer_key = input("consumer_key: ") 36 | consumer_secret = input("consumer_secret: ") 37 | username = input("username: ") 38 | password = getpass(prompt="password: ") 39 | 40 | token = get_xauth_access_token(consumer_key, consumer_secret, username, password) 41 | for i in token: 42 | print(f'{i}: {token[i]}') 43 | -------------------------------------------------------------------------------- /twitter_autobase/quick_reply.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | from typing import NoReturn 3 | 4 | class ProcessQReply(ABC): 5 | ''' 6 | Ref: https://developer.twitter.com/en/docs/twitter-api/v1/direct-messages/quick-replies/api-reference/options 7 | ''' 8 | _tmp_dms: list = None 9 | dms: list = None 10 | credential: object = None 11 | 12 | @abstractmethod 13 | def transfer_dm(self, dm) -> NoReturn: 14 | pass 15 | 16 | @abstractmethod 17 | def send_dm(self, recipient_id, text, quick_reply_type=None, quick_reply_data=None, 18 | attachment_type=None, attachment_media_id=None) -> NoReturn: 19 | pass 20 | 21 | @abstractmethod 22 | def _command(self, sender_id: str, message: str, message_data: dict) -> bool: 23 | pass 24 | 25 | def _verif_menfess(self, action, sender_id) -> NoReturn: 26 | ''' 27 | Move dm dict from self._tmp_dms to self.dms 28 | ''' 29 | for x in self._tmp_dms.copy()[::-1]: 30 | if x['sender_id'] == sender_id: 31 | if action == "accept": 32 | self.transfer_dm(x) 33 | self._tmp_dms.remove(x) 34 | break 35 | 36 | def _button_command(self, sender_id, message) -> NoReturn: 37 | ''' 38 | Process dm command using button 39 | ''' 40 | message_data = { 41 | 'entities' : { 42 | 'urls' : [] 43 | } 44 | } 45 | self._command(sender_id, message, message_data) 46 | 47 | def _quick_reply_manager(self, sender_id: str, metadata: str) -> NoReturn: 48 | ''' 49 | Manage dm buttons 50 | ''' 51 | metadata = metadata.split("|") 52 | action = metadata[0] 53 | data = metadata[1] 54 | 55 | if action == "send_text": 56 | data = eval(data) 57 | self.send_dm(sender_id, data) 58 | elif action == "send_button": 59 | data = eval(data) 60 | self.send_dm(sender_id, data['text'], quick_reply_type='options', 61 | quick_reply_data=data['options']) 62 | elif action == "exec": 63 | exec(data) 64 | else: 65 | raise Exception("action is not valid") 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | pyvenv.cfg 132 | bin/ 133 | **/__pycache__ 134 | .vscode/ 135 | **/.pytest_cache 136 | twitter_autobase/**/config.py 137 | Darksiede** 138 | autobase_reborn** 139 | **/test**.py -------------------------------------------------------------------------------- /twitter_autobase/clean_dm_autobase.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def delete_trigger_word(message, list_keyword): 5 | list_keyword = [i.lower() for i in list_keyword] 6 | for word in list_keyword: 7 | tmp_message = message.lower() 8 | pos = tmp_message.find(word) 9 | if pos != -1: 10 | replaced = message[pos : pos + len(word)] 11 | if pos == 0: 12 | if len(word) == len(message): 13 | pass 14 | # Error will happen on post_tweet method. If the message only contains trigger 15 | # that will be deleted on replaced variable 16 | elif message[pos+len(word)] == " ": 17 | # when trigger is placed on the start of text and there is a space after it 18 | replaced += " " 19 | elif message[pos-1] == " ": 20 | # when trigger is placed on the middle or the end of text 21 | replaced = " " + replaced 22 | message = message.replace(replaced, "") 23 | 24 | return message 25 | 26 | def count_emoji(text: str) -> int: 27 | ''' 28 | Count emojis on the text 29 | ''' 30 | # Ref: https://gist.github.com/Alex-Just/e86110836f3f93fe7932290526529cd1#gistcomment-3208085 31 | # Ref: https://en.wikipedia.org/wiki/Unicode_block 32 | emoji = re.compile("[" 33 | "\U0001F1E0-\U0001F1FF" # flags (iOS) 34 | "\U0001F300-\U0001F5FF" # symbols & pictographs 35 | "\U0001F600-\U0001F64F" # emoticons 36 | "\U0001F680-\U0001F6FF" # transport & map symbols 37 | "\U0001F700-\U0001F77F" # alchemical symbols 38 | "\U0001F780-\U0001F7FF" # Geometric Shapes Extended 39 | "\U0001F800-\U0001F8FF" # Supplemental Arrows-C 40 | "\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs 41 | "\U0001FA00-\U0001FA6F" # Chess Symbols 42 | "\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A 43 | "\U00002702-\U000027B0" # Dingbats 44 | "\U000024C2-\U0001F251" 45 | "]+") 46 | 47 | return len(re.findall(emoji, text)) 48 | 49 | def get_list_media_ids(media_idsAndTypes: list) -> list: 50 | ''' 51 | Manage and divide media ids from media_idsAndTypes 52 | :return: list of list media_ids per 4 photo or 1 video/gif e.g. [[media_ids],[media_ids],[media_ids]] 53 | ''' 54 | list_media_ids = [[]] # e.g. [[media_ids],[media_ids],[media_ids]] 55 | temp = 0 56 | 57 | while len(media_idsAndTypes): 58 | if temp == 0: 59 | temp = 1 60 | list_media_ids = list() 61 | media_ids = list() 62 | added = 0 63 | for media_id, media_type in media_idsAndTypes[:4]: 64 | if media_type == 'video' or media_type == 'animated_gif': 65 | if added == 0: 66 | media_ids.append(media_id) 67 | added += 1 68 | break 69 | media_ids.append(media_id) 70 | added += 1 71 | 72 | list_media_ids.append(media_ids) 73 | # media_idsAndTypes are dynamic here 74 | media_idsAndTypes = media_idsAndTypes[added:] 75 | 76 | return list_media_ids -------------------------------------------------------------------------------- /twitter_autobase/webhook/webhook_manager.py: -------------------------------------------------------------------------------- 1 | from .twitivity import Event, Activity 2 | from pyngrok import ngrok 3 | from time import sleep 4 | from typing import NoReturn 5 | import logging 6 | import json 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | # Connect ngrok 11 | def connect_ngrok(ngrok_auth_token: str) -> str: 12 | ''' 13 | :return: ngrok url 14 | ''' 15 | try: 16 | ngrok.set_auth_token(ngrok_auth_token) 17 | ngrok_tunnel = ngrok.connect(8080, bind_tls=True) 18 | except: 19 | logger.warning("waiting ngrok... Make sure you have disconnected another client session!") 20 | sleep(15) 21 | ngrok.set_auth_token(ngrok_auth_token) 22 | ngrok_tunnel = ngrok.connect(8080, bind_tls=True) 23 | 24 | print("NGROK URL: {}".format(ngrok_tunnel.public_url)) 25 | 26 | return ngrok_tunnel.public_url 27 | 28 | # Register webhook 29 | def register_webhook(url: str, name: str, credential: object, delLastWeb: bool=True) -> object: 30 | ''' 31 | Register webhook to twitter 32 | ''' 33 | activity = Activity( 34 | { 35 | 'consumer_key' : credential.CONSUMER_KEY, 36 | 'consumer_secret' : credential.CONSUMER_SECRET, 37 | 'access_token' : credential.ACCESS_KEY, 38 | 'access_token_secret': credential.ACCESS_SECRET, 39 | 'env_name' : credential.ENV_NAME 40 | } 41 | ) 42 | # delete the last active webhook 43 | if delLastWeb: 44 | for environment in activity.webhooks()['environments']: 45 | if environment['environment_name'] == activity.env_name: 46 | if len(environment['webhooks']): 47 | webhook_id = environment['webhooks'][0]['id'] 48 | activity.delete(webhook_id) 49 | break 50 | 51 | url += "/{}".format(name) 52 | print(activity.register_webhook(url)) 53 | return activity.subscribe() 54 | 55 | # Webhook server 56 | class StreamEvent(Event): 57 | ''' 58 | :param func_data: dict of function(one arg) that will be called when webhook receives data, user_id (str) as a key 59 | func_data = { 60 | 'username': function, 61 | } 62 | 63 | :param subscribe: list of (str) subscriptions that will be subscribed.\ 64 | see more on: https://developer.twitter.com/en/docs/twitter-api/enterprise/account-activity-api/guides/account-activity-data-objects 65 | ''' 66 | 67 | def __init__(self, func_data: dict, subscribe: list): 68 | self.func_data = func_data 69 | self.subcriptions = subscribe 70 | 71 | @classmethod 72 | def set_callback(cls, callback_url: str) -> NoReturn: 73 | cls.CALLBACK_URL = callback_url 74 | 75 | @classmethod 76 | def update_credential_id(cls, credential_id: dict) -> NoReturn: 77 | cls.credential_id.update(credential_id) 78 | 79 | def on_data(self, data: json) -> NoReturn: 80 | if data is None: 81 | return 82 | if any(i in data for i in self.subcriptions): 83 | user_id = data['for_user_id'] 84 | self.func_data[user_id](data) 85 | 86 | 87 | def server_config(url, dict_credential: dict, dict_func: dict, subscribe: list) -> object: 88 | ''' 89 | :param dict_credential: dict of consumer secret, username as a key 90 | dict_credential={ 91 | 'username': 'consumer_secret' 92 | } 93 | :param dict_func: dict of function (one arg) that will be called when webhook receives data, user_id (str) as a key 94 | dict_func={ 95 | 'user_id': function 96 | } 97 | :return: stream event object 98 | ''' 99 | stream_event = StreamEvent(dict_func, subscribe) 100 | stream_event.set_callback(url) 101 | stream_event.update_credential_id(dict_credential) 102 | 103 | return stream_event 104 | -------------------------------------------------------------------------------- /twitter_autobase/watermark/app.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageFont, ImageDraw 2 | 3 | def watermark_text_image(filename, watermark='twitter_autobase/watermark/photo.png', font='twitter_autobase/watermark/FreeMono.ttf', 4 | text=str(), ratio=0.1, pos=('right', 'bottom'), output='watermarked.jpg', color=(0,0,0,0), 5 | stroke_color=(225,225,225,1)): 6 | 7 | '''Watermark with photo and text 8 | :param filename: file photo location -> str 9 | :param watermark: watermark image location or bool. True: 'watermark/photo', False: without image -> str or bool 10 | :param font: font location -> str 11 | :param text: text watermark -> str 12 | :param ratio: ratio between watermark and photo -> float under 1 13 | :param pos: (x, y) position, x:'right','center','right', y:'top','center','bottom' -> tuple 14 | :param output: output file name -> str 15 | ''' 16 | 17 | # CONTROLLER 18 | posX = pos[0] 19 | posY = pos[1] 20 | if watermark is True: 21 | watermark = 'watermark/photo.png' 22 | 23 | # OPEN PHOTO 24 | img = Image.open(filename) 25 | preset = Image.new("RGBA", (img.width, img.height)) 26 | preset.paste(img, (0,0)) 27 | 28 | # ADD WATERMARK 29 | 30 | # Resizing based on ratio 31 | if img.size[0] > img.size[1]: 32 | size = round(img.size[1] * ratio), round(img.size[1] * ratio) 33 | else: 34 | size = round(img.size[0] * ratio), round(img.size[0] * ratio) 35 | 36 | if watermark != False: 37 | watermark = (Image.open(watermark)).resize(size) 38 | 39 | # Setting location and font 40 | font = ImageFont.truetype(font, round(size[0] * 2.5 * ratio)) 41 | draw = ImageDraw.Draw(preset, "RGBA") 42 | textsize = draw.textsize(text, font=font, stroke_width=round(size[0]*0.2*ratio)) 43 | 44 | dictPos = { 45 | 'center': round(img.height * 0.5 - size[0] * 0.5), 46 | 'top' : round(img.height * 0.01), 47 | 'bottom': round(img.height * 0.99 - size[0]), 48 | 'left' : round(img.width * 0.01), 49 | 'right' : round(img.width * 0.99 - size[0] - textsize[0]) 50 | } 51 | 52 | if posX == 'center': 53 | posX = round(img.width * 0.5 - (size[0] + textsize[0]) * 0.5) 54 | else: 55 | posX = dictPos[posX] 56 | 57 | posY = dictPos[posY] 58 | 59 | # Paste watermark and write text 60 | if watermark != False: 61 | preset.paste(watermark, (posX, posY), mask=watermark) 62 | draw.text((posX + round(size[0]), posY + round((size[0]- textsize[1]) * 0.5)), 63 | text, font=font, fill=color, stroke_width=round(size[0]*0.2*ratio), stroke_fill=stroke_color) 64 | else: 65 | draw.text((posX + round(size[0]*0.5), posY + round((size[0]- textsize[1]) * 0.5)), 66 | text, font=font, fill=color, stroke_width=round(size[0]*0.2*ratio), stroke_fill=stroke_color) 67 | 68 | # preset.mode = 'RGB' 69 | preset = preset.convert('RGB') 70 | preset.save(output) 71 | img.close() 72 | preset.close() 73 | if watermark != False: 74 | watermark.close() 75 | 76 | 77 | if __name__ == "__main__": 78 | from sys import argv 79 | # Ignore vscode problems message on these lines 80 | # argv e.g 'python3 app.py 'autobase_reborn' right,bottom photo.png 0.103 100,0,0,1 0,225,225,1 watermarked.jpg' 81 | script, text, font, pos, watermark, ratio, color, stroke_color, output = argv 82 | ratio = float(ratio) 83 | pos = tuple([i for i in pos.split(",")]) 84 | if watermark == 'False': 85 | exec(f"watermark = {watermark}") 86 | elif watermark != 'True': 87 | exec(f"watermark = '{watermark}'") 88 | else: 89 | raise Exception("argv['watermark'] must be filename or False") 90 | 91 | color = tuple([int(i) for i in [float(i) for i in color.split(",")]]) 92 | stroke_color = tuple(int(i) for i in [float(i) for i in stroke_color.split(",")]) 93 | watermark_text_image("filename.jpg", watermark, text=text, font=font, 94 | color=color, stroke_color=stroke_color, ratio=ratio, pos=pos, output=output) -------------------------------------------------------------------------------- /twitter_autobase/webhook/twitivity.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 Saadman Rafat 2 | # Distributed under MIT software license 3 | # Source: https://github.com/twitivity/twitivity 4 | 5 | import json 6 | import hmac 7 | import hashlib 8 | import base64 9 | import re 10 | 11 | import requests 12 | 13 | from typing import NoReturn 14 | from abc import ABC, abstractmethod 15 | 16 | from tweepy.error import TweepError 17 | from tweepy import OAuthHandler 18 | from flask import Flask, request 19 | from waitress import serve 20 | 21 | 22 | class Activity: 23 | _protocol: str = "https:/" 24 | _host: str = "api.twitter.com" 25 | _version: str = "1.1" 26 | _product: str = "account_activity" 27 | 28 | def __init__(self, credential: dict): 29 | ''' 30 | :param credential: dict that contains consumer_key, consumer_secret, \ 31 | access_token, access_token_secret, and env_name 32 | ''' 33 | self.env_name = credential['env_name'] 34 | 35 | self._auth = OAuthHandler(credential['consumer_key'], credential['consumer_secret']) 36 | self._auth.set_access_token(credential['access_token'], credential['access_token_secret']) 37 | 38 | 39 | def api(self, method: str, endpoint: str, data: dict = None) -> json: 40 | """ 41 | :param method: GET or POST 42 | :param endpoint: API Endpoint to be specified by user 43 | :param data: POST Request payload parameter 44 | :return: json 45 | """ 46 | try: 47 | with requests.Session() as r: 48 | response = r.request( 49 | url="/".join( 50 | [ 51 | self._protocol, 52 | self._host, 53 | self._version, 54 | self._product, 55 | endpoint, 56 | ] 57 | ), 58 | method=method, 59 | auth=self._auth.apply_auth(), 60 | data=data, 61 | ) 62 | return response 63 | except TweepError: 64 | raise 65 | 66 | def register_webhook(self, callback_url: str) -> json: 67 | try: 68 | return self.api( 69 | method="POST", 70 | endpoint=f"all/{self.env_name}/webhooks.json", 71 | data={"url": callback_url}, 72 | ).json() 73 | except Exception as e: 74 | raise e 75 | 76 | def refresh(self, webhook_id: str) -> NoReturn: 77 | """Refreshes CRC for the provided webhook_id. 78 | """ 79 | try: 80 | return self.api( 81 | method="PUT", 82 | endpoint=f"all/{self.env_name}/webhooks/{webhook_id}.json", 83 | ) 84 | except Exception as e: 85 | raise e 86 | 87 | def delete (self, webhook_id: str) -> NoReturn: 88 | """Removes the webhook from the provided webhook_id. 89 | """ 90 | try: 91 | return self.api( 92 | method="DELETE", 93 | endpoint=f"all/{self.env_name}/webhooks/{webhook_id}.json", 94 | ) 95 | except Exception as e: 96 | raise e 97 | 98 | def subscribe(self) -> NoReturn: 99 | try: 100 | return self.api( 101 | method="POST", 102 | endpoint=f"all/{self.env_name}/subscriptions.json", 103 | ) 104 | except Exception: 105 | raise 106 | 107 | def webhooks(self) -> json: 108 | """Returns all environments, webhook URLs and their statuses for the authenticating app. 109 | Only one webhook URL can be registered to each environment. 110 | """ 111 | try: 112 | return self.api(method="GET", endpoint=f"all/webhooks.json").json() 113 | except Exception as e: 114 | raise e 115 | 116 | 117 | def url_params(url: str) -> str: 118 | pattern: str = r"^[^\/]+:\/\/[^\/]*?\.?([^\/.]+)\.[^\/.]+(?::\d+)?\/" 119 | return re.split(pattern=pattern, string=url)[-1] 120 | 121 | 122 | class Event(ABC): 123 | CALLBACK_URL: str = str() 124 | credential_id: dict = dict() 125 | 126 | @abstractmethod 127 | def on_data(self, data: json) -> NoReturn: 128 | pass 129 | 130 | def listen(self) -> NoReturn: 131 | app = self._get_server() 132 | serve(app, host='0.0.0.0', port=8080) 133 | 134 | def _get_server(self) -> Flask: 135 | try: 136 | app = Flask(__name__) 137 | 138 | @app.route( 139 | f"/{url_params(url=self.CALLBACK_URL)}/", methods=["GET", "POST", "PUT"] 140 | ) #pylint: disable=unused-variable 141 | def callback(username) -> json: 142 | if request.method == "GET" or request.method == "PUT": 143 | hash_digest = hmac.digest( 144 | key=self.credential_id[username].encode("utf-8"), 145 | msg=request.args.get("crc_token").encode("utf-8"), 146 | digest=hashlib.sha256, 147 | ) 148 | return { 149 | "response_token": "sha256=" 150 | + base64.b64encode(hash_digest).decode("ascii") 151 | } 152 | elif request.method == "POST": 153 | data = request.get_json() 154 | self.on_data(data) 155 | return {"code": 200} 156 | 157 | return app 158 | 159 | except Exception as e: 160 | raise e 161 | -------------------------------------------------------------------------------- /twitter_autobase/async_upload.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016- @TwitterDev 2 | # Distributed under the MIT software license 3 | # Source: https://github.com/twitterdev/large-video-upload-python 4 | 5 | # Re-code by Fakhri Catur Rofi 6 | # Source: https://github.com/fakhrirofi/twitter_autobase 7 | 8 | from os.path import getsize 9 | from requests import get, post 10 | from time import sleep 11 | from typing import NoReturn 12 | import json 13 | 14 | 15 | MEDIA_ENDPOINT_URL = 'https://upload.twitter.com/1.1/media/upload.json' 16 | POST_TWEET_URL = 'https://api.twitter.com/1.1/statuses/update.json' 17 | 18 | 19 | class MediaUpload: 20 | ''' 21 | Upload media using twitter api v.1.1 22 | 23 | Attributes: 24 | - filename 25 | - total_bytes 26 | - media_id 27 | - processing_info 28 | - file_format 29 | - media_type 30 | - media_category 31 | :param file_name: filename of the media 32 | :param media_category: 'tweet' or 'dm' 33 | ''' 34 | 35 | def __init__(self, auth: object, file_name: str, media_category: str='tweet'): 36 | ''' 37 | :param file_name: filename of the media 38 | :param media_category: 'tweet' or 'dm' 39 | ''' 40 | self.oauth = auth 41 | self.filename = file_name 42 | self.total_bytes = getsize(self.filename) 43 | self.media_id = None 44 | self.processing_info = None 45 | data_media = { 46 | 'gif' : 'image/gif', 47 | 'mp4' : 'video/mp4', 48 | 'jpg' : 'image/jpeg', 49 | 'webp' : 'image/webp', 50 | 'png' : 'image/png', 51 | 'jpeg' : 'image/jpeg', 52 | 'image/gif' : 'tweet_gif', 53 | 'video/mp4' : 'tweet_video', 54 | 'image/jpeg': 'tweet_image', 55 | 'image/webp': 'tweet_image', 56 | 'image/png' : 'tweet_image' 57 | } 58 | self.file_format = file_name.split('.')[-1] 59 | if self.file_format in data_media.keys(): 60 | self.media_type = data_media[file_name.split('.')[-1]] 61 | self.media_category = data_media[self.media_type] 62 | else: 63 | raise Exception(f"sorry, the .{self.file_format} format is not supported") 64 | if media_category == 'dm': 65 | self.media_category = None 66 | 67 | 68 | def upload_init(self) -> tuple: 69 | ''' 70 | init section 71 | :return: media id, media_type 72 | ''' 73 | # print('INIT') 74 | request_data = { 75 | 'command': 'INIT', 76 | 'media_type': self.media_type, 77 | 'total_bytes': self.total_bytes, 78 | 'media_category': self.media_category 79 | } 80 | if self.media_category == None: 81 | del request_data['media_category'] 82 | 83 | req = post(url=MEDIA_ENDPOINT_URL, 84 | data=request_data, auth=self.oauth) 85 | media_id = req.json()['media_id'] 86 | 87 | self.media_id = media_id 88 | print('Media ID: %s' % str(media_id)) 89 | 90 | dict_format = { 91 | 'gif':'animated_gif', 92 | 'mp4':'video', 93 | 'png':'photo', 94 | 'jpg':'photo', 95 | 'webp':'photo', 96 | 'jpeg':'photo', 97 | } 98 | media_type = dict_format[self.file_format] 99 | return str(media_id), media_type 100 | 101 | 102 | def upload_append(self) -> NoReturn: 103 | ''' 104 | append section 105 | ''' 106 | segment_id = 0 107 | bytes_sent = 0 108 | file = open(self.filename, 'rb') 109 | 110 | while bytes_sent < self.total_bytes: 111 | chunk = file.read(1024*1024) 112 | # print('APPEND') 113 | request_data = { 114 | 'command': 'APPEND', 115 | 'media_id': self.media_id, 116 | 'segment_index': segment_id, 117 | 118 | } 119 | 120 | files = { 121 | 'media': chunk 122 | } 123 | 124 | req = post(url=MEDIA_ENDPOINT_URL, 125 | data=request_data, files=files, auth=self.oauth) 126 | 127 | if req.status_code < 200 or req.status_code > 299: 128 | print(req.status_code) 129 | print("Getting error status code") 130 | return False 131 | else: 132 | segment_id = segment_id + 1 133 | bytes_sent = file.tell() 134 | print('%s of %s bytes uploaded' % 135 | (str(bytes_sent), str(self.total_bytes))) 136 | 137 | file.close() 138 | # print('Upload chunks complete.') 139 | 140 | 141 | def upload_finalize(self) -> NoReturn: 142 | ''' 143 | Finalize upload and start media processing 144 | ''' 145 | # print('FINALIZE') 146 | request_data = { 147 | 'command': 'FINALIZE', 148 | 'media_id': self.media_id 149 | } 150 | 151 | req = post(url=MEDIA_ENDPOINT_URL, 152 | data=request_data, auth=self.oauth) 153 | 154 | self.processing_info = req.json().get('processing_info', None) 155 | self.check_status() 156 | 157 | 158 | def check_status(self) -> NoReturn: 159 | ''' 160 | Check video processing status 161 | ''' 162 | if self.processing_info is None: 163 | return 164 | 165 | state = self.processing_info['state'] 166 | print('Media processing status is %s ' % state) 167 | 168 | if state == 'succeeded': 169 | return 170 | 171 | elif state == 'failed': 172 | raise ValueError("Upload failed") 173 | 174 | else: 175 | 176 | check_after_secs = self.processing_info['check_after_secs'] 177 | 178 | # print('Checking after %s seconds' % str(check_after_secs)) 179 | sleep(check_after_secs) 180 | # print('STATUS') 181 | request_params = { 182 | 'command': 'STATUS', 183 | 'media_id': self.media_id 184 | } 185 | 186 | req = get(url=MEDIA_ENDPOINT_URL, 187 | params=request_params, auth=self.oauth) 188 | 189 | self.processing_info = req.json().get('processing_info', None) 190 | self.check_status() 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twitter_autobase 2 | A Twitter bot that can read your DMs, then tweets like Twitter autobase. Inspired by 3 | https://github.com/ydhnwb/autodm_base and using https://github.com/twitivity/twitivity. Collaboration with @fakhrirofi https://github.com/fakhrirofi 4 | 5 | - **Read Twitter rules[[1]](https://help.twitter.com/en/rules-and-policies/twitter-search-policies)[[2]](https://developer.twitter.com/en/developer-terms/more-on-restricted-use-cases)[[3]](https://help.twitter.com/en/rules-and-policies/twitter-automation)**
6 | - **USING THIS BOT FOR 'ADULT' BASE IS STRICTLY PROHIBITED**
7 | 8 | ## Features 9 | - Real-time account activity 10 | - User (account) requirements 11 | - Tweet photo, GIF, and video 12 | - Post a Thread when characters > 280 13 | - Make quick reply button 14 | - Send command from admin account (via DM) 15 | - Run multiple autobase accounts (see on app.py) 16 | - etc. see on config.py 17 | 18 | 19 | ## Requirements 20 | - Python 3.8.x 21 | - Twitter Developer Account 22 | - Ngrok Account 23 | - Heroku Account (optional) 24 | 25 | 26 | ## Getting Started 27 | - Install [pip](https://pypi.org/project/pip/), [virtualenv](https://pypi.org/project/virtualenv/), 28 | [git](https://github.com/git-guides/install-git), and [heroku](https://devcenter.heroku.com/articles/heroku-cli)(optional) 29 | - [Do Installation](#installation) 30 | - Edit contents on config.py 31 | - [Deploy to Heroku](#deploy-to-heroku) 32 | 33 | 34 | ## Installation 35 | Open your terminal on the specified folder
36 | ```bash 37 | # download on https://github.com/fakhrirofi/twitter_autobase/releases for stable version 38 | git clone https://github.com/fakhrirofi/twitter_autobase.git 39 | cd twitter_autobase 40 | ``` 41 | Linux 42 | ``` 43 | virtualenv venv 44 | source venv/bin/activate 45 | pip3 install -r requirements.txt 46 | ``` 47 | Windows 48 | ``` 49 | virtualenv venv 50 | venv\Scripts\activate 51 | pip3 install -r requirements.txt 52 | ``` 53 | Make .gitignore file
54 | ``` 55 | venv/ 56 | **/__pycache__ 57 | # add another, up to you 58 | ``` 59 | After modifying contents on config, run app by using syntax: `python3 app.py` 60 | 61 | 62 | ## Deploy to Heroku 63 | 64 | ### Deploy using Heroku CLI 65 | ```bash 66 | git add . 67 | git commit -m "initial commit" 68 | heroku git:remote -a your_heroku_app_name 69 | git push -f heroku master 70 | ``` 71 | 72 | ### Heroku limitations 73 | - 550 free dyno hours, you can upgrade to 1000 hours by adding credit card to your account. 74 | - Dyno cycling (restart), so `add_admin`, `rm_admin`, `add_blacklist`, `rm_blacklist`, 75 | `switch on/off`, and all temporary db won't work perfectly. It would be better if you use Heroku database services e.g. Postgres. Please setting `Blacklist_words`, `Admin_id`, and etc. before 76 | deploying to Heroku. Database will be changed to Postgres in future updates. 77 | 78 | 79 | ## DMs examples (based on config) 80 | You can tweet more than one media with media link on tweet. Open your twitter app then tap (hold) the tweet. 81 | Media link automatically will be copied, then send the link to this bot from DM. 82 | 83 | ### Quote-retweet 84 | `fess! your message https://twitter.com/username/1234567890?s=19` (by attaching media, url, or not) 85 | 86 | ### Make a thread 87 | `fess! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.` (by attaching media, url, or not) 88 | 89 | ### Normal tweet 90 | `fess! your message` (by attaching media, url, or not) 91 | 92 | ### Admin command 93 | Note: Due to Heroku dyno cycling, some commands that use temporary data won't work perfectly.
94 | Only admin can access these commands.
95 | `/add_blacklist word1 word2 word-n`
96 | `/rm_blacklist word1 word2 word-n`
97 | `/display_blacklist`
98 | `/add_admin username1 username2 username-n`
99 | `/rm_admin username1 username2 username-n`
100 | `/who https://twitter.com/username/status/1234567890?s=19` check who was sent the menfess
101 | `/switch off` turn off the automenfess
102 | `/switch on` turn on the automenfess
103 | `/block https://twitter.com/username/status/1234567890?s=19` delete menfess & block user by attaching his menfess url
104 | `/unfoll https://twitter.com/username/status/1234567890?s=19` delete menfess & unfoll user by attaching his menfess url
105 | `/follow username1 username2 username-n` 106 |
107 | For add_blacklist and rm_blacklist, you can add space into your words by giving underscore "_". Example:
108 | `/add_blacklist _word1_ word2_word3 word-n`
109 | This command will append " word1 ", "word2 word3", and "word-n" to Blacklist words list. 110 | 111 | ### User command 112 | `/delete https://twitter.com/username/status/1234567890?s=19` delete menfess by attaching tweet url
113 | `/unsend` delete the last menfess
114 | `/menu` send config.DMCmdMenu to sender
115 | `/cancel` cancel the last menfess when it's still on the queue 116 | 117 | 118 | ## Quick Reply Documentation 119 | 120 | ### Quick Reply Json 121 | Reference: https://developer.twitter.com/en/docs/twitter-api/v1/direct-messages/quick-replies/api-reference/options 122 | ```python 123 | { 124 | "text" : "Text that will be sent from DM", 125 | "options" : [ 126 | { 127 | "label" : "button's name", 128 | "description" : "button's description", 129 | "metadata" : "metadata", 130 | } 131 | ] 132 | } 133 | ``` 134 | The maximum of options is 20 and the maximum characters of description is 72 135 | 136 | ### Raw Send DM 137 | ```python 138 | self.send_dm(sender_id, data['text'], quick_reply_type='options', quick_reply_data=data['options']) 139 | ``` 140 | - `data` : [Quick Reply Json](#quick-reply-json) 141 | 142 | ### Metadata 143 | You can see the code of metadata processing on _quick_reply_manager method at quick_reply.py. The abstract value of metadata is: `action|data` 144 | 145 | ### Execute method call `exec|method_call` 146 | The method can be normal method or protected method. Method call must have only one undefined sender_id argument. Example:
147 | Method: 148 | ```python 149 | # something.py (inside class that inherited to Autobase class) 150 | def _do_something(self, sender_id, defined_argument) -> NoReturn: 151 | pass 152 | ``` 153 | Metadata: `exec|self._do_something(sender_id, "defined argument")` # sender_id is only one undefined argument here 154 | 155 | ### Send text message `send_text|attribute_string` 156 | Send Autobase's attribute (string) to user. Example:
157 | Attribute: 158 | ```python 159 | # config.py (credential) 160 | Notif_something = "This message will be sent to sender" 161 | ``` 162 | Metadata: `send_text|self.credential.Notif_something` 163 | 164 | ### Send quick reply button `send_button|attribute_quick_reply_json` 165 | attribute_quick_reply_json template is [Quick Reply Json](#quick-reply-json). Example:
166 | Attribute: 167 | ```python 168 | # config.py (credential) 169 | Button_something = { 170 | 'text' : 'Message that will be sent to sender', 171 | 'options' : [ 172 | { 173 | 'label' : 'something', 174 | 'description' : 'something description', 175 | 'metadata' : 'exec|self._do_something(sender_id, "defined argument")', 176 | } 177 | ] 178 | } 179 | ``` 180 | Metadata: `send_button|self.credential.Button_something` 181 | 182 | 183 | ## Notes 184 | - Admin passes all filters 185 | - Only admin that can set account using admin command 186 | - All temporary data only available for one day (reset at midnight or heroku dyno cycling) 187 | - If you use github repository to deploy to heroku, make sure to set the repository to private. 188 | - Keywords are not case-sensitive 189 | - See changelogs on [releases's notes](https://github.com/fakhrirofi/twitter_autobase/releases) 190 | - I have written documentation in config 191 | 192 | 193 | ## Contributing 194 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to 195 | change. To make a pull request, see the GitHub documentation on [creating a pull request](https://help.githubcom/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request). 196 | 197 | 198 | ## MIT License 199 | 200 | Copyright (c) 2020- Fakhri Catur Rofi 201 | 202 | Permission is hereby granted, free of charge, to any person obtaining a copy 203 | of this software and associated documentation files (the "Software"), to deal 204 | in the Software without restriction, including without limitation the rights 205 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 206 | copies of the Software, and to permit persons to whom the Software is 207 | furnished to do so, subject to the following conditions: 208 | 209 | The above copyright notice and this permission notice shall be included in all 210 | copies or substantial portions of the Software. 211 | 212 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 213 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 214 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 215 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 216 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 217 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 218 | SOFTWARE. 219 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | NGROK_AUTH_TOKEN = "" 2 | # copy the auth token from https://dashboard.ngrok.com/get-started/your-authtoken 3 | # you don't need to fill ngrok auth token for debugging on local 4 | 5 | CONSUMER_KEY = "" 6 | CONSUMER_SECRET = "" 7 | ACCESS_KEY = "" 8 | ACCESS_SECRET = "" 9 | ENV_NAME = "" 10 | # create Account Activity API (AAPI) dev env on https://developer.twitter.com/en/account/environments 11 | # ENV_NAME is the same as Dev environment label 12 | # Check your AAPI subcription renewal date on https://developer.twitter.com/en/account/subscriptions 13 | 14 | Admin_id = [""] # list of str 15 | # Admin id is like sender id. To check it, send a menfess from your admin account. 16 | # IF YOU WANT TO TEST THE CONFIG, REMEMBER THIS! USERS IN ADMIN_ID PASS ALL USER'S FILTERS, you should delete your id on Admin_id 17 | 18 | Timezone = 7 19 | 20 | Notify_queue = True 21 | # bool, True: Send the menfess queue to sender 22 | # The first tweet in queue won't be notified to sender (the delay is very quick). 23 | Notify_queueMessage = "Menfess kamu berada pada urutan ke-{}, akan terkirim sekitar pukul {}.\nKirim '/cancel' untuk " \ 24 | "membatalkan menfess sebelum terkirim" 25 | # Please keep the "{}" format -> .format(queue, time) 26 | 27 | Notify_sent = True 28 | # bool, True: Send menfess tweet link to sender when menfess sent 29 | Notify_sentMessage = "Yeay! Menfess kamu telah terkirim! https://twitter.com/{}/status/" 30 | # Please keep the "{}" format -> .format(bot_username) + postid 31 | 32 | Notify_sentFail1 = "Maaf ada kesalahan pada sistem :( \ntolong screenshot & laporkan kepada admin" 33 | # Used when error is happened in system 34 | 35 | Interval_perSender = False # bool 36 | Interval_time = 5 # int 37 | # Interval time (in minute) of sending menfess per sender, Admin pass this filter 38 | Notify_intervalPerSender = "Mengirim menfess dibatasi! silakan coba lagi setelah pukul {}" 39 | # Please keep the "{}".format(time) 40 | 41 | Delay_time = 24 # int, seconds 42 | # Twitter API limits user to post tweet. System will delay 36s per/tweet. You can add it by input 43 | # seconds in Delay_time. e.g Delay_time = 24, it means system will delay 60 seconds per tweet 44 | # I advice you to fill Delay_time to avoid being banned from twitter 45 | 46 | # Welcome message to new followers 47 | Greet_newFollower = True 48 | Notif_newFollower = "Makasih yaa udah follow base ini :) \nJangan lupa baca peraturan base!" 49 | 50 | Keyword_deleter = False # Trigger word deleter 51 | # bool, True: Delete keyword from menfess before uploaded 52 | 53 | # send notif to user that followed by bot 54 | Greet_followed = True 55 | Notif_followed = "Yeay! kamu udah difollow base ini. Jangan lupa baca peraturan sebelum mengirim menfess yaa!" 56 | 57 | Minimum_lenMenfess = 0 # length of the menfess 58 | Maximum_lenMenfess = 1120 59 | Notify_lenMenfess = f"Maksimum jumlah karakter: {Maximum_lenMenfess}, Minimum jumlah karakter {Minimum_lenMenfess}" 60 | 61 | Only_QRTBaseTweet = False 62 | Notif_QRTBaseTweet = "Kamu hanya bisa mengirim url tweet dari base ini :(" 63 | 64 | Only_twitterUrl = True 65 | Notif_twitterUrl = "Kamu hanya bisa mengirim url yang berasal dari twitter :(" 66 | 67 | Verify_beforeSent = True 68 | Verify_beforeSentData = { 69 | 'text' : 'Baca dulu peraturan base di blabla. Kamu yakin mau mengirim menfess ini?', 70 | 'options' : [ 71 | { 72 | 'label' : 'ya', 73 | 'description' : 'melanjutkan untuk mengirim menfess', # max 72 chars (include space) 74 | 'metadata' : 'exec|self._verif_menfess("accept", sender_id)' 75 | }, 76 | { 77 | 'label' : 'tidak', 78 | 'description' : 'membatalkan untuk mengirim menfess', # max 72 chars (include space) 79 | 'metadata' : 'exec|self._verif_menfess("reject", sender_id)' 80 | } 81 | ] 82 | } 83 | # Please keep the metadata, Read metadata documentation at README.md 84 | 85 | Sender_requirements = False 86 | # bool, True: sender should passes the following requirements: (admin pass this filter) 87 | Only_followed = False 88 | Notif_notFollowed = "Hmm, kamu belum difollow base ini. Jadi ga bisa ngirim menfess dehh :(" 89 | # Minimum_followers and Minimum_day is (automatically) required when Sender_requirements is True. 90 | Minimum_followers = 0 # int 91 | # Minimum-account-created-at 92 | Minimum_day = 0 # e.g 100, it means sender account must be created at 100 days ago 93 | Notify_senderRequirements = f"Kamu harus punya {Minimum_followers} followers dan umur akun kamu harus \ 94 | lebih dari {Minimum_day} hari biar bisa ngirim menfess :(" 95 | 96 | Private_mediaTweet = False 97 | # bool, True: Delete username on the bottom of the attached video tweet. 98 | # Usually when sender want to attach video (from tweet), they will attach a media url 99 | # But the username of the (VIDEO) OWNER is mentioned on the bottom of video. With this 100 | # when sender attach (media and/or media tweet) and if total of media ids are more than 101 | # 4 or the space is not available, THE REST OF THE MEDIA WILL BE ATTACHED TO THE 102 | # SUBSEQUENT TWEETS IN SORTED ORDER. 103 | 104 | Watermark = False 105 | # bool, True: Add watermark text to menfess's photo 106 | Watermark_data = { 107 | 'image' : 'twitter_autobase/watermark/photo.png', # bool (True: default image, False: no image) or image file path (str) 108 | 'text' : 'lorem ipsum', # if you won't to add text, fill it with empty string '' 109 | 'font' : 'twitter_autobase/watermark/FreeMono.ttf', # font file path 110 | 'textColor' : (100,0,0,1), # RGBA 111 | 'textStroke': (0,225,225,1), # RGBA 112 | 'ratio' : 0.15, # ratio between watermark and photo (float number under 1) 113 | 'position' : ('right', 'bottom'), # (x, y) | x: 'left', 'center', 'right' | y: 'top', 'center', 'bottom' 114 | } 115 | 116 | Account_status = True 117 | # bool, True: Turn on the automenfess. If it turned into False, this bot won't 118 | # post menfess. You can switch it using '/switch on/off' command from DM 119 | Notify_accountStatus = "Automenfess sedang dimatikan oleh admin, silakan cek tweet terbaru atau \ 120 | hubungi admin untuk informasi lebih lanjut" 121 | 122 | Off_schedule = False 123 | # schedule automenfess to turned off 124 | Off_scheduleData = { 125 | 'start' : ('21', '06'), # ('hour', 'minute') 126 | 'different_day' : True, 127 | 'end' : ('04', '36'), # ('hour', 'minute') 128 | } 129 | Off_scheduleMsg = f"Automenfess dimatikan setiap pukul {Off_scheduleData['start'][0]}:{Off_scheduleData['start'][1]} \ 130 | sampai dengan pukul {Off_scheduleData['end'][0]}:{Off_scheduleData['end'][1]}" 131 | 132 | Trigger_word = ["fess!", "blablabla!"] 133 | Notify_wrongTrigger = { 134 | 'user' : True, # send notif to user 135 | 'admin' : False, # send wrong trigger menfess to admin 136 | 'message' : "Trigger menfess tidak terdeteksi", 137 | } 138 | 139 | Sensitive_word = "/sensitive" 140 | # Used when sender send sensitive content, order them to use this word 141 | # But I advise against sending sensitive content, Twitter may ban your account, 142 | # And using this bot for 'adult' base is strictly prohibited. 143 | Blacklist_words = ['covid', 'blablabla'] 144 | # hashtags and mentions will be changed to "#." and "@." 145 | Notify_blacklistWords = "di menfess kamu terdapat blacklist words, jangan lupa baca peraturan base yaa!" 146 | Notify_blacklistWordsAdmin = False # Will be sent to admin 147 | 148 | # Please set Admin_cmd and User_cmd in lowercase 149 | # Read README.md for the DMs examples 150 | # You can move Admin_cmd to User_cmd and vice versa 151 | 152 | Admin_cmd = { 153 | '/add_blacklist' : 'self._add_blacklist(arg)', # /add_blacklist word1 word2 word-n 154 | '/rm_blacklist' : 'self._rm_blacklist(arg)', # /rm_blacklist word1 word2 wordn 155 | '/display_blacklist': 'self._display_blacklist(sender_id)', # /display_blacklist 156 | '/who' : 'self._who_sender(sender_id, urls)', # /who tweet_url 157 | '/add_admin' : 'self._add_admin(arg)', # /add_admin username1 username2 username-n 158 | '/rm_admin' : 'self._rm_admin(arg)', # /rm_admin username username2 username-n 159 | '/switch' : 'self._switch_status(arg)', # /switch on | /switch off 160 | '/block' : 'self._block_user(sender_id, urls)', # /block tweet_url 161 | '/unfoll' : 'self._unfoll_user(sender_id, urls)', # /unfoll tweet_url 162 | '/follow' : 'self._foll_user(arg)', #/follow username 163 | } 164 | # if arg argument exists on method call, the terminal message will be sent to sender (admin). 165 | # You can prevent it by adding #no_notif after the method call. 166 | # /who is only available for one day (reset every midnight or heroku dyno cycling) 167 | 168 | User_cmd = { 169 | '/delete' : 'self._delete_menfess(sender_id, urls)', # /delete tweet_url 170 | '/unsend' : 'self._unsend_menfess(sender_id)', # /unsend 171 | '/menu' : 'self._menu_dm(sender_id)', # /menu 172 | '/cancel' : 'self._cancel_menfess(sender_id)', # /cancel 173 | } 174 | # /delete and /unsend is not available for user when bot was just started and user id not in db_sent 175 | # /delete & db_sent are only available for one day (reset every midnight or heroku dyno cycling) 176 | Notif_DMCmdDelete = { 177 | 'succeed' : 'Yeay! Menfess kamu sudah berhasil dihapus', 178 | 'failed' : 'Duh! Menfess ini ngga bisa kamu hapus :(' 179 | } 180 | # Notif_DMCmdDelete is only for user, '/unsend' using this notif too 181 | Notif_DMCmdCancel = { 182 | 'succeed' : 'Yeay! Menfess kamu berhasil di-cancel', 183 | 'failed' : 'Duh! Menfess kamu ngga bisa di-cancel', 184 | 'on_process': 'Duh! Menfess kamu lagi diproses, kirim "/unsend" setelah menfess terkirim', 185 | } 186 | 187 | # Max 20 options, Max 72 chars description, Please keep the metadata, Read metadata doc at README.md 188 | # When user click the button, It is automatically sent to webhook (dont use if command has an argument e.g. /delete (url)) 189 | DMCmdMenu = { 190 | 'text' : 'Kamu bisa mengirim beberapa command secara langsung, atau menulis manual:\n' 191 | '/delete (url) : menghapus menfess dengan menyertakan url\n', 192 | 'options' : [ 193 | { 194 | 'label' : 'unsend', 195 | 'description' : 'menghapus menfess terakhir yang telah terkirim', 196 | 'metadata' : 'exec|self._button_command(sender_id, "/unsend")' 197 | }, 198 | { 199 | 'label' : 'cancel', 200 | 'description' : 'Menghapus menfess sebelum terkirim', 201 | 'metadata' : 'exec|self._button_command(sender_id, "/cancel")' 202 | }, 203 | ] 204 | } 205 | -------------------------------------------------------------------------------- /twitter_autobase/dm_command.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | from typing import NoReturn 3 | import re 4 | 5 | 6 | class DMCommand(ABC): 7 | ''' 8 | Command that can be accessed from direct message, you can manage command on config.py 9 | ''' 10 | api: object = None 11 | credential: object = None 12 | db_deleted: dict = None 13 | db_sent: dict = None 14 | dms: list = None 15 | 16 | @abstractmethod 17 | def get_user_screen_name(self, id): 18 | pass 19 | 20 | @abstractmethod 21 | def send_dm(self, recipient_id, text, quick_reply_type=None, quick_reply_data=None, 22 | attachment_type=None, attachment_media_id=None) -> NoReturn: 23 | pass 24 | 25 | @abstractmethod 26 | def db_sent_updater(self, action, sender_id, postid, list_postid_thread=list()) -> NoReturn: 27 | pass 28 | 29 | def _add_blacklist(self, word: str) -> NoReturn: 30 | ''' 31 | :param word: word that will be added to Blacklist_words 32 | ''' 33 | word = word.replace("_", " ") 34 | self.credential.Blacklist_words.append(word) 35 | 36 | def _rm_blacklist(self, word: str) -> NoReturn: 37 | ''' 38 | :param word: word that will be deleted from Blacklist_words 39 | ''' 40 | word = word.replace("_", " ") 41 | self.credential.Blacklist_words.remove(word) 42 | 43 | def _display_blacklist(self, sender_id: str) -> NoReturn: 44 | ''' 45 | Send list of blacklist words to sender 46 | ''' 47 | self.send_dm(sender_id, str(self.credential.Blacklist_words)) 48 | 49 | def _who_sender(self, sender_id: str, urls: list) -> NoReturn: 50 | '''Check who was sent the menfess 51 | :param urls: list of urls from dm 52 | ''' 53 | if len(urls) == 0: 54 | raise Exception("Tweet link is not mentioned") 55 | 56 | for i in urls: 57 | url = i["expanded_url"] 58 | postid = str() 59 | 60 | if "?" in url: 61 | postid = re.sub("[/?]", " ", url).split()[-2] 62 | else: 63 | postid = url.split("/")[-1] 64 | 65 | for req_senderId in self.db_sent.keys(): 66 | if postid in self.db_sent[req_senderId].keys(): 67 | username = self.get_user_screen_name(req_senderId) 68 | text = f"username: @{username}\nid: {req_senderId}\nstatus: exists {url}" 69 | self.send_dm(sender_id, text) 70 | return 71 | 72 | for req_senderId in self.db_deleted.keys(): 73 | if postid in self.db_deleted[req_senderId]: 74 | username = self.get_user_screen_name(req_senderId) 75 | text = f"username @{username}\nid: {req_senderId}\nstatus: deleted\nurl: {url}" 76 | self.send_dm(sender_id, text) 77 | return 78 | 79 | raise Exception("menfess is not found in db_sent or db_deleted") 80 | 81 | def _add_admin(self, username: str) -> NoReturn: 82 | ''' 83 | Add (append) user to credential.Admin_id 84 | :param username: username without '@' 85 | ''' 86 | user = (self.api.get_user(screen_name=username))._json 87 | self.credential.Admin_id.append(str(user['id'])) 88 | 89 | def _rm_admin(self, username: str) -> NoReturn: 90 | ''' 91 | :param username: username without '@' 92 | ''' 93 | user = (self.api.get_user(screen_name=username))._json 94 | self.credential.Admin_id.remove(str(user['id'])) 95 | 96 | def _switch_status(self, arg: str) -> NoReturn: 97 | ''' 98 | :param arg: 'on' or 'off' 99 | ''' 100 | if arg.lower() == "on": 101 | self.credential.Account_status = True 102 | elif arg.lower() == "off": 103 | self.credential.Account_status = False 104 | else: 105 | raise Exception("available parameters are on or off") 106 | 107 | def __delete_tweet(self, postid: str, list_postid_thread: list) -> NoReturn: 108 | try: 109 | for postidx in [postid] + list_postid_thread: 110 | self.api.destroy_status(postidx) # It doesn't matter when error happen here 111 | except Exception as ex: 112 | raise Exception(f"You can't delete this tweet. Error: {ex}") 113 | 114 | def __delete_menfess(self, sender_id: str, urls: list) -> str: 115 | '''Delete tweet 116 | :param urls: list of urls from dm 117 | ''' 118 | if sender_id not in self.db_sent and sender_id not in self.credential.Admin_id: 119 | raise Exception("sender_id not in db_sent") 120 | if len(urls) == 0: 121 | raise Exception("Tweet link is not mentioned") 122 | 123 | for i in urls: 124 | if "?" in i["expanded_url"]: 125 | postid = re.sub("[/?]", " ", i["expanded_url"]).split()[-2] 126 | else: 127 | postid = i["expanded_url"].split("/")[-1] 128 | 129 | if sender_id in self.db_sent: # user has sent menfess 130 | if postid in self.db_sent[sender_id]: # normal succes 131 | list_postid_thread = self.db_sent[sender_id][postid] 132 | self.db_sent_updater('add_deleted', sender_id, postid) 133 | self.db_sent_updater('delete_sent', sender_id, postid) 134 | self.__delete_tweet(postid, list_postid_thread) 135 | return sender_id 136 | 137 | elif sender_id not in self.credential.Admin_id: # normal trying other menfess 138 | raise Exception("sender doesn't have access to delete postid") 139 | 140 | elif sender_id not in self.credential.Admin_id: # user hasn't sent menfess 141 | raise Exception("sender doesn't have access to delete postid") 142 | 143 | # administrator mode 144 | found = 0 # sender_id that will be searched 145 | for req_senderId in self.db_sent.keys(): 146 | if postid in self.db_sent[req_senderId].keys(): 147 | found = req_senderId 148 | break 149 | 150 | if found != 0: 151 | list_postid_thread = self.db_sent[found][postid] 152 | self.db_sent_updater('add_deleted', found, postid) 153 | self.db_sent_updater('delete_sent', found, postid) 154 | else: 155 | list_postid_thread = list() 156 | print("admin mode: directly destroy_status") 157 | 158 | self.__delete_tweet(postid, list_postid_thread) 159 | if found != 0: return found 160 | 161 | def _delete_menfess(self, sender_id: str, urls: list) -> NoReturn: 162 | try: 163 | self.__delete_menfess(sender_id, urls) 164 | except Exception as e: 165 | self.send_dm(sender_id, self.credential.Notif_DMCmdDelete['failed']) 166 | raise e 167 | else: 168 | self.send_dm(sender_id, self.credential.Notif_DMCmdDelete['succeed']) 169 | 170 | def _unsend_menfess(self, sender_id: str) -> NoReturn: 171 | ''' 172 | Delete the last tweet that sent by sender 173 | ''' 174 | try: 175 | last_postid = list(self.db_sent[sender_id])[-1] 176 | list_postid_thread = self.db_sent[sender_id][last_postid] 177 | self.db_sent_updater('add_deleted', sender_id, last_postid) 178 | self.db_sent_updater('delete_sent', sender_id, last_postid) 179 | self.__delete_tweet(last_postid, list_postid_thread) 180 | except Exception as e: 181 | self.send_dm(sender_id, self.credential.Notif_DMCmdDelete['failed']) 182 | raise e 183 | else: 184 | self.send_dm(sender_id, self.credential.Notif_DMCmdDelete['succeed']) 185 | 186 | def _menu_dm(self, sender_id) -> NoReturn: 187 | ''' 188 | Send command's menu to sender 189 | ''' 190 | self.send_dm(sender_id, self.credential.DMCmdMenu['text'], quick_reply_type='options', 191 | quick_reply_data=self.credential.DMCmdMenu['options']) 192 | 193 | def _block_user(self, sender_id, urls) -> NoReturn: 194 | ''' 195 | Delete menfess and block the sender 196 | ''' 197 | sender_idx = self.__delete_menfess(sender_id, urls) 198 | if sender_idx is None: 199 | raise Exception("sender_id not found") 200 | elif sender_idx in self.credential.Admin_id: 201 | raise Exception("You can't block Admin") 202 | else: 203 | username = self.get_user_screen_name(sender_idx) 204 | 205 | try: 206 | self.api.create_block(user_id=sender_idx) 207 | except: 208 | raise Exception("You can't block the sender") 209 | else: 210 | self.send_dm(sender_id, f'username: @{username}\nid: {sender_idx}\nstatus: blocked') 211 | 212 | def _unfoll_user(self, sender_id, urls) -> NoReturn: 213 | ''' 214 | Delete menfess and unfoll the sender 215 | ''' 216 | sender_idx = self.__delete_menfess(sender_id, urls) 217 | if sender_idx is None: 218 | raise Exception("sender_id not found") 219 | else: 220 | username = self.get_user_screen_name(sender_idx) 221 | 222 | try: 223 | self.api.destroy_friendship(user_id=sender_idx) 224 | except: 225 | raise Exception("You can't unfollow the sender") 226 | else: 227 | self.send_dm(sender_id, f'username: @{username}\nid: {sender_idx}\nstatus: unfollowed') 228 | 229 | def _foll_user(self, username: str) -> NoReturn: 230 | ''' 231 | Follow user 232 | :param username: username without '@' 233 | ''' 234 | user = (self.api.get_user(screen_name=username))._json 235 | self.api.create_friendship(str(user['id'])) 236 | 237 | def _cancel_menfess(self, sender_id) -> NoReturn: 238 | ''' 239 | Cancel menfess when it's still on self.dms queue 240 | ''' 241 | for x in self.dms.copy()[::-1]: 242 | if x['sender_id'] == sender_id: 243 | if x['posting']: 244 | self.send_dm(sender_id, self.credential.Notif_DMCmdCancel['on_process']) 245 | return 246 | self.dms.remove(x) 247 | self.send_dm(sender_id, self.credential.Notif_DMCmdCancel['succeed']) 248 | break 249 | else: 250 | self.send_dm(sender_id, self.credential.Notif_DMCmdCancel['failed']) 251 | -------------------------------------------------------------------------------- /twitter_autobase/main.py: -------------------------------------------------------------------------------- 1 | # Original code by Prieyudha Akadita S. 2 | # Source: https://https://github.com/ydhnwb/autodm_base 3 | 4 | # Re-code by Fakhri Catur Rofi under MIT License 5 | # Source: https://github.com/fakhrirofi/twitter_autobase 6 | 7 | from .clean_dm_autobase import delete_trigger_word 8 | from .process_dm import ProcessDM 9 | from .twitter import Twitter 10 | from datetime import datetime, timezone, timedelta 11 | from threading import Thread, Lock 12 | from time import sleep 13 | from typing import NoReturn 14 | import logging 15 | import traceback 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | class Autobase(Twitter, ProcessDM): 20 | ''' 21 | Attributes: 22 | - credential 23 | - bot_username 24 | - bot_id 25 | - db_sent 26 | - db_deleted 27 | - dms 28 | - prevent_loop 29 | - indicator 30 | 31 | :param credential: object that contains attributes like config.py 32 | ''' 33 | prevent_loop = list() # list of all bot_id (str) that runs using this bot to prevent loop messages from each accounts 34 | 35 | def __init__(self, credential: object): 36 | ''' 37 | :param credential: object that contains attributes like config.py 38 | ''' 39 | super().__init__(credential) 40 | self.bot_username = self.me.screen_name 41 | self.bot_id = str(self.me.id) 42 | self.db_intervalTime = dict() 43 | self.db_sent = dict() # { 'sender_id': {'postid': [list_postid_thread],}, } 44 | self.db_deleted = dict() # { 'sender_id': ['postid',] } 45 | self.dms = list() # list of filtered dms that processed by process_dm 46 | self._tmp_dms = list() # used if Verify_beforeSent is True 47 | self.indicator = { 48 | 'day': (datetime.now(timezone.utc) + timedelta(hours=credential.Timezone)).day, 49 | 'automenfess': 0, 50 | } 51 | self._lock = Lock() 52 | self.add_prevent_loop(self.bot_id) 53 | 54 | @classmethod 55 | def add_prevent_loop(cls, bot_id): 56 | cls.prevent_loop.append(bot_id) 57 | 58 | def notify_follow(self, follow_events: dict) -> NoReturn: 59 | ''' 60 | Notify user when he follows the bot and followed by the bot 61 | :param follow_events: dict of 'follow_events' from self.webhook_connector 62 | ''' 63 | if follow_events['follow_events'][0]['type'] != 'follow': 64 | return 65 | 66 | # id of the user who clicks 'follow' buttons 67 | source_id = follow_events['follow_events'][0]['source']['id'] 68 | 69 | # Greet to new follower 70 | if source_id not in self.prevent_loop: # user is not bot 71 | if self.credential.Greet_newFollower: 72 | self.send_dm(source_id, self.credential.Notif_newFollower) 73 | 74 | # Notify user followed by bot, (admin of the bot clicks follow buttons on user profile) 75 | # elif source_id in self.prevent_loop: 76 | elif self.credential.Greet_followed: 77 | target_id = follow_events['follow_events'][0]['target']['id'] 78 | 79 | if target_id not in self.prevent_loop: 80 | self.send_dm(target_id, self.credential.Notif_followed) 81 | 82 | def db_sent_updater(self, action: str, sender_id=str(), postid=str(), list_postid_thread=list()) -> NoReturn: 83 | '''Update self.db_sent and self.db_deleted 84 | :param action: 'update','add_sent', 'add_deleted' or 'delete_sent' 85 | :param sender_id: sender id who has sent the menfess 86 | :param postid: main post id 87 | :param list_postid_thread: list of post id after the main post id (if user sends menfess that contains characters > 280) 88 | ''' 89 | try: 90 | if action == 'update': 91 | day = (datetime.now(timezone.utc) + timedelta(hours=self.credential.Timezone)).day 92 | if day != self.indicator['day']: 93 | self.indicator['day'] = day 94 | self.db_sent.clear() 95 | self.db_deleted.clear() 96 | 97 | elif action == 'add_sent': 98 | if sender_id not in self.db_sent: # require sender_id, postid, list_postid_thread 99 | self.db_sent[sender_id] = {postid: list_postid_thread} 100 | else: self.db_sent[sender_id][postid] = list_postid_thread 101 | 102 | elif action == 'add_deleted': # require sender_id and postid 103 | if sender_id not in self.db_deleted: 104 | self.db_deleted[sender_id] = [postid] 105 | else: self.db_deleted[sender_id] += [postid] 106 | 107 | elif action == 'delete_sent': # require sender_id and postid 108 | del self.db_sent[sender_id][postid] 109 | if len(self.db_sent[sender_id]) == 0: 110 | del self.db_sent[sender_id] 111 | 112 | except: 113 | logger.error(traceback.format_exc()) 114 | 115 | def notify_queue(self, dm: dict, queue: int) -> NoReturn: 116 | """Notify the menfess queue to sender 117 | :param dm: dict that returned from process_dm 118 | :param queue: the number of queue (len of self.dms) 119 | """ 120 | try: 121 | x, y, z = queue, queue, 0 122 | # x is primary time (36 sec); y is queue; z is addition time for media 123 | time = datetime.now(timezone.utc) + timedelta(hours=self.credential.Timezone) 124 | 125 | y += 1 126 | x += (len(dm['message']) // 272) + 1 127 | if dm['media_url'] != None: 128 | z += 3 129 | 130 | if self.credential.Private_mediaTweet: 131 | z += len(dm['attachment_urls']['media']) * 3 132 | 133 | sent_time = time + timedelta(seconds= x * (37 + self.credential.Delay_time) + z) 134 | sent_time = datetime.strftime(sent_time, '%H:%M') 135 | notif = self.credential.Notify_queueMessage.format(str(y), sent_time) 136 | self.send_dm(dm['sender_id'], notif) 137 | 138 | except: 139 | logger.error(traceback.format_exc()) 140 | 141 | def transfer_dm(self, dm) -> NoReturn: 142 | ''' 143 | Append dm dict to self.dms 144 | ''' 145 | if self.credential.Notify_queue: 146 | # notify queue to sender 147 | self.notify_queue(dm, queue=len(self.dms)) 148 | self.dms.append(dm) 149 | if self.indicator['automenfess'] == 0: 150 | self.indicator['automenfess'] = 1 151 | Thread(target=self.start_automenfess).start() 152 | 153 | def webhook_connector(self, raw_data: dict) -> NoReturn: 154 | ''' 155 | Method that will be called by webhook to sends data to Autobase, the process must be separated by Thread 156 | or Process(if there is a Database app i.e. Postgres) 157 | :param raw_data: dict from webhook 158 | ''' 159 | # https://developer.twitter.com/en/docs/twitter-api/enterprise/account-activity-api/guides/account-activity-data-objects 160 | if 'direct_message_events' in raw_data: 161 | dm = self.process_dm(raw_data) # Inherited from ProcessDM class 162 | if dm != None: 163 | if self.credential.Verify_beforeSent: 164 | button = self.credential.Verify_beforeSentData 165 | self.send_dm(dm['sender_id'], button['text'], quick_reply_type='options', 166 | quick_reply_data=button['options']) 167 | self._tmp_dms.append(dm) 168 | else: 169 | self.transfer_dm(dm) 170 | 171 | elif 'follow_events' in raw_data: 172 | self.notify_follow(raw_data) 173 | 174 | def start_automenfess(self) -> NoReturn: 175 | ''' 176 | Process data from self.dms, the process must be separated by Thread or Process(if there is a Database app i.e. Postgres) 177 | ''' 178 | while len(self.dms): 179 | self.dms[0]['posting'] = True 180 | dm = self.dms[0] 181 | try: 182 | message = dm['message'] 183 | sender_id = dm['sender_id'] 184 | media_url = dm['media_url'] 185 | attachment_urls = dm['attachment_urls']['tweet'] 186 | list_attchmentUrlsMedia = dm['attachment_urls']['media'] 187 | 188 | if self.credential.Keyword_deleter: 189 | message = delete_trigger_word(message, self.credential.Trigger_word) 190 | 191 | # Cleaning attachment_url 192 | if attachment_urls != (None, None): 193 | message = message.split(" ") 194 | for x in message.copy(): 195 | if attachment_urls[0] in x: 196 | message.remove(x) 197 | break 198 | message = " ".join(message) 199 | 200 | # Cleaning hashtags and mentions 201 | message = message.replace("#", "#.") 202 | message = message.replace("@", "@.") 203 | 204 | # Private_mediaTweet 205 | media_idsAndTypes = list() # e.g [(media_id, media_type), (media_id, media_type), ] 206 | if self.credential.Private_mediaTweet: 207 | for media_tweet_url in list_attchmentUrlsMedia: 208 | list_mediaIdsAndTypes = self.upload_media_tweet(media_tweet_url[1]) 209 | if len(list_mediaIdsAndTypes): 210 | media_idsAndTypes.extend(list_mediaIdsAndTypes) 211 | message = message.split(" ") 212 | message.remove(media_tweet_url[0]) 213 | message = " ".join(message) 214 | 215 | # Menfess contains sensitive contents 216 | possibly_sensitive = False 217 | if self.credential.Sensitive_word.lower() in message.lower(): 218 | possibly_sensitive = True 219 | 220 | # POST TWEET 221 | print("Posting menfess...") 222 | response = self.post_tweet(message, sender_id, media_url=media_url, attachment_url=attachment_urls[1], 223 | media_idsAndTypes=media_idsAndTypes, possibly_sensitive=possibly_sensitive) 224 | 225 | # NOTIFY MENFESS SENT OR NOT 226 | # Ref: https://github.com/azukacchi/twitter_autobase/blob/master/main.py 227 | if response['postid'] != None: 228 | if self.credential.Notify_sent: 229 | notif = self.credential.Notify_sentMessage.format(self.bot_username) 230 | text = notif + str(response['postid']) 231 | self.send_dm(sender_id, text) 232 | # ADD TO DB_SENT 233 | self.db_sent_updater('add_sent', sender_id, response['postid'], response['list_postid_thread']) 234 | elif response['postid'] == None: 235 | # Error happen on system 236 | self.send_dm(sender_id, self.credential.Notify_sentFail1) 237 | else: 238 | # credential.Notify_sent is False 239 | pass 240 | 241 | except: 242 | self.send_dm(sender_id, self.credential.Notify_sentFail1) 243 | logger.critical(traceback.format_exc()) 244 | 245 | finally: 246 | # self.notify_queue is using len of dms to count queue, it's why the dms[0] deleted here 247 | del self.dms[0] 248 | 249 | self.indicator['automenfess'] = 0 250 | -------------------------------------------------------------------------------- /twitter_autobase/process_dm.py: -------------------------------------------------------------------------------- 1 | from .dm_command import DMCommand 2 | from .quick_reply import ProcessQReply 3 | from abc import abstractmethod, ABC 4 | from datetime import datetime, timezone, timedelta 5 | from time import sleep 6 | import logging 7 | import traceback 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class ProcessDM(ProcessQReply, DMCommand, ABC): 12 | api: object = None 13 | bot_username: str = None 14 | credential: object = None 15 | db_intervalTime: dict = None 16 | prevent_loop: list = None 17 | _lock: object = None 18 | 19 | @abstractmethod 20 | def send_dm(self, recipient_id, text, quick_reply_type=None, quick_reply_data=None, 21 | attachment_type=None, attachment_media_id=None): 22 | pass 23 | 24 | @abstractmethod 25 | def get_user_screen_name(self, id): 26 | pass 27 | 28 | @abstractmethod 29 | def db_sent_updater(self, action=str(), sender_id=str(), postid=str(), list_postid_thread=list()): 30 | pass 31 | 32 | def _command(self, sender_id: str, message: str, message_data: dict) -> bool: 33 | ''' 34 | Process command (DMCmd) that sent from dm 35 | :param sender_id: id of account who sends the message 36 | :param message: message text 37 | :param message_data: dict of message data 38 | :return: bool, True: There is a command, False: There is no command 39 | ''' 40 | list_command = list(self.credential.Admin_cmd) + list(self.credential.User_cmd) 41 | command = message.split(" ")[0].lower() 42 | 43 | if command not in list_command: 44 | return False 45 | 46 | if command in self.credential.Admin_cmd: 47 | if sender_id not in self.credential.Admin_id: 48 | return False 49 | dict_command = self.credential.Admin_cmd 50 | else: 51 | dict_command = self.credential.User_cmd 52 | 53 | contents = message.split(" ")[1:] 54 | print(f"command {command} {str(contents)} in progress...") 55 | notif = str() # terminal message 56 | if len(contents): 57 | urls = message_data["entities"]["urls"] #pylint: disable=unused-variable 58 | for arg in contents: 59 | try: 60 | notif += f"\nprocessed: {command} {arg}" 61 | exec(dict_command[command]) 62 | if "urls" in dict_command[command] or "arg" not in dict_command[command]: 63 | break 64 | except Exception as ex: 65 | logger.warning(ex) 66 | notif += f"\nException: {ex}" 67 | else: 68 | try: 69 | notif += f"\nprocessed: {command}" 70 | exec(dict_command[command]) 71 | except Exception as ex: 72 | logger.warning(ex) 73 | notif += f"\nException: {ex}" 74 | 75 | if sender_id not in self.credential.Admin_id: 76 | return True 77 | if "#no_notif" in dict_command[command]: 78 | return True 79 | if "arg" not in dict_command[command]: 80 | if "Exception" not in notif: 81 | return True 82 | 83 | self.send_dm(sender_id, notif) 84 | return True 85 | 86 | 87 | def __check_off_schedule(self, sender_id: str, date_now: object) -> bool: 88 | off_data = self.credential.Off_scheduleData 89 | delta_start_day = 0 90 | delta_end_day = 0 91 | if off_data['different_day']: 92 | if date_now.hour < int(off_data['end'][0]) and date_now.minute < int(off_data['end'][1]): 93 | # at the beginning of midnight until the end of schedule 94 | delta_start_day -= 1 95 | else: 96 | delta_end_day += 1 97 | 98 | off_start = date_now.replace(hour=int(off_data['start'][0]), minute=int(off_data['start'][1])) \ 99 | + timedelta(days=delta_start_day) 100 | 101 | off_end = date_now.replace(hour=int(off_data['end'][0]), minute=int(off_data['end'][1])) \ 102 | + timedelta(days=delta_end_day) 103 | 104 | if date_now > off_start and date_now < off_end: 105 | print("Off_schedule is active") 106 | self.send_dm(sender_id, self.credential.Off_scheduleMsg) 107 | return True 108 | else: 109 | return False 110 | 111 | 112 | def __user_filter(self, sender_id: str, message: str) -> bool: 113 | ''' 114 | Filter user requirements and rules which has been set on config.py 115 | :return: bool, True: dm shouldn't be processed, False: dm should be processed 116 | ''' 117 | 118 | if sender_id in self.credential.Admin_id: 119 | return False 120 | 121 | # Account_status 122 | if not self.credential.Account_status: 123 | print("Account_status: False") 124 | self.send_dm(sender_id, self.credential.Notify_accountStatus) 125 | return True 126 | 127 | # DATA 128 | username = 0 # Will be edited on requirements or used on blacklist words, to make get_user effectively 129 | date_now = datetime.now(timezone.utc) + timedelta(hours=self.credential.Timezone) 130 | # Used on Off schedule, interval per sender, and sender requirements (minimum age) 131 | 132 | # Off schedule 133 | if self.credential.Off_schedule: 134 | if self.__check_off_schedule(sender_id, date_now): 135 | return True 136 | 137 | # Interval time per sender 138 | if self.credential.Interval_perSender: 139 | with self._lock: # pylint: disable=not-context-manager 140 | for i in list(self.db_intervalTime): 141 | # cleaning self.db_intervalTime 142 | if self.db_intervalTime[i] < date_now: 143 | del self.db_intervalTime[i] 144 | 145 | if sender_id in self.db_intervalTime: 146 | free_time = datetime.strftime(self.db_intervalTime[sender_id], '%H:%M') 147 | notif = self.credential.Notify_intervalPerSender.format(free_time) 148 | self.send_dm(sender_id, notif) 149 | return True 150 | else: 151 | self.db_intervalTime[sender_id] = date_now + timedelta(minutes=self.credential.Interval_time) 152 | 153 | # Minimum/Maximum lenMenfess 154 | if len(message) < self.credential.Minimum_lenMenfess or len(message) > self.credential.Maximum_lenMenfess: 155 | self.send_dm(sender_id, self.credential.Notify_lenMenfess) 156 | return True 157 | 158 | # SENDER REQUIREMENTS 159 | if self.credential.Sender_requirements: 160 | user = (self.api.get_user(sender_id))._json 161 | username = user['screen_name'] 162 | 163 | # only followed 164 | if self.credential.Only_followed: 165 | if user['following'] is False: 166 | self.send_dm(sender_id, self.credential.Notif_notFollowed) 167 | return True 168 | 169 | # minimum followers 170 | if user['followers_count'] < self.credential.Minimum_followers: 171 | self.send_dm(sender_id, self.credential.Notify_senderRequirements) 172 | return True 173 | 174 | # minimum age 175 | created_at = datetime.strptime(user['created_at'], '%a %b %d %H:%M:%S +0000 %Y') 176 | date_now_req = date_now.replace(tzinfo=None) 177 | 178 | if (date_now_req - created_at).days < self.credential.Minimum_day: 179 | self.send_dm(sender_id, self.credential.Notify_senderRequirements) 180 | return True 181 | 182 | # BLACKLIST WORDS 183 | list_blacklist = [i.lower() for i in self.credential.Blacklist_words] 184 | if any(i in message.lower() for i in list_blacklist): 185 | self.send_dm(sender_id, self.credential.Notify_blacklistWords) 186 | if self.credential.Notify_blacklistWordsAdmin: 187 | if username == 0: 188 | username = self.get_user_screen_name(sender_id) 189 | for id in self.credential.Admin_id: 190 | self.send_dm(id, f"{message}\nstatus: blacklistWords\nfrom: @{username}\nid: {sender_id}") 191 | 192 | return True 193 | 194 | # All filters were processed 195 | return False 196 | 197 | 198 | def __menfess_trigger(self, sender_id: str, message: str, message_data: dict) -> dict or None: 199 | ''' 200 | Clean data from raw message_data, Contains __user_filter method call 201 | :return: dict dm that contains menfess trigger or None 202 | ''' 203 | if any(j.lower() in message.lower() for j in self.credential.Trigger_word): 204 | # User Filter 205 | if self.__user_filter(sender_id, message): 206 | return None 207 | 208 | dict_dm = dict(message=message, sender_id=sender_id, posting=False, 209 | media_url=None, attachment_urls={'tweet':(None, None), 210 | 'media':list()}) 211 | # 'tweet' and 'media': (url in message, the real url) 212 | # attachment url 213 | urls = message_data['entities']['urls'] 214 | for i in urls: 215 | if "twitter.com/" in i['expanded_url'] and "/status/" in i['expanded_url']: 216 | # i['url]: url in text message 217 | # Media tweet 218 | if any(j in i['expanded_url'] for j in ['/video/', '/photo/', '/media/']): 219 | dict_dm['attachment_urls']['media'].append((i['url'], i['expanded_url'])) 220 | #i['expanded_url'] e.g https://twitter.com/username/status/123/photo/1 221 | # Tweet 222 | else: 223 | # Only_QRTBaseTweet 224 | if self.credential.Only_QRTBaseTweet and sender_id not in self.credential.Admin_id: 225 | if self.bot_username not in i['expanded_url']: 226 | self.send_dm(sender_id, self.credential.Notif_QRTBaseTweet) 227 | return None 228 | dict_dm['attachment_urls']['tweet'] = (i['url'], i['expanded_url']) 229 | #i['expanded_url'] e.g https://twitter.com/username/status/123?s=19 230 | # Only_twitterUrl 231 | elif "twitter.com/" not in i['expanded_url']: 232 | if self.credential.Only_twitterUrl and sender_id not in self.credential.Admin_id: 233 | self.send_dm(sender_id, self.credential.Notif_twitterUrl) 234 | return None 235 | 236 | # attachment media 237 | if 'attachment' in message_data: 238 | media = message_data['attachment']['media'] 239 | media_type = media['type'] 240 | if media_type == 'photo': 241 | dict_dm['media_url'] = media['media_url'] 242 | elif media_type == 'video': 243 | media_urls = media['video_info']['variants'] 244 | temp_bitrate = list() 245 | for varian in media_urls: 246 | if varian['content_type'] == "video/mp4": 247 | temp_bitrate.append((varian['bitrate'], varian['url'])) 248 | # sort to choose the highest bitrate 249 | temp_bitrate.sort() 250 | dict_dm['media_url'] = temp_bitrate[-1][1] 251 | elif media_type == 'animated_gif': 252 | dict_dm['media_url'] = media['video_info']['variants'][0]['url'] 253 | 254 | return dict_dm 255 | 256 | # WRONG TRIGGER 257 | else: 258 | if self.credential.Notify_wrongTrigger['user']: 259 | self.send_dm(sender_id, self.credential.Notify_wrongTrigger['message']) 260 | 261 | if self.credential.Notify_wrongTrigger['admin']: 262 | # Send wrong menfess to admin 263 | username = self.get_user_screen_name(sender_id) 264 | notif = message + f"\nstatus: wrong trigger\nfrom: @{username}\nid: {sender_id}" 265 | for admin in self.credential.Admin_id: 266 | self.send_dm(admin, notif) 267 | 268 | return None 269 | 270 | 271 | def process_dm(self, raw_dm: dict) -> dict or None: 272 | ''' 273 | :param raw_dm: raw data from webhook 274 | :return: dict filtered dm or None 275 | This method contains DMCmd that can do exec and self.db_sent_updater 276 | Filters: 277 | - account status 278 | - admin & user command 279 | - user filter 280 | - account status 281 | - off schedule 282 | - interval per sender 283 | - minimum and maximum len menfess 284 | - sender requirements (only followed, minimum followers and age of account) 285 | - blacklist words 286 | - menfess trigger 287 | - attachment_url 288 | - photo 289 | - video 290 | - animated_gif 291 | ''' 292 | 293 | # Update db_sent 294 | self.db_sent_updater('update') 295 | 296 | try: 297 | message_create = raw_dm['direct_message_events'][0]['message_create'] 298 | sender_id = message_create['sender_id'] #str 299 | message_data = message_create['message_data'] 300 | message = message_data['text'] 301 | 302 | # Avoid keyword error & loop messages by skipping bot messages 303 | if sender_id in self.prevent_loop: 304 | return None 305 | print(f"Processing direct message, sender_id: {sender_id}") 306 | 307 | # button (quick reply response) 308 | if "quick_reply_response" in message_data: 309 | metadata = message_data['quick_reply_response']['metadata'] 310 | self._quick_reply_manager(sender_id, metadata) 311 | return None 312 | 313 | # ADMIN & USER COMMAND 314 | if self._command(sender_id, message, message_data): 315 | return None 316 | 317 | return self.__menfess_trigger(sender_id, message, message_data) 318 | 319 | except: 320 | logger.critical(traceback.format_exc()) 321 | self.send_dm(sender_id, self.credential.Notify_sentFail1) 322 | return None 323 | -------------------------------------------------------------------------------- /twitter_autobase/twitter.py: -------------------------------------------------------------------------------- 1 | # Original code by Prieyudha Akadita S. 2 | # Source: https://https://github.com/ydhnwb/autodm_base 3 | # Re-code by Fakhri Catur Rofi 4 | # Source: https://github.com/fakhrirofi/twitter_autobase 5 | 6 | from .async_upload import MediaUpload 7 | from .clean_dm_autobase import count_emoji, get_list_media_ids 8 | from .watermark import app as watermark 9 | from html import unescape 10 | from os import remove 11 | from time import sleep 12 | from tweepy import OAuthHandler, API 13 | from tweepy.binder import bind_api 14 | from typing import NoReturn 15 | import logging 16 | import re 17 | import requests 18 | import traceback 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | class EditedAPI(API): 23 | ''' 24 | Add quick reply support 25 | Ref: https://developer.twitter.com/en/docs/twitter-api/v1/direct-messages/quick-replies/api-reference/options 26 | ''' 27 | def send_direct_message(self, recipient_id, text, quick_reply_type=None, quick_reply_data=None, 28 | attachment_type=None, attachment_media_id=None): 29 | """ 30 | Send a direct message to the specified user from the authenticating 31 | user 32 | """ 33 | json_payload = { 34 | 'event': {'type': 'message_create', 35 | 'message_create': { 36 | 'target': {'recipient_id': recipient_id}, 37 | 'message_data': {'text': text} 38 | } 39 | } 40 | } 41 | message_data = json_payload['event']['message_create']['message_data'] 42 | if quick_reply_type is not None: 43 | message_data['quick_reply'] = {'type': quick_reply_type} 44 | message_data['quick_reply'][quick_reply_type] = quick_reply_data 45 | if attachment_type is not None and attachment_media_id is not None: 46 | message_data['attachment'] = {'type': attachment_type} 47 | message_data['attachment']['media'] = {'id': attachment_media_id} 48 | return self._send_direct_message(json_payload=json_payload) 49 | 50 | @property 51 | def _send_direct_message(self): 52 | """ :reference: https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event 53 | :allowed_param: 'recipient_id', 'text', 'quick_reply_type', 'quick_reply_data', 54 | 'attachment_type', attachment_media_id' 55 | """ 56 | return bind_api( 57 | api=self, 58 | path='/direct_messages/events/new.json', 59 | method='POST', 60 | payload_type='direct_message', 61 | allowed_param=['recipient_id', 'text', 'quick_reply_type', 'quick_reply_data', 62 | 'attachment_type', 'attachment_media_id'], 63 | require_auth=True 64 | ) 65 | 66 | 67 | class Twitter: 68 | ''' 69 | Control twitter account 70 | Attributes: 71 | - credential 72 | - api 73 | - me 74 | :param credential: object that contains attributes like config 75 | ''' 76 | def __init__(self, credential: object): 77 | ''' 78 | initialize twitter with tweepy 79 | :param credential: object that contains attributes like config 80 | ''' 81 | self.credential = credential 82 | self._auth = OAuthHandler(credential.CONSUMER_KEY, credential.CONSUMER_SECRET) 83 | self._auth.set_access_token(credential.ACCESS_KEY, credential.ACCESS_SECRET) 84 | self.api = EditedAPI(self._auth, wait_on_rate_limit=True, wait_on_rate_limit_notify=True) 85 | try: 86 | self.me = self.api.me() 87 | except: 88 | logger.error("Check your twitter credentials in config") 89 | exit() 90 | print(f"Initializing twitter... ({self.me.screen_name})") 91 | 92 | def send_dm(self, recipient_id: str, text: str, quick_reply_type: str=None, quick_reply_data: list=None, 93 | attachment_type: str=None, attachment_media_id: str=None) -> NoReturn: 94 | ''' 95 | :param recipient_id: account target 96 | :param quick_reply_type: 'options' 97 | :param quick_reply_data: list of json quick reply object 98 | :param attachment_type: 'media' 99 | :param attachment_media_id: media id that returned from upload_media method 100 | ''' 101 | try: 102 | self.api.send_direct_message(recipient_id, text, quick_reply_type, quick_reply_data, 103 | attachment_type, attachment_media_id) 104 | except: 105 | logger.error(traceback.format_exc()) 106 | 107 | def get_user_screen_name(self, id: str) -> str: 108 | ''' 109 | :param id: account id 110 | :return: username 111 | ''' 112 | try: 113 | user = self.api.get_user(id) 114 | return user.screen_name 115 | 116 | except: 117 | logger.error(traceback.format_exc()) 118 | return "Exception" 119 | 120 | def download_media(self, media_url: str, filename: str=None) -> str: 121 | '''Download media from url 122 | :param media_url: url 123 | :param filename: None (default) or filename 124 | :return: file name (if filename==None) 125 | ''' 126 | print("Downloading media...") 127 | r = requests.get(media_url, auth=self._auth.apply_auth()) 128 | if filename == None: 129 | for i in re.sub("[/?=]", " ", media_url).split(): 130 | if re.search(r"\.mp4$|\.gif$|\.jpg$|\.jpeg$|\.png$|\.webp$", i): 131 | filename = i 132 | break 133 | else: 134 | raise Exception("filename is not supported, please check the link") 135 | 136 | with open(filename, 'wb') as f: 137 | f.write(r.content) 138 | f.close() 139 | 140 | return filename 141 | 142 | def add_watermark(self, filename: str, output: str=None) -> str: 143 | '''Add watermark to photo, then save as output. Only support photo type 144 | :returns: output file name 145 | ''' 146 | try: 147 | if output == None: 148 | output = filename 149 | 150 | file_type = filename.split('.')[-1] 151 | if file_type in "jpg jpeg png webp": 152 | print("Adding watermark...") 153 | i = self.credential.Watermark_data 154 | watermark.watermark_text_image(filename, text=i['text'], font=i['font'], 155 | ratio=i['ratio'], pos=i['position'], output=output, color=i['textColor'], 156 | stroke_color=i['textStroke'], watermark=i['image']) 157 | 158 | return output 159 | 160 | except: 161 | logger.error(traceback.format_exc()) 162 | return filename 163 | 164 | def upload_media_tweet(self, media_tweet_url: str) -> list: 165 | '''Upload media from media tweet url 166 | Usually when sender wants to post more than one media, he will attachs media tweet url. 167 | But the sender's username is mentioned on the bottom of the media. This method intended to make sender anonym. 168 | you can add media_ids to other method. This method contains watermark module 169 | :param media_tweet_url: media tweet url i.e. https://twitter.com/username/status/123/photo/1 170 | :return: [(media_id, media_type),] a.k.a media_idsAndTypes 171 | ''' 172 | try: 173 | postid = re.sub(r"[/\.:]", " ", media_tweet_url).split()[-3] 174 | status = self.api.get_status(postid) 175 | media_idsAndTypes = list() 176 | 177 | if 'extended_entities' not in status._json: 178 | return list() 179 | print("Uploading media tweet...") 180 | 181 | for media in status._json['extended_entities']['media']: 182 | media_type = media['type'] 183 | 184 | if media_type == 'photo': 185 | media_url = media['media_url'] 186 | 187 | elif media_type == 'video': 188 | media_urls = media['video_info']['variants'] 189 | temp_bitrate = list() 190 | for varian in media_urls: 191 | if varian['content_type'] == "video/mp4": 192 | temp_bitrate.append((varian['bitrate'], varian['url'])) 193 | # sort to choose the highest bitrate 194 | temp_bitrate.sort() 195 | media_url = temp_bitrate[-1][1] 196 | 197 | elif media_type == 'animated_gif': 198 | media_url = media['video_info']['variants'][0]['url'] 199 | 200 | filename = self.download_media(media_url) 201 | 202 | # Add watermark 203 | if self.credential.Watermark is True: 204 | self.add_watermark(filename) 205 | 206 | media_id, media_type = self.upload_media(filename) 207 | remove(filename) 208 | media_idsAndTypes.append((media_id, media_type)) 209 | 210 | return media_idsAndTypes # e.g [(media_id, media_type), (media_id, media_type), ] 211 | 212 | except: 213 | logger.error(traceback.format_exc()) 214 | return list() 215 | 216 | def upload_media(self, filename: str, media_category: str='tweet') -> tuple: 217 | '''Upload media using twitter api v1.1 218 | This method are needed when you want to use media to do something on twitter 219 | :param media_category: 'tweet' or 'dm'. default to 'tweet' 220 | :return: media id, media_type 221 | ''' 222 | mediaupload = MediaUpload(self._auth.apply_auth(), filename, media_category) 223 | media_id, media_type = mediaupload.upload_init() 224 | mediaupload.upload_append() 225 | mediaupload.upload_finalize() 226 | del mediaupload 227 | return media_id, media_type 228 | 229 | def post_tweet(self, tweet: str, sender_id: str, media_url: str=None, attachment_url: str=None, 230 | media_idsAndTypes: list=list(), possibly_sensitive: bool=False) -> dict: 231 | '''Post a tweet, contains watermark module 232 | Per tweet delay is 36s + self.credential.Delay_time 233 | :param tweet: message 234 | :param media_url: url of the media that sent from dm 235 | :param attachment_url: url that will be attached to twett (retweet) 236 | :param media_idsAndTypes: [(media_ids, media_type),] 237 | :param possibly_sensitive: True if menfess contains sensitive contents 238 | :return: {'postid': str, 'list_postid_thread': list} -> dict 239 | ''' 240 | try: 241 | sleep(36+self.credential.Delay_time) 242 | #### ADD MEDIA_ID AND MEDIA_TYPE TO LIST_MEDIA_IDS #### 243 | # media_idsAndTypes e.g. [(media_id, media_type), (media_id, media_type), ] 244 | if media_url != None: 245 | tweet = tweet.split(" ") 246 | tweet = " ".join(tweet[:-1]) 247 | filename = self.download_media(media_url) 248 | 249 | # Add watermark 250 | if self.credential.Watermark: 251 | self.add_watermark(filename) 252 | 253 | media_id, media_type = self.upload_media(filename) 254 | # Add attachment media from DM to the first order 255 | media_idsAndTypes.insert(0, (media_id, media_type)) 256 | remove(filename) 257 | 258 | list_media_ids = get_list_media_ids(media_idsAndTypes) #[[media_ids],[media_ids],[media_ids]] 259 | 260 | #### POST TWEET #### 261 | postid = 0 262 | list_postid_thread = list() # used for #delete command 263 | # postid is the first tweet of the tweets thread 264 | while len(tweet) + round(count_emoji(tweet) / 2) > 280: 265 | # Making a Thread. 266 | limit = 272 267 | # some emoticons are counted as 2 char 268 | limit -= round(count_emoji(tweet[:limit]) / 2) 269 | 270 | check = tweet[:limit].split(" ") 271 | if len(check) == 1: 272 | # avoid error when user send 272 char in one word 273 | separator = 0 274 | else: 275 | separator = len(check[-1]) 276 | 277 | tweet_thread = unescape(tweet[:limit-separator]) + '-cont-' 278 | 279 | if postid == 0: 280 | print("Making a thread...") 281 | # postid is static after first update. 282 | postid = self.api.update_status( 283 | tweet_thread, attachment_url=attachment_url, media_ids=list_media_ids[:1][0], 284 | possibly_sensitive=possibly_sensitive).id 285 | postid_thread = str(postid) 286 | else: 287 | postid_thread = self.api.update_status( 288 | tweet_thread, in_reply_to_status_id=postid_thread, auto_populate_reply_metadata=True, 289 | media_ids=list_media_ids[:1][0], possibly_sensitive=possibly_sensitive).id 290 | 291 | list_postid_thread.append(postid_thread) 292 | 293 | list_media_ids = list_media_ids[1:] + [[]] 294 | sleep(36+self.credential.Delay_time) 295 | # tweet are dynamic here 296 | tweet = tweet[limit-separator:] 297 | 298 | # Above and below operation differences are on tweet_thread and unescape(tweet), also tweet[limit-separator:] 299 | # It's possible to change it to be one function 300 | if postid == 0: 301 | # postid is static after first update. 302 | postid = self.api.update_status( 303 | unescape(tweet), attachment_url=attachment_url, media_ids=list_media_ids[:1][0], 304 | possibly_sensitive=possibly_sensitive).id 305 | postid_thread = str(postid) 306 | else: 307 | postid_thread = self.api.update_status( 308 | unescape(tweet), in_reply_to_status_id=postid_thread, auto_populate_reply_metadata=True, 309 | media_ids=list_media_ids[:1][0], possibly_sensitive=possibly_sensitive).id 310 | 311 | list_postid_thread.append(postid_thread) 312 | 313 | list_media_ids = list_media_ids[1:] + [[]] 314 | 315 | # When media_ids still exists, It will be attached to the subsequent tweets 316 | while len(list_media_ids[0]) != 0: # Pay attention to the list format, [[]] 317 | sleep(36+self.credential.Delay_time) 318 | 319 | print("Posting the rest of media...") 320 | postid_thread = self.api.update_status( 321 | in_reply_to_status_id=postid_thread, 322 | auto_populate_reply_metadata=True, media_ids=list_media_ids[:1][0], 323 | possibly_sensitive=possibly_sensitive).id 324 | 325 | list_postid_thread.append(postid_thread) 326 | 327 | list_media_ids = list_media_ids[1:] + [[]] 328 | 329 | print(f'Menfess is posted -> postid: {postid}') 330 | return {'postid': str(postid), 'list_postid_thread': list_postid_thread} 331 | 332 | except: 333 | logger.error(traceback.format_exc()) 334 | return {'postid': None} 335 | --------------------------------------------------------------------------------