├── .gitignore ├── .idea └── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── LICENSE ├── NOTES ├── README.md ├── neb.py ├── neb ├── __init__.py ├── engine.py ├── matrix.py ├── plugins.py └── webhook.py ├── plugins ├── __init__.py ├── b64.py ├── github.py ├── guess_number.py ├── jenkins.py ├── jira.py ├── prometheus.py ├── time_utils.py └── url.py ├── prometheus.json └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # NEB/plugin related config files 2 | *.json 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | bin/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # Installer logs 29 | pip-log.txt 30 | pip-delete-this-directory.txt 31 | 32 | # Unit test / coverage reports 33 | htmlcov/ 34 | .tox/ 35 | .coverage 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | 40 | # Translations 41 | *.mo 42 | 43 | # Mr Developer 44 | .mr.developer.cfg 45 | .project 46 | .pydevproject 47 | 48 | # Rope 49 | .ropeproject 50 | 51 | # Django stuff: 52 | *.log 53 | *.pot 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /NOTES: -------------------------------------------------------------------------------- 1 | This file contains general notes for things which need to be done. 2 | 3 | Plugins 4 | ------- 5 | - Add helper classes for plugins (many plugins will pull from A to dump in B, and 6 | this can be factored out) 7 | - Store ALL THE STATE in rooms, there shouldn't be config files lying around. 8 | - Make rooms read-only where they need to be. 9 | - Allow certain users to do special things? (op user list) 10 | 11 | Potential APIs 12 | -------------- 13 | - Remote social/news: RSS, Digg, Reddit, Pinterest, Tumblr, Facebook, G+, BBC(?) 14 | - Remote computation: Google, Wolfram Alpha, Dictionary/Thesaurus, Currency conversions, Weather, Transport info 15 | - Local computation: Conversion of units (lb/kg, oct/hex/dec, etc) 16 | - Randoms: Urban define, XKCD, random small games (rock/paper/scissors), emotes e.g. !fluffle 17 | - Chat integration: IRC 18 | 19 | 20 | Protocol considerations 21 | ----------------------- 22 | - Keep text / images / etc as standard m. formats 23 | - Custom state events per plugin (to store plugin specific info, namespaced) 24 | - Wary about introducing new message event types, maybe a neb.command so they 25 | can be silenced from other users? 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | N E Bot 2 | ======= 3 | 4 | This is a generic client bot for Matrix which supports plugins. 5 | 6 | Setup 7 | ===== 8 | Run: 9 | 10 | python neb.py -c 11 | 12 | If the config file cannot be found, you will be asked to enter in the home server URL, 13 | user ID and access token which will then be stored at this location. 14 | 15 | Create a room and invite NEB to it, and then type ``!help`` for a list of valid commands. 16 | 17 | 18 | Plugins 19 | ======= 20 | 21 | Github 22 | ------ 23 | - Processes webhook requests and send messages to interested rooms. 24 | - Supports secret token HMAC authentication. 25 | - Supported events: ``push``, ``create``, ``ping``, ``pull_request`` 26 | 27 | Jenkins 28 | ------- 29 | - Sends build failure messages to interested rooms. 30 | - Support via the Notification plugin. 31 | - Supports shared secret authentication. 32 | 33 | JIRA 34 | ---- 35 | - Processes webhook requests and sends messages to interested rooms. 36 | - Resolves JIRA issue IDs into one-line summaries as they are mentioned by other people. 37 | 38 | Guess Number 39 | ------------ 40 | - Basic guess-the-number game. 41 | 42 | URL 43 | --- 44 | - Provides URL encoding/decoding. 45 | 46 | B64 47 | --- 48 | - Provides base64 encoding/decoding. 49 | -------------------------------------------------------------------------------- /neb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | 4 | from matrix_client.api import MatrixHttpApi 5 | from neb.engine import Engine 6 | from neb.matrix import MatrixConfig 7 | from plugins.b64 import Base64Plugin 8 | from plugins.guess_number import GuessNumberPlugin 9 | from plugins.jenkins import JenkinsPlugin 10 | from plugins.jira import JiraPlugin 11 | from plugins.url import UrlPlugin 12 | from plugins.time_utils import TimePlugin 13 | from plugins.github import GithubPlugin 14 | from plugins.prometheus import PrometheusPlugin 15 | 16 | import logging 17 | import logging.handlers 18 | import time 19 | 20 | log = logging.getLogger(name=__name__) 21 | 22 | # TODO: 23 | # - Add utility plugins in neb package to do things like "invite x to room y"? 24 | # - Add other plugins as tests of plugin architecture (e.g. anagrams, dictionary lookup, etc) 25 | 26 | 27 | def generate_config(url, username, token, config_loc): 28 | config = MatrixConfig( 29 | hs_url=url, 30 | user_id=username, 31 | access_token=token, 32 | admins=[] 33 | ) 34 | save_config(config_loc, config) 35 | return config 36 | 37 | 38 | def save_config(loc, config): 39 | with open(loc, 'w') as f: 40 | MatrixConfig.to_file(config, f) 41 | 42 | 43 | def load_config(loc): 44 | try: 45 | with open(loc, 'r') as f: 46 | return MatrixConfig.from_file(f) 47 | except: 48 | pass 49 | 50 | 51 | def configure_logging(logfile): 52 | log_format = "%(asctime)s %(levelname)s: %(message)s" 53 | logging.basicConfig( 54 | level=logging.DEBUG, 55 | format=log_format 56 | ) 57 | 58 | if logfile: 59 | formatter = logging.Formatter(log_format) 60 | 61 | # rotate logs (20MB, max 6 = 120MB) 62 | handler = logging.handlers.RotatingFileHandler( 63 | logfile, maxBytes=(1000 * 1000 * 20), backupCount=5) 64 | handler.setFormatter(formatter) 65 | logging.getLogger('').addHandler(handler) 66 | 67 | 68 | def main(config): 69 | # setup api/endpoint 70 | matrix = MatrixHttpApi(config.base_url, config.token) 71 | 72 | log.debug("Setting up plugins...") 73 | plugins = [ 74 | TimePlugin, 75 | Base64Plugin, 76 | GuessNumberPlugin, 77 | JiraPlugin, 78 | UrlPlugin, 79 | GithubPlugin, 80 | JenkinsPlugin, 81 | PrometheusPlugin, 82 | ] 83 | 84 | # setup engine 85 | engine = Engine(matrix, config) 86 | for plugin in plugins: 87 | engine.add_plugin(plugin) 88 | 89 | engine.setup() 90 | 91 | while True: 92 | try: 93 | log.info("Listening for incoming events.") 94 | engine.event_loop() 95 | except Exception as e: 96 | log.error("Ruh roh: %s", e) 97 | time.sleep(5) 98 | 99 | log.info("Terminating.") 100 | 101 | 102 | if __name__ == '__main__': 103 | a = argparse.ArgumentParser("Runs NEB. See plugins for commands.") 104 | a.add_argument( 105 | "-c", "--config", dest="config", 106 | help="The config to create or read from." 107 | ) 108 | a.add_argument( 109 | "-l", "--log-file", dest="log", 110 | help="Log to this file." 111 | ) 112 | args = a.parse_args() 113 | 114 | configure_logging(args.log) 115 | log.info(" ===== NEB initialising ===== ") 116 | 117 | config = None 118 | if args.config: 119 | log.info("Loading config from %s", args.config) 120 | config = load_config(args.config) 121 | if not config: 122 | log.info("Setting up for an existing account.") 123 | print "Config file could not be loaded." 124 | print ("NEB works with an existing Matrix account. " 125 | "Please set up an account for NEB if you haven't already.'") 126 | print "The config for this account will be saved to '%s'" % args.config 127 | hsurl = raw_input("Home server URL (e.g. http://localhost:8008): ").strip() 128 | if hsurl.endswith("/"): 129 | hsurl = hsurl[:-1] 130 | username = raw_input("Full user ID (e.g. @user:domain): ").strip() 131 | token = raw_input("Access token: ").strip() 132 | config = generate_config(hsurl, username, token, args.config) 133 | else: 134 | a.print_help() 135 | print "You probably want to run 'python neb.py -c neb.config'" 136 | 137 | if config: 138 | main(config) 139 | -------------------------------------------------------------------------------- /neb/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class NebError(Exception): 4 | """A standard NEB error, which can be sent back to the user.""" 5 | 6 | def __init__(self, code=0, msg=""): 7 | Exception.__init__(self, msg) 8 | self.code = code 9 | self.msg = msg 10 | 11 | def as_str(self): 12 | return "(%s) : %s" % (self.code, self.msg) -------------------------------------------------------------------------------- /neb/engine.py: -------------------------------------------------------------------------------- 1 | from matrix_client.api import MatrixRequestError 2 | from neb import NebError 3 | from neb.plugins import CommandNotFoundError 4 | from neb.webhook import NebHookServer 5 | 6 | import json 7 | import logging as log 8 | import pprint 9 | 10 | 11 | class Engine(object): 12 | """Orchestrates plugins and the matrix API/endpoints.""" 13 | PREFIX = "!" 14 | 15 | def __init__(self, matrix_api, config): 16 | self.plugin_cls = {} 17 | self.plugins = {} 18 | self.config = config 19 | self.matrix = matrix_api 20 | self.sync_token = None # set later by initial sync 21 | 22 | def setup(self): 23 | self.webhook = NebHookServer(8500) 24 | self.webhook.daemon = True 25 | self.webhook.start() 26 | 27 | # init the plugins 28 | for cls_name in self.plugin_cls: 29 | self.plugins[cls_name] = self.plugin_cls[cls_name]( 30 | self.matrix, 31 | self.config, 32 | self.webhook 33 | ) 34 | 35 | sync = self.matrix.sync(timeout_ms=30000, since=self.sync_token) 36 | self.parse_sync(sync, initial_sync=True) 37 | log.debug("Notifying plugins of initial sync results") 38 | for plugin_name in self.plugins: 39 | plugin = self.plugins[plugin_name] 40 | plugin.on_sync(sync) 41 | 42 | # see if this plugin needs a webhook 43 | if plugin.get_webhook_key(): 44 | self.webhook.set_plugin(plugin.get_webhook_key(), plugin) 45 | 46 | def _help(self): 47 | return ( 48 | "Installed plugins: %s - Type '%shelp ' for more." % 49 | (self.plugins.keys(), Engine.PREFIX) 50 | ) 51 | 52 | def add_plugin(self, plugin): 53 | log.debug("add_plugin %s", plugin) 54 | if not plugin.name: 55 | raise NebError("No name for plugin %s" % plugin) 56 | 57 | self.plugin_cls[plugin.name] = plugin 58 | 59 | def parse_membership(self, event): 60 | log.info("Parsing membership: %s", event) 61 | if (event["state_key"] == self.config.user_id 62 | and event["content"]["membership"] == "invite"): 63 | user_id = event["sender"] 64 | if user_id in self.config.admins: 65 | self.matrix.join_room(event["room_id"]) 66 | else: 67 | log.info( 68 | "Refusing invite, %s not in admin list. Event: %s", 69 | user_id, event 70 | ) 71 | 72 | def parse_msg(self, event): 73 | body = event["content"]["body"] 74 | if (event["sender"] == self.config.user_id or 75 | event["content"]["msgtype"] == "m.notice"): 76 | return 77 | if body.startswith(Engine.PREFIX): 78 | room = event["room_id"] # room_id added by us 79 | try: 80 | segments = body.split() 81 | cmd = segments[0][1:] 82 | if self.config.case_insensitive: 83 | cmd = cmd.lower() 84 | 85 | if cmd == "help": 86 | if len(segments) == 2 and segments[1] in self.plugins: 87 | # return help on a plugin 88 | self.matrix.send_message( 89 | room, 90 | self.plugins[segments[1]].__doc__, 91 | msgtype="m.notice" 92 | ) 93 | else: 94 | # return generic help 95 | self.matrix.send_message(room, self._help(), msgtype="m.notice") 96 | elif cmd in self.plugins: 97 | plugin = self.plugins[cmd] 98 | responses = None 99 | 100 | try: 101 | responses = plugin.run( 102 | event, 103 | unicode(" ".join(body.split()[1:]).encode("utf8")) 104 | ) 105 | except CommandNotFoundError as e: 106 | self.matrix.send_message( 107 | room, 108 | str(e), 109 | msgtype="m.notice" 110 | ) 111 | except MatrixRequestError as ex: 112 | self.matrix.send_message( 113 | room, 114 | "Problem making request: (%s) %s" % (ex.code, ex.content), 115 | msgtype="m.notice" 116 | ) 117 | 118 | if responses: 119 | log.debug("[Plugin-%s] Response => %s", cmd, responses) 120 | if type(responses) == list: 121 | for res in responses: 122 | if type(res) in [str, unicode]: 123 | self.matrix.send_message( 124 | room, 125 | res, 126 | msgtype="m.notice" 127 | ) 128 | else: 129 | self.matrix.send_message_event( 130 | room, "m.room.message", res 131 | ) 132 | elif type(responses) in [str, unicode]: 133 | self.matrix.send_message( 134 | room, 135 | responses, 136 | msgtype="m.notice" 137 | ) 138 | else: 139 | self.matrix.send_message_event( 140 | room, "m.room.message", responses 141 | ) 142 | except NebError as ne: 143 | self.matrix.send_message(room, ne.as_str(), msgtype="m.notice") 144 | except Exception as e: 145 | log.exception(e) 146 | self.matrix.send_message( 147 | room, 148 | "Fatal error when processing command.", 149 | msgtype="m.notice" 150 | ) 151 | else: 152 | try: 153 | for p in self.plugins: 154 | self.plugins[p].on_msg(event, body) 155 | except Exception as e: 156 | log.exception(e) 157 | 158 | def event_proc(self, event): 159 | etype = event["type"] 160 | switch = { 161 | "m.room.member": self.parse_membership, 162 | "m.room.message": self.parse_msg 163 | } 164 | try: 165 | switch[etype](event) 166 | except KeyError: 167 | try: 168 | for p in self.plugins: 169 | self.plugins[p].on_event(event, etype) 170 | except Exception as e: 171 | log.exception(e) 172 | except Exception as e: 173 | log.error("Couldn't process event: %s", e) 174 | 175 | def event_loop(self): 176 | while True: 177 | j = self.matrix.sync(timeout_ms=30000, since=self.sync_token) 178 | self.parse_sync(j) 179 | 180 | def parse_sync(self, sync_result, initial_sync=False): 181 | self.sync_token = sync_result["next_batch"] # for when we start syncing 182 | 183 | # check invited rooms 184 | rooms = sync_result["rooms"]["invite"] 185 | for room_id in rooms: 186 | events = rooms[room_id]["invite_state"]["events"] 187 | self.process_events(events, room_id) 188 | 189 | # return early if we're performing an initial sync (ie: don't parse joined rooms, just drop the state) 190 | if initial_sync: 191 | return 192 | 193 | # check joined rooms 194 | rooms = sync_result["rooms"]["join"] 195 | for room_id in rooms: 196 | events = rooms[room_id]["timeline"]["events"] 197 | self.process_events(events, room_id) 198 | 199 | def process_events(self, events, room_id): 200 | for event in events: 201 | event["room_id"] = room_id 202 | self.event_proc(event) 203 | 204 | 205 | class RoomContextStore(object): 206 | """Stores state events for rooms.""" 207 | 208 | def __init__(self, event_types, content_only=True): 209 | """Init the store. 210 | 211 | Args: 212 | event_types(list): The state event types to store. 213 | content_only(bool): True to only store the content for state events. 214 | """ 215 | self.state = {} 216 | self.types = event_types 217 | self.content_only = content_only 218 | 219 | def get_content(self, room_id, event_type, key=""): 220 | if self.content_only: 221 | return self.state[room_id][(event_type, key)] 222 | else: 223 | return self.state[room_id][(event_type, key)]["content"] 224 | 225 | def get_room_ids(self): 226 | return self.state.keys() 227 | 228 | def update(self, event): 229 | try: 230 | room_id = event["room_id"] 231 | etype = event["type"] 232 | if etype in self.types: 233 | if room_id not in self.state: 234 | self.state[room_id] = {} 235 | key = (etype, event["state_key"]) 236 | 237 | s = event 238 | if self.content_only: 239 | s = event["content"] 240 | 241 | self.state[room_id][key] = s 242 | except KeyError: 243 | pass 244 | 245 | def init_from_sync(self, sync): 246 | for room_id in sync["rooms"]["join"]: 247 | # see if we know anything about these rooms 248 | room = sync["rooms"]["join"][room_id] 249 | 250 | self.state[room_id] = {} 251 | 252 | try: 253 | for state in room["state"]["events"]: 254 | if state["type"] in self.types: 255 | key = (state["type"], state["state_key"]) 256 | 257 | s = state 258 | if self.content_only: 259 | s = state["content"] 260 | 261 | self.state[room_id][key] = s 262 | except KeyError: 263 | pass 264 | 265 | log.debug(pprint.pformat(self.state)) 266 | 267 | 268 | class KeyValueStore(object): 269 | """A persistent JSON store.""" 270 | 271 | def __init__(self, config_loc, version="1"): 272 | self.config = { 273 | "version": version 274 | } 275 | self.config_loc = config_loc 276 | self._load() 277 | 278 | def _load(self): 279 | try: 280 | with open(self.config_loc, 'r') as f: 281 | self.config = json.loads(f.read()) 282 | except: 283 | self._save() 284 | 285 | def _save(self): 286 | with open(self.config_loc, 'w') as f: 287 | f.write(json.dumps(self.config, indent=4)) 288 | 289 | def has(self, key): 290 | return key in self.config 291 | 292 | def set(self, key, value, save=True): 293 | self.config[key] = value 294 | if save: 295 | self._save() 296 | 297 | def get(self, key): 298 | return self.config[key] 299 | -------------------------------------------------------------------------------- /neb/matrix.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import logging as log 4 | 5 | 6 | class MatrixConfig(object): 7 | URL = "url" 8 | USR = "user" 9 | TOK = "token" 10 | ADM = "admins" 11 | CIS = "case_insensitive" 12 | 13 | def __init__(self, hs_url, user_id, access_token, admins, case_insensitive): 14 | self.user_id = user_id 15 | self.token = access_token 16 | self.base_url = hs_url 17 | self.admins = admins 18 | self.case_insensitive = case_insensitive 19 | 20 | @classmethod 21 | def to_file(cls, config, f): 22 | f.write(json.dumps({ 23 | MatrixConfig.URL: config.base_url, 24 | MatrixConfig.TOK: config.token, 25 | MatrixConfig.USR: config.user_id, 26 | MatrixConfig.ADM: config.admins, 27 | MatrixConfig.CIS: config.case_insensitive 28 | }, indent=4)) 29 | 30 | @classmethod 31 | def from_file(cls, f): 32 | j = json.load(f) 33 | 34 | # convert old 0.0.1 matrix-python-sdk urls to 0.0.3+ 35 | hs_url = j[MatrixConfig.URL] 36 | if hs_url.endswith("/_matrix/client/api/v1"): 37 | hs_url = hs_url[:-22] 38 | log.info("Detected legacy URL, using '%s' instead. Consider changing this in your configuration." % hs_url) 39 | 40 | return MatrixConfig( 41 | hs_url=hs_url, 42 | user_id=j[MatrixConfig.USR], 43 | access_token=j[MatrixConfig.TOK], 44 | admins=j[MatrixConfig.ADM], 45 | case_insensitive=j[MatrixConfig.CIS] if MatrixConfig.CIS in j else False 46 | ) 47 | -------------------------------------------------------------------------------- /neb/plugins.py: -------------------------------------------------------------------------------- 1 | # Notes: 2 | # matrix_endpoint 3 | # listen_for(["event.type", "event.type"]) 4 | # web_hook_server 5 | # register_hook("name", cb_fn) 6 | # matrix_api 7 | # send_event(foo, bar) 8 | # send_message(foo, bar) 9 | 10 | from functools import wraps 11 | import inspect 12 | import json 13 | import shlex 14 | 15 | import logging as log 16 | 17 | 18 | def admin_only(fn): 19 | @wraps(fn) 20 | def wrapped(*args, **kwargs): 21 | config = args[0].config 22 | event = args[1] 23 | if event["sender"] not in config.admins: 24 | return "Sorry, only %s can do that." % json.dumps(config.admins) 25 | result = fn(*args, **kwargs) 26 | return result 27 | return wrapped 28 | 29 | 30 | class CommandNotFoundError(Exception): 31 | pass 32 | 33 | 34 | class PluginInterface(object): 35 | 36 | def __init__(self, matrix_api, config, web_hook_server): 37 | self.matrix = matrix_api 38 | self.config = config 39 | self.webhook = web_hook_server 40 | 41 | def run(self, event, arg_str): 42 | """Run the requested command. 43 | 44 | Args: 45 | event(dict): The raw event 46 | arg_str(list): The parsed arguments from the event 47 | Returns: 48 | str: The message to respond with. 49 | list: The messages to respond with. 50 | dict : The m.room.message content to respond with. 51 | """ 52 | pass 53 | 54 | def on_sync(self, response): 55 | """Received initial sync results. 56 | 57 | Args: 58 | response(dict): The raw initialSync response. 59 | """ 60 | pass 61 | 62 | def on_event(self, event, event_type): 63 | """Received an event. 64 | 65 | Args: 66 | event(dict): The raw event 67 | event_type(str): The event type 68 | """ 69 | pass 70 | 71 | def on_msg(self, event, body): 72 | """Received an m.room.message event.""" 73 | pass 74 | 75 | def get_webhook_key(self): 76 | """Return a string for a webhook path if a webhook is required.""" 77 | pass 78 | 79 | def on_receive_webhook(self, data, ip, headers): 80 | """Someone hit your webhook. 81 | 82 | Args: 83 | data(str): The request body 84 | ip(str): The source IP address 85 | headers: A dict of headers (via .get("headername")) 86 | Returns: 87 | A tuple of (response_body, http_status_code, header_dict) or None 88 | to return a 200 OK. Raise an exception to return a 500. 89 | """ 90 | pass 91 | 92 | 93 | class Plugin(PluginInterface): 94 | 95 | def run(self, event, arg_str): 96 | args_array = [arg_str.encode("utf8")] 97 | try: 98 | args_array = shlex.split(arg_str.encode("utf8")) 99 | except ValueError: 100 | pass # may be 1 arg without need for quotes 101 | 102 | if len(args_array) == 0: 103 | raise CommandNotFoundError(self.__doc__) 104 | 105 | # Structure is cmd_foo_bar_baz for "!foo bar baz" 106 | # This starts by assuming a no-arg cmd then getting progressively 107 | # more general until no args remain (in which case there isn't a match) 108 | for index, arg in enumerate(args_array): 109 | possible_method = "cmd_" + "_".join(args_array[:(len(args_array) - index)]) 110 | if self.config.case_insensitive: 111 | possible_method = possible_method.lower() 112 | if hasattr(self, possible_method): 113 | method = getattr(self, possible_method) 114 | remaining_args = [event] + args_array[len(args_array) - index:] 115 | 116 | # function params prefixed with "opt_" should be None if they 117 | # are not specified. This makes cmd definitions a lot nicer for 118 | # plugins rather than a generic arg array or no optional extras 119 | fn_param_names = inspect.getargspec(method)[0][1:] # remove self 120 | if len(fn_param_names) > len(remaining_args): 121 | # pad out the ones at the END marked "opt_" with None 122 | for i in reversed(fn_param_names): 123 | if i.startswith("opt_"): 124 | remaining_args.append(None) 125 | else: 126 | break 127 | 128 | try: 129 | if remaining_args: 130 | return method(*remaining_args) 131 | else: 132 | return method() 133 | except TypeError as e: 134 | log.exception(e) 135 | raise CommandNotFoundError(method.__doc__) 136 | 137 | raise CommandNotFoundError("Unknown command") 138 | 139 | 140 | -------------------------------------------------------------------------------- /neb/webhook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Devoted to services which use web hooks. Plugins are identified via the 3 | path being hit, which then delegates to the plugin to process. 4 | """ 5 | from flask import Flask 6 | from flask import request 7 | import threading 8 | 9 | import logging as log 10 | 11 | app = Flask("NebHookServer") 12 | 13 | 14 | class NebHookServer(threading.Thread): 15 | 16 | def __init__(self, port): 17 | super(NebHookServer, self).__init__() 18 | self.port = port 19 | self.plugin_mappings = { 20 | # plugin_key : plugin_instance 21 | } 22 | 23 | app.add_url_rule('/neb/', '/neb/', 24 | self.do_POST, methods=["POST"]) 25 | 26 | def set_plugin(self, key, plugin): 27 | log.info("Registering plugin %s for webhook on /neb/%s" % (plugin, key)) 28 | self.plugin_mappings[key] = plugin 29 | 30 | def do_POST(self, service=""): 31 | log.debug("NebHookServer: Plugin=%s : Incoming request from %s", 32 | service, request.remote_addr) 33 | if service.split("/")[0] not in self.plugin_mappings: 34 | return ("", 404, {}) 35 | 36 | plugin = self.plugin_mappings[service.split("/")[0]] 37 | 38 | try: 39 | # tuple (body, status_code, headers) 40 | response = plugin.on_receive_webhook( 41 | request.url, 42 | request.get_data(), 43 | request.remote_addr, 44 | request.headers 45 | ) 46 | if response: 47 | return response 48 | return ("", 200, {}) 49 | except Exception as e: 50 | log.exception(e) 51 | return ("", 500, {}) 52 | 53 | def notify_plugin(self, content): 54 | self.plugin.on_receive_github_push(content) 55 | 56 | def run(self): 57 | log.info("Running NebHookServer") 58 | app.run(host="0.0.0.0", port=self.port) 59 | -------------------------------------------------------------------------------- /plugins/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /plugins/b64.py: -------------------------------------------------------------------------------- 1 | from neb.plugins import Plugin 2 | 3 | import base64 4 | 5 | 6 | class Base64Plugin(Plugin): 7 | """Encode or decode base64. 8 | b64 encode : Encode as base64. 9 | b64 decode : Decode and return text. 10 | """ 11 | 12 | name="b64" 13 | 14 | def cmd_encode(self, event, *args): 15 | """Encode as base64. 'b64 encode '""" 16 | # use the body directly so quotes are parsed correctly. 17 | return base64.b64encode(event["content"]["body"][12:]) 18 | 19 | def cmd_decode(self, event, *args): 20 | """Decode from base64. 'b64 decode '""" 21 | # use the body directly so quotes are parsed correctly. 22 | return base64.b64decode(event["content"]["body"][12:]) 23 | 24 | -------------------------------------------------------------------------------- /plugins/github.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from neb.engine import KeyValueStore, RoomContextStore 3 | from neb.plugins import Plugin, admin_only 4 | 5 | from hashlib import sha1 6 | import hmac 7 | import json 8 | import requests 9 | 10 | import logging as log 11 | 12 | 13 | class GithubPlugin(Plugin): 14 | """Plugin for interacting with Github. 15 | github show projects : Show which github projects this bot recognises. 16 | github show track|tracking : Show which projects are being tracked. 17 | github track "owner/repo" "owner/repo" : Track the given projects. 18 | github add owner/repo : Add the given repo to the tracking list. 19 | github remove owner/repo : Remove the given repo from the tracking list. 20 | github stop track|tracking : Stop tracking github projects. 21 | github create owner/repo "Bug title" "Bug desc" : Create an issue on Github. 22 | github label add|remove owner/repo issue# label : Label an issue on Github. 23 | """ 24 | name = "github" 25 | #New events: 26 | # Type: org.matrix.neb.plugin.github.projects.tracking 27 | # State: Yes 28 | # Content: { 29 | # projects: [projectName1, projectName2, ...] 30 | # } 31 | 32 | #Webhooks: 33 | # /neb/github 34 | TYPE_TRACK = "org.matrix.neb.plugin.github.projects.tracking" 35 | TYPE_COLOR = "org.matrix.neb.plugin.github.projects.color" 36 | 37 | TRACKING = ["track", "tracking"] 38 | 39 | def __init__(self, *args, **kwargs): 40 | super(GithubPlugin, self).__init__(*args, **kwargs) 41 | self.store = KeyValueStore("github.json") 42 | self.rooms = RoomContextStore( 43 | [GithubPlugin.TYPE_TRACK] 44 | ) 45 | 46 | if not self.store.has("known_projects"): 47 | self.store.set("known_projects", []) 48 | 49 | if not self.store.has("secret_token"): 50 | self.store.set("secret_token", "") 51 | 52 | if not self.store.has("github_access_token"): 53 | log.info("A github access_token is required to create github issues.") 54 | log.info("Issues will be created as this user.") 55 | token = raw_input("(Optional) Github token: ").strip() 56 | if token: 57 | self.store.set("github_access_token", token) 58 | else: 59 | log.info("You will not be able to create Github issues.") 60 | 61 | def on_receive_github_push(self, info): 62 | log.info("recv %s", info) 63 | 64 | # add the project if we didn't know about it before 65 | if info["repo"] not in self.store.get("known_projects"): 66 | log.info("Added new repo: %s", info["repo"]) 67 | projects = self.store.get("known_projects") 68 | projects.append(info["repo"]) 69 | self.store.set("known_projects", projects) 70 | 71 | push_message = "" 72 | 73 | if info["type"] == "delete": 74 | push_message = '[%s] %s deleted %s' % ( 75 | info["repo"], 76 | info["commit_username"], 77 | info["branch"] 78 | ) 79 | elif info["type"] == "commit": 80 | # form the template: 81 | # [] pushed commits to : 82 | # 1<=3 of : 83 | if info["num_commits"] == 1: 84 | push_message = "[%s] %s pushed to %s: %s - %s" % ( 85 | info["repo"], 86 | info["commit_username"], 87 | info["branch"], 88 | info["commit_msg"], 89 | info["commit_link"] 90 | ) 91 | else: 92 | summary = "" 93 | max_commits = 3 94 | count = 0 95 | for c in info["commits_summary"]: 96 | if count == max_commits: 97 | break 98 | summary += "\n%s: %s" % (c["author"], c["summary"]) 99 | count += 1 100 | 101 | push_message = "[%s] %s pushed %s commits to %s: %s %s" % ( 102 | info["repo"], 103 | info["commit_username"], 104 | info["num_commits"], 105 | info["branch"], 106 | info["commit_link"], 107 | summary 108 | ) 109 | else: 110 | log.warn("Unknown push type. %s", info["type"]) 111 | return 112 | 113 | self.send_message_to_repos(info["repo"], push_message) 114 | 115 | def send_message_to_repos(self, repo, push_message): 116 | # send messages to all rooms registered with this project. 117 | for room_id in self.rooms.get_room_ids(): 118 | try: 119 | if repo in self.rooms.get_content(room_id, GithubPlugin.TYPE_TRACK)["projects"]: 120 | self.matrix.send_message_event( 121 | room_id, 122 | "m.room.message", 123 | self.matrix.get_html_body(push_message, msgtype="m.notice") 124 | ) 125 | except KeyError: 126 | pass 127 | 128 | def cmd_show(self, event, action): 129 | """Show information on projects or projects being tracked. 130 | Show which projects are being tracked. 'github show tracking' 131 | Show which proejcts are recognised so they could be tracked. 'github show projects' 132 | """ 133 | if action == "projects": 134 | projects = self.store.get("known_projects") 135 | return "Available projects: %s - To add more projects, you must register a webhook on Github." % json.dumps(projects) 136 | elif action in self.TRACKING: 137 | return self._get_tracking(event["room_id"]) 138 | else: 139 | return self.cmd_show.__doc__ 140 | 141 | @admin_only 142 | def cmd_add(self, event, repo): 143 | """Add a repo for tracking. 'github add owner/repo'""" 144 | if repo not in self.store.get("known_projects"): 145 | return "Unknown project name: %s." % repo 146 | 147 | try: 148 | room_repos = self.rooms.get_content( 149 | event["room_id"], 150 | GithubPlugin.TYPE_TRACK)["projects"] 151 | except KeyError: 152 | room_repos = [] 153 | 154 | if repo in room_repos: 155 | return "%s is already being tracked." % repo 156 | 157 | room_repos.append(repo) 158 | self._send_track_event(event["room_id"], room_repos) 159 | 160 | return "Added %s. Commits for projects %s will be displayed as they are commited." % (repo, room_repos) 161 | 162 | @admin_only 163 | def cmd_remove(self, event, repo): 164 | """Remove a repo from tracking. 'github remove owner/repo'""" 165 | try: 166 | room_repos = self.rooms.get_content( 167 | event["room_id"], 168 | GithubPlugin.TYPE_TRACK)["projects"] 169 | except KeyError: 170 | room_repos = [] 171 | 172 | if repo not in room_repos: 173 | return "Cannot remove %s : It isn't being tracked." % repo 174 | 175 | room_repos.remove(repo) 176 | self._send_track_event(event["room_id"], room_repos) 177 | 178 | return "Removed %s. Commits for projects %s will be displayed as they are commited." % (repo, room_repos) 179 | 180 | @admin_only 181 | def cmd_track(self, event, *args): 182 | if len(args) == 0: 183 | return self._get_tracking(event["room_id"]) 184 | 185 | for project in args: 186 | if not project in self.store.get("known_projects"): 187 | return "Unknown project name: %s." % project 188 | 189 | self._send_track_event(event["room_id"], args) 190 | 191 | return "Commits for projects %s will be displayed as they are commited." % (args,) 192 | 193 | @admin_only 194 | def cmd_stop(self, event, action): 195 | """Stop tracking projects. 'github stop tracking'""" 196 | if action in self.TRACKING: 197 | self._send_track_event(event["room_id"], []) 198 | return "Stopped tracking projects." 199 | else: 200 | return self.cmd_stop.__doc__ 201 | 202 | @admin_only 203 | def cmd_create(self, event, *args): 204 | """Create a new issue. Format: 'create <desc(optional)>' 205 | E.g. 'create matrix-org/synapse A bug goes here 206 | 'create matrix-org/synapse "Title here" "desc here" """ 207 | if not args or len(args) < 2: 208 | return self.cmd_create.__doc__ 209 | project = args[0] 210 | others = args[1:] 211 | # others must contain a title, may contain a description. If it contains 212 | # a description, it MUST be in [1] and be longer than 1 word. 213 | title = ' '.join(others) 214 | desc = "" 215 | try: 216 | possible_desc = others[1] 217 | if ' ' in possible_desc: 218 | desc = possible_desc 219 | title = others[0] 220 | except: 221 | pass 222 | 223 | return self._create_issue( 224 | event["user_id"], project, title, desc 225 | ) 226 | 227 | @admin_only 228 | def cmd_label_remove(self, event, repo, issue_num, *args): 229 | """Remove a label on an issue. Format: 'label remove <owner/repo> <issue num> <label> <label> <label>' 230 | E.g. 'label remove matrix-org/synapse 323 bug p2 blocked' 231 | """ 232 | e = self._is_valid_issue_request(repo, issue_num) 233 | if e: 234 | return e 235 | if len(args) == 0: 236 | return "You must specify at least one label." 237 | 238 | errs = [] 239 | 240 | for label in args: 241 | url = "https://api.github.com/repos/%s/issues/%s/labels/%s" % (repo, issue_num, label) 242 | res = requests.delete(url, headers={ 243 | "Authorization": "token %s" % self.store.get("github_access_token") 244 | }) 245 | if res.status_code < 200 or res.status_code >= 300: 246 | errs.append( 247 | "Problem removing label %s : HTTP %s" % (label, res.status_code) 248 | ) 249 | return err 250 | 251 | if len(errs) == 0: 252 | return "Removed labels %s" % (json.dumps(args),) 253 | else: 254 | return "There was a problem removing some labels:\n" + "\n".join(errs) 255 | 256 | @admin_only 257 | def cmd_label_add(self, event, repo, issue_num, *args): 258 | """Label an issue. Format: 'label add <owner/repo> <issue num> <label> <label> <label>' 259 | E.g. 'label add matrix-org/synapse 323 bug p2 blocked' 260 | """ 261 | e = self._is_valid_issue_request(repo, issue_num) 262 | if e: 263 | return e 264 | if len(args) == 0: 265 | return "You must specify at least one label." 266 | 267 | url = "https://api.github.com/repos/%s/issues/%s/labels" % (repo, issue_num) 268 | res = requests.post(url, data=json.dumps(args), headers={ 269 | "Content-Type": "application/json", 270 | "Authorization": "token %s" % self.store.get("github_access_token") 271 | }) 272 | if res.status_code < 200 or res.status_code >= 300: 273 | err = "%s Failed: HTTP %s" % (url, res.status_code,) 274 | log.error(err) 275 | return err 276 | 277 | return "Added labels %s" % (json.dumps(args),) 278 | 279 | def _create_issue(self, user_id, project, title, desc=""): 280 | if not self.store.has("github_access_token"): 281 | return "This plugin isn't configured to create Github issues." 282 | 283 | # Add a space after the @ to avoid pinging people on Github! 284 | user_id = user_id.replace("@", "@ ") 285 | 286 | desc = "Created by %s.\n\n%s" % (user_id, desc) 287 | info = { 288 | "title": title, 289 | "body": desc 290 | } 291 | 292 | url = "https://api.github.com/repos/%s/issues" % project 293 | res = requests.post(url, data=json.dumps(info), headers={ 294 | "Content-Type": "application/json", 295 | "Authorization": "token %s" % self.store.get("github_access_token") 296 | }) 297 | if res.status_code < 200 or res.status_code >= 300: 298 | err = "%s Failed: HTTP %s" % (url, res.status_code,) 299 | log.error(err) 300 | return err 301 | 302 | response = json.loads(res.text) 303 | return "Created issue: %s" % response["html_url"] 304 | 305 | def _is_valid_issue_request(self, repo, issue_num): 306 | issue_is_num = True 307 | try: 308 | issue_is_num = int(issue_num) 309 | except ValueError: 310 | issue_is_num = False 311 | 312 | if "/" not in repo: 313 | return "Repo must be in the form 'owner/repo' e.g. 'matrix-org/synapse'." 314 | 315 | if not issue_is_num: 316 | return "Issue number must be a number" 317 | 318 | if not self.store.has("github_access_token"): 319 | return "This plugin isn't configured to interact with Github issues." 320 | 321 | def _send_track_event(self, room_id, project_names): 322 | self.matrix.send_state_event( 323 | room_id, 324 | self.TYPE_TRACK, 325 | { 326 | "projects": project_names 327 | } 328 | ) 329 | 330 | def _get_tracking(self, room_id): 331 | try: 332 | return ("Currently tracking %s" % json.dumps( 333 | self.rooms.get_content(room_id, GithubPlugin.TYPE_TRACK)["projects"] 334 | )) 335 | except KeyError: 336 | return "Not tracking any projects currently." 337 | 338 | def on_event(self, event, event_type): 339 | self.rooms.update(event) 340 | 341 | def on_sync(self, sync): 342 | log.debug("Plugin: Github sync state:") 343 | self.rooms.init_from_sync(sync) 344 | 345 | def get_webhook_key(self): 346 | return "github" 347 | 348 | def on_receive_pull_request(self, data): 349 | action = data["action"] 350 | pull_req_num = data["number"] 351 | repo_name = data["repository"]["full_name"] 352 | pr = data["pull_request"] 353 | pr_url = pr["html_url"] 354 | pr_state = pr["state"] 355 | pr_title = pr["title"] 356 | 357 | user = data["sender"]["login"] 358 | 359 | action_target = "" 360 | if pr.get("assignee") and pr["assignee"].get("login"): 361 | action_target = " to %s" % (pr["assignee"]["login"],) 362 | 363 | msg = "[<u>%s</u>] %s %s <b>pull request #%s</b>: %s [%s]%s - %s" % ( 364 | repo_name, 365 | user, 366 | action, 367 | pull_req_num, 368 | pr_title, 369 | pr_state, 370 | action_target, 371 | pr_url 372 | ) 373 | 374 | self.send_message_to_repos(repo_name, msg) 375 | 376 | def on_receive_create(self, data): 377 | if data["ref_type"] != "branch": 378 | return # only echo branch creations for now. 379 | 380 | branch_name = data["ref"] 381 | user = data["sender"]["login"] 382 | repo_name = data["repository"]["full_name"] 383 | 384 | msg = '[<u>%s</u>] %s <font color="green">created</font> a new branch: <b>%s</b>' % ( 385 | repo_name, 386 | user, 387 | branch_name 388 | ) 389 | 390 | self.send_message_to_repos(repo_name, msg) 391 | 392 | def on_receive_ping(self, data): 393 | repo_name = data.get("repository", {}).get("full_name") 394 | # add the project if we didn't know about it before 395 | if repo_name and repo_name not in self.store.get("known_projects"): 396 | log.info("Added new repo: %s", repo_name) 397 | projects = self.store.get("known_projects") 398 | projects.append(repo_name) 399 | self.store.set("known_projects", projects) 400 | 401 | def on_receive_comment(self, data): 402 | repo_name = data["repository"]["full_name"] 403 | issue = data["issue"] 404 | comment = data["comment"] 405 | is_pull_request = "pull_request" in issue 406 | if not is_pull_request: 407 | return # don't bother displaying issue comments 408 | 409 | pr_title = issue["title"] 410 | pr_num = issue["number"] 411 | pr_username = issue["user"]["login"] 412 | comment_url = comment["html_url"] 413 | username = comment["user"]["login"] 414 | 415 | msg = "[<u>%s</u>] %s commented on %s's <b>pull request #%s</b>: %s - %s" % ( 416 | repo_name, 417 | username, 418 | pr_username, 419 | pr_num, 420 | pr_title, 421 | comment_url 422 | ) 423 | self.send_message_to_repos(repo_name, msg) 424 | 425 | 426 | def on_receive_pull_request_comment(self, data): 427 | repo_name = data["repository"]["full_name"] 428 | username = data["sender"]["login"] 429 | pull_request = data["pull_request"] 430 | pr_username = pull_request["user"]["login"] 431 | pr_num = pull_request["number"] 432 | assignee = "None" 433 | if data["pull_request"].get("assignee"): 434 | assignee = data["pull_request"]["assignee"]["login"] 435 | pr_title = pull_request["title"] 436 | comment_url = data["comment"]["html_url"] 437 | 438 | msg = "[<u>%s</u>] %s made a line comment on %s's <b>pull request #%s</b> (assignee: %s): %s - %s" % ( 439 | repo_name, 440 | username, 441 | pr_username, 442 | pr_num, 443 | assignee, 444 | pr_title, 445 | comment_url 446 | ) 447 | self.send_message_to_repos(repo_name, msg) 448 | 449 | 450 | def on_receive_issue(self, data): 451 | action = data["action"] 452 | repo_name = data["repository"]["full_name"] 453 | issue = data["issue"] 454 | title = issue["title"] 455 | issue_num = issue["number"] 456 | url = issue["html_url"] 457 | 458 | user = data["sender"]["login"] 459 | 460 | if action == "assigned": 461 | try: 462 | assignee = data["assignee"]["login"] 463 | msg = "[<u>%s</u>] %s assigned issue #%s to %s: %s - %s" % ( 464 | repo_name, 465 | user, 466 | issue_num, 467 | assignee, 468 | title, 469 | url 470 | ) 471 | self.send_message_to_repos(repo_name, msg) 472 | return 473 | except: 474 | pass 475 | 476 | 477 | msg = "[<u>%s</u>] %s %s issue #%s: %s - %s" % ( 478 | repo_name, 479 | user, 480 | action, 481 | issue_num, 482 | title, 483 | url 484 | ) 485 | 486 | self.send_message_to_repos(repo_name, msg) 487 | 488 | 489 | def on_receive_webhook(self, url, data, ip, headers): 490 | if self.store.get("secret_token"): 491 | token_sha1 = headers.get('X-Hub-Signature') 492 | payload_body = data 493 | calc = hmac.new(str(self.store.get("secret_token")), payload_body, 494 | sha1) 495 | calc_sha1 = "sha1=" + calc.hexdigest() 496 | if token_sha1 != calc_sha1: 497 | log.warn("GithubWebServer: FAILED SECRET TOKEN AUTH. IP=%s", 498 | ip) 499 | return ("", 403, {}) 500 | 501 | json_data = json.loads(data) 502 | is_private_repo = json_data.get("repository", {}).get("private") 503 | if is_private_repo: 504 | log.info( 505 | "Received private repo event for %s", json_data["repository"].get("name") 506 | ) 507 | return 508 | 509 | 510 | event_type = headers.get('X-GitHub-Event') 511 | if event_type == "pull_request": 512 | self.on_receive_pull_request(json_data) 513 | return 514 | elif event_type == "issues": 515 | self.on_receive_issue(json_data) 516 | return 517 | elif event_type == "create": 518 | self.on_receive_create(json_data) 519 | return 520 | elif event_type == "ping": 521 | self.on_receive_ping(json_data) 522 | return 523 | elif event_type == "issue_comment": 524 | # INCLUDES PR COMMENTS!!! 525 | # But not line comments! 526 | self.on_receive_comment(json_data) 527 | return 528 | elif event_type == "pull_request_review_comment": 529 | self.on_receive_pull_request_comment(json_data) 530 | return 531 | 532 | j = json_data 533 | repo_name = j["repository"]["full_name"] 534 | # strip 'refs/heads' from 'refs/heads/branch_name' 535 | branch = '/'.join(j["ref"].split('/')[2:]) 536 | 537 | commit_msg = "" 538 | commit_name = "" 539 | commit_link = "" 540 | short_hash = "" 541 | push_type = "commit" 542 | 543 | if j["head_commit"]: 544 | commit_msg = j["head_commit"]["message"] 545 | commit_name = j["head_commit"]["committer"]["name"] 546 | commit_link = j["head_commit"]["url"] 547 | # short hash please 548 | short_hash = commit_link.split('/')[-1][0:8] 549 | commit_link = '/'.join(commit_link.split('/')[0:-1]) + "/" + short_hash 550 | elif j["deleted"]: 551 | # looks like this branch was deleted, no commit and deleted=true 552 | commit_name = j["pusher"]["name"] 553 | push_type = "delete" 554 | 555 | commit_uname = None 556 | try: 557 | commit_uname = j["head_commit"]["committer"]["username"] 558 | except Exception: 559 | # possible if they haven't tied up with a github account 560 | commit_uname = commit_name 561 | 562 | # look for multiple commits 563 | num_commits = 1 564 | commits_summary = [] 565 | if "commits" in j and len(j["commits"]) > 1: 566 | num_commits = len(j["commits"]) 567 | for c in j["commits"]: 568 | cname = None 569 | try: 570 | cname = c["author"]["username"] 571 | except: 572 | cname = c["author"]["name"] 573 | commits_summary.append({ 574 | "author": cname, 575 | "summary": c["message"] 576 | }) 577 | 578 | 579 | self.on_receive_github_push({ 580 | "branch": branch, 581 | "repo": repo_name, 582 | "commit_msg": commit_msg, 583 | "commit_username": commit_uname, 584 | "commit_name": commit_name, 585 | "commit_link": commit_link, 586 | "commit_hash": short_hash, 587 | "type": push_type, 588 | "num_commits": num_commits, 589 | "commits_summary": commits_summary 590 | }) 591 | -------------------------------------------------------------------------------- /plugins/guess_number.py: -------------------------------------------------------------------------------- 1 | from neb.plugins import Plugin 2 | import collections 3 | import random 4 | 5 | class GuessNumberPlugin(Plugin): 6 | """Play a guess the number game. 7 | You have to guess what the number is in a certain number of attempts. You 8 | will be told information such as higher/lower than the guessed number. 9 | guessnumber new : Starts a new game. 10 | guessnumber hint : Get a hint for the number. Consumes an attempt. 11 | guessnumber guess <number> : Guess the number. Consumes an attempt. 12 | """ 13 | name = "guessnumber" 14 | 15 | MAX_NUM = 100 16 | ATTEMPTS = 5 17 | 18 | def __init__(self, *args, **kwargs): 19 | super(Plugin, self).__init__(*args, **kwargs) 20 | self.games = {} 21 | 22 | 23 | def cmd_new(self, event): 24 | """Start a new game. 'guessnumber new'""" 25 | usr = event["user_id"] 26 | game_state = { 27 | "num": random.randint(0, GuessNumberPlugin.MAX_NUM), 28 | "attempts": 0 29 | } 30 | self.games[usr] = game_state 31 | return ("Created a new game. Guess what the chosen number is between 0-%s. You have %s attempts." % 32 | (GuessNumberPlugin.MAX_NUM, GuessNumberPlugin.ATTEMPTS)) 33 | 34 | def cmd_guess(self, event, num): 35 | """Make a guess. 'guessnumber guess <number>'""" 36 | usr = event["user_id"] 37 | 38 | if usr not in self.games: 39 | return "You need to start a game first." 40 | 41 | int_num = -1 42 | try: 43 | int_num = int(num) 44 | except: 45 | return "That isn't a number." 46 | 47 | target_num = self.games[usr]["num"] 48 | if int_num == target_num: 49 | self.games.pop(usr) 50 | return "You win!" 51 | 52 | game_over = self._add_attempt(usr) 53 | 54 | if game_over: 55 | return game_over 56 | else: 57 | sign = "greater" if (target_num > int_num) else "less" 58 | return "Nope. The number is %s than that." % sign 59 | 60 | def cmd_hint(self, event): 61 | """Get a hint. 'guessnumber hint'""" 62 | # hints give a 50% reduction, e.g. between 0-50, even/odd, ends with 12345 63 | usr = event["user_id"] 64 | 65 | if usr not in self.games: 66 | return "You need to start a game first." 67 | 68 | num = self.games[usr]["num"] 69 | hint_pool = [self._odd_even, self._ends_with, self._between] 70 | hint_func = hint_pool[random.randint(1, len(hint_pool)) - 1] 71 | 72 | game_over = self._add_attempt(usr) 73 | 74 | if game_over: 75 | return game_over 76 | 77 | return hint_func(num) 78 | 79 | def _add_attempt(self, usr): 80 | self.games[usr]["attempts"] += 1 81 | 82 | if self.games[usr]["attempts"] >= GuessNumberPlugin.ATTEMPTS: 83 | res = "Out of tries. The number was %s." % self.games[usr]["num"] 84 | self.games.pop(usr) 85 | return res 86 | 87 | def _between(self, num): 88 | half = GuessNumberPlugin.MAX_NUM / 2 89 | if num < half: 90 | return "The number is less than %s." % half 91 | else: 92 | return "The number is %s or greater." % half 93 | 94 | def _ends_with(self, num): 95 | actual = num % 10 96 | if actual < 5: 97 | return "The last digit is either 0, 1, 2, 3, 4." 98 | else: 99 | return "The last digit is either 5, 6, 7, 8, 9." 100 | 101 | 102 | def _odd_even(self, num): 103 | if num % 2 == 0: 104 | return "The number is even." 105 | else: 106 | return "The number is odd." 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /plugins/jenkins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from neb.plugins import Plugin, admin_only 3 | from neb.engine import KeyValueStore, RoomContextStore 4 | 5 | import json 6 | import urlparse 7 | 8 | import logging as log 9 | 10 | 11 | class JenkinsPlugin(Plugin): 12 | """ Plugin for receiving Jenkins notifications via the Notification Plugin. 13 | jenkins show projects : Display which projects this bot recognises. 14 | jenkins show track|tracking : Display which projects this bot is tracking. 15 | jenkins track project1 project2 ... : Track Jenkins notifications for the named projects. 16 | jenkins stop track|tracking : Stop tracking Jenkins notifications. 17 | jenkins add projectName : Start tracking projectName. 18 | jenkins remove projectName : Stop tracking projectName. 19 | """ 20 | name = "jenkins" 21 | 22 | # https://wiki.jenkins-ci.org/display/JENKINS/Notification+Plugin 23 | 24 | #New events: 25 | # Type: org.matrix.neb.plugin.jenkins.projects.tracking 26 | # State: Yes 27 | # Content: { 28 | # projects: [projectName1, projectName2, ...] 29 | # } 30 | 31 | #Webhooks: 32 | # /neb/jenkins 33 | 34 | TRACKING = ["track", "tracking"] 35 | TYPE_TRACK = "org.matrix.neb.plugin.jenkins.projects.tracking" 36 | 37 | def __init__(self, *args, **kwargs): 38 | super(JenkinsPlugin, self).__init__(*args, **kwargs) 39 | self.store = KeyValueStore("jenkins.json") 40 | self.rooms = RoomContextStore( 41 | [JenkinsPlugin.TYPE_TRACK] 42 | ) 43 | 44 | if not self.store.has("known_projects"): 45 | self.store.set("known_projects", []) 46 | 47 | if not self.store.has("secret_token"): 48 | self.store.set("secret_token", "") 49 | 50 | self.failed_builds = { 51 | # projectName:branch: { commit:x } 52 | } 53 | 54 | def cmd_show(self, event, action): 55 | """Show information on projects or projects being tracked. 56 | Show which projects are being tracked. 'jenkins show tracking' 57 | Show which proejcts are recognised so they could be tracked. 'jenkins show projects' 58 | """ 59 | if action in self.TRACKING: 60 | return self._get_tracking(event["room_id"]) 61 | elif action == "projects": 62 | projects = self.store.get("known_projects") 63 | return "Available projects: %s" % json.dumps(projects) 64 | else: 65 | return "Invalid arg '%s'.\n %s" % (action, self.cmd_show.__doc__) 66 | 67 | @admin_only 68 | def cmd_track(self, event, *args): 69 | """Track projects. 'jenkins track Foo "bar with spaces"'""" 70 | if len(args) == 0: 71 | return self._get_tracking(event["room_id"]) 72 | 73 | for project in args: 74 | if not project in self.store.get("known_projects"): 75 | return "Unknown project name: %s." % project 76 | 77 | self._send_track_event(event["room_id"], args) 78 | 79 | return "Jenkins notifications for projects %s will be displayed when they fail." % (args) 80 | 81 | @admin_only 82 | def cmd_add(self, event, project): 83 | """Add a project for tracking. 'jenkins add projectName'""" 84 | if project not in self.store.get("known_projects"): 85 | return "Unknown project name: %s." % project 86 | 87 | try: 88 | room_projects = self.rooms.get_content( 89 | event["room_id"], 90 | JenkinsPlugin.TYPE_TRACK)["projects"] 91 | except KeyError: 92 | room_projects = [] 93 | 94 | if project in room_projects: 95 | return "%s is already being tracked." % project 96 | 97 | room_projects.append(project) 98 | self._send_track_event(event["room_id"], room_projects) 99 | 100 | return "Added %s. Jenkins notifications for projects %s will be displayed when they fail." % (project, room_projects) 101 | 102 | @admin_only 103 | def cmd_remove(self, event, project): 104 | """Remove a project from tracking. 'jenkins remove projectName'""" 105 | try: 106 | room_projects = self.rooms.get_content( 107 | event["room_id"], 108 | JenkinsPlugin.TYPE_TRACK)["projects"] 109 | except KeyError: 110 | room_projects = [] 111 | 112 | if project not in room_projects: 113 | return "Cannot remove %s : It isn't being tracked." % project 114 | 115 | room_projects.remove(project) 116 | self._send_track_event(event["room_id"], room_projects) 117 | 118 | return "Removed %s. Jenkins notifications for projects %s will be displayed when they fail." % (project, room_projects) 119 | 120 | @admin_only 121 | def cmd_stop(self, event, action): 122 | """Stop tracking projects. 'jenkins stop tracking'""" 123 | if action in self.TRACKING: 124 | self._send_track_event(event["room_id"], []) 125 | return "Stopped tracking projects." 126 | else: 127 | return "Invalid arg '%s'.\n %s" % (action, self.cmd_stop.__doc__) 128 | 129 | def _get_tracking(self, room_id): 130 | try: 131 | return ("Currently tracking %s" % 132 | json.dumps(self.rooms.get_content( 133 | room_id, JenkinsPlugin.TYPE_TRACK)["projects"] 134 | ) 135 | ) 136 | except KeyError: 137 | return "Not tracking any projects currently." 138 | 139 | def _send_track_event(self, room_id, project_names): 140 | self.matrix.send_state_event( 141 | room_id, 142 | self.TYPE_TRACK, 143 | { 144 | "projects": project_names 145 | } 146 | ) 147 | 148 | def send_message_to_repos(self, repo, push_message): 149 | # send messages to all rooms registered with this project. 150 | for room_id in self.rooms.get_room_ids(): 151 | try: 152 | if (repo in self.rooms.get_content( 153 | room_id, JenkinsPlugin.TYPE_TRACK)["projects"]): 154 | self.matrix.send_message_event( 155 | room_id, 156 | "m.room.message", 157 | self.matrix.get_html_body(push_message, msgtype="m.notice") 158 | ) 159 | except KeyError: 160 | pass 161 | 162 | def on_event(self, event, event_type): 163 | self.rooms.update(event) 164 | 165 | def on_sync(self, sync): 166 | log.debug("Plugin: Jenkins sync state:") 167 | self.rooms.init_from_sync(sync) 168 | 169 | def get_webhook_key(self): 170 | return "jenkins" 171 | 172 | def on_receive_webhook(self, url, data, ip, headers): 173 | # data is of the form: 174 | # { 175 | # "name":"Synapse", 176 | # "url":"job/Synapse/", 177 | # "build": { 178 | # "full_url":"http://localhost:9009/job/Synapse/8/", 179 | # "number":8, 180 | # "phase":"FINALIZED", 181 | # "status":"SUCCESS", 182 | # "url":"job/Synapse/8/", 183 | # "scm": { 184 | # "url":"git@github.com:matrix-org/synapse.git", 185 | # "branch":"origin/develop", 186 | # "commit":"72aef114ab1201f5a5cd734220c9ec738c4e2910" 187 | # }, 188 | # "artifacts":{} 189 | # } 190 | # } 191 | log.info("URL: %s", url) 192 | log.info("Data: %s", data) 193 | log.info("Headers: %s", headers) 194 | 195 | j = json.loads(data) 196 | name = j["name"] 197 | 198 | query_dict = urlparse.parse_qs(urlparse.urlparse(url).query) 199 | if self.store.get("secret_token"): 200 | if "secret" not in query_dict: 201 | log.warn("Jenkins webhook: Missing secret.") 202 | return ("", 403, {}) 203 | 204 | # The jenkins Notification plugin does not support any sort of 205 | # "execute this code on this json object before you send" so we can't 206 | # send across HMAC SHA1s like with github :( so a secret token will 207 | # have to do. 208 | secrets = query_dict["secret"] 209 | if len(secrets) > 1: 210 | log.warn("Jenkins webhook: FAILED SECRET TOKEN AUTH. Too many secrets. IP=%s", 211 | ip) 212 | return ("", 403, {}) 213 | elif secrets[0] != self.store.get("secret_token"): 214 | log.warn("Jenkins webhook: FAILED SECRET TOKEN AUTH. Mismatch. IP=%s", 215 | ip) 216 | return ("", 403, {}) 217 | else: 218 | log.info("Jenkins webhook: Secret verified.") 219 | 220 | 221 | # add the project if we didn't know about it before 222 | if name not in self.store.get("known_projects"): 223 | log.info("Added new job: %s", name) 224 | projects = self.store.get("known_projects") 225 | projects.append(name) 226 | self.store.set("known_projects", projects) 227 | 228 | status = j["build"]["status"] 229 | branch = None 230 | commit = None 231 | git_url = None 232 | jenkins_url = None 233 | info = "" 234 | try: 235 | branch = j["build"]["scm"]["branch"] 236 | commit = j["build"]["scm"]["commit"] 237 | git_url = j["build"]["scm"]["url"] 238 | jenkins_url = j["build"]["full_url"] 239 | # try to format the git url nicely 240 | if (git_url.startswith("git@github.com") and 241 | git_url.endswith(".git")): 242 | # git@github.com:matrix-org/synapse.git 243 | org_and_repo = git_url.split(":")[1][:-4] 244 | commit = "https://github.com/%s/commit/%s" % (org_and_repo, commit) 245 | 246 | 247 | info = "%s commit %s - %s" % (branch, commit, jenkins_url) 248 | except KeyError: 249 | pass 250 | 251 | fail_key = "%s:%s" % (name, branch) 252 | 253 | if status.upper() != "SUCCESS": 254 | # complain 255 | msg = '<font color="red">[%s] <b>%s - %s</b></font>' % ( 256 | name, 257 | status, 258 | info 259 | ) 260 | 261 | if fail_key in self.failed_builds: 262 | info = "%s failing since commit %s - %s" % (branch, self.failed_builds[fail_key]["commit"], jenkins_url) 263 | msg = '<font color="red">[%s] <b>%s - %s</b></font>' % ( 264 | name, 265 | status, 266 | info 267 | ) 268 | else: # add it to the list 269 | self.failed_builds[fail_key] = { 270 | "commit": commit 271 | } 272 | 273 | self.send_message_to_repos(name, msg) 274 | else: 275 | # do we need to prod people? 276 | if fail_key in self.failed_builds: 277 | info = "%s commit %s" % (branch, commit) 278 | msg = '<font color="green">[%s] <b>%s - %s</b></font>' % ( 279 | name, 280 | status, 281 | info 282 | ) 283 | self.send_message_to_repos(name, msg) 284 | self.failed_builds.pop(fail_key) 285 | 286 | 287 | -------------------------------------------------------------------------------- /plugins/jira.py: -------------------------------------------------------------------------------- 1 | from neb.engine import KeyValueStore, RoomContextStore 2 | from neb.plugins import Plugin, admin_only 3 | 4 | import getpass 5 | import json 6 | import re 7 | import requests 8 | 9 | import logging as log 10 | 11 | 12 | class JiraPlugin(Plugin): 13 | """ Plugin for interacting with JIRA. 14 | jira version : Display version information for this platform. 15 | jira track <project> <project2> ... : Track multiple projects 16 | jira expand <project> <project2> ... : Expand issue IDs for the given projects with issue information. 17 | jira stop track|tracking : Stops tracking for all projects. 18 | jira stop expand|expansion|expanding : Stop expanding jira issues. 19 | jira show track|tracking : Show which projects are being tracked. 20 | jira show expansion|expand|expanding : Show which project keys will result in issue expansion. 21 | jira create <project> <priority> <title> <desc> : Create a new JIRA issue. 22 | jira comment <issue-id> <comment> : Comment on a JIRA issue. 23 | """ 24 | name = "jira" 25 | 26 | TRACK = ["track", "tracking"] 27 | EXPAND = ["expansion", "expand", "expanding"] 28 | 29 | # New events: 30 | # Type: org.matrix.neb.plugin.jira.issues.tracking / expanding 31 | # State: Yes 32 | # Content: { 33 | # projects: [projectKey1, projectKey2, ...] 34 | # } 35 | TYPE_TRACK = "org.matrix.neb.plugin.jira.issues.tracking" 36 | TYPE_EXPAND = "org.matrix.neb.plugin.jira.issues.expanding" 37 | 38 | def __init__(self, *args, **kwargs): 39 | super(JiraPlugin, self).__init__(*args, **kwargs) 40 | self.store = KeyValueStore("jira.json") 41 | self.rooms = RoomContextStore( 42 | [JiraPlugin.TYPE_TRACK, JiraPlugin.TYPE_EXPAND] 43 | ) 44 | 45 | if not self.store.has("url"): 46 | url = raw_input("JIRA URL: ").strip() 47 | self.store.set("url", url) 48 | 49 | if not self.store.has("user") or not self.store.has("pass"): 50 | user = raw_input("(%s) JIRA Username: " % self.store.get("url")).strip() 51 | pw = getpass.getpass("(%s) JIRA Password: " % self.store.get("url")).strip() 52 | self.store.set("user", user) 53 | self.store.set("pass", pw) 54 | 55 | self.auth = (self.store.get("user"), self.store.get("pass")) 56 | self.regex = re.compile(r"\b(([A-Za-z]+)-\d+)\b") 57 | 58 | @admin_only 59 | def cmd_stop(self, event, action): 60 | """ Clear project keys from tracking/expanding. 61 | Stop tracking projects. 'jira stop tracking' 62 | Stop expanding projects. 'jira stop expanding' 63 | """ 64 | if action in self.TRACK: 65 | self._send_state(JiraPlugin.TYPE_TRACK, event["room_id"], []) 66 | url = self.store.get("url") 67 | return "Stopped tracking project keys from %s." % (url) 68 | elif action in self.EXPAND: 69 | self._send_state(JiraPlugin.TYPE_EXPAND, event["room_id"], []) 70 | url = self.store.get("url") 71 | return "Stopped expanding project keys from %s." % (url) 72 | else: 73 | return "Invalid arg '%s'.\n %s" % (action, self.cmd_stop.__doc__) 74 | 75 | @admin_only 76 | def cmd_track(self, event, *args): 77 | """Track project keys. 'jira track FOO BAR'""" 78 | if not args: 79 | return self._get_tracking(event["room_id"]) 80 | 81 | args = [k.upper() for k in args] 82 | for key in args: 83 | if re.search("[^A-Z]", key): # something not A-Z 84 | return "Key %s isn't a valid project key." % key 85 | 86 | self._send_state(JiraPlugin.TYPE_TRACK, event["room_id"], args) 87 | 88 | url = self.store.get("url") 89 | return "Issues for projects %s from %s will be displayed as they are updated." % (args, url) 90 | 91 | @admin_only 92 | def cmd_expand(self, event, *args): 93 | """Expand issues when mentioned for the given project keys. 'jira expand FOO BAR'""" 94 | if not args: 95 | return self._get_expanding(event["room_id"]) 96 | 97 | args = [k.upper() for k in args] 98 | for key in args: 99 | if re.search("[^A-Z]", key): # something not A-Z 100 | return "Key %s isn't a valid project key." % key 101 | 102 | self._send_state(JiraPlugin.TYPE_EXPAND, event["room_id"], args) 103 | 104 | url = self.store.get("url") 105 | return "Issues for projects %s from %s will be expanded as they are mentioned." % (args, url) 106 | 107 | @admin_only 108 | def cmd_create(self, event, *args): 109 | """Create a new issue. Format: 'create <project> <priority(optional;default 3)> <title> <desc(optional)>' 110 | E.g. 'create syn p1 This is the title without quote marks' 111 | 'create syn p1 "Title here" "desc here" 112 | """ 113 | if not args or len(args) < 2: 114 | return self.cmd_create.__doc__ 115 | project = args[0] 116 | priority = 3 117 | others = args[1:] 118 | if re.match("[Pp][0-9]", args[1]): 119 | if len(args) < 3: # priority without title 120 | return self.cmd_create.__doc__ 121 | try: 122 | priority = int(args[1][1:]) 123 | others = args[2:] 124 | except ValueError: 125 | return self.cmd_create.__doc__ 126 | elif re.match("[Pp][0-9]", args[0]): 127 | priority = int(args[0][1:]) 128 | project = args[1] 129 | others = args[2:] 130 | # others must contain a title, may contain a description. If it contains 131 | # a description, it MUST be in [1] and be longer than 1 word. 132 | title = ' '.join(others) 133 | desc = "" 134 | try: 135 | possible_desc = others[1] 136 | if ' ' in possible_desc: 137 | desc = possible_desc 138 | title = others[0] 139 | except: 140 | pass 141 | 142 | return self._create_issue( 143 | event["user_id"], project, priority, title, desc 144 | ) 145 | 146 | @admin_only 147 | def cmd_comment(self, event, *args): 148 | """Comment on an issue. Format: 'comment <key> <comment text>' 149 | E.g. 'comment syn-56 A comment goes here' 150 | """ 151 | if not args or len(args) < 2: 152 | return self.cmd_comment.__doc__ 153 | key = args[0].upper() 154 | text = ' '.join(args[1:]) 155 | return self._comment_issue(event["user_id"], key, text) 156 | 157 | def cmd_version(self, event): 158 | """Display version information for the configured JIRA platform. 'jira version'""" 159 | url = self._url("/rest/api/2/serverInfo") 160 | response = json.loads(requests.get(url).text) 161 | 162 | info = "%s : version %s : build %s" % (response["serverTitle"], 163 | response["version"], response["buildNumber"]) 164 | 165 | return info 166 | 167 | def cmd_show(self, event, action): 168 | """Show which project keys are being tracked/expanded. 169 | Show which project keys are being expanded. 'jira show expanding' 170 | Show which project keys are being tracked. 'jira show tracking' 171 | """ 172 | action = action.lower() 173 | if action in self.TRACK: 174 | return self._get_tracking(event["room_id"]) 175 | elif action in self.EXPAND: 176 | return self._get_expanding(event["room_id"]) 177 | 178 | def _get_tracking(self, room_id): 179 | try: 180 | return ("Currently tracking %s" % 181 | json.dumps( 182 | self.rooms.get_content( 183 | room_id, 184 | JiraPlugin.TYPE_TRACK 185 | )["projects"] 186 | ) 187 | ) 188 | except KeyError: 189 | return "Not tracking any projects currently." 190 | 191 | def _get_expanding(self, room_id): 192 | try: 193 | return ("Currently expanding %s" % 194 | json.dumps( 195 | self.rooms.get_content( 196 | room_id, 197 | JiraPlugin.TYPE_EXPAND 198 | )["projects"] 199 | ) 200 | ) 201 | except KeyError: 202 | return "Not expanding any projects currently." 203 | 204 | def _send_state(self, etype, room_id, project_keys): 205 | self.matrix.send_state_event( 206 | room_id, 207 | etype, 208 | { 209 | "projects": project_keys 210 | } 211 | ) 212 | 213 | def on_msg(self, event, body): 214 | room_id = event["room_id"] 215 | body = body.upper() 216 | groups = self.regex.findall(body) 217 | if not groups: 218 | return 219 | 220 | projects = [] 221 | try: 222 | projects = self.rooms.get_content( 223 | room_id, JiraPlugin.TYPE_EXPAND 224 | )["projects"] 225 | except KeyError: 226 | return 227 | 228 | for (key, project) in groups: 229 | if project in projects: 230 | try: 231 | issue_info = self._get_issue_info(key) 232 | if issue_info: 233 | self.matrix.send_message( 234 | event["room_id"], 235 | issue_info, 236 | msgtype="m.notice" 237 | ) 238 | except Exception as e: 239 | log.exception(e) 240 | 241 | def on_event(self, event, event_type): 242 | self.rooms.update(event) 243 | 244 | def on_receive_jira_push(self, info): 245 | log.debug("on_recv %s", info) 246 | project = self.regex.match(info["key"]).groups()[1] 247 | 248 | # form the message 249 | link = self._linkify(info["key"]) 250 | push_message = "%s %s <b>%s</b> - %s %s" % (info["user"], info["action"], 251 | info["key"], info["summary"], link) 252 | 253 | # send messages to all rooms registered with this project. 254 | for room_id in self.rooms.get_room_ids(): 255 | try: 256 | content = self.rooms.get_content(room_id, JiraPlugin.TYPE_TRACK) 257 | if project in content["projects"]: 258 | self.matrix.send_message_event( 259 | room_id, 260 | "m.room.message", 261 | self.matrix.get_html_body(push_message, msgtype="m.notice") 262 | ) 263 | except KeyError: 264 | pass 265 | 266 | def on_sync(self, sync): 267 | log.debug("Plugin: JIRA sync state:") 268 | self.rooms.init_from_sync(sync) 269 | 270 | def _get_issue_info(self, issue_key): 271 | url = self._url("/rest/api/2/issue/%s" % issue_key) 272 | res = requests.get(url, auth=self.auth) 273 | if res.status_code != 200: 274 | return 275 | 276 | response = json.loads(res.text) 277 | link = self._linkify(issue_key) 278 | desc = response["fields"]["summary"] 279 | status = response["fields"]["status"]["name"] 280 | priority = response["fields"]["priority"]["name"] 281 | reporter = response["fields"]["reporter"]["displayName"] 282 | assignee = "" 283 | if response["fields"]["assignee"]: 284 | assignee = response["fields"]["assignee"]["displayName"] 285 | 286 | info = "%s : %s [%s,%s,reporter=%s,assignee=%s]" % (link, desc, status, 287 | priority, reporter, assignee) 288 | return info 289 | 290 | def _create_issue(self, user_id, project, priority, title, desc=""): 291 | if priority < 1: 292 | priority = 1 293 | if priority > 5: 294 | priority = 5 295 | desc = "Submitted by %s\n%s" % (user_id, desc) 296 | 297 | fields = {} 298 | fields["priority"] = { 299 | "name": ("P%s" % priority) 300 | } 301 | fields["project"] = { 302 | "key": project.upper().strip() 303 | } 304 | fields["issuetype"] = { 305 | "name": "Bug" 306 | } 307 | fields["summary"] = title 308 | fields["description"] = desc 309 | 310 | info = { 311 | "fields": fields 312 | } 313 | 314 | url = self._url("/rest/api/2/issue") 315 | res = requests.post(url, auth=self.auth, data=json.dumps(info), headers={ 316 | "Content-Type": "application/json" 317 | }) 318 | 319 | if res.status_code < 200 or res.status_code >= 300: 320 | err = "Failed: HTTP %s - %s" % (res.status_code, res.text) 321 | log.error(err) 322 | return err 323 | 324 | response = json.loads(res.text) 325 | issue_key = response["key"] 326 | link = self._linkify(issue_key) 327 | 328 | return "Created issue: %s" % link 329 | 330 | def _comment_issue(self, user_id, key, text): 331 | text = "By %s: %s" % (user_id, text) 332 | info = { 333 | "body": text 334 | } 335 | 336 | url = self._url("/rest/api/2/issue/%s/comment" % key) 337 | res = requests.post(url, auth=self.auth, data=json.dumps(info), headers={ 338 | "Content-Type": "application/json" 339 | }) 340 | 341 | if res.status_code < 200 or res.status_code >= 300: 342 | err = "Failed: HTTP %s - %s" % (res.status_code, res.text) 343 | log.error(err) 344 | return err 345 | link = self._linkify(key) 346 | return "Commented on issue %s" % link 347 | 348 | def _linkify(self, key): 349 | return "%s/browse/%s" % (self.store.get("url"), key) 350 | 351 | def _url(self, path): 352 | return self.store.get("url") + path 353 | 354 | def get_webhook_key(self): 355 | return "jira" 356 | 357 | def on_receive_webhook(self, url, data, ip, headers): 358 | j = json.loads(data) 359 | 360 | info = self.get_webhook_json_keys(j) 361 | self.on_receive_jira_push(info) 362 | 363 | def get_webhook_json_keys(self, j): 364 | key = j['issue']['key'] 365 | user = j['user']['name'] 366 | self_key = j['issue']['self'] 367 | summary = self.get_webhook_summary(j) 368 | action = "" 369 | 370 | if j['webhookEvent'] == "jira:issue_updated": 371 | action = "updated" 372 | elif j['webhookEvent'] == "jira:issue_deleted": 373 | action = "deleted" 374 | elif j['webhookEvent'] == "jira:issue_created": 375 | action = "created" 376 | 377 | return { 378 | "key": key, 379 | "user": user, 380 | "summary": summary, 381 | "self": self_key, 382 | "action": action 383 | } 384 | 385 | def get_webhook_summary(self, j): 386 | summary = j['issue']['fields']['summary'] 387 | priority = j['issue']['fields']['priority']['name'] 388 | status = j['issue']['fields']['status']['name'] 389 | 390 | if "resolution" in j['issue']['fields'] \ 391 | and j['issue']['fields']['resolution'] is not None: 392 | status = "%s (%s)" \ 393 | % (status, j['issue']['fields']['resolution']['name']) 394 | 395 | return "%s [%s, %s]" \ 396 | % (summary, priority, status) 397 | -------------------------------------------------------------------------------- /plugins/prometheus.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from jinja2 import Template 3 | import json 4 | from matrix_client.api import MatrixRequestError 5 | from neb.engine import KeyValueStore, RoomContextStore 6 | from neb.plugins import Plugin, admin_only 7 | from Queue import PriorityQueue 8 | from threading import Thread 9 | 10 | 11 | import time 12 | import logging as log 13 | 14 | queue = PriorityQueue() 15 | 16 | 17 | class PrometheusPlugin(Plugin): 18 | """Plugin for interacting with Prometheus. 19 | """ 20 | name = "prometheus" 21 | 22 | #Webhooks: 23 | # /neb/prometheus 24 | TYPE_TRACK = "org.matrix.neb.plugin.prometheus.projects.tracking" 25 | 26 | 27 | def __init__(self, *args, **kwargs): 28 | super(PrometheusPlugin, self).__init__(*args, **kwargs) 29 | self.store = KeyValueStore("prometheus.json") 30 | self.rooms = RoomContextStore( 31 | [PrometheusPlugin.TYPE_TRACK] 32 | ) 33 | self.queue_counter = 1L 34 | self.consumer = MessageConsumer(self.matrix) 35 | self.consumer.daemon = True 36 | self.consumer.start() 37 | 38 | def on_event(self, event, event_type): 39 | self.rooms.update(event) 40 | 41 | def on_sync(self, sync): 42 | log.debug("Plugin: Prometheus sync state:") 43 | self.rooms.init_from_sync(sync) 44 | 45 | def get_webhook_key(self): 46 | return "prometheus" 47 | 48 | def on_receive_webhook(self, url, data, ip, headers): 49 | json_data = json.loads(data) 50 | log.info("recv %s", json_data) 51 | template = Template(self.store.get("message_template")) 52 | for alert in json_data.get("alert", []): 53 | for room_id in self.rooms.get_room_ids(): 54 | log.debug("queued message for room " + room_id + " at " + str(self.queue_counter) + ": %s", alert) 55 | queue.put((self.queue_counter, room_id, template.render(alert))) 56 | self.queue_counter += 1 57 | 58 | 59 | class MessageConsumer(Thread): 60 | """ This class consumes the produced messages 61 | also will try to resend the messages that 62 | are failed for instance when the server was down. 63 | """ 64 | 65 | INITIAL_TIMEOUT_S = 5 66 | TIMEOUT_INCREMENT_S = 5 67 | MAX_TIMEOUT_S = 60 * 5 68 | 69 | def __init__(self, matrix): 70 | super(MessageConsumer, self).__init__() 71 | self.matrix = matrix 72 | 73 | def run(self): 74 | timeout = self.INITIAL_TIMEOUT_S 75 | 76 | log.debug("Starting consumer thread") 77 | while True: 78 | priority, room_id, message = queue.get() 79 | log.debug("Popped message for room " + room_id + " at position " + str(priority) + ": %s", message) 80 | try: 81 | self.send_message(room_id, message) 82 | timeout = self.INITIAL_TIMEOUT_S 83 | except Exception as e: 84 | log.debug("Failed to send message: %s", e) 85 | queue.put((priority, room_id, message)) 86 | 87 | time.sleep(timeout) 88 | timeout += self.TIMEOUT_INCREMENT_S 89 | if timeout > self.MAX_TIMEOUT_S: 90 | timeout = self.MAX_TIMEOUT_S 91 | 92 | def send_message(self, room_id, message): 93 | try: 94 | self.matrix.send_message_event( 95 | room_id, 96 | "m.room.message", 97 | self.matrix.get_html_body(message, msgtype="m.notice"), 98 | ) 99 | except KeyError: 100 | log.error(KeyError) 101 | except MatrixRequestError as e: 102 | if 400 <= e.code < 500: 103 | log.error("Matrix ignored message %s", e) 104 | else: 105 | raise 106 | -------------------------------------------------------------------------------- /plugins/time_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from neb.plugins import Plugin 3 | 4 | import calendar 5 | import datetime 6 | import time 7 | from dateutil import parser 8 | 9 | 10 | class TimePlugin(Plugin): 11 | """Encodes and decodes timestamps. 12 | time encode <date> : Encode <date> as a unix timestamp. 13 | time decode <unix timestamp> : Decode the unix timestamp and return the date. 14 | """ 15 | 16 | name="time" 17 | 18 | def cmd_encode(self, event, *args): 19 | """Encode a time. Multiple different formats are supported, e.g. YYYY-MM-DD HH:MM:SS 'time encode <date>'""" 20 | # use the body directly so spaces are handled correctly. 21 | date_str = event["content"]["body"][len("!time encode "):] 22 | 23 | if date_str.lower().strip() == "now": 24 | now = time.time() 25 | return "Parsed as %s\n%s" % (datetime.datetime.utcfromtimestamp(now), now) 26 | 27 | try: 28 | d = parser.parse(date_str) 29 | ts = calendar.timegm(d.timetuple()) 30 | return "Parsed as %s\n%s" % (d.strftime("%Y-%m-%d %H:%M:%S"), ts) 31 | except ValueError: 32 | return "Failed to parse '%s'" % date_str 33 | 34 | def cmd_decode(self, event, timestamp): 35 | """Decode from a unix timestamp. 'time decode <timestamp>'""" 36 | is_millis = len(timestamp) > 10 37 | try: 38 | ts = int(timestamp) 39 | if is_millis: 40 | return datetime.datetime.utcfromtimestamp(ts/1000.0).strftime("%Y-%m-%d %H:%M:%S.%f") 41 | else: 42 | return datetime.datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") 43 | except ValueError: 44 | return "Failed to parse '%s'" % timestamp 45 | 46 | -------------------------------------------------------------------------------- /plugins/url.py: -------------------------------------------------------------------------------- 1 | from neb.plugins import Plugin 2 | 3 | import urllib 4 | 5 | 6 | class UrlPlugin(Plugin): 7 | """URL encode or decode text. 8 | url encode <text> 9 | url decode <text> 10 | """ 11 | 12 | name = "url" 13 | 14 | def cmd_encode(self, event, *args): 15 | """URL encode text. 'url encode <text>'""" 16 | # use the body directly so quotes are parsed correctly. 17 | return urllib.quote(event["content"]["body"][12:]) 18 | 19 | def cmd_decode(self, event, *args): 20 | """URL decode text. 'url decode <url encoded text>'""" 21 | # use the body directly so quotes are parsed correctly. 22 | return urllib.unquote(event["content"]["body"][12:]) 23 | 24 | -------------------------------------------------------------------------------- /prometheus.json: -------------------------------------------------------------------------------- 1 | { 2 | "message_template": "[ALERT] {{labels.alertname}} : {{summary}}\n{{payload.generatorURL}}\n{{description}}\nValue: {{payload.value}}\n{{payload.alertingRule}}\nActive since: {{payload.activeSince}}" 3 | } -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | setup( 5 | name="Matrix-NEB", 6 | version="0.0.2", 7 | description="A generic bot for Matrix", 8 | author="Kegan Dougal", 9 | author_email="kegsay@gmail.com", 10 | url="https://github.com/Kegsay/Matrix-NEB", 11 | packages = ['neb', 'plugins'], 12 | license = "LICENSE", 13 | install_requires = [ 14 | "matrix_client", 15 | "Flask", 16 | "python-dateutil" 17 | ], 18 | dependency_links=[ 19 | "https://github.com/matrix-org/matrix-python-sdk/tarball/v0.0.5#egg=matrix_client-0.0.5" 20 | ] 21 | ) 22 | --------------------------------------------------------------------------------