├── .gitignore ├── .travis.yml ├── LICENSE-APACHE ├── LICENSE-MIT ├── Makefile ├── README.md ├── __init__.py ├── chatexchange ├── __init__.py ├── _utils.py ├── browser.py ├── client.py ├── events.py ├── messages.py ├── rooms.py └── users.py ├── docs ├── Documentation.md └── se-chat-interface.md ├── examples ├── chat.py ├── hello_world.py └── web_viewer.py ├── setp.sh ├── setup.py └── tests ├── __init__.py ├── all_tests.sh ├── live_testing.py ├── mock_responses.py ├── test_browser.py ├── test_client.py ├── test_events.py ├── test_messages.py ├── test_openid_login.py ├── test_rooms.py └── test_users.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.log* 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # epydoc output 57 | html/ 58 | epydocs/ 59 | 60 | # IntelliJ IDEA Project Files 61 | .idea/ 62 | .iml 63 | 64 | # Python VirtualEnvs for local testing 65 | VEnvs/ 66 | /venv-* 67 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - "2.6" 5 | - "2.7" 6 | - "3.2" 7 | - "3.3" 8 | - "3.4" 9 | - "3.5" 10 | - "3.5-dev" 11 | - "nightly" 12 | matrix: 13 | allow_failures: 14 | - python: "3.2" 15 | install: 16 | - pip install . 17 | script: 18 | - python -W default::Warning -m coverage.__main__ run --branch -m pytest --tb short 19 | - python -m coverage.__main__ report --include 'chatexchange/*' 20 | branches: 21 | only: 22 | - master 23 | env: 24 | global: 25 | - secure: "S9c9S/OgKGa7mLz/Ys0aVwbyNtxBubUVWO0i3ew0sm0KwUBFiNNPtWTyCT21jGrjd1Zp0AvMlcSFhJ9auGAMAVIqPTqM1GkNGU5fPwx6Lo3KAGSqW+XN2cAFXljEyAQ2Bhn/ilWeUymC1EpkNRIDarCJZcNsctrs8P3H67gSFtc=" 26 | - secure: "av9hxTZp/Dhe9xAOq6WlhTNDoWjjczN3lFanG6h/3h4kW7DsxhfXMRA96z6MambbC6c9ARFiwsQ24NeCAfPQ1m6r9uZwNkusqnRDOwZQeVQcmopnoNNG4Kd/9oclIVgsAlSG6WfhkyQPUG2p7PkOvxFV4/YjDSViYDR3eoih3JA=" 27 | notifications: 28 | email: false 29 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 ChatExchange Developers 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: test run-example 2 | 3 | WARGS = -W default::Warning 4 | 5 | run-example: install-dependencies PHONY 6 | python $(WARGS) examples/chat.py 7 | 8 | run-web-example: install-dependencies PHONY 9 | python $(WARGS) examples/web_viewer.py 10 | 11 | test: install-dependencies PHONY 12 | python $(WARGS) -m pytest 13 | 14 | test-coverage: install-dependencies PHONY 15 | python -m coverage run --branch -m pytest 16 | python -m coverage report --include 'chatexchange/*' 17 | 18 | install-dependencies: PHONY 19 | # This also creates a link to `chatexchange/` in the Python 20 | # environment, which is neccessary for the other files to be 21 | # able to find it. 22 | rm -rf src/*.egg-info 23 | pip install -e . 24 | 25 | epydocs: PHONY 26 | epydoc chatexchange --html -o epydocs \ 27 | --top ChatExchange.chatexchange --no-frames --no-private --verbose 28 | 29 | clean: PHONY 30 | rm -rf src/*.egg-info 31 | find . -type f -name '*.pyc' -delete 32 | find . -type d -name '__pycache__' -delete 33 | 34 | PHONY: 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ChatExchange 2 | ============ 3 | 4 | > ## WARNING 5 | 6 | > #### This is a fork of the original https://github.com/Manishearth/ChatExchange . I created it originally because I wanted to make ChatExchange portable for Python 2 and 3. Now my ChatExchange6 fork has been merged back to the original ChatExchange repository. 7 | > #### Therefore please use the original repository linked above for your projects instead of this one. It's not guaranteed to be kept up to date and regularly maintained! 8 | 9 | [![Travis CI build status for master](https://travis-ci.org/Manishearth/ChatExchange.svg?branch=master)](https://travis-ci.org/Manishearth/ChatExchange) 10 | 11 | A Python2 and Python3 cross-version API for talking to Stack Exchange chat. 12 | 13 | - Supported Python versions (Travis-CI build passes): 14 | 2.6 2.7 3.3 3.4 3.5 3.5-dev 3.6-dev (nightly) 15 | - Unclear versions (Travis-CI build fails because of the packages we use for our Travis-CI tests): 16 | Those Python versions are always built by Travis-CI, but the result is ignored in the summary. 17 | 3.2 because of package `pytest` 18 | 19 | ## Dependencies 20 | **Make sure you use either `pip2` or `pip3` depending on which Python version you want to run this on.** 21 | 22 | 23 | - BeautifulSoup (`pip install beautifulsoup4`) 24 | - Requests (`pip install requests`). Usually there by default. Please upgrade it with `pip install requests --upgrade` 25 | *Note that Ubuntu comes with an old version of `pip` that is not compatible any more with the latest version of `requests`. It will be broken after you installed `requests`, except if you update it before (or afterwards) with `easy_install pip` or `pip install --upgrade pip` (that one works only before).* 26 | - python-websockets for the experimental websocket listener (`pip install websocket-client`). This module is optional, without it `initSocket()` from SEChatBrowser will not work 27 | 28 | ## Shortcuts 29 | 30 | 1. `make install-dependencies` will install the necessary Python package dependencies into your current environment (active virtualenv or system site packages) 31 | 2. `make test` will run the tests 32 | 3. `make run-example` will run the example script 33 | 4. `make` will run the above three in order 34 | 35 | ## License 36 | 37 | Licensed under either of 38 | 39 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 40 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 41 | 42 | at your option. 43 | 44 | ### Contribution 45 | 46 | Unless you explicitly state otherwise, any contribution intentionally submitted 47 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any 48 | additional terms or conditions. 49 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The ChatExchange package 3 | """ 4 | -------------------------------------------------------------------------------- /chatexchange/__init__.py: -------------------------------------------------------------------------------- 1 | from . import browser 2 | 3 | from . import users 4 | from . import messages 5 | from . import rooms 6 | from . import events 7 | from . import client 8 | from . import _utils 9 | 10 | 11 | Browser = browser.Browser 12 | 13 | Client = client.Client 14 | 15 | __all__ = [ 16 | 'browser', 'users', 'messages', 'rooms', 'events', 'client', 17 | 'Browser', 'Client'] 18 | -------------------------------------------------------------------------------- /chatexchange/_utils.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import sys 3 | if sys.version_info[0] == 2: 4 | from HTMLParser import HTMLParser 5 | import htmlentitydefs 6 | else: 7 | from html.parser import HTMLParser 8 | from html import entities as htmlentitydefs 9 | import functools 10 | import logging 11 | import weakref 12 | 13 | 14 | def log_and_ignore_exceptions( 15 | f, exceptions=Exception, logger=logging.getLogger('exceptions') 16 | ): 17 | """ 18 | Wraps a function to catch its exceptions, log them, and return None. 19 | """ 20 | @functools.wraps(f) 21 | def wrapper(*a, **kw): 22 | try: 23 | return f(*a, **kw) 24 | except exceptions: 25 | logger.exception("ignored unhandled exception in %s", f) 26 | return None 27 | 28 | return wrapper 29 | 30 | 31 | class HTMLTextExtractor(HTMLParser): 32 | # Originally posted at http://stackoverflow.com/a/7778368. 33 | # by Søren Løvborg (http://stackoverflow.com/u/13679) and Eloff. 34 | 35 | def __init__(self): 36 | HTMLParser.__init__(self) 37 | self.result = [] 38 | 39 | def handle_data(self, d): 40 | self.result.append(d) 41 | 42 | def handle_charref(self, number): 43 | if number[0] in ('x', 'X'): 44 | codepoint = int(number[1:], 16) 45 | else: 46 | codepoint = int(number) 47 | self.result.append(chr(codepoint)) 48 | 49 | def handle_entityref(self, name): 50 | codepoint = htmlentitydefs.name2codepoint[name] 51 | self.result.append(chr(codepoint)) 52 | 53 | def get_text(self): 54 | return ''.join(self.result) 55 | 56 | 57 | def html_to_text(html): 58 | s = HTMLTextExtractor() 59 | s.feed(html) 60 | return s.get_text() 61 | 62 | 63 | # Number of seconds since the user was last seen, based on <12d ago> data. 64 | def parse_last_seen(text): 65 | suffixes = { 66 | 's': 1, 67 | 'm': 60, 68 | 'h': 3600, 69 | 'd': 86400, 70 | 'y': 31536000 71 | } 72 | if text == "n/a": 73 | return -1 # Take this as an error code if you want 74 | splat = text.split(' ') 75 | assert len(splat) == 2, "text doesn't appear to be in format" 76 | char = splat[0][-1] 77 | number = int(splat[0][:-1]) 78 | assert char in suffixes, "suffix char unrecognized" 79 | return number * suffixes[char] 80 | 81 | 82 | class LazyFrom(object): 83 | """ 84 | A descriptor used when multiple lazy attributes depend on a common 85 | source of data. 86 | """ 87 | def __init__(self, method_name): 88 | """ 89 | method_name is the name of the method that will be invoked if 90 | the value is not known. It must assign a value for the attribute 91 | attribute (through this descriptor). 92 | """ 93 | self.method_name = method_name 94 | self.values = weakref.WeakKeyDictionary() 95 | 96 | def __get__(self, obj, cls): 97 | if obj is None: 98 | return self 99 | 100 | if obj not in self.values: 101 | method = getattr(obj, self.method_name) 102 | method() 103 | 104 | assert obj in self.values, "method failed to populate attribute" 105 | 106 | return self.values[obj] 107 | 108 | def __set__(self, obj, value): 109 | self.values[obj] = value 110 | 111 | def __delete__(self, obj): 112 | if obj in self.values: 113 | del self.values[obj] 114 | -------------------------------------------------------------------------------- /chatexchange/browser.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import sys 3 | import logging 4 | if sys.version_info[:2] <= (2, 6): 5 | logging.Logger.getChild = lambda self, suffix:\ 6 | self.manager.getLogger('.'.join((self.name, suffix)) if self.root is not self else suffix) 7 | import json 8 | import threading 9 | import time 10 | 11 | from bs4 import BeautifulSoup 12 | import requests 13 | import websocket 14 | from . import _utils 15 | import socket 16 | import re 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class Browser(object): 23 | """ 24 | An interface for scraping and making requests to Stack Exchange chat. 25 | """ 26 | user_agent = ('ChatExchange/0.dev ' 27 | '(+https://github.com/Manishearth/ChatExchange)') 28 | 29 | chat_fkey = _utils.LazyFrom('_update_chat_fkey_and_user') 30 | user_name = _utils.LazyFrom('_update_chat_fkey_and_user') 31 | user_id = _utils.LazyFrom('_update_chat_fkey_and_user') 32 | 33 | request_timeout = 30.0 34 | 35 | def __init__(self): 36 | self.logger = logger.getChild('Browser') 37 | self.session = requests.Session() 38 | self.session.headers.update({ 39 | 'User-Agent': self.user_agent 40 | }) 41 | self.rooms = {} 42 | self.sockets = {} 43 | self.polls = {} 44 | self.host = None 45 | self.on_websocket_closed = self._default_ws_recovery 46 | 47 | def _default_ws_recovery(self, room_id): 48 | on_activity = self.sockets[room_id].on_activity 49 | try: 50 | self.leave_room(room_id) 51 | except websocket.WebSocketConnectionClosedException: 52 | pass 53 | self.join_room(room_id) 54 | self.watch_room_socket(room_id, on_activity) 55 | 56 | @property 57 | def chat_root(self): 58 | assert self.host, "browser has no associated host" 59 | return 'http://chat.%s' % (self.host,) 60 | 61 | # request helpers 62 | 63 | def _request( 64 | self, method, url, 65 | data=None, headers=None, with_chat_root=True 66 | ): 67 | if with_chat_root: 68 | url = self.chat_root + '/' + url 69 | 70 | method_method = getattr(self.session, method) 71 | # using the actual .post method causes data to be form-encoded, 72 | # whereas using .request with method='POST' would create a query string 73 | 74 | # Try again if we fail. We're blaming "the internet" for weirdness. 75 | MAX_HTTP_RETRIES = 5 # EGAD! A MAGIC NUMBER! 76 | attempt = 0 77 | response = None 78 | 79 | while attempt <= MAX_HTTP_RETRIES: 80 | attempt += 1 81 | try: 82 | response = method_method( 83 | url, data=data, headers=headers, timeout=self.request_timeout) 84 | break 85 | except requests.exceptions.ConnectionError as e: # We want to try again, so continue 86 | # BadStatusLine throws this error 87 | print("Connection Error -> Trying again...") 88 | time.sleep(0.1) # short pause before retrying 89 | if attempt == MAX_HTTP_RETRIES: # Only show exception if last try 90 | raise 91 | continue 92 | 93 | except (requests.exceptions.Timeout, socket.timeout) as e: # Timeout occurred, retry 94 | # Catching both because of this bug in requests 95 | # https://github.com/kennethreitz/requests/issues/1236 96 | print("Timeout -> Trying again...") 97 | time.sleep(1.0) # Longer pause because it was a time out. Assume overloaded and give them a second 98 | if attempt == MAX_HTTP_RETRIES: # Only show exception if last try 99 | raise 100 | continue 101 | 102 | response.raise_for_status() 103 | 104 | # XXX: until throttling is implemented everywhere in Client, at least add some delay here. 105 | time.sleep(0.1) 106 | 107 | return response 108 | 109 | def get(self, url, data=None, headers=None, with_chat_root=True): 110 | return self._request('get', url, data, headers, with_chat_root) 111 | 112 | def post(self, url, data=None, headers=None, with_chat_root=True): 113 | return self._request('post', url, data, headers, with_chat_root) 114 | 115 | def get_soup(self, url, data=None, headers=None, with_chat_root=True): 116 | response = self.get(url, data, headers, with_chat_root) 117 | return BeautifulSoup(response.text, "html.parser") 118 | 119 | def post_soup(self, url, data=None, headers=None, with_chat_root=True): 120 | response = self.post(url, data, headers, with_chat_root) 121 | return BeautifulSoup(response.text, "html.parser") 122 | 123 | def post_fkeyed(self, url, data=None, headers=None): 124 | if data is None: 125 | data = {} 126 | elif not isinstance(data, dict): 127 | raise TypeError("data must be a dict") 128 | else: 129 | data = dict(data) 130 | 131 | data['fkey'] = self.chat_fkey 132 | 133 | return self.post(url, data, headers) 134 | 135 | # authentication 136 | 137 | def login_se_openid(self, user, password): 138 | """ 139 | Logs the browser into Stack Exchange's OpenID provider. 140 | """ 141 | self.userlogin = user 142 | self.userpass = password 143 | 144 | self._se_openid_login_with_fkey( 145 | 'https://openid.stackexchange.com/account/login', 146 | 'https://openid.stackexchange.com/account/login/submit', 147 | { 148 | 'email': user, 149 | 'password': password, 150 | }) 151 | 152 | if not self.session.cookies.get('usr', None): 153 | raise LoginError( 154 | "failed to get `usr` cookie from Stack Exchange OpenID") 155 | 156 | def login_site(self, host): 157 | """ 158 | Logs the browser into a Stack Exchange site. 159 | """ 160 | assert self.host is None or self.host is host 161 | 162 | self._se_openid_login_with_fkey( 163 | 'http://%s/users/login?returnurl = %%2f' % (host,), 164 | 'http://%s/users/authenticate' % (host,), 165 | { 166 | 'oauth_version': '', 167 | 'oauth_server': '', 168 | 'openid_identifier': 'https://openid.stackexchange.com/' 169 | }) 170 | 171 | self.host = host 172 | 173 | def _se_openid_login_with_fkey(self, fkey_url, post_url, data=()): 174 | """ 175 | POSTs the specified login data to post_url, after retrieving an 176 | 'fkey' value from an element named 'fkey' at fkey_url. 177 | 178 | Also handles SE OpenID prompts to allow login to a site. 179 | """ 180 | fkey_soup = self.get_soup(fkey_url, with_chat_root=False) 181 | fkey_input = fkey_soup.find('input', {'name': 'fkey'}) 182 | if fkey_input is None: 183 | raise LoginError("fkey input not found") 184 | fkey = fkey_input['value'] 185 | 186 | data = dict(data) 187 | data['fkey'] = fkey 188 | 189 | response = self.post(post_url, data, with_chat_root=False) 190 | 191 | response = self._handle_se_openid_prompt_if_neccessary(response) 192 | 193 | return response 194 | 195 | def _handle_se_openid_prompt_if_neccessary(self, prompt_response): 196 | prompt_prefix = 'https://openid.stackexchange.com/account/prompt' 197 | 198 | if not prompt_response.url.startswith(prompt_prefix): 199 | # no prompt for us to handle 200 | return prompt_response 201 | 202 | prompt_soup = BeautifulSoup(prompt_response.text, "html.parser") 203 | 204 | data = { 205 | 'session': prompt_soup.find('input', {'name': 'session'})['value'], 206 | 'fkey': prompt_soup.find('input', {'name': 'fkey'})['value'] 207 | } 208 | 209 | url = 'https://openid.stackexchange.com/account/prompt/submit' 210 | 211 | response = self.post(url, data, with_chat_root=False) 212 | 213 | return response 214 | 215 | def _load_fkey(self, soup): 216 | chat_fkey = soup.find('input', {'name': 'fkey'})['value'] 217 | if not chat_fkey: 218 | raise BrowserError('fkey missing') 219 | 220 | self.chat_fkey = chat_fkey 221 | 222 | def _load_user(self, soup): 223 | user_link_soup = soup.select('.topbar-menu-links a')[0] 224 | user_id, user_name = self.user_id_and_name_from_link(user_link_soup) 225 | 226 | self.user_id = user_id 227 | self.user_name = user_name 228 | 229 | @staticmethod 230 | def user_id_and_name_from_link(link_soup): 231 | user_name = link_soup.text 232 | user_id = int(link_soup['href'].split('/')[-2]) 233 | return user_id, user_name 234 | 235 | def _update_chat_fkey_and_user(self): 236 | """ 237 | Updates the fkey used by this browser, and associated user name/id. 238 | """ 239 | favorite_soup = self.get_soup('chats/join/favorite') 240 | self._load_fkey(favorite_soup) 241 | self._load_user(favorite_soup) 242 | 243 | # remote requests 244 | 245 | def join_room(self, room_id): 246 | room_id = str(room_id) 247 | self.rooms[room_id] = {} 248 | response = self.post_fkeyed( 249 | 'chats/%s/events' % (room_id,), 250 | { 251 | 'since': 0, 252 | 'mode': 'Messages', 253 | 'msgCount': 100 254 | }) 255 | eventtime = response.json()['time'] 256 | self.rooms[room_id]['eventtime'] = eventtime 257 | 258 | def leave_room(self, room_id): 259 | room_id = str(room_id) 260 | if room_id in self.rooms: 261 | self.rooms.pop(room_id) 262 | if room_id in self.sockets: 263 | self.sockets[room_id].close() 264 | self.sockets.pop(room_id) 265 | if room_id in self.polls: 266 | self.polls[room_id].close() 267 | self.polls.pop(room_id) 268 | self.post_fkeyed('/chats/leave/%s' % (room_id,)) 269 | 270 | def watch_room_socket(self, room_id, on_activity): 271 | """ 272 | Watches for raw activity in a room using WebSockets. 273 | 274 | This starts a new daemon thread. 275 | """ 276 | room_id = str(room_id) 277 | socket_watcher = RoomSocketWatcher(self, room_id, on_activity) 278 | socket_watcher.on_websocket_closed = self.on_websocket_closed 279 | self.sockets[room_id] = socket_watcher 280 | socket_watcher.start() 281 | return socket_watcher 282 | 283 | def watch_room_http(self, room_id, on_activity, interval): 284 | """ 285 | Watches for raw activity in a room using HTTP polling. 286 | 287 | This starts a new daemon thread. 288 | """ 289 | room_id = str(room_id) 290 | http_watcher = RoomPollingWatcher(self, room_id, on_activity, interval) 291 | self.polls[room_id] = http_watcher 292 | http_watcher.start() 293 | return http_watcher 294 | 295 | def toggle_starring(self, message_id): 296 | return self.post_fkeyed( 297 | 'messages/%s/star' % (message_id,)) 298 | 299 | def toggle_pinning(self, message_id): 300 | return self.post_fkeyed( 301 | 'messages/%s/owner-star' % (message_id,)) 302 | 303 | def send_message(self, room_id, text): 304 | return self.post_fkeyed( 305 | 'chats/%s/messages/new' % (room_id,), 306 | {'text': text}) 307 | 308 | def edit_message(self, message_id, text): 309 | return self.post_fkeyed( 310 | 'messages/%s' % (message_id,), 311 | {'text': text}) 312 | 313 | def delete_message(self, message_id): 314 | return self.post_fkeyed('messages/%s/delete' % (message_id, )) 315 | 316 | def get_history(self, message_id): 317 | """ 318 | Returns the data from the history page for message_id. 319 | """ 320 | history_soup = self.get_soup( 321 | 'messages/%s/history' % (message_id,)) 322 | 323 | latest_soup = history_soup.select('.monologue')[0] 324 | previous_soup = history_soup.select('.monologue')[1:] 325 | 326 | page_message_id = int(latest_soup.select('.message a')[0]['name']) 327 | assert message_id == page_message_id 328 | 329 | room_id = int(latest_soup.select('.message a')[0]['href'] 330 | .rpartition('/')[2].partition('?')[0]) 331 | 332 | latest_content = str( 333 | latest_soup.select('.content')[0] 334 | ).partition('>')[2].rpartition('<')[0].strip() 335 | 336 | latest_content_source = ( 337 | previous_soup[0].select('.content b')[0].next_sibling.strip()) 338 | 339 | owner_soup = latest_soup.select('.username a')[0] 340 | owner_user_id, owner_user_name = ( 341 | self.user_id_and_name_from_link(owner_soup)) 342 | 343 | edits = 0 344 | has_editor_name = False 345 | 346 | for item in previous_soup: 347 | if item.select('b')[0].text != 'edited:': 348 | continue 349 | 350 | edits += 1 351 | 352 | if not has_editor_name: 353 | has_editor_name = True 354 | user_soup = item.select('.username a')[0] 355 | latest_editor_user_id, latest_editor_user_name = ( 356 | self.user_id_and_name_from_link(user_soup)) 357 | 358 | assert (edits > 0) == has_editor_name 359 | 360 | if not edits: 361 | latest_editor_user_id = None 362 | latest_editor_user_name = None 363 | 364 | star_data = self._get_star_data( 365 | latest_soup, include_starred_by_you=False) 366 | 367 | if star_data['pinned']: 368 | pins = 0 369 | pinner_user_ids = [] 370 | pinner_user_names = [] 371 | 372 | for p_soup in history_soup.select('#content p'): 373 | if not p_soup.select('.stars.owner-star'): 374 | break 375 | 376 | a_soup = p_soup.select('a')[0] 377 | 378 | pins += 1 379 | user_id, user_name = self.user_id_and_name_from_link(a_soup) 380 | pinner_user_ids.append(user_id) 381 | pinner_user_names.append(user_name) 382 | else: 383 | pins = 0 384 | pinner_user_ids = [] 385 | pinner_user_names = [] 386 | 387 | data = {} 388 | 389 | data.update(star_data) 390 | 391 | data.update({ 392 | 'room_id': room_id, 393 | 'content': latest_content, 394 | 'content_source': latest_content_source, 395 | 'owner_user_id': owner_user_id, 396 | 'owner_user_name': owner_user_name, 397 | 'editor_user_id': latest_editor_user_id, 398 | 'editor_user_name': latest_editor_user_name, 399 | 'edited': bool(edits), 400 | 'edits': edits, 401 | 'pinned': bool(pins), 402 | 'pins': pins, 403 | 'pinner_user_ids': pinner_user_ids, 404 | 'pinner_user_names': pinner_user_names, 405 | # TODO: 'time_stamp': ... 406 | }) 407 | 408 | return data 409 | 410 | def get_transcript_with_message(self, message_id): 411 | """ 412 | Returns the data from the transcript page associated with message_id. 413 | """ 414 | transcript_soup = self.get_soup( 415 | 'transcript/message/%s' % (message_id,)) 416 | 417 | room_soups = transcript_soup.select('.room-name a') 418 | room_soup = room_soups[-1] 419 | room_id = int(room_soup['href'].split('/')[-2]) 420 | room_name = room_soup.text 421 | 422 | messages_data = [] 423 | 424 | monologues_soups = transcript_soup.select( 425 | '#transcript .monologue') 426 | 427 | for monologue_soup in monologues_soups: 428 | user_link, = monologue_soup.select('.signature .username a') 429 | user_id, user_name = self.user_id_and_name_from_link(user_link) 430 | 431 | message_soups = monologue_soup.select('.message') 432 | 433 | for message_soup in message_soups: 434 | message_id = int(message_soup['id'].split('-')[1]) 435 | 436 | edited = bool(message_soup.select('.edits')) 437 | 438 | content = str( 439 | message_soup.select('.content')[0] 440 | ).partition('>')[2].rpartition('<')[0].strip() 441 | 442 | star_data = self._get_star_data( 443 | message_soup, include_starred_by_you=True) 444 | 445 | parent_info_soups = message_soup.select('.reply-info') 446 | 447 | if parent_info_soups: 448 | parent_info_soup, = parent_info_soups 449 | parent_message_id = int( 450 | parent_info_soup['href'].partition('#')[2]) 451 | else: 452 | parent_message_id = None 453 | 454 | message_data = { 455 | 'id': message_id, 456 | 'content': content, 457 | 'room_id': room_id, 458 | 'room_name': room_name, 459 | 'owner_user_id': user_id, 460 | 'owner_user_name': user_name, 461 | 'edited': edited, 462 | 'parent_message_id': parent_message_id, 463 | # TODO: 'time_stamp': ... 464 | } 465 | 466 | message_data.update(star_data) 467 | 468 | if not edited: 469 | message_data['editor_user_id'] = None 470 | message_data['editor_user_name'] = None 471 | message_data['edits'] = 0 472 | 473 | messages_data.append(message_data) 474 | 475 | data = { 476 | 'room_id': room_id, 477 | 'room_name': room_name, 478 | 'messages': messages_data 479 | } 480 | 481 | return data 482 | 483 | def _get_star_data(self, root_soup, include_starred_by_you): 484 | """ 485 | Gets star data indicated to the right of a message from a soup. 486 | """ 487 | 488 | stars_soups = root_soup.select('.stars') 489 | 490 | if stars_soups: 491 | stars_soup, = stars_soups 492 | 493 | times_soup = stars_soup.select('.times') 494 | if times_soup and times_soup[0].text: 495 | stars = int(times_soup[0].text) 496 | else: 497 | stars = 1 498 | 499 | if include_starred_by_you: 500 | # some pages never show user-star, so we have to skip 501 | starred_by_you = bool( 502 | root_soup.select('.stars.user-star')) 503 | 504 | pinned = bool( 505 | root_soup.select('.stars.owner-star')) 506 | 507 | if pinned: 508 | pins_known = False 509 | else: 510 | pins_known = True 511 | pinner_user_ids = [] 512 | pinner_user_names = [] 513 | pins = 0 514 | else: 515 | stars = 0 516 | if include_starred_by_you: 517 | starred_by_you = False 518 | 519 | pins_known = True 520 | pinned = False 521 | pins = 0 522 | pinner_user_ids = [] 523 | pinner_user_names = [] 524 | 525 | data = { 526 | 'stars': stars, 527 | 'starred': bool(stars), 528 | 'pinned': pinned, 529 | } 530 | 531 | if pins_known: 532 | data['pinner_user_ids'] = pinner_user_ids 533 | data['pinner_user_names'] = pinner_user_names 534 | data['pins'] = pins 535 | 536 | if include_starred_by_you: 537 | data['starred_by_you'] = starred_by_you 538 | 539 | return data 540 | 541 | def get_profile(self, user_id): 542 | """ 543 | Returns the data from the profile page for user_id. 544 | """ 545 | profile_soup = self.get_soup('users/%s' % (user_id,)) 546 | 547 | name = profile_soup.find('h1').text 548 | 549 | is_moderator = bool('♦' in profile_soup.select('.user-status')[0].text) 550 | message_count = int(profile_soup.select('.user-message-count-xxl')[0].text) 551 | room_count = int(profile_soup.select('.user-room-count-xxl')[0].text) 552 | reputation_elements = profile_soup.select('.reputation-score') 553 | if len(reputation_elements) > 0: 554 | reputation = int(profile_soup.select('.reputation-score')[0]['title']) 555 | else: 556 | reputation = -1 557 | 558 | stats_elements = profile_soup.select('.user-valuecell') 559 | if len(stats_elements) >= 4: 560 | last_seen = _utils.parse_last_seen(stats_elements[2].text) 561 | else: 562 | last_seen = -1 563 | 564 | return { 565 | 'name': name, 566 | 'is_moderator': is_moderator, 567 | 'message_count': message_count, 568 | 'room_count': room_count, 569 | 'reputation': reputation, 570 | 'last_seen': last_seen 571 | } 572 | 573 | def get_room_info(self, room_id): 574 | """ 575 | Returns the data from the room info page for room_id. 576 | """ 577 | info_soup = self.get_soup('rooms/info/%s' % (room_id,)) 578 | 579 | name = info_soup.find('h1').text 580 | 581 | description = str( 582 | info_soup.select('.roomcard-xxl p')[0] 583 | ).partition('>')[2].rpartition('<')[0].strip() 584 | 585 | message_count = int(info_soup.select('.room-message-count-xxl')[0].text) 586 | user_count = int(info_soup.select('.room-user-count-xxl')[0].text) 587 | 588 | parent_image_soups = info_soup.select('.roomcard-xxl img') 589 | if parent_image_soups: 590 | parent_site_name = parent_image_soups[0]['title'] 591 | else: 592 | parent_site_name = None 593 | 594 | owner_user_ids = [] 595 | owner_user_names = [] 596 | 597 | for card_soup in info_soup.select('#room-ownercards .usercard'): 598 | user_id, user_name = self.user_id_and_name_from_link(card_soup.find('a')) 599 | owner_user_ids.append(user_id) 600 | owner_user_names.append(user_name) 601 | 602 | tags = [] 603 | 604 | for tag_soup in info_soup.select('.roomcard-xxl .tag'): 605 | tags.append(tag_soup.text) 606 | 607 | return { 608 | 'name': name, 609 | 'description': description, 610 | 'message_count': message_count, 611 | 'user_count': user_count, 612 | 'parent_site_name': parent_site_name, 613 | 'owner_user_ids': owner_user_ids, 614 | 'owner_user_names': owner_user_names, 615 | 'tags': tags 616 | } 617 | 618 | def get_pingable_user_ids_in_room(self, room_id): 619 | url = "rooms/pingable/{0}".format(room_id) 620 | resp_json = self.get(url).json() 621 | user_ids = [] 622 | for user in resp_json: 623 | user_ids.append(user[0]) 624 | return user_ids 625 | 626 | def get_pingable_user_names_in_room(self, room_id): 627 | url = "rooms/pingable/{0}".format(room_id) 628 | resp_json = self.get(url).json() 629 | user_names = [] 630 | for user in resp_json: 631 | user_names.append(user[1]) 632 | return user_names 633 | 634 | def get_current_user_ids_in_room(self, room_id): 635 | url = "/rooms/{0}/".format(room_id) 636 | soup = self.get_soup(url) 637 | script_tag = soup.body.script 638 | users_js = re.compile(r"(?s)CHAT\.RoomUsers\.initPresent\(\[.+\]\);").findall(script_tag.text)[0] 639 | user_data = [x.strip() for x in users_js.split('\n') if len(x.strip()) > 0][1:-1] 640 | user_ids = [] 641 | for ud in user_data: 642 | user_ids.append(int(re.compile("id: (\d+),").search(ud).group(1))) 643 | return user_ids 644 | 645 | def get_current_user_names_in_room(self, room_id): 646 | url = "/rooms/{0}/".format(room_id) 647 | soup = self.get_soup(url) 648 | script_tag = soup.body.script 649 | users_js = re.compile(r"(?s)CHAT\.RoomUsers\.initPresent\(\[.+\]\);").findall(script_tag.text)[0] 650 | user_data = [x.strip() for x in users_js.split('\n') if len(x.strip()) > 0][1:-1] 651 | user_names = [] 652 | for ud in user_data: 653 | user_names.append(re.compile("name: \(\"(.+?)\"\),").search(ud).group(1)) 654 | return user_names 655 | 656 | 657 | def set_websocket_recovery(self, on_ws_closed): 658 | self.on_websocket_closed = on_ws_closed 659 | for s in self.sockets: 660 | s.on_websocket_closed = self.on_websocket_closed 661 | 662 | 663 | class RoomSocketWatcher(object): 664 | def __init__(self, browser, room_id, on_activity): 665 | self.logger = logger.getChild('RoomSocketWatcher') 666 | self.browser = browser 667 | self.room_id = str(room_id) 668 | self.thread = None 669 | self.on_activity = on_activity 670 | self.on_websocket_closed = None 671 | self.killed = False 672 | 673 | def close(self): 674 | self.killed = True 675 | if hasattr(self, 'ws'): 676 | self.ws.close() 677 | 678 | def start(self): 679 | last_event_time = self.browser.rooms[self.room_id]['eventtime'] 680 | 681 | ws_auth_data = self.browser.post_fkeyed( 682 | 'ws-auth', 683 | {'roomid': self.room_id} 684 | ).json() 685 | wsurl = ws_auth_data['url'] + '?l=%s' % (last_event_time,) 686 | self.logger.debug('wsurl == %r', wsurl) 687 | 688 | self.ws = websocket.create_connection( 689 | wsurl, origin=self.browser.chat_root) 690 | 691 | self.thread = threading.Thread(target=self._runner) 692 | self.thread.setDaemon(True) 693 | self.thread.start() 694 | 695 | def _runner(self): 696 | #look at wsdump.py later to handle opcodes 697 | while not self.killed: 698 | try: 699 | a = self.ws.recv() 700 | except websocket.WebSocketConnectionClosedException as e: 701 | if self.on_websocket_closed is not None: 702 | self.on_websocket_closed(self.room_id) 703 | else: 704 | raise e 705 | self.killed = True 706 | break 707 | 708 | if a is not None and a != "": 709 | self.on_activity(json.loads(a)) 710 | 711 | 712 | class RoomPollingWatcher(object): 713 | def __init__(self, browser, room_id, on_activity, interval): 714 | self.logger = logger.getChild('RoomPollingWatcher') 715 | self.browser = browser 716 | self.room_id = str(room_id) 717 | self.thread = None 718 | self.on_activity = on_activity 719 | self.interval = interval 720 | self.killed = False 721 | 722 | def start(self): 723 | self.thread = threading.Thread(target=self._runner) 724 | self.thread.setDaemon(True) 725 | self.thread.start() 726 | 727 | def close(self): 728 | self.killed = True 729 | 730 | def _runner(self): 731 | while not self.killed: 732 | last_event_time = self.browser.rooms[self.room_id]['eventtime'] 733 | 734 | activity = self.browser.post_fkeyed( 735 | 'events', {'r' + self.room_id: last_event_time}).json() 736 | 737 | try: 738 | room_result = activity['r' + self.room_id] 739 | eventtime = room_result['t'] 740 | self.browser.rooms[self.room_id]['eventtime'] = eventtime 741 | except KeyError as ex: 742 | pass # no updated time from room 743 | 744 | self.on_activity(activity) 745 | 746 | time.sleep(self.interval) 747 | 748 | 749 | class BrowserError(Exception): 750 | pass 751 | 752 | 753 | class LoginError(BrowserError): 754 | pass 755 | -------------------------------------------------------------------------------- /chatexchange/client.py: -------------------------------------------------------------------------------- 1 | import sys 2 | if sys.version_info[0] == 2: 3 | import Queue as queue 4 | else: 5 | import queue 6 | import logging 7 | if sys.version_info[:2] <= (2, 6): 8 | logging.Logger.getChild = lambda self, suffix:\ 9 | self.manager.getLogger('.'.join((self.name, suffix)) if self.root is not self else suffix) 10 | import collections 11 | import re 12 | import time 13 | import threading 14 | import weakref 15 | import requests 16 | 17 | from . import browser, events, messages, rooms, users 18 | 19 | 20 | TOO_FAST_RE = r"You can perform this action again in (\d+) seconds" 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class Client(object): 27 | """ 28 | A high-level interface for interacting with Stack Exchange chat. 29 | 30 | @ivar logged_in: Whether this client is currently logged-in. 31 | If False, attempting requests will result in errors. 32 | @type logged_in: L{bool} 33 | @ivar host: Hostname of associated Stack Exchange site. 34 | @type host: L{str} 35 | @cvar valid_hosts: Set of valid/real Stack Exchange hostnames with chat. 36 | @type valid_hosts: L{set} 37 | """ 38 | 39 | _max_recently_gotten_objects = 5000 40 | 41 | def __init__(self, host='stackexchange.com', email=None, password=None): 42 | """ 43 | Initializes a client for a specific chat host. 44 | 45 | If email and password are provided, the client will L{login}. 46 | """ 47 | self.logger = logger.getChild('Client') 48 | 49 | if email or password: 50 | assert email and password, ( 51 | "must specify both email and password or neither") 52 | 53 | # any known instances 54 | self._messages = weakref.WeakValueDictionary() 55 | self._rooms = weakref.WeakValueDictionary() 56 | self._users = weakref.WeakValueDictionary() 57 | 58 | if host not in self.valid_hosts: 59 | raise ValueError("invalid host: %r" % (host,)) 60 | 61 | self.host = host 62 | self.logged_in = False 63 | self.on_message_sent = None 64 | self._request_queue = queue.Queue() 65 | 66 | self._br = browser.Browser() 67 | self._br.host = host 68 | self._previous = None 69 | self._recently_gotten_objects = collections.deque(maxlen=self._max_recently_gotten_objects) 70 | self._requests_served = 0 71 | self._thread = threading.Thread(target=self._worker, name="message_sender") 72 | self._thread.setDaemon(True) 73 | 74 | if email or password: 75 | assert email and password 76 | self.login(email, password) 77 | 78 | def get_message(self, message_id, **attrs_to_set): 79 | """ 80 | Returns the Message instance with the given message_id. 81 | Any keyword arguments will be assigned as attributes of the Message. 82 | 83 | @rtype: L{chatexchange.messages.Message} 84 | """ 85 | return self._get_and_set_deduplicated( 86 | messages.Message, message_id, self._messages, attrs_to_set) 87 | 88 | def get_room(self, room_id, **attrs_to_set): 89 | """ 90 | Returns the Room instance with the given room_id. 91 | Any keyword arguments will be assigned as attributes of the Room. 92 | 93 | @rtype: L{rooms.Room} 94 | """ 95 | return self._get_and_set_deduplicated( 96 | rooms.Room, room_id, self._rooms, attrs_to_set) 97 | 98 | def get_user(self, user_id, **attrs_to_set): 99 | """ 100 | Returns the User instance with the given room_id. 101 | Any keyword arguments will be assigned as attributes of the Room. 102 | 103 | @rtype: L{users.User} 104 | """ 105 | return self._get_and_set_deduplicated( 106 | users.User, user_id, self._users, attrs_to_set) 107 | 108 | def _get_and_set_deduplicated(self, cls, id, instances, attrs): 109 | instance = instances.setdefault(id, cls(id, self)) 110 | 111 | for key, value in attrs.items(): 112 | setattr(instance, key, value) 113 | 114 | # we force a fixed number of recent objects to be cached 115 | self._recently_gotten_objects.appendleft(instance) 116 | 117 | return instance 118 | 119 | valid_hosts = ('stackexchange.com', 'meta.stackexchange.com', 'stackoverflow.com') 120 | 121 | def get_me(self): 122 | """ 123 | Returns the currently-logged-in User. 124 | 125 | @rtype: L{users.User} 126 | """ 127 | assert self._br.user_id is not None 128 | return self.get_user(self._br.user_id, name=self._br.user_name) 129 | 130 | def login(self, email, password): 131 | """ 132 | Authenticates using the provided Stack Exchange OpenID credentials. 133 | If successful, blocks until the instance is ready to use. 134 | """ 135 | assert not self.logged_in 136 | self.logger.info("Logging in.") 137 | 138 | self._br.login_se_openid(email, password) 139 | 140 | self._br.login_site(self.host) 141 | 142 | self.logged_in = True 143 | self.logger.info("Logged in.") 144 | self._thread.start() 145 | 146 | def logout(self): 147 | """ 148 | Logs out this client once all queued requests are sent. 149 | The client cannot be logged back in/reused. 150 | """ 151 | assert self.logged_in 152 | 153 | for watcher in self._br.sockets.values(): 154 | watcher.killed = True 155 | 156 | for watcher in self._br.polls.values(): 157 | watcher.killed = True 158 | 159 | self._request_queue.put(SystemExit) 160 | self.logger.info("Logged out.") 161 | self.logged_in = False 162 | 163 | def set_websocket_recovery(self, on_ws_closed): 164 | self._br.set_websocket_recovery(on_ws_closed) 165 | 166 | def __del__(self): 167 | if self.logged_in: 168 | self._request_queue.put(SystemExit) 169 | assert False, "You forgot to log out." 170 | 171 | def _worker(self): 172 | assert self.logged_in 173 | self.logger.info("Worker thread reporting for duty.") 174 | while True: 175 | next_action = self._request_queue.get() # blocking 176 | if next_action == SystemExit: 177 | self.logger.info("Worker thread exits.") 178 | return 179 | else: 180 | self._requests_served += 1 181 | self.logger.info( 182 | "Now serving customer %d, %r", 183 | self._requests_served, next_action) 184 | 185 | self._do_action_despite_throttling(next_action) 186 | 187 | self._request_queue.task_done() 188 | 189 | # Appeasing the rate limiter gods is hard. 190 | _BACKOFF_ADDER = 5 191 | 192 | # When told to wait n seconds, wait n * BACKOFF_MULTIPLIER + BACKOFF_ADDER 193 | 194 | @staticmethod 195 | def _unpack_response(response): 196 | try: 197 | j = response.json() 198 | return j 199 | except ValueError: 200 | return response.text 201 | 202 | def _do_action_despite_throttling(self, action): 203 | action_type = action[0] 204 | if action_type == 'send': 205 | action_type, room_id, text = action 206 | else: 207 | assert action_type == 'edit' or action_type == 'delete' 208 | action_type, message_id, text = action 209 | 210 | sent = False 211 | attempt = 0 212 | if text == self._previous: 213 | text = " " + text 214 | response = None 215 | unpacked = None 216 | while not sent: 217 | wait = 0 218 | attempt += 1 219 | self.logger.debug("Attempt %d: start.", attempt) 220 | 221 | try: 222 | if action_type == 'send': 223 | response = self._br.send_message(room_id, text) 224 | elif action_type == 'edit': 225 | response = self._br.edit_message(message_id, text) 226 | else: 227 | assert action_type == 'delete' 228 | response = self._br.delete_message(message_id) 229 | except requests.HTTPError as ex: 230 | if ex.response.status_code == 409: 231 | # this could be a throttling message we know how to handle 232 | response = ex.response 233 | else: 234 | raise 235 | 236 | unpacked = Client._unpack_response(response) 237 | ignored_messages = ["ok", "It is too late to delete this message", 238 | "It is too late to edit this message", 239 | "The message has been deleted and cannot be edited", 240 | "This message has already been deleted."] 241 | if isinstance(unpacked, str) and unpacked not in ignored_messages: 242 | match = re.match(TOO_FAST_RE, unpacked) 243 | if match: # Whoops, too fast. 244 | wait = int(match.group(1)) 245 | self.logger.debug( 246 | "Attempt %d: denied: throttled, must wait %.1f seconds", 247 | attempt, wait) 248 | # Wait more than that, though. 249 | wait += 1 250 | else: # Something went wrong. I guess that happens. 251 | if attempt > 5: 252 | err = ChatActionError("5 failed attempts to do chat action. Unknown reason: %s" % unpacked) 253 | raise err 254 | wait = self._BACKOFF_ADDER 255 | logging.error( 256 | "Attempt %d: denied: unknown reason %r", 257 | attempt, unpacked) 258 | elif isinstance(unpacked, dict): 259 | if unpacked["id"] is None: # Duplicate message? 260 | text += " " # Append because markdown 261 | wait = self._BACKOFF_ADDER 262 | self.logger.debug( 263 | "Attempt %d: denied: duplicate, waiting %.1f seconds.", 264 | attempt, wait) 265 | 266 | if wait: 267 | self.logger.debug("Attempt %d: waiting %.1f seconds", attempt, wait) 268 | else: 269 | wait = self._BACKOFF_ADDER 270 | self.logger.debug("Attempt %d: success. Waiting %.1f seconds", attempt, wait) 271 | sent = True 272 | self._previous = text 273 | 274 | time.sleep(wait) 275 | if action_type == 'send' and isinstance(unpacked, dict) and self.on_message_sent is not None: 276 | self.on_message_sent(response.json()["id"], room_id) 277 | 278 | def _join_room(self, room_id): 279 | self._br.join_room(room_id) 280 | 281 | def _leave_room(self, room_id): 282 | self._br.leave_room(room_id) 283 | 284 | 285 | class ChatActionError(Exception): 286 | pass 287 | -------------------------------------------------------------------------------- /chatexchange/events.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from . import messages 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def make(data, client): 10 | """ 11 | Instantiates an instance of Event or a subclass, for the given 12 | event data and (optional) client. 13 | """ 14 | type_id = data['event_type'] 15 | cls = types.get(type_id, Event) 16 | return cls(data, client) 17 | 18 | 19 | # Event subclasses by type_id 20 | types = {} 21 | 22 | 23 | def register_type(event_type): 24 | """ 25 | Registers an Event subclass for use with events.make(). 26 | """ 27 | type_id = event_type.type_id 28 | assert type_id not in types 29 | types.setdefault(type_id, event_type) 30 | return event_type 31 | 32 | 33 | class Event(object): 34 | def __init__(self, data, client): 35 | self.logger = logger.getChild(type(self).__name__) 36 | 37 | assert data, "empty data passed to Event constructor" 38 | 39 | self.client = client 40 | self.data = data 41 | 42 | if hasattr(self, 'type_id'): 43 | assert self.type_id == data['event_type'] 44 | else: 45 | self.type_id = data['event_type'] 46 | 47 | self.id = data['id'] 48 | if 'room_id' in data: 49 | self.room = client.get_room(data['room_id'], name=data['room_name']) 50 | else: 51 | self.room = None 52 | self.time_stamp = data['time_stamp'] 53 | 54 | self._init_from_data() 55 | 56 | def _init_from_data(self): 57 | """ 58 | Initializes any type-specific fields from self.data. 59 | """ 60 | pass 61 | 62 | def __repr__(self): 63 | return '{0!s}({1!r}, {2!r})'.format( 64 | type(self).__name__, self.data, self.client) 65 | 66 | 67 | class MessageEvent(Event): 68 | """ 69 | Base class for events about Messages. 70 | """ 71 | def _init_from_data(self): 72 | if 'user_id' in self.data: 73 | self.user = self.client.get_user( 74 | self.data['user_id'], name=self.data['user_name']) 75 | else: 76 | self.user = None 77 | self.content = self.data.get('content', None) 78 | self._message_id = self.data['message_id'] 79 | self._message_edits = self.data.get('message_edits', 0) 80 | self.show_parent = self.data.get('show_parent', False) 81 | self._message_stars = self.data.get('message_stars', 0) 82 | self._message_owner_stars = self.data.get('message_owner_stars', 0) 83 | self.target_user_id = self.data.get('target_user_id', None) 84 | self.parent_message_id = self.data.get('parent_id', None) 85 | 86 | self.message = self.client.get_message(self._message_id) 87 | 88 | self._update_message() 89 | 90 | def _update_message(self): 91 | # XXX: assuming Event has newer information than Message. 92 | message = self.message 93 | message.content = self.content 94 | message.deleted = self.content is None 95 | message.edits = self._message_edits 96 | message.stars = self._message_stars 97 | if message.stars == 0: 98 | message.starred_by_you = False 99 | 100 | pinned = self._message_owner_stars > 0 101 | 102 | if pinned: 103 | if not messages.Message.pinned.values.get(message): 104 | # If it just became pinned but was previously known unpinned, 105 | # these cached pin details will be stale if set. 106 | try: 107 | del message.pinner_user_ids 108 | del message.pinner_user_names 109 | del message.pins 110 | except AttributeError: 111 | # The pin details are not set 112 | pass 113 | else: 114 | message.pinner_user_ids = [] 115 | message.pinner_user_names = [] 116 | message.pins = 0 117 | 118 | message.pinned = pinned 119 | 120 | message.target_user_id = self.target_user_id 121 | message._parent_message_id = self.parent_message_id 122 | 123 | # this is ugly 124 | if not isinstance(self, MessageMovedOut): 125 | message.room = self.room 126 | 127 | 128 | @register_type 129 | class MessagePosted(MessageEvent): 130 | type_id = 1 131 | 132 | def _update_message(self): 133 | super(MessagePosted, self)._update_message() 134 | self.message.owner = self.user 135 | self.message.time_stamp = self.time_stamp 136 | 137 | 138 | @register_type 139 | class MessageEdited(MessageEvent): 140 | type_id = 2 141 | 142 | def _update_message(self): 143 | super(MessageEdited, self)._update_message() 144 | # XXX: I need to test with a moderator to determine whether the 145 | # XXX: user information associate with an edit is the owner 146 | # XXX: of the post or the user doing the editing. If it's the 147 | # XXX: user doing the editing, then we should add a new 148 | # XXX: editor field to Message. For now, we'll ignore the user. 149 | 150 | 151 | @register_type 152 | class UserEntered(Event): 153 | type_id = 3 154 | def _init_from_data(self): 155 | self.user = self.client.get_user( 156 | self.data['user_id'], name=self.data['user_name']) 157 | 158 | 159 | @register_type 160 | class UserLeft(Event): 161 | type_id = 4 162 | def _init_from_data(self): 163 | self.user = self.client.get_user( 164 | self.data['user_id'], name=self.data['user_name']) 165 | 166 | @register_type 167 | class RoomNameChanged(Event): 168 | type_id = 5 169 | 170 | 171 | @register_type 172 | class MessageStarred(MessageEvent): 173 | type_id = 6 174 | 175 | 176 | @register_type 177 | class UserMentioned(MessageEvent): 178 | type_id = 8 179 | 180 | 181 | @register_type 182 | class MessageFlagged(Event): 183 | type_id = 9 184 | 185 | 186 | @register_type 187 | class MessageDeleted(MessageEvent): 188 | type_id = 10 189 | 190 | 191 | @register_type 192 | class FileAdded(Event): 193 | type_id = 11 194 | 195 | 196 | @register_type 197 | class ModeratorFlag(Event): 198 | type_id = 12 199 | 200 | 201 | @register_type 202 | class UserSettingsChanged(Event): 203 | type_id = 13 204 | 205 | 206 | @register_type 207 | class GlobalNotification(Event): 208 | type_id = 14 209 | 210 | 211 | @register_type 212 | class AccountLevelChanged(Event): 213 | type_id = 15 214 | 215 | 216 | @register_type 217 | class UserNotification(Event): 218 | type_id = 16 219 | 220 | 221 | @register_type 222 | class Invitation(Event): 223 | type_id = 17 224 | 225 | 226 | @register_type 227 | class MessageReply(MessageEvent): 228 | type_id = 18 229 | 230 | 231 | @register_type 232 | class MessageMovedOut(MessageEvent): 233 | type_id = 19 234 | 235 | 236 | @register_type 237 | class MessagedMovedIn(MessageEvent): 238 | type_id = 20 239 | 240 | 241 | @register_type 242 | class TimeBreak(Event): 243 | type_id = 21 244 | 245 | 246 | @register_type 247 | class FeedTicker(Event): 248 | type_id = 22 249 | 250 | 251 | @register_type 252 | class UserSuspended(Event): 253 | type_id = 29 254 | 255 | 256 | @register_type 257 | class UserMerged(Event): 258 | type_id = 30 259 | -------------------------------------------------------------------------------- /chatexchange/messages.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from . import _utils 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Message(object): 10 | def __init__(self, id, client): 11 | self.id = id 12 | self._logger = logger.getChild('Message') 13 | self._client = client 14 | 15 | room = _utils.LazyFrom('scrape_transcript') 16 | content = _utils.LazyFrom('scrape_transcript') 17 | owner = _utils.LazyFrom('scrape_transcript') 18 | _parent_message_id = _utils.LazyFrom('scrape_transcript') 19 | stars = _utils.LazyFrom('scrape_transcript') 20 | starred_by_you = _utils.LazyFrom('scrape_transcript') 21 | pinned = _utils.LazyFrom('scrape_transcript') 22 | 23 | content_source = _utils.LazyFrom('scrape_history') 24 | editor = _utils.LazyFrom('scrape_history') 25 | edited = _utils.LazyFrom('scrape_history') 26 | edits = _utils.LazyFrom('scrape_history') 27 | pins = _utils.LazyFrom('scrape_history') 28 | pinners = _utils.LazyFrom('scrape_history') 29 | time_stamp = _utils.LazyFrom('scrape_history') 30 | 31 | def scrape_history(self): 32 | data = self._client._br.get_history(self.id) 33 | 34 | self.owner = self._client.get_user( 35 | data['owner_user_id'], name=data['owner_user_name']) 36 | self.room = self._client.get_room(data['room_id']) 37 | self.content = data['content'] 38 | self.content_source = data['content_source'] 39 | self.edits = data['edits'] 40 | self.edited = data['edited'] 41 | if data['editor_user_id'] is not None: 42 | self.editor = self._client.get_user( 43 | data['editor_user_id'], name=data['editor_user_name']) 44 | else: 45 | self.editor = None 46 | 47 | self._scrape_stars(data) 48 | 49 | self.pinned = data['pinned'] 50 | self.pins = data['pins'] 51 | self.pinners = [ 52 | self._client.get_user(user_id, name=user_name) 53 | for user_id, user_name 54 | in zip(data['pinner_user_ids'], data['pinner_user_names']) 55 | ] 56 | 57 | # TODO: self.time_stamp = ... 58 | 59 | def scrape_transcript(self): 60 | data = self._client._br.get_transcript_with_message(self.id) 61 | 62 | self.room = self._client.get_room( 63 | data['room_id'], name=data['room_name']) 64 | 65 | for message_data in data['messages']: 66 | message_id = message_data['id'] 67 | 68 | message = self._client.get_message(message_id) 69 | 70 | message.owner = self._client.get_user( 71 | message_data['owner_user_id'], name=message_data['owner_user_name']) 72 | message.room = self._client.get_room( 73 | message_data['room_id'], name=message_data['room_name']) 74 | 75 | if message_data['edited']: 76 | if not Message.edited.values.get(message): 77 | # If it was edited but not previously known to be edited, 78 | # these might have cached outdated None/0 no-edit values. 79 | del message.editor 80 | del message.edits 81 | 82 | if 'editor_user_id' in message_data: 83 | if message_data['editor_user_id'] is not None: 84 | message.editor = self._client.get_user( 85 | message_data['editor_user_id'], name=message_data['editor_user_name']) 86 | else: 87 | message.editor = None 88 | if 'edits' in message_data: 89 | message.edits = message_data['edits'] 90 | 91 | message.edited = message_data['edited'] 92 | message.content = message_data['content'] 93 | message._scrape_stars(message_data) 94 | 95 | message._parent_message_id = message_data['parent_message_id'] 96 | 97 | # TODO: message.time_stamp = ... 98 | 99 | def _scrape_stars(self, data): 100 | self.starred = data['starred'] 101 | self.stars = data['stars'] 102 | 103 | if 'starred_by_you' in data: 104 | self.starred_by_you = data['starred_by_you'] 105 | 106 | if data['pinned'] and not Message.pinned.values.get(self): 107 | # If it just became pinned but was previously known unpinned, 108 | # these cached pin details will be stale if set. 109 | del self.pinners 110 | del self.pins 111 | 112 | self.pinned = data['pinned'] 113 | 114 | if 'pinner_user_ids' in data: 115 | self.pinners = [ 116 | self._client.get_user(user_id, name=user_name) 117 | for user_id, user_name 118 | in zip(data['pinner_user_ids'], data['pinner_user_names']) 119 | ] 120 | if 'pins' in data: 121 | self.pins = data['pins'] 122 | 123 | @property 124 | def parent(self): 125 | if self._parent_message_id is not None: 126 | return self._client.get_message(self._parent_message_id) 127 | 128 | @property 129 | def text_content(self): 130 | if self.content is not None: 131 | return _utils.html_to_text(self.content) 132 | 133 | def reply(self, text, length_check=True): 134 | self.room.send_message( 135 | ":%s %s" % (self.id, text), length_check) 136 | 137 | def edit(self, text): 138 | self._client._request_queue.put(('edit', self.id, text)) 139 | self._logger.info("Queued edit %r for message_id #%r.", text, self.id) 140 | self._logger.info("Queue length: %d.", self._client._request_queue.qsize()) 141 | 142 | def delete(self): 143 | self._client._request_queue.put(('delete', self.id, '')) 144 | self._logger.info("Queued deletion for message_id #%r.", self.id) 145 | self._logger.info("Queue length: %d.", self._client._request_queue.qsize()) 146 | 147 | def star(self, value=True): 148 | del self.starred_by_you # don't use cached value 149 | if self.starred_by_you != value: 150 | self._client._br.toggle_starring(self.id) 151 | # we assume this was successfully 152 | 153 | self.starred_by_you = value 154 | 155 | if self in Message.stars.values: 156 | if value: 157 | self.stars += 1 158 | else: 159 | self.stars -= 1 160 | 161 | self.starred = bool(self.stars) 162 | else: 163 | # bust potential stale cached values 164 | del self.starred 165 | else: 166 | self._logger.info(".starred_by_you is already %r", value) 167 | 168 | def pin(self, value=True): 169 | del self.pinned # don't used cached value 170 | if self.pinned != value: 171 | self._client._br.toggle_pinning(self.id) 172 | # we assume this was successfully 173 | 174 | if self in Message.pins.values: 175 | assert self in Message.pinners.values 176 | me = self._client.get_me() 177 | 178 | if value: 179 | self.pins += 1 180 | self.pinners.append(me) 181 | else: 182 | self.pins -= 1 183 | self.pinners.remove(me) 184 | 185 | self.pinned = bool(self.pinned) 186 | else: 187 | # bust potential stale cached values 188 | del self.pinned 189 | del self.pinners 190 | else: 191 | self._logger.info(".pinned is already %r", value) 192 | -------------------------------------------------------------------------------- /chatexchange/rooms.py: -------------------------------------------------------------------------------- 1 | import sys 2 | if sys.version_info[0] == 2: 3 | import Queue as queue 4 | else: 5 | import queue 6 | import contextlib 7 | import collections 8 | import logging 9 | 10 | from . import _utils, events 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class Room(object): 17 | def __init__(self, id, client): 18 | self.id = id 19 | self._logger = logger.getChild('Room') 20 | self._client = client 21 | 22 | name = _utils.LazyFrom('scrape_info') 23 | description = _utils.LazyFrom('scrape_info') 24 | message_count = _utils.LazyFrom('scrape_info') 25 | user_count = _utils.LazyFrom('scrape_info') 26 | parent_site_name = _utils.LazyFrom('scrape_info') 27 | owners = _utils.LazyFrom('scrape_info') 28 | tags = _utils.LazyFrom('scrape_info') 29 | 30 | def scrape_info(self): 31 | data = self._client._br.get_room_info(self.id) 32 | 33 | self.name = data['name'] 34 | self.description = data['description'] 35 | self.message_count = data['message_count'] 36 | self.user_count = data['user_count'] 37 | self.parent_site_name = data['parent_site_name'] 38 | self.owners = [ 39 | self._client.get_user(user_id, name=user_name) 40 | for user_id, user_name 41 | in zip(data['owner_user_ids'], data['owner_user_names']) 42 | ] 43 | self.tags = data['tags'] 44 | 45 | @property 46 | def text_description(self): 47 | if self.description is not None: 48 | return _utils.html_to_text(self.description) 49 | 50 | def join(self): 51 | return self._client._join_room(self.id) 52 | 53 | def leave(self): 54 | return self._client._leave_room(self.id) 55 | 56 | def send_message(self, text, length_check=True): 57 | """ 58 | Sends a message (queued, to avoid getting throttled) 59 | @ivar text: The message to send 60 | @type text: L{str} 61 | """ 62 | if len(text) > 500 and length_check: 63 | self._logger.info("Could not send message because it was longer than 500 characters.") 64 | return 65 | if len(text) == 0: 66 | self._logger.info("Could not send message because it was empty.") 67 | return 68 | self._client._request_queue.put(('send', self.id, text)) 69 | self._logger.info("Queued message %r for room_id #%r.", text, self.id) 70 | self._logger.info("Queue length: %d.", self._client._request_queue.qsize()) 71 | 72 | def watch(self, event_callback): 73 | return self.watch_polling(event_callback, 3) 74 | 75 | def watch_polling(self, event_callback, interval): 76 | def on_activity(activity): 77 | for event in self._events_from_activity(activity, self.id): 78 | event_callback(event, self._client) 79 | 80 | return self._client._br.watch_room_http(self.id, on_activity, interval) 81 | 82 | def watch_socket(self, event_callback): 83 | def on_activity(activity): 84 | for event in self._events_from_activity(activity, self.id): 85 | event_callback(event, self._client) 86 | 87 | return self._client._br.watch_room_socket(self.id, on_activity) 88 | 89 | def _events_from_activity(self, activity, room_id): 90 | """ 91 | Returns a list of Events associated with a particular room, 92 | given an activity message from the server. 93 | """ 94 | room_activity = activity.get('r%s' % (room_id,), {}) 95 | room_events_data = room_activity.get('e', []) 96 | for room_event_data in room_events_data: 97 | if room_event_data: 98 | event = events.make(room_event_data, self._client) 99 | self._client._recently_gotten_objects.appendleft(event) 100 | yield event 101 | 102 | def new_events(self, types=events.Event): 103 | return FilteredEventIterator(self, types) 104 | 105 | def new_messages(self): 106 | return MessageIterator(self) 107 | 108 | def get_pingable_user_ids(self): 109 | return self._client._br.get_pingable_user_ids_in_room(self.id) 110 | 111 | def get_pingable_user_names(self): 112 | return self._client._br.get_pingable_user_names_in_room(self.id) 113 | 114 | def get_current_user_ids(self): 115 | return self._client._br.get_current_user_ids_in_room(self.id) 116 | 117 | def get_current_user_names(self): 118 | return self._client._br.get_current_user_names_in_room(self.id) 119 | 120 | 121 | class FilteredEventIterator(object): 122 | def __init__(self, room, types): 123 | self.types = types 124 | self._queue = queue.Queue() 125 | 126 | room.join() 127 | self._watcher = room.watch(self._on_event) 128 | 129 | def __enter__(self): 130 | return self 131 | 132 | def __exit__(self, exc_type, exc_value, tracback): 133 | self._watcher.close() 134 | 135 | def __iter__(self): 136 | while True: 137 | yield self._queue.get() 138 | 139 | def _on_event(self, event, client): 140 | if isinstance(event, self.types): 141 | self._queue.put(event) 142 | 143 | 144 | class MessageIterator(object): 145 | def __init__(self, room): 146 | self._event_iter = FilteredEventIterator(room, events.MessagePosted) 147 | 148 | def __enter__(self): 149 | return self 150 | 151 | def __exit__(self, exc_type, exc_value, tracback): 152 | self._event_iter._watcher.close() 153 | 154 | def __iter__(self): 155 | for event in self._event_iter: 156 | yield event.message 157 | 158 | def _on_event(self, event, client): 159 | return self._event_iter._on_event(event) 160 | -------------------------------------------------------------------------------- /chatexchange/users.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from . import _utils 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class User(object): 10 | def __init__(self, id, client): 11 | self.id = id 12 | self._logger = logger.getChild('User') 13 | self._client = client 14 | 15 | name = _utils.LazyFrom('scrape_profile') 16 | about = _utils.LazyFrom('scrape_profile') 17 | is_moderator = _utils.LazyFrom('scrape_profile') 18 | message_count = _utils.LazyFrom('scrape_profile') 19 | room_count = _utils.LazyFrom('scrape_profile') 20 | reputation = _utils.LazyFrom('scrape_profile') 21 | last_seen = _utils.LazyFrom('scrape_profile') 22 | 23 | def scrape_profile(self): 24 | data = self._client._br.get_profile(self.id) 25 | 26 | self.name = data['name'] 27 | self.is_moderator = data['is_moderator'] 28 | self.message_count = data['message_count'] 29 | self.room_count = data['room_count'] 30 | self.reputation = data['reputation'] 31 | self.last_seen = data['last_seen'] 32 | -------------------------------------------------------------------------------- /docs/Documentation.md: -------------------------------------------------------------------------------- 1 | # Documentation: `_utils.py` 2 | Contains utility functions for use within ChatExchange. Not originally indented for external use, but are accessible from external code. 3 | 4 | #### `log_and_ignore_exceptions(f, exceptions=Exception, logger=logging.getLogger('exceptions'))` 5 | Wraps a function to catch and log any exceptions it throws. 6 | 7 | **`f`** is the function to wrap. Required. 8 | **`exceptions`** are the exceptions to catch from `f`. An array of exception types. Optional. 9 | **`logger`** is the logging manager. You should not need to specify this. Optional. 10 | 11 | ----- 12 | 13 | ### `class HTMLTextExtractor(HTMLParser)` 14 | Extends the `HTMLParser` class, which provides methods for working with raw HTML. Adapted from a [Stack Overflow post](http://stackoverflow.com/a/7778368) by [Søren Løvborg](http://stackoverflow.com/u/13679) and Eloff. 15 | 16 | #### `handle_data(self, d)` 17 | Appends the given data, **`d`**, to the class' result. 18 | 19 | #### `handle_charref(self, number)` 20 | Finds the codepoint specified by **`number`** and appends the Unicode character at this codepoint to the class' result. 21 | 22 | #### `handle_entityref(self, name)` 23 | Finds a codepoint based on the HTML entity provided in **`name`** and appends it to the class' result. 24 | 25 | ----- 26 | 27 | #### `html_to_text(html)` 28 | When given a string of valid HTML in **`html`**, returns the text contained in it. Internally, uses the `HTMLTextExtractor` class. 29 | 30 | ----- 31 | 32 | ### `class LazyFrom(object)` 33 | A descriptor used when multiple lazy attributes depend on a common source of data. This class lazily extracts data from the specified object and returns it. Only special methods are defined in this class and should not be directly used. For an example of usage, see [`messages.py`](https://github.com/Manishearth/ChatExchange/blob/master/chatexchange/messages.py). -------------------------------------------------------------------------------- /docs/se-chat-interface.md: -------------------------------------------------------------------------------- 1 | Incomplete unofficial documentation of Stack Exchange chat interface. 2 | 3 | All POST methods require an `fkey` POST form data argument. It won't be 4 | specifically listed for each of them. 5 | 6 | ## Event JSON Objects 7 | 8 | Chat events are represented by JSON objects with at least the following 9 | fields: 10 | 11 | - `room_id` - integer 12 | - `room_name` - string 13 | - `event_type` - integer, possible values enumerated below 14 | - `time_stamp` - integer 15 | 16 | ### `event_type` values 17 | 18 | #### Message Events 19 | 20 | - `1` - message posted 21 | - `2` - message edited 22 | - `6` - message starred or unstarred 23 | 24 | All message events contain the the following fields based on the message 25 | they refer to: 26 | 27 | - `message_id` - integer 28 | - `content` - string or missing if the user deleted the message 29 | - `message_edits` - integer or missing if message hasn't been edited. 30 | - `message_stars` - integer or missing if message has no stars. 31 | - `message_owner_stars` - integer or missing if message not pinned. 32 | - `target_user_id` - integer or missing 33 | - `show_parent` - boolean or missing 34 | 35 | They also contain the following fields, which may refer to the owner of 36 | the most in some cases (e.g. for an id=1 message posted event), but 37 | others may refer to the user taking the action triggering the event 38 | (e.g. for an id=6 message starred or unstarred event). 39 | 40 | - `user_id` - integer 41 | - `user_name` - string 42 | 43 | ## Reading 44 | 45 | ### POST `/chats/ROOM_ID/events?mode=Messages` 46 | 47 | Returns the most available recent event_type=1 (MessagePosted) Events 48 | for the given ROOM_ID. 49 | 50 | #### URL Query String Arguments 51 | 52 | ##### `before` 53 | 54 | Optional. Limits results to events with a `message_id` less than 55 | `before`. 56 | 57 | ##### `after` 58 | 59 | Optional. Limits results to events with a `message_id` greater than or 60 | equal to `after`. 61 | 62 | ##### `msgCount` 63 | 64 | Optional. Number of events to return. 65 | Maximum: 500 66 | Default: 100 67 | 68 | ### POST `/events` 69 | 70 | Returns a list of recent events for a room. 71 | 72 | #### POST form data arguments 73 | 74 | ##### `rROOM_ID` 75 | 76 | The name specifies the room we are interested in (e.g. `'r14219'`), and 77 | the value specifies the `time_stamp` we would like to see messages 78 | since. 79 | 80 | ### GET `/message/MESSAGE_ID` 81 | 82 | Returns the text of the specified message. 83 | 84 | #### URL Query String Arguments 85 | 86 | ##### `plain` 87 | 88 | Optional. If false or missing, the rendered HTML of the message will be 89 | returned. If true, the original markdown source of the message will be. 90 | 91 | ### POST `/ws-auth` 92 | 93 | Authenticates a websocket connection to listen for events in a given 94 | room. This returns a JSON Object with a `url` field, identifying the URL 95 | to be used for the websocket connection. The `l` query string paramter 96 | should be used with websocket URL to specify the time_stamp after which 97 | we are interested in events. 98 | 99 | #### POST form data arguments 100 | 101 | ##### `roomid` 102 | 103 | ### GET `/rooms/thumbs/ROOM_ID` 104 | 105 | Returns a JSON object with information about the specified room. 106 | Includes the following fields: 107 | 108 | - `id` - integer 109 | - `name` - string 110 | - `description` - string 111 | - `isFavorite` - boolean 112 | - `usage` - null or string with HTML displaying graph of room activity 113 | - `tags` - string with HTML displays tags associated with room 114 | 115 | #### URL Query String Arguments 116 | 117 | ##### `showUsage` 118 | 119 | Optional Whether the `usage` field should be populated, else null. 120 | Default: false 121 | 122 | ### POST `/user/info/` 123 | 124 | Returns information about users in the context of a given room. 125 | 126 | The result is a JSON object with a single field, `users`, a list of 127 | objects each of which has the following keys: 128 | 129 | - `id` - integer 130 | - `name` - string 131 | - `reputation` - integer 132 | - `is_moderator` - boolean 133 | - `is_owner` - boolean 134 | - `email_hash` - an hex MD5 hash digest used to identify a gravatar, 135 | or a `!`-prefixed avatar URL. 136 | 137 | #### POST form data arguments 138 | 139 | ##### `ids` 140 | 141 | The ID (comma-seperated list of IDs?) of the users in question. 142 | 143 | ##### `roomId` 144 | 145 | The ID of the room in question. 146 | 147 | ## Writing 148 | 149 | ### POST `/chats/ROOM_ID/messages/new` 150 | 151 | Attempts to post a message to the specified chat room. 152 | 153 | #### POST form data arguments 154 | 155 | ##### `text` 156 | 157 | The content of the message. 158 | 159 | ### POST `/messages/MESSAGE_ID` 160 | 161 | Attempts to edit a message. 162 | 163 | #### POST form data arguments 164 | 165 | ##### `text` 166 | 167 | The new content of the message. 168 | 169 | ### POST `/messages/MESSAGE_ID/star` 170 | 171 | Stars or unstars the specified message. You can't specify whether you 172 | want to have starred the message or not, you can just toggle whether 173 | you have. 174 | 175 | ### POST `/messages/MESSAGE_ID/owner-star` 176 | 177 | Pins or unpins the specified message. This is a toggle, like starring. 178 | 179 | ### POST `/messages/MESSAGE_ID/delete` 180 | 181 | Removes `content` of the specified message. 182 | 183 | ### POST `/admin/movePosts/ROOM_ID` 184 | 185 | Moves posts from the specified room to another room. 186 | 187 | #### POST form data arguments 188 | 189 | - `ids` - comma-seperated list of message_ids 190 | - `to` - room_id of room the messages should be moved to 191 | -------------------------------------------------------------------------------- /examples/chat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import getpass 3 | import logging 4 | import logging.handlers 5 | import os 6 | import random 7 | import sys 8 | 9 | import chatexchange.client 10 | import chatexchange.events 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def main(args): 17 | setup_logging() 18 | 19 | # Run `. setp.sh` to set the below testing environment variables 20 | 21 | host_id = 'stackexchange.com' 22 | room_id = '14219' # Charcoal Chatbot Sandbox 23 | 24 | if 'ChatExchangeU' in os.environ: 25 | email = os.environ['ChatExchangeU'] 26 | else: 27 | email = input("Email: ") 28 | if 'ChatExchangeP' in os.environ: 29 | password = os.environ['ChatExchangeP'] 30 | else: 31 | password = getpass.getpass("Password: ") 32 | 33 | client = chatexchange.client.Client(host_id) 34 | client.login(email, password) 35 | 36 | room = client.get_room(room_id) 37 | room.join() 38 | room.watch(on_message) 39 | 40 | print("(You are now in room #%s on %s.)" % (room_id, host_id)) 41 | while True: 42 | message = input("<< ") 43 | room.send_message(message) 44 | 45 | client.logout() 46 | 47 | 48 | def on_message(message, client): 49 | if not isinstance(message, chatexchange.events.MessagePosted): 50 | # Ignore non-message_posted events. 51 | logger.debug("event: %r", message) 52 | return 53 | 54 | print("") 55 | print(">> (%s) %s" % (message.user.name, message.content)) 56 | if message.content.startswith('!!/random'): 57 | print(message) 58 | print("Spawning thread") 59 | message.message.reply(str(random.random())) 60 | 61 | 62 | def setup_logging(): 63 | logging.basicConfig(level=logging.INFO) 64 | logger.setLevel(logging.DEBUG) 65 | 66 | # In addition to the basic stderr logging configured globally 67 | # above, we'll use a log file for chatexchange.client. 68 | wrapper_logger = logging.getLogger('chatexchange.client') 69 | wrapper_handler = logging.handlers.TimedRotatingFileHandler( 70 | filename='client.log', 71 | when='midnight', delay=True, utc=True, backupCount=7, 72 | ) 73 | wrapper_handler.setFormatter(logging.Formatter( 74 | "%(asctime)s: %(levelname)s: %(threadName)s: %(message)s" 75 | )) 76 | wrapper_logger.addHandler(wrapper_handler) 77 | 78 | 79 | if __name__ == '__main__': 80 | main(*sys.argv[1:]) 81 | -------------------------------------------------------------------------------- /examples/hello_world.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import getpass 3 | import logging 4 | import os 5 | 6 | import chatexchange 7 | from chatexchange.events import MessageEdited 8 | 9 | 10 | logging.basicConfig(level=logging.DEBUG) 11 | 12 | if 'ChatExchangeU' in os.environ: 13 | email = os.environ['ChatExchangeU'] 14 | else: 15 | email = input("Email: ") 16 | if 'ChatExchangeP' in os.environ: 17 | password = os.environ['ChatExchangeP'] 18 | else: 19 | password = getpass.getpass("Password: ") 20 | client = chatexchange.Client('stackexchange.com', email, password) 21 | 22 | me = client.get_me() 23 | sandbox = client.get_room(14219) 24 | my_message = None 25 | 26 | with sandbox.new_messages() as messages: 27 | sandbox.send_message("hello worl") 28 | 29 | for message in messages: 30 | if message.owner is me: 31 | my_message = message 32 | assert my_message.content == "hello worl" 33 | print("message sent successfully") 34 | break 35 | 36 | with sandbox.new_events(MessageEdited) as edits: 37 | my_message.edit("hello world") 38 | 39 | for edit in edits: 40 | if edit.message is my_message: 41 | assert my_message.content == "hello world" 42 | print("message edited successfully") 43 | break 44 | -------------------------------------------------------------------------------- /examples/web_viewer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Opens a web page displaying a simple updating view of a chat room. 4 | 5 | This is not meant for unauthenticated, remote, or multi-client use. 6 | """ 7 | 8 | import sys 9 | if sys.version_info[0] == 2: 10 | from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler 11 | else: 12 | from http.server import HTTPServer, BaseHTTPRequestHandler 13 | import collections 14 | import getpass 15 | import json 16 | import logging 17 | import os 18 | import webbrowser 19 | 20 | import chatexchange 21 | from chatexchange import events 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def main(port='8462'): 28 | port = int(port) 29 | 30 | logging.basicConfig(level=logging.INFO) 31 | 32 | room_id = 14219 # Charcoal Chatbot Sandbox 33 | 34 | if 'ChatExchangeU' in os.environ: 35 | email = os.environ['ChatExchangeU'] 36 | else: 37 | sys.stderr.write("Username: ") 38 | sys.stderr.flush() 39 | email = input() 40 | if 'ChatExchangeP' in os.environ: 41 | password = os.environ['ChatExchangeP'] 42 | else: 43 | password = getpass.getpass("Password: ") 44 | 45 | client = chatexchange.Client('stackexchange.com') 46 | client.login(email, password) 47 | 48 | httpd = Server( 49 | ('127.0.0.1', 8462), Handler, client=client, room_id=room_id) 50 | webbrowser.open('http://localhost:%s/' % (port,)) 51 | httpd.serve_forever() 52 | 53 | 54 | class Server(HTTPServer, object): 55 | def __init__(self, *a, **kw): 56 | self.client = kw.pop('client') 57 | self.room = self.client.get_room(kw.pop('room_id')) 58 | self.room.name = "Chat Room" # prevent request 59 | self.messages = collections.deque(maxlen=25) 60 | 61 | self.room.join() 62 | self.room.watch_socket(self.on_chat_event) 63 | 64 | self.room.send_message("Hello, world!") 65 | 66 | super(Server, self).__init__(*a, **kw) 67 | 68 | def get_state(self): 69 | return { 70 | 'host': self.client.host, 71 | 'room': { 72 | 'id': self.room.id, 73 | 'name': self.room.name 74 | }, 75 | 'recent_events': 76 | map(str, self.client._recently_gotten_objects), 77 | 'messages': [{ 78 | 'id': message.id, 79 | 'owner_user_id': message.owner.id, 80 | 'owner_user_name': message.owner.name, 81 | 'text_content': message.text_content, 82 | 'stars': message.stars, 83 | 'starred_by_you': message.starred_by_you, 84 | 'pinned': message.pinned, 85 | 'pinner_user_name': message.pinners and message.pinners[0].name, 86 | 'pinner_user_id': message.pinners and message.pinners[0].id, 87 | 'edits': message.edits, 88 | } for message in self.messages] 89 | } 90 | 91 | def on_chat_event(self, event, client): 92 | if isinstance(event, events.MessagePosted) and event.room is self.room: 93 | self.messages.append(event.message) 94 | 95 | 96 | class Handler(BaseHTTPRequestHandler, object): 97 | logger = logging.getLogger(__name__).getChild('Handler') 98 | 99 | def do_GET(self): 100 | if self.path == '/': 101 | self.send_page() 102 | elif self.path == '/state': 103 | self.send_state() 104 | else: 105 | self.send_error(404) 106 | 107 | self.wfile.close() 108 | 109 | def do_POST(self): 110 | assert self.path == '/action' 111 | 112 | length = int(self.headers.getheader('content-length')) 113 | json_data = self.rfile.read(length) 114 | data = json.loads(json_data) 115 | 116 | if data['action'] == 'create': 117 | self.server.room.send_message(data['text']) 118 | elif data['action'] == 'edit': 119 | message = self.server.client.get_message(data['target']) 120 | message.edit(data['text']) 121 | elif data['action'] == 'reply': 122 | message = self.server.client.get_message(data['target']) 123 | message.reply(data['text']) 124 | elif data['action'] == 'set-starring': 125 | message = self.server.client.get_message(data['target']) 126 | message.star(data['value']) 127 | elif data['action'] == 'set-pinning': 128 | message = self.server.client.get_message(data['target']) 129 | message.pin(data['value']) 130 | elif data['action'] == 'scrape-transcript': 131 | message = self.server.client.get_message(data['target']) 132 | message.scrape_transcript() 133 | elif data['action'] == 'scrape-history': 134 | message = self.server.client.get_message(data['target']) 135 | message.scrape_history() 136 | else: 137 | assert False 138 | 139 | self.send_response(200) 140 | self.end_headers() 141 | self.wfile.write("queued!") 142 | self.wfile.close() 143 | 144 | def send_state(self): 145 | body = json.dumps(self.server.get_state()) 146 | self.send_response(200) 147 | self.send_header('Content-Type', 'application/json') 148 | self.end_headers() 149 | self.wfile.write(body) 150 | 151 | def send_page(self): 152 | self.send_response(200) 153 | self.send_header('Content-Type', 'text/html') 154 | self.end_headers() 155 | self.wfile.write(''' 156 | 157 | 158 | ChatExchange Web Viewer Example 159 | 160 | 161 | 162 | 163 | 248 |
249 |

250 | 251 | {{room.name}} #{{room.id}} 252 | 253 |

254 |
255 | There have been no new messages. 256 |
257 | 258 |
259 | 263 | 264 |
265 | 266 | {{message.text_content}} 267 | 268 |
269 | 270 |
271 | 277 | 278 | 284 | 285 | 292 | 293 | 300 | 301 | 308 | 309 | 316 | 317 | 323 | 324 | 330 | 331 | 332 | edited 333 | x{{message.edits}} 334 | 335 | 336 | 337 | starred 338 | x{{message.stars}} 339 | by you 340 | 341 | 342 | 343 | pinned by {{message.pinner_user_name}} (#{{message.pinner_user_id}}) 344 | 345 |
346 |
347 | 348 |
349 |
350 | {{action}} 351 |
352 |
353 | 354 |
355 |
356 | 359 |
360 |
361 |
362 |
363 |
{{event}}
364 |
365 | 382 | 383 | ''') 384 | 385 | 386 | if __name__ == '__main__': 387 | main(*sys.argv[1:]) 388 | -------------------------------------------------------------------------------- /setp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "Make sure you source this file instead of simply running it!" 3 | read -p "Username: " u 4 | export ChatExchangeU=$u 5 | export CEU="h" 6 | stty -echo 7 | read -p "Password: " p 8 | export ChatExchangeP=$p 9 | stty echo 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name='ChatExchange', 5 | version='0.0.3', 6 | url='https://github.com/Manishearth/ChatExchange', 7 | packages=[ 8 | 'chatexchange' 9 | ], 10 | install_requires=[ 11 | 'beautifulsoup4>=4.3.2', 12 | 'requests>=2.2.1', 13 | 'websocket-client>=0.13.0', 14 | # only for dev: 15 | 'coverage==3.7.1', 16 | 'epydoc>=3.0.1', 17 | 'httmock>=1.2.2', 18 | 'pytest-capturelog>=0.7', 19 | 'pytest-timeout>=0.3', 20 | 'pytest>=2.7.3', 21 | 'py>=1.4.29' 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | something 3 | """ 4 | -------------------------------------------------------------------------------- /tests/all_tests.sh: -------------------------------------------------------------------------------- 1 | testdir=$(dirname "$(readlink -f $0)") 2 | errcount=0 3 | 4 | col_good="\033[0;32m" # green 5 | col_bad="\033[0;31m" # red 6 | col_off="\033[0m" # no color 7 | 8 | echo "*** Running all tests for ChatExchange ***" 9 | echo 10 | echo "Interpreter: $(python --version) ( $(which python) )" 11 | echo "Test directory: $testdir" 12 | echo 13 | 14 | for x in "$testdir"/test_*.py ; do 15 | basename "$x" 16 | 17 | if PYTHONPATH="$(pwd)" python "$x" ; then 18 | echo -e "${col_good} --> ok${col_off}" 19 | else 20 | echo -e "${col_bad} --> FAILED !!!${col_off}" 21 | errcount=$(( $errcount+1 )) 22 | fi 23 | done 24 | 25 | echo 26 | if test $errcount -eq 0 ; then 27 | echo -e "${col_good}Finished successfully.${col_off}" 28 | else 29 | echo -e "${col_bad}Exited with $errcount errors.${col_off}" 30 | fi 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/live_testing.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module looks for live testing configuration in environment 3 | variables, and exports them for test use if found. 4 | """ 5 | 6 | import os 7 | 8 | 9 | enabled = False 10 | 11 | 12 | if (os.environ.get('ChatExchangeU') and 13 | os.environ.get('ChatExchangeP')): 14 | enabled = True 15 | email = os.environ['ChatExchangeU'] 16 | password = os.environ['ChatExchangeP'] 17 | -------------------------------------------------------------------------------- /tests/mock_responses.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import httmock 3 | 4 | 5 | def only_httmock(*mocks): 6 | """ 7 | Wraps a typical HTTPMock context manager to raise an error if no mocks match. 8 | """ 9 | mocks = mocks + (fail_everything_else,) 10 | return httmock.HTTMock(*mocks) 11 | 12 | 13 | TEST_FKEY = 'dc63b60f5ada11372b8ff63821d9bf24' 14 | 15 | 16 | @httmock.urlmatch(path=r'^/chats/join/favorite$') 17 | def favorite_with_test_fkey(url, request): 18 | return ''' 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | 47 | 48 | 49 | 50 |
51 |
52 | 53 |
54 |
55 | 56 | 79 | 91 |
92 |
93 | 94 | 95 | 100 | 101 | 122 | 123 | 124 |
125 | 126 | 127 |

Join

128 | Please confirm that you wish to rejoin your favorited rooms: 129 |
130 | 131 | 132 |
133 | 134 |
135 |
136 | 149 | 150 | ''' 151 | 152 | 153 | @httmock.urlmatch(netloc=r'.*') 154 | def fail_everything_else(url, request): 155 | raise Exception("unexpected request; no mock available", request) 156 | -------------------------------------------------------------------------------- /tests/test_browser.py: -------------------------------------------------------------------------------- 1 | import httmock 2 | 3 | from chatexchange import Browser 4 | 5 | from tests.mock_responses import only_httmock, favorite_with_test_fkey, TEST_FKEY 6 | 7 | 8 | def test_update_fkey(): 9 | """ 10 | Tests that the correct chat fkey is retrived, using a mock response 11 | with a copy of a real response from /chats/join/favorite 12 | """ 13 | with only_httmock(favorite_with_test_fkey): 14 | browser = Browser() 15 | browser.host = 'stackexchange.com' 16 | 17 | assert browser.chat_fkey == TEST_FKEY 18 | 19 | 20 | def test_user_agent(): 21 | """ 22 | Tests that HTTP requests made from a Browser use the intended 23 | User-Agent. 24 | 25 | WebSocket connections are not tested. 26 | """ 27 | good_requests = [] 28 | 29 | @httmock.all_requests 30 | def verify_user_agent(url, request): 31 | assert request.headers['user-agent'] == Browser.user_agent 32 | good_requests.append(request) 33 | return 'Hello<body>World' 34 | 35 | with only_httmock(verify_user_agent): 36 | browser = Browser() 37 | 38 | browser.get_soup('http://example.com/', with_chat_root=False) 39 | browser.get_soup('http://example.com/2', with_chat_root=False) 40 | 41 | assert len(good_requests) == 2, "Unexpected number of requests" 42 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import sys 2 | if sys.version_info[0] == 2: 3 | import Queue as queue 4 | else: 5 | import queue 6 | import logging 7 | if sys.version_info[:2] <= (2, 6): 8 | logging.Logger.getChild = lambda self, suffix:\ 9 | self.manager.getLogger('.'.join((self.name, suffix)) if self.root is not self else suffix) 10 | import time 11 | import uuid 12 | import os 13 | 14 | import pytest 15 | 16 | from chatexchange.client import Client 17 | from chatexchange import events 18 | 19 | from tests import live_testing 20 | 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | TEST_ROOMS = [ 26 | ('stackexchange.com', '14219'), # Charcoal Sandbox 27 | ] 28 | 29 | 30 | if (os.environ.get('TRAVIS_BUILD_ID') and 31 | os.environ.get('TRAVIS_REPO_SLUG') and 32 | os.environ.get('TRAVIS_COMMIT')): 33 | TEST_MESSAGE_FORMAT = ( 34 | "[ [ChatExchange@Travis](https://travis-ci.org/" 35 | "{0[TRAVIS_REPO_SLUG]}/builds/{0[TRAVIS_BUILD_ID]} \"This is " 36 | "a test message for ChatExchange using the nonce {{0}}.\") ] " 37 | "This is a test of [{0[TRAVIS_REPO_SLUG]}@{short_commit}](" 38 | "https://github.com/{0[TRAVIS_REPO_SLUG]}/commit/{0[TRAVIS_COMMIT]})." 39 | ).format(os.environ, short_commit=os.environ['TRAVIS_COMMIT'][:8]) 40 | else: 41 | TEST_MESSAGE_FORMAT = ( 42 | "[ [ChatExchange@localhost](https://github.com/Manishearth/" 43 | "ChatExchange/ \"This is a test message for ChatExchange using " 44 | "the nonce {0}.\") ] This is a test message for ChatExchange.") 45 | 46 | 47 | if live_testing.enabled: 48 | @pytest.mark.parametrize('host_id,room_id', TEST_ROOMS) 49 | @pytest.mark.timeout(240) 50 | def test_se_message_echo(host_id, room_id): 51 | """ 52 | Tests that we are able to send a message, and recieve it back, 53 | send a reply, and recieve that back, within a reasonable amount 54 | of time. 55 | 56 | This is a lot of complexity for a single test, but we don't want 57 | to flood Stack Exchange with more test messages than necessary. 58 | """ 59 | 60 | client = Client(host_id) 61 | client.login( 62 | live_testing.email, 63 | live_testing.password) 64 | 65 | timeout_duration = 60 66 | 67 | pending_events = queue.Queue() 68 | 69 | def get_event(predicate): 70 | """ 71 | Waits until it has seen a message passing the specified 72 | predicate from both polling and sockets. 73 | 74 | Asserts that it has not waited longer than the specified 75 | timeout, and asserts that the events from difference sources 76 | have the same ID. 77 | 78 | This may dequeue any number of additional unrelated events 79 | while it is running, so it's not appropriate if you are 80 | trying to wait for multiple events at once. 81 | """ 82 | 83 | socket_event = None 84 | polling_event = None 85 | 86 | timeout = time.time() + timeout_duration 87 | 88 | while (not (socket_event and polling_event) 89 | and time.time() < timeout): 90 | try: 91 | is_socket, event = pending_events.get(timeout=1) 92 | except queue.Empty: 93 | continue 94 | 95 | if predicate(event): 96 | logger.info( 97 | "Expected event (is_socket==%r): %r", 98 | is_socket, event) 99 | if is_socket: 100 | assert socket_event is None 101 | socket_event = event 102 | else: 103 | assert polling_event is None 104 | polling_event = event 105 | else: 106 | logger.debug("Unexpected events: %r", event) 107 | 108 | assert socket_event and polling_event 109 | assert type(socket_event) is type(polling_event) 110 | assert socket_event.id == polling_event.id 111 | 112 | return socket_event 113 | 114 | logger.debug("Joining chat") 115 | 116 | room = client.get_room(room_id) 117 | 118 | room.join() 119 | 120 | room.watch_polling(lambda event, _: 121 | pending_events.put((False, event)), 5) 122 | room.watch_socket(lambda event, _: 123 | pending_events.put((True, event))) 124 | 125 | time.sleep(2) # Avoid race conditions 126 | 127 | test_message_nonce = uuid.uuid4().hex 128 | test_message_content = TEST_MESSAGE_FORMAT.format(test_message_nonce) 129 | 130 | logger.debug("Sending test message") 131 | room.send_message(test_message_content) 132 | 133 | @get_event 134 | def test_message_posted(event): 135 | return ( 136 | isinstance(event, events.MessagePosted) 137 | and test_message_nonce in event.content 138 | ) 139 | 140 | logger.debug("Observed test edit") 141 | 142 | test_reply_nonce = uuid.uuid4().hex 143 | test_reply_content = TEST_MESSAGE_FORMAT.format(test_reply_nonce) 144 | 145 | logger.debug("Sending test reply") 146 | test_message_posted.message.reply(test_reply_content) 147 | 148 | # XXX: The limitations of get_event don't allow us to also 149 | # XXX: look for the corresponding MessagePosted event. 150 | @get_event 151 | def test_reply(event): 152 | return ( 153 | isinstance(event, events.MessageReply) 154 | and test_reply_nonce in event.content 155 | ) 156 | 157 | logger.debug("Observed test reply") 158 | 159 | assert test_reply.parent_message_id == test_message_posted.message.id 160 | assert test_reply.message.parent.id == test_reply.parent_message_id 161 | assert test_message_posted.message.id == test_message_posted.message.id 162 | assert test_reply.message.parent is test_message_posted.message 163 | 164 | # unsafe - html content is unstable; may be inconsistent between views 165 | # assert test_reply.message.parent.content == test_message_posted.content 166 | 167 | test_edit_nonce = uuid.uuid4().hex 168 | test_edit_content = TEST_MESSAGE_FORMAT.format(test_edit_nonce) 169 | 170 | logger.debug("Sending test edits") 171 | 172 | # Send a lot of edits in a row, to ensure we don't lose any 173 | # from throttling being ignored. 174 | test_message_posted.message.edit( 175 | "**this is a** test edit and should be edited again") 176 | test_message_posted.message.edit( 177 | "this is **another test edit** and should be edited again") 178 | test_message_posted.message.edit( 179 | "this is **yet** another test edit and **should be edited again**") 180 | test_message_posted.message.edit(test_edit_content) 181 | 182 | @get_event 183 | def test_edit(event): 184 | return ( 185 | isinstance(event, events.MessageEdited) 186 | and test_edit_nonce in event.content 187 | ) 188 | 189 | logger.debug("Observed final test edit") 190 | 191 | assert test_message_posted.message is test_edit.message 192 | assert test_edit.message.id == test_message_posted.message.id 193 | assert test_edit.message.edits == 4 194 | assert test_edit.message.content_source == test_edit_content 195 | 196 | # it should be safe to assume that there isn't so much activity 197 | # that these events will have been flushed out of recent_events. 198 | assert test_message_posted in client._recently_gotten_objects 199 | assert test_reply in client._recently_gotten_objects 200 | assert test_edit in client._recently_gotten_objects 201 | 202 | client.logout() 203 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | from chatexchange import events, client 2 | 3 | 4 | def test_message_posted_event_initialization(): 5 | event_type = 1 6 | room_name = "Charcoal Chatbot Sandbox" 7 | content = 'hello <b>world</b>' 8 | intended_text_content = 'hello world' 9 | id = 28258802 10 | message_id = 15249005 11 | room_id = 14219 12 | time_stamp = 1398822427 13 | user_id = 97938 14 | user_name = "bot" 15 | 16 | event_data = { 17 | "content": content, 18 | "event_type": event_type, 19 | "id": id, 20 | "message_id": message_id, 21 | "room_id": room_id, 22 | "room_name": room_name, 23 | "time_stamp": time_stamp, 24 | "user_id": user_id, 25 | "user_name": user_name 26 | } 27 | 28 | event = events.make(event_data, client.Client()) 29 | 30 | assert isinstance(event, events.MessagePosted) 31 | assert type(event) == events.MessagePosted 32 | assert event.type_id == event_type 33 | 34 | assert event.id == id 35 | assert event.room.id == room_id 36 | assert event.room.name == room_name 37 | 38 | assert event.content == content 39 | assert event.message.text_content == intended_text_content 40 | assert event.message.id == message_id 41 | assert event.user.id == user_id 42 | assert event.user.name == user_name 43 | 44 | 45 | def test_message_edited_event_initialization(): 46 | event_type = 2 47 | room_name = "Charcoal Chatbot Sandbox" 48 | content = 'hello <b>world</b>' 49 | intended_text_content = 'hello world' 50 | id = 28258802 51 | message_id = 15249005 52 | message_edits = 2 53 | room_id = 14219 54 | time_stamp = 1398822427 55 | user_id = 97938 56 | user_name = "bot" 57 | 58 | event_data = { 59 | "content": content, 60 | "event_type": event_type, 61 | "id": id, 62 | "message_id": message_id, 63 | "message_edits": message_edits, 64 | "room_id": room_id, 65 | "room_name": room_name, 66 | "time_stamp": time_stamp, 67 | "user_id": user_id, 68 | "user_name": user_name 69 | } 70 | 71 | event = events.make(event_data, client.Client()) 72 | 73 | assert isinstance(event, events.MessageEdited) 74 | assert type(event) == events.MessageEdited 75 | assert event.type_id == event_type 76 | 77 | assert event.id == id 78 | assert event.room.id == room_id 79 | assert event.room.name == room_name 80 | 81 | assert event.content == content 82 | assert event.message.text_content == intended_text_content 83 | assert event.message.id == message_id 84 | assert event.message.edits == message_edits 85 | assert event.user.id == user_id 86 | assert event.user.name == user_name 87 | 88 | 89 | def test_message_starred_event_initialization(): 90 | event_type = 6 91 | room_name = "Charcoal Chatbot Sandbox" 92 | content = 'hello <b>world</b>' 93 | intended_text_content = 'hello world' 94 | id = 28258802 95 | message_id = 15249005 96 | message_stars = 3 97 | room_id = 14219 98 | time_stamp = 1398822427 99 | user_id = 97938 100 | user_name = "bot" 101 | 102 | event_data = { 103 | "content": content, 104 | "event_type": event_type, 105 | "id": id, 106 | "message_id": message_id, 107 | "message_stars": message_stars, 108 | "room_id": room_id, 109 | "room_name": room_name, 110 | "time_stamp": time_stamp, 111 | "user_id": user_id, 112 | "user_name": user_name 113 | } 114 | 115 | event = events.make(event_data, client.Client()) 116 | 117 | assert isinstance(event, events.MessageStarred) 118 | assert type(event) == events.MessageStarred 119 | assert event.type_id == event_type 120 | 121 | assert event.id == id 122 | assert event.room.id == room_id 123 | assert event.room.name == room_name 124 | 125 | assert event.content == content 126 | assert event.message.text_content == intended_text_content 127 | assert event.message.id == message_id 128 | assert event.message.stars == message_stars 129 | assert event.user.id == user_id 130 | assert event.user.name == user_name 131 | -------------------------------------------------------------------------------- /tests/test_messages.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | if sys.version_info[:2] <= (2, 6): 4 | logging.Logger.getChild = lambda self, suffix:\ 5 | self.manager.getLogger('.'.join((self.name, suffix)) if self.root is not self else suffix) 6 | 7 | from chatexchange import Client 8 | 9 | from tests import live_testing 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | if live_testing.enabled: 16 | def test_specific_messages(): 17 | client = Client('stackexchange.com') 18 | 19 | message1 = client.get_message(15359027) 20 | 21 | assert message1.id == 15359027 22 | assert message1.text_content == '@JeremyBanks hello' 23 | assert message1.content_source == ":15358991 **hello**" 24 | assert message1.owner.id == 1251 25 | assert message1.room.id == 14219 26 | 27 | message2 = message1.parent 28 | 29 | assert message2.id == 15358991 30 | assert message2 is client.get_message(15358991) 31 | assert message2.text_content == "@bot forever in my tests" 32 | assert message2.owner.id == 1251 33 | 34 | message3 = message2.parent 35 | message3.scrape_history = 'NOT EXPECTED TO BE CALLED' 36 | message3.scrape_transcript() 37 | 38 | assert message3.id == 15356758 39 | assert not message3.edited 40 | assert message3.editor is None 41 | assert not message3.pinned 42 | assert message3.pinners == [] 43 | assert message3.pins == 0 44 | assert message3.owner.id == 97938 45 | 46 | message4 = message3.parent 47 | 48 | assert message4.id == 15356755 49 | assert message4.edited 50 | # assert message4.pinned 51 | assert message4.owner.id == 97938 52 | assert message4.text_content == "and again!" 53 | assert message4.parent is None 54 | 55 | message5 = client.get_message(15359293) 56 | message5.scrape_transcript = 'NOT EXPECTED TO BE CALLED' 57 | message5.scrape_history() 58 | 59 | assert message5.edited 60 | assert message5.edits == 1 61 | # assert message5.pinned 62 | # assert message5.pins == 1 63 | # assert set(p.id for p in message5.pinners) == {1251} 64 | assert message5.editor.id == 97938 65 | 66 | message6 = client.get_message(15493758) 67 | message6.scrape_transcript = 'NOT EXPECTED TO BE CALLED' 68 | message6.scrape_history() 69 | 70 | assert not message6.edited 71 | assert message6.edits == 0 72 | assert message6.editor is None 73 | # assert message6.pinned 74 | # assert message6.pins == 2 75 | # assert set(p.id for p in message6.pinners) == {1251, 97938} 76 | -------------------------------------------------------------------------------- /tests/test_openid_login.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from chatexchange.browser import Browser, LoginError 6 | 7 | from tests import live_testing 8 | 9 | 10 | if live_testing.enabled: 11 | def test_openid_login_recognizes_failure(): 12 | """ 13 | Tests that failed SE OpenID logins raise errors. 14 | """ 15 | browser = Browser() 16 | 17 | # avoid hitting the SE servers too frequently 18 | time.sleep(2) 19 | 20 | with pytest.raises(LoginError): 21 | invalid_password = 'no' + 't' * len(live_testing.password) 22 | 23 | browser.login_se_openid( 24 | live_testing.email, 25 | invalid_password) 26 | -------------------------------------------------------------------------------- /tests/test_rooms.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | if sys.version_info[:2] <= (2, 6): 4 | logging.Logger.getChild = lambda self, suffix:\ 5 | self.manager.getLogger('.'.join((self.name, suffix)) if self.root is not self else suffix) 6 | 7 | import chatexchange 8 | from chatexchange.events import MessageEdited 9 | 10 | from tests import live_testing 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | if live_testing.enabled: 17 | def test_room_info(): 18 | client = chatexchange.Client('stackexchange.com') 19 | 20 | a_feeds_user = client.get_user(-2) 21 | bot_user = client.get_user(97938) 22 | sandbox = client.get_room(14219) 23 | 24 | assert bot_user in sandbox.owners 25 | assert a_feeds_user not in sandbox.owners 26 | assert sandbox.user_count >= 4 27 | assert sandbox.message_count >= 10 28 | assert 'test' in sandbox.tags 29 | 30 | # we aren't checking these result, just that it doesn't blow up 31 | sandbox.description 32 | sandbox.text_description 33 | sandbox.parent_site_name + sandbox.name 34 | 35 | def test_room_iterators(): 36 | client = chatexchange.Client( 37 | 'stackexchange.com', live_testing.email, live_testing.password) 38 | 39 | me = client.get_me() 40 | sandbox = client.get_room(14219) 41 | 42 | my_message = None 43 | 44 | with sandbox.new_messages() as messages: 45 | sandbox.send_message("hello worl") 46 | 47 | for message in messages: 48 | if message.owner is me: 49 | my_message = message 50 | assert my_message.content == "hello worl" 51 | break 52 | else: 53 | logger.info("ignoring message: %r", message) 54 | 55 | with sandbox.new_events(MessageEdited) as edits: 56 | my_message.edit("hello world") 57 | 58 | for edit in edits: 59 | assert isinstance(edit, MessageEdited) 60 | 61 | if edit.message is my_message: 62 | assert my_message.content == "hello world" 63 | break 64 | -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | import chatexchange 2 | 3 | from tests import live_testing 4 | 5 | 6 | if live_testing.enabled: 7 | def test_user_info(): 8 | client = chatexchange.Client('stackexchange.com') 9 | 10 | user = client.get_user(-2) 11 | assert user.id == -2 12 | assert not user.is_moderator 13 | assert user.name == "Stack Exchange" 14 | assert user.room_count >= 18 15 | assert user.message_count >= 129810 16 | assert user.reputation == -1 17 | 18 | user = client.get_user(31768) 19 | assert user.id == 31768 20 | assert user.is_moderator 21 | assert user.name == "ManishEarth" 22 | assert user.room_count >= 222 23 | assert user.message_count >= 89093 24 | assert user.reputation > 115000 25 | 26 | user = client.get_user(-5) 27 | assert user.id == -5 28 | assert not user.is_moderator 29 | assert user.last_seen == -1 30 | assert user.reputation == -1 31 | --------------------------------------------------------------------------------