├── .gitignore ├── tweebot ├── __init__.py └── tweebot.py ├── setup.py ├── LICENSE ├── examples ├── repeater.py ├── complementor.py └── thanks_follow.py ├── README.md └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.egg 3 | *.pyc 4 | build/ 5 | dist/ 6 | venv 7 | -------------------------------------------------------------------------------- /tweebot/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | __version__ = '1.0' 5 | __author__ = 'Maxim Kamenkov' 6 | __license__ = 'MIT' 7 | 8 | from tweebot import * 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name="tweebot", 7 | version="1.0", 8 | description="Python library to build twitter bots", 9 | license="MIT", 10 | author="Maxim Kamenkov", 11 | author_email="mkamenkov@gmail.com", 12 | url="http://github.com/caxap/tweebot", 13 | packages=find_packages(), 14 | install_requires=[ 15 | 'tweepy' 16 | ], 17 | keywords=["twitter", "bot", "library", "api"], 18 | zip_safe=False, 19 | classifiers=[ 20 | 'Development Status :: 3 - Alpha', 21 | 'Environment :: Web Environment', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: MIT License', 24 | 'Operating System :: OS Independent', 25 | 'Programming Language :: Python', 26 | 'Topic :: Software Development :: Libraries', 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2011-2012 Maxim Kamenkov (mkamenkov@gmail.com) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /examples/repeater.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import tweebot 5 | 6 | 7 | class Repeater(tweebot.Context): 8 | def __init__(self, *args, **kwargs): 9 | settings = { 10 | 'app_name': 'repeater', 11 | #'username' : '', 12 | #'consumer_key' : '', 13 | #'consumer_secret' : '', 14 | #'access_key' : '', 15 | #'access_secret' : '', 16 | 'timeout': 1 * 60, # 1 min 17 | 'history_file': 'repeater.history', 18 | 'log_file_prefix': 'repeater.log', 19 | 'log_file_max_size': 1024 * 1024 * 10, 20 | 'log_file_num_backups': 5, 21 | } 22 | super(Repeater, self).__init__(settings) 23 | 24 | 25 | def main(): 26 | bot = Repeater() 27 | tweebot.enable_logging(bot) 28 | bot.start_forever( 29 | tweebot.SearchMentions(), 30 | tweebot.MultiPart.And( 31 | tweebot.BaseFilter, 32 | tweebot.UsersFilter.Friends(reload_every=100), 33 | tweebot.BadTweetFilter), 34 | tweebot.ReplyRetweet) 35 | 36 | if __name__ == '__main__': 37 | main() 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | TweeBot v1.0 2 | ============ 3 | A Python library to build twitter bots over tweepy library. It's a very simple 4 | and flexible way to create your own bot. 5 | 6 | 7 | Example 8 | ======= 9 | 10 | ```python 11 | # Next code demonstrates how to create simple twitter bot that select all friends' 12 | # tweets with your mentiones and retweet they. (See comments in code for more 13 | # details about how filters work.) 14 | 15 | import tweebot as twb 16 | 17 | def main(): 18 | # Step 1. setup context configuration 19 | repeater = twb.Context({ 20 | 'app_name' : 'repeater', 21 | 'username' : '', 22 | 'consumer_key' : '', 23 | 'consumer_secret' : '', 24 | 'access_key' : '', 25 | 'access_secret' : '', 26 | 'timeout' : 1 * 60, # 1 min, ensure twitter api limits 27 | 'history_file' : 'history.json', 28 | }) 29 | 30 | # Step 2. enable pretty logging 31 | twb.enable_logging(repeater) 32 | 33 | # Step 3. setup chain Selector->Filters->Action 34 | chain = ( 35 | twb.SearchMentions(), 36 | twb.MultiPart.And( 37 | twb.BaseFilter, 38 | twb.UsersFilter.Friends(), 39 | twb.BadTweetFilter), 40 | twb.ReplyRetweet) 41 | 42 | # Step 4. start processing 43 | repeater.start_forever(*chain) 44 | 45 | if __name__ == '__main__': 46 | main() 47 | ``` 48 | 49 | Other 50 | ===== 51 | 52 | * **Bug tracker**: 53 | * **Souce code**: 54 | * **Dependencies**: 55 | - Python 2.5 or newer (<3.0) 56 | - Tweepy 57 | - Simplejson (Included in python 2.6+) 58 | -------------------------------------------------------------------------------- /examples/complementor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import tweebot 5 | 6 | 7 | TEMPLATES = [ 8 | 'Have a good day @%s!', 9 | 'Well done @%s!', 10 | 'You are so sweety @%s!', 11 | 'You are so amazing @%s!', 12 | 'Your beauty is amazing @%s!', 13 | 'You look well @%s!', 14 | 'It\'s a pleasure to talk to you @%s!', 15 | 'You are an intelligent person @%s!', 16 | 'It\'s a pleasure to deal with you @%s!', 17 | 'You look wonderful @%s!', 18 | 'You are charming @%s!', 19 | 'You look lovely @%s!', 20 | 'How are you @%s?', 21 | ] 22 | 23 | 24 | class Complementor(tweebot.Context): 25 | def __init__(self, *args, **kwargs): 26 | settings = { 27 | 'app_name': 'complementor', 28 | #'username' : '', 29 | #'consumer_key' : '', 30 | #'consumer_secret': '', 31 | #'access_key' : '', 32 | #'access_secret' : '', 33 | 'timeout': 30 * 60, # 30 min 34 | 'history_file': 'complementor.history' 35 | } 36 | super(Complementor, self).__init__(settings) 37 | 38 | 39 | def main(): 40 | bot = Complementor() 41 | tweebot.enable_logging(bot) 42 | bot.start_forever( 43 | tweebot.MultiPart.Add( 44 | tweebot.SearchMentions(), 45 | tweebot.SearchQuery('complementor')), 46 | tweebot.MultiPart.And( 47 | tweebot.BaseFilter, 48 | tweebot.MultiPart.Or( 49 | tweebot.UsersFilter.Friends(), 50 | tweebot.UsersFilter.Followers())), 51 | tweebot.ReplyTemplate(TEMPLATES)) 52 | 53 | if __name__ == '__main__': 54 | main() 55 | -------------------------------------------------------------------------------- /examples/thanks_follow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | This is simple Twitter Bot that sends "Thanks for Following" 6 | replies to every new follower. 7 | ''' 8 | 9 | import tweebot 10 | 11 | # Feel free to add more templates 12 | TEMPLATES = ["@%s, Thanks for Following!", ] 13 | 14 | 15 | def SelectFollowers(context): 16 | # Cursor wrapper can be used here, but for demo we ok with 17 | # current implementation 18 | try: 19 | users_ids = context.api.followers_ids() 20 | return context.api.lookup_users(user_ids=users_ids[:100]) 21 | except Exception, e: # Tweepy error 22 | logging.error('Failed to select followers %s' % str(e)) 23 | return [] 24 | 25 | 26 | class ReplyTemplateDirect(tweebot.ReplyTemplate): 27 | '''Sends direct message generated from template''' 28 | def reply(self, context, user_id, text): 29 | return context.api.send_direct_message(user_id=user_id, text=text) 30 | 31 | 32 | class ThanksForFollowing(tweebot.Context): 33 | def __init__(self, *args, **kwargs): 34 | settings = { 35 | 'app_name': 'thanks_follow', 36 | #'username' : '', 37 | #'consumer_key' : '', 38 | #'consumer_secret': '', 39 | #'access_key' : '', 40 | #'access_secret' : '', 41 | 'timeout': 20 * 60, # check every 20 min 42 | 'history_file': 'thanks_follow.history' 43 | } 44 | super(ThanksForFollowing, self).__init__(settings) 45 | 46 | 47 | def main(): 48 | bot = ThanksForFollowing() 49 | tweebot.enable_logging(bot) 50 | bot.start_forever( 51 | SelectFollowers, 52 | tweebot.BaseFilter, 53 | ReplyTemplateDirect(TEMPLATES)) 54 | 55 | 56 | if __name__ == '__main__': 57 | main() 58 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import operator 5 | import unittest 6 | import tweebot 7 | 8 | # Some helpful utilitest 9 | True_ = lambda *a, **kw: True 10 | False_ = lambda *a, **kw: False 11 | OneTwo = lambda *a, **kw: [1, 2] 12 | ThreeFour = lambda *a, **kw: [3, 4] 13 | 14 | 15 | class AttrProxy(dict): 16 | def __getattr__(self, key): 17 | try: 18 | return self[key] 19 | except KeyError: 20 | raise AttributeError 21 | 22 | 23 | class MockContext(AttrProxy): 24 | def __init__(self, settings=None, history=None, api=None): 25 | required_attrs = { 26 | 'settings': AttrProxy(settings or {}), 27 | 'history': history or [], 28 | 'api': AttrProxy(api or {}), 29 | } 30 | super(MockContext, self).__init__(required_attrs) 31 | 32 | 33 | class SettingsTests(unittest.TestCase): 34 | '''Test tweebot.Settings class''' 35 | def setUp(self): 36 | self.settings = tweebot.Settings( 37 | {'a': 1, 'b': 2, 'c': 3}, parent_settings={'c': 4, 'd': 5}) 38 | 39 | def test_getattr(self): 40 | self.assertEqual(self.settings.a, 1) 41 | self.assertEqual(self.settings.b, 2) 42 | 43 | def test_parentattr(self): 44 | self.assertEqual(self.settings.c, 3) 45 | self.assertEqual(self.settings.d, 5) 46 | 47 | def test_invalidattr(self): 48 | self.assertRaises(AttributeError, lambda: self.settings.a_invalid_attr) 49 | self.assertRaises(AttributeError, lambda: self.settings.b_invalid_attr) 50 | 51 | def test_setattr(self): 52 | self.settings.e = 1 53 | self.assertEqual(self.settings.e, 1) 54 | self.settings.f = 2 55 | self.assertEqual(self.settings.f, 2) 56 | 57 | def test_delattr(self): 58 | del self.settings.a 59 | self.assertRaises(AttributeError, lambda: self.settings.a) 60 | del self.settings.b 61 | self.assertRaises(AttributeError, lambda: self.settings.b) 62 | 63 | def test_mergesettings(self): 64 | merged_sett = self.settings.merge_settings( 65 | {'a': 1}, {'a': 2, 'b': 3}, {'b': 2, 'c': 2}) 66 | self.assertEqual(merged_sett['a'], 2) 67 | self.assertEqual(merged_sett['b'], 2) 68 | self.assertEqual(merged_sett['c'], 2) 69 | 70 | def test_defaultsettings(self): 71 | def_sett = self.settings.default_settings() 72 | def_sett.logging 73 | def_sett.timeout 74 | 75 | 76 | class TestMultiPart(unittest.TestCase): 77 | '''Test tweebot.MultiPart class''' 78 | 79 | def test_and(self): 80 | part = tweebot.MultiPart.And(True_, False_, True_) 81 | self.assertFalse(part()) 82 | 83 | def test_or(self): 84 | part = tweebot.MultiPart.Or(True_, False_, True_) 85 | self.assertTrue(part()) 86 | 87 | def test_add(self): 88 | part = tweebot.MultiPart.Add(OneTwo, ThreeFour) 89 | self.assertEqual(part(), [1, 2, 3, 4]) 90 | 91 | def test_prepare(self): 92 | # count summary lists size 93 | part = tweebot.MultiPart( 94 | OneTwo, ThreeFour, prepare=len, reduce_operator=operator.add) 95 | self.assertEqual(part(), 4) 96 | 97 | def test_overrideprepare(self): 98 | class TestMultiPart(tweebot.MultiPart): 99 | def prepare(self, result): 100 | return len(result) 101 | part = TestMultiPart(OneTwo, ThreeFour, reduce_operator=operator.add) 102 | self.assertEqual(part(), 4) 103 | 104 | 105 | class TestCondition(unittest.TestCase): 106 | '''Test tweebot.Condition, tweebot.RegexpCondition classes''' 107 | def setUp(self): 108 | pass 109 | 110 | def test_condition(self): 111 | cond = tweebot.Condition(False_, default_result=1) 112 | self.assertFalse(cond(None, None)) 113 | 114 | def test_defaultresult(self): 115 | class FalseCondition(tweebot.Condition): 116 | def is_suitable(self, *a, **kw): 117 | return False 118 | cond = FalseCondition(False_, default_result=1) 119 | self.assertEqual(cond(None, None), 1) 120 | 121 | def test_regepxcondition(self): 122 | cond = tweebot.RegexpCondition(False_, r'\d+', default_result=1) 123 | self.assertFalse(cond(None, AttrProxy(text="abc123"))) 124 | self.assertEqual(cond(None, AttrProxy(text="abc")), 1) 125 | 126 | 127 | class TestBaseFilter(unittest.TestCase): 128 | '''Test tweebot.BaseFilter class''' 129 | def setUp(self): 130 | settings = {'username': 'user1', 'blocked_users': ['user2', 'user3']} 131 | history = [1, 2, 3] 132 | self.context = MockContext(settings=settings, history=history) 133 | 134 | def test_myname(self): 135 | entity1 = AttrProxy(id=0, screen_name='user1') 136 | self.assertFalse(tweebot.BaseFilter(self.context, entity1)) 137 | entity2 = AttrProxy(id=0, screen_name='user0') 138 | self.assertTrue(tweebot.BaseFilter(self.context, entity2)) 139 | 140 | def test_blockeduser(self): 141 | entity1 = AttrProxy(id=0, screen_name='user2') 142 | self.assertFalse(tweebot.BaseFilter(self.context, entity1)) 143 | entity2 = AttrProxy(id=0, screen_name='user3') 144 | self.assertFalse(tweebot.BaseFilter(self.context, entity2)) 145 | 146 | def test_alreadyreplyed(self): 147 | entity = AttrProxy(id=1, screen_name='user0') 148 | self.assertFalse(tweebot.BaseFilter(self.context, entity)) 149 | 150 | 151 | class TestBadTweetFilter(unittest.TestCase): 152 | '''Test tweebot.BadTweetFilter class''' 153 | def setUp(self): 154 | settings = {'blocked_words': ['word1', 'word2']} 155 | self.context = MockContext(settings=settings) 156 | 157 | def test_validtweet(self): 158 | entity = AttrProxy(id=0, screen_name='user0', text='text') 159 | self.assertTrue(tweebot.BadTweetFilter(self.context, entity)) 160 | 161 | def test_blockedwords(self): 162 | entity = AttrProxy(id=0, screen_name='user0', text='word1 word2') 163 | self.assertFalse(tweebot.BadTweetFilter(self.context, entity)) 164 | 165 | def test_alotofusernames(self): 166 | entity = AttrProxy(id=0, screen_name='user0', text='@user0 @user1') 167 | self.assertFalse(tweebot.BadTweetFilter(self.context, entity)) 168 | 169 | 170 | class TestUsersFilter(unittest.TestCase): 171 | '''Test tweebot.UsersFilter class''' 172 | def setUp(self): 173 | api = {'friends_ids': OneTwo, 'followers_ids': OneTwo} 174 | self.context = MockContext(api=api) 175 | self.entity_ok = AttrProxy(id=1, screen_name='user0') 176 | self.entity_fail = AttrProxy(id=0, screen_name='user0') 177 | 178 | def test_validuser(self): 179 | filter_ = tweebot.UsersFilter([1, 2]) 180 | self.assertTrue(filter_(self.context, self.entity_ok)) 181 | 182 | def test_allowedlist(self): 183 | filter_ = tweebot.UsersFilter([1, 2]) 184 | self.assertFalse(filter_(self.context, self.entity_fail)) 185 | 186 | def test_allowedfunc(self): 187 | filter_ = tweebot.UsersFilter(OneTwo) 188 | self.assertTrue(filter_(self.context, self.entity_ok)) 189 | self.assertFalse(filter_(self.context, self.entity_fail)) 190 | 191 | def test_followers(self): 192 | filter_ = tweebot.UsersFilter.Followers() 193 | self.assertTrue(filter_(self.context, self.entity_ok)) 194 | self.assertFalse(filter_(self.context, self.entity_fail)) 195 | 196 | def test_friends(self): 197 | filter_ = tweebot.UsersFilter.Friends() 198 | self.assertTrue(filter_(self.context, self.entity_ok)) 199 | self.assertFalse(filter_(self.context, self.entity_fail)) 200 | 201 | def test_reloadevery(self): 202 | self.rotate = False 203 | 204 | def allowed(ctx): 205 | self.rotate = not self.rotate 206 | return self.rotate and [1] or [0] 207 | 208 | filter_ = tweebot.UsersFilter(allowed, reload_every=2) 209 | self.assertTrue(filter_(self.context, self.entity_ok)) 210 | self.assertFalse(filter_(self.context, self.entity_ok)) 211 | self.assertTrue(filter_(self.context, self.entity_fail)) 212 | self.assertFalse(filter_(self.context, self.entity_fail)) 213 | 214 | def test_reloadwaserror(self): 215 | self.rotate = False 216 | 217 | def allowed(ctx): 218 | self.rotate = not self.rotate 219 | if self.rotate: 220 | return [1, 2] 221 | raise Exception() 222 | 223 | filter_ = tweebot.UsersFilter(allowed, reload_every=1) 224 | self.assertTrue(filter_(self.context, self.entity_ok)) 225 | self.assertTrue(filter_(self.context, self.entity_ok)) 226 | self.assertTrue(filter_.was_error) 227 | self.assertFalse(filter_(self.context, self.entity_fail)) 228 | self.assertFalse(filter_.was_error) 229 | self.assertTrue(filter_(self.context, self.entity_ok)) 230 | 231 | # 232 | # Other tests comming soon... :) 233 | # 234 | 235 | if __name__ == '__main__': 236 | unittest.main() 237 | -------------------------------------------------------------------------------- /tweebot/tweebot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | import time 6 | import random 7 | import operator 8 | import logging 9 | import logging.handlers 10 | import tweepy 11 | 12 | try: 13 | import simplejson as json 14 | except ImportError: 15 | try: 16 | import json 17 | except ImportError: 18 | try: 19 | from django.utils import simplejson as json # Google App Engine? 20 | except ImportError: 21 | raise ImportError('json library is not installed') 22 | 23 | 24 | __all__ = [ 25 | '_author', 'enable_logging', 'Settings', 'History', 'Context', 'MultiPart', 26 | 'SearchQuery', 'SearchMentions', 'BaseFilter', 'UsersFilter', 27 | 'BadTweetFilter', 'ReplyRetweet', 'ReplyTemplate', 'Condition', 28 | 'RegexpCondition', ] 29 | 30 | 31 | def enable_logging(context): 32 | '''Turns on formated logging output based on provided settings 33 | from context. Stdout will be used by default if no settings has 34 | been given. 35 | ''' 36 | settings = context.settings 37 | root_logger = logging.getLogger() 38 | if settings.get('logging') == 'none': 39 | # logging has been disabled by user 40 | return root_logger 41 | level = getattr(logging, settings.get('logging', 'info').upper()) 42 | root_logger.setLevel(level) 43 | formatter = logging.Formatter('%(asctime)s | %(levelname)s | %(message)s') 44 | if settings.get('log_file_prefix'): 45 | channel = logging.handlers.RotatingFileHandler( 46 | filename=settings.log_file_prefix, 47 | maxBytes=settings.log_file_max_size, 48 | backupCount=settings.log_file_num_backups) 49 | channel.setFormatter(formatter) 50 | root_logger.addHandler(channel) 51 | if settings.get('log_to_stderr') or \ 52 | ('log_to_stderr' not in settings and not root_logger.handlers): 53 | channel = logging.StreamHandler() 54 | channel.setFormatter(formatter) 55 | root_logger.addHandler(channel) 56 | return root_logger 57 | 58 | 59 | def _author(entity, details=False, default=None): 60 | '''Helper to unify access to author's info in different Models. 61 | 62 | If `details` set to True will return tuple (name, id) otherwise 63 | will return only name. 64 | ''' 65 | # check for `Status` Model 66 | if hasattr(entity, 'author'): 67 | a = entity.author 68 | return details and (a.screen_name, a.id) or a.screen_name 69 | # else check for `SearchResult` Model 70 | if hasattr(entity, 'from_user'): 71 | return details and (entity.from_user, entity.from_user_id) or entity.from_user 72 | # else check for `User` model 73 | if hasattr(entity, 'screen_name'): 74 | return details and (entity.screen_name, entity.id) or entity.screen_name 75 | # so, use default 76 | return default 77 | 78 | 79 | class Settings(dict): 80 | '''Context's settings, an dictionary with object-like access. 81 | This class allows create hierarchical structure for your settings. 82 | ''' 83 | @classmethod 84 | def merge_settings(cls, *args): 85 | res = {} 86 | for arg in args: 87 | if isinstance(arg, dict): 88 | res.update(arg.copy()) 89 | return res 90 | 91 | @classmethod 92 | def default_settings(cls): 93 | '''Returns default settings thats can be userd as parent for 94 | your ones. 95 | ''' 96 | if not hasattr(cls, '_default_setttings'): 97 | cls._default_setttings = cls({ 98 | 'logging': 'info', 99 | 'history_file': 'replyed.json', 100 | 'log_to_stderr': True, 101 | 'log_file_prefix': False, 102 | 'log_file_max_size': 1024 * 1024 * 64, 103 | 'log_file_num_backups': 4, 104 | 'timeout': 30 * 60, 105 | 'blocked_users': [], 106 | 'blocked_words': [], 107 | }) 108 | return cls._default_setttings 109 | 110 | def __init__(self, settings=None, parent_settings=None): 111 | if settings is None: 112 | settings = {} 113 | if parent_settings is None: 114 | parent_settings = {} 115 | dict.__init__(self, self.merge_settings(parent_settings, settings)) 116 | self._parent_settings = parent_settings 117 | 118 | def __getattr__(self, key): 119 | try: 120 | return self[key] 121 | except KeyError, ex: 122 | raise AttributeError(ex) 123 | 124 | def __setattr__(self, key, value): 125 | self[key] = value 126 | 127 | def __delattr__(self, key): 128 | try: 129 | del self[key] 130 | except KeyError, ex: 131 | raise AttributeError(ex) 132 | 133 | def __repr__(self): 134 | return '' % dict.__repr__(self) 135 | 136 | 137 | class History(list): 138 | '''Simple history class with python list interface. The history 139 | will be saved/loaded to/from text file in JSON format. You can 140 | override save()/load() methods to provide other way to persist 141 | data. 142 | 143 | If `auto_load` parameter set to True data will be loaded 144 | automaticaly otherwise load() method should be called manually. 145 | Also you can `limit` number of ids that will be saved to file. 146 | ''' 147 | def __init__(self, name, auto_load=True, limit=None): 148 | super(History, self).__init__() 149 | self.name = name 150 | self.limit = limit 151 | if auto_load: 152 | self.load(limit=limit) 153 | 154 | def load(self, limit=None): 155 | try: 156 | f = open(self.name) 157 | try: 158 | self.extend(json.loads(f.read())[:limit or self.limit]) 159 | finally: 160 | f.close() 161 | except Exception, e: # IOError, JSONDecodeError 162 | logging.error('Filed to load history | %s' % str(e)) 163 | 164 | def save(self, limit=None): 165 | try: 166 | f = open(self.name, 'w') 167 | try: 168 | # prefer newest ids, it will be helpful to track 169 | # replyed tweets: 170 | # 171 | # >> if max(history) > current_id: 172 | # >> print 'current_id should be skipped' 173 | # 174 | recent_first = sorted(self, reverse=True) 175 | f.write(json.dumps(recent_first[:limit or self.limit])) 176 | finally: 177 | f.close() 178 | except IOError, e: 179 | logging.error('Filed to save history | %s' % str(e)) 180 | 181 | 182 | class Context(object): 183 | '''Base Twitter Bot class. The class provides context-sensitive 184 | data that will be used in Filters, Selectors, Payloads and etc. 185 | It works like glue between all bot's bloks. 186 | ''' 187 | def __init__(self, settings, *args, **kwargs): 188 | if not isinstance(settings, Settings): 189 | settings = Settings( 190 | settings, parent_settings=Settings.default_settings()) 191 | self.settings = settings 192 | 193 | def get_api(self): 194 | '''Returns configured tweepy API object.''' 195 | if not hasattr(self, '_api'): 196 | auth = tweepy.OAuthHandler( 197 | self.settings.consumer_key, self.settings.consumer_secret) 198 | auth.set_access_token( 199 | self.settings.access_key, self.settings.access_secret) 200 | self._api, self._auth = tweepy.API(auth), auth 201 | return self._api 202 | api = property(get_api) 203 | 204 | def get_history(self, auto_load=True, limit=None): 205 | '''Returns configured history object.''' 206 | if not hasattr(self, '_history'): 207 | self._history = History( 208 | self.settings.history_file, auto_load=auto_load, limit=limit) 209 | return self._history 210 | history = property(get_history) 211 | 212 | def start(self, select, validate, payload, save_history=True): 213 | try: 214 | return [payload(self, entity) for entity in select(self) if validate(self, entity)] 215 | finally: 216 | if save_history: 217 | self.history.save() 218 | 219 | def start_forever(self, select, validate, payload, save_history=True): 220 | try: 221 | while True: 222 | logging.info('Started') 223 | results = self.start( 224 | select, validate, payload, save_history=save_history) 225 | logging.info('Finished | %d' % len(results)) 226 | time.sleep(self.settings.timeout) 227 | except (KeyboardInterrupt, SystemExit): 228 | raise 229 | finally: 230 | self.history.save() 231 | 232 | 233 | class MultiPart(object): 234 | '''It's bot blok's container. This class allow to work with 235 | several bloks (Selectors, for example) like with single one. 236 | The main idea that we can reduce results from every given part 237 | using a reduce operator. Also you can pass `prepare` function, 238 | this function will be used to handle results before they will 239 | be reduced. 240 | 241 | For example, filter that allows tweets that match one of the 242 | two given filters. Filters results will be converted to bool 243 | type and than reduced: 244 | >> multi_filter = MultiPart(filter1, filter2, 245 | ... reduce_operator=operator.or_, prepare=bool) 246 | >> context.start(selector1, multi_filter, payload1) 247 | ''' 248 | @classmethod 249 | def And(cls, *parts): 250 | return cls(*parts, **dict(reduce_operator=operator.and_)) 251 | 252 | @classmethod 253 | def Or(cls, *parts): 254 | return cls(*parts, **dict(reduce_operator=operator.or_)) 255 | 256 | @classmethod 257 | def Add(cls, *parts): 258 | return cls(*parts, **dict(reduce_operator=operator.add)) 259 | 260 | def __init__(self, *parts, **kwargs): 261 | self.parts = parts 262 | self.reduce_operator = kwargs.get('reduce_operator') or operator.add 263 | self.prepare_func = kwargs.get('prepare') 264 | 265 | def prepare(self, result): 266 | '''You can override this methos to provide global-level 267 | result preprocessing before reduce operation. 268 | ''' 269 | if self.prepare_func: 270 | return self.prepare_func(result) 271 | return result 272 | 273 | def __call__(self, *args, **kwargs): 274 | handle_results = lambda p: self.prepare(p(*args, **kwargs)) 275 | return reduce(self.reduce_operator, map(handle_results, self.parts)) 276 | 277 | 278 | def SearchQuery(query, limit=100): 279 | '''Returns tweets tnats match the given `query`''' 280 | def search_handler(context): 281 | try: 282 | return context.api.search(query)[:limit] 283 | except tweepy.error.TweepError, e: 284 | logging.error('Filed to search query `%s` | %s' % (query, str(e))) 285 | return [] 286 | return search_handler 287 | 288 | 289 | def SearchMentions(limit=100): 290 | '''Returns the mentions of the current user''' 291 | def search_handler(context): 292 | try: 293 | mentions = tweepy.Cursor(context.api.mentions).items(limit=limit) 294 | return list(mentions) 295 | except tweepy.error.TweepError, e: 296 | logging.error('Filed to search mentions | %s' % str(e)) 297 | return [] 298 | return search_handler 299 | 300 | 301 | def BaseFilter(context, entity): 302 | '''Filter that excules: 303 | 1. Ours tweets (from user whose name is given in settings). 304 | 2. Tweets from blocked users. 305 | 3. Tweets that already have been answered (tweet_id saved in history). 306 | ''' 307 | settings = context.settings 308 | reply_id, reply_to = entity.id, _author(entity) 309 | if reply_to == settings.get('username'): 310 | return False 311 | if reply_to.lower() in settings.get('blocked_users', []): 312 | return False 313 | if reply_id in context.history: 314 | return False 315 | # if max(context.history) > reply_id: 316 | # return False 317 | return True 318 | 319 | 320 | def BadTweetFilter(context, entity): 321 | '''Filter that excules tweets with invalid content.''' 322 | blocked_words = set(context.settings.get('blocked_words', [])) 323 | normalized_tweet = entity.text.lower().strip() 324 | tweet_parts = normalized_tweet.split() 325 | username_count = normalized_tweet.count('@') 326 | # if contains bloked words 327 | if blocked_words & set(tweet_parts): 328 | return False 329 | # if contains more usernames than words 330 | if username_count >= len(tweet_parts) - username_count: 331 | return False 332 | # if contains author mentions 333 | # if tweet_parts.count('@'+ _author(entity, default='').lower()) > 0: 334 | # return False 335 | return True 336 | 337 | 338 | class UsersFilter(object): 339 | '''Filter that allows only tweets from specific category of 340 | users. 341 | 342 | `allowed_users` - it's list with users ids or accessor function, 343 | that applies context and returns list with ids. 344 | ''' 345 | @classmethod 346 | def Followers(cls, reload_every=100): 347 | '''Followers only filter''' 348 | return cls(lambda ctx: ctx.api.followers_ids(), reload_every=reload_every) 349 | 350 | @classmethod 351 | def Friends(cls, reload_every=100): 352 | '''Friends only filter''' 353 | return cls(lambda ctx: ctx.api.friends_ids(), reload_every=reload_every) 354 | 355 | def __init__(self, allowed_users, reload_every=100): 356 | # users list will be refreshed for every `reload_every` call 357 | self.allowed_users = allowed_users 358 | self.reload_every = reload_every 359 | self.was_error = False 360 | self._calls = 0 # current num. of calls 361 | self._users = None 362 | 363 | def relaod_users(self, context): 364 | try: 365 | if callable(self.allowed_users): 366 | self._users = self.allowed_users(context) 367 | else: 368 | self._users = self.allowed_users 369 | self.was_error = False 370 | except Exception, e: 371 | logging.warning(str(e)) 372 | self.was_error = True 373 | 374 | def __call__(self, context, entity): 375 | self._calls += 1 376 | need_reload = self._calls >= self.reload_every 377 | if self._users is None or need_reload or self.was_error: 378 | self.relaod_users(context) 379 | if need_reload: 380 | self._calls = 0 381 | _, author_id = _author(entity, details=True, default=(0, 0)) 382 | return self._users and author_id in self._users 383 | 384 | 385 | def ReplyRetweet(context, entity): 386 | '''Just retweets given tweet.''' 387 | reply_id = entity.id 388 | try: 389 | result = context.api.retweet(reply_id) 390 | logging.info('Retweeted | %s ' % reply_id) 391 | context.history.append(reply_id) 392 | return result 393 | except tweepy.error.TweepError, e: 394 | logging.error('%s | %s' % (reply_id, str(e))) 395 | return False 396 | 397 | 398 | class ReplyTemplate(object): 399 | '''Replies with one of the given template''' 400 | @classmethod 401 | def validate_templates(cls, templates): 402 | if not hasattr(templates, '__iter__'): 403 | return [] 404 | valid_templates = [] 405 | for tmpl in templates: 406 | try: 407 | # we should include @username in reply 408 | tmpl % ('just for test',) 409 | valid_templates.append(tmpl) 410 | except: 411 | logging.error('Invalid template: %s' % tmpl) 412 | return valid_templates 413 | 414 | def __init__(self, templates): 415 | self.templates = self.validate_templates(templates) 416 | 417 | def render_template(self, context, entity): 418 | return random.choice(self.templates) % _author(entity) 419 | 420 | def reply(self, context, reply_id, text): 421 | return context.api.update_status(text, reply_id) 422 | 423 | def __call__(self, context, entity): 424 | reply_id = entity.id 425 | text = self.render_template(context, entity) 426 | try: 427 | result = self.reply(context, reply_id, text) 428 | context.history.append(reply_id) 429 | logging.info('%s | Reply: %s' % (reply_id, text)) 430 | return result 431 | except tweepy.error.TweepError, e: 432 | logging.error('%s | %s | %s' % (reply_id, str(e), text)) 433 | return False 434 | 435 | 436 | class Condition(object): 437 | '''Conditional payload part. Child part (payloads only) will 438 | be executed only if this condition is suitable for given 439 | context and entity, otherwise `default_result` will be returned. 440 | ''' 441 | def __init__(self, payload_part, default_result=None): 442 | self.part = payload_part 443 | self.default_result = default_result 444 | 445 | def is_suitable(self, context, entity): 446 | '''Override this method to implement specific rule. 447 | 448 | Say for example, child part should be executed for entity 449 | with text length == 10. It should look like this: 450 | 451 | def is_suitable(self, ctx, entity): 452 | return len(entity.text) == 10 453 | ''' 454 | return True 455 | 456 | def handle(self, context, entity): 457 | return self.part(context, entity) 458 | 459 | def __call__(self, context, entity): 460 | if self.is_suitable(context, entity): 461 | return self.handle(context, entity) 462 | return self.default_result 463 | 464 | 465 | class RegexpCondition(Condition): 466 | '''Executes for tweet that matches to given regexp''' 467 | def __init__(self, payload_part, regexp, *args, **kwargs): 468 | super(RegexpCondition, self).__init__(payload_part, *args, **kwargs) 469 | self.regexp = regexp 470 | 471 | def is_suitable(self, context, entity): 472 | # note that we are looking w/o any `re` flags 473 | return re.search(self.regexp, entity.text) 474 | --------------------------------------------------------------------------------