├── 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 |
--------------------------------------------------------------------------------