├── .gitignore ├── COPYING ├── LICENSE ├── MANIFEST.in ├── README.rst ├── TYPES.rst ├── emots.py ├── endpointtest.py ├── histtest.py ├── hypchat.sublime-project ├── hypchat ├── __init__.py ├── __main__.py ├── requests.py └── restobject.py ├── setup.py └── tests ├── __init__.py ├── common.py └── test_rate_limit.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyo 2 | *.pyc 3 | *.sublime-workspace 4 | build/ 5 | dist/ 6 | MANIFEST 7 | emots.html 8 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2013, Riders Discount 3 | http://www.ridersdiscount.com/ 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | COPYING -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | HypChat 3 | ======= 4 | A Python package for HipChat's `v2 JSON REST API`_. It's based on v2's navigability and self-declaration. 5 | 6 | .. _v2 JSON REST API: https://www.hipchat.com/docs/apiv2 7 | 8 | Installation 9 | ============ 10 | HypChat can either be installed from PyPI_: 11 | :: 12 | 13 | pip install hypchat 14 | 15 | Or from source_: 16 | :: 17 | 18 | python setup.py install 19 | 20 | .. _PyPI: https://pypi.python.org/pypi/hypchat/ 21 | .. _source: https://github.com/RidersDiscountCom/HypChat 22 | 23 | Concepts 24 | ======== 25 | 26 | There are two basic types in HypChat: ``Linker`` and ``RestObject``. They are not meant to be instantiated directly but instead created as references from other objects. 27 | 28 | Linker 29 | ------ 30 | A simple callable that represents an unfollowed reference. 31 | 32 | ``l.url`` 33 | The URL this object points to 34 | 35 | ``l()`` 36 | Calling a ``Linker`` will perform the request and return a ``RestObject`` 37 | 38 | RestObject 39 | ---------- 40 | A subclass of ``dict``, contains additional functionality for links and actions. 41 | 42 | Links 43 | ~~~~~ 44 | As part of the v2 API, all objects have a ``links`` property with references to other objects. This is used to create ``Linker`` objects as attributes. 45 | 46 | For example, all objects have a link called ``self``. This may be referenced as: 47 | :: 48 | 49 | obj.self 50 | 51 | And the request performed by calling it: 52 | :: 53 | 54 | obj.self() 55 | 56 | .. _expand: 57 | 58 | If `Title Expansion`_ is desired, just past a list of things to be expanded as the ``expand`` keyword argument. 59 | 60 | .. _Title Expansion: https://www.hipchat.com/docs/apiv2/expansion 61 | 62 | Other Actions 63 | ~~~~~~~~~~~~~ 64 | 65 | Many of the v2 types define additional types, eg Rooms have methods for messaging, setting the topic, getting the history, and inviting users to the room. These are implemented as methods of subclasses. The complete listing is in the `Type List`_. 66 | 67 | Timezone Handling 68 | ----------------- 69 | HypChat uses aware ``datetime`` objects throughout by the ``dateutil`` module. However, the HipChat API universally uses UTC. 70 | 71 | For methods that take a ``datetime``, if a naive object is given, it will be assumed to be in UTC. If this is not what you mean, ``dateutil.tz`` has a wonderful selection of timezones_ available. 72 | 73 | .. _timezones: http://labix.org/python-dateutil#head-587bd3efc48f897f55c179abc520a34330ee0a62 74 | 75 | Usage 76 | ===== 77 | 78 | First, create a HypChat object with the token 79 | 80 | :: 81 | 82 | hc = HypChat("mytoken") 83 | 84 | If you use Hipchat Server 85 | 86 | :: 87 | 88 | hc = HypChat("mytoken", endpoint="https://hipchat.example.com") 89 | 90 | There are several root links: 91 | 92 | :: 93 | 94 | rooms = hc.rooms() 95 | users = hc.users() 96 | emots = hc.emoticons() 97 | caps = hc.capabilities() 98 | 99 | In addition, the HypChat object has methods for creating objects and directly referencing the basic types. 100 | 101 | For example, you might do: 102 | 103 | :: 104 | 105 | for room in (r for r in hipchat.rooms(expand='items') if r['last_active'] < datetime.datetime(2013, 12, 1)): 106 | room.owner.message("Your room is dead; maybe archive it") 107 | 108 | Since ``room.owner`` is a User stub, not just a generic object. (The Room objects are not stubs, since the ``expand`` keyword is used). 109 | 110 | Downloading history is as easy as: 111 | 112 | :: 113 | 114 | list(HypChat(token).get_room(id).history(datetime.datetime.utcnow()).contents()) 115 | 116 | Note that this may eat up many requests for large rooms. 117 | 118 | Navigation 119 | ---------- 120 | Any time an object is referenced in a value (eg ``room['owner']``), a stub of that object is created, and the full object may be found with ``.self()``. Stubs contain the ID of the object, the name (if applicable), and any links that object has-including ``self``. This can be avoided by using the expand_ keyword. 121 | 122 | Collections-such as ``rooms``, ``users``, and ``emots`` above-all have an ``'items'`` key containing their list of things. In addition, the ``.contents()`` method will generate all of the items, handling pagination. As usual, object 123 | 124 | Console 125 | ------- 126 | If you call ``python -m hypchat``, a interactive prompt (using IPython_ if available) will appear. The environment will contain ``hipchat``, an instance of the ``HypChat`` object. The token is pulled from ``~/.hypchat``, ``/etc/hypchat``, or the environment variable ``HIPCHAT_TOKEN``. 127 | 128 | .. _IPython: http://ipython.org/ 129 | 130 | Type List 131 | ========= 132 | 133 | See `TYPES.rst`_ 134 | 135 | .. _TYPES.rst: https://github.com/RidersDiscountCom/HypChat/blob/master/TYPES.rst 136 | 137 | TODO List 138 | ========= 139 | * API Links 140 | -------------------------------------------------------------------------------- /TYPES.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | HypChat Type Reference 3 | ====================== 4 | 5 | HypChat 6 | ------- 7 | As the root object, this is mostly full of "singletons" and shortcut methods. 8 | 9 | Links 10 | ~~~~~ 11 | ``capabilities`` 12 | The `capabilities descriptor`_ for HipChat 13 | 14 | ``emoticons`` 15 | The `Emoticons Collection`_ 16 | 17 | ``rooms`` 18 | The `Rooms Collection`_ 19 | 20 | ``users`` 21 | The `Users Collection`_ 22 | 23 | .. _capabilities descriptor: https://www.hipchat.com/docs/apiv2/method/get_capabilities 24 | 25 | Methods 26 | ~~~~~~~ 27 | ``create_user()`` 28 | Creates a new User_ object and returns the response. 29 | 30 | ``get_user()`` 31 | Gets a User_ directly without having to navigate 32 | 33 | ``create_room()`` 34 | Creates a new Room_ object and returns the response. 35 | 36 | ``get_room()`` 37 | Gets a Room_ directly without having to navigate 38 | 39 | ``get_emoticon()`` 40 | Gets a Emoticon_ directly without having to navigate 41 | 42 | REST Objects 43 | ------------ 44 | All objects, collections, rooms, webhooks, etc have these things. 45 | 46 | Links 47 | ~~~~~ 48 | ``self`` 49 | A link to this object 50 | 51 | Values 52 | ~~~~~~ 53 | ``id`` 54 | The numeric ID of this object (except collections and webhooks) 55 | 56 | Methods 57 | ~~~~~~~ 58 | ``delete()`` 59 | Attempts to delete the object (with HTTP ``DELETE``) 60 | 61 | ``save()`` 62 | Attempts to save the object (with HTTP ``PUT``) 63 | 64 | All Collections 65 | --------------- 66 | All collections share this interface. Specific collection objects are "paged": no single object contains all items. 67 | 68 | Links 69 | ~~~~~ 70 | ``next`` 71 | (Optional) The next page in this collection 72 | ``prev`` 73 | (Optional) The previous page in this collection 74 | 75 | Values 76 | ~~~~~~ 77 | ``items`` 78 | A list of the things for this 'page' of the collection 79 | 80 | ``maxResults`` 81 | The maximum number of items that could be in this page 82 | 83 | ``startIndex`` 84 | The index of the first item in this page, starting at 0 85 | 86 | Methods 87 | ~~~~~~~ 88 | ``contents()`` 89 | A generator that produces all items, navigating pagination in the process 90 | 91 | Rooms Collection 92 | ---------------- 93 | In addition to the things defined in `All Collections`_, the Rooms Collection has the below. 94 | 95 | Methods 96 | ~~~~~~~ 97 | ``create()`` 98 | Creates a new Room_ 99 | 100 | Room 101 | ---- 102 | Representing a single chat room. 103 | 104 | Links 105 | ~~~~~ 106 | ``webhooks`` 107 | The `Webhooks Collection`_ for this room 108 | 109 | ``members`` 110 | (Optional) For private rooms only; the `Members Collection`_ for this room 111 | 112 | Methods 113 | ~~~~~~~ 114 | ``message()`` 115 | Currently a pointer to ``notification()`` 116 | 117 | ``notification()`` 118 | Sends a message to a room 119 | 120 | ``topic()`` 121 | Sets the topic 122 | 123 | ``history()`` 124 | Grabs a collection of the history. Note that the items of this collection are not full REST objects, 125 | just dictionaries. See `All Collections`_ for the interface. 126 | 127 | ``invite()`` 128 | Invite a user to this room 129 | 130 | Values 131 | ~~~~~~ 132 | ``name`` 133 | Display name 134 | 135 | ``created`` 136 | When the room was created 137 | 138 | ``guest_access_url`` 139 | The URL to give for guest access, if enabled 140 | 141 | ``is_archived`` 142 | ``True`` if this room is archived, ``False`` otherwise 143 | 144 | ``last_active`` 145 | When the room last had activity 146 | 147 | ``owner`` 148 | A reference to the owning User_ 149 | 150 | ``participants`` 151 | A list of User_ stubs currently in the room 152 | 153 | ``privacy`` 154 | One of ``'public'`` or ``'private'`` 155 | 156 | ``topic`` 157 | The current topic 158 | 159 | ``xmpp_jid`` 160 | The XMPP (Jabber) ID 161 | 162 | Webhooks Collection 163 | ------------------- 164 | In addition to the those in `All Collections`_, the Webhooks Collection has the below. 165 | 166 | Methods 167 | ~~~~~~~ 168 | ``create()`` 169 | Create a new Webhook_ 170 | 171 | Webhook 172 | ------- 173 | Unlike most REST Objects, Webhooks don't have an ID. Their stub is also much more extensive 174 | 175 | Fields 176 | ~~~~~~ 177 | ``url`` 178 | (Stubbed) The URL to ``POST`` to 179 | 180 | ``event`` 181 | (Stubbed) The event to call this hook on, one of ``'room_message'``, ``'room_notification'``, ``'room_exit'``, ``'room_enter'``, ``'room_topic_change'`` 182 | 183 | ``pattern`` 184 | (Stubbed) When ``event`` is ``'room_message'``, a regular expression to match against the message 185 | 186 | ``name`` 187 | (Stubbed) A human label for this hook 188 | 189 | ``room`` 190 | The Room_ this webhook is for 191 | 192 | ``creator`` 193 | The User_ that created this webhook 194 | 195 | ``created`` 196 | When this webhook was created 197 | 198 | Members Collection 199 | ------------------ 200 | In addition to the those in `All Collections`_, the Members Collection has the below. 201 | 202 | Methods 203 | ~~~~~~~ 204 | ``add()`` 205 | Add a User_ to the list of members 206 | 207 | ``remove()`` 208 | Remove a User_ from the list of members 209 | 210 | Users Collection 211 | ---------------- 212 | In addition to the things defined in `All Collections`_, the Users Collection has the below. 213 | 214 | Methods 215 | ~~~~~~~ 216 | ``create()`` 217 | Creates a new User_ 218 | 219 | User 220 | ---- 221 | 222 | Methods 223 | ~~~~~~~ 224 | ``message()`` 225 | Sends a private message to the given user 226 | 227 | Values 228 | ~~~~~~ 229 | ``name`` 230 | Display name 231 | 232 | ``xmpp_jid`` 233 | The XMPP (Jabber) ID of the user 234 | 235 | ``is_deleted`` 236 | ``True`` if the user has been deleted 237 | 238 | ``last_active`` 239 | The last time the user was active 240 | 241 | ``title`` 242 | The person's company title 243 | 244 | ``presence`` 245 | A ``dict`` of the values: 246 | 247 | ``status`` 248 | A status message, or ``None`` 249 | 250 | ``idle`` 251 | The number of seconds the user has been idle, or ``None`` 252 | 253 | ``show`` 254 | The status category, one of: ``'away'``, ``'chat'``, ``'dnd'``, ``'xa'``, or ``None`` 255 | 256 | ``is_online`` 257 | ``True`` if the user is online 258 | 259 | ``created`` 260 | When the User was created 261 | 262 | ``mention_name`` 263 | User's @mention name 264 | 265 | ``is_group_admin`` 266 | ``True`` if the user is an admin of the group/company/etc 267 | 268 | ``timezone`` 269 | The user's timezone 270 | 271 | ``email`` 272 | The user's email 273 | 274 | ``photo_url`` 275 | The user's URL as a string. 276 | 277 | Emoticons Collection 278 | -------------------- 279 | Defines nothing above `All Collections`_ 280 | 281 | Emoticon 282 | -------- 283 | 284 | Fields 285 | ~~~~~~ 286 | ``shortcut`` 287 | (Stubbed) The name used to invoke the emoticon, as in "(shortcut)" 288 | 289 | ``url`` 290 | (Stubbed) The image file for this emoticon 291 | 292 | ``creator`` 293 | The User_ that created this emoticon 294 | 295 | ``width`` 296 | The width of the image, in pixels 297 | 298 | ``height`` 299 | The height of the image, in pixels 300 | 301 | ``audio_path`` 302 | (Optional) An audio file that should be played at the same time 303 | -------------------------------------------------------------------------------- /emots.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """ 3 | Writes out emots.html, and index of all emoticons available to you. 4 | """ 5 | 6 | import os, os.path 7 | import ConfigParser 8 | import sys 9 | from hypchat import * 10 | 11 | config = ConfigParser.ConfigParser() 12 | config.read([os.path.expanduser('~/.hypchat'), '/etc/hypchat']) 13 | AUTH_TOKEN = config.get('HipChat', 'token') 14 | 15 | hipchat = HypChat(AUTH_TOKEN) 16 | 17 | with open('emots.html', 'w') as html: 18 | html.write(""" 19 | 20 | 21 | 22 | HipChat Emoticons 23 | 24 | 25 | """) 26 | for emot in hipchat.emoticons().contents(): 27 | html.write(""" 28 | {shortcut} 29 | """.format(**emot)) 30 | 31 | html.write(""" 32 | 33 | """) -------------------------------------------------------------------------------- /endpointtest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ConfigParser 3 | import sys 4 | from hypchat import * 5 | 6 | AUTH_TOKEN = None 7 | ENDPOINT = None 8 | config = ConfigParser.ConfigParser() 9 | config.read([os.path.expanduser('~/.hypchat'), '/etc/hypchat']) 10 | if config.has_section('HipChat'): 11 | AUTH_TOKEN = config.get('HipChat', 'token') 12 | ENDPOINT = config.get('HipChat', 'endpoint') 13 | 14 | if 'HIPCHAT_TOKEN' in os.environ: 15 | AUTH_TOKEN = os.environ['HIPCHAT_TOKEN'] 16 | 17 | if 'HIPCHAT_ENDPOINT' in os.environ: 18 | ENDPOINT = os.environ['HIPCHAT_ENDPOINT'] 19 | 20 | if ENDPOINT: 21 | hipchat = HypChat(AUTH_TOKEN, ENDPOINT) 22 | else: 23 | hipchat = HypChat(AUTH_TOKEN) 24 | 25 | import datetime 26 | 27 | room = hipchat.rooms()['items'][0] 28 | print(room['name']) 29 | -------------------------------------------------------------------------------- /histtest.py: -------------------------------------------------------------------------------- 1 | import os, os.path 2 | import ConfigParser 3 | import sys 4 | from hypchat import * 5 | 6 | config = ConfigParser.ConfigParser() 7 | config.read([os.path.expanduser('~/.hypchat'), '/etc/hypchat']) 8 | AUTH_TOKEN = config.get('HipChat', 'token') 9 | 10 | if 'HIPCHAT_TOKEN' in os.environ: 11 | AUTH_TOKEN = os.environ['HIPCHAT_TOKEN'] 12 | 13 | hipchat = HypChat(AUTH_TOKEN) 14 | 15 | import datetime 16 | 17 | room = hipchat.rooms()['items'][0] 18 | print room['name'] 19 | hist = room.history(datetime.datetime.utcnow(), maxResults=500) 20 | fullhist = [] 21 | for item in hist.contents(): 22 | print item['message'] -------------------------------------------------------------------------------- /hypchat.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "follow_symlinks": true, 6 | "path": "." 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /hypchat/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division 2 | import json 3 | import time 4 | import warnings 5 | import sys 6 | import os 7 | import datetime 8 | import six 9 | from .requests import Requests, BearerAuth, HttpTooManyRequests 10 | from .restobject import Linker 11 | 12 | 13 | class RateLimitWarning(Warning): 14 | """ 15 | This token has been rate limited. Waiting for the next reset. 16 | """ 17 | 18 | 19 | def jsonify(obj): 20 | if isinstance(obj, datetime.datetime): 21 | return obj.isoformat() 22 | elif isinstance(obj, set): 23 | return list(obj) 24 | else: 25 | raise TypeError("Can't JSONify objects of type %s" % type(obj).__name__) 26 | 27 | 28 | class _requests(Requests): 29 | def __init__(self, *p, **kw): 30 | super(_requests, self).__init__(*p, **kw) 31 | self.rl_remaining = 99999 32 | self.rl_reset = 0 33 | self.dump_reqs = '__HYPCHAT_DEBUG_REQUESTS__' in os.environ 34 | 35 | @staticmethod 36 | def _data(data, kwargs): 37 | if isinstance(data, six.string_types): 38 | return data 39 | elif data is not None: 40 | kwargs.setdefault('headers', {})['Content-Type'] = 'application/json' 41 | rv = json.dumps(data, default=jsonify) 42 | return rv 43 | 44 | def _rl_sleep(self, until): 45 | t = until - time.time() 46 | if t > 0: 47 | warnings.warn("HipChat has been rate limited; Waiting %0.1fs for the next reset." % t, RateLimitWarning) 48 | time.sleep(t) 49 | 50 | def request(self, method, url, **kwargs): 51 | if self.dump_reqs: 52 | print >> sys.stderr, "REQUEST", method, url 53 | while True: 54 | try: 55 | if self.rl_remaining <= 0: 56 | # We're out of requests, chill 57 | self._rl_sleep(self.rl_reset) 58 | resp = super(_requests, self).request(method, url, **kwargs) 59 | except HttpTooManyRequests as e: 60 | self.rl_remaining = int(e.response.headers['x-ratelimit-remaining']) 61 | if not self.rl_remaining: 62 | self.rl_reset = float(e.response.headers['x-ratelimit-reset']) 63 | continue # Try the request again 64 | else: 65 | raise 66 | else: 67 | self.rl_remaining = int(resp.headers['x-ratelimit-remaining']) 68 | self.rl_reset = float(resp.headers['x-ratelimit-reset']) 69 | return resp 70 | 71 | def post(self, url, data=None, **kwargs): 72 | data = self._data(data, kwargs) 73 | return super(_requests, self).post(url, data=data, **kwargs) 74 | 75 | def patch(self, url, data=None, **kwargs): 76 | data = self._data(data, kwargs) 77 | return super(_requests, self).patch(url, data=data, **kwargs) 78 | 79 | def put(self, url, data=None, **kwargs): 80 | data = self._data(data, kwargs) 81 | return super(_requests, self).put(url, data=data, **kwargs) 82 | 83 | 84 | __all__ = ('HypChat',) 85 | 86 | 87 | class HypChat(object): 88 | def __init__(self, token, endpoint='https://api.hipchat.com', verify=True): 89 | self._requests = _requests(auth=BearerAuth(token), verify=verify) 90 | self.capabilities = Linker('{0}/v2/capabilities'.format(endpoint), _requests=self._requests) 91 | self.emoticons = Linker('{0}/v2/emoticon'.format(endpoint), _requests=self._requests) 92 | self.rooms = Linker('{0}/v2/room'.format(endpoint), _requests=self._requests) 93 | self.users_url = '{0}/v2/user'.format(endpoint) 94 | self.endpoint = endpoint 95 | 96 | def users(self, **ops): 97 | """users([guests=bool], [deleted=bool]) -> UserCollection 98 | 99 | Returns a collection of users, with the following keyword options: 100 | * guests: If True, return active guests 101 | * deleted: If True, return deleted users 102 | """ 103 | params = {} 104 | if ops.get('guests', False): 105 | params['include-guests'] = 'true' 106 | if ops.get('deleted', False): 107 | params['include-deleted'] = 'true' 108 | if 'expand' in ops: 109 | params['expand'] = ops['expand'] 110 | resp = self._requests.get(self.users_url, params=params) 111 | return Linker._obj_from_text(resp.text, self._requests) 112 | 113 | 114 | def fromurl(self, url, **kwargs): 115 | return Linker(url, _requests=self._requests)(**kwargs) 116 | 117 | def create_room(self, name, owner=Ellipsis, privacy='public', guest_access=True): 118 | """ 119 | Creates a new room. 120 | """ 121 | data = { 122 | 'name': name, 123 | 'privacy': privacy, 124 | 'guest_access': guest_access, 125 | } 126 | if owner is not Ellipsis: 127 | if owner is None: 128 | data['owner_user_id'] = owner 129 | else: 130 | data['owner_user_id'] = owner['id'] 131 | resp = self._requests.post(self.rooms.url, data=data) 132 | return Linker._obj_from_text(resp.text, self._requests) 133 | 134 | def create_user(self, name, email, title='', mention_name='', is_group_admin=False, timezone='UTC', password=''): 135 | """ 136 | Creates a new user. 137 | """ 138 | data = { 139 | 'name': name, 140 | 'email': email, 141 | 'title': title, 142 | 'mention_name': mention_name, 143 | 'is_group_admin': is_group_admin, 144 | 'timezone': timezone, # TODO: Support timezone objects 145 | 'password': password, 146 | } 147 | resp = self._requests.post(self.users_url, data=data) 148 | return Linker._obj_from_text(resp.text, self._requests) 149 | 150 | def get_room(self, id_or_name, **kwargs): 151 | return self.fromurl('{0}/v2/room/{1}'.format(self.endpoint, id_or_name), **kwargs) 152 | 153 | def get_user(self, id_or_email, **kwargs): 154 | return self.fromurl('{0}/v2/user/{1}'.format(self.endpoint, id_or_email), **kwargs) 155 | 156 | def get_emoticon(self, id_or_shortcut, **kwargs): 157 | return self.fromurl('{0}/v2/emoticon/{1}'.format(self.endpoint, id_or_shortcut), **kwargs) 158 | -------------------------------------------------------------------------------- /hypchat/__main__.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | import os, os.path 3 | import ConfigParser 4 | import sys 5 | 6 | config = ConfigParser.ConfigParser() 7 | config.read([os.path.expanduser('~/.hypchat'), '/etc/hypchat']) 8 | if config.has_section('HipChat'): 9 | AUTH_TOKEN = config.get('HipChat', 'token') 10 | 11 | elif 'HIPCHAT_TOKEN' in os.environ: 12 | AUTH_TOKEN = os.environ['HIPCHAT_TOKEN'] 13 | 14 | else: 15 | print('Authorization token not detected! The token is pulled from ' \ 16 | '~/.hypchat, /etc/hypchat, or the environment variable HIPCHAT_TOKEN.') 17 | sys.exit(1) 18 | 19 | ENDPOINT = None 20 | if config.has_section('HipChat'): 21 | ENDPOINT = config.get('HipChat', 'endpoint') 22 | 23 | elif 'HIPCHAT_ENDPOINT' in os.environ: 24 | ENDPOINT = os.environ['HIPCHAT_ENDPOINT'] 25 | 26 | if ENDPOINT: 27 | hipchat = HypChat(AUTH_TOKEN, ENDPOINT) 28 | else: 29 | hipchat = HypChat(AUTH_TOKEN) 30 | 31 | capabilities = hipchat.capabilities 32 | emoticons = hipchat.emoticons 33 | rooms = hipchat.rooms 34 | users = hipchat.users 35 | endpoint = hipchat.endpoint 36 | 37 | try: 38 | import IPython 39 | 40 | IPython.embed() 41 | except ImportError: 42 | import code 43 | 44 | code.interact(local=locals()) 45 | -------------------------------------------------------------------------------- /hypchat/requests.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division 2 | import requests 3 | 4 | 5 | class BearerAuth(requests.auth.AuthBase): 6 | def __init__(self, token): 7 | self.token = token 8 | 9 | def __call__(self, r): 10 | r.headers['Authorization'] = "Bearer %s" % self.token 11 | return r 12 | 13 | 14 | def req(method, url, params=None, data=None): 15 | c = getattr(requests, method.lower()) 16 | kwargs = {} 17 | if params is not None: 18 | kwargs['params'] = params 19 | if data is not None: 20 | kwargs['headers'] = {'content-type': 'application/json'} 21 | kwargs['data'] = json.dumps(data) 22 | return c(url, auth=BearerAuth(AUTH_TOKEN), **kwargs) 23 | 24 | 25 | class HttpClientError(requests.exceptions.HTTPError): 26 | """The 4xx class of status code is intended for cases in which the client seems to have erred. 27 | Except when responding to a HEAD request, the server SHOULD include an entity containing an 28 | explanation of the error situation, and whether it is a temporary or permanent condition. These 29 | status codes are applicable to any request method. User agents SHOULD display any included 30 | entity to the user. 31 | """ 32 | 33 | 34 | _http_errors = {} 35 | 36 | 37 | class HttpBadRequest(HttpClientError): 38 | """400 Bad Request 39 | 40 | The request could not be understood by the server due to malformed syntax. The client SHOULD 41 | NOT repeat the request without modifications. 42 | """ 43 | 44 | 45 | _http_errors[400] = HttpBadRequest 46 | 47 | 48 | class HttpUnauthorized(HttpClientError): 49 | """401 Unauthorized 50 | 51 | The request requires user authentication. The response MUST include a WWW-Authenticate header 52 | field (section 14.47) containing a challenge applicable to the requested resource. The client 53 | MAY repeat the request with a suitable Authorization header field (section 14.8). If the 54 | request already included Authorization credentials, then the 401 response indicates that 55 | authorization has been refused for those credentials. If the 401 response contains the same 56 | challenge as the prior response, and the user agent has already attempted authentication at 57 | least once, then the user SHOULD be presented the entity that was given in the response, since 58 | that entity might include relevant diagnostic information. HTTP access authentication is 59 | explained in "HTTP Authentication: Basic and Digest Access Authentication". 60 | """ 61 | 62 | 63 | _http_errors[401] = HttpUnauthorized 64 | 65 | 66 | class HttpPaymentRequired(HttpClientError): 67 | """402 Payment Required 68 | 69 | This code is reserved for future use. 70 | """ 71 | 72 | 73 | _http_errors[402] = HttpPaymentRequired 74 | 75 | 76 | class HttpForbidden(HttpClientError): 77 | """403 Forbidden 78 | 79 | The server understood the request, but is refusing to fulfill it. Authorization will not help 80 | and the request SHOULD NOT be repeated. If the request method was not HEAD and the server 81 | wishes to make public why the request has not been fulfilled, it SHOULD describe the reason for 82 | the refusal in the entity. If the server does not wish to make this information available to 83 | the client, the status code 404 (Not Found) can be used instead. 84 | """ 85 | 86 | 87 | _http_errors[403] = HttpForbidden 88 | 89 | 90 | class HttpNotFound(HttpClientError): 91 | """404 Not Found 92 | 93 | The server has not found anything matching the Request-URI. No indication is given of whether 94 | the condition is temporary or permanent. The 410 (Gone) status code SHOULD be used if the 95 | server knows, through some internally configurable mechanism, that an old resource is 96 | permanently unavailable and has no forwarding address. This status code is commonly used when 97 | the server does not wish to reveal exactly why the request has been refused, or when no other 98 | response is applicable. 99 | """ 100 | 101 | 102 | _http_errors[404] = HttpNotFound 103 | 104 | 105 | class HttpMethodNotAllowed(HttpClientError): 106 | """405 Method Not Allowed 107 | 108 | The method specified in the Request-Line is not allowed for the resource identified by the 109 | Request-URI. The response MUST include an Allow header containing a list of valid methods for 110 | the requested resource. 111 | """ 112 | 113 | 114 | _http_errors[405] = HttpMethodNotAllowed 115 | 116 | 117 | class HttpNotAcceptable(HttpClientError): 118 | """The resource identified by the request is only capable of generating response entities which 119 | have content characteristics not acceptable according to the accept headers sent in the request. 120 | 121 | Unless it was a HEAD request, the response SHOULD include an entity containing a list of 122 | available entity characteristics and location(s) from which the user or user agent can choose 123 | the one most appropriate. The entity format is specified by the media type given in the 124 | Content-Type header field. Depending upon the format and the capabilities of the user agent, 125 | selection of the most appropriate choice MAY be performed automatically. However, this 126 | specification does not define any standard for such automatic selection. 127 | 128 | Note: HTTP/1.1 servers are allowed to return responses which are not acceptable according 129 | to the accept headers sent in the request. In some cases, this may even be preferable to 130 | sending a 406 response. User agents are encouraged to inspect the headers of an incoming 131 | response to determine if it is acceptable. 132 | 133 | If the response could be unacceptable, a user agent SHOULD temporarily stop receipt of more 134 | data and query the user for a decision on further actions. 135 | """ 136 | 137 | 138 | _http_errors[406] = HttpNotAcceptable 139 | 140 | 141 | class HttpProxyAuthenticationRequired(HttpClientError): 142 | """407 Proxy Authentication Required 143 | 144 | This code is similar to 401 (Unauthorized), but indicates that the client must first 145 | authenticate itself with the proxy. The proxy MUST return a Proxy-Authenticate header field 146 | (section 14.33) containing a challenge applicable to the proxy for the requested resource. The 147 | client MAY repeat the request with a suitable Proxy-Authorization header field (section 14.34). 148 | HTTP access authentication is explained in "HTTP Authentication: Basic and Digest Access 149 | Authentication". 150 | """ 151 | 152 | 153 | _http_errors[407] = HttpProxyAuthenticationRequired 154 | 155 | 156 | class HttpRequestTimeout(HttpClientError): 157 | """408 Request Timeout 158 | 159 | The client did not produce a request within the time that the server was prepared to wait. The 160 | client MAY repeat the request without modifications at any later time. 161 | """ 162 | 163 | 164 | _http_errors[408] = HttpRequestTimeout 165 | 166 | 167 | class HttpConflict(HttpClientError): 168 | """409 Conflict 169 | 170 | The request could not be completed due to a conflict with the current state of the resource. 171 | This code is only allowed in situations where it is expected that the user might be able to 172 | resolve the conflict and resubmit the request. The response body SHOULD include enough 173 | information for the user to recognize the source of the conflict. Ideally, the response entity 174 | would include enough information for the user or user agent to fix the problem; however, that 175 | might not be possible and is not required. 176 | 177 | Conflicts are most likely to occur in response to a PUT request. For example, if versioning 178 | were being used and the entity being PUT included changes to a resource which conflict with 179 | those made by an earlier (third-party) request, the server might use the 409 response to 180 | indicate that it can't complete the request. In this case, the response entity would likely 181 | contain a list of the differences between the two versions in a format defined by the response 182 | Content-Type. 183 | """ 184 | 185 | 186 | _http_errors[409] = HttpConflict 187 | 188 | 189 | class HttpGone(HttpClientError): 190 | """410 Gone 191 | 192 | The requested resource is no longer available at the server and no forwarding address is known. 193 | This condition is expected to be considered permanent. Clients with link editing capabilities 194 | SHOULD delete references to the Request-URI after user approval. If the server does not know, 195 | or has no facility to determine, whether or not the condition is permanent, the status code 404 196 | (Not Found) SHOULD be used instead. This response is cacheable unless indicated otherwise. 197 | 198 | The 410 response is primarily intended to assist the task of web maintenance by notifying the 199 | recipient that the resource is intentionally unavailable and that the server owners desire that 200 | remote links to that resource be removed. Such an event is common for limited-time, promotional 201 | services and for resources belonging to individuals no longer working at the server's site. It 202 | is not necessary to mark all permanently unavailable resources as "gone" or to keep the mark 203 | for any length of time -- that is left to the discretion of the server owner. 204 | """ 205 | 206 | 207 | _http_errors[410] = HttpGone 208 | 209 | 210 | class HttpLengthRequired(HttpClientError): 211 | """411 Length Required 212 | 213 | The server refuses to accept the request without a defined Content-Length. The client MAY 214 | repeat the request if it adds a valid Content-Length header field containing the length of the 215 | message-body in the request message. 216 | """ 217 | 218 | 219 | _http_errors[411] = HttpLengthRequired 220 | 221 | 222 | class HttpPreconditionFailed(HttpClientError): 223 | """412 Precondition Failed 224 | 225 | The precondition given in one or more of the request-header fields evaluated to false when it 226 | was tested on the server. This response code allows the client to place preconditions on the 227 | current resource metainformation (header field data) and thus prevent the requested method from 228 | being applied to a resource other than the one intended. 229 | """ 230 | 231 | 232 | _http_errors[412] = HttpPreconditionFailed 233 | 234 | 235 | class HttpRequestEntityTooLarge(HttpClientError): 236 | """413 Request Entity Too Large 237 | 238 | The server is refusing to process a request because the request entity is larger than the 239 | server is willing or able to process. The server MAY close the connection to prevent the client 240 | from continuing the request. 241 | 242 | If the condition is temporary, the server SHOULD include a Retry-After header field to indicate 243 | that it is temporary and after what time the client MAY try again. 244 | """ 245 | 246 | 247 | _http_errors[413] = HttpRequestEntityTooLarge 248 | 249 | 250 | class HttpRequestUriTooLong(HttpClientError): 251 | """414 Request-URI Too Long 252 | 253 | The server is refusing to service the request because the Request-URI is longer than the server 254 | is willing to interpret. This rare condition is only likely to occur when a client has 255 | improperly converted a POST request to a GET request with long query information, when the 256 | client has descended into a URI "black hole" of redirection (e.g., a redirected URI prefix that 257 | points to a suffix of itself), or when the server is under attack by a client attempting to 258 | exploit security holes present in some servers using fixed-length buffers for reading or 259 | manipulating the Request-URI. 260 | """ 261 | 262 | 263 | _http_errors[414] = HttpRequestUriTooLong 264 | 265 | 266 | class HttpUnsupportedMediaType(HttpClientError): 267 | """415 Unsupported Media Type 268 | 269 | The server is refusing to service the request because the entity of the request is in a format 270 | not supported by the requested resource for the requested method. 271 | """ 272 | 273 | 274 | _http_errors[415] = HttpUnsupportedMediaType 275 | 276 | 277 | class HttpRequestedRangeNotSatisfiable(HttpClientError): 278 | """416 Requested Range Not Satisfiable 279 | 280 | A server SHOULD return a response with this status code if a request included a Range 281 | request-header field (section 14.35), and none of the range-specifier values in this field 282 | overlap the current extent of the selected resource, and the request did not include an 283 | If-Range request-header field. (For byte-ranges, this means that the first-byte-pos of all of 284 | the byte-range-spec values were greater than the current length of the selected resource.) 285 | 286 | When this status code is returned for a byte-range request, the response SHOULD include a 287 | Content-Range entity-header field specifying the current length of the selected resource (see 288 | section 14.16). This response MUST NOT use the multipart/byteranges content-type. 289 | """ 290 | 291 | 292 | _http_errors[416] = HttpRequestedRangeNotSatisfiable 293 | 294 | 295 | class HttpExpectationFailed(HttpClientError): 296 | """417 Expectation Failed 297 | 298 | The expectation given in an Expect request-header field (see section 14.20) could not be met by 299 | this server, or, if the server is a proxy, the server has unambiguous evidence that the request 300 | could not be met by the next-hop server. 301 | """ 302 | 303 | 304 | _http_errors[417] = HttpExpectationFailed 305 | 306 | 307 | class HttpTooManyRequests(HttpClientError): 308 | """429 Too Many Requests 309 | 310 | The 429 status code indicates that the user has sent too many 311 | requests in a given amount of time ("rate limiting") 312 | 313 | https://www.hipchat.com/docs/apiv2/rate_limiting 314 | """ 315 | 316 | 317 | _http_errors[429] = HttpTooManyRequests 318 | 319 | 320 | class HttpServerError(requests.exceptions.HTTPError): 321 | """Server Error 5xx 322 | 323 | Response status codes beginning with the digit "5" indicate cases in which the server is aware 324 | that it has erred or is incapable of performing the request. Except when responding to a HEAD 325 | request, the server SHOULD include an entity containing an explanation of the error situation, 326 | and whether it is a temporary or permanent condition. User agents SHOULD display any included 327 | entity to the user. These response codes are applicable to any request method. 328 | """ 329 | 330 | 331 | class HttpInternalServerError(HttpServerError): 332 | """500 Internal Server Error 333 | 334 | The server encountered an unexpected condition which prevented it from fulfilling the request. 335 | """ 336 | 337 | 338 | _http_errors[500] = HttpInternalServerError 339 | 340 | 341 | class HttpNotImplemented(HttpServerError, NotImplementedError): 342 | """501 Not Implemented 343 | 344 | The server does not support the functionality required to fulfill the request. This is the 345 | appropriate response when the server does not recognize the request method and is not capable 346 | of supporting it for any resource. 347 | """ 348 | 349 | 350 | _http_errors[501] = HttpNotImplemented 351 | 352 | 353 | class HttpBadGateway(HttpServerError): 354 | """502 Bad Gateway 355 | 356 | The server, while acting as a gateway or proxy, received an invalid response from the upstream 357 | server it accessed in attempting to fulfill the request. 358 | """ 359 | 360 | 361 | _http_errors[502] = HttpBadGateway 362 | 363 | 364 | class HttpServiceUnavailable(HttpServerError): 365 | """503 Service Unavailable 366 | 367 | The server is currently unable to handle the request due to a temporary overloading or 368 | maintenance of the server. The implication is that this is a temporary condition which will be 369 | alleviated after some delay. If known, the length of the delay MAY be indicated in a 370 | Retry-After header. If no Retry-After is given, the client SHOULD handle the response as it 371 | would for a 500 response. 372 | 373 | Note: The existence of the 503 status code does not imply that a server must use it when 374 | becoming overloaded. Some servers may wish to simply refuse the connection. 375 | """ 376 | 377 | 378 | _http_errors[503] = HttpServiceUnavailable 379 | 380 | 381 | class HttpGatewayTimeout(HttpServerError): 382 | """504 Gateway Timeout 383 | 384 | The server, while acting as a gateway or proxy, did not receive a timely response from the 385 | upstream server specified by the URI (e.g. HTTP, FTP, LDAP) or some other auxiliary server 386 | (e.g. DNS) it needed to access in attempting to complete the request. 387 | 388 | Note: Note to implementors: some deployed proxies are known to return 400 or 500 when DNS 389 | lookups time out. 390 | """ 391 | 392 | 393 | _http_errors[504] = HttpGatewayTimeout 394 | 395 | 396 | class HttpVersionNotSupported(HttpServerError): 397 | """505 HTTP Version Not Supported 398 | 399 | The server does not support, or refuses to support, the HTTP protocol version that was used in 400 | the request message. The server is indicating that it is unable or unwilling to complete the 401 | request using the same major version as the client, as described in section 3.1, other than 402 | with this error message. The response SHOULD contain an entity describing why that version is 403 | not supported and what other protocols are supported by that server. 404 | """ 405 | 406 | 407 | _http_errors[505] = HttpVersionNotSupported 408 | 409 | 410 | class Requests(requests.sessions.Session): 411 | """ 412 | Class that extends the requests module in two ways: 413 | * Supports default arguments, for things like repeated Auth 414 | * Raise errors when requests go poorly 415 | """ 416 | 417 | def __init__(self, **kwargs): 418 | self._template = kwargs.copy() 419 | super(Requests, self).__init__() 420 | 421 | def _kw(self, kwargs): 422 | kw = self._template.copy() 423 | kw.update(kwargs) 424 | return kw 425 | 426 | def request(self, method, url, **kwargs): 427 | rv = super(Requests, self).request(method, url, **self._kw(kwargs)) 428 | # Raise one of our specific errors 429 | if rv.status_code in _http_errors: 430 | raise _http_errors[rv.status_code](rv.text, response=rv) 431 | # Try to raise for errors we didn't code for 432 | rv.raise_for_status() 433 | return rv 434 | -------------------------------------------------------------------------------- /hypchat/restobject.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division 2 | import json 3 | import re 4 | import datetime 5 | import dateutil.parser 6 | import dateutil.tz 7 | import six 8 | 9 | _urls_to_objects = {} 10 | 11 | 12 | def timestamp(dt): 13 | """ 14 | Parses a HipChat datetime value. 15 | 16 | HipChat uses ISO 8601, optionally with the timezone attached. Except for when they use a timestamp. 17 | """ 18 | # '2013-12-05T22:42:18+00:00' <== History 19 | #'2013-11-27T15:33:24' <== Rooms, Users 20 | if dt is None: 21 | return 22 | if isinstance(dt, int): 23 | rv = datetime.datetime.fromtimestamp(dt, dateutil.tz.tzutc()) 24 | elif dt.isdigit(): 25 | rv = datetime.datetime.fromtimestamp(int(dt), dateutil.tz.tzutc()) 26 | else: 27 | rv = dateutil.parser.parse(dt) 28 | if rv.tzinfo is None: 29 | rv = rv.replace(tzinfo=dateutil.tz.tzutc()) 30 | return rv 31 | 32 | 33 | def mktimestamp(dt): 34 | """ 35 | Prepares a datetime for sending to HipChat. 36 | """ 37 | if dt.tzinfo is None: 38 | dt = dt.replace(tzinfo=dateutil.tz.tzutc()) 39 | return dt.isoformat(), dt.tzinfo.tzname(dt) 40 | 41 | 42 | class Linker(object): 43 | """ 44 | Responsible for on-demand loading of JSON objects. 45 | """ 46 | url = None 47 | 48 | def __init__(self, url, parent=None, _requests=None): 49 | self.url = url 50 | self.__parent = parent 51 | self._requests = _requests or __import__('requests') 52 | 53 | @staticmethod 54 | def _obj_from_text(text, requests): 55 | """ 56 | Constructs objects (including our wrapper classes) from a JSON-formatted string 57 | """ 58 | 59 | def _object_hook(obj): 60 | if 'links' in obj: 61 | klass = RestObject 62 | if 'self' in obj['links']: 63 | for p, c in six.iteritems(_urls_to_objects): 64 | if p.match(obj['links']['self']): 65 | klass = c 66 | break 67 | rv = klass(obj) 68 | rv._requests = requests 69 | return rv 70 | else: 71 | return obj 72 | 73 | return json.JSONDecoder(object_hook=_object_hook).decode(text) 74 | 75 | def __call__(self, expand=None, **kwargs): 76 | """ 77 | Actually perform the request 78 | """ 79 | params = {} 80 | if expand is not None: 81 | if isinstance(expand, six.string_types): 82 | params = {'expand': expand} 83 | else: 84 | params = {'expand': ','.join(expand)} 85 | if kwargs: 86 | merge_params = {} 87 | for k, v in six.iteritems(kwargs): 88 | merge_params[k.replace('_', '-')] = v 89 | params.update(merge_params) 90 | 91 | rv = self._obj_from_text(self._requests.get(self.url, params=params).text, self._requests) 92 | rv._requests = self._requests 93 | if self.__parent is not None: 94 | rv.parent = self.__parent 95 | return rv 96 | 97 | def __repr__(self): 98 | return "<%s url=%r>" % (type(self).__name__, self.url) 99 | 100 | 101 | class RestObject(dict): 102 | """ 103 | Nice wrapper around the JSON objects and their links. 104 | """ 105 | 106 | def __getattr__(self, name): 107 | if name in self.get('links', {}): 108 | return Linker(self['links'][name], parent=self, _requests=self._requests) 109 | elif name in self: 110 | return self[name] 111 | else: 112 | raise AttributeError("%r object has no attribute %r" % (type(self).__name__, name)) 113 | 114 | @property 115 | def url(self): 116 | return self['links']['self'] 117 | 118 | def save(self): 119 | return self._requests.put(self.url, data=self).json() 120 | 121 | def delete(self): 122 | self._requests.delete(self.url) 123 | 124 | 125 | _at_mention = re.compile('@[\w]+(?: |$)') 126 | 127 | 128 | class Room(RestObject): 129 | def __init__(self, *p, **kw): 130 | super(Room, self).__init__(*p, **kw) 131 | if 'last_active' in self: 132 | self['last_active'] = timestamp(self['last_active']) 133 | if 'created' in self: 134 | self['created'] = timestamp(self['created']) 135 | 136 | def reply(self, message, parent_message_id): 137 | """ 138 | Send a reply to a message 139 | """ 140 | data = {'message': message, 'parentMessageId': parent_message_id} 141 | self._requests.post(self.url + '/reply', data=data) 142 | 143 | def message(self, message): 144 | """ 145 | Allows a user to send a message to a room. 146 | """ 147 | data = {'message': message} 148 | self._requests.post(self.url + '/message', data=data) 149 | 150 | def notification(self, message, color=None, notify=False, format=None): 151 | """ 152 | Send a message to a room. 153 | """ 154 | if not format: 155 | if len(_at_mention.findall(message)) > 0: 156 | format = 'text' 157 | else: 158 | format = 'html' 159 | data = {'message': message, 'notify': notify, 'message_format': format} 160 | if color: 161 | data['color'] = color 162 | self._requests.post(self.url + '/notification', data=data) 163 | 164 | def topic(self, text): 165 | """ 166 | Set a room's topic. Useful for displaying statistics, important links, server status, you name it! 167 | """ 168 | self._requests.put(self.url + '/topic', data={ 169 | 'topic': text, 170 | }) 171 | 172 | def history(self, date='recent', maxResults=200): 173 | """ 174 | Requests the room history. 175 | 176 | Note that if date is 'recent' (the default), HipChat will not return the complete history. 177 | """ 178 | tz = 'UTC' 179 | if date != 'recent': 180 | date, tz = mktimestamp(date) 181 | params = { 182 | 'date': date, 183 | 'timezone': tz, 184 | 'max-results': maxResults, 185 | } 186 | resp = self._requests.get(self.url + '/history', params=params) 187 | return Linker._obj_from_text(resp.text, self._requests) 188 | 189 | def latest(self, not_before=None, maxResults=200): 190 | """ 191 | Return the latest room history. 192 | 193 | If ``not_before`` is provided, messages that precede the message id will not be returned 194 | """ 195 | params = { 196 | "max-results": maxResults 197 | } 198 | if not_before is not None: 199 | params["not-before"] = not_before 200 | 201 | resp = self._requests.get(self.url + '/history/latest', params=params) 202 | return Linker._obj_from_text(resp.text, self._requests) 203 | 204 | def invite(self, user, reason): 205 | self._requests.post(self.url + '/invite/%s' % user['id'], data={ 206 | 'reason': reason, 207 | }) 208 | 209 | def create_webhook(self, url, event, pattern=None, name=None): 210 | """ 211 | Creates a new webhook. 212 | """ 213 | data = { 214 | 'url': url, 215 | 'event': event, 216 | 'pattern': pattern, 217 | 'name': name, 218 | } 219 | resp = self._requests.post(self.url + '/webhook', data=data) 220 | return Linker._obj_from_text(resp.text, self._requests) 221 | 222 | def save(self): 223 | data = {} 224 | for key in ('name', 'privacy', 'is_archived', 'is_guest_accessible', 'topic'): 225 | data[key] = self[key] 226 | data['owner'] = {'id': self['owner']['id']} 227 | headers = {'content-type': 'application/json'} 228 | return self._requests.put(self.url, data=json.dumps(data), headers=headers) 229 | 230 | 231 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+$')] = Room 232 | 233 | 234 | class User(RestObject): 235 | def __init__(self, *p, **kw): 236 | super(User, self).__init__(*p, **kw) 237 | if 'last_active' in self: 238 | self['last_active'] = timestamp(self['last_active']) 239 | if 'created' in self: 240 | self['created'] = timestamp(self['created']) 241 | 242 | def message(self, message, message_format='text', notify=False): 243 | """ 244 | Sends a user a private message. 245 | """ 246 | self._requests.post(self.url + '/message', data={ 247 | 'message': message, 248 | 'message_format': message_format, 249 | 'notify': notify, 250 | }) 251 | 252 | def history(self, maxResults=200, notBefore=None): 253 | """ 254 | Requests the users private message history. 255 | 256 | ::param not_before: the oldest message id to be returned. If not set the history is limited by maxResults only 257 | """ 258 | tz = 'UTC' 259 | params = { 260 | 'timezone': tz, 261 | 'max-results': maxResults, 262 | 263 | } 264 | if notBefore is not None: 265 | params["not-before"] = notBefore 266 | 267 | resp = self._requests.get(self.url + '/history/latest', params=params) 268 | return Linker._obj_from_text(resp.text, self._requests) 269 | 270 | def save(self): 271 | data = {} 272 | for key, value in six.iteritems(self): 273 | if key == 'presence' and isinstance(value, dict): 274 | p = {} 275 | for k, v in six.iteritems(value): 276 | if k in ('status', 'show'): 277 | p[k] = v 278 | if len(p) != 0: 279 | data[key] = p 280 | else: 281 | data[key] = value 282 | self._requests.put(self.url, data=data) 283 | 284 | 285 | _urls_to_objects[re.compile(r'https://[^/]+/v2/user/[^/]+$')] = User 286 | 287 | 288 | class Collection(object): 289 | """ 290 | Mixin for collections 291 | """ 292 | 293 | def contents(self, **kwargs): 294 | page = self 295 | ops = {} 296 | if kwargs.get('expand'): 297 | ops['expand'] = 'items' 298 | while hasattr(page, 'next'): 299 | for item in page['items']: 300 | yield item 301 | page = page.next(**ops) 302 | # Last page handling 303 | for item in page['items']: 304 | yield item 305 | 306 | 307 | class MemberCollection(RestObject, Collection): 308 | def add(self, user): 309 | """ 310 | Adds a member to a private room. 311 | """ 312 | self._requests.put(self.url + '/%s' % user['id']) 313 | 314 | def remove(self, user): 315 | """ 316 | Removes a member from a private room. 317 | """ 318 | self._requests.delete(self.url + '/%s' % user['id']) 319 | 320 | 321 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+/member$')] = MemberCollection 322 | 323 | 324 | class UserCollection(RestObject, Collection): 325 | def create(self, name, email, title=None, mention_name=None, is_group_admin=False, timezone='UTC', password=None): 326 | """ 327 | Creates a new user. 328 | """ 329 | data = { 330 | 'name': name, 331 | 'email': email, 332 | 'title': title, 333 | 'mention_name': mention_name, 334 | 'is_group_admin': is_group_admin, 335 | 'timezone': timezone, # TODO: Support timezone objects 336 | 'password': password, 337 | } 338 | resp = self._requests.post(self.url, data=data) 339 | return Linker._obj_from_text(resp.text, self._requests) 340 | 341 | 342 | _urls_to_objects[re.compile(r'https://[^/]+/v2/user$')] = UserCollection 343 | 344 | 345 | class RoomCollection(RestObject, Collection): 346 | def create(self, name, owner=Ellipsis, privacy='public', guest_access=True): 347 | """ 348 | Creates a new room. 349 | """ 350 | data = { 351 | 'name': name, 352 | 'privacy': privacy, 353 | 'guest_access': guest_access, 354 | } 355 | if owner is not Ellipsis: 356 | if owner is None: 357 | data['owner_user_id'] = owner 358 | else: 359 | data['owner_user_id'] = owner['id'] 360 | resp = self._requests.post(self.url, data=data) 361 | return Linker._obj_from_text(resp.text, self._requests) 362 | 363 | 364 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room$')] = RoomCollection 365 | 366 | 367 | class WebhookCollection(RestObject, Collection): 368 | def create(self, url, event, pattern=None, name=None): 369 | """ 370 | Creates a new webhook. 371 | """ 372 | data = { 373 | 'name': name, 374 | 'email': email, 375 | 'title': title, 376 | 'mention_name': mention_name, 377 | 'is_group_admin': is_group_admin, 378 | 'timezone': timezone, # TODO: Support timezone objects 379 | 'password': password, 380 | } 381 | resp = self._requests.post(self.url, data=data) 382 | return Linker._obj_from_text(resp.text, self._requests) 383 | 384 | 385 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+/webhook$')] = WebhookCollection 386 | 387 | 388 | class EmoticonCollection(RestObject, Collection): 389 | pass 390 | 391 | 392 | _urls_to_objects[re.compile(r'https://[^/]+/v2/emoticon$')] = EmoticonCollection 393 | 394 | 395 | class Webhook(RestObject): 396 | def __init__(self, *p, **kw): 397 | super(Webhook, self).__init__(*p, **kw) 398 | if 'created' in self: 399 | self['created'] = timestamp(self['created']) 400 | 401 | 402 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+/webhook/[^/]+$')] = Webhook 403 | 404 | 405 | class HistoryCollection(RestObject, Collection): 406 | def __init__(self, *p, **kw): 407 | super(HistoryCollection, self).__init__(*p, **kw) 408 | for item in self['items']: 409 | if 'date' in item: 410 | item['date'] = timestamp(item['date']) 411 | 412 | 413 | _urls_to_objects[re.compile(r'https://[^/]+/v2/room/[^/]+/history$')] = HistoryCollection 414 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup, Command 4 | 5 | 6 | class PyTest(Command): 7 | user_options = [] 8 | 9 | def initialize_options(self): 10 | pass 11 | 12 | def finalize_options(self): 13 | pass 14 | 15 | def run(self): 16 | import sys, subprocess 17 | 18 | errno = subprocess.call([sys.executable, '-m', 'unittest', 'discover']) 19 | raise SystemExit(errno) 20 | 21 | 22 | def read_file(name): 23 | """ 24 | Read file content 25 | """ 26 | f = open(name) 27 | try: 28 | return f.read() 29 | except IOError: 30 | print("could not read %r" % name) 31 | f.close() 32 | 33 | 34 | setup(name='hypchat', 35 | version='0.21', 36 | description="Package for HipChat's v2 API", 37 | long_description=read_file('README.rst'), 38 | author='Riders Discount', 39 | author_email='opensource@ridersdiscount.com', 40 | url='https://github.com/RidersDiscountCom/HypChat', 41 | packages=['hypchat'], 42 | install_requires=['requests', 'python-dateutil', 'six'], 43 | test_requires=['requests_mock'], 44 | provides=['hypchat'], 45 | cmdclass={'test': PyTest}, 46 | classifiers=[ # https://pypi.python.org/pypi?%3Aaction=list_classifiers 47 | 'Development Status :: 3 - Alpha', 48 | 'Intended Audience :: Developers', 49 | 'License :: OSI Approved :: MIT License', 50 | 'Operating System :: OS Independent', 51 | 'Programming Language :: Python', 52 | 'Programming Language :: Python :: 2', 53 | 'Programming Language :: Python :: 3', 54 | 'Topic :: Communications :: Chat', 55 | 'Topic :: Software Development :: Libraries', 56 | 'Topic :: Software Development :: Libraries :: Python Modules', 57 | ] 58 | ) 59 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RidersDiscountCom/HypChat/d38e1a15031fdb11694c9ed971eb3e7e67e70907/tests/__init__.py -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | import os, os.path 4 | import ConfigParser 5 | 6 | package = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) 7 | sys.path.insert(0, package) 8 | 9 | import hypchat 10 | 11 | 12 | class TestHypChat(unittest.TestCase): 13 | def setUp(self): 14 | self.setUpConfig() 15 | self.setUpHipChat() 16 | 17 | def setUpConfig(self): 18 | search_paths = [os.path.expanduser('~/.hypchat'), '/etc/hypchat'] 19 | 20 | self.config = ConfigParser.ConfigParser() 21 | self.config.read(search_paths) 22 | if self.config.has_section('HipChat'): 23 | self.access_token = self.config.get('HipChat', 'token') 24 | elif 'HIPCHAT_TOKEN' in os.environ: 25 | self.access_token = os.environ['HIPCHAT_TOKEN'] 26 | else: 27 | print('Authorization token not detected! The token is pulled from ' \ 28 | '~/.hypchat, /etc/hypchat, or the environment variable HIPCHAT_TOKEN.') 29 | 30 | def setUpHipChat(self): 31 | self.hipchat = hypchat.HypChat(self.access_token) -------------------------------------------------------------------------------- /tests/test_rate_limit.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import time 3 | import requests_mock 4 | 5 | from common import TestHypChat 6 | 7 | 8 | class TestRateLimit(TestHypChat): 9 | def setUp(self): 10 | super(TestRateLimit, self).setUp() 11 | self.call_counter = 1 12 | 13 | def successfull_callback(self, request, context): 14 | context.status_code = 200 15 | 16 | headers = { 17 | 'X-Ratelimit-Limit': '100', 18 | 'X-Ratelimit-Remaining': '99', 19 | 'X-Ratelimit-Reset': str(time.time()) 20 | } 21 | 22 | context.headers = headers 23 | 24 | payload = { 25 | 'atlassian_id': None, 26 | 'created': '2014-09-26T10:44:04+00:00', 27 | 'email': 'john.doe@example.com', 28 | 'group': None, 29 | 'id': 12345, 30 | 'is_deleted': False, 31 | 'is_group_admin': True, 32 | 'is_guest': False, 33 | 'last_active': str(int(time.time())), 34 | 'links': { 35 | 'self': 'https://api.hipchat.com/v2/user/12345' 36 | }, 37 | 'mention_name': 'john', 38 | 'name': 'John Doe', 39 | 'photo_url': 'https://s3.amazonaws.com/uploads.hipchat.com/photos/12345/ABCDEFG.jpg', 40 | 'presence': { 41 | 'client': { 42 | 'type': 'http://hipchat.com/client/mac', 43 | 'version': '12345' 44 | }, 45 | 'is_online': True 46 | }, 47 | 'timezone': 'Europe/Berlin', 48 | 'title': 'Software Engineer', 49 | 'xmpp_jid': '12345@chat.hipchat.com' 50 | } 51 | 52 | return payload 53 | 54 | def rate_limited_callback(self, request, context): 55 | context.status_code = 429 56 | 57 | headers = { 58 | 'X-Ratelimit-Limit': '100', 59 | 'X-Ratelimit-Remaining': '0', 60 | 'X-Ratelimit-Reset': str(time.time() + 0.1) 61 | } 62 | 63 | context.headers = headers 64 | 65 | payload = { 66 | 'error': { 67 | 'code': 429, 68 | 'type': 'Too Many Requests', 69 | 'message': 'Rate Limit exceeded' 70 | } 71 | } 72 | 73 | return payload 74 | 75 | def hipchat_callback(self, request, context): 76 | rate_limit = self.call_counter % 2 == 0 77 | self.call_counter += 1 78 | 79 | if rate_limit: 80 | return self.rate_limited_callback(request, context) 81 | else: 82 | return self.successfull_callback(request, context) 83 | 84 | 85 | def runTest(self): 86 | """ 87 | We are mocking the request so every second call is rate limited. 88 | """ 89 | with requests_mock.Mocker() as m: 90 | m.register_uri('GET', 'https://api.hipchat.com/v2/user/@john', status_code=200, json=self.hipchat_callback) 91 | for i in xrange(3): 92 | self.hipchat.get_user('@john') --------------------------------------------------------------------------------