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