├── .gitignore ├── LICENCE.txt ├── MANIFEST.in ├── README.rst ├── ez_setup.py ├── setup.py └── twittcher ├── __init__.py ├── twittcher.py └── version.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # Temp files 39 | 40 | *~ 41 | 42 | # Pipy codes 43 | 44 | .pypirc 45 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | [OSI Approved License] 4 | 5 | The MIT License (MIT) 6 | 7 | Copyright (c) 2014 Zulko 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in 17 | all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | THE SOFTWARE. 26 | 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | recursive-include examples *.txt *.py 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Twittcher 2 | ========== 3 | 4 | Twittcher (for *twitter-watcher*) is a Python module to make bots that will watch a Twitter user page or search page, and react to the tweets they find. 5 | 6 | It's simple, small (currently ~150 lines of code), and doesn't require any registration on Twitter or *dev.twitter.com*, as it doesn't depend on the Twitter API (instead it parses the HTML). 7 | 8 | Twittcher is an open-source software originally written by Zulko_, and released under the MIT licence. The project is hosted on Github_, where you can report bugs, propose improvements, etc. 9 | 10 | Install 11 | -------- 12 | 13 | If you have `pip`, install twittcher by typing in a terminal: 14 | :: 15 | 16 | (sudo) pip install twittcher 17 | 18 | Else, download the sources (on Github_ or PyPI_), and in the same directory as the `setup.py` file, type this in a terminal: 19 | :: 20 | 21 | (sudo) python setup.py install 22 | 23 | Twittcher requires the Python package BeautifulSoup (a.k.a. bs4), which will be automatically installed when twittcher is installed. 24 | 25 | 26 | Examples of use 27 | ---------------- 28 | 29 | There is currently no documentation for Twittcher, but the following examples should show you everything you need to get started. 30 | 31 | 1. Print the tweets of a given user 32 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 33 | 34 | Every 120 seconds, print all the new tweets by John D. Cook: 35 | :: 36 | 37 | from twittcher import UserWatcher 38 | UserWatcher("JohnDCook").watch_every(120) 39 | 40 | Result: 41 | :: 42 | 43 | Kicking off some simulations before I quit work for the day. #dejavu 44 | Author: JohnDCook 45 | Date: 15:43 - 24 juil. 2014 46 | Link: https://twitter.com/JohnDCook/status/492440083073859585 47 | “Too often we enjoy the comfort of opinion without the discomfort of thought." -- John F. Kennedy, 48 | Author: JerryWeinberg 49 | Date: 13:18 - 24 juil. 2014 50 | Link: https://twitter.com/JerryWeinberg/status/492403371975114752 51 | 52 | 53 | The default action of `UserWatcher` is to print the tweets, but you can ask any other action instead. 54 | For instance, here is how to only print the tweets that are actually written by John D. Cook (not the ones he retweets): 55 | :: 56 | 57 | from twittcher import UserWatcher 58 | 59 | def my_action(tweet): 60 | if tweet.username == "JohnDCook": 61 | print(tweet) 62 | 63 | UserWatcher("JohnDCook", action=my_action).watch_every(120) 64 | 65 | 66 | 2. Control a distant machine through Twitter ! 67 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 68 | 69 | Every 60 seconds, for any of my new tweets of the form ``cmd: my_command``, run ``my_command`` in a terminal. 70 | Using simple tweets I can control any distant computer running this script. 71 | :: 72 | 73 | import subprocess 74 | from twittcher import UserWatcher 75 | 76 | def my_action(tweet): 77 | """ Execute the tweet's command, if any. """ 78 | if tweet.text.startswith("cmd: "): 79 | subprocess.Popen( tweet.text[5:], shell=True ) 80 | 81 | # Watch my account and react to my tweets 82 | bot = UserWatcher("Zulko___", action=my_action) 83 | bot.watch_every(60) 84 | 85 | For instance, the tweet ``cmd: firefox`` will open Firefox on the computer, and the tweet ``cmd: echo "Hello"`` will have the computer print Hello in a terminal. 86 | 87 | 88 | 3. Watch search results and send alert mails 89 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 90 | 91 | Every 20 seconds, send me all the new tweets in the Twitter search results for `chocolate milk`. 92 | :: 93 | 94 | from twittcher import TweetSender, SearchWatcher 95 | sender = TweetSender(smtp="smtp.gmail.com", port=587, 96 | login="tintin.zulko@gmail.com", 97 | password="fibo112358", # be nice, don't try. 98 | to_addrs="tintin.zulko@gmail.com", # where to send 99 | sender_id = "chocolate milk") 100 | bot = SearchWatcher("chocolate milk", action=sender.send) 101 | bot.watch_every(20) 102 | 103 | 4. Multibot watching 104 | ~~~~~~~~~~~~~~~~~~~~~~~~ 105 | 106 | If you want to run several bots at once, make sure that you leave a few seconds between the requests of the different bots. 107 | Here is how you print the new tweets of John D. Cook, Mathbabe, and Eolas. Each of them is watched every minute, with 20 seconds between the requests of two bots: 108 | :: 109 | 110 | import time 111 | import itertools 112 | from twittcher import UserWatcher 113 | 114 | bots = [ UserWatcher(user) for user in 115 | ["JohnDCook", "mathbabedotorg", "Maitre_Eolas"]] 116 | 117 | for bot in itertools.cycle(bots): 118 | bot.watch() 119 | time.sleep(20) 120 | 121 | 122 | 5. Saving the tweets 123 | ~~~~~~~~~~~~~~~~~~~~~~ 124 | 125 | A bot can save to a file the tweets that it has already seen, so that in future sessions it will remember not to process these tweets again, in case they still appear on the watched page. 126 | :: 127 | 128 | from twittcher import SearchWatcher 129 | bot = SearchWatcher("chocolate milk", database="choco.db") 130 | bot.watch_every(20) 131 | 132 | 133 | 134 | .. _PyPI: https://pypi.python.org/pypi/twittcher 135 | .. _Zulko : https://github.com/Zulko 136 | .. _Github: https://github.com/Zulko/twittcher -------------------------------------------------------------------------------- /ez_setup.py: -------------------------------------------------------------------------------- 1 | 2 | #!python 3 | """Bootstrap setuptools installation 4 | 5 | If you want to use setuptools in your package's setup.py, just include this 6 | file in the same directory with it, and add this to the top of your setup.py:: 7 | 8 | from ez_setup import use_setuptools 9 | use_setuptools() 10 | 11 | If you want to require a specific version of setuptools, set a download 12 | mirror, or use an alternate download directory, you can do so by supplying 13 | the appropriate options to ``use_setuptools()``. 14 | 15 | This file can also be run as a script to install or upgrade setuptools. 16 | """ 17 | import os 18 | import shutil 19 | import sys 20 | import tempfile 21 | import tarfile 22 | import optparse 23 | import subprocess 24 | 25 | from distutils import log 26 | 27 | try: 28 | from site import USER_SITE 29 | except ImportError: 30 | USER_SITE = None 31 | 32 | DEFAULT_VERSION = "0.9.6" 33 | DEFAULT_URL = "https://pypi.python.org/packages/source/s/setuptools/" 34 | 35 | def _python_cmd(*args): 36 | args = (sys.executable,) + args 37 | return subprocess.call(args) == 0 38 | 39 | def _install(tarball, install_args=()): 40 | # extracting the tarball 41 | tmpdir = tempfile.mkdtemp() 42 | log.warn('Extracting in %s', tmpdir) 43 | old_wd = os.getcwd() 44 | try: 45 | os.chdir(tmpdir) 46 | tar = tarfile.open(tarball) 47 | _extractall(tar) 48 | tar.close() 49 | 50 | # going in the directory 51 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 52 | os.chdir(subdir) 53 | log.warn('Now working in %s', subdir) 54 | 55 | # installing 56 | log.warn('Installing Setuptools') 57 | if not _python_cmd('setup.py', 'install', *install_args): 58 | log.warn('Something went wrong during the installation.') 59 | log.warn('See the error message above.') 60 | # exitcode will be 2 61 | return 2 62 | finally: 63 | os.chdir(old_wd) 64 | shutil.rmtree(tmpdir) 65 | 66 | 67 | def _build_egg(egg, tarball, to_dir): 68 | # extracting the tarball 69 | tmpdir = tempfile.mkdtemp() 70 | log.warn('Extracting in %s', tmpdir) 71 | old_wd = os.getcwd() 72 | try: 73 | os.chdir(tmpdir) 74 | tar = tarfile.open(tarball) 75 | _extractall(tar) 76 | tar.close() 77 | 78 | # going in the directory 79 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 80 | os.chdir(subdir) 81 | log.warn('Now working in %s', subdir) 82 | 83 | # building an egg 84 | log.warn('Building a Setuptools egg in %s', to_dir) 85 | _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) 86 | 87 | finally: 88 | os.chdir(old_wd) 89 | shutil.rmtree(tmpdir) 90 | # returning the result 91 | log.warn(egg) 92 | if not os.path.exists(egg): 93 | raise IOError('Could not build the egg.') 94 | 95 | 96 | def _do_download(version, download_base, to_dir, download_delay): 97 | egg = os.path.join(to_dir, 'setuptools-%s-py%d.%d.egg' 98 | % (version, sys.version_info[0], sys.version_info[1])) 99 | if not os.path.exists(egg): 100 | tarball = download_setuptools(version, download_base, 101 | to_dir, download_delay) 102 | _build_egg(egg, tarball, to_dir) 103 | sys.path.insert(0, egg) 104 | import setuptools 105 | setuptools.bootstrap_install_from = egg 106 | 107 | 108 | def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 109 | to_dir=os.curdir, download_delay=15): 110 | # making sure we use the absolute path 111 | to_dir = os.path.abspath(to_dir) 112 | was_imported = 'pkg_resources' in sys.modules or \ 113 | 'setuptools' in sys.modules 114 | try: 115 | import pkg_resources 116 | except ImportError: 117 | return _do_download(version, download_base, to_dir, download_delay) 118 | try: 119 | pkg_resources.require("setuptools>=" + version) 120 | return 121 | except pkg_resources.VersionConflict: 122 | e = sys.exc_info()[1] 123 | if was_imported: 124 | sys.stderr.write( 125 | "The required version of setuptools (>=%s) is not available,\n" 126 | "and can't be installed while this script is running. Please\n" 127 | "install a more recent version first, using\n" 128 | "'easy_install -U setuptools'." 129 | "\n\n(Currently using %r)\n" % (version, e.args[0])) 130 | sys.exit(2) 131 | else: 132 | del pkg_resources, sys.modules['pkg_resources'] # reload ok 133 | return _do_download(version, download_base, to_dir, 134 | download_delay) 135 | except pkg_resources.DistributionNotFound: 136 | return _do_download(version, download_base, to_dir, 137 | download_delay) 138 | 139 | 140 | def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 141 | to_dir=os.curdir, delay=15): 142 | """Download setuptools from a specified location and return its filename 143 | 144 | `version` should be a valid setuptools version number that is available 145 | as an egg for download under the `download_base` URL (which should end 146 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 147 | `delay` is the number of seconds to pause before an actual download 148 | attempt. 149 | """ 150 | # making sure we use the absolute path 151 | to_dir = os.path.abspath(to_dir) 152 | try: 153 | from urllib.request import urlopen 154 | except ImportError: 155 | from urllib2 import urlopen 156 | tgz_name = "setuptools-%s.tar.gz" % version 157 | url = download_base + tgz_name 158 | saveto = os.path.join(to_dir, tgz_name) 159 | src = dst = None 160 | if not os.path.exists(saveto): # Avoid repeated downloads 161 | try: 162 | log.warn("Downloading %s", url) 163 | src = urlopen(url) 164 | # Read/write all in one block, so we don't create a corrupt file 165 | # if the download is interrupted. 166 | data = src.read() 167 | dst = open(saveto, "wb") 168 | dst.write(data) 169 | finally: 170 | if src: 171 | src.close() 172 | if dst: 173 | dst.close() 174 | return os.path.realpath(saveto) 175 | 176 | 177 | def _extractall(self, path=".", members=None): 178 | """Extract all members from the archive to the current working 179 | directory and set owner, modification time and permissions on 180 | directories afterwards. `path' specifies a different directory 181 | to extract to. `members' is optional and must be a subset of the 182 | list returned by getmembers(). 183 | """ 184 | import copy 185 | import operator 186 | from tarfile import ExtractError 187 | directories = [] 188 | 189 | if members is None: 190 | members = self 191 | 192 | for tarinfo in members: 193 | if tarinfo.isdir(): 194 | # Extract directories with a safe mode. 195 | directories.append(tarinfo) 196 | tarinfo = copy.copy(tarinfo) 197 | tarinfo.mode = 448 # decimal for oct 0700 198 | self.extract(tarinfo, path) 199 | 200 | # Reverse sort directories. 201 | if sys.version_info < (2, 4): 202 | def sorter(dir1, dir2): 203 | return cmp(dir1.name, dir2.name) 204 | directories.sort(sorter) 205 | directories.reverse() 206 | else: 207 | directories.sort(key=operator.attrgetter('name'), reverse=True) 208 | 209 | # Set correct owner, mtime and filemode on directories. 210 | for tarinfo in directories: 211 | dirpath = os.path.join(path, tarinfo.name) 212 | try: 213 | self.chown(tarinfo, dirpath) 214 | self.utime(tarinfo, dirpath) 215 | self.chmod(tarinfo, dirpath) 216 | except ExtractError: 217 | e = sys.exc_info()[1] 218 | if self.errorlevel > 1: 219 | raise 220 | else: 221 | self._dbg(1, "tarfile: %s" % e) 222 | 223 | 224 | def _build_install_args(options): 225 | """ 226 | Build the arguments to 'python setup.py install' on the setuptools package 227 | """ 228 | install_args = [] 229 | if options.user_install: 230 | if sys.version_info < (2, 6): 231 | log.warn("--user requires Python 2.6 or later") 232 | raise SystemExit(1) 233 | install_args.append('--user') 234 | return install_args 235 | 236 | def _parse_args(): 237 | """ 238 | Parse the command line for options 239 | """ 240 | parser = optparse.OptionParser() 241 | parser.add_option( 242 | '--user', dest='user_install', action='store_true', default=False, 243 | help='install in user site package (requires Python 2.6 or later)') 244 | parser.add_option( 245 | '--download-base', dest='download_base', metavar="URL", 246 | default=DEFAULT_URL, 247 | help='alternative URL from where to download the setuptools package') 248 | options, args = parser.parse_args() 249 | # positional arguments are ignored 250 | return options 251 | 252 | def main(version=DEFAULT_VERSION): 253 | """Install or upgrade setuptools and EasyInstall""" 254 | options = _parse_args() 255 | tarball = download_setuptools(download_base=options.download_base) 256 | return _install(tarball, _build_install_args(options)) 257 | 258 | if __name__ == '__main__': 259 | sys.exit(main()) 260 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import ez_setup 2 | ez_setup.use_setuptools() 3 | 4 | from setuptools import setup, find_packages 5 | 6 | exec(open('twittcher/version.py').read()) # loads __version__ 7 | 8 | setup(name='twittcher', 9 | version=__version__, 10 | author='Zulko', 11 | description=("Watch tweets on Twitter's user pages or search pages."), 12 | long_description=open('README.rst').read(), 13 | license='see LICENSE.txt', 14 | keywords="Twitter tweet search bot", 15 | install_requires=['beautifulsoup'], 16 | packages= find_packages(exclude='docs')) 17 | -------------------------------------------------------------------------------- /twittcher/__init__.py: -------------------------------------------------------------------------------- 1 | """ twittcher/__init__.py """ 2 | 3 | __all__ = ["PageWatcher", "UserWatcher", "SearchWatcher", 4 | "Tweet", "TweetSender"] 5 | 6 | from .twittcher import (PageWatcher, UserWatcher, SearchWatcher, 7 | Tweet, TweetSender) 8 | 9 | from .version import __version__ 10 | -------------------------------------------------------------------------------- /twittcher/twittcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import time 5 | import pickle 6 | from urllib import urlopen 7 | import smtplib 8 | from bs4 import BeautifulSoup 9 | 10 | class Tweet: 11 | """ A class to make tweets from HTML data. 12 | 13 | Finds tweet.text, tweet.username, tweet.date, tweet.link 14 | from the HTML attributes of the tweet. 15 | 16 | See PageWatcher.get_new_tweets() to understand how it's used. 17 | """ 18 | 19 | def __init__(self, text, attrs): 20 | self.text = text.encode('utf8') 21 | self.username = (attrs["href"].split("/")[1]).encode('utf8') 22 | self.date = attrs['title'].encode('utf8') 23 | self.link = ("https://twitter.com" + attrs["href"]).encode('utf8') 24 | 25 | def __eq__(self, other): 26 | """ Two tweets are the same if they have the same address.""" 27 | return self.link == other.link 28 | 29 | def __str__(self): 30 | return ("\n".join(["%(text)s", 31 | " Author: %(username)s", 32 | " Date: %(date)s", 33 | " Link: %(link)s"])%self.__dict__) 34 | 35 | 36 | class PageWatcher: 37 | """ General class for (username/search) page watchers """ 38 | 39 | def __init__(self, action, database=None): 40 | 41 | self.action = action 42 | self.database = database 43 | 44 | if (database is not None) and os.path.exists(database): 45 | with open(database, 'r') as f: 46 | self.seen_tweets = pickle.load(f) 47 | else: 48 | self.seen_tweets = [] 49 | 50 | 51 | def get_new_tweets(self): 52 | """ Go watch the page, return all new tweets. """ 53 | 54 | url = urlopen(self.url) 55 | page = BeautifulSoup( url ) 56 | url.close() 57 | 58 | texts = [p.text for p in page.findAll("p") 59 | if ("class" in p.attrs) and 60 | (self.p_class in p.attrs["class"])] 61 | 62 | attrs = [a.attrs for a in page.findAll("a") 63 | if ("class" in a.attrs) and 64 | (self.a_class in a.attrs["class"])] 65 | 66 | tweets = [Tweet(txt, a) for (txt, a) in zip(texts, attrs)] 67 | new_tweets = [t for t in tweets if t not in self.seen_tweets] 68 | 69 | self.seen_tweets += new_tweets 70 | 71 | if self.database is not None: 72 | with open(self.database, "w+") as f: 73 | pickle.dump(self.seen_tweets, f, 74 | protocol = pickle.HIGHEST_PROTOCOL) 75 | 76 | return new_tweets 77 | 78 | def watch(self): 79 | 80 | for new_tweet in self.get_new_tweets(): 81 | self.action(new_tweet) 82 | 83 | def watch_every(self, seconds): 84 | 85 | while True: 86 | self.watch() 87 | time.sleep(seconds) 88 | 89 | 90 | 91 | class UserWatcher(PageWatcher): 92 | """ Gets tweets from a user page. 93 | 94 | >>> from twittcher import UserWatcher 95 | >>> def my_action(tweet): 96 | if tweet.username == "JohnDCook": 97 | print(tweet) 98 | >>> bot=UserWatcher("JohnDCook", action=my_action) 99 | >>> bot.watch_every(120) 100 | """ 101 | 102 | def __init__(self, username, action=print, database=None): 103 | PageWatcher.__init__(self, action, database) 104 | self.url = "https://twitter.com/"+username 105 | self.username = username 106 | self.p_class = "ProfileTweet-text" 107 | self.a_class = "ProfileTweet-timestamp" 108 | 109 | 110 | 111 | class SearchWatcher(PageWatcher): 112 | """ Gets tweets from a search page. 113 | 114 | Examples: 115 | --------- 116 | 117 | >>> from twittcher import SearchWatcher 118 | >>> bot=SearchWatcher("milk chocolate") 119 | >>> # watch every 120s. Print all new tweets. 120 | >>> bot.watch_every(120) 121 | 122 | """ 123 | 124 | def __init__(self, search_term, action=print, database=None): 125 | PageWatcher.__init__(self, action, database) 126 | self.url ="https://twitter.com/search?f=realtime&q="+search_term 127 | self.search_term = search_term 128 | self.p_class = "tweet-text" 129 | self.a_class = "tweet-timestamp" 130 | 131 | 132 | 133 | class TweetSender: 134 | """ A class to make it easy to send tweets per email. 135 | 136 | Examples: 137 | --------- 138 | >>> from twittcher import TweetSender, SearchWatcher 139 | >>> sender = TweetSender(smtp="smtp.gmail.com", port=587, 140 | login="mr.zulko@gmail.com", 141 | password="fibo112358", 142 | address="mr.zulko@gmail.com", 143 | name = "milk chocolate") 144 | >>> bot = SearchWatcher("milk chocolate", action= sender.send) 145 | >>> bot.watch_every(600) 146 | """ 147 | 148 | def __init__(self, smtp, port, login, password, to_addrs=None, 149 | from_addrs="twittcher@noanswer.com", sender_id=""): 150 | # Configure the smtp, store email address 151 | if to_addrs is None: 152 | to_addrs = login 153 | self.server = smtplib.SMTP(smtp, port) 154 | self.server.starttls() 155 | self.server.login(login, password) 156 | self.to_addrs = to_addrs 157 | self.from_addrs = from_addrs 158 | self.sender_id = sender_id 159 | 160 | 161 | def make_message(self, tweet): 162 | return ("\n".join(["From: <%(from_addrs)s>", 163 | "To: <%(to_addrs)s>", 164 | "Subject: Twittcher[ %(sender_id)s ]: New tweet !", 165 | "", str(tweet)]))%(self.__dict__) 166 | 167 | 168 | def send(self, tweet): 169 | self.server.sendmail(self.from_addrs, self.to_addrs, 170 | self.make_message(tweet)) 171 | -------------------------------------------------------------------------------- /twittcher/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.05" 2 | --------------------------------------------------------------------------------