Skip to content

Commit 06d0ff7

Browse files
committed
SayMinimal v2.0 (First version in Git) for Python3&Gtk+3
0 parents  commit 06d0ff7

File tree

8 files changed

+1500
-0
lines changed

8 files changed

+1500
-0
lines changed

.gitignore

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
env/
12+
build/
13+
develop-eggs/
14+
dist/
15+
downloads/
16+
eggs/
17+
.eggs/
18+
lib/
19+
lib64/
20+
parts/
21+
sdist/
22+
var/
23+
*.egg-info/
24+
.installed.cfg
25+
*.egg
26+
27+
# PyInstaller
28+
# Usually these files are written by a python script from a template
29+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
30+
*.manifest
31+
*.spec
32+
33+
# Installer logs
34+
pip-log.txt
35+
pip-delete-this-directory.txt
36+
37+
# Unit test / coverage reports
38+
htmlcov/
39+
.tox/
40+
.coverage
41+
.coverage.*
42+
.cache
43+
nosetests.xml
44+
coverage.xml
45+
*,cover
46+
.hypothesis/
47+
48+
# Translations
49+
*.mo
50+
*.pot
51+
52+
# Django stuff:
53+
*.log
54+
local_settings.py
55+
56+
# Flask stuff:
57+
instance/
58+
.webassets-cache
59+
60+
# Scrapy stuff:
61+
.scrapy
62+
63+
# Sphinx documentation
64+
docs/_build/
65+
66+
# PyBuilder
67+
target/
68+
69+
# IPython Notebook
70+
.ipynb_checkpoints
71+
72+
# pyenv
73+
.python-version
74+
75+
# celery beat schedule file
76+
celerybeat-schedule
77+
78+
# dotenv
79+
.env
80+
81+
# virtualenv
82+
venv/
83+
ENV/
84+
85+
# Spyder project settings
86+
.spyderproject
87+
88+
# Rope project settings
89+
.ropeproject

LICENSE

+674
Large diffs are not rendered by default.

README.md

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# SayMinimal
2+
3+
SayMinimal is a simple, write-only¹ Twitter client. The point of SayMinimal is to map it to a keypress so you can press a button, comment on something, and then go back to what you were doing before.
4+
5+
¹Technically SayMinimal also read your most recent tweet when you ask it to thread tweets together.
6+
7+
## Setup
8+
9+
SayMinimal requires Python 3, Gtk+ 3, and [PyGObject](https://pygobject.readthedocs.io/en/latest/).
10+
11+
After that, the GUI should walk you through OAuth setup. Basically, you can choose to use the default Consumer Key pair that's hard-coded into the app, or you can provide your own. I recommend you provide your own because random people can find and abuse consumer keys that are published along with the source. You also need to authorize the app to read and write to your Twitter account (verifying it with a PIN).
12+
13+
## Config
14+
15+
SayMinimal saves its configuration in `~/.config/sayminimal/conf.yml`, which you can delete at any time to reset. The only conf saved in the current version is the four OAuth keys.
16+
17+
## Keyboard Shortcuts
18+
19+
- **Enter** - Send the current tweet.
20+
- **Shift-Enter** - Add a newline to your tweet. (It displays funky in the input text box but works)
21+
- **Alt+I** - Browse for an image to attach to the current tweet. SayMinimal only supports 1 image per tweet (not the 4 Twitter properly supports).
22+
- **Alt+T** - Thread this tweet as a reply to your most recent previous tweet. Press again to toggle off.

sayminimal/__init__.py

Whitespace-only changes.

sayminimal/tweet.py

+258
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
import re
5+
import os
6+
import logging
7+
import pkg_resources
8+
9+
import tweepy
10+
import yaml
11+
import gi
12+
gi.require_version("Gtk", "3.0")
13+
gi.require_version("Gdk", "3.0")
14+
from gi.repository import Gtk, Gdk
15+
16+
# Credentials for this app to use OAuth
17+
# APP_KEY = "9MNMUIprKQBvdH08Ms1w"
18+
# APP_SECRET = "NhPoCBwtVk9P1Tc7Ru3KaYTh5rjF9vUbcmIOS3tH0"
19+
20+
DEFAULT_APP_KEY = "zcoB9FlShIl3GI2BgZLvldEjo"
21+
DEFAULT_APP_SECRET = "tfzg6wnXwhbUReB8C3idgvNuzVSpIwOQFAZublN6ShpavXez2m"
22+
23+
GRUBER_URL_REGEX = re.compile( r'(?i)\b((?:https?:(?:/{1,3}|[a-z0-9%])|[a-z0-9.\-]+[.](?:com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|Ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)/)(?:[^\s()<>{}\[\]]+|\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\))+(?:\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\)|[^\s`!()\[\]{};:\'".,<>?«»“”‘’])|(?:(?<!@)[a-z0-9]+(?:[.\-][a-z0-9]+)*[.](?:com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|Ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)\b/?(?!@)))' )
24+
25+
URL_LEN_STR = 25*" " #t.co automatically shortens URLs to this length.
26+
#Technically we're supposed to lookup & cache this daily instead of hardcoding it.
27+
28+
CONF_FILE = "~/.config/sayminimal/conf.yml"
29+
GLADE_FILE = "twitters.glade"
30+
31+
class Conf:
32+
def __init__(self):
33+
self.conf_file = os.path.expanduser(CONF_FILE)
34+
try:
35+
with open(self.conf_file) as f:
36+
self.vals = yaml.load(f)
37+
except FileNotFoundError:
38+
logging.warning("Couldn't load conf file, opening a new one.")
39+
self.vals = {}
40+
41+
def Get(self, key):
42+
return self.vals[key]
43+
44+
def Set(self, key, val):
45+
self.vals[key] = val
46+
with open(self.conf_file, "w") as f:
47+
f.write(yaml.dump(self.vals))
48+
49+
def Unset(self, key):
50+
del self.vals[key]
51+
with open(self.conf_file, "w") as f:
52+
f.write(yaml.dump(self.vals))
53+
54+
55+
56+
class AuthedApi(tweepy.API):
57+
def __init__(self, conf, builder):
58+
#Get a twitter API
59+
self.conf = conf
60+
self.builder = builder
61+
62+
try:
63+
consumer_key = self.conf.Get("consumer_key")
64+
consumer_secret = self.conf.Get("consumer_secret")
65+
except KeyError:
66+
consumer_key, consumer_secret = self.GetAppKeys()
67+
self.conf.Set("consumer_key", consumer_key)
68+
self.conf.Set("consumer_secret", consumer_secret)
69+
70+
auth = tweepy.OAuthHandler(consumer_key, consumer_secret)
71+
72+
#If we have the user's OAuth key & secret, we're done. Otherwise,
73+
# do the OAuth Dance
74+
try:
75+
user_key = self.conf.Get("oauth_key")
76+
user_secret = self.conf.Get("oauth_secret")
77+
auth.set_access_token(user_key, user_secret)
78+
except KeyError:
79+
try:
80+
pin = self.GetPIN(auth)
81+
except tweepy.error.TweepError:
82+
logging.warning("Get authorization URL failed; maybe consumer key is bad?")
83+
self.conf.Unset("consumer_key")
84+
self.conf.Unset("consumer_secret")
85+
exit(1)
86+
87+
auth.get_access_token(pin)
88+
self.conf.Set("oauth_key", auth.access_token)
89+
self.conf.Set("oauth_secret", auth.access_token_secret)
90+
91+
tweepy.API.__init__(self, auth)
92+
93+
def GetAppKeys(self):
94+
dialog = self.builder.get_object("consumerkey_dialog")
95+
res = dialog.run()
96+
dialog.destroy()
97+
if res: # User clicked "Register an App" button
98+
dialog2 = self.builder.get_object("consumerkey_entry_dialog")
99+
res2 = dialog2.run()
100+
if res2:
101+
consumer_key = self.builder.get_object("consumer_key_entry").get_text()
102+
consumer_secret = self.builder.get_object("consumer_secret_entry").get_text()
103+
dialog2.destroy()
104+
return (consumer_key, consumer_secret)
105+
else:
106+
logging.warning("User canceled consumer key entry")
107+
108+
logging.warning("Using default Consumer Key")
109+
return (DEFAULT_APP_KEY, DEFAULT_APP_SECRET)
110+
111+
def GetPIN(self, auth):
112+
authurl = auth.get_authorization_url()
113+
dialog = self.builder.get_object("pin_dialog")
114+
urlbutton = self.builder.get_object("pin_auth_url")
115+
urlbutton.set_label(authurl)
116+
urlbutton.set_uri(authurl)
117+
res = dialog.run()
118+
if res:
119+
pin_field = self.builder.get_object("pin_entry")
120+
pin = pin_field.get_text()
121+
dialog.destroy()
122+
if pin:
123+
return pin
124+
Gtk.main_quit()
125+
exit("Can't connect to Twitter without authorization.")
126+
127+
128+
class TweetWindow:
129+
def __init__(self, builder):
130+
131+
self.api = AuthedApi(Conf(), builder)
132+
self.attached_media = None
133+
self.reply_id = None
134+
135+
self.window = builder.get_object('tweetwin')
136+
self.label = builder.get_object('tweetprompt')
137+
self.bonus_label = builder.get_object('bonus_label')
138+
self.textbox = builder.get_object('tweetentry')
139+
140+
#Events
141+
self.window.connect("delete-event", Gtk.main_quit)
142+
self.window.connect("key-press-event", self.keypress)
143+
self.textbox.connect("changed", self.text_changed)
144+
self.textbox.connect("activate", self.enter_tweet)
145+
146+
#Start
147+
self.window.show_all()
148+
Gtk.main()
149+
150+
def text_changed(self, entry):
151+
#n = entry.get_text_length()
152+
text = entry.get_text()
153+
n = len(GRUBER_URL_REGEX.sub(URL_LEN_STR, text))
154+
labeltext = self.label.get_text()
155+
labeltext = re.sub(r"\(-?[0-9]+\)", "("+str(140-n)+")", labeltext)
156+
self.label.set_text(labeltext)
157+
158+
def keypress(self, widget, event):
159+
key_name = Gdk.keyval_name(event.keyval)
160+
if event.state & Gdk.ModifierType.SHIFT_MASK and key_name == "Return":
161+
self.textbox.do_insert_at_cursor(self.textbox, "\n")
162+
elif (event.state & Gdk.ModifierType.MOD1_MASK) and key_name == "i":
163+
self.prompt_for_media_file()
164+
elif (event.state & Gdk.ModifierType.MOD1_MASK) and key_name == "t":
165+
self.toggle_threaded()
166+
elif event.keyval == Gdk.KEY_Escape:
167+
Gtk.main_quit()
168+
169+
def toggle_threaded(self):
170+
if self.reply_id == None:
171+
recent_tweet = self.api.user_timeline(count=1)
172+
if not len(recent_tweet):
173+
logging.warning("Can't find previous tweets to thread to.")
174+
return
175+
#print(recent_tweet[0])
176+
self.reply_id = recent_tweet[0].id
177+
self.reply_text = recent_tweet[0].text
178+
else:
179+
self.reply_id = None
180+
self.update_bonus_label()
181+
182+
def prompt_for_media_file(self):
183+
fd = Gtk.FileChooserDialog(title="Select a file to upload...",
184+
parent=self.window,
185+
action=Gtk.FileChooserAction.OPEN,
186+
buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
187+
Gtk.STOCK_OPEN, Gtk.ResponseType.OK))
188+
response = fd.run()
189+
if response == Gtk.ResponseType.OK:
190+
image_file = fd.get_filename()
191+
if not self.attached_media:
192+
labeltext = self.label.get_text()
193+
self.label.set_text(labeltext + " (Image attached)")
194+
self.attached_media = image_file
195+
self.update_bonus_label()
196+
fd.destroy()
197+
198+
def update_bonus_label(self):
199+
s = ""
200+
if self.attached_media:
201+
s += "Attached: %s" % media_file
202+
if self.reply_id:
203+
s += " Replying to: %s\n> %s" % (self.reply_id, self.reply_text)
204+
self.bonus_label.set_label(s)
205+
206+
def display_error(self, e):
207+
dlg = Gtk.MessageDialog(self.window, Gtk.DialogFlags.DESTROY_WITH_PARENT | Gtk.DialogFlags.MODAL,
208+
Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, str(e))
209+
dlg.run()
210+
dlg.destroy()
211+
212+
def enter_tweet(self, entry):
213+
text = entry.get_text()
214+
if text:
215+
entry.set_sensitive(False)
216+
217+
if self.attached_media and not self.reply_id:
218+
self.label.set_text("Uploading image and message to Twitter...")
219+
try:
220+
self.api.update_with_media(self.attached_media, status=text)
221+
except Exception as e:
222+
self.display_error(e)
223+
224+
elif self.attached_media and self.reply_id:
225+
self.label.set_text("Uploading image and reply to Twitter...")
226+
try:
227+
self.api.update_with_media(self.attached_media, status=text,
228+
in_reply_to_status_id=self.reply_id)
229+
except Exception as e:
230+
self.display_error(e)
231+
232+
elif self.reply_id:
233+
self.label.set_text("Sending reply to Twitter...")
234+
try:
235+
self.api.update_status(text, in_reply_to_status_id=self.reply_id)
236+
except Exception as e:
237+
self.display_error(e)
238+
239+
else:
240+
self.label.set_text("Sending message to Twitter...")
241+
try:
242+
self.api.update_status(text)
243+
except Exception as e:
244+
self.display_error(e)
245+
246+
Gtk.main_quit()
247+
248+
def main():
249+
#Initialize from glade
250+
builder = Gtk.Builder()
251+
#builder.add_from_file(GLADE_FILE)
252+
builder.add_from_string(
253+
pkg_resources.resource_string(__name__, GLADE_FILE).decode('utf-8')
254+
)
255+
TweetWindow(builder)
256+
257+
if __name__ == "__main__":
258+
main()

0 commit comments

Comments
 (0)