pax_global_header00006660000000000000000000000064135533710320014514gustar00rootroot0000000000000052 comment=2fc94f2e2049d258ebe7c43a0aaf720da46a2751 errbot-6.1.1+ds/000077500000000000000000000000001355337103200134205ustar00rootroot00000000000000errbot-6.1.1+ds/.codeclimate.yml000066400000000000000000000004001355337103200164640ustar00rootroot00000000000000--- engines: csslint: enabled: false duplication: enabled: true config: languages: - python eslint: enabled: true fixme: enabled: true radon: enabled: true ratings: paths: - "**.py" exclude_paths: - tests/ errbot-6.1.1+ds/.eslintignore000066400000000000000000000000201355337103200161130ustar00rootroot00000000000000**/*{.,-}min.js errbot-6.1.1+ds/.eslintrc000066400000000000000000000077361355337103200152610ustar00rootroot00000000000000ecmaFeatures: modules: true jsx: true env: amd: true browser: true es6: true jquery: true node: true # http://eslint.org/docs/rules/ rules: # Possible Errors comma-dangle: [2, never] no-cond-assign: 2 no-console: 0 no-constant-condition: 2 no-control-regex: 2 no-debugger: 2 no-dupe-args: 2 no-dupe-keys: 2 no-duplicate-case: 2 no-empty: 2 no-empty-character-class: 2 no-ex-assign: 2 no-extra-boolean-cast: 2 no-extra-parens: 0 no-extra-semi: 2 no-func-assign: 2 no-inner-declarations: [2, functions] no-invalid-regexp: 2 no-irregular-whitespace: 2 no-negated-in-lhs: 2 no-obj-calls: 2 no-regex-spaces: 2 no-sparse-arrays: 2 no-unexpected-multiline: 2 no-unreachable: 2 use-isnan: 2 valid-jsdoc: 0 valid-typeof: 2 # Best Practices accessor-pairs: 2 block-scoped-var: 0 complexity: [2, 6] consistent-return: 0 curly: 0 default-case: 0 dot-location: 0 dot-notation: 0 eqeqeq: 2 guard-for-in: 2 no-alert: 2 no-caller: 2 no-case-declarations: 2 no-div-regex: 2 no-else-return: 0 no-empty-label: 2 no-empty-pattern: 2 no-eq-null: 2 no-eval: 2 no-extend-native: 2 no-extra-bind: 2 no-fallthrough: 2 no-floating-decimal: 0 no-implicit-coercion: 0 no-implied-eval: 2 no-invalid-this: 0 no-iterator: 2 no-labels: 0 no-lone-blocks: 2 no-loop-func: 2 no-magic-number: 0 no-multi-spaces: 0 no-multi-str: 0 no-native-reassign: 2 no-new-func: 2 no-new-wrappers: 2 no-new: 2 no-octal-escape: 2 no-octal: 2 no-proto: 2 no-redeclare: 2 no-return-assign: 2 no-script-url: 2 no-self-compare: 2 no-sequences: 0 no-throw-literal: 0 no-unused-expressions: 2 no-useless-call: 2 no-useless-concat: 2 no-void: 2 no-warning-comments: 0 no-with: 2 radix: 2 vars-on-top: 0 wrap-iife: 2 yoda: 0 # Strict strict: 0 # Variables init-declarations: 0 no-catch-shadow: 2 no-delete-var: 2 no-label-var: 2 no-shadow-restricted-names: 2 no-shadow: 0 no-undef-init: 2 no-undef: 0 no-undefined: 0 no-unused-vars: 0 no-use-before-define: 0 # Node.js and CommonJS callback-return: 2 global-require: 2 handle-callback-err: 2 no-mixed-requires: 0 no-new-require: 0 no-path-concat: 2 no-process-exit: 2 no-restricted-modules: 0 no-sync: 0 # Stylistic Issues array-bracket-spacing: 0 block-spacing: 0 brace-style: 0 camelcase: 0 comma-spacing: 0 comma-style: 0 computed-property-spacing: 0 consistent-this: 0 eol-last: 0 func-names: 0 func-style: 0 id-length: 0 id-match: 0 indent: 0 jsx-quotes: 0 key-spacing: 0 linebreak-style: 0 lines-around-comment: 0 max-depth: 0 max-len: 0 max-nested-callbacks: 0 max-params: 0 max-statements: [2, 30] new-cap: 0 new-parens: 0 newline-after-var: 0 no-array-constructor: 0 no-bitwise: 0 no-continue: 0 no-inline-comments: 0 no-lonely-if: 0 no-mixed-spaces-and-tabs: 0 no-multiple-empty-lines: 0 no-negated-condition: 0 no-nested-ternary: 0 no-new-object: 0 no-plusplus: 0 no-restricted-syntax: 0 no-spaced-func: 0 no-ternary: 0 no-trailing-spaces: 0 no-underscore-dangle: 0 no-unneeded-ternary: 0 object-curly-spacing: 0 one-var: 0 operator-assignment: 0 operator-linebreak: 0 padded-blocks: 0 quote-props: 0 quotes: 0 require-jsdoc: 0 semi-spacing: 0 semi: 0 sort-vars: 0 space-after-keywords: 0 space-before-blocks: 0 space-before-function-paren: 0 space-before-keywords: 0 space-in-parens: 0 space-infix-ops: 0 space-return-throw-case: 0 space-unary-ops: 0 spaced-comment: 0 wrap-regex: 0 # ECMAScript 6 arrow-body-style: 0 arrow-parens: 0 arrow-spacing: 0 constructor-super: 0 generator-star-spacing: 0 no-arrow-condition: 0 no-class-assign: 0 no-const-assign: 0 no-dupe-class-members: 0 no-this-before-super: 0 no-var: 0 object-shorthand: 0 prefer-arrow-callback: 0 prefer-const: 0 prefer-reflect: 0 prefer-spread: 0 prefer-template: 0 require-yield: 0 errbot-6.1.1+ds/.github/000077500000000000000000000000001355337103200147605ustar00rootroot00000000000000errbot-6.1.1+ds/.github/ISSUE_TEMPLATE.md000066400000000000000000000015151355337103200174670ustar00rootroot00000000000000In order to let us help you better, please fill out the following fields as best you can: ### I am... * [ ] Reporting a bug * [ ] Suggesting a new feature * [ ] Requesting help with running my bot * [ ] Requesting help writing plugins * [ ] Here about something else ### I am running... * Errbot version: * OS version: * Python version: * Using a virtual environment: yes/no ### Issue description Please describe your bug/feature/problem here. The more information you can provide, the better. ### Steps to reproduce In case of a bug, please describe the steps we need to take in order to reproduce your issue. If you cannot easily reproduce the issue please let us know and provide as much information as you can which might help us pinpoint the problem. ### Additional info If you have any more information, please specify it here. errbot-6.1.1+ds/.gitignore000066400000000000000000000006431355337103200154130ustar00rootroot00000000000000*.pyc __pycache__ .venv /.coafile /.idea /atlassian-ide-plugin.xml /.settings /config.py nohup.out /build /dist /errbot.egg-info* /config-*.egg /config*.py /docs/_build /docs/errbot.rst /docs/errbot.*.rst /.cache /.coverage /data /config.py.* /.pytest_cache /.mypy_cache # tools /tools/Home.md /tools/token /tools/repos.json *.log *.crt # OS X .DS_Store # legacy stuff legacy/dist/ legacy/err.egg-info/ # tox .tox errbot-6.1.1+ds/.travis.yml000066400000000000000000000011751355337103200155350ustar00rootroot00000000000000sudo: false language: python matrix: include: - python: 3.6 env: TOXENV=py36 - python: 3.7-dev env: TOXENV=py37 - python: 3.6 env: TOXENV=pypi-lint - python: 3.6 env: TOXENV=codestyle install: pip install tox before_script: cp tests/config-travisci.py config.py script: tox # notification for gitter integration notifications: webhooks: urls: - https://webhooks.gitter.im/e/788e94bb42a75aa2df4c on_success: always # options: [always|never|change] default: always on_failure: always # options: [always|never|change] default: always on_start: true # default: false errbot-6.1.1+ds/CHANGES-pre-v4.rst000066400000000000000000000675521355337103200163540ustar00rootroot00000000000000v3.2.3 (2016-02-18) ------------------- fixes: - IRC: Use the NickMask helper for parsing IRC Identity and proper ACL (thx Marcus Carlsson) - IRC: Fix random UnicodeDecodeErrors (thx mr.Shu) - XMPP: Fix join on MUCRoom with password (thx Mikko Lehto) - XMPP: Fix join on Room list (from CHATROOM_PRESENCE for example) (thx Mikko Lehto) - Backup: NullBackend was missing few methods and was crashing. - IRC: Synchronize join and joined events v3.2.2 (2015-12-08) ------------------- fixes: - shutdown was not called properly anymore leading to possible plugin configuration loss. - fixed tarfile plugin install - fixed error reporting on webhook json parsing - fixed/hacked so the prompt on text mode appear after the asynchronous log entries features: - added a warning if the system encoding is not utf-8 v3.2.1 (2015-11-15) ------------------- other: - Pypi fixes. v3.2.0 (2015-11-13) ------------------- features: - Official support for Python 3.5 - The API surface is now type hinted (https://www.python.org/dev/peps/pep-0484/) and base classes are tagged Abstract. - Added send_templated() to the BotPlugin class to be able to use send() with a template - Various improvements to the ``@arg_botcmd`` decorator. - Now the bot can set its own status/presence with change_presence - Non-standard hipchat server (thx Barak Schiller) fixes: - Fixed various bugs with the ``@arg_botcmd`` decorator (`#516 `_) - Fixed warn_admins() on Telegram - Slack ACLs now properly check against usernames starting with `@` - Slack identifiers can now be built from a bare `#channel` string (without a username part) - Slack identifiers can now be built from a `<#C12345>` or `<@username>` string (the webclient formats them like this automatically when chatting with the bot) - HipChat backend now respects the `server` option under `BOT_IDENTITY` (`#544 `_) - The IRC backend will no longer throw UnicodeDecodeError but replaces characters which cannot be decoded as UTF-8 (`#570 `_, Mr. Shu) - Fixed a bug that would prevent the bot from joining password-protected rooms (`#578 `_, Mikko Lehto) other: - various internal improvements and refactoring - Removed some dead code - Removed deprecated bare_send and invite_to_room bot methods - Doc improvements (thx Anita Woodruff) v3.1.3 (2015-11-12) ------------------- updated the version checker to errbot.io. v3.1.2 (2015-11-05) ------------------- fixes: - XMPP: self.send on a mess.frm on XMPP was failing - XMPP: reply to a private message from a chatroom was failing - blacklist now deactivate automatically a plugin if activated - unblacklist new activate automatically a plugin v3.1.1 (2015-10-26) ------------------- fixes: - fix regression on !help (thx kromey) v3.1.0 (2015-10-22) ------------------- features: - now setup will install 'errbot' in the path instead of 'err.py' (thx mr.Shu) - new SUPPRESS_CMD_NOT_FOUND to simply ignore a command if it is not found (thx James O'Beirne) - err-shellexec in the list of repos (thx Will Fife) - msg.extras is a new message property to get extra metadata that doesn't fit into a traditional message like attachments for Slack (thx James O'Beirne) - Terse output - IRC: now you can use nickserv to auth the bot (thx mr.Shu) - IRC: COMPACT_OUTPUT option allows you to remove the ascii art around the tables. - BOT_ADMINS: having a simple string instead of a tuple is possible too (thx mr.Shu) fixes: - better error message for unblacklisting (thx Sijis) - respect optional prefix for re_botcmd (thx Travis Veazey) - fix breakage on pytest on deps (thx Joel Perras) - !help foo bar for foo_bar fix + cosmetic (thx James O'Beirne) - fixed path report for config.py in case of problem - yield not work with @arg_botcmd (thx Andre Van Der Merwe) - backup/restore fixes v3.0.4 (2015-09-12) ------------------- fixes: - Small setup.py cleanup - force XMPP to ascii rendering (xhtml-im is beyond broken) - Fixed !room list - Fixed !room occupants [room] on XMPP v3.0.3 (2015-08-26) ------------------- fixes: - fixed the missing path for relative imports in plugins. - better pre rendering on graphic backend - better !log tail rendering - add alt as an alternative modifier on graphic backend (it was problematic on MacOS) v3.0.2 (2015-08-26) ------------------- fixes: - multiple fixes for the graphic backend (it is waaay nicer now) - missing spots in doc and feedback for for activate/deactivate - aclattr fix for the slack backend - status uses more of the markdown goodies v3.0.1 (2015-08-20) ------------------- fixes: - IRC backend not starting. v3.0.0 (2015-08-17) ------------------- We have decided to promote this release as the v3 \\o/. This document includes all the changes since the last stable version (2.2.0). If you have any difficulty using this new release, feel free to jump into our `dev room on gitter `_. v3 New and noteworthy ~~~~~~~~~~~~~~~~~~~~~ - backends are now plugins too - new Slack backend (see the `config template `_ for details) - new Telegram backend - new Gitter backend (see `the gitter backend repo `_ for more info about installing it) - completely new rendering engine: now all text from either a plugin return or a template is **markdown extras** - you can test the various formatting under your backend with the ``!render test`` command. - the text backend exposes the original md, its html representation and ansi representation so plugin developers can anticipate what the rendering will look like under various backends. See the screenshots below: Slack_, Hipchat_, IRC_, Gitter_ and finally Text_. - completely revamped backup/restore feature (see ``!help backup``). - Identifiers are now generic (and not tight to XMPP anymore) with common notions of ``.person`` ``.room`` (for MUCIdentifiers) ``.client`` ``.nick`` and ``.displayname`` see `this doc `_ for details. - New ``!whoami`` command to debug identity problems for your plugins. - New ``!killbot`` command to stop your bot remotely in case of emergency. - New support for `argparse style command arguments `_ with the ``@arg_botcmd`` decorator. - IRC: file transfer from the bot is now supported (DCC) Minor improvements ~~~~~~~~~~~~~~~~~~ - hipchat endpoint can be used (#348) - XMPP server parameter can be overriden - deep internal reorganisation of the bot: the most visible change is that internal commands have been split into internal plugins. - IRC backend: we have now a reconnection logic on disconnect and on kick (see ``IRC_RECONNECT_ON_DISCONNECT`` in the config file for example) Stuff that might break you ~~~~~~~~~~~~~~~~~~~~~~~~~~ - if you upgrade from a previous version, please install: ``pip install markdown ansi Pygments "pygments-markdown-lexer>=0.1.0.dev29"`` - you need to add the type of backend you use in your config file instead of the command like. i.e. ``BACKEND = 'XMPP'`` - XMPP properties ``.node``, ``.domain`` and ``.resource`` on identifiers are deprecated, a backward compatibility layer has been added but we highly encourage you to not rely on those but use the generic ones from now on: ``.person``, ``.client`` and for MUCOccupants ``.room`` on top of ``.person`` and ``.client``. - To create identifiers from a string (i.e. if you don't get it from the bot itself) you now have to use ``build_identifier(string)`` to make the backend parse it - command line parameter -c needs to be the full path of your config file, it allows us to have different set of configs to test the bot. - campfire and TOX backends are now external plugins: see `the tox backend repo `_ and `the campfire backend repo `_ for more info about installing them. - any output from plugin is now considered markdown, it might break some of your output if you had any markup characters (\#, \-, \* ...). - we removed the gtalk support as it is going away. Bugs squashed ~~~~~~~~~~~~~ - plugin loader do not traverse __pycache__ and dotted directory anymore - import error at install time. - IRC backend compatibility with gitter - Better logging to debug plugin callbacks - Better dependency requirements (setup.py vs requirements.txt) - builtins are now named core_plugins (the plan is to move more there) - a lot of refactoring around globals (it enabled the third party plugins) - git should now work under Windows - None was documented as a valid value for the IRC rate limiter but was not. - removed xep_0004 from the xmpp backend (it was deprecated) since 3.0.0-rc1: - imtext was removing the \` for Slack - corrected the leaking
 in text/ansi
- fixed a restart loop in Telegram
- clear formatting in the Slack backend for angle brackets [thx @RobSpectre]
- XMPP: allow slashes in resources

Annex
~~~~~

.. _Slack:

Rendering under **Slack**:

.. image:: docs/imgs/slack.png

.. _Hipchat:

Rendering under **Hipchat**:

.. image:: docs/imgs/hipchat.png

.. _IRC:

Rendering under **IRC**:

.. image:: docs/imgs/IRC.png

.. _Gitter:

Rendering under **Gitter**:

.. image:: docs/imgs/gitter.png

.. _Text:

Rendering under **Text** (for plugin development):

.. image:: docs/imgs/text.png


v2.3.0-rc2 (2015-07-06)
-----------------------

fixes:

- import error at install time.


v2.3.0-beta (2015-07-05)
------------------------

features:

- new Slack backend
- third party backends (they are plugins too)
- completely revamped backup/restore feature.
- hipchat endpoint can be used (#348)
- XMPP server parameter can be overriden
- Identifiers are now generic (not tight to XMPP anymore)

fixes:

- IRC backend compatibility with gitter
- Better logging to debug plugin callbacks
- Better dependency requirements (setup.py vs requirements.txt)
- builtins are now named core_plugins (the plan is to move more there)
- a lot of refactoring around globals (it enabled the third party plugins)


v2.2.1 (2015-05-16)
-------------------

fixes:

- hipchat keepalive

v2.2.0 (2015-05-16)
-------------------

features:

- New AUTOINSTALL_DEPS config to autoinstall the dependencies required for plugins

fixes:

- Don't 3to2 the config template
- version pinned yapsy because of an incompatibility with the last version
- added timeout to the version check builtin

v2.2.0-beta (2015-02-16)
------------------------

features:

- New serverless tox backend (see http://tox.im for more info)
- New Presence callbacks with status messages etc.
- New file transfert support (upload and downloads) for selected backends
- New MUC management API
- added err-githubhook to the official repo list (thx Daniele Sluijters)
- added err-linksBot to the official repo list (thx Arnaud Vazard)
- added err-stash to the official repo list (thx Charles Gomes)
- shlex.split on split_args_with
- improved !status command (Thx Sijis Aviles)
- colorized log output
- configuration access improvements, it is now a property accessible from the plugins (self.bot_configuration) and the backends.
- bot can optionally name people it replies to in mucs with local conventions toto: or @toto etc... (thx Sijis Aviles)

fixes:

- complete pass & fixes with a static analyser
- better feedback when config.py is borken
- hipchat has been rewritten and goes through the API
- more consistency on properties versus setters/getters
- mac osx fixes (thx Andrii Kostenko)
- unicode fix on irc backend (thx Sijis Aviles)

v2.1.0 (2014-07-24)
-------------------

features:

- Various changes to the test backend:

  - `setUp `_
    method of `FullStackTest` now takes an `extra_plugin_dir` argument, deprecating the
    `extra_test_file` argument.
  - `popMessage` and `pushMessage` are now more pythonically called `pop_message` and
    `push_message`, though the old names continue to work.
  - New `testbot `_ fixture
    to write tests using `pytest `_.

- Better display of active plugins in debug info (#262).
- Allow optional username for IRC backend (#256).
- *Raw* option for the webhook API.
- `Regex-based `_ bot commands.
- Pretty-printed output of the !config command.

fixes:

- Fix make_ssl_certificate on Python 2.
- Newer version of Rocket, fixing an issue with releasing ports on OSX (#268).
- Only run 3to2 during actual install steps (#232).
- Ignore messages from self (#247).
- Import `irc.connection` within try/except block (#245).
- Better message recipient setting in XMPP MUC responses.
- Only configure XMPP MUC when having owner affiliation.
- Use SleekXMPP plugin `xep_0004` instead of deprecated `old_0004` (#236).


v2.0.0 (2014-01-05)
-------------------

features:

- split load/unload from blacklist/unblacklist
- provides a better feedback for 3to2 conversion
- better formatting for plugin list with unicode bullets
- better formatting for !reload
- better feedback on case of !reload problems
- made loglevel configuration (Thx Daniele Sluijters)
- added err-dnsnative to the plugin list.

fixes:

- Fixed a missing callback_connect on plugin activation
- Forced Python 3.3 as a minimal req for the py3 version as deps break with 3.2
- Fixed pip installs during setup.py
- warn_admin breakage on python2
- SSL IRC backend fix
- Various typos.

v2.0.0-rc2 (2013-11-28)
-----------------------

Migrated the version checker to github.io

fixes:

- Fix MUC login: Support tuple & add username
- Language correction (thx daenney)

v2.0.0-rc1 (2013-10-03)
-----------------------

features:

- Added err-faustbot to the official repo list
- Added the !room create command for adhoc room creation (google talk)
- Added sedbot to the official repos
- Added support for plugin based webviews
- Add err-agressive-keepalive to the official repos
- Allow botcmd's to yield values
- Allow configuration of MESSAGE_SIZE_LIMIT

fixes:

- Properly close shelf upon restart (thx Max Wagner)
- Fix inverted display of repo status (private/official) (thx Max Wagner)
- Include jid resource in Message.from/to (Thx Kha)
- Fix messed up display of status and repos commands (thx Max Wagner)
- fixed the standalone execution with -c parameter
- corrected the QT backend under python 3
- hipchat fix
- missing dependencies for SRV records (google compatibility)
- bug in the apropos while adding a command to chatroom
- XMPP: forward HTML of incoming messages (Thx Kha)
- corrected the linkyfier in the graphic interface
- corrected the status display of a plugin that failed at activation stage
- Handle disconnect events correctly

v2.0.0-beta (2013-03-31)
------------------------

features:

- SSL support for webhook callbacks
- JID unicode support
- Per user command history (Thanks to Leonid S. Usov https://github.com/leonid-s-usov)
- HIDE_RESTRICTED_COMMANDS option added to filter out the non accessoble commands from the help  (Thanks to Leonid S. Usov https://github.com/leonid-s-usov)
- err-markovbot has been added to the official plugins list (Thanks to Max Wagner https://github.com/MaxWagner)
- the version parsing now supports beta, alpha, rc etc ... statuses

other:

- python 3 compatibility
- xmpp backend has been replaced by sleekxmpp
- flask has been replaced by bottle (sorry flask no py3 support, no future) [edit from 2016: This is not true anymore].
- rocket is used as webserver with SSL support
- now the IRC backend uses the smpler python/irc package
- improved unittest coverage

v1.7.1 (2012-12-25)
-------------------

fixes:

- unicode encoding on jabber


v1.7.0 (2012-12-24)
-------------------

Incompatible changes:

For this one if your plugin uses PLUGIN_DIR, you will need to change it to self.plugin_dir as it is a runtime value now. 

fixes:

- yapsy 1.10 compatibility 
- better detection of self in MUC
- force python 2 for shebang lines
- Parses the real nick and the room and put it in the from identity of messages
- fix for JID Instance has no attribute '__len__'
- partial support for @ in JIDs nodes
- when a plugin was reloaded, it was not connect notified


features:

- botprefix is now optional on one on one chats
- fine grained access control
- better serialization to disk by default (protocol 2)
- configurable separate rate limiting for IRC for public and private chats
- added support for MUC with passwords
- bot prefixes can be of any length
- modular !help command (it lists the plugin list with no parameters instead of the full command list)


other:

- better unit tests
- Travis CI

v1.6.7 (2012-10-08)
-------------------

fixes:

- the XMPP from was not removed as it should and broke the gtalk compatibility
- fixed 'jid-malformed' error with build_reply()

features:

- new plugin : err-dnsutils https://github.com/zoni/err-dnsutils
- Now you can selectively divert chatroom answers from a list of specified commands to a private chat (avoids flooding on IRC for example)
- the logging can be done using sentry
- Err can now login using SSL on IRC (thx to Dan Poirier https://github.com/poirier)


v1.6.6 (2012-09-27)
-------------------

fixes:

- bot initiated messages were not correctly callbacked on jabber backend
- !apropos was generating an unicode error thx to https://github.com/zoni for the fix
- corrected a serie of issues related to the sharedmiddleware on flask
- fixed a regression on the IRC backend thx to https://github.com/nvdk for helping on those

features:

- added err-mailwatch to the official repo thx to https://github.com/zoni for the contribution
- added a "null" backend to stabilise the web ui

v1.6.5 (2012-09-10)
-------------------

fixes:

- https://github.com/errbotio/errbot/issues/59 [Thx to https://github.com/bubba-h57 & https://github.com/zoni for helping to diagnose it]

features:

- The graphical backend now uses a multiline chat to better reflect some backends.


v1.6.4 (2012-09-04)
-------------------

You will need to add 2 new config entries to your config.py. See below for details

fixes:

- Identity stripping problems
- fixed warn_admin that regressed
- close correctly shelves on connection drop [Thx to linux techie https://github.com/linuxtechie] 
- corrected the !status reporting was incorrect for non configured plugins (label C)
- force a complete reconnection on "See Other Host" XMPP message

features:

- You can now change the default prefix of the bot (new config BOT_PREFIX) [Thx to Ciaran Gultnieks https://github.com/CiaranG]
- Added an optional threadpool to execute commands in parallel (Experimental, new config : BOT_ASYNC)
- Now the bot waits on signal USR1 so you can do a kill -USR1 PID of err to make it spawn a local python console to debug it live
- Now you can have several config_*.py, one per backend (to be able to test specifically a backend without having to reconfigure each time the bot)

v1.6.3 (2012-08-26)
-------------------

fixes:

- !reload was causing a crash on templating
- !update was failing on internal_shelf
- several consistency fixups around Identity and Message, now they should behave almost the same was across all the backends
- corrected several unicode / utf-8 issues across the backends
- unified the standard xmpp and hipchat keep alive, they work the same

features:

- added err-timemachine, an "history" plugin that logs and indexes every messages. You can query it with a lucene syntax over specific dates etc ...
- Added a webserver UI from the webserver builtin plugin (disabled by default see !config webserver to enable it)
- Now if a config structure changed or failed, the bot will present you the config you had and the default template so you can adapt your current config easily
- Added the schema for xhtml-im so you can use your favorite xml editor to check what your templates are generating

v1.6.2 (2012-08-24)
-------------------

fixes:

- missing a dependency for python config [thx to Joshua Tobin https://github.com/joshuatobin]
- Fixing two logging debug statements that are mixed up [thx to Joshua Tobin https://github.com/joshuatobin]
- Removed the URL rewritting from the QT user interface

features:

- Added basic IRC support
- Now the BOT_EXTRA_PLUGIN_DIR can be a list so you can develop several plugins at the same time

v1.6.1 (2012-08-22)
-------------------
Simplified the installation.

fixes:

- put pyfire as an optional dependency as it is used only for the campfire backend
- put PySide as an optional dependency as it is used only for the QT graphical backend

v1.6.0 (2012-08-16)
-------------------

fixes:

- corrected a threading issue that was preventing err to quit
- the python shebangs lines where not generic
- the config path is not inserted first so we don't conflict with other installs
- corrected a corruption of the configs on some persistance stores on shutdown

features:

- Added support for CampFire
- Added support for Hipchat API with basic html messages
- Added support for webhooks
- Independent backends can be implemented
- In order to simplify : now botcmd and BotPlugin are both imported from errbot (we left a big fat warning for the old deprecated spot, they will be removed in next release)
- Better status report from !status (including Errors and non-configured plugins)


v1.5.1 (2012-08-11)
-------------------

fixes:

- the pypi package was not deploying html templates

v1.5.0 (2012-08-10)
-------------------

fixes:

- fix for ExpatError exception handling [Thx to linux techie https://github.com/linuxtechie]
- Graphic mode cosmetics enhancement [thx to Paul Labedan https://github.com/pol51]
- fix for high CPU usage  [Thx to linux techie https://github.com/linuxtechie]

features:

- Added XHTML-IM support with Jinja2 templating
- Better presentation on the !repos command
- load / unload of plugins is now persistent (they are blacklisted when unloaded)
- Better presentation of the !status command : Now you can see loaded, blacklisted and Erroneous plugins from there
- A new !about command with some credits and the current version
- Implemented the history navigation in the graphic test mode (up and down)
- Added an autocomplete in the graphic test mode
- Added the logo in the background of the graphic mode

v1.4.1 (2012-07-13)
-------------------

fixes:

- corrected a vicious bug when you use metaclasses on plugins with botcmd decorator generated with parameters
- don't call any callback message if the message is from the chat history
- dependency problem with dnspython, it fixes the compatibility with google apps [Thx to linux techie https://github.com/linuxtechie]
- on repos updates, err now recheck the dependencies (you never know if they changed after the update)

features:

- Added a new check_configuration callback now by default the configuration check is basic and no implementation has to be done on plugin side
- Warn the admins in case of command name clashes and fix them by prefixing the name of the plugin + -
- A brand new graphical mode so you can debug with images displayed etc ... (command line option -G) it requires pyside [thx to Paul Labedan https://github.com/pol51]
- A new !apropos command that search a term into the help descriptions [thx to Ben Van Daele https://github.com/benvd]
- Now the bot reconnects in case of bad internet connectivity [Thx to linux techie https://github.com/linuxtechie]
- The bot now supports a "remote chatroom relay" (relay all messages from a MUC to a list of users) on top of a normal relay (from a user to a list of MUC) 
     With this you can emulate a partychat mode.
- err-music [thx to Ben Van Daele https://github.com/benvd and thx to Tali Petrover https://github.com/atalyad]

v1.4.0 (2012-07-09)
-------------------
fixes:

- improved the detection of own messages
- automatic rejection if the configuration failed so it the plugin restart with a virgin config

features:

- send a close match tip if the command is not found
- added a polling facility for the plugins
- added loads of plugins to the official repos:
  err-coderwall     [thx to glenbot https://github.com/glenbot]
  err-nettools
  err-topgunbot     [thx to krismolendyke https://github.com/krismolendyke]
  err-diehardbot    [thx to krismolendyke https://github.com/krismolendyke]
  err-devops_borat  [thx to Vincent Alsteen https://github.com/valsteen]
  err-social
  err-rssfeed       [thx to Tali Petrover https://github.com/atalyad]
  err-translate     [thx to Ben Van Daele https://github.com/benvd]
  err-tourney

v1.3.1 (2012-07-02)
-------------------

fixes:

- nicer warning message in case of public admin command

features:

- added a warn_admins api for the plugins to warn the bot admins in case of serious problem
- added err-tv in the official repos list
- added an automatic version check so admins are warned if a new err is out
- now if a repo has a standard requirements.txt it will be checked upon to avoid admins having to dig in the logs (warning: it added setuptools as a new dependency for err itself)

v1.3.0 (2012-06-26)
-------------------

fixes:

- Security fix : the plugin directory permissions were too lax. Thx to Pinkbyte (Sergey Popov)
- Corrected a bug in the exit of test mode, the shelves could loose data
- Added a userfriendly git command check to notify if it is missing

features:

- Added a version check: plugins can define min_err_version and max_err_version to notify their compatibility
- Added an online configuration of the plugins. No need to make your plugin users hack the config.py anymore ! just use the command !config
- Added a minimum Windows support.

v1.2.2 (2012-06-21)
-------------------

fixes:

- Corrected a problem when executing it from the dev tree with ./scripts/err.py
- Corrected the python-daemon dependency
- Corrected the encoding problem from the console to better match what the bot will gives to the plugins on a real XMPP server
- Corrected a bug in the python path for the BOT_EXTRA_PLUGIN_DIR setup parameter

features:

- Added a dictionary mixin for the plugins themselves so you can access you data directly with self['entry']
- admin_only is now a simple parameter of @botcmd
- Implemented the history commands : !history !! !1 !2 !3

v1.2.1 (2012-06-16)
-------------------

fixes:

- Corrected a crash if the bot could not contact the server

features:

- Added a split_args_with to the botcmd decorator to ease the burden of parsing args on the plugin side
- Added the pid, uid, gid parameters to the daemon group to be able to package it on linux distributions


v1.2.0 (2012-06-14)
-------------------

fixes:

- Don't nag the user for irrelevant settings from the setting-template
- Added a message size security in the framework to avoid getting banned from servers when a plugin spills too much

features:

- Added a test mode (-t) to ease plugin development (no need to have XMPP client / server to install and connect to in order to test the bot)
- Added err-reviewboard a new plugin by Glen Zangirolam https://github.com/glenbot to the repos list
- Added subcommands supports like the function log_tail will match !log tail [args]

v1.1.1 (2012-06-12)
-------------------

fixes:

- Fixed the problem updating the core + restart
- Greatly improved the reporting in case of configuration mistakes.
- Patched the presence for a better Hipchat interop.

v1.1.0 (2012-06-10)
-------------------

features:

- Added the !uptime command
- !uninstall doesn't require a full restart anymore
- !update a plugin doesn't require a full restart anymore
- Simplified the usage of the asynchronous self.send() by stripping the last part of the JID for chatrooms
- Improved the !restart feature so err.py is standalone now (no need to have a err.sh anymore)
- err.py now takes 2 optional parameters : -d to daemonize it and -c to specify the location of the config file

v1.0.4 (2012-06-08)
-------------------
- First real release, fixups for Pypi compliance.

.. v9.9.9 (leave that there so master doesn't complain)
errbot-6.1.1+ds/CHANGES.rst000066400000000000000000000545311355337103200152320ustar00rootroot00000000000000v6.1.1 (2019-06-22)
-------------------

fixes:

- Installation using wheel distribution on python 3.6 or older

v6.1.0 (2019-06-16)
-------------------

features:

- Use python git instead of system git binary (#1296)

fixes:

- `errbot -l` cli error (#1315)
- Slack backend by pinning slackclient to supported version (#1343)
- Make --storage-merge merge configs (#1311)
- Exporting values in backup command (#1328)
- Rename Spark to Webex Teams (#1323)
- Various documentation fixes (#1310, #1327, #1331)

v6.0.0 (2019-03-23)
-------------------

features:

- TestBot: Implement inject_mocks method (#1235)
- TestBot: Add multi-line command test support (#1238)
- Added optional room arg to inroom
- Adds ability to go back to a previous room
- Pass telegram message id to the callback

fixes:

- Remove extra spaces in uptime output
- Fix/backend import error messages (#1248)
- Add docker support for installing package dependencies (#1245)
- variable name typo (#1244)
- Fix invalid variable name (#1241)
- sanitize comma quotation marks too (#1236)
- Fix missing string formatting in "Command not found" output (#1259)
- Fix webhook test to not call fixture directly
- fix: arg_botcmd decorator now can be used as plain method
- setup: removing dnspython
- pin markdown <3.0 because safe is deprecated

v6.0.0-alpha (2018-06-10)
-------------------------

major refactoring:

- Removed Yapsy dependency
- Replaced back Bottle and Rocket by Flask
- new Pep8 compliance
- added Python 3.7 support
- removed Python 3.5 support
- removed old compatibility cruft
- ported formats and % str ops to f-strings
- Started to add field types to improve type visibility across the codebase
- removed cross dependencies between PluginManager & RepoManager

fixes:

- Use sys.executable explicitly instead of just 'pip' (thx Bruno Oliveira)
- Pycodestyle fixes (thx Nitanshu)
- Help: don't add bot prefix to non-prefixed re cmds (#1199) (thx Robin Gloster)
- split_string_after: fix empty string handling (thx Robin Gloster)
- Escaping bug in dynamic plugins
- botmatch is now visible from the errbot module (fp to Guillaume Binet)
- flows: hint boolean was not forwarded
- Fix possible event without bot_id (#1073) (thx Roi Dayan)
- decorators were working only if kwargs were empty
- Message.clone was ignoring partial and flows


features:

- partial boolean to flag partial mesages (thx Meet Mangukiya)
- Slack: room joined callback (thx Jeremy Kenyon)
- XMPP: real_jid to get the jid the users logged in (thx Robin Gloster)
- The callback order set in the config is not globally respected
- Added a default parameter to the storage context manager


v5.2.0 (2018-04-04)
-------------------

fixes:

- backup fix : SyntaxError: literal_eval on file with statements (thx Bruno Oliveira)
- plugin_manager: skip plugins not in CORE_PLUGIN entirely (thx Dylan Page)
- repository search fix (thx Sijis)
- Text: mentions in the Text backend (thx Sijis)
- Text: double @ in replies (thx Sijis)
- Slack: Support breaking messages body attachment
- Slack: Add channelname to Slackroom (thx Davis Garana Pena)

features:

- Enable split arguments on room_join so you can use " (thx Robert Honig)
- Add support for specifying a custom log formatter (Thx Oz Linden)
- Add Sentry transport support (thx Dylan Page)
- File transfert support (send_stream_request) on the Hipchat backend (thx Brad Payne)
- Show user where they are in a flow (thx Elijah Roberts)
- Help commands are sorted alphabetically (thx Fabian Chong)
- Proxy support for Slack (thx deferato)


v5.1.3 (2017-10-15)
-------------------

fixes:

- Default --init config is now compatible with Text backend requirements.
- Windows: Config directories as raw string (Thx defAnfaenger)
- Windows: Repo Manager first time update (Thx Jake Shadle)
- Slack: fix Slack identities to be hashable
- Hipchat: fix HicpChat Server XMPP namespace (Thx Antti Palsola)
- Hipchat: more aggressive cashing of user list to avoid API quota exceeds (thx Roman)

v5.1.2 (2017-08-26)
-------------------

fixes:

- Text: BOT_IDENTITY to stay optional in config.py
- Hipchat: send_card fix for room name lookup (thx Jason Kincl)
- Hipchat: ACL in rooms

v5.1.1 (2017-08-12)
-------------------

fixes:

- allows spaces in BOT_PREFIX.
- Text: ACLs were not working (@user vs user inconsistency).

v5.1.0 (2017-07-24)
-------------------

fixes:

- allow webhook receivers on / (tx Robin Gloster)
- force utf-8 to release changes (thx Robert Krambovitis)
- don't generate an errbot section if no version is specified in plugin gen (thx Meet Mangukiya)
- callback on all unknown commands filters
- user friendly message when a room is not found
- webhook with no uri but kwargs now work as intended
- Slack: support for Enterprise Grid (thx Jasper)
- Hipchat: fix room str repr. (thx Roman)
- XMPP: fix for MUC users with @ in their names (thx Joon Guillen)
- certificate generation was failing under some conditions

features:

- Support for threaded messages (Slack initially but API is done for other backends to use)
- Text: now the text backend can emulate an inroom/inperson or asuser/asadmin behavior
- Text: autocomplete of command is now supported
- Text: multiline messages are now supported
- start_poller can now be restricted to a number of execution (thx Marek Suppa)
- recurse_check_structure back to public API (thx Alex Sheluchin)
- better flow status (thx lijah Roberts)
- !about returns a git tag instead of just 9.9.9 as version for a git checkout. (thx Sven)
- admin notifications can be set up to a set of users (thx Sijis Aviles)
- logs can be colorized with drak, light or nocolor as preference.

v5.0.1 (2017-05-08)
-------------------
hotfixes for v5.0.0.

fixes:
- fix crash for SUPPRESS_CMD_NOT_FOUND=True (thx Romuald Texier-Marcadé!)

breaking / API cleanups:
- Missed patch for 5.0.0: now the name of a plugin is defined by its name in .plug and not its class name.



v5.0.0 (2017-04-23)
-------------------

features:

- Add support for cascaded subcommands (cmd_sub1_sub2_sub3) (thx Jeremiah Lowin)
- You can now use symbolic links for your plugins
- Telegram: send_stream_request support added (thx Alexandre Manhaes Savio)
- Callback to unhandled messages (thx tamarin)
- flows: New option to disable the next step hint (thx Aviv Laufer)
- IRC: Added Notice support (bot can listen to them)
- Slack: Original slack event message is attached to Message (Thx Bryan Shelton)
- Slack: Added reaction support and Message.extras['url'] (Thx Tomer Chachamu)
- Text backend: readline support (thx Robert Coup)
- Test backend: stream requests support (thx Thomas Lee)

fixes:

- When a templated cmd crashes, it was crashing in the handling of the error.
- Slack: no more crash if a message only contains attachments
- Slack: fix for some corner case links (Thx Tomer Chachamu)
- Slack: fixed LRU for better performance on large teams
- Slack: fix for undefined key 'username' when the bot doesn't have one (thx Octavio Antonelli)

other:

- Tests: use conftest module to specify testbot fixture location (thx Pavel Savchenko)
- Python 3.6.x added to travis.
- Ported the yield tests to pytest 4.0
- Removed a deprecated dependency for the threadpool, now uses the standard one (thx Muri Nicanor)

breaking / API cleanups:

- removed deprecated presence attributes (nick and occupant)
- removed deprecated type from messages.
- utils.ValidationException has moved to errbot.ValidationException and is fully part of the API.
- {utils, errbot}.get_class_that_defined_method is now _bot.get_plugin_class_from_method
- utils.utf8 has been removed, it was a leftover for python 2 compat.
- utils.compat_str has been removed, it was a vestige for python 2 too.


v4.3.7 (2017-02-08)
-------------------

fixes:

- slack: compatibility  with slackclient > 1.0.5.
- render test fix (thx Sandeep Shantharam)

v4.3.6 (2017-01-28)
-------------------

fixes:

- regression with Markdown 2.6.8.

v4.3.5 (2016-12-21)
-------------------

fixes:

- slack: compatibility with slackclient > 1.0.2
- slack: block on reads on RTM (better response time) (Thx Tomer Chachamu)
- slack: fix link names (")
- slack: ignore channel_topic messages (thx Mikhail Sobolev)
- slack: Match ACLs for bots on integration ID
- slack: Process messages from webhook users
- slack: don't crash when unable to look up alternate prefix
- slack: trm_read refactoring (thx Chris Niemira)
- telegram: fix telegram ID test against ACLs
- telegram: ID as strings intead of ints (thx Pmoranga)
- fixed path to the config template in the startup error message (Thx Ondrej Skopek)

v4.3.4 (2016-10-05)
-------------------

features:

- Slack: Stream (files) uploads are now supported
- Hipchat: Supports for self-signed server certificates.

fixes:

- Card emulation support for links (Thx Robin Gloster)
- IRC: Character limits fix (Thx lqaz)
- Dependency check fix.


v4.3.3 (2016-09-09)
-------------------

fixes:

- err references leftovers
- requirements.txt is now standard (you can use git+https:// for example)

v4.3.2 (2016-09-04)
-------------------

hotfix:

- removed the hard dependency on pytest for the Text backend

v4.3.1 (2016-09-03)
-------------------

features:

- now the threadpool is of size 10 by default and added a configuration.

fixes:

- fixed imporlib/use pip as process (#835)  (thx Raphael Wouters)
- if pip is not found, don't crash errbot
- build_identifier to send message to IRC channels (thx mr Shu)


v4.3.0 (2016-08-10)
-------------------

v4.3 features
~~~~~~~~~~~~~

- `DependsOn:` entry in .plug and `self.get_plugin(...)` allowing you to make a plugin dependent from another.
- New entry in config.py: PLUGINS_CALLBACK_ORDER allows you to force a callback order on your installed plugins.
- Flows can be shared by a room if you build the flow with `FlowRoot(room_flow=True)`  (thx Tobias Wilken)
- New construct for persistence: `with self.mutable(key) as value:` that allows you to change by side
  effect value without bothering to save value back.

v4.3 Miscellaneous changes
~~~~~~~~~~~~~~~~~~~~~~~~~~

- This version work only on Python 3.4+ (see 4.2 announcement)
- Presence.nick is deprecated, simply use presence.identifier.nick instead.
- Slack: Bot identity is automatically added to BOT_ALT_PREFIXES
- The version checker now reports your Python version to be sure to not upgrade Python 2 users to 4.3
- Moved testing to Tox. We used to use a custom script, this improves a lot the local testing setup etc.
  (Thx Pedro Rodrigues)


v4.3 fixes
~~~~~~~~~~

- IRC: fixed IRC_ACL_PATTERN
- Slack: Mention callback improvements (Thx Ash Caire)
- Encoding error report was inconsistent with the value checked (Thx Steve Jarvis)
- core: better support for all the types of virtualenvs (Thx Raphael Wouters)


v4.2.2 (2016-06-24)
-------------------

fixes:

- send_templated fix
- CHATROOM_RELAY fix
- Blacklisting feedback message corrected

v4.2.1 (2016-06-10)
-------------------
Hotfix

- packaging failure under python2
- better README

v4.2.0 (2016-06-10)
-------------------

v4.2 Announcement
~~~~~~~~~~~~~~~~~

- Bye bye Python 2 ! This 4.2 branch will be the last to support Python 2. We will maintain bug fixes on it for at least
  the end of 2016 so you can transition nicely, but please start now !

  Python 3 has been released 8 years ago, now all the major distributions finally have it available, the ecosystem has
  moved on too. This was not the case at all when we started to port Errbot to Python 3.

  This will clean up *a lot* of code with ugly `if PY2`, unicode hacks, 3to2 reverse hacks all over the place and
  packaging tricks.
  But most of all it will finally unite the Errbot ecosystem under one language and open up new possibilities as we
  refrained from using py3 only features.

- A clarification on Errbot's license has been accepted. The contributors never intended to have the GPL licence
  be enforced for external plugins. Even if it was not clear it would apply, our new licence exception makes sure
  it isn't.
  Big big thanks for the amazing turnout on this one !


v4.2 New features
~~~~~~~~~~~~~~~~~

- Errbot initial installation. The initial installation has been drastically simplified::

    $ pip install errbot
    $ mkdir errbot; cd errbot
    $ errbot --init
    $ errbot -T
    >>>     <- You are game !!

  Not only that but it also install a development directory in there so it now takes only seconds to have an Errbot
  development environment.

- Part of this change, we also made most of the config.py entries with sane defaults, a lot of those settings were
  not even relevant for most users.

- cards are now supported on the graphic backend with a nice rendering (errbot -G)

- Hipchat: mentions are now supported.


v4.2 Miscellaneous changes
~~~~~~~~~~~~~~~~~~~~~~~~~~

- Documentation improvements
- Reorganization and rename of the startup files. Those were historically the first ones to be created and their meaning
  drifted over the years. We had err.py, main.py and errBot.py, it was really not clear what were their functions and
  why one has been violating the python module naming convention for so long :)
  They are now bootstrap.py (everything about configuring errbot), cli.py (everything about the errbot command line)
  and finally core.py (everything about the commands, and dispatching etc...).
- setup.py cleanup. The hacks in there were incorrect.

v4.2 fixes
~~~~~~~~~~

- core: excpetion formatting was failing on some plugin load failures.
- core: When replacing the prefix `!` from the doctrings only real commands get replaced (thx Raphael Boidol)
- core: empty lines on plugins requirements.txt does crash errbot anymore
- core: Better error message in case of malformed .plug file
- Text: fix on build_identifier (thx Pawet Adamcak)
- Slack: several fixes for identifiers parsing, the backend is fully compliant with Errbot's
  contract now (thx Raphael Boidol and Samuel Loretan)
- Hipchat: fix on room occupants (thx Roman Forkosh)
- Hipchat: fix for organizations with more than 100 rooms. (thx Naman Bharadwaj)
- Hipchat: fixed a crash on build_identifier

v4.1.3 (2016-05-10)
-------------------

hotfixes:

- Slack: regression on build_identifier
- Hipchat: regression on build_identifier (query for room is not supported)

v4.1.2 (2016-05-10)
-------------------

fixes:

- cards for hipchat and slack were not merged.

v4.1.1 (2016-05-09)
-------------------

fixes:

- Python 2.7 conversion error on err.py.

v4.1.0 (2016-05-09)
-------------------

v4.1 features
~~~~~~~~~~~~~

- Conversation flows: Errbot can now keep track of conversations with its users and
  automate part of the interactions in a state machine manageable from chat.
  see `the flows documentation `_
  for more information.

- Cards API: Various backends have a "canned" type of formatted response.
  We now support that for a better native integration with Slack and Hipchat.

- Dynamic Plugins API: Errbot has now an official API to build plugins at runtime (on the fly).
  see `the dynamic plugins doc `_

- Storage command line interface: It is now possible to provision any persistent setting from the command line.
  It is helpful if you want to automate end to end the deployment of your chatbot.
  see `provisioning doc `_

v4.1 Miscellaneous changes
~~~~~~~~~~~~~~~~~~~~~~~~~~

- Now if no [python] section is set in the .plug file, we assume Python 3 instead of Python 2.
- Slack: identifier.person now gives its username instead of slack id
- IRC: Topic change callback fixed. Thx Ezequiel Brizuela.
- Text/Test: Makes the identifier behave more like a real backend.
- Text: new TEXT_DEMO_MODE that removes the logs once the chat is started: it is made for presentations / demos.
- XMPP: build_identifier can now resolve a Room (it will eventually be available on other backends)
- Graphic Test backend: renders way better the chat, TEXT_DEMO_MODE makes it full screen for your presentations.
- ACLs: We now allow a simple string as an entry with only one element.
- Unit Tests are now all pure py.test instead of a mix of (py.test, nose and unittest)

v4.1 fixed
~~~~~~~~~~

- Better resillience on concurrent modifications of the commands structures.
- Allow multiline table cells. Thx Ilya Figotin.
- Plugin template was incorrectly showing how to check config. Thx Christian Weiske.
- Slack: DIVERT_TO_PRIVATE fix.
- Plugin Activate was not reporting correctly some errors.
- tar.gz packaged plugins are working again.


v4.0.3 (2016-03-17)
-------------------

fixes:

- XMPP backend compatibility with python 2.7
- Telegram startup error
- daemonize regression
- UTF-8 detection

v4.0.2 (2016-03-15)
-------------------

hotfixes:

- configparser needs to be pinned to a 3.5.0b2 beta
- Hipchat regression on Identifiers
- Slack: avoid URI expansion.

v4.0.1 (2016-03-14)
-------------------

hotfixes:

- v4 doesn't migrate plugin repos entries from v3.
- py2 compatibility.

v4.0.0 (2016-03-13)
-------------------

This is the next major release of errbot with significant changes under the hood.


v4.0 New features
~~~~~~~~~~~~~~~~~

- Storage is now implemented as a plugin as well, similar to command plugins and backends.
  This means you can now select different storage implementations or even write your own.

The following storage backends are currently available:

  + The traditional Python `shelf` storage.
  + In-memory storage for tests or ephemeral storage.
  + `SQL storage `_ which supports relational databases such as MySQL, Postgres, Redshift etc.
  + `Firebase storage `_ for the Google Firebase DB.
  + `Redis storage `_ (thanks Sijis Aviles!) which uses the Redis in-memory data structure store.

- Unix-style glob support in `BOT_ADMINS` and `ACCESS_CONTROLS` (see the updated `config-template.py` for documentation).

- The ability to apply ACLs to all commands exposed by a plugin (see the updated `config-template.py` for documentation).

- The mention_callcack() on IRC (mr. Shu).

- A new (externally maintained) `Skype backend `_.

- The ability to disable core plugins (such as `!help`, `!status`, etc) from loading (see `CORE_PLUGINS` in the updated `config-template.py`).

- Added a `--new-plugin` flag to `errbot` which can create an emply plugin skeleton for you.

- IPv6 configuration support on IRC (Mike Burke)

- More flexible access controls on IRC based on nickmasks (in part thanks to Marcus Carlsson).
  IRC users, see the new `IRC_ACL_PATTERN` in `config-template.py`.

- A new `callback_mention()` for plugins (not available on all backends).

- Admins are now notified about plugin startup errors which happen during bot startup

- The repos listed by the `!repos` command are now fetched from a public index and can be
  queried with `!repos query [keyword]`. Additionally, it is now possible to add your own
  index(es) to this list as well in case you wish to maintain a private index (special
  thanks to Sijis Aviles for the initial proof-of-concept implementation).


v4.0 fixed
~~~~~~~~~~

- IRC backend no longer crashes on invalid UTF-8 characters but instead replaces
  them (mr. Shu).

- Fixed joining password-protected rooms (Mikko Lehto)

- Compatibility to API changes introduced in slackclient-1.0.0 (used by the Slack backend).

- Corrected room joining on IRC (Ezequiel Hector Brizuela).

- Fixed *"team_join event handler raised an exception"* on Slack.

- Fixed `DIVERT_TO_PRIVATE` on HipChat.

- Fixed `DIVERT_TO_PRIVATE` on Slack.

- Fixed `GROUPCHAT_NICK_PREFIXED` not prefixing the user on regular commands.

- Fixed `HIDE_RESTRICTED_ACCESS` from accidentally sending messages when issuing `!help`.

- Fixed `DIVERT_TO_PRIVATE` on IRC.

- Fixed markdown rendering breaking with `GROUPCHAT_NICK_PREFIXED` enabled.

- Fixed `AttributeError` with `AUTOINSTALL_DEPS` enabled.

- IRC backend now cleanly disconnects from IRC servers instead of just cutting the connection.

- Text mode now displays the prompt beneath the log output

- Plugins which fail to install no longer remain behind, obstructing a new installation attempt


v4.0 Breaking changes
~~~~~~~~~~~~~~~~~~~~~

- The underlying implementation of Identifiers has been drastically refactored
  to be more clear and correct. This makes it a lot easier to construct Identifiers
  and send messages to specific people or rooms.

- The file format for `--backup` and `--restore` has changed between 3.x and 4.0
  On the v3.2 branch, backup can now backup using the new v4 format with `!backupv4` to
  make it possible to use with `--restore` on errbot 4.0.

A number of features which had previously been deprecated have now been removed.
These include:

- `configure_room` and `invite_in_room` in `XMPPBackend` (use the
  equivalent functions on the `XMPPRoom` object instead)

- The `--xmpp`, `--hipchat`, `--slack` and `--irc` command-line options
  from `errbot` (set a proper `BACKEND` in `config.py` instead).


v 4.0 Miscellaneous changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~

- Version information is now specified in plugin `.plug` files instead of in
  the Python class of the plugin.

- Updated `!help` output, more similar to Hubot's help output (James O'Beirne and Sijis Aviles).

- XHTML-IM output can now be enabled on XMPP again.

- New `--version` flag on `errbot` (mr. Shu).

- Made `!log tail` admin only (Nicolas Sebrecht).

- Made the version checker asynchronous, improving startup times.

- Optionally allow bot configuration from groupchat

- `Message.type` is now deprecated in favor of `Message.is_direct` and `Message.is_group`.

- Some bundled dependencies have been refactored out into external dependencies.

- Many improvements have been made to the documention, both in docstrings internally as well
  as the user guide on the website at http://errbot.io.


Further info on identifier changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

- Person, RoomOccupant and Room are now all equal and can be used as-is to send a message
  to a person, a person in a Room or a Room itself.

The relationship is as follow:

.. image:: https://raw.githubusercontent.com/errbotio/errbot/master/docs/_static/arch/identifiers.png
   :target: https://github.com/errbotio/errbot/blob/master/errbot/backends/base.py

For example: A Message sent from a room will have a RoomOccupant as frm and a Room as to.

This means that you can now do things like:

- `self.send(msg.frm, "Message")`
- `self.send(self.query_room("#general"), "Hello everyone")`



.. v9.9.9 (leave that there so master doesn't complain)
errbot-6.1.1+ds/COPYING000066400000000000000000001045131355337103200144570ustar00rootroot00000000000000                    GNU GENERAL PUBLIC LICENSE
                       Version 3, 29 June 2007

 Copyright (C) 2007 Free Software Foundation, Inc. 
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.

                            Preamble

  The GNU General Public License is a free, copyleft license for
software and other kinds of works.

  The licenses for most software and other practical works are designed
to take away your freedom to share and change the works.  By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.  We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors.  You can apply it to
your programs, too.

  When we speak of free software, we are referring to freedom, not
price.  Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.

  To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights.  Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.

  For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received.  You must make sure that they, too, receive
or can get the source code.  And you must show them these terms so they
know their rights.

  Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.

  For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software.  For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.

  Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so.  This is fundamentally incompatible with the aim of
protecting users' freedom to change the software.  The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable.  Therefore, we
have designed this version of the GPL to prohibit the practice for those
products.  If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.

  Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary.  To prevent this, the GPL assures that
patents cannot be used to render the program non-free.

  The precise terms and conditions for copying, distribution and
modification follow.

                       TERMS AND CONDITIONS

  0. Definitions.

  "This License" refers to version 3 of the GNU General Public License.

  "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.

  "The Program" refers to any copyrightable work licensed under this
License.  Each licensee is addressed as "you".  "Licensees" and
"recipients" may be individuals or organizations.

  To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy.  The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.

  A "covered work" means either the unmodified Program or a work based
on the Program.

  To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy.  Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.

  To "convey" a work means any kind of propagation that enables other
parties to make or receive copies.  Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.

  An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License.  If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.

  1. Source Code.

  The "source code" for a work means the preferred form of the work
for making modifications to it.  "Object code" means any non-source
form of a work.

  A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.

  The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form.  A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.

  The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities.  However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work.  For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.

  The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.

  The Corresponding Source for a work in source code form is that
same work.

  2. Basic Permissions.

  All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met.  This License explicitly affirms your unlimited
permission to run the unmodified Program.  The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work.  This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.

  You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force.  You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright.  Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.

  Conveying under any other circumstances is permitted solely under
the conditions stated below.  Sublicensing is not allowed; section 10
makes it unnecessary.

  3. Protecting Users' Legal Rights From Anti-Circumvention Law.

  No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.

  When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.

  4. Conveying Verbatim Copies.

  You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.

  You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.

  5. Conveying Modified Source Versions.

  You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:

    a) The work must carry prominent notices stating that you modified
    it, and giving a relevant date.

    b) The work must carry prominent notices stating that it is
    released under this License and any conditions added under section
    7.  This requirement modifies the requirement in section 4 to
    "keep intact all notices".

    c) You must license the entire work, as a whole, under this
    License to anyone who comes into possession of a copy.  This
    License will therefore apply, along with any applicable section 7
    additional terms, to the whole of the work, and all its parts,
    regardless of how they are packaged.  This License gives no
    permission to license the work in any other way, but it does not
    invalidate such permission if you have separately received it.

    d) If the work has interactive user interfaces, each must display
    Appropriate Legal Notices; however, if the Program has interactive
    interfaces that do not display Appropriate Legal Notices, your
    work need not make them do so.

  A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit.  Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.

  6. Conveying Non-Source Forms.

  You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:

    a) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by the
    Corresponding Source fixed on a durable physical medium
    customarily used for software interchange.

    b) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by a
    written offer, valid for at least three years and valid for as
    long as you offer spare parts or customer support for that product
    model, to give anyone who possesses the object code either (1) a
    copy of the Corresponding Source for all the software in the
    product that is covered by this License, on a durable physical
    medium customarily used for software interchange, for a price no
    more than your reasonable cost of physically performing this
    conveying of source, or (2) access to copy the
    Corresponding Source from a network server at no charge.

    c) Convey individual copies of the object code with a copy of the
    written offer to provide the Corresponding Source.  This
    alternative is allowed only occasionally and noncommercially, and
    only if you received the object code with such an offer, in accord
    with subsection 6b.

    d) Convey the object code by offering access from a designated
    place (gratis or for a charge), and offer equivalent access to the
    Corresponding Source in the same way through the same place at no
    further charge.  You need not require recipients to copy the
    Corresponding Source along with the object code.  If the place to
    copy the object code is a network server, the Corresponding Source
    may be on a different server (operated by you or a third party)
    that supports equivalent copying facilities, provided you maintain
    clear directions next to the object code saying where to find the
    Corresponding Source.  Regardless of what server hosts the
    Corresponding Source, you remain obligated to ensure that it is
    available for as long as needed to satisfy these requirements.

    e) Convey the object code using peer-to-peer transmission, provided
    you inform other peers where the object code and Corresponding
    Source of the work are being offered to the general public at no
    charge under subsection 6d.

  A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.

  A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling.  In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage.  For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product.  A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.

  "Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source.  The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.

  If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information.  But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).

  The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed.  Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.

  Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.

  7. Additional Terms.

  "Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law.  If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.

  When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it.  (Additional permissions may be written to require their own
removal in certain cases when you modify the work.)  You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.

  Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:

    a) Disclaiming warranty or limiting liability differently from the
    terms of sections 15 and 16 of this License; or

    b) Requiring preservation of specified reasonable legal notices or
    author attributions in that material or in the Appropriate Legal
    Notices displayed by works containing it; or

    c) Prohibiting misrepresentation of the origin of that material, or
    requiring that modified versions of such material be marked in
    reasonable ways as different from the original version; or

    d) Limiting the use for publicity purposes of names of licensors or
    authors of the material; or

    e) Declining to grant rights under trademark law for use of some
    trade names, trademarks, or service marks; or

    f) Requiring indemnification of licensors and authors of that
    material by anyone who conveys the material (or modified versions of
    it) with contractual assumptions of liability to the recipient, for
    any liability that these contractual assumptions directly impose on
    those licensors and authors.

  All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10.  If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term.  If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.

  If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.

  Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.

  8. Termination.

  You may not propagate or modify a covered work except as expressly
provided under this License.  Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).

  However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.

  Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.

  Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License.  If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.

  9. Acceptance Not Required for Having Copies.

  You are not required to accept this License in order to receive or
run a copy of the Program.  Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance.  However,
nothing other than this License grants you permission to propagate or
modify any covered work.  These actions infringe copyright if you do
not accept this License.  Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.

  10. Automatic Licensing of Downstream Recipients.

  Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License.  You are not responsible
for enforcing compliance by third parties with this License.

  An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations.  If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.

  You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License.  For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.

  11. Patents.

  A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based.  The
work thus licensed is called the contributor's "contributor version".

  A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version.  For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.

  Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.

  In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement).  To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.

  If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients.  "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.

  If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.

  A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License.  You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.

  Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.

  12. No Surrender of Others' Freedom.

  If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License.  If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all.  For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.

  13. Use with the GNU Affero General Public License.

  Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work.  The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.

  14. Revised Versions of this License.

  The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time.  Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

  Each version is given a distinguishing version number.  If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation.  If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.

  If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.

  Later license versions may give you additional or different
permissions.  However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.

  15. Disclaimer of Warranty.

  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

  16. Limitation of Liability.

  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.

  17. Interpretation of Sections 15 and 16.

  If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.

                     END OF TERMS AND CONDITIONS

            How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.

  To do so, attach the following notices to the program.  It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.

    
    Copyright (C)   

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see .

Also add information on how to contact you by electronic and paper mail.

  If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:

      Copyright (C)   
    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
    This is free software, and you are welcome to redistribute it
    under certain conditions; type `show c' for details.

The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License.  Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".

  You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.

  The GNU General Public License does not permit incorporating your program
into proprietary programs.  If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library.  If this is what you want to do, use the GNU Lesser General
Public License instead of this License.  But first, please read
.
errbot-6.1.1+ds/MANIFEST.in000066400000000000000000000004361355337103200151610ustar00rootroot00000000000000include COPYING
include *.rst
include errbot/*.svg
include errbot/templates/*.md
include errbot/templates/*.tmpl
include errbot/templates/initdir/*.tmpl
include errbot/templates/initdir/*.plug
include errbot/templates/initdir/*.py
include errbot/core_plugins/templates/*.md
prune tests
errbot-6.1.1+ds/README.rst000066400000000000000000000201641355337103200151120ustar00rootroot00000000000000.. image:: https://errbot.readthedocs.org/en/latest/_static/errbot.png
   :target: http://errbot.io

|

.. image:: https://img.shields.io/travis/errbotio/errbot/master.svg
   :target: https://travis-ci.org/errbotio/errbot/

.. image:: https://img.shields.io/pypi/v/errbot.svg
   :target: https://pypi.python.org/pypi/errbot
   :alt: Latest Version

.. image:: https://img.shields.io/badge/License-GPLv3-green.svg
   :target: https://pypi.python.org/pypi/errbot
   :alt: License

.. image:: https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg
   :target: https://gitter.im/errbotio/errbot?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
   :alt: Join the chat at https://gitter.im/errbotio/errbot

|


Errbot
======

Errbot is a chatbot. It allows you to start scripts interactively from your chatrooms
for any reason: random humour, chatops, starting a build, monitoring commits, triggering
alerts...

It is written and easily extensible in Python.

Errbot is available as open-source software and released under the GPL v3 license.


Features
--------

Chat servers support
~~~~~~~~~~~~~~~~~~~~

**Built-in**

- IRC support
- `Hipchat support `_
- `Slack support `_
- `Telegram support `_
- `XMPP support `_

**With add-ons**

- `CampFire `_ (See `instructions `__)
- `Cisco Spark `_ (See `instructions `__)
- `Discord `_ (See `instructions `__)
- `Gitter support `_ (See `instructions `__)
- `Matrix `_ (See `instructions `__)
- `Mattermost `_ (See `instructions `__)
- `RocketChat `_ (See `instructions `__)
- `Skype `_ (See `instructions `__)
- `TOX `_ (See `instructions `__)
- `VK `_ (See `instructions `__)
- `Zulip `_ (See `instructions `__)


Administration
~~~~~~~~~~~~~~

After the initial installation and security setup, Errbot can be administered by just chatting to the bot (chatops).

- install/uninstall/update/enable/disable private or public plugins hosted on git
- plugins can be configured from chat
- direct the bot to join/leave Multi User Chatrooms (MUC)
- Security: ACL control feature (admin/user rights per command)
- backup: an integrated command !backup creates a full export of persisted data.
- logs: can be inspected from chat or streamed to Sentry.

Developer features
~~~~~~~~~~~~~~~~~~

- Very easy to extend in Python! (see below)
- Presetup storage for every plugin i.e. ``self['foo'] = 'bar'`` persists the value.
- Conversation flows to track conversation states from users.
- Webhook callbacks support
- supports `markdown extras `_ formatting with tables, embedded images, links etc.
- configuration helper to allow your plugin to be configured by chat
- Graphical and text development/debug consoles
- Self-documenting: your docstrings become help automatically
- subcommands and various arg parsing options are available (re, command line type)
- polling support: your can setup a plugin to periodically do something
- end to end test backend
- card rendering under Slack and Hipchat.

Community and support
---------------------

If you have:

- a quick question feel free to join us on chat at `errbotio/errbot on Gitter `_.
- a plugin development question please use `Stackoverflow `_ with the tags `errbot` and `python`.
- a bug to report or a feature request, please use our `GitHub project page `_.

For more general discussion and announcements, you can join us on `google plus community `_.
You can also ping us on Twitter with the hashtag ``#errbot``.


Installation
------------

Prerequisites
~~~~~~~~~~~~~

Errbot runs under Python 3.3+ on Linux, Windows and Mac. For some chatting systems you'll need a key or a login for your bot to access it.
Note: Python 2 support is still available in `errbot-4.2.x`, but it is going away.

Quickstart
~~~~~~~~~~

We recommend to setup a `virtualenv `_.

1. Install `errbot` from pip
2. Make a directory somewhere (here called `errbot`) to host Errbot's data files
3. Initialize the directory
4. Try out Errbot in text mode

.. code:: bash

    $ pip install errbot
    $ mkdir errbot; cd errbot
    $ errbot --init
    $ errbot

It will show you a prompt `>>>` so you can talk to your bot directly! Try `!help` to get started.

Adding support for a chat system
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

For the built-ins, just use one of those options `slack, hipchat, telegram, IRC, XMPP` with pip, you can still do it
after the initial installation to add the missing support for example ::

   $ pip install "errbot[slack]"

For the external ones (Skype, Gitter, Discord etc ...), please follow their respective github pages for instructions.

Configuration
~~~~~~~~~~~~~

In order to configure Errbot to connect to one of those chat systems you'll need to tweak the `config.py` file generated
by `errbot --init`.

To help you, we have a documented template available here: `config-template.py `_.

Note: even if you changed the BACKEND from the configuration, you can still use `errbot -T` and `errbot -G` to test
out your instance locally (in text and graphic mode respectively).

Starting Errbot as a daemon
~~~~~~~~~~~~~~~~~~~~~~~~~~~

If all that worked, you can now use the -d (or --daemon) parameter to run it in a
detached mode:

.. code:: bash

    errbot --daemon

Interacting with the Bot
------------------------

After starting Errbot, you should add the bot to your buddy list if you haven't already.
You'll need to invite the bot explicitly to chatrooms on some chat systems too.
You can now send commands directly to the bot!

To get a list of all available commands, you can issue:

.. code:: bash

    !help

If you just wish to know more about a specific command you can issue:

.. code:: bash

    !help command

Managing plugins
~~~~~~~~~~~~~~~~

You can administer the bot in a one-on-one chat if your handle is in the BOT_ADMINS list in `config.py`.

For example to keyword search in the public plugin repos you can issue:

.. code:: bash

    !repos search jira

To install a plugin from this list, issue:

.. code:: bash

    !repos install 


For example `!repos install errbotio/err-imagebot`.

Writing plugins
---------------

Writing your own plugins is extremely simple. `errbot --init` will have installed in the `plugins` subdirectory a plugin
called `err-example` you can use as a base.

As an example, this is all it takes to create a "Hello, world!" plugin for Errbot:

.. code:: python

    from errbot import BotPlugin, botcmd

    class Hello(BotPlugin):
        """Example 'Hello, world!' plugin for Errbot"""

        @botcmd
        def hello(self, msg, args):
            """Return the phrase "Hello, world!" to you"""
            return "Hello, world!"

This plugin will create the command "!hello" which, when issued, returns "Hello, world!"
to you. For more info on everything you can do with plugins, see the
`plugin development guide `_.

Contribution to Errbot itself
-----------------------------

Feel free to fork and propose changes on `github `_
errbot-6.1.1+ds/conftest.py000066400000000000000000000000521355337103200156140ustar00rootroot00000000000000pytest_plugins = ['errbot.backends.test']
errbot-6.1.1+ds/docs/000077500000000000000000000000001355337103200143505ustar00rootroot00000000000000errbot-6.1.1+ds/docs/Makefile000066400000000000000000000151651355337103200160200ustar00rootroot00000000000000# Makefile for Sphinx documentation
#

# You can set these variables from the command line.
SPHINXOPTS    =
SPHINXBUILD   = sphinx-build
PAPER         =
BUILDDIR      = _build
EXTRADIR      = _extra

# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif

# Internal variables.
PAPEROPT_a4     = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .

.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext

help:
	@echo "Please use \`make ' where  is one of"
	@echo "  html       to make standalone HTML files"
	@echo "  dirhtml    to make HTML files named index.html in directories"
	@echo "  singlehtml to make a single large HTML file"
	@echo "  pickle     to make pickle files"
	@echo "  json       to make JSON files"
	@echo "  htmlhelp   to make HTML files and a HTML help project"
	@echo "  qthelp     to make HTML files and a qthelp project"
	@echo "  devhelp    to make HTML files and a Devhelp project"
	@echo "  epub       to make an epub"
	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
	@echo "  latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
	@echo "  text       to make text files"
	@echo "  man        to make manual pages"
	@echo "  texinfo    to make Texinfo files"
	@echo "  info       to make Texinfo files and run them through makeinfo"
	@echo "  gettext    to make PO message catalogs"
	@echo "  changes    to make an overview of all changed/added/deprecated items"
	@echo "  xml        to make Docutils-native XML files"
	@echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
	@echo "  linkcheck  to check all external links for integrity"
	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"

clean:
	rm -rf $(BUILDDIR)/*

html:
	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
	@echo
	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."

dirhtml:
	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
	@echo
	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."

singlehtml:
	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
	@echo
	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."

pickle:
	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
	@echo
	@echo "Build finished; now you can process the pickle files."

json:
	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
	@echo
	@echo "Build finished; now you can process the JSON files."

htmlhelp:
	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
	@echo
	@echo "Build finished; now you can run HTML Help Workshop with the" \
	      ".hhp project file in $(BUILDDIR)/htmlhelp."

qthelp:
	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
	@echo
	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Err.qhcp"
	@echo "To view the help file:"
	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Err.qhc"

devhelp:
	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
	@echo
	@echo "Build finished."
	@echo "To view the help file:"
	@echo "# mkdir -p $$HOME/.local/share/devhelp/Err"
	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Err"
	@echo "# devhelp"

epub:
	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
	@echo
	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."

latex:
	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
	@echo
	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
	@echo "Run \`make' in that directory to run these through (pdf)latex" \
	      "(use \`make latexpdf' here to do that automatically)."

latexpdf:
	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
	@echo "Running LaTeX files through pdflatex..."
	$(MAKE) -C $(BUILDDIR)/latex all-pdf
	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."

latexpdfja:
	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
	@echo "Running LaTeX files through platex and dvipdfmx..."
	$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."

text:
	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
	@echo
	@echo "Build finished. The text files are in $(BUILDDIR)/text."

man:
	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
	@echo
	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."

texinfo:
	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
	@echo
	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
	@echo "Run \`make' in that directory to run these through makeinfo" \
	      "(use \`make info' here to do that automatically)."

info:
	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
	@echo "Running Texinfo files through makeinfo..."
	make -C $(BUILDDIR)/texinfo info
	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."

gettext:
	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
	@echo
	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."

changes:
	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
	@echo
	@echo "The overview file is in $(BUILDDIR)/changes."

linkcheck:
	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
	@echo
	@echo "Link check complete; look for any errors in the above output " \
	      "or in $(BUILDDIR)/linkcheck/output.txt."

doctest:
	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
	@echo "Testing of doctests in the sources finished, look at the " \
	      "results in $(BUILDDIR)/doctest/output.txt."

xml:
	$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
	@echo
	@echo "Build finished. The XML files are in $(BUILDDIR)/xml."

pseudoxml:
	$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
	@echo
	@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
errbot-6.1.1+ds/docs/README.rst000066400000000000000000000026051355337103200160420ustar00rootroot00000000000000Website and documentation
=========================

Errbot's website and documentation are built using `Sphinx`_. Useful
references when contributing to the docs are the `reStructuredText Primer`_
and `Inline markup`_ documents.


Requirements
------------

Documentation *must* be built using Python 3. Additionally, extra requirements
must be installed, which may be done through `pip install -r docs/requirements.txt`
(note: you must run this from the root of the repository).

You must also have make installed in order to use the supplied Makefile.


Building and viewing locally
----------------------------

With the necessary requirements installed, the documentation can be built using
the command `make html`. Once generated, the resulting pages can be viewed by
opening `_build/html/index.html` in your webbrowser of choice.


Publishing to Read the Docs
---------------------------

*Note: This is only relevant to project maintainers*

Read the Docs should rebuild the site automatically when new commits are pushed.
When new project releases are made, it may be necessary to add the new version
and remove older versions (to prevent clutter in the version drop-down).
This can be done at https://readthedocs.org/dashboard/errbot/versions/.


.. _Sphinx: http://sphinx-doc.org/
.. _reStructuredText Primer: http://sphinx-doc.org/rest.html
.. _Inline markup: http://sphinx-doc.org/markup/inline.html
errbot-6.1.1+ds/docs/_static/000077500000000000000000000000001355337103200157765ustar00rootroot00000000000000errbot-6.1.1+ds/docs/_static/arch/000077500000000000000000000000001355337103200167135ustar00rootroot00000000000000errbot-6.1.1+ds/docs/_static/arch/identifiers.png000066400000000000000000000400021355337103200217220ustar00rootroot00000000000000PNG


IHDRsBITO	pHYs+ IDATx}|S?w{0KqMPPM(+eL
h&Qn6hXl*A8ThY	4Eք^=T@r7$u[[KNN$|nFP;n}D>P"d(J%B!@}D>P"d(J%B!@}D>P"d(J%B!@}D>P"d(J%Bu1X-//x222ƍ2p@anZ۱c}>N9}qju\D4yhZ%KǭYf͚5>O#bz>z&eف[.%%Eq3f)++3RB'3eff,i&:P~~5kqE="3yyy|ڵױ/_l2~>kGQ''Nt&&&nڴIDٳg;eټ<1lx޴4㪪0:
6ԩSeeeFQ@'A|,@0֭#B!~ltڕӧOc/ӏ=Z]]Eh~<\.ƎYf锺!*>yL"uCbܸqDTRR"uC T0~[__/uC$;;{F+D[,| V>}a#OX	ʅh%B!@}D>P"d(J%B!@}D>P"d(J%B!@}D>P"d(J%B)^t6
ju|-=j}sjZszOuiMZQ:KPS;ˬ22{
KDi5#'wۯ)%l)($Ke[>G^`,slʁ1/4yqb}jgeeeU)kӺ
x;VZK䈈*-=|EthfouʆۑZOg0my;j	's>xG~}3^9Zܛ
p{fd=f;nmۖi?z޺
A5oڶxAJݚI&\``;r8_[Y^mj"Q}V4$6HgUk34c>96feְkjSnڶmģ[v>B̕q^=99Zc^:1YU;ѨQû55;hԨچw;v[ra|ZIHNv'E힜=VE*&1999[rF(qԨ!	f]:kdуCTc7-uC`} p_Q8AJ;~?6ݲ?@K>ɘ2Dؿ032E&QC@ܡC>mCޭv	} i5&;y"y\ѯ:*}mNݡ[d-MCTw@t-|M(yNp͍i4"ص^HMi01/t@eCMv~O顫?&|)Ak>#"bH(9d\_Pz4$eAv6ɚz˞o81+w㖿0ķuRe{*+w)kCjo̚foiivcVSWSgdž|ӘU
C00Ë*v&,T}FZ8dͽϊM~~g+W|iUp|-}Ԃ7?t4vĖ5I.(XGJ82o!bn܆صk
dҥ/rAAf-0%B!@}D>P"d(J%B!@}D>P"d(J%B!@}D>P"d('Hh^/u`0H	dl߿_&(KeeMBɃV%: ~t:
@Ƀh$D¨&55}:رC(_ɾ((uE|ǎǿRBSBO6l6͜9SDիWՍ1NWWWv)SHݖUWWq\mm-&}rp8h555R%:qOr7w\_tCɉhǍ;૩f a+?awygR7'JxPG9w\ƌ/s'ud4##fĈ>@ONj%"a-[c_>Xt)?`6N-0Aɘl%%%fJJʸq4Mh|yLMMMX#Fl6ɬ(>cYt\.TF1^#u[ ܐ}уeY˲
O>za#FFt:NCGOɐ}N3//bѨ|>NlfY`:LJJZi40Lnn.y^ҵkB,Or^os	sww1c4

"g ~tǓi2GD|w] dx 55UTk	Q0qeVv:LdYv߾}:Nع$x9D!}p+'ꚞ.Axwѡ-]yypE8x^BBE'@uu脀%x˗tUaׯQ8iHB(!EOX[?a|(:u׿5T-uGx2/_fn頄aꐽ P4u@KY֦:.xV+:g@ch	u@8f؄awrf3k4͛793Lt<; 8mXpgh4 Jqqqǿ0P0d^b۞?![":DZ~!샛Fi:-++h4QNNs"Xqh4G7}$m-O.r+..nI- effkn|ak`@A1. &
>"r:1xQ[B	r1*9il6YZ7Z.+i#gh4D'}h9uEEEMM,`CBU>Td&'xBA;aN,Lܟs*)˲cewxRRR"DZ>EFq:2
>"r:ޟd^Y)WonB٧PM7e|<
Ƽ
%
bY5">%jZ9C)Cs΍!}"lv85'$Nnn"YXPUbD}J!侤٧r/h3Q1v@>%VeYV	CA^jr)'IoӃA}ь8aqM!V^rHB?|_2LEMF{,k0|>?%C/:.9zǣhMDUkflE1p3FEohK+/CB)/zx


|4DLB:,l&7:qȑ#[D(T/!dF
/aONX<"BaeW:_4AO6~!	Y,&zQ'^7--
Wސ(CYYxd
c^%$t:_9w隞Wr!O)d_3L
 ]s
M}j
O5!/rC	}tt
sAqDchsrrۅ!zzƁ/r8.WސQt2/FO'?an7/!".9$S?9ZG%KTpt@Ⱦ d';>5;q8>A':&9"}%^x<D/b!dD6M@iiii(ˉ4Jn/a6# CE_$CIeٴ&7/ba/VEE15Y \./j "/|p!,˦oAI&1x&4~'d_8Ck l}!'̝;d-!Zs85"/%83G	":/Tp!-F} cސ`YVTJ
}%^/nb75d_tuuu(ހij},˖,qN$	0h1b.z].>?R''''??}#8T{F'U_&&w-pܙ
o1(A)))lkQKԢ߯jзsR㟟ޢvhIrOm3GfgJR{>˽BX\\S,39&kdvIݮ6q}}y]{wVQh0v{H}yyyٙ#[ȋ(˷X{5oCÆH"`ա	iRfqVrũ%uÆ<$UK|~KݾaQg~)+Y^!\.us2B}= U3%6ob>s8yyy8"t"Csv=77WI}6]oj=|E/Qǩ(ߩ=ESeee!My<8V)z|2%o:z}uuziiiǭv%aKUmv-aZɗ:Ir1l߮_^4l,kϪxO}^1}|yy`t-}CDžkrǿR
xdOOBz#~EYSV7:Jb$]=*ywPC!Gt	%Aǒ^HCd(J%B!@}D>P"d(J%B!@}D>P"d(J%B!@}D>PR7 2ԮK7F陖'I@MuE|~.G%E@wjyr=%_Ss
c+jmV*yϪ eˀ.!eҳS~Ü	bLiBZ%1c7coT\91^v5ՀL}]9o7ߺp/zNH}/gz;XݻrG$p pbC{NH}`Д+4?hH޽?WULNH_O+wBjZfKa}"*Aٽ7a`^:xAJk`߄O[(p/=5hP߄}{[⩊W'<28a`߄ge!n(G7"j8.{B'1vWY<[q1WՏZMy,v)o1s֔YlT|=oѥfETٿ[+F*fn=u}Mʮ
U'sJ4p:~ljĬx"ڒ~Eo]x^y:s^>.욕>uiE/.zԥrLu&_o|1۶Av.tx]r-xU^Pٸk}>i#M#Z]s)(k>aaΒU[<.#߰/ǧ{4ݻpՔDD
xoº*
QUђ].tm""2jPV,YR5aWG}DD[}REt݂~ve[ֻ'+ݺb,uB=Qsu!wxSie,gcL<ፙO6y1૧PkN
4.zsCG?.>NE_^!Q7=%:MCYu^u(.sGMK(pŋKO̿}z__ā"NpoxyYYqD&K럧K>"=*&.^C;'K%Ca2q?Rëm;Vсc<;kN?lKt/zf\׻r>uu_Q I_rGD{wÛw
>$p̵k612.;!9S꒚}oZh=Zą|U\Rz2IUG5Zra>jis	m[0۝䩩ZutRV5(j=7,yb׊ԸߦW-3rΊUKޫ\9/UE_]{-{ŅOCgw7j/mz&@:
4)w%EDϲ]P"d(J%B!@}D>P"d(J%B!@}D>P"d(ڎa0Qׇy:Ҿd_ա:OR|v={l)ta#±_JIwhHxɾc'~c9w&3Dt9_;NM;F#r
~;ũsgRSSß}(/ü_X|FJ>^h>ük(W5f};+¿w{wV}i!Z7F3o$""I|[C$ߝBdfh4+֭:uLFf,V=Xu`!IqZV 	O܂/Nf]aDN>tJMMM:tp΋?g~.JFl6=ui4	iSgf
qܑ#G<퀦VnY@ZMX\\,!0tǓ2pPr%%%I-:z#F;wnpk5Ls[#Pa&''_x<2/vã
f)?!e3335Cs\_a䯐FBggt8III|5(4݄Ɉo@hk6msgX233#HBvdP@zfǓVXX(ӣu"JMMmz/}-a3t:nf\7ц~_!BK88O7n^d̔E0ڄǕ{~_ BNrssY}zﳺ՚ֵkWhقfG
9a˗/rL&Fx06g#F~_5;
bYVXFDm|Ѥj999yبcAx
E5Dd6f|j믛+55`0zr0Ø7Z,ѱ{^xh39;Gb5UakŢ!mLL/ue u:¤VDWWW=j%Tǃٌȁ01IE/ѻ@Ⱦ0a
vpXt:#:~zG,!"
/|٧hnwnnbVZV@8CŠaϗr4`U0k#>^8F=!bQ#8t>ĺnqG/^aeY6---iXDGH@3L¢b"zĺXVYD՘-|VUEe6}8ud_zz 
>JF0qt:pr7pd٤nt:avo,0QX5L


&dcȅuմj~Q:Ⱦȅuq\RRj
_K0
/Il.O}
i՚@/{DYvP!k{HNTO!-TEy麇r:y_t@Ɇh`M|ur"8x =BrM4)J$(0n

TEd=t:]uu5&;֢OD2n[&ET):d	5@ɕޣ퇪5WތFcyy93=ZSt?<kZkv_C0N3XzQ6M0ݐ}XhT):A~h߾}&=Zi.9XXh%%@r =ZUkJuViZr|ʁ*Xhf7P(X6X->^/5e|_tº4ZC2!=nUk|_tºGPAȾtCsZ!uD3ѺǾ}z=8&}M<t́~u^v5hh݃eYa'(^,} Zp\JE:Biǥ֢BkaOY!*@418NVqj{Zk>earEjE.OqznFٺ֠0ߧPEEEͲ2(a{:DӪ5o `0}`CT[C)qzߔu֠0ߧ\QrD$mVwq"2RX5L


&֠0{4ZcY@<=8KJJUE~!B`\.=$ZDr[@:*{L4Ix֠
07{H?Qn%
} &ZSQ4xb[5@Xyyy6ZCA{`!Z`YaCJ#ڇ~+U{ 
`7!
TA@H5=L&S8=D{7|!}p+Gp3|7Q닋NSxAE,KIIIp3&IDt8d^?rsH=Pa샖8N|>~3DZ|0³bt" <}
!]@ƼjXxh\@H}5
ڨZ	auUk(ހpBA{mCT0ØOpWV! ̐}^M=n}]\afB!4]YI B`:hݣZh۴jMa'dt$ѺGmmmgTAD:h#v,ˊ.2i!#5Ѵjr!@ZB_t!}u"/ҕ,q+fYد_[&ң/By<"@Zz>77l6KȾzV+b=R-.]8]};R3HȾ\%qy}b-\ξObֱ!"H^^
,PVVI@BE
&8͛?EMg&9]6GN5[44gH쓞Z8[>Pkrl6́Wziii,+r}'1ɲ젡>?i]?9BObIII^wwpV'%z)ydd"B d>)7x(:}26ӔKhd0	j>)/mQ^yAI)xw5eG}գWMVC!@}D>P"d(J%B!@}D>P"d(J%B!@}D>P"d(J%BKij>Mx*ï$iS	|zm=ҴW=l||zZ>`_t~Ү50]4D\nsçNXO'{Nw_Y$l}:quT<~=,xOИ֘
|KO`9|}\hu爨fEz3{#s,>m_~b7z{zÄ8;}$-?(y3y
JKP?Q'G
.\w/'U݉N>=;#ӳ-n–1SelûUղ&L\鿱/{T兴Snί5,곒F~~n޲jޯGn=y>{o>W*տp㶗t7#"ߞǼ~`M]855fRrWROVW'dcƼ>w?6^ڲzf۬ems9[_Rݷw~ǙCTCys<CDDStG?u'"+غ4)ѕ^ھ7u@Q'<=(pUm_ކ]A\=h*D#SMDMN	N&&l9֣l(;)75wB+Τ.-%>M|ӕUSvc>}fCEv[n͊?9U_yb.pdŦL~-OGvfa&=хo.o
54%&B{QR
|8=sd:U7.O&%CE_mG3Of}(4j>!sk~}{K?	P}5
vBCf̟6wwQ5Wb|{Ƽ@©[K_\6#^3t﫿ɚuᓆWQl7nyR/7|d}hn
621g(CԺjUVyaw3;ٱ}WW|]p'/0k'
K/;ec{\Ȇޙ`3{R~8+sO6J|͆sl]`?>Nx)~:_l]SrݖD70fo%i*:ʍe$C}!6%o#ytƪ)Ow_,2WkbٛgHol,|IK!37ףM\5k-"͏hKONGG߬R]mO+ADQ/ٷjc2]Z5gJHep.Wה..=39~VXV7N-Cc(	jigO:9xI!͐fX[~6rN_Ԋill
CDէoHAC͊at+kxrK_vA@Ka'N1wϝc:ADtG:CDU?ӥSL!cKإS(^(Wl~/DCZtR>'>uCw d螘?HwycܹS=G*'q?ĸIuW5Wjz۸۝(?Z]Hط#!һff{My??E̟K\b'I}w|^߾z&?[=ߗ|ok
m}zQ]R#O~)a#(Nu:{?wbH{ouԩSzJQ̹3R5Սn	t۳1=z5v}=]Иt5^8?!^qDžoK+kV>9BI	'G%B!@}D>P"d(J%B!@}D>P"d(J%B׹ӧnOJF&@8wN&@!׷8pyAIǕR7ǤnOJF쓱W?J|
sAOJz^՞^qo}uAF}lD
(ݸǙfN'u[ubnt>pZd'=ID5RZvf+((@OznǞ;de='>g-@v0DaA)R7n:yMjjaFA[)Nlqq́rl@_@/8NJD=zig-xdd[Wreh$҂|!"˲#"<4#q=zj(j
:a';qTVu88/
 "q8R7JMM͵X,R7:/ҹ\.e9cYV(ш(%:/(J%B!@}D>P"d(J%B!@}D?UTIENDB`errbot-6.1.1+ds/docs/_static/err.png000077700000000000000000000000001355337103200212722errbot.pngustar00rootroot00000000000000errbot-6.1.1+ds/docs/_static/errbot.png000066400000000000000000000132411355337103200200020ustar00rootroot00000000000000PNG


IHDRddpTsBIT|d	pHYsmhtEXtSoftwarewww.inkscape.org<IDATx]yx\uޛ3I3ڼHe1
fKY>@K!)Аn/_!,M(I!qHCIYvŖ-Ief6{flɒlF`o>o>sνs#s,bEB	Y`X$daEB	Y`X$d&B}ق["J!ymӅ744FCc%{rG~ 9W.1/7[ZVzcjze|SgPUJW߿~۶m^~BHsm"[-1t!8N{x@)c>G5PXZyۅy h~y4ȯ57>Xvq`ۚNq%n+}Rze,-[ <(!d%ȷL{\Bm۶P(
@hۘ3j%f3wSY.v;w/pBy޽{[_	{#7=xzP(t@x>&':t[Kw?=dYSKV>L}9qRO6nXo`D oY
FRQnq.`χ'TU}ټ(V-n9ɾNl}bw
03A9zY=iiy&ɝ97oi1ȥH;Kf=D@
y4xÊ͛;+XHȝ*1hnT7@^C=#G7O%=1pSWi8BWa1CW{rEۿw}ങ-|bŜ^TXP`A]VNrYsNI4Z
݅TZW9T2P(a4O;e(	R4wR6CH n@Sw!Fʎ;FѢat:SZbpEU|׬m588
ફ6ygdf,)/֏l/WTCյВw.H2D!IQ{nwzfQY2APqf 3T-,6	!|	45~Mjr[oDJS񽿺U[fV*Bhl$rv7o	!Qι)y!$Ƅêգ
ay麥YL`ǎ
7Pi745zUD+*[޿J~j_p4I=I'nṝ;!#z,!XlA.PW9U1(h:~=:4{SCs55@c" /u[@@+4 F˴<Ջb7L&@(W!Xmv;tvYs&DQSZRMC4H/Тl/Pgٌy/4w|:6M"C \689D#D*8:fi͍Y_G2%!4 :˧ncl֭sA.L8ؼknԙib-OFC/<P\}}>ʙ.@UUXkS#Et---	eNV)2@^嗌8+YpmUؘ/WoZ]/٬-!X)3T-_^
-!$]r:-:~;kVL9kVpa|?	xgpH/.
_ʧO.){BFz02UW^v8uULy5"=Ҫ{ze]nJrL&_#? gB̜+VO:GnwM~er;f,]ovXv1"X	i؏e,
x^^їlE&'dZo<~OI9qeg34B(NN{Bl0CB΅p\+W_B諒0z
Ic?;oJ舸 7Njyth˧M_L~N 
.8NSXF5!r|{.&T51\t
.5.V8RH3,%cb)Q=	i,$]WF\4B,>{fso7䎲IfȆu4z.-
NC~̽43dҘd*U7!Ǣ7[n~_
su2BtR|8@}_vOjp\,@z3.Q`M̌;(K7dsJ&Q<s?;
u$GhV+X˷OMRh<|>@#圁!MZZN|3cUF~G%	
:%Kǧ@wdD<>br<CͧlBڌcO<'m+b#Ж0e|3"HM8
=7Y=@HЩr*"ca4c9Xxl7gHpv%#ZfR5cBF`۽Mmz8~$$#A[_snd3AB,a%>@ɾnZ7vttPh_di^w^̸Qym	Xps8YS7jzlNY?US
ϩ鳅13TeYc,S]$a~ؐb/4ЫcePf}?">SŒoN(!fl:&"d4	|
KPN5BZ%V2h?hp6wlap醙%A3%Gd=e%b"V

ΈaR_a!9qw/
d=eOЈBW	TϺ$hPM
XTf6SchwObg*"?{%5 r^>pY4IF`2С$hF7O"iyWL:<\eBI[
KkavA#k#~+~%\$jb4|.q|̊"Z;U0V"X&v!V֮&Bl[H8:9CfA'JoV;Aܳ\8) ꓖX3{ֳ]9wyg7?jL7+Rb3;kOИm'X4~3次


image/svg+xmlerrbot-6.1.1+ds/docs/_static/screenshots/000077500000000000000000000000001355337103200203365ustar00rootroot00000000000000errbot-6.1.1+ds/docs/_static/screenshots/basecamp.png000066400000000000000000004132201355337103200226210ustar00rootroot00000000000000PNG


IHDR 'M	pHYs76vtIME	06y` IDATxx60X\9X	"9LSfNZ%6id~"	M4f';,]zvz8[G_B!>-Ad~0˛Vs  22o`AA+iAAFAAܸq    2ffNzwwE#?u7?#8'}lWcmz$[-'բ	{z2dڹs׿/?Gѡ_K~zyZXz8}k4дQHSB={Z|,Xՙ)"޷^{CnڜDEAyEObݕߍ]s/hݫjbhݽCEN֦2Y-]w@rW/<^AOhzZ>?K,g.zvʭt'rjC;k%_uۉ#xkW~p#rmU04  suC'[s^gBd:a}Wy"ٯU$q矮l|vϽgwpaW/q#Z2~/k|w)-|Z}GDB,SO\sCN+};|mڲ=*fuudu{gYLanDO|ʓ
W/w.FX:B_bUx[b]~p8\]=.o9XApwT7lqp*w~rͽW^gG._%sI^]xuNW޽AA%啱CEkjXΝyٳN~c\띟=	ulA魫K|
{t3_n@沥=+n^ĝOg׭~ꑜ]G֖];y=&'EC?{fc܆kf+~uY9^[٥[Y9yc"jHpF;QNXݱ瓹o_p6[='|y1Z;>.sb~\C>Z	?yB|77]j >δҍjk9Cg_{~y꘮=Oy">6ۏq-oeN7t|+Vd]q#XyϚ/>`^$@r  wCC!3oi,i&/ŗ'SyG֪;V5kL7˭'^جbwGf|i}fn(}UNk `͆,sxYɳs?}O ~)I㟆OLO_,]\}뮞<|O
a.2CZrTLZV=:O>?>݇W=Ow9I#};?-]]NGAC9댪o?=ճ6_	q}uy)
)<TOܷjor*g}8??
{ܟ	u쥕3XI=}bt[+X{8gJ
ov޵
x廙vż<;h{cwߤљLnP-I}8ZYz޶G{}=_.&.oɟ|F+P?\|Gsp͜?qm&Io
U|7 <܁on]i_@w}]Y
jWWj?8-l*ַG>[(_Z=uTA:]>5	A䎥Wv~_O	PuKUxr
s3+uk;vez9SIj-kVOvXY-3lYY TOP쮗Y~I4-Ad~p4/Ұ9g˝-?f/o!|*8B^AAg.p#
M޵.􎿝2nĽ?}vAA+\?O;>h}l묷gÁ  <.AAAFAA[D8|+/oxꎋi8C*
7!S9v'kgm+m;u1<Ӹ=gnNґmUe{O||dG[/<=DIJoKUš3cIyfP Tl906&4\-IϤ%PRUTǵk;gHN:Thڻhq]Lt-	vL{B2KFW0;~&+mrTX(J:mGVmXV^PJ1vxN}ALii˙C_mUe/w^rʕ+{Ouݲ+|Gpcǎ/9s_|q^d^-&I"~7!TڕM2c9z(DzRCB+dt!D$\d
툲Ir@}ހFe{6E⠏?89d*vHeٶre1W'H_VauKV
|-{L?$h$Q&Zdr2xuiqebuͫ_74zl("z59容РX6LRqp6"IRKPFJ	fYuumLҚY49qN-|j±LPW&I ͖-JD#Vs
w659P^@;ZO\bH%`^֒/DX
XBvG|z#9šPdț35Aji@kh6@;j̍M5ʄ=:_vuu2ԡ8Iv$te,9b(@:^iy:c**Γ_R,==)kow3)u$&h2K-A	yYICvwu$p}ZJ-xmY49[
'}#I!6i-x"D').?`=/usn0M`65ֺn"o4zxAr	<{l6:|c!qv8c9.[rnB`9
x썌F/'iJ=*}!9Kp,;24"TmNǸ\4H2$I2{8a&!Irjqju|e
INO$=%sJO|u4`0q4{&L:X+rJܾۑR:T_B RD8;?lGRIIt͗Z2MISFNGL,f_gQNxO+o|
<eX6N/n=.=ʖ+4Y:ZUV-EcH;[E,?fXot/՟pnsҳb"hLζ޺aCei\KQ5ƞ~J-EQzAv;,FM6w贇hTj)Jk+yn0\IOQzS`X? h٫[A'yiQ`GId˶h;R
,dR!PxgӬ[N3Z;;;]L|Dh/
jぎ6z}cję|.dž5T//3:bRhmRU$;O묳UwwM4t2']#v75BF%9%ԩLŴ
li>0iP^h;8>Vj8qwh7 rq	 d2
	ĿOqN3eu6&42bdওwmvW\rʇkO^>+W\_~KG?bXIRZD>!sh(/oXNo() 	uj)vD4R$J]	xS("U^JHĠ|
b)dR(V	))T?4z-jM&}AZPr}vGm#emwڎp\(9 $2!7Um12	)U}}la23y3&He2jELJ6*jIDD&f(5(QHHdl!SeNF
82KZ5l)1($<^qR@J4vP)^WR}a,Շ}~$r5BytBt@7Li`	!}
ElwMG){t8WWIo,ܴ2('o;;Α:9PHD(q]olUWV$+!j'\9y˛MVj_Y(&E,m&lLm"գbqrtU77ʲmUi{p	G&ȯw9y
KO4dbu6~6'͜F
zxZJ75d!|vx[b8.48ޡ. dQ?ԩ>0-OԇZ
74zl("z5mS7B: ?f3C#&b4:u_79iyY8'&H8xWwLUUj_,_ IDATOŬns5O%5YXa%=!te,7E˷Te?COp6B dxYפPh
$ĿD"|hhG^I_)˂Ml붧xkj
WmHbUĸGӑI搵Yt2B on*G*<VQfBȟWy~_H
¦<3L$~rvF[߾5kyc!\1ɖyfHX
Э>BVRvW2kt7]=A
x<#_!&r^	VGz_h˸N,Xw=A6xZ˴E3A$I$ݜ
2ę]N*OiG-Z8r*u4`0q4{L6R_\&SkͦOPH.nl9((XS(%GK}4W%hja-OO:c\vdeTebh17 q\MtaBgvjMk٣G_K3ClAaֽpmu3eey~,MR8
A$duE=#손^?$C"SH$7o|H86K`dĜAYL[("BC̮4sҳ>Pt`s9&=EMMtk)׌=r-EQ`nG2G}ʍFfdvXz
ћlߍ*KF'	1.IOQhqt7̕:T5wGڛ}* #1ڏ`%=x$uTnetƆWV<~ĬYi:n;~uu}u_F|>&[W{e_gu7CN6_$xYg)^8|y*k=4
!B{iPt+'
JUuKgg}SonAmztW=mweCGLjW̛	U<^'xʣ|CAN9,oWqt+v9~`Fčv75B#>@=ѲW㯷H Hye:tv߄Wkp>Heֱ[H,%A1"]y_H&z'3$^7~XR%C0p!auB@
cA~/lnmleX6N/Sy8@0XcF/kc\.	r$M$8Tf7zaql@,0b[՟Ar.De2Zl_:-FF.:1e
eY
2ٸQL=Kl| dfgJ_H&zǗ2$~Ʌh*d/ʱc*M4T Ww`%j"bP\lz@^ݫ0B
ZL=T_[5TQEfoTTT\shBAOd}EE70%qBaճMjD&i"ҚZ]M
EElt0:]W/>1A1V[KUI,u vՍW@o1˽6QEkpİD]e1w7	[
EE;`T(Ȥ.':
cctݾ?)N600'
֬Y3=#PbNc-9VLliY=Q{P/!H$ZhѢED"H$Z`]w݅?q\v63
hzYwfoĥL\1/8As?U[*j+XȔa}NLiA&Q@Gz^;f3AA[ʭYݑ䑟ӖP.nڙKNGJC]=Jތr)gWw  83O7:Z]a7Z[D}FJz.cN]ۜSOO1B]c6_>%ҶY00}0}jWM
t4rff4U޶-,H͚jnդ(tpS6/(- oCnNé.#5ܸJ&NӰyztô&X-?s=8yyˢZIyrٮ7ؔx&铺'oK7-[dYzM?zYv-M/.g@'womߺKr:<ח:ik|&2LS?ݜw6'+KvW)W<0' HL@Bfxsa
;4I:,bQ$io7L!kٶfjr6V#5
ؐv;,nݢs(f6^I~K nYXZO=.B]
9fϵ_;/ޝywZ,圬k|x:۬+du﹔mϸל[ڜڶůκr4mK]WvY/^|e\bΠhnپ|~qçE	O^3(7ݥ7{&PVZ8y톮]t$22ݳ+pk~%Jfw˧\O~W{=5]k~"܀ZVPt<6	uĦgLq퉦5c:DgPM3ߢ/Xۓ3#*p랹֢hyZCwg$g?zeWGo3sIl<ݖ^m6wmY0:yqbv׺g9ܝ542DPjZuML@>l\Ke+-7ܩ<'vsySj"~e"A<\"҈ǿ
#^d%lhk)2g^p:&p7N7s2'zX+Pr2/G{5~edtBkf~`sMxW.3%yt>Ș>^}B|xExl(#-c];[`*o#naK-92ǥV^j37<_o͉o&YvX9ƕ1	@-J1p96IpY|ʪ;;_ަ
]E|>&[W{e_guNs}>XhyNtܶCntYNbXBeu~pɲ&7n&Nqt+v9~`Fık=4:g(0N4O)IvIJJT̨UV<~ĬYGx$uTnetƑ]Z뛡`ggI/J;ރ#t;
jmG:]p2G$!C}nBb]=GTig3B;\gůqJR|2M=8KDI}]g|uG3MoFÏ=M|"g&nğ
vNE'C|m//IMq7k4{4I*)Ǥr+72?:ve`f⅂у.rGd%KWlr#4~Vj?\9{
$Kɫ'B_S%awp(ȿ5~B>*Q/TDMmVl,2VWȍpƅPWZ_7.E4 +Wr;x[du.+Y]?d6ގ1&Hpceo?s%TpBd,l 29i,(Tk2xW D3ֈD!!	R"LUH%TQbPYl1hHDgVE*ɨU+
dRo
KT=N:+?^Fӆ"J[is;8$RR֩}vG%emNn!-˔BV	ԋd9Xo4Wַ숃[:LBJ^S !%2J8p\(9 $2G	=Xb)eR"WXn$)`h#=!<ҥL\J2vI"pHoz ibϿgLf>>whjiƻ^ᓞEas281^>
&/:Ejb8+/zkeouHH]u]-j/z[uȭ%¬컲<8wwAó#@2{xTn'-/$^$
Q3
2c61d;RaX&/6G'yl8c9H3Ǹֳ:
wh$AwYV]][!c`D$F2MQaTm*WJcsp7$>$zF:t I4*c'5?U#APB_ffuGxQ3?C],:_-[r#Θp,KF^09k7Gnz2kT#sk6y4/۳^+RO ZgaJz.WjF`3滜u/L(
30aFkܣ{pcץOYᆯN@Sh*졋g,?@d&P/qDBWnۇA#`hF4A\bSWnefe/
\XV-Ȋ	1.d5oP#Ll:[}x\I+̚ID7,Е#)%BֿP߱cJy=!/+a}W7&hAJdʒrM=LL㶑R`_H"#vbo}N;j<|KZ&޷ZEkQcn,h<'.`
~YHݩ @;}J]J:cN.]4Hfތ=@`8	^'9cR>5%}>ЉX9cw7ak=W>r^}ll"v]@.XD 8wnL1Z>jLfyο2-}Lҡ)>"K.e>w!x#O`n\HQ.	䂬Xs67{`{wTY|unsn6wFRυ.$:Q:nbGJң@G=R%
H<
C%*M?YשGf'd{p]gzutoMɂO\&㱒FnE`]Ttz=	cE;̬6}q_ϊ6FvnfɤV
gm|4U$52xL,s53reYi]˛$`/g]{wi
஬Ň>͠be7ȊSXBmur9pMA
,9{/>PtrlU"Qjcp#t_
G344|k,zE_p^{Xtbh	ۛiޙv1r'Z=kT
?,O2Ksm***~9R kʢ"P](+`PT6at\/JLEEE[͎H^N
@-fwߦ"`u
!ך;(JkkjuD/JDBQZ>	"bP\lz@^}6ZDQTySRk( c	1V[KU\E?WPPTT@CR钦`uHPk7GPiҮS>^5x$My=9=sN	>{ηsT^\ay ܟwټI\r#{4="JbIgyprnVѣG	- ffffddZ[:=oG//ygZ9ĜprU^~gkƽXF8}7 XI#]XU.5\;71tt[44@fjuM.\@yo۝=LAAf3c^^ӷFA)n.?Foy@xci,=
pTV616tW]Fj}eIqLe8xIbF]knsy+ttnn8I?`VcR)7
0(&hhqtvZM/&K{i3fX
C=u/zu|N_Nf@V^fhur{9Ad6#5
ؐv;,nݢ ;:,2mlp+	@ٶW+ZӪ0RY?Xn6EXXӫP946C-Jd	eF!iVӴ{'b밈E㧽2Oo3׌7%2MAf]y	IrXn*REi
榑ǣ2vC(%
Ei'NrTl Fx4U!0[=l\a5TڢsxAaR7Z4A\(Me]sY" RДm&h]qL
R(掝_Q#ћǺ)vr3F.+((崙)JSi/\c)(JӂrXMzқ3rd|KFѹDx>SӕAѹ/Fc:DgPM3ߢ@=ѲW㯷Ɣˤ&%Ҡ6h׏cu
;;fRXE%ƃ-&r/vH46𾴋Czk}Cdێ:qKz$}4ͩTt[jU;tɝ2q;hyBd2
	ļMhҀ\ߣnrwv&-"2|RӪKmf+a_*3^iNa=ˣ(P{ϥ+F2Jk^IjQv~mf*iK7sҨt;;Awwpأ*(01̾G4{LӨ#5f#cD@*?:TtB~>?RSO=|E?O}
g"d֚8:~z꙽37W7ᙺ*'Ø14fw<1̱۾=D&I$DLAybab/e6T՛\Q\fԿ*V\$")TkT9u{}D
VB%DwtZ(*/ݹp4IxpZ&K:#5VB1%f>Z\T_WD,o)-n?ۄqDcm	.VզŋE'uNƤaPCW0j{kà60,s9	ʒ!ײN%RaHnj<Ш.kRg:|QUѷs:ޥ1:
Yڷ6Lѯ%ge8L+YM:x>^10EXkx+=yD՛U;&lo4f*_YﶭpEK%NouEunJObz2-+߭YV}v]ޥӍly1	/Y6M޲K%޺!@dLwb:w"evjF[3eMWwoX<6ݬ~۵c/7tf4Y#@!c6b1=ܾ.sЎ{WtCγWJ*.%7iZOs9)F0RՕ5J1Xʛ,XNSOjTjs}4iOg	fkIS׮fk:e;Jdwk-r<5}j竜9⤕뗪!9(.-U3OZ;]j!ʾwkv:A9.z7-1ׄ۸4Dt7;cB۵KݶoK+>h49djm]ύI.]F7&.m-Mec0@Alj|,ܨ)n݈LӴrݗLjw굫׮s۰=ґ\D(zJ֯v'+@04T%6rvwHǵhvw[*Wõc#I2PԸIq8CDfkrI_+ʅuk%WjҎΚ͑iWMH35T"3Z1*/pYn
v
EϖmDgjObW8iU_UQ{vNs
Q}*v"j0M3*]JnǙt}JѸiihpX5LQ)!Pyż%'hюo=%DDͯVEF!\'dn]=u,~#'V2ep-j3Q88b^h8_?:onk'vn\Ui]&:q.&@aoJfwVVNie)U/;wIuMڍ~T∓V-r
RmS.p?dnҪ@+Dz%F͒EKqRnNG 4b݊n[7?wlܧIv//5n\>7??p}%"KX:wkjre1ϣ|>6MzikfŒBpz׺}.[^~zB#+i{U)gӕ9TDNy+Uަ
;6U |t/rz~
[P"-;wZ
@'YS%z͝?tte:DMU2Of=rK"
oJ“EDd
A+CDd1˶/V<_:%3Ovw_,zdғٟ	y_<ү^m<~|Kͩ%ڦh}wuk["U'SgW\[S*8M;O>p2՚*:cȮV"TUvͥ¸d[4XY6%%S08*ws0W?>p-Neq8Gff9H/N32I31cƙ3gp$
p]ӧOC"IXn믿F;|`H/w4H@~$ĈVokD_]5}'5Z/wmUVC'X@wc&\"%/V]c6הn_YD~P
@0gW%xQ0 ԯU_z[~#ʾ֬{WU6sʡ/n^\kU&ͻv4V>{
E}+EU)3Ʊ^ZB]]ەD+vFv-Ԯ1ɱ})@'(g.-+VgT,tUpDD՛}tm!QaŊu?XOD15n{W"WKW>]n/6?}]0nopu]er?y-%"Y·k#ǸvU9FVޫKrS|U(em~H_Leo/}a+߫{S9WN#"||~yH)Um/*Ν@V9y>+l'ig*79q@zf{\>ن'8wlwir28GQ|Ywtx#5[:;WDDe#\4s=c=V96gM
;vrjųKXMV_탒lvƷ>?wӆ'CbU:;׼KK50cdtɾ)B?t}y!R[2&Y5o~/OlOʜ1ڗk6הW>R"s,fddddd,6vQF]<+4͓Y݁NDT|҅fw Iq҇wr6Q4i4`tbIR' I@}_vS{ϟ?#b=ُ
"gϟ?=ץng~jj˧j֊5?<#ˊ$
0:0;YsfᏚ
"2:@DD
M"j=Q"}ԾԁuL%ʞ򧻵{oV{P4TuO[I
;fM#ǎ9mi>t8ȑtG9tk7+oڵer)Y;_䘵`}˗/#b(ifϾi#vOz݁#i˖?|M>|/.z{vp׊9햎o9Kd}X9Cr׷xw,n	0
ƽ;9y̮c>:ں9L`fÁCGES49:pӳggixm?zbO;zh[<=vm8pw8f.,/grDԴgCU
.ϲ?=G͖]-ןyuO?8sö

_sfΜ7~D4{^ MCD:e5'V>mQT=رλ3._pe-XZ1pX IDATi[{.ozXtdeKDqoEۺ9C0?$i7HOfɚ5g&>w1Ysflּ<Ǯuۚw6tde1DVss+GcD43e#+kQ[ʎ=vȞ?7El/?o;&XpDdY]6Ngbd6{V^{윚7+:ov^̻I԰q㳯jcD[ehUU;=>wKV/oμlQCu+o>|{vVm|9-VVch/Ƥ hOo1ysY"fΛMMiäܴ;wFu51,[|®󐙩dyj."ۗ]^TfowU u_6▭G߮n,iHɓ' }>jKKZ>c0r;ddd;vԨQ$L_C;`5׮^cU(僪?=3e;|w1mύ[3OkϗJ_*a
@w>Ƥ/+CU
O.ߦ$hegl֓L^=lojkj}Um7=::^OfsېUqU~>ϓPmM7xk\00&ԝ?g1_[GOQc<w_ۉ/_nrRU'{,ɸY䈈35c~ZIyTzn0uknǬZ/*V;ʛ2僪?=)N
LN!gy|SW;)T[Wӡu;ُK~8vkWcWtQ!<=բIj2&(?gҥwq/dХO}Ko5|+]a­O[V4T~9p=@覦&00_=o%>(~#Kv_c9`~Z
*雋1#98໡aW\sF\5-fj~GgjZBbLIL
ژMnn}q7f-udnQUCx8j7UlsMzdVO;ɕOb>Z5|-mrv-}7E6ikySgm7.ZG?p܄=pJ~(-;9ӨqMZ8%'h{"<7qe^86Gqv/-/;֖]==sR:###-$=L0l_Y	'ǶS1夌YfhONc[ue5–'W_ĝ@pe|"०!OkhQ%Snes-E֗5ۿ'M>j
Y]Կk_tE]&W$oة1Qü_<}}Eǵ}VG:;ֿ-x4w\;\w_ޝDD	gv6\=ښ6Xw?e>q\U>+~!rrv6P@t*nIf$3݁$}})Hr0IԥL)Uj	k1+]4=To.Z0EbiфrOmR77"sYߊ^Ǚiw_mܼ/jV٤o*Ӊ#QMO,m;/ǹJW46VճEbf`‰X/0xz/7mٱhqԟon}wQF'Q&7&lǟ_^$59ndQڔ3?I|lx_	7ʿ	taO jbo}6#:{	}9M9㚣Nw:ŝ6?F҈Zc29ifw I__Lq9BfpcW-?\fxbQDDؚYHޙ&2Dq}ͷ=m5VE'+hq1,EN}ITt|t-u'3P[IjҥenjK^^OL_;N=3r(ϻ6;xwԬkJӥ-^~ied/[1%Z7tbzD‡yZy|SWٕ]o7ZگK%i8GWp<ȥgdd00̘1cF`=2`pla]CUBמ}os}/].	1ຄ#3aLz^ƤoYMI,6vQF
HNÿ:1>s|bV
+*WLgiƤ{'k`߻c5/?5$
ܐ>{7.7>ˇ=Iz>##s޺Ο:Nd>$
:="w{c~&hw4#fwqг.}m&4K8LOOOOOIàH;,H[w42/ſ^#>q8#̥Ofdd00̘1cF`=2`L`av4+@F^Zًn)8 rf(Of(d^rxCDd
&Tv7Ǟ438;Z8y'FKًO-|aM_Q+ژ.}pi+
y&9qu7|AS-=I~NE+V̟-Z^|yo]#c/L$RxDS%D2Dݯ7<@:0Эʯ~OboWܷR&g}/gqO?WLfo{:&Rk7ӪZDtwyy&]Q@E7T0}3#O?ɷX13>%-2'/o
+vcL`Cw=8!"fsCEpƥQҀ:~7$wI'O$"b&NfηZD}m9b46e$ϼ>o!
zC|?iDe$i7;,ˢgv3+xlDƊĻ6mO_~
z*s40:0;7x&F/?LbH?N:ODzD%'&"̗g3jgm7a{'e~j։;з2igYQ
'<FNN, @#IpcjJ(P[*H(U}"@Mb}7WDM1hXA9$LA`R +ȥZ=x=j}lG$iXwP),@FxiW!,
B,K,yDDM[HÐeqR7i7PP1cY)3
X7^'T4aȲX,5}{<1PX+W$ήR$zx,J
Fa,IT^aIw-]i/_R#TהK:"$Љh$i~5%˹)E%KHʉ+Eb"Fk5,0~nL`P}وl"T>KJP-)roEeL50ϗZ6/J)0jm}$٬4%kSyV$W,$"C*
Kd5ryiY%Ǹ w ICJA:hMF @DzLnKĹ&Q4A&"SǛ_
UWȻ)1F=:$
ÊvUT+rdDHHrJ$'KN|+37M4˒i)
룆+
1&:=j>ja:iK+#3I̓fvgݲK	%Ea,"!1C1OPvjHQ4KFLzT#n+V*es\^Q#zE"R,xPaZ#ReA*jCDdS4jMkMM`4$#_TCA`eY+d-5Mw8+L5Xˑs<_F)8\tbISUZ5w#DDĊrj^ܒ"gNk#RunȈ<ܬ(IXnID3"AU,%@eUQ$/-b4\0I1V"7KT3`9#k$ƋRV$EϥcCU
&P;lqKD߱	C1$k%H9Fcv4P-8IF,$	DDfD%X	$"b<Enw
,b5/XJ)IGM;djJ}dFЉh$i^LM+eqRX@ ²dz$x{GS'!,jђdrJUlJJ
%RB(1E+xDHZDQ]ʕ
#CV+`D꣢HWvI̓fw{ɾ)}}KK;]g̘v1ya222Ǝ;j(Of(݁$
}a;͂$
{w I@j:$
)$i@tbI?H#MM5mUj6gejӉ-	l[[SO;F6<eu<:XA*4~;A^鈦iD쐞+HMO6ȥZ8qvm[[w`0b`v4@LG(ÐeBYd⑐i1W]z0Dڤ.󰦦BJ=Es{"GDI/GBCX疽9zhvU*]*,ŕ-'D˗5=>TC0eY9	뢛,weB&?P(bCe>ԒVʝ[ɶM5TXW
h7^ggaNpN0DBa!t_8T[jDp0uTɾnXv0tbI?.gF	';(Ñ6RW`̸fD
R'K.NL50ϗZ6/:Ъv7+}RƂ%"\ZQ/-WCF$HTZD*ʢʳĐJTeL5\5]`R䦉F} aY2
=asjKK;]g̘vtA?⏹|<@
ya222Ǝ;j(cp}GB5$NN ;Vbcz:=<@J0cdØ01iҘtFF0Ì3f#q0p$iH-@' @  IDATI@oav4H@~$iH-@' ݁$
HZN, @#I@
0I; X@F`v4fw I@j:$
)$i@tbISSj++jꚨZ(]0ڐ5?ŦKM<
wURn^TMMG5Պף%U샽|a;Fh/V
QPf_"MGL$b]ܒ"g?4;#TW/FMy؝I^
Uyڶ|;a2Mb9ѕTʼn!-{EveaLM	*0dYK
)D%"?5\J֨8/elΨÜ8aB^'qdP$,|'4`,&NTC0eY9GaIw-]IlTCA`eY|ǁ/M=ٶL&I/q*GhbwO|J;w⑀_'!BzӞ6d%#!Eb$.:J66[v$Omz{%<QN, @#I0ЪFջDLM#ߡUnWK??mWXDPKd-\xT$ɒYҲJq>FfGϕ$Sa D#WIYiJ"1QT^$b%^'?fΈUЗ8-PYkj/']\zH6iDErKd=9>w"iȶeZ;]׻WDwgTiEIDdDA4zӞ6u'6676dnl߆GI*CvvR54(Ené\G`UX-t_؝%*@0x//"&9'Vd/5gXk<1CD0Kdb҈êAD,ɗQ(C1ql#	NoQªeY&,?hOCU
&P;~U8#ԳvveW/͉,hTΝuwNtQAVɦ=\bnDnתtwԮfo+;arJiUP?㲂ӊ$
L}`iλ:
qljJ}䔇I׫bbjHJJI,Iی[4beDL-ob5T]w
,biLֶ]Q&b#[܉L>:cjžd)R%6V:movA	Љh$i^LM+鍺 1ը$ֳNO$+ݲ8IS1Pn>=|02(κkMM:O8P'ےeYfnG7Րhl;
F>8WWT4K4˒i)rzE1I(KsBAaj%H}T>9 $'$v̺eGin,rDXtN@rrl)vl[mMv^nw3r
+JLsܶ).	+#3I̓=+m{vohޗ%YJ}0uE!ӊuRݶd$ݦ{M
;]G1¯ɓ' }>jKK;]g̘v1ya222Ǝ;j((݁$
}' I@#>q0:0:33-<0&
0P0I; X@Fx:ロ@Mb(Z(mOSSj++j@ CQc}ZZ0@ h'I}?Ԕڪ]Q\o..`vȆO gsz5σeyn x5B|A^	zDo$-z +d@Ҭ~^am7\_mvš={xiʚ7ooV.mgP1cY),ȾJDH`H=Xܲ7)IcY\L"SaIw-]I$lJھ=IЉH(I%MHl7ᰢ(DLX]m/4HHV+{HKܽGORyFoiꑠ?8PNT{ݗ:MYە66Lrv!4&L@N,㣭=(Ml:}y9e˲~eiyӦ:U_"Iug.g;bEHLkY "Sm>g?򾜦>_"thp+HSGlQ"9N}(Urj1T`@bt$"#/S\"@0s#ߡUnWEIrrKN"2=6Rq"56[M+J)0jm}$(0\RΗͶ[V&ӮAv6=$%^OW7W;\OvQ<|Ͻ}ɾ{vZ66LrvLC	%EΎ7&=Ma;cRΪ]Gf.{U?JD&mݏ8US}dVޝCDd|9M3o_oH;_Q:k˖g":R7)כ<ܫj#Z}%}>ښeIW&ewMcmWvjm҂[;Y	]3o%Ǭ[|Mtާ'":z`둤aƤyw!8#k$w,

rhxy$YyE7əAVd\feOx_L7E%CU
&P;ޒgWgUXpE$Sb/NoQªeY&,$6[v0DDðDF{YȦv)- 6LCz=,轫 ^:B,c{ۗz66~t$[
&"Z@0w-([K#lΏ{~S?nvnض5sZlxq{YVkeeum=}uwe1V(/73{Ij^=[׺7ĺY9-ۻiU66?CێP9ٶkÁ֯λ|ёfl™3gRH0xxWlK5#zHb},u&	L}jλڋn߇m)1F=
BݯhW]+v@U7@-1lM92y+uI}죓}Iz?mY}n^AugXgTC4zgwX(;;!jzџllHg0<Ee,sPhcu{-c>mHs8NͻI,{V8=w-r+Mǎ5n0hO->e/=&%˛,:Jҕyfes<}ͣ{^9BWv9d΢#?vxgh*5OD/a-e[g$iQDXLCDOK+#3ICeDYR~D@ueךaI>OԔb<+{E.RdG;>+ɺȑdEFE1FeX@X2"b0+QEreR냊$rSطݲ{+jHQ4KFLzT#&9#&QQOT߲<H#6Z:i59t#-<W.cez5QbKpSe 
ȁC
F0k"uk"X7EkA@U"LdM1\pPcWf]f?f+z>(?[O=~Ox#g?{wc}_~j'W='~_]yٿ܇7?ǟ_}(@kk1iAAbG;[?gK?OD#g^\O/?{-_]_|[U;μ/9L~[P GSz}<߼%x
7~M.>ww̑|dqKw᷻>O]|.ZXX\g)W^5/| {Ç>|xjjjjj^w7w=ʋ388ĻWs|_rx̫/=}K_}'Ͼ7{{}/=O97_{-7ܫo~;KO7>'u?<ۿ=?c9ޔ{%3 5o
W;3wȫ//>[;FV~7[oкbv.;AdȎ# l;3JAAd }w4  Rؙy  (н^=JK7==k
Ҙ]YU-!麕
Z>[dgc'+Ev>.qַUcy)·՞ۢvnכT7a͟h[^w2mݙeff=JkbކvՉy^FM?2D"Ev\twjrf$[>l'(s1XojcE{";PI#DjMQԵB:|?L¨6,
aI/1* Z!TOQ>P*c4f-hv݊wAqB`O[`{:IAZ1}(14f<}By'L;hf{K=RK%"2hP֍8y-yɪYE-yy=˼9gq܅[A4zw|"VVDډ#_Ҫcv9ڛ\5e9G{҇yGiZ4ư%@d;o]xa)Kŝ,9/>dBF% @J<֍Ynǖkc.GN{0f'O
>qLrПw{+5QOXܲKOSy;FՌD[NJXwu]	dI[L0l;pua)oǗ'o&p5YEҎ"1^ȂT%z\CbTOF*n6CJ!IҲ5@

v͡YcPͪ:j6ky0j]z-I|lwi˟X/V~tvxR1bUj
5u5P-jv@yxq	ĊH1ĶI3yfpCXbϺzUɲ!'vE'`fR(v󄅔%U[RK3~K~4aHfKV췼!@1
eO*ō;{SҲ8lt%Z$kv3	=R"$˓9Lϡgqa'KwX8ݱƍS7ҡ7oGIwf^ll5ȝxݨDؽb<dl^J>|C8bz1id70X7&5iNގܩ>M6HUX;汄 ;7E`LAA1陙B!djj(  
4  2P@K0OAAdww{;ƤAA@% [xF% o[Ɓyf[ 7]b~+Ә-IڎsݶC?ܹ1_|wy.#d"}I#;m϶$LNٺ^DW픊
uWRݮڱ;.uU{"öП;9拢Yn݌D{'j5`v*i(4A=DSk
ҘX.gznJXYi'V4,
 >/.-lڝ^o-S;fYGS~dԬ~4Dvv21OIi|J%+$lyN7su@@aTY[ ؍%̩hdB_(85%.SFG.;JF% @Jo-6XmYDgmqͰm;;h$
KP*N]B+h9)po}Bȇ;h'j	dEW$'O
o]lu9Nˍ#$׮uZ.{Rm̟śW:nȬ,cs2Z8-, ,OiEA;k۪5ohs
^ږc.GZQ9/<\cLYM\Hww&||0뛴'C+/?sËοso>6mW_>#?g~m IDAT?|i_+;f	4+cjeML2Mbհov0nŇuCeI
PYc~a&r6we}7)wY}^F'~3$B9_y֎~aͨC5,&>胧FG\7{(Q͉嘢$5u"oi]bv6O8;S-8R;7Pf34L1-*!͎d|`X;ZuMSf;wN{?^~/C/k>3C4? :Ƈ
 n~Ƨy?t萐޵˝y?/,fs):)93G?xoďodi?/	|OmXԑoWaiTNg.O\D5Dy@-Wj6ky0'jv-
 v	c@RjVV[^HkSuvKE(O۩z@Cl58'n@+|3M'8<-Yjgo~HGZ|>v(<|MTJ/_g>/E_yzjdwkw}go<7(/>Cѣ)EG^i7}V=_WQz
^|r4Ak{/\RѨ7nl;t}N7oDa9J$ǏG?l'K,Asډfb+
sW53iqUBÇ:t-Ӓ=I?6
?{}7?Q{;n><
i}xHI?}V^{{_+Ч!/O#w_ɟGCoՒw<ˇ~}ώ*vқ{tjpη_WG3o\PK|~cx#90]q]ŏ[q/>+o{qoT݀RqSjYݿz[^Oן3L1=)@_eg[:uϟC
|OW~+TK?'L+;|/wFq'xI\WF*ԽO)/u}kޢ"Jr~>E58zߡiH;?v?o1~JAAd٩ww3?/_rS8vg2W^܋ʑCU2yOO٣Ga
r·~Ou9x?GצUg?x}V>:W_l30}=ʟ~g/>r?"?14I"0OAA_)333333mϓ#ɓ޷·p}qwU~}]|  n`;~m?ßTʩt?u֯﷍~??=X5z5ƤwFA}NYAAݲ3JAb|3^w.fgqѕwyq2$A,t˾)}IXMgeWNfu3-6AA(^E%l4Z+|^bT@BV1(@-I[x,,Z;hَql}5w
wU*~ٕ2G)k#k-S;fYG濾vbH$&QRϰRփ  "AF6z= ǂP˱[B_ӎ׊
{%,ͻt -[rB9k%҈E”ESWn}\SNƥ]d`+9)p&7mvg
xaaAvzg4R͙!"!D

PzL[&9(oFB(X55s6K(Sbgh1K9Z9f7ZQڙa <;3J)[NZE#aPH#厦dg`@5gujIM}еaa
m2eJgy¡’bYZ8A])Ш݆-I&55;ޠbTPk2759kz t(hwx0$UfUmB?Y8̓mr%vfe򄅔%U[z@ClU+-/$DPLCQDfpCXbϺ:J# ;fwŝYv[3g^lػ>x^b7*Aƶ̂q0&A1I-1m'Ah:hAdI"ppAgƤgff!  4  2P@FAA@t*iAA(݁JAAO@g(QI#$C>mi̖_eV&Zc}Cb [fwF-B&c@uj+hZ27mUK%NSnxe0E0c_; ;UuN#8)!jDIOr44	[^Z5נƬb4]2Tñ%}qi.S;fYGK慥aBڙF~+!kHl1Z_(ְ&1}(7R'K
Xv5 [(ШdRVjlWܼبY+k͢Jj.7,%|FF#q6>yN!5b!U;N HlV8gYTf#AdKtv4L/`ӊk2H+k(s< @薫C?d.;ucӬWf-&%n%&'U Or:-T3/.XI7vn1N,1 c=[[^HVd&Z$h2jexu]MŦ#2(jVC2츖"ux홶CAd}޿7nܝo֛7oGI_fti\Adz*!dfffzzzzzÇ:p܆IRX
 [fwo0A#P]_"l5ݱ4  ȶ@%  'ШAA1fw[PI#  ET  x:;@/ZXXܝ^yt(/#Gos7ټ/߉?~鞲dlj=P7Dx'a'E'Ĩ j	V/4f-v݊ʏRqr|Jq6KD6-"驵S&(zJDe`N0HRfgٺ"VVDډ#
KK8!KGNZ~-|`I_\Z=$ZQfiGƬOU(~sgMϞƏZ~!QmX[֢adgQ0h9&RbgUգ/\
RxB^k
Rir/M6l$!mv ¥>kPMrjaCsK%@5GyվjYp1h4j%-PoF% @JWK^	s@5[rBuw1ŐFϸu	N,d+uZQQFԎ?hY=tVu­v欕׵ӓEcDRafwv3
KP*N]b]UUz\CbTΞN<\V}B4bf$:u<,@&3\S\%+ndw۱5F4jdv,L*7INy3ed۪5ohs
^R͟SK
rFlxgNIǧhͻ^JjMCra)8!
-^+Ry^^{fXg|OUE"Mk-;FI5`Eh
ϾyU,Xz|I=I>ȪǴcJ0ntCeI
Yf05kMiY~^E:x;s"dA$HA]]MjnB]+MRCNF)i{ hB#n8਒Y!v0e"O.B
f,hTa3.#lQZ7vװt4b륢aJd+A%e"CðndI7V14LjY,UsV;|N,pEH#厖}cuv[J%B\DKВt0;<#VTH+,:R+å9Ӥ1kwZ՝ZXw<'8dx'c~
5~43泯0nxBC0!{e0̉cNld/cQRhJ'trQw,&RKk՘oy͎[(dI:;@JA&\08EWi5uA5`+01*A<9Ш5Lx+5Z72e $"Tg誥Z͛Q$9~8Afާ?^b%"w8q
GCo%]իW	!333Ӈ>to߀1id? `ݘֆosϒAZGtێ>hwާKF`vcһI# >c5&=33C!LMMH̟{t1
Kl4Q
N|41[n@*R=^U'z%4GF}]w]35˭hdQݮڱ@wU6$Ln~gjg$OϽf9Vԝdi1'Y04L$E]+o1NJDZ5Ǡم!ˏ?	%FQKz)1kx׮VS~dԬ~
P*m ݠv1LJ8!KGNZ}{X
n&ъ2Ks]74f<}By'L;hr/zJDeР|G|?L¨6ɷ(C:9Yw=[ׇz^
TcL}RؖPBZ.s2O8$wi'~7%D+GfRdz5(&90`!y+`IOr4 aKv:y^Ve|Ѩ&r&2P@FI JYk0ywNQbH#g\Xݺ'?x(|FF#Sq
4=nêtA?$K$T{.{cq>yN!dtV3vl:jmfM $Hfafwv ˗Kb\n,c'/4jdvXZx<{^Rq
k]FE'BɶUk"r(?LIr]6h9)ps=GkPJ=U3h5G3ddv
'tDs4`HkBCˤ׊ԆAeמ{S@HNc"@UJAVFjeM4G'~3W³os5_ROҡ1m؄6R,[5PY=Zu~9v7IsDȂHjrK)԰zVpN]7A3uh׾Kå
I0Jfٱ..EcLB
f,hT0Ndcq}Ӷ(Ik
П!ifBW6Rv|*4̜@~hLTқ;PI#DX5gu#K24}^[ɾ4b]hwQ(:iw5Q.bFK,lRwra324f.]SK<뎞纜fCy?ti[7hw0D؁2[}YZKGGX$`ga|\&>ڰ
:IvhYL:RKk՘oy͎[(˅d" 4*i^8sx|dVHǯ\(rB0yy@@j
`6ky0s=tcPͪzS-jv@WR;&bQd)vW5I.bIhoJcOU:U*\XaKM6S3/.XI7vu!i&یqbqKYW7~R9fvR
)O9G(hw8OXHIZ]5NYJ8 IDAT-JC0N첣NX76z;ţ'hwx0$U"anDc1VfwY4
Yl:ejc0H[ё,i~	Ӹ݇WeN.M.i35Dy@-W*-8al<!Y4z^V;Qо&l4ۆ_k.kt" oܸ;u}y7oD׏$5&!Ȥ/s釟p4NիÇ:t(Fvs/֍Ijm֍$XRh:ݠ}'vlAp"D0c1]ƤAd!B|ԁ!}OAn#>"؉b&Vmdmo&jm#>;uQݮڱs:N^n']'YvCbu7d"(Mgql]H#ҥ@-'(ƱD-Us
Ƭ[Q"#f} T\0gmRp(͞IOr4YEiԼ3,T"B(w
SV*f)!beEXi<Ұ4Z+|5xťIҁO[<XꥢN6"m M–*jnfg2wmI̔T$ztv䍈40Bڰ4k{=م!ˏ?	{E01YKl*SK<뎮:uunϯHhMܵ7AEYgr{=;M>V@jNwnn뤋.t]@g(QI#™<&IUYYqV+j,6-j<<  V@k0\c5&=33C!LMM4艽fwo}"pUtQe]MSHor<Ÿ^A$vIĪZDujblp@G-04L+:!}]pWwo^XZ*!8h1NMc
P*!j73>/.-lO4~T9+6%BZ?Z`ZˡyTNෙ:wx)!beEXi<Ұ^S~dԬ~4D<ӘX<9u+;余G2%v¥>kPMrj!G)NJ4k'RؖPBZ{9-4	[^'+Ӫ٠qr^wi.2Ȝ=ChEioQޠ:x^)G!EGHN	/<8o45"yKz8|>PO- ٕ%tc.=+ШdRVڕy4I
6پܰT{)@ڏ͂iu­n[7'Rg[s
1oM۝K"W,n]]뵢TeQ21$ag5t
-F(<?SO7)<]:PNQ_FVx,yGuNYLּ%
{JSWx}U2ڶj[$Zn\%*gRm;N(J>:VONy,h"MVdକ׵YDsFEGDv]>U*Nj}a*-\4,bBq#c!@UJAuj:K͙!"!D
J~,($12S7/aI1m؄6)spr6|Zu<8B(ʌ/¸
%)hxwH)-Sb'h(Ik؍MDַ]V
Btr8-uG'wŴuCtC%GnEEG77"NNX55*1Fċ0}1:IjY~G}s@% 0;j90 h麥VϘϾONć]b[YaerG˾`6z+H8@WXYl:RkЬN]w@֠(QMQwlΡJ<%	؍L^E8YjN
[I5fE'WZ%5A׆5D\7qD}4"@|tkvzE$]O6*J
O,ШdU^8sxjl4aHRͪںیqbqKYWQŢ2ɞMZZ1͐RHyl9:DAyBJ쪭TvvKE(Oz!*-
v͡fH3/.XI7vuV[^H gq/_csf
:ßҊxõY5Wiw>)sY;2NΌ~p'ۮ}4i2:Qb3GZ,`eGnlɲV)feqzUG7}$Bqs< @薫:,ol~¦Qо&l4ۆ_kf"s!J}/hNxq#oܸ;u}I7oD׏$üٰ"v#oG/,7B;7z*!dfffzzzzzÇ:pƤ~aq)@3~Yt۱1m?K;W
ƤwFA}jLzffB:x 
q  ;PI#  	4*iAAd@FAAAAtv4WͥEefw~.%}/mdbߏ'Zy^l,N7i̼ťN:i̖_M8م`v'}WYupf$#Geo4~cp;>?"{0j#;eTVc'-w2k?i+8٥`v*idwOzQAZ5ǀC
o3uM/B@Ů9Cqܳ[?HHzjT޾mʏRqbIS4f-7uQ?h°1i'VzI9'7mnx86t@#):	6ܞZTƏ{5/,K-Zׅ"U@sENHu2;Y!9G
.j2 tv4,n]M;^+*7jgaB"	<2!"%jZ|]"U=t\+aynޥuψhd0[-׈oS9Gi?4v!;&JYt{)1^uA5]F]T;5lfpua)^{$V
{,V]S:"Nf4
2K0n}?*KRX)֨AgN,)+څܤ
VF6fJ/uPa@[>v8UJV&/qbA;PI#4b]hwQAxpnѲ+ItaG\7:u]bv[ 6%y]DT]8iITѳ"lSI6vOLT.x|mUtb"9whIc۵0A;'Ш]5Lx+5f+^\"n=S)IS4
RfWmf'ӄ1 J5jTsNrx'19fdbVPZ7̗6r:LbQd
N]
&g.O\׷RsZZ>yҺZp0EfpCXbϺb@ac&eؙ,`eGnlJqv\3Q˟X/A;fwqu}ӵ޼y]?J$Ǐμأaˌ']}7و4n.GIo nիÇ:t( `ݘw0̏}(ٍ`vcһc jVc333BQXpCAAm݁JAAO@g(QI#  c }w4  R@%  'Шdsڭ./6Mc-.uq͹精w~f" ;7>idb5h[7#ўTU+Nƽ־v
A΂d"ͩ(*EiZ,fP)BՆAvrBTQ^-">/.->ZQaI/1* Z!T4<}By'L;hݔ"N4iXCqܳu}hPRϰRփ lP@FI JYk4qLrПw4[7:rua+2jLsˢ6,DOS9~vKj1zr%8ԗvVTn4.E$ag5t
-Ff
iSö켣A97,X#lTըd\Z݆<ƭHn,IA
I.@LݲuViڀsKj-u\]=Pjmnz#		R)
Ƃ+`+"J(\5(E@RP$Md9?f6	~y_n̳ϜSp_QnNQ]_h]g}= =yFݡ|u]?$DZ!2	+դ?ő;;kv#ku7o9psrMF%=”+=guvShزjU=7(-klZFWgun95s>{bA?듾dt~އlJHvl#x!<W'D$nY]doמWEb͢=c'%=֛%'aLKE999^*M!uw\緰=266o?kEE
v{RRFNv^Ѳ\9۞>%cB[fhX,fl6Yj҄\Js7o+2zҺvMiߒ텈.(FB债;(IB.
}ZvВB.'qe;	!B.94!BiX6(@S&B!
pJ҄B!䯢J҄B!aZ۠MIB!4uwP&B!;(IB!hm4%iB!AIB!\$M.KsBZ۠MI\9=<&!B.oaa{dll~֊
Db=))ƁBbYh4Z,l6
,ˈH%+դ	!B.$M!B$M!B.AIB!4,@k)IB!J҄B!Pw%iB!ҰmP$M!B;(IB!B	!BHA4!Bi$M!B.uwP&,J_+}_/mP"aaa{dll~֊
s$B!䊱fhX,h4$1(X_&M!rQw%iB!ҰmP$M!B$M!B.uwP&B!
hJ҄B!4!B AIB!4,@k)IB!J҄B!Pw%iB!ҰmP$M!B;(IB!B	!BHA4!Bi$M!B.uw\dJEE
!r		mP$}_GheB!|y;(U_1B!䒣J҄B!aZ۠MIB!4AIB!\$M!B
Д	!BHPw%iB!rAqpjFVFƬ"ωW222/={"!>=9_|SωPY֬Ecgeg+@!
Wo84w? `O)fČ0j&B.72˕rI{v/bFFրdeddMqrMTrfZ0VؑfddedLʚr}lz!yKWN܄{
.}eFjpX22LXUvجI[ixX/%l!Y,έ{䅫{ g+YfdahR@{{{>?1`Ian9cZ,fz+-_AF6&'uzKB!uwPJpM'NͺamV}vjXzQg;%8ކF3j4dZQwƬ5rŎBgPw%KŤSvn t:<#|uZJj'n-[x~ܧۄ5Ob/Y{5''%md:m'6+Ž*2S{8}Fdz*ߟxM!\mP$}ؚfd–9&b*^S1sYA-6pWLy[0%qfΒL
oiޖ<M:OF^Epb}wllT-}u_=;/MaIh)k5iS.OoyGl۳xi?zTeAA#<.,5qx!y﯊WL
L-6[J3Ã`
B!\]wF?&3U>a^Lj2aLf2Zq=gfxzz횞2E	mxϼ<ઙ3̜ިcFөnkܛ:cıSz8Lx,|ުKm|;G7-L1#ܒ?Ygtm;ޞܭ۔shB!䢡+J=266ww9s\7`߂et8r$$)^Be#?L&bXL&`e)O_.Y[ʳ@xz_фBPw%@@>KB!W&"1B!K
Д	!BH4!B4!BiX6(@_;wVT$ПFB|\hJҗuBCmB!W$"Qw!B%hJ҄B!hJ҄B!Pw%iB!ҰmP$M!B;(IB!B	!BHA4!Bi$M!B.uwP&B!
hJ҄B!4!B AIB!4,@k)IB!J҄B!Pw%iB!ҰmP$M!B;l2
e͝E(`Mu ξޭ\"ƌ1Uγ;GD P&۟BV=w
~{2(48BQI8ߍFw<uOp+ǻ?=.fk}ý{qݠ^'6OgMlkqޓ=Agk;!szjlB.<@BIU%27žЦ"!~οsRjۿܼ%y
DT@Mj)y%[.8CCoe6v{RyE7B}n^_GY.80VwupB	+۾}=<{nL{fA0ޏi}
xpUȅ~uMk׺MI*NcNߟ;!hg*:ԂB.y;(US/}9VW~⭨R'D8'@]	@YKO, jA+l#Fm==JjCоG	d-騨R.1.+w>2YcrAF~M=SPp~ߕ\QF뺋s.ɲWi^Dh۶JԀpJupD@DQ,ԍ?`dW"`cOQvR$&XG=FljlVYQ5FU,Id%p9 I<>-x1߿$ֽ@U,UUWCB[^Œ`L
oG@@I5eH%-VWZlmHp^W0	akeB'ɠ.RK0*3~
O(!AE'Jڕ~x7nkNÁ%̘2D`B;:B@*0L?TA HZ]UPH1p/
W_WO'@0	*r@\w\!;ur)!@pLϣ[^Ӳ%
`jG^]
c%Gilb$ΡUͅl
VnvVEGW,!dbUli֢*WE@\tԾû32"uGE%9te|J5\D}y'gj6Y6:
U:]	vo:8]k/0KSdP`V	D_BhP_6oo9d<|ٟh}`~B#nuec (8(#"pUP "
(BqƘ\o!E\$82rdBp(Bpr\Uq.Ȁ%	/
2*
DdUE/"0(*(6U?dTU eKZ\m IUK2ઐ$8S^$K;/>uki2^yl.
i'$4jV^ufd^7xٻRU]ѾauرsG~uctt+$ ڥD!8
YF	!;(Iqћ0yH֔j~$B+ʢvwVJZ>G$fd@^P97$93hPLx=f+bf(dT^0r)`׫}Ze<^^Bzy1@iX׬w;vRzl2	!J	&.l
@"#dZTk	UwSj@P9 CQ[G4a  pVڇBiģop
BKʡ^EČFSyEhCj@""Hj:?zѓ3{..XOu%4 $io^^-K;kz\ݯYSJf=F
?cO?O=ߟ:':}vHxzjRب_)zIϜV7~}ӎ_Xo+yo?S3=,굀Cm_]i-
FYJCjv^ovvHГt#Mj󝿰PPrLKd(hdEg
~3:e~W8`̦?7il2{
1ܴ2qrTx̅KOU婚ݮ^S3
E%xvYGscPm!>ӸQc'Mrʕq-oڹm>b}]Hh2M_4K}8B$8밐$+kt~~GQNQ']
\LZ\HF
jM,VLrӮAP\zwԁZﱨj(PXkӺui"zɠ~;KB`26pOz,.G#N&@?FKpp{xP`HUS倈c/ΙmA6sٟd\{p#L*tY7E_iӼMz@HIhiWDhQ@ġGศ&>׼IʒUVo^Co&%=z_yMӥ9quPh0[{5z*^D%>^ -NJWYߟݡ5,,j(( UEX${xW=n_7W>qS_}⺭+WV;kJ+;t`MbUUW]1
Weŵ]\JFq!QS[vz9{H(\N	
0|l^f}wLڹFCwЪSd-7/oʅwxb0	U%!Ppk 2z#%[dݗj3gJM&`릱C!80
Gd4YUr	.,>O$IIFU*W=>A%ftyLsnA
I0.u\	Q@hH@ڐHk"2M­uzk'Ǩ_iƵtmD.ErQY[4onXbVgwp=ILP(;v"&Ö^5wn G4`ԧiW7U{d(8LuRolJCBȅJ>ۑr!4u!^[HUn1lϡIWQ1_rNzWٟ5-q⒲G?,6y~;Gcl5	.Tq1
CɘԤIu
tjbۯ:GzO%YsjUmmuC&-Z%6is֦	ӚY9Wb-Y骹G:Jxx,JkX[Qo(BO?Y9צn/;a?d(*K-xۇr[\dsZr	c%㞃4Yu׈GPgvpPd.`٦5{G=v}o"{6	-Ӧ֮BkPg-!8C.%
(PA[oG@Co)d!<"<  Eq^3sZW6Gƪ5[sa
ZUDI}xs}KN}7=M{
oVVY?}T<DSYϛ3[las>i2mҊ]2~ڳ,_^7~ixG$T8D7檾n7s%K=+1S_Qߏ@|k!UJC~\ool~S[><6ߵj͆\;gV*W[5Ok<>~TE}מ*)Գӭ2I9BE`QAH\CT0ڨJo? dлR3\(%Zo{yR:02[]?eNl;bͲB>
!62֡wG#'緭E%EAs޵>6:N~ᔤܒok
GEeߞnjl(++ّd/-KMM}nvyR\TrXbd& B"FFGDWBєaߡ=V1QŠuU}tqVG IDATMRkP_DxJ4#([
rhs\Q]q@BBou+j~$!KkvAIȐi'(BXJ+['YN7L>E8*BBB<^^QUz*,$TQOUJOE>~]SWUjyԱfc"EkӖ$8ZxTܷ~.%]dnRm!>wyʫ]Ŧ^d;|𩒢6-[$J @7>!Qw%51#^s?fͣa<;i?֛箹@ggCo$LAb"TaBh
;b}
Ob
EBr-5Â#=mܾQ]ٷې@
W[$|K\nkX[$I"tȇ3k2RQ]
v0X].I63[l6u6Ȇ~2n!A(@Aj#r"1f/-ǵΚFq
ȹ
 TM$•FQϤ/,>T(y&#v5OO**Xm.GlQq7
5ScLd
.{}$&D?ڹүkƫ"b-75[x]֜s@gIHEvIA
IIiA2|c_o9Zo|vˊƍ"k咯7|ef3	}o`	{.Y񼾝>,}Jk_%/ kĻ_hv+fy_Z"vK%h폕;^4D6ew=}kOӳc|7xܻcoG7
%+L2'S5! ]Ai$M72f}\GDT dYdrMFvUT esLqBhpDFU08jsh߬CfN7\߯
\_\nv`\Z'd8]\a
0Ki]U
Fn}(	H&<^d988pcp9ArL[vD?d
)ƍf	' El>c?i6}wCemА0rr'.ΞeyK祷ilb֕ՠ,K啮/V,q۪Eߤ2dǫj=78
șRGU]isGWH(	-) \}nqP&;>ЂąPB	(P&iׁU};xtܼo.zEy3 >pSA!?n[1zB%/NkޡMO9;8yWn\u{ۦuxmݹ/ޒ$ ֠'wkgӣg!B!8CYY5{`LRU%44тM?mm8tKzQP0ƪk-bBozOǶ]C#.IWFӥV#

n""b"UM$va)PQ`TӒ=1&\pI
W$}b 0IB/NNABIZE`BkNnjBnBa(k)\PҡK:Q>}mz!-]֙Pq۰+?>v誟o|ρ#Z%kjޤˏͼo8:^$f6Xg<={C:Hn$gzARo81 @2̌IFi̇}JQKfLkݹ}a!a҉u=;wn^pE|<=<$Aw|%E_-0vrJbc$BLUXAsMB+;p0PkXm#ɈGٿ#en0?k?1<)1%20d?6SfSZThp]s@+oIf|un
xTURZEG"Ã-f$
WA6I)ɉIɲfd`BdJKIINH	M02&1
d00w` BBYFv@Mvwjfv^`f(#$!A?;:cl3z!kVB[Fk>ߪM_V-:d"EqQפZyou.f7^}[Wd{Ά-9_}(48$byиzݧ`q㎵Zֲ$O~%bևMc#
K+J#G]w
\O5e=r(rg2"c(iC͐@PҮRпULԍ{صo??՛6>Uj9kpÔV*$IoZ҉o.|UݳӵMSN5`&sк+TU;e>M_>w}ν;zw߳cTU]yMm:S
	w?q&iZڎ(j@mGw1#@^d"h{kQm}i?3%fApq$T4/V,Ԯ{b]M7
1g?qyB[E0`.ryC>jViy7UܩcL}xZ/4jٿwӵuQG3\r#oޱ߮J͘6qϧfOթ]zvy}OYlm
),zj}`7Hjܻ@SE	.3=9 FH*s!!@[&?lD]Y+v&\ЈV^Y*dy|ܼ]Sf=1b-BHPhd4)W:߰}£\֧{oeUNw;t _rՕ^n3ewūx
۲t-,>GYUj,*,D2>d5UQ=>h3nG@p@IQ*ȄduO{ڵCB1!:n4`aIc/$[Qɩ/W|´u"oꤶsQ(4Jծ'@[\BQE𘙓fJp,SOy=acG#cST6\@Eh)sbnʼ=ͅ6-}Jn0&9V"""^~b
Of=t=n6@Owc3mZ4{δWqILk7gMԁZ*WC4!;NbV	X+ȘfƵUjJǶk{Nz:wn,)?=顇sm=Y\p(pwTDlHPXUuexEHHeƨHւiGNmyWDXwE,Iel7mIҊdzs 'd"&U{"
`cv{K:{7som6u!G6^Ct9UUsk!1D{ř_}4r۷n(q-폋myu/;1<$D޷Q]qMkٷQ*.Y}Mϫ;5zt]Z4K߼k[V`/1J0zÌò+
UDL&vzF1Z/80gBb
<>rhx9e!TsiQ2Xf0mmEISb"OEq{ܥ5g^$F)w8BaB Ó%'
N
JLs}J
핥!fQUڪj>ZVF	Fc)Im0Z"09?]`NBC[$$<~hv&a{^Is5OHqT9J*N5Nق+I]tViA^;m2Df \4uwP&Vv&ģI,)T
L!iSWmebJǶW4Μ8zX]OEU
o+`{9oӢ͓Nټs3HoݎIrhpxӘ},@TvCjrи;b(Ӟx\^!r!4VQV&Ғ:5=x-7E'x~̙32fL۵C˕_t,q^x>?ڢYE(\}Z??,X;_u;~0O3E[\Ч7wnzlvi8;۸lC/`.yMMgǿzwwj"ȉ_y72pso>ԴEC{iZ8;09Ʊ	=p}=/62.(o>g驝bnݳIlBfN]=?6*.$$_m"@0LՊB[EU 1F-N+/U]铚	CWCf6CcLNWqaM#ЀԖ)ѱs]Mb"30F7kܢQd@xyۖ
t6."ܡHo޸(HҶ(%'ƵMIHܺM	qeƤ:
WNQY
1)	[591{IkV 2@D	%~
(!2	@VUUaM7F>BZlXS{+8JYbm[[/V|2%ju9~2; @v]l1kzY'O3@MU(v""S:jme*)̸??7w.&ǁ#{yΤۆ[Z^2Wϔ>VxXSy;-*9)d[wmjޤcw?go}AVnqu*<0b?BOv]CIn+ 1rsJ3{ԃ7^w=˫T\GY0rDHAxUoAa(Jl3I{SUX,'UgJTE,[fEk@߉޾'mJbyEٯwE2F ҄4,T&{P[N0\o-<+KW.ՙ},[ܭS#
Wz,{w{wόn%UoZ;{ٌ7ܴQR|8fo[%3^aAmذ}ù[zgor[&}Ȃsj*ozUim]:6&.mܮ-
cP&(wwZ%9s,PUY+j,`IPQp.8B7ㅽz%	$0OB#&j	r9+f
5I˧}txhd*bG()+r8Eރۧf4xGI`WՈoV|.ɨ@@hM~#}ݢ>6-)uU6M	nHOM%)48R>W~i-RmPc=ä(ޯ<;s^6e9gIɂ
(ETsfɛwtz?tςz?>fw't:uBflvg.LξRtyrrbT$+7/L~Jg%nїo>ֺh~ͱwoFzuqQ
^*]f0!/?w1ҮSŜ,)6oQj:ظX%P._$qD#Gq5S;A.BG鐻#I/=kh
" g!ݥub*߰-:E3  d߼+.)8o+NЩ?f9+=[H0)&ᵏ_4g=l#.m6NOʜv̅SR9	EQғyb@3Nܼ}"1#rUVWsȩ^(B	!4F
ÒTX xrBO^tyYݬaw(b2
WYSLk\K>X$73_pYQ֯ӽ]0==%n;~AzjعyR\Cz
7l_sZu,f{Ltn1&[Fs(*gGGČ~j*eᇝYG8w+>tyS2-OZuW$@E
ʵ~ĔEM&mԠ()J5}}r:O}pWa7k$fVFpg.{h6=%EAD*[lЮiK*W!,fuuDp@`\BqLO]e=/]?ߺyug$
k+CzS_ؘĸĻŷح0Nײ5F!jHΥ/>aюր'q$ggtlU?|Sܧ.wv\ZUc:߮j=1Q	/*X3kےٽm߳O|ndxAS)%D9bLfsÂ&~7b~ul\ݺW%efgkV8utHumurnoLTLg̍WOҷ_drDclV'ƥmiSɆflX8fJzg^! b! Vǽ1N׌UJkfI2sW<YuFE (/;VNS{?D\LI,X>J
gVmk_.[ŏ|zгUTUT:+KJfYV$0Gz~zre3Zqi*
ʪJTt;`1!# h!zffZpd1p#5nųf5++SpNH5%(<;-}.{n׽Yӧ5ҏ!u)qOkt֟\Y/x3kf**>go@ ;rѢA>mC"]0Щ'/U#^C\nW>6-6W΃zٚ=1z蟷ӥNEU|6[oѨwmѼ?~ٶetuhkݎu?9pFDc<3=lI_V
G|Zpa~zssxY9TIR޿Cz?2KJ@̟zY4jߺa{GGDΟOZ6QzuaV?´pDPUAPB8rwtԎ9a =q Zb猡hx_#3ɼJ
)g1+~XӦ&QȢًZizEQS\жO6of}|@Ӣ;7Q$&l_{"cb?{k˟cRfKΝ1PATPURJʴQ&o,?pp]FW{@-3=` LsrJ2ƀh1!jΑpڤ^WDD$'>t
JH]1.#)'#!ʍOӰAn69fp},)+YjL.4m	(p8W#
)0Α#C9Le*vjݽ^vKXfZ/*[#[9}ĥk4l7[xw$'7\p̼:5s59.3Eg]w~?+9AA#ጃ`LaZKE3ޭ[Wg63
e,$g.]0n+.*>F_/IJ^NO!@Q١SMS~V]QtfDOe$bye(>[|~R\JR\dT4.2Z@a2po޹^=Fj"3BʝeOG1*#ãc.3ߺ[d0Qo2j蓼VI
7f?{,1>))&]bhSNO%%heqUkH)LVBl:B>C:ĤC!9('T$@D*׋U9۰[7ݼs|׮w@Om)&2֠ttGj*wۥT߸Qf"N@DB
?,nr5!&i3)>}܁n'Fgy|bekdj

{gb,(
joZQ&&L0{lx=<~揀6eZDA\ϙ@F
۴i{9Sd%Im4s}Bimj2VI᪇y^RQ<ܵyiH⍳NNIJٵ밾#VpݶUP@P!HpM&F P++VĊ2hYXЦUJ5 QE@U#@YR##^Sː/W6hIeB;Pܸtlv[9sh{YEIܾ>wEةE܊*LKJa4['ޮY)ټ-?;,za5ϣ;ӲZ7锚P|oг]OaC|޷~g2SkL!pg0'+*춰}'v
7n[ӯ}eypbj֨WSwط`}N9_z/]tiř]f]Z6lRfӖMjzπ*U VsH!!&¿<	 	G)T@b
;&|J*`3Y^3m^(-g!mSdPTH0:Re@ELMZܸ pXRB|^xIEh״˭lԤs&
rZ7n @(JΉQĴTTԦ'O9Ar3*PUs Hp@\<`z3chDp9];ۻO=NswP6.s	E;>vYFr;7;m:mܜ~]ɉbEѪ2
ةEϾh,/|+1JolQ筏>޸1CU	jTs@-ǃ 'rrՕUCnQ0	u]-g䞃'Ӱ^m2Ù߫(J\l|vFi׼EigÙiLF]5>x^g&֔!ݻ[~MH Չ33韼d_7s⛙iSz]q=8{̐O$h<{Br|kgϞ+jTg|x_WTjtUN;9`f3y}5X`9]";{4`\?GByeYv&}z6ط[%`0:wlҜg,vO~Xyӏ~ 
\E|~/8vABBIB8rwt	"00U
g$
*]{wթ,I{QX2/tDO5ȉ+/XL
퉅
kfIZuј5Q>l4-Y|NNv^n_Un*?حv:߼Q/;.|ˎuV-!.IAO-oZnlۨ5 ^qᣥ{n?	?omڽܕ%%wHxbr/=M+/)I@^-DF&VCD0>aaZ9vf^q5=>16*fP'nܾfs}ş|֡EȒU;;gу':v"p]?a.7{-׷;nC_VUScLl쑳"&V9A1΁s*w,q`.R~coOJLrr&-E34j=J@MO:s/;Λ9b1D@5br"h:+@8(B'>C
B)x/Zh\	Z!6 /~K@T<~i- &ְ|_		N_$%&5*,vNfc"*QIIJ2I||XFr"RDLc08!Ѽ(\?t|Q/<̔6Mڜra/wlo=99*,zܑQl6/UCmtF5.^#U'dwյ|w>Z{}nu|0+2Qps`?(n4O3H8Sy *?cfedFdiPcg8l:]v[W>eg=䳔YR,^q.?+/dd!O%%:!11Qx^B0$ɜs>i䜬\~ZZ^0|>Gans<(?{̨떦&;u߮nv$&&sV.iWn4*Cl!7]2B?C,!M:s$ :@Fi	:RPa Xz,lqڤZ
:s6usʬ/جuNa&]9v?U;}}.	)o^a-&j3MVnÎ:Ҵ׻iVo/~c"#GVRp8l/yԅS6 
D )k~\##WUʑ
TPC	ץ]-֕kEnVf "g+Rth=~aKJ[0A֪Q0"|ǓSgOyś?c;IOmX\y돵rh8 A=GUUaNዾNno_S#<
x :2qS͟9WߊI;!fPQns*n],}&C&vKrE7n
t|^h{j.;%98"241RtެA+YVF×=hBJB'[BLLGVUO
ʨi4_=۠0mHIsn	OZ]J*jYlٰ5. &2?bXq~?Ȏq/LD29.)\~a+07QUFЦigMU/nVZo;~NK[~qݒUVLLߡU(~^]\qU_22Um<~9odXqFAӡub1SJxW9yhy/	(!*&`4~|~V"
Bs1JGLKʠ(nOO켁*,g\!rFx59f8Z,.]ӹ
Z[8g8ZLkRаae؍$$ CmZ4jenDQU>O%Pd'K
f'A
χ޴A5f5׈)r+=k:oOUt4AQCO̯r5iL.QxcRPOR*+LpA[WLF*R'0Bѭ{*j;WNfѽW+/(qEbf3$E)y-EW*++5(@@yWP"
&WNPZ\tWo];|̭qziѓwJޓ\v<']x//)u+Td1jC^Iz|! $!B!;Btmh҈*hF*PvcuW~BnVvY̖ٴc^]|Lqb-⺢6-lT n+׋O?|/߽wb\*p)""#ț2vz}O	qI$<+={P'oܼ!ũ
[?ysūcc
V_](0&Ѧ:`QVtBh5ؠ2f0') "@uC
RlݸcrlzaaW~NqDllla]>C@8p^]zZ͂`.w@*+++2j\rK
 IDATNOn֠(GMR*h9{ԀgZ4$d?lҭ{736w~٫zw
;ljv;q~JEW]=ݭ}ϒ)ic
xF>>ҵѲ'%jx|q3^^y}*#
"u(!18g*Sqf_,29a°߮[rks?)r.32;⸉o*\))/Oɀ/Yf	۽{lLT{vC_y}![9q3&ƞ>we>F/ȘyS>e&HĄGLvmݲhهQo4ӎn	)Ŏ/tM)-;һ9y=
PXaA~{7l8!Ad}@&B!tb!\jvi-d9݂zm.ReP98]j<rYsk2>Y3dNԚ@@@$p
(
Z6Z>M7$YvV"%&␾)EIbiڠcԤ8x rBVm$diz6o|$&MaMxxn:4s)JyٮIΪ-u/]?'6M:L?fИV/kިюݸurMuK4{Ҿ'Hќ+~ߛk4CԢPHp-PV 91(2ͺvܥC@%/3Ӑ	(<::zu@anYRSY@EbU$i`+狿Y-&qFA3.Ns*Hxik+-mUn*HʔvkK_+.VQj֨=m⋌YFDD[4au۞_F;zS]qCԿϴ_ٰmmdx>Ckf"1I{nNf^zr<{ě\4g,$JDѿqOzk_~nċ
..wZ&:}L_1@$p7QqmFB!C1$iVL\d9j|8R
k$T@j*B"0K*  jc[QW8;HE$z|*!Q-Wu{JƵ_A H pUN5kx9@DJAUY֯,B@VuU &iVqB$>ii%n[vnWH=^QFÔ7.zbBRLb*X"k[(3Y.zYh4hefR_(%Ta*c~|aaJ2`2Vy8sA4حPRAٯT\%`5c=jn{F70@;RmD;h(,!6qO~[^5=o]#buk?{DlTDpyZtoڠŖݿ6>o#{
Zmee2΍adP'6xv؋[H*g
x)Ble7**qI晧<3ώң]w5é/n4"TmOz]!Bx5B>$^q\LM)pIMP9-<$JCU&Fd7DI""HEJADԸ-A$$H)j ! A#H2$jt9"hE@(A΀?U@4ҥK)HIDD w#9nL-S0|Z)i・ou8L5sfٹkq1vؿ[[Q8xՈpˀb
T:CR4kmă:0|>Q5Yc>lq-dkM-ϘJUU5Ĺczm;a>_vؿ~}@|i`>uZxBDu܌'oF#mB񷴬8HLNaPtyq.zD8sT;rnԶǩK:y~5fhbeJ:%@zROOm QmӬse5П*oV1_~юX2Ϩ[V(\r9czSj3?0Y6{ʊDauTf==d|A|„_yRkxHmBFBC&¿ J#^(VAw}m1T!P83`i|	rp?-Z<fr:];	f#rr
	`mF}'߯ȨHIC=Y,G5x/K2._Z59.㇍+nܾbIuc:(2	G֩U竕e&ոp?,7ݷ[7eUwEwn޼yU숩bWspI5r>{7}vď>ڝKoN^ι~??7bҌfu?6|~6\*+|5s DJd" q㈀_x~}c{;ѱEGA$g/_y`sUk۶7Mrq*`lT|vFb
}G{];kڲz쫣S^y~nB\c(?QA$QB!3?BL:vVt`Lӕ y㧃9Wڣ~;0A$ FD)2 0!nP#Ăt= tmBIжAt	b?vOOsB^o0b8ȁ	"ݳ)/lަ`'_=upw޸y[־4~ƕ+qfwm֩n!//o
T^t_fnszLVBCШsT3uQa\)Io./\Ȉ
|~}:?.~|¬yudvٰy^EdG`޶gKIQMI`*kRb̗ׯ(]3/'1^ PjޢYܹSf(	^HnN	>5y;"7qE5֍oV-]'JgϟZVqx=	eOy1G4yg=wms9LLeYdqYg*cLe\sV0~3frƀsIQeYU9SU&+9iLJUB!_C11x[SѪJQ~
wF0DPLEd	!:+`L}TXg!@@p$!ƙhޣ`T8|h֝>xs·?߰.m{\&
8VNGx
d4KAA]3Pm+F9>ܙ[?wv{+9^G75oO2H@9Y;vo]Z@pҸ/l_(Z1PbZ6m[w@rmRma>uw>sZ[M]fFJ8P[ƑM~m$Ku
rnݹM(! GBq8{<:¼WǠ "fK'N0L-Z4&Y xmBA~C1FFGs	Vp¨'QTy5r~nr33k0v깺G;fOg6Y=Wݽ7 sE-ڰrKqɽ&-eWYn^n\")];tА]#;7+jq4nJ	hr(!6CRj=>z"q`Wzj恣{\STLkBBlތI-sdD?|#YJqٜYhڹkNfne˯|:"k݁8lײé'Z4+@jX
zd&V~Tt!b!wGIo<]e=k!\֞5r#Z>En>CaIЙ͂8{:9)KmCJ >09nZr)**OLHy9*Ԙ~
Q{D^t
L>xaޠ	Y9SS#ãilddKjr	A@QyrbAH'
WcbBBff/[S! 1)N0#zE
Y@R7j$nFL3xh~ꬓWP;529BFs	(Wn^HҮ5tm/MZl_Snɯ|dCK*9QoHھ疝i15!_~ݮi*7%^ƑCFz5!LBPWu|dÕMɣ?
DOBOA€
C!@?C:ĤCwFt;T.4ZBuzk$
YШmm&=wN'	\*]#p:x4!7r<臭s;"۷o_Iq马D@`0@\.O(vKxF-j5i\םNW嚍[-ᣟmfGD^*3T ( pJ1@C-F@UB(Z9ܒJ2GJ>Nv_	qI		}_	A*@VyME"k;h#iCV]8n
NTrJ̝:~FX,.XE|LBiy+~ѷtVN8͋pjŗ|sEjOg;-Vwߚ6E/)+/_PЫWνrr}SP0#ƪW2PF|um>0(W?O9D=5!ဨGP!x>.![;BL:w>"鲦z>\\wavUU_k}Xs%5dT	LFQln>Z[YPQ@dÔ	CeyusZ{s$ʭs9w߫
@)qxju%/ PyXD@np]=q3mVxrmQ:a@}_>#g)O#u/?-MQz{
,"FkF>?#O?xd`#CO8ċϿ!_Gmھx֙keBEH@8B{5eD`"$ I8@D@A)`<ىA  JAAA)bTb	|h˕
Ov؀u|69Mk<_6eg5j͍-0Tkw@fႳ/J2 xB)ICS#:
#oQ7&͕W5:@"HEռEB,agh1{Y,<لiEq)O*:Jm?jz
B)ӄ<{_j U[, ِd(SAF	İgۧD'GHEP@Z؀xPDr[5DS&)qd6\ulذSOj6ڍQP:3K>p'3zVx?~WȜORt% +Puc{vﶇe&Ӭ$`yF\FxfܟmZ4F$,B`dfAF$dc-AIJAJ:Mٯ|wݮ`~̵/ѱt}swl}
di㶗Znd](ZLK*a	#QI@`
r9<]ԶٍqO5hH;AZ`&? #UmK"H@
{Snj"TjiMr%GUwl\8tIP RT_޼sy;:eD
hdn*cHi>f{U\MD
fǜoN8ecܼ;#tWlfPX rReNށ#YȎ+bPI<' Zۉ="r*;wavo-ԺZR~zۏ~࣏4v7nͯkQݷPcĒwB837lxv0ܔkg3gWMj5yI*tRNuIZ}^N~"AmSzL/찠B1QȜYskU=`޽QZb7]XdԞ}{?]ScO?|ws/ֿvJ^@72lc<"Ca@%vXhR "H(!c\sGGc(!#v@uq6Dr}
R5}灻Q;_x#^*4v8gW 89r1 8餓׭{'haD@Z
s%4*@\DT,fff$djT0(j͈ ),D`
sZݸ%L[W&RYpŊ+V
T6Ѱ
llXׄ
vt
#ș쉙 \׎OL̚5#d~o!ȄS4I9Xܓz>{Rq;hl`eC2"ݮ8뽘"ͮQKU`{tF7|Cwܴwvw״7ϣ˯|own9M&[hYĀֳn]J*՜NNH<܇(_ۺH찠da7$A[Aq_,۟J{Ąg:(@
M~ǕR"biS["~?|yexE۲7҂QD$IrR+D$ %Cx
 !	) EBH!iКRݗ*?F%Pֿ>V(*R@
Ib;{
|5"aJrKVH|Nj`d`/=;wpK[ɪ(!"H@$B _K=nesϮ>̞&;7h"sԶ
 A=rS?5$`%#ȕ,I4a7ďCu9w\z[^nO>g.EgWvYK`o}L6o"Ζ։]؟Ve;PRI%?nN'?wI%)@
T$Egit`%`-cتnyv^L.7o^
+K/,m/ 	Q(0;0ۈ ouセ5X)O3>@uF\$"ƍgؔJ%WٍD(>PWika!=Q&Ab@7{tVˬ|J|ړ#yډ"

}xzSr-+9=bhVUQ%(l,_I^윾j^xq`Eݩ&h5|I?փ}E|6{QE('$F{'	U7~
D,8{ΌAA{/pUAs̲%Ǫ%"tpم+0gޢ*eV!tRI%ut>wGz#_x_v2#lhB%JXfU+Z*U6R@̮(d<1E5n:6 */Ne&@(#*kt8x}{ִqf7@DuEգ
@T,wWBg+j̥ ؾ7؆Ճ"]:1Opӄؽwך㏱tBVpF[ 0Z^9>075[~y
}_ZOZRynómx{?Ώno:ir2k'/bZſB9s>OMN2nM3ޓN*bBWj:PG`-(6OXEɤ֝;^e^VR$")90+/ݿ+ЩL6xޒeK粩bFj@	D(ErB`aI>J*Y#QI!<0sR6[od`ZZUYV6kV͏dloEacR={(DJ{ٓz4P#Cr"dT9<,nm1ԊRP6dx@¨:<<$ Z QC/	#'|k32vo90nàHRϑ.>XML>J*!9Tz;VGĉQ@PMV˟ʍ/'ٽw+J%`1b|#%^DB`*fepDb E`M]V3+_j%$UX@"[tLT*{ό-MV%BB1}hXPFYLAEX(2*Jc-4
9r ^OGY#"5:10SD@}}CnP.=;yG>JX.con?.);J*;%Yv	:J|J(Q"F\:՞ts(*-P	ذ*Ms;wzM|tU{}é?1mxoO^pE3mLV/Is{qmkUh*tlVf+cVDUoljBDUgPA!gX&&29t\Pl"q޶ߣPF95jjDm!׃gRf={vsǰ8opx!
; ƭ\6]|d0w>@]wyK #(KD ry#((@l1uRPutN'%>oQnebKA!eGHD8Q[GJ'wȒK.|yʼnQY]/	P.tRI%uT+qw$J:7d-kn@)H/@V8\)H:u6)-r{49j>Z
T~[?!EIgZlYz@ԣU$HRIˆAD3}!7:`Fm{vygS,F}S|Ή#҄l8.^0#3kk_S
;$GV↗^+'Kbȵ¤Iǟr؀ꊀ*k᣸L~}O1Ш"
ٹs-uǮbx#0s{vmݺu%n|Ў$ZEkÊ 1F5CFRL,I"_A
j[/VCRdPm8n;ۿ-ki/o~Xu=RfS3dõ'QKEqmR۞t{<Rr:;M^*M%pozֿ}l0*#XcEjeؘmpO]r~c\췡0eP(;>}{hUw_˚#6!JPss
DBaCc;hnnypoZu_^%3M
&D
ET^Zeo-JD	Tލ)DB@"Mgҥr+,d""JĐ0*H)%=0DD
p&0(=4bR^u饂!'^`XeSf1n+N=C*#xax+֝'˙L6EY(N>J*KN
|jg9%
pT~o޿ԥTe֮8zzySQTؼmL"TY"J$@.;TC!\
VxzqwgGƄ@E,]r&f(a^X:9e{"lvӦM
_Uk:,vI[	a6S=gET.i!`|06:b,8׷k"a@˴p}k.,x+n³`tb*KNYQ,?oݕo9w<@پ-{Edt|t>6aqgi	wU~@
h*H=ܺ#G
q1P'FǪaZ~_*ԖTGx+H($t$~״	
PR]3{HA/_wfrUH(cd4̊BAMnUfrouJ*R%DI'F(iz  l$׬go?e,Zڔfk{\uهzZ8mzX[sA$5W	KRsb"s/&aV/jhӅk?E@8ĥ@gqG^8r?brܰ z3z6~3FEo_.\.Y8>fZ ]VڛSS\&h5ml8VQj0K*8sUF"8ܘ$mt	D*2Q_n\m.MN•9<9͚!Mur[k-3ٝz8O]Fc>r탏<_ܴجY36RNW0{Vd!"<@0a}
dpx40pwWD߆?ަ|V3+ώȮa4Z(]?]igq0?4w,i5OJ(	h$J:yAMq BTZZC*` Gr-?)LN5u6dJJQR6T1@g'z] "cUS`"í͹0?u#~󵗝ZF{'_ŸQbaڇ7SO>y:_bDpi HDBe_~ʅFR@ljNrbAoW?ox&'"x1IxoTDgR>pC464V՚X]ft?Ҏj3%P;|? IDATJ*ĎXHtRod)k,,@)S߽no/\a7/XoRjwoݲԨY'|SZH;b-9)uMK.)- l8
t,-M֐M?5Ű"TDV֮E*!n:
Pjչs{?q'a My:snz{*YT
|hҦ̖1a_>Et/ډцf
駟\ɮS&hq׵!Ҕ[SPlf3zz[==-3g>0\lx^,*45:O~TϮ[{;w׬Y.bS5X)-.[bI2\!l&F \JzW
bWg٧].Rܮ7Ua| uЈ»m[{̜C>gk/:0Vg#H45΋N޼hz|b%K*^k,qw$J:7`-땫bᖆs;DV.XQp.:+cQTcd\xFxFMNP>kCm]<{̄ebb{yskkS61DdezcTJĄQzޱ<]}Wz:Z#春wcL	u޸$qI%^r6%Ih
1QsV"巍 TlǮjE$2r|^ԍti(ުH@ 9mhI~v+ͽd2rb[ڍ
kqVtq`g ""RJyv	s>9%U)R2R/@YVa/⿍/8b&
n	@oݼ}gRM0{twB2mho"(*T^)"iO)`d6Ec϶+̘ٓ91wl:~aqæRT?^(Gf#fMkokJ?Bl%r	AW:;b#QI@#bVC@*xNYt
T֟)BXM/.!X I0uׁ!C@x;W6eLQ3m%V׃q
D5Aim~Mӧw56G"ʰI_edltO+nLK&x{PZ,!;p}G{rl[ڭD3Ơxh7Mm]ӚZXaѩԁ=iyO/ͮV)755XLN>J*ۜNNuY\lv) >	!GE&"2Qd{,BZ9ryyK*j5mc]ֶQ0ZA-3gU5ʋ$T@@Hh( 7
3-(ڊnAP	jmͦ2mMs;٩8faZS>\4pet
ihUA0mҗߺDID6/Yb\x=z:BfFpQbkAm|9&B:?_$=^Ŭ?
"1X!E&)
T}Ԓɞ6L:(TÃF)Y3,v=o!FHG@eYvN	EDP(gT.
¶
Ew}=uF7٥j;^:ܰg; q}dq{@ę.
	ZX4jtë-z	@$5$(T~mG.ŶI,1"On-!!3V*}Cs\/KM
*T.0Nm;@^P)U/bpa="
H{ϟ>kzs6l@+}?QJk߽qZh,
n8_0"	00E@TC@'KT-
"xLӞ\&,1b73)Q$*n{XǵP}YH! b{!p:5%T̲gD7s]Ӧ5WZ
eqWvFUSV:ÑͶ'$q
~÷~nG:D쿀ㅑ{qXoyh zhkk>f\qNC>NݍAAkWGQN#շJ*d%TQҀ$G{wڴVja
TBZi}KFƶ94|$l
!Rvv.dkC7ف01Jh1
*T:㏭l>i|X~9لbtD*J+6fhSO=~}oOϗ'"	kxcpgK9=/m\_)Ep`Ea	H1{AA?QcS|qTK1Q]v5Bu
q	NC	(,Hh1rg]bSW#E!d3)cX0	0
MiEw@ww7NzbÈH.l-FE3+! 	"yH1RT]DxlRĂ]=@>(&߭SPww<:"b?w1ٍׄ
|k(AIO::	#QI/AD$H㮝}Ϯ5rEن#!nod29whPp߾=#-3!bQlͱpk9j^,Bsb
n
E7fe}^bP°Dݝ]k>g,__̚9V|*:cm$===O<Шasm>C[ўIDtHHad6QO{-׼#Pr#rN c,DԐ%	
b y).Xp`p8X5ePJwkQy߾
KJaR1	vg Y,2x^6T"XHBbö@;
z`l?>kkİl$d@DeQyLn7vZI"m*D1̥kfTf7O::;;`|m'N .mwM>tJ*ܜNNuvfkP=[@TV}ޖ^##pt<>V?9ϥN3{ZOi}56lx|΢uDƲ4Aܭfז$<~bJ3&i8Eg򦑇^Μաuvv6,jmir3gnٲF
@Қ
D"`;dHi?E]ZkԊ<DcpQd0rٕMg4h%,@%Z9)WS%Dk#G-85dJ
_ S	$֝lGIxoZjR?89P?wȢ(

=渓>D(NWY]Tߠ{XkXw"2MIDI
  FXTk+ūT>RD}IB1͚5iFϒ+Wpct;FJg3ڄQ.f8QHXI[gX @J*t%
8[Ra_޴	D8NGut4ttV>2GFʥGoؘo0FȲ,cD>X,vہsRt4dc0^Vj"2S/km;}g?Q&Nuvt/q!}Gb+0wO/o>3C0 
@Y30s%T-;*pŠ|Z1eSV.^Ii+|֤{ƿwzzv(:,>sc3-z;&m\{W)KmYMm=uܼi
7\wVoNi@b\%KWkbdRXw
ŧ̘")T
ЪNΎpɹ NaĈڵMbX"
7ky5r?O-_?|x`Y9Kolx>Ӕ%M4TR5JqOCwWx$]2rvV4?+Q|ZHN9+H:646
9
C&ݒmn7Ƙ`w}׉O9k
/͘93ߘk\vy*^47u擸Ͻpͻ{鴈c(vTlIVH:|tw׷>(FdRlw^6CO0Ndq@am|DbT(V7C}TQP
zmZe˖mW_s7{܊ErU*#nݲ-at=lXc"L'dc<1駟fXЃ- P;w-jۚJ@*剰:dtcD$`kdҧYXCq>Lk"E!Ovyvoϝ7ăcyEKT>0˕l*J,?O?͇N8qVdقZԣB4ƂhV%J(7wGz݋1X DT֊I]ĆwܾmpHWӻ?;=_%2;nΙe`炤%:f	ӺmgQJŁc6Ut
<Ǹ	btx*W/RU	5;v.̜ΫKRUƷmV{{v|
ah*(kx~Tg	G[3\f"?Υ(|:A~uA ـcyK.9S7t܂S
&0G
{wy[޽}M$v~c2?/gε[mPN΢Q-fǛwMOF51KvӋ*ןoomEF@A-  9i(GwYKCEj|Œ P^vTΓC+Es_
s{瞾}{o;+^)FJS}vԉ
&TRIiI%J:ש,GE3FlpxҒilL_Ú[~~}cub~[=kyadeM;1\
phbrN}߼)%Ju7mRT)Wp&aS.] KS;$\zYk:|H*=a{SZE)5}z͛
2saV.)Mک7=X<ݤMO_|(1BcP!r(q^ĐF1A)yr$!@\mK.(LLd9L:[&
MNêP\WlkٟK9פ)5׸ݩgDdaoΦ'oSW?~#*:ʲ*Ak
s3܎q#D`ܞl NQyh«xϷ})S
(LΎEK_;0^I;	I("MBB X6/Ŋ (*K Mt%vwm|ܖ<6/V~jiicO>ڻOАEQKj"p8W>\IshՁCZ]	LB`0%ͦ{	a8A Mڷ؞*w?~⏷~co	YO 
AJ) M)ED={DF@8Nl_#MQ
d2(IhYEk]B`p ذ:X8e\VO IDATn_f]ttCDFDJjUa4m05*UUu1H~f uO-|j?>=ܶc"!OTDOmV\ *`
,y&pדnmyMiYNM&BUPIAsqP҈IC6:XQU쇢gA@U'O`/@t_q.ϯ:RJT0*jB*|sټ3Ch=Ŭl;5JSK3TeEO@,h>J bBq~_escJfAe@AUIdd$c@iY ܵT7AU:s%o4窟xV*ceV$`z!(J߾6k)Τh$).ȨIbU	
Ѡ\#^xuhz1gCʋXDn\Ƃ "K^QUU,
	*ei6ѓ͓Ms`_r}r\hHT2@	Ewm9Nb]n.<ªTHPE

QY}ե_feRL9{ZZߙ$<{np@"{0vDE	
nkUyΟJ+jOš~q}~islDV  bUՒ%^gweDT-aUB6X2BVW(E\bb;@9qD?Pf޻gV=,ҲU ;tbZU8]˩\RWej
ڣD $j?$ OJLlHUҲ0zm"]o;ˤ悖]F1@LZCJJCn6	Z>$UPِp08Js$cl2@`a3@
eghy
"E{!UQVgtz
x'fuIjijR/(I@@_ߺ]G:sx806XJ$d@6!!a͚ 6a2'Q	3i>$ݲjx@G=$8`0*ޙIcDenF$ׅ;rff#QapQ΁v#{
x-<\W]">ّťA 3ω^ТΞj,Q#;|c
_e/-,}`
y+*kBd"V뫪 `l")"G2$j^6ڡ)gKSRz&&#hND(aBdPaaE]uzB!,b2`hm1g+52=p.OINM
#L4OyE'G;ސǯu(`A_h}!F%V5ylshݪ寿eV\Xis<~Xq8?
wwp%͹
bDRU0c?)(n"(TUV!cחLJ


s}E=Y0b
ջOEnbdY(MNGͦlnY,6P[)fIZ3~PuǶ/nݺYT3I&y{T/^jUi0`AkkmDrK[E1V5FS/ȭWT4	AZ_hٶO"E*8BT{l"S3^UT;"u~V$OKbDDG"C
؁ f%"(Igr8+wwp%͹:"Je(B2ZA)U32JPbɊB%	:zCWP:uBArxRVۃRL)` b@+lG(R{\̴_(Blmڴ܁JQ$0Ri{$I DU"1Fչ+I]{3"2]T!Жɩ($Is0)-4I0Q~~`n0E_{24RWR4'r1}*	)xB[B1L+ <:F'*.
/gmz4( XTPqIQfQvz)Q;ܸKo!t)BI
ljW0`٩"Xe&H&y/MI	(@fF"V㒂וQL&T)fr5<̧jM?su\ntlu@07EOtJ;
j0^"ڪo[#RLϝ	.
ĵkD)LFkE:_jdU.4_Vx2P^-\}apÕ4*csB
	VBcB#j5#(%n>#ڄ3dJ,fT0P*x1{*>3Q& D	2
JT`,RD5ˮ6US`l3'B!ИDRRRym۾h{}J>ztYfhlܸEؠ@Ŕ6*,q	m@U
eMQTX\줔PJ{yOAmדe;;y<\,/+9Qp]Ͻ⓺fKah`2 *
H*I	h=&J:_m{5W.*
ᣟ&?=?5n&\n#ܲ-.̵HJF@DdC)AALR(֮	*qXS-(!$j[-ʋ%HTEDl`
-VHOX8EBy9]9tY_c7[5}ǮĴh;k`xz2\+iεDQ˥\n/ȚzYQ=
*`U8UmR:+W7D
W@MUbL(J)Ƅ1vTUn4
G3BRc$DQ9b-ƭf4,,Y0^Ppr՗?o>|F3ZnM3`,|.ҷAL<8$OB
MiW'KJkttW$T2i?tNc߼/>3_bixMؒtCLLtYiEeeۅwY?*UB@:{nNd2Y 0LV@U*.7qUXEn*HUр-!Gl`/*@UYB=!y 
1QR#Ng:",{VsN+.."
&h6zqEQ	@-CTsj\U(le>$s#%AUTAޗsGRVV`AΜ9]Ny
@TߩK3
@	P<}(Q,V_Jcl4oBO]nƢqkn^#zΔ=gfYRu5_6숷U6/9
$(vR&N IRQṀ ǎD5	
e%VUuS,PʜlEsJC$ў
?vu՗?=ZY~3SJDX_* 'n.%9"5l-{w;@m-i#0P%2~L>h.QJiP01(BN˕Mdj/*.%S{( @P__r $aw	Ujc-PUY68:3)(6 'W|Y[A-D#yU6Ce7AzBU*3{h
"T@TV@k=	P$s'OyO#5{
CT	"RU$HU??g).
b;0„BXhXBBYjp};\˱,+wM h1f]21"la;H;買eѾA=aؿL˲e
-M U%zҒ	8UkI0F[P">vZ(тѱmƸ]Ew_Rʒ+S݊ I$a4@`YP5E{7_:/G
' BXm^r牁 !oz1[iW82{ƝGF5m!g

TR|fݺv,
R@L~1c%7wbBwa1ztR,(2]$qGQV"
V8
jCDe^JP !(34ン
݅@aϮP")` O?=u<׫QBILf]NJڼy?Jй+iε6HRFeV)XsY]l Ɣ84ƚaalDrԳ<ң6Y};7x!*E	
h٪MPGtNcGبkn8aǪu`LcgSjr/>OY>A!0ej
<#zo3[qKY}W:h+XPf&U9]Ni1+*	
߿fYR!+ِ՚R0B=jDJ!(jiI_:$({#""ݎ\G7xop%͹`%`8
P<3{,
`fcqHTUݰ0"L	yt@6BA߫*a/AعWE?@{m?nYG)UTX<5}!laPk6ix"Er.Ym׈}EӬ5ՙτ(D4(7P"D%	}E^c![b0!,˵Ъ*-cDBD`=?@N^x/6d6|GiYۭ@uD_Xڵ)}=QL*HU^N;'xGu%wf?
Ğ?POh	KSq/K]hP
Cǎ8 //pMFBݡ:N6ITѥ^fFoY^4MBÓMSؓQ@+Д>c	xrc\h's&{["IRMM]ps\]N<<ƙP"`Q5B	#9\s7g&M}q&'kҪ+(ڷ啌ɓ'=\|!
 it|hAnW׏6k-?<+c\l.?%MNͯWf
N:jp8U\Ie,~tM}z]W]GEa_4󾵫-璴%:EBI漹C267F'EBnY_U_XRq5scW]#V"sY?\NDTu>gΚ%z妤QxwkMW/|vÿ1oS

Lpo捛{DIp8篆;6w+gג\dqF2^#̰眫&-w"
q3H
xMp+c&z{,DN"D}i?
	ƴcnt$z(Or‘̳:\^HFu\]=uWEWlmq寙Dϧ]}߼)j0Ƥ-S}Elp_ .:xezדߚ%Gtǒբ6νKqk\0*:! IDAT0Hl4>g
I]gzϢC*mQ-^_g1cp8*.02zp	voôLnztÛC\g+,X
/z`=,>󕊜
`+sW4]38`KsݾŻwcMtxh++'s)qF k5n~s8sj$4صvߒ0ڤYWDZr2em0>͂1#޷rAi]~b	#MoĚ[ #ccگ͞w"^̰^YFĤ圯W}1'f͚8~ѣ!u9MhRi	Rc;cSz>[c1ŊV*1?p8W	h=fڋ20[-@Iw%MvUDM<#b횽xoQu=>Sc;GzXo={yG*"F^T/YDd13?u.IDImDLhcFrw<`Fی1oIصrxϱ=3Vn1[d7`	sR`~s8s4{toFEE6f?mpdՑ#1NhٲmH߼y`0f`0A0\X7
6YlŻVl{ᇃEi;H)ecYYq8+ua9WA>XB%!R1f[1&0N`>B5cL)_0!*ƘJoJUU[F_kjʔ)uuu_~Z
M}xo[z6EQ4n^bϾ"kGWl}Aeqq{)//2dȔ)SdYŦpƩ*-[N:)--*"I҇~ȯBh&{+iyD1!!!&&je뛐pV%88Xl2`̘1aaa111Vu]v5L۶m}}}KJJL&ӂ$IۼysTT7߬t 7W'dt!99$utfgg'%%uԉ-|ԩę3g~͛71c/ҵkoرc]tahѢ'Oʲ(%IZFqҥ&l/ѣF7|3gggwܙ].=ӯ_?}G.v;!2!DQ&fg.)#y+ɩ=`h{ma)ij8xի㎗^zi4Oömv5{y睔UV>}zL{]Qi|7{ꩧLO˲,Z
c>cƌ5kք-XCv*((h׮]qq}̘1N;dڽG=
7܀1~ꩧ(G_z[oUUU/rXXX]]ٳeY5j,_޲Z?7lذgy&.....QEQ)}ibv8fI\I7

Os븵>> x7|~^h fbQ[3͛w
ccf͚mٲe?֭[TUQQl2]__7>|xƌ6mڳgώ;BBBBqxxxff?ܹs眜7>?Cnn.+~f͚?`>|̙^x
7ܐȑz_
9s1JD}֦M˔AS>ӯKhO<p8{ٳڻwojjs***!,FGG?ݻV_?m4ROPWWWWW׫Wɓ'gw}wfffppN:3==}СwuWZZڭڱcǎ;把
QsssO>p‘#G޽{gddkK.4ib6lXHHBv[.##?\z%I*((hѢ,Ç֭[|ׯߵkWRRҩS֮]|C3g㏫͛K/qH03p8ޡ1W%((bw.?tVTT̟?{Ν[vm߾}80qD5|pJ%Kv{YYܹsnw~DQqqqmڴС(ڵ5jT>}Ξ=;z/Y/>>>cgȑ;w>|ĉ{3L>}bZ,EQx≙3g^wu+W4hPFFx	Y/%+65t__뮻.==]3g˲cM6vp}kNQRJ|I/((HHHHII			ak4ÇO0AQwygѢE]v]`,:tؼy9s}.]<#ڵ>}?0o޼VZ	p8߂;[XXǾZ]]7-JS37޹v.*Y
l|GEEEeYvcEQp.E)=w}/Ǘ?˗/swϏ5fp~n4ZiiilZt%n`^bFWIйs&M|r֬Y3gΜ&c[n>}:̞=;88x̘1M,wٳ|I~p8͛7l2L&$I(58W
M~:ݭH{ Օjrǻ><sG[.#V]VVVSSӪU+
Jp!3v:!VԵ5+D 38Ţ+!Z.S5.I0+.=N'{Rj4[h(ٌC		i߾}/\fZfaaa-LF5o#qAA޽{_$&YwڴiӧOgvvvҥK_DDDtMm۶=tPTTT`` 4+++77w&MԩSQQeYNJJڿ?h4\.'''+²]d>Otұc!C,^{ݸqcBBBN~^zہ?=kj/#F1b077w+V6mZZZڸqΞ=p=znlkȐ!|Ǘ={\nݞ={'Lv-Zsϵk׎<8?D<Õt&"}Ml͏{aKj2r摗srr0.|kkk.\7{nnNsݷr˪UfΜYWWO>˖-fgW

-,,ܽ{w޽য়~oc=7k֬3goVpp0m۶m˖-hѢq
2dΜ9YYYNIIԩS۷Oo߾7|s̙3q~aС<ضm[J9sۯv{Gyk׮K,:u…{˲p8$"ٳgvi[KO|wU__jttb'ܹs`A۶mۛnpÆ
l";W^̭qIW|x̘1۷o?w[w:7nuԚ5k>=EX
jKֳS0#7A}/9s)q.*'wxKKKa֭۴iӌFAƍsܘJiHHEiӦMTTCCCSRR>3h4vn?쳝:u4iҲeF󿠠`Æ
SL1cFHHkdZ>mڴ-[Ł,ĸnIy51+W8p`ǎy,p8W>ye9WD=?s.]XM쬬,Œ:ydBBBJJJ||ݻYl7nܸxO?4!!aSNcJ_MM
xȐ!&LصkWbb~[TT(Mze0Fw^ñ`p-nʕ(?~<..!$hlӦlVg]5e}XTTĒq8s9EQTUa	%g~GF


:wӧO\IsC~u׍5jǎ/3fLTTԂv٢EhKuvw2$11q$I&n{衇aÆyK.UU5%%%///++۷ݻQ&Nm۶Ȩ~wDhh44hf|СgϞ۷odd5@6p09]x1^FD7^D/l6!.9U7:kWҜ
K/EFF&$$<fƍBȋ/NGyDB̙3CBBRZ[[۾}{;4ɓ'@jjj߾}aaa$IlzۗR:l0ȑ#bcc/zK_IvN'Y:}2Mܼp1VY.rb}^Ã/\Fs%͹#Zzb{"NQylK3*+`$%c)d2Dppppp%LUZ}s͎v>}_=xr8Ν+//R%  b0w媫3Ͳ,Fp8v(FPfB  XV___Q&)00044d2DFFL&1f)C᪚\s\Yu-U>=ٛ"l1YRRR7Z/\eʩѺ.U:P/++cj_q>?x{ao6"((`0;wRZWW$IEve
C@@l[^zv},4ùWF$~JsmFSB?5 ^PPP6mX-_7Ư:у͛7_nf.PSSszo6cbbbcc#""Y8v{II	Zh4VeRzJZR
v2\.I)luuu6\UUU[[[^^^ZZZ^^^[[[TT`2رc͚5ꫯX䯓4KvEQ$IڲeK۶mN}N<ɛ0Lݻw?gΜQ%::zΜ9{

muuu555.d2u҅pBCCv<36-33駟ɓ>e.*'\bh9>((HBVҺo{fͪdAG~Q+F,&iݺuw_ii){Μ9Çg;dYf޲!C߿Q#SNqD(Iw!ٴ+VŽ曬8<\ˣ?JӔOxF``Ǜ@Yl޲eK}
0}aRC*?=-իW"~^xm۶ދyV^OZ,Bл[QV>==yyyyLLz#Gy+i?^z]_gF/,	LSoy}}]yرc={oM:;;wܟWQBwZF,[l˖-QQQ7o%IX,6mݺuw֍bdJ_8Εwwp%爭=#sy@Ow|ugf-Mɦ)!DI倈gCi?;,'D!@EgBi&bڒf;}|nMYPB?gf>i{XbS8GiPȏ#ɰ\$.4rmLLmmmppptttLLT*iN0XVBfth4*
n0F#hlllt:fAzqJ
RT&,k69X,nٳMKAD"V@m[l7o^xx8>1zwFuq`{ӷ'(BJvܹ|r~EhXxLjgV*q5!o͚5f9ᩧRolٲ=AʷWF~lid'O^nݠAN7mں駟Φ& =BJHSSSxx8U֑DPnSm(u@:#G?o=w`xW^rY\.L2P.+((h߾}k׮(J?K.?u"?3,$:ŸO`A&&&fYT]Tm>Z@֭[!XV-H$oVDDQ =,8}&:8nGDDRYQ(D";s_	&:4%%S"?'toF_s 艁 vA^z0

322N8i|*duA7|w\/b劎V(}HwX,v8yyy&MiZ"\xqϞ=8nSNBp۷o{ ݿ!{wT|_|YT*>=^wd2}wk֬9sa,Yo>7?߅t?k|?lĥrjz
W^gq@/D[CC2{얖6k	[aY6##W^q:Bp̙o9&Ncǎ,S~A,'&&/.\OǏS=AyJ4MߟGѦ ˈ͏DsfKo&M4xqƽ;v(,,8N$bT*9rZZZ`(--j]3B<;v466BV09lSNݻĉBDb*
.]?g1QFmذn??%K?~~SN=w\ss3+(((((عs5ko~X@ChyM2eРA.G%
ݏv:_RQh4ۏ=ϟAB|2}'NPW-cƌ9uÇ/_#	!n:xrqh阘'vӧcǎ'feeQWoїڿٳg֯3fD"!	aT].ׂƏAzFhe	NPUZZڿX̿J?%tk#~4MGih.,,|?ޝ7]v)))s5j9C%&&\r׮]}=0E-]`j&8NPh4@nݺ[^xfAg4zJ z_ӛeYrĉӧOD"^/JjuRR{kjjΝ;s[j0,Bh6lx9d&	
a4M4ss7nܸW_}4**ꆕ$vʔ)n{ƌ20S} HOQ@7}y@!8%ܹsg~~ƍ'Ot:A/RݳOW!yzxbajjj


6o|IK.ő:s`C"$W\;wG}mĈ/N_rM8qΝdΝ;x={:VIH/75gX,H.\8nܸqƑoヂ6l{
Z9_\&s	#FO81  Ξ={ԩg}vGEEmݺh4_|EBBI]83ݘ3gK/TQQ	'	q=Q9䨹\؄oOdgg?3sc-ЅgtPgi4T9%H%i	Aۋ9o@%ݑScT!	&EQvѣ{ٿcc#(O2ha8rƲD"={]6nΘ1Yfmڴ_(a0zƓH$dm(=۽r֏?X"gWx`YnK$˗۷/88xرB۹s>tN:uݺu[l8q"Mӓ&MOe`XˡMӛ7oV*O<ߏߣ  HᇲxTJ;/=ƍ7~0h*
]ƌ!`
4hPnn.
@f" q			o{j[Z&.>wA{{*,,:t(hH ?/'!td2i4???x!X~;X$o:D^xojIIIg˖-3LO<ݻ;F3b0ԸnBp8D"Q~~jN;{X^^P(J	BT
?GaCNSM6m޽AAAG2e
?MuH	ST,VTT|{o||<˲b;zywzwqǨ?~\R4V6lXZZڑ#G=HQTmm-1_XPʒS׿J%ҦtFI
.R]8aCd26|IJjYY˲\.'JT/^7D/ĉb1hhUQQqロe?
6TUU} aSRRH{g߾}8?6U#%	N>Ô?6EQq\fffZZ?T=$bפV;?^xO&B1++kڴiroA!a%%%HKʋdee566˰K4M>/Щ,h4e***


nwRRRRRR^^J%t
Ǝ{i-q=ԩSdԩD{ƇHU82eJii"##ϝ;pgZ:p%ԩSӽFHmAjJuT믿.))	#$0<,f2LΉ;w5ksDDP(


%qc$133z,{wrmc=aÆ۷O6fÆ
7nq鑓
rrrr~~>dGNgvvvAA>hH+???G;:.hW*UVV\.F|&~DYf\.Vp8N8q}8F]TT?K_0 66#w!%fyyX^jNhT@p8N|O]5cv[,m4kjj1c ŋz=اJAQ ݝ@COiz+iJLL4(
K
b!n`x;vwB~ioIDATjՍ1BTu]ӦM1gϖf?""4 ^>\.333HZ-*++tS>|xQQQff}6uVQQq)=wܐRSS0JR((ieZ-r99@W_4.++;rHLLL~ȵI<+ZmRRŋT*Uɦ80̤IHoMrwtGIIIfDlCM(^[??NwL=/TtkG/K;?nٶ{^I_$]m=F5((hر&	^8_l݄Ǘ//_%JrjjjVVVNNСC\>&LX^^~ILp8FI#}+ۭRF}FFnȝL]`I0Lw^L&
[ZZQ(
"((ɓ8NLWҠ8;
펌,,,4L


...h4Ɩ^/+4	#G666;v,==Ja{s\yyyfyԨQ~c2\b2ryppp`` I-0޹;"$4 ==ʜuA}5bcims>Ri%Ikk+8x0A֠b1"H$ƍ;{b1xiAi\?ze;VYY
c4'XAO?1,H}GrNz_>N0:+斖!˵ZmYY˗.<6w[s0ul6L0nt36-88XP?j'P"BM;L\pl6GGG~QQ0H$(RȓͷIwG,**wqGTT/111f˭(+lmm
~>m>|nkt{.kṃ
oBa~@R~
ʒ@0 SjOB䩟_rrbimm ??_,GDDd			q:DxQWmҾzTg2{e?;ɤVáwzd2O˭SPH$jHr"
NgEE0 =dJV'&&^t̙3:NՂh4Bp\:⺺^/JGqqqUUUHH05;#qzh4655jp|:LBJ:\imECŶضmB!T7l3ԻMڔ>nl6r"v؝-BFlzd2A,ṪJ@ctz'8lPUYY	n*%ݵ̠hX***._N74mX4IssD"H$.p@DxArt΂QX#^K-H
4L)[BAu5ZNHH0LDDDkkkKK85:::((0LTTTqqb@@/p2,88E&x|Hj% 
Է'@>BBaZ%JQ{ t;4?ZDolJhod;c!]f08ST9..IpQB>b,v;k̪JT*JXX,NSRaF7\SJv Mz!'wwn%DjO^"Q~BBBl6ہlAr"v$孭6gi7(hE{&W\?ӝl-*p]k޶J%EQUUU&I&pKKj"&5\JU,uE\; rMMM2/C֘7,,pXֲ2vjX,@V"h8T#ELȏa@Ql`w5o7$X,4MfiY1wdD|s2q;|y
.%ՂaM4C!kMMMvjAT]Ԏ6kުnwppL&+))@&zn1"?hjjr8*(	jd)JDX7\l6jPmhh{4/_^Dk}ɞgN3**
,BfJQF^G.YTvl&N Q0imm%jۻ68ztsNSPL0V0OZ]^b[l{[57V7md0E"1,ru\}.Ne477b0"
_/)e2T*fD0
v\.
!~~~aa4
0---.K,[VVtBb1RVuC,Ì?ZZZ`p:mbZ=N6M).Z|-\YX^z8 MhNjn  7X7O&bkyBmvACcw/(7;^LGA{>=m=BJ|o]夗,xݕԘF
AAzg*㑯(*6.3<-AwIsm,?;>)Q+':δAwhߞhFǃu!ض*.RDA[\y?}#AX5iN|ܬ
AA[\Bضq~?6\Vܺ\ݡt߼v{ tC#<:^K~`!  鶾)߶`ƶ{5R[M?=eɑ5.olnSXwmVCC  HJ
5?%|x̝AJ2(ޙZ;Oo4ZK{jV4yɦj]s&u9Ǐ9ysF8ΘuAZ9o(0;O:6nelz+έۻXyŦKV\u֋Xyr]]š{+]`*rW|jS>h1FAOо?"a|4Voe٦m_.mò!SMٞUr,{葴0eYݐKFKmR=cY&(=9=HròXGu,*^2Z
8_XH^(aٶ&=0het2#^|$AǰLo^4ZqlhF%lGAAz>>bzl߷T{otꇗ0rгC+MV%sU0HjU:e0
ZQJζا7=W҇F=F$m-8SQʙyW6,(3*cW n:IwG uG_ƶ}NI{/o7Pv# ȭ{4&}y骖LxD2)tʰ)[/V]/pܯQ蔦ګѦZ>Sǵ-c5?Y]>d帶||Oo
}Uz8eX앭Ծh^Զ+Ma  83(#z\*n.+8a0>a3F,,kY9u쟎6,QTqDZIs~ڏs-,kc&[3qlj*X;}IJB!Q*öj55K
1˲KY7Y3rnobYKف
kWRl{)S/E%  jkgzsIOz_	B8ekIӧOĄ<ԖӔQ6)s)uƄ&!7&#MזPs[R&:^ˌ0ksnA491&N7'-)4t҆qTѐ.4em0زC_7hFAK_{y*zH]| Vc?=0Kcͩ ܂wG^xWTOjA2hT %^azo1rmX>\r
cApziT N':a^눡F%  B=
8vۓѨQIA j@NAAA>hO=f[·qƱJ=>Ż BbmUQzu[Ow  ;D{c8DAq2A%  tEFF%  FPI#  *iAA:iT  FA!A4  A%  bA%   AAnJAAAT  JAAAPI#  *iAAA%   *iAAA%   AA4  FAA4  FAAT  JAAAPI#  JAAAz٬69)ZAAn0qMq@Ca.j  7m
C% YI#  HAA4  FAAT  JAAAT  5A[Ь^mIENDB`errbot-6.1.1+ds/docs/_static/screenshots/help.png000066400000000000000000002476571355337103200220210ustar00rootroot00000000000000PNG


IHDR9\	pHYstIME	 S^}GtEXtCommentCreated with GIMPW IDATxwTWwf{^ bnFŮ1Kl؍-*6PnD~^0
bAPw:*e,}sܹsgwgT"IMy!.)JK!Ba`2zv.;3'>B!D^>RQ*%%W
D+oצ}䩅3R_/sPE9"Pk>''c'ի!<3<flj¤C,?%J-UrV4E
be`7nVukvIM!xx7B!ߠ'4DՑj٫T/]~ZE\D5ZTt>awl"E2JJ⑊Vhe"Ӏcx.ap5U'lXbJSܱfs?l]ڑ[呯L8B!	!%ձ_"[E+@E+DJ˲%U2%-+Vf%$ܬs[W[ꩧ*#B!􏧢W	IŪ,VSrJ,U(*iC
iuE^ݪGKt^Z3sY;.X)=B!"T,V
JZ&$RU*&EXAUBڪrz!,KGGB!HEvFn9L,KU@ʲєL$	`((Inn[-	,+_W!B[iΩy3eРRr$$%2^ew^F!пQ\T_Tԭ~nژ`lT&A2	I^20i3R]ыq"H+B4~ߝx*2~">@@PZ*~٥nqx>fXW:	beDYxؑg[:R]ۈmqT!B_R8/L&SWW3'uSZZMIF/$!{fh9-a@(iin~}LsΑ?B~םvCi 9
.o@qvF;_3{X)B!ũ 	rDt?|ngObûQˇY:a"BA2#c!B??B!TB!!BSB!P%SB!"B!LE!B01!B!B"&SB!!B^WB!TB!SSsR^H$b#BSD"6ݏB5d<> ?RQSs!PU8s'G%'[G!B!0!BWNG ?>wy*q>]ʌ,9-X[B!O\u܌/T
w$)X}SFEVؿRsͽsBmӨ R%~6[P````W
h-jZBy*bj8p>$n;<0@W=59
U8r*e+ջEqo^]7u͸_)TƱ^7!@!鸹	TrzY~hӞf9P%I1r*R!$=6ͭ?M,bY7_9N{`?v2+nA=xL jZ)&Z
2$.I8W6|NzL5Hy9睂\9"wWWɶ-FM:{nֈ"	h»GŊf\mRT`lU!f
+glao*b|°NL)uWb^ۏbU|=FQXL`ً%,hfm>h9~#\]XGȢ[9ݭIy5<~Y؆u
f."olSW6!:hѝW,	lʫr{vq<}@/WVo9z;MB:CT)]eQէyR['oWiZ!CޱXڻk@^S
ݿs5@vow;熇B\CQG\̍N	vmeH&m;5#hZT4]CAfY\9ҚϬPd%	bIkԈT(hUaR7Fy@^zdA7HwsoTЙq4MtW4[wQr;`o7o(Py#[ΏrE*2(yjK'.Zfٴ-LJOsTv{
vQZ.\fIm76MTZ~"wܹso,xd jH˹"$J
g%*T%?wc`d&  Jnk{O@oPb	۰qBo:bŪ**2B%'ޏ[{!qF,I1\zutݺƹ6r;ATL 9"+U"Wh鍈/3ƍ?]M”#W.ӿKy]=~SIthĢ?*mfï\	M~j>qihjvm?e7v1!	2#ʀO$~Ms〯+1d
XS!O;K@!H\R6etcIwϯMx%#b8xU…GG
Fd̮ @g	1#"GˡF[<*u\NO͔e5$CDET*ta2uQ&m&*2oQv8%j!E٥r~ S9skih%9%*)JeޒoT$qxܻaG!*jH&rPG޼.V	EEEx{0UcO6z2TMi.[գkIN.>x6JRZcVE-D嚚H$lK)Fd°edCg]=	Ev~&M"氳%nu|Ȯg`[-/Q>ňkNvmjizDWif#G6gK޼,C!;+vu2_ze}nòjr+UJ{VX>^*lYg[B"-ol{&j^o	 ͿBv1[:YEmEʴbظJ;
JsoC~.ow:"mAZ%|hKL;?[sA&hhs@L;z6sEZU,*x_vkDYJ,naQ>Y13ҫ^|mȰi4;ymVޣC)A~dniebj^H*4E4jN/~CYr,F?e7oP>yBSQ/R^P(5,HK
2NW^Kk*vܶ\
{&Bj{})ǹ|]]3
%p#[Sau.)3-Y.A5JZqEY
;u
5?~Ҧ"9;H[*oi~x"|d}~
	gHz@ {ڡf]Uny=L
-]Y*3;[3!8B!SugcNZsdfۏ9,.Ln\sv5KLd`/.b5B֩9gV;ժ`vW@KX_MAZ3wrѩ}=؅O9*߇YiP`[_~kNDBL)^%"=DZ:XvWX3",B!=-gJKcNgX2/ex[w3]x&J:j)o"fenЩϛNRNui?	l$9A|IɃ.ɦt-+,
Ҧ0	 ҅WtwdM|бIl99ӂɠ(`Q3'r,GUZyi'K^$+Seaఔz:yy
DU-w4sʚvg߶戟PrkNEqbqtLˎ,=7l׬YsC#NG[`H=nyȻ~\'Tf=8PȸziR׹ݰоJ]̍ݻvӡkES߽߲u#PP#dRfE2W\SZPm=(HS)H׎\qqI#N_%@̘TGgJ@u`ѫW0Sy]#;5<,w1lk=onƗ]
L}9t
v1L]qkĆEm^Px5-Ou[󇻿96izd4稴
ֹ>}
Kѥ	BYjݚE[<2ida׬Y<1iɑP%Iw8_5a#_x[)K׬х5#iӴue.Zf[f-*]3y&V^8PvS毻"hӶȍacjlP6UJB
{H%IYS`1(_M0<_	f[QE@u~(qYłJUUupZ>SV}5WɨR	]Fz1Ux
vS_d+O2z.-Xd{7ǼLCAR	XŲ41PM~o~Ϭɱ|fL/lxTI/Ty~U-whԈ]FeN[IN&O1֙
NN䊋zw5&J'P&z$?	Y7z-jB1Y8tמ`-Ǝ޳"t4$\fd_yWPυvN{0{R7یӷv,_oҧFmYBZq4ݶzvxbφ}h\^մuB{n6O12_;4[k&41xrV|ٻ"5o* 
SB5TD(BMT2֝;o[k2{q|AvrBN{SK9oCDV^0~,:AQLBfcf;xw@!ęTٵD$XFWmkCTl\ȫ>B::@}|qaNauGјO7/b{]:s3a,Ϩ
.qp!>ʍ!vM&|UB:S}e{C;G݃bAK=5+XPmtFCZg&hUh"o%hҨ<J,IYsyLlJWR8Vïw{vJQy".yCWFn+Ac) [<YzpXٱnXָ|˿n)^9k%>D6Gt5ok9g|GiVm^ꍊGoLCBz5`f7˄D,)èS "B
9QE6MbY^|EN<%1N*q^1_P!M=}ݤ;難'y?kinz
@|`A-c`ua
\c6kgE7]̊ca`O.B_h񃈅1)#6rEv_P@Ι9}~.T:C"Ь#be\f
	0~mh@ujDhѪu~ГGje]k7Dv&miT^TWmV `6>-w\VgKwiWS`63r.r{P$LER#ލC]sB
fS/n_Ʈ{'q=)[
 )!%"s	ް;OmH_߹aˌ1Z}.f$b
[qxEPYK3	+#X"=JdВG[&͈OvS7O\c;rC>,}xXP_q]
mzmˤwݷo`7}M[ےu:nԝLyG}Z&K{0hc^Ǘ%['ET$ E[MВ
srUjиVŁed\mճ˚Sj֩m?{T"gJEL}szw,y!`[4gQم"=6Ĝ]Shwm~`cogcH볤ƮDڽ"c3VeklӚ̻wR&a!)`{X;fmoPՆ_jz?`կlN{~[]kG%B}ܹ" ux㨐ǃzT>L4W`{vuM]1ۜ-yKwvu,^m~TXuT߃6|xԡ~T"З&vݾuT,=[JM)×|rki#$4h:so7ij{o<᳾9?iڳ9~JM;.`a<ƧF4|Y{e?wܐ`;MĮϒBttlix5z
[	rk(~JͦnY.)Z
m֑w$7ɣ8f}LHӢ󸾗Yogivl=*`:MTnūZԊC_>*A><]qT"?8sX=Ux9Y!!2\`Ʉq.nU(co]lo3'i!B"B'0'rBˁsE!BB!0!Ba*B!TB!!BS7Q>yPq1#Χ+>?ZPesKU|jUYW3zOkCSѧ?II_,mPd\31;I(ͫZMFDŽc_8
._{-?nWW*N;CJ}!4a+ubzE}TZ7GCSQUtc{ dOw̜fއ(I9{w/dk٪GQIG~۰7_0Xc}aV}&ٚ	RSjxTѽMƤn%_|?.OIoYqws:,8c̷^lINI%XvW9;7!LE*zĊm⓲"Yѫ27w|:Qy5=ʼ:lĴðqAmwOZ93م
R!}5j;ѓW.vѴ;-@όXZ!CޱXڻk-KcFK1f7t1CoN*7\J,!4shcvPG\-ְSԤMch}m5QER#ލ2vNEqbqtL}jJPG}!Y[0{O/>M͓8=AR}WΚ["LE K^[
X_%
іI3bÄœ!'F΃J>qY2ăWКlԑs-rul\=6]9)2hjLJOsl
&WO_c}pԥÌDTVUQa<*?._͜	Ljh	JRQ??8x.OjlպRᲗ͝q1`"Qǰ!Z-gk{O敛9@}9t."W.]QMi8j$T& %EMWV:5UEQcgُY8˕'M۶Mo6ƅZ^L%Gig[v<=>YiǬe}3-WS?,uAW OKBVTP\aǜUcCo(yK	ZRa>#fQv]x]eRֱ4
]Y&KӢ*1Tܗ$QAMLjpLcG#()Q8jډwkrKwguZ^M_imc]mjDzm/=.IچS*XmwJ+)oMkcȨ_%ưCTw>ݼw}|6N2τ/#wUq]
mzmˤwݷo`7}f]*׽mubPhlVx͵^蓧"COV&ITELhamh~˻>5W%EϘtyaXsSᅭv3ڦHڣMUIУOUT7AkrKkEf,xr?|)%$@AN./\UfzB=K28+oL
>ni_]h*Hc5~wOTHYxPikό:L_>j}ڸ򩂇I%-a!A9[`G]?10BȎp;:Vi)xN+_nNn9J]cѶ\Y IDATل+FBSj{9WveJ8ʰt߲#Z}jvP[S?{߸fι *t:Elz"~[1;Ufmd6/7X{Tuh?%ysWZ1pt-9?CP>s
9
sİaNlкGnF/ܓGq̼.1ЦٵGzԟQB9qsמ*O<}!\pr԰ῷz0ԃ
G\`Ʉq.nU(co]lo3'{PSGZYIvK31#BK!T_TI?\x-Vc;lBSB!
ZOl=;is5j!BB!0!Ba*B!TB!!BSB!"B!LE!BB!0!Ba*B!TB!!BSB!"B!LE!BB!0!Ba*B!TB!!BSB!"B!LE!BB!0!Ba*B!TPrnؼF#̸֩~Βc/Pq1#Χ+^-/QvB!LE$[b
E=*H|%]z)U$ֹU1O>,>x&weCW/MB"*сUڙq*ߖjۥ_Ȝ)5N鎙7̫ӹ$_3wBntG-1f6vfB,!-^V&!W]~v#\HB,̴+,Lϔ;Zڌ//I?kolс9"#n	Utoӄ1)}eW$ߏSdIJr_37	!0{5m[1erGv!>)[
 +*t!t7@sg)}E,Pfܵvk/K@doRЖFڔuPMuwW&McRGl.({W}3
kQe^\ZbZaظ'IWp-[
9w.bH=4l׬YsCo_/ t>c	@KSmYL9ٕY'~zt)LYnjPd\Z4	nXhh_Q@
ͫD6
jm5er{vq|iPBޭz>y51DZ85ͮ4F9xi_wh$<**<@^mxrg_]!B
&)HtըYUbрAV}
*,(~)v͘@Km4#:LX<Nm<}riĎ	._͜	L2JT(
<'3MP52հ$ǐ%Cnu__jYB>
]C^\=k1sߒV\7ʻcS&Hd^'Qg_&iKsSUᄑ)L^K~nݬFm=mOtINyMhǁn.&g-__nhzwN$gk({4{v6&SE,Ot	ɿ0>

P$Te7h
bܪxz:z\on
Op35a_l_ԛ2x/b]5ʩ\,sU[hz7gȫTAޛH4{SX\ms5d hţw-	u43%z^uNnsJ&y/su:kCUQq4X5Ղ[ŕr/*X:-	go>̯dxuBYA&/3n$5IS{\4Aǎ렫8O5y7J$EyG#/x)JQ"m
\":Ә"?8kǦe>UmY }*6MoġW _m?"0_ܜ}_^6&\a-aAT&`瀌T^M/dY"1!]e.8OCL*dD6r"&:AC	7to4Xja􍥪*}(7	CAG$5sx=9EWݶ禦smX\JT1K]T7Yh/Jţ`?y>]l43a)J>3/zph{T)ќ(ĪKcp)'g5hJ3*[q4T=x%Oݘ-7&Tr\U`o 1de\>>wj/nk+KݻN !f6
ܽx5?pHvdT|,Icdϋȁ+|vwm3g=iiI]ZIsθVEsWcH! ;]
e'8]1f{PG2|jqR@dg.~z5B¡Mj偐Ĭ.&wd
z6塹<9;2,Tn4ߪK3#mޗ{59RIYNtf踫^o	i%TqU
rUO}m	w4qA:fWpo{_d=T،mb=xK3ذ}OrZy~.x:=i˶E_l'_Sd|m4(bᏻqӹRVgEڄImd!Tt/}Ζ6pͺ@ѵ)m1n>s+yrV9YяF0
nضȏ43Ciz9kֹ^jRVEB_ʭcAj XNA=芤)IߟULhTE΍T|{#727M6\'ELM$9"B]6
rPMsRE´É2KW{=qH	R]nܢmWs!9-x/0X𗄫Y5EQ7/M]|.TnPPK;Ż{& ZP{^^T

Sܽ)(-7*>YpՄq#y~@o_'=L: 6|&~t(xI$9pˊ76҇$Iq˩~}Ϳ:uISCS
6Lk+982vÝ$IARE痎&Ts=z3o͊ܓGT^ع``$͈H,m82iᥨG$I$?3ٹo'Po*{dڧPDžϕ4Ǵɓڽ7$ں/I',ܓs⬘QS^L\QEkLPH!ʥ*FhYEu6)LE$ZsÐSi"fU1 ;=aCI?ӨەA+",6`빌4Myu֩mӨӗtNݿ%#($	欜+̭U]^Ǯf)D*jCuY5Aa,B$ǯٺ`yrwLPř瓟͌XMWZ.鱩GYnX|f^b¶!mzv`٦>zq^**˺v@4;rzɭCvom9y߈.Ƭ]EԢIV;j*oQ$Rc.Z$mT>E_{iϖ)PT*
aȸ:M^˚[M"7);ª̘@Gmqa)ye ."GO%zx8igocv&KU|%h}K&Zq\,*MGGn"y]U?2+V{(S^;^8:`>%$w*ܝFTAh́_wZzqIY~ˮ!{4{v22OVvs3{-Fi8rSOg\wfLc'oWHT#a(UX3r!nؔ*H>-FUEb.^|F9gR0_TxsI
Ler˙{OU2Fi{Elz.c$0-UYBY;6-YZnFDոP5usщW|*7Z^'{ƅ~DM͇IQGޑWWy!IzM⎠
=v(_݆QYZFOG:Z,jJ
@PY"ь$U՚ip_6"ЖkCnIX5~b=/WJA	;i]~6@/dY"1!]e.8xF,("H7=K6^3vv&@]ꯖYj^/zeC
<5ńWo᎚r#vbР=yKլRJ
6opmƅǤ6u@hz9kֹXNjlؾ'L9=}D&(A۰N86xȪʦmfFL#Ayo)Y<ȓ7ӻ885Y<5#=s$_FAh  ]  vE  ! `W  ]  vE  ! `W'mgr*/yznsXG]RۊAQ7}.Ox ]QlA*rn\L.ڏ$iaS%Jgq!ImPiصo]JW*ZJTI0%8 Ҏ"ً_I?)Whq΅|HoGnPXݸEۮQyѩ[`laei,l/V:Vķv,a7"ʋJBWaꏻ7F9kН7k5am܄57P? mMPh?$IrQ%M9$I={7^=^Z8}t:L
+S$I~854>@QU~ükUϕPE痎&Ts=z3o͊ܓGT^ع``$͈H,Ý$IABe#x\wIi䐕4J6pڿx
KArSp*l/IޯU04>+Ίj`EW-1ܛ$Igȸf(u4Ц,9l q{?$,!_
ߢ͟!Io-r}Q/N*eE QlM\f&̈t2[Vq{GPHY9WY?GE-[7:(y+qe.tFfz
(D*jCuY5Aa,B$ǯٺ`yrw.ՑL800&)(뀯+_Lض#dM^h7{Y|,Ҟ-VЇo(sn6F>Pϛe
vTF{_aUf_Inԓן(L+UT(#-@mNW2
fښA?	]5|b~ӗtNݿmLBzVb'Y֢"G]'X@m=mOtIN1Uh݁n)&]SvMO7sB\LXT2i8+L[={	z;XPٗ9*4Dg:\B	.!S7|TVHA v7˳N]CF#azxz:z\onC.'6SΪ2Fy|hGrL;c2%vŌ8J6KkOVAĚs׋F5deɤ>E *KxM>ʁj'gj¾.:
;Yvjί`~i)tR.rAz-4T/P{얄7Wj:WrJ~܌l1\S7+y5>Mc=.נcǎuUbüf=gB?&C(#c+j IDAT[%k!Iz)`(22iM^Ch)ޫ5^%goo9S@sf!>^%݈|_vlZ泚F]s/-US"G]~6@/dY"1!]e.8x=%K(:a&m3D+gM߱uE7TdY񚱛62xoRdX&@*6c,6TDp3LjX
07*v%/7Vzn#{O-w"i!|5c}X,`fLY~k-9 H+߶Z[4Qc]11IX>!KY#\Lxy[3u;;qy9̫=ׯ6s	G]ɊAz!BO=Tyq|ҢnPoAΰ?+VΥtktkVWT\>ڽmt&TW_J'ixf95U,o4IcX*JAv5dpmƅǤ6u߹"xWW+.c|3O,Njlؾ'L9Җm\CN~Nڶ.y::ny	GzkI*&+G>ƞ_utayJ3 O·uE@zn	@t
]gݨm}U4(LFq#N|8ju+	MQ/'?ÿp
:wZSOP褲1U/rAN86xȪʦmfFL޵"qVx=tٱ31Q}~fAGD̼
ɗ6mCy1Ǩˈ36UYXZhq致ۛ@ݸE~F@myc+KcakM9*񿸽Wubc--6}fd^P÷GF	mg֞vEmy=|^Ԉ#PҾ2µ6nBݔx~not?V	moI,6mz$)L?ԛ$#>]	 ɿ:uISCS
ޚ>;mI$IzIL\QEkL*4rpJi}g&I2nFKGYV*kz2S<Ysp9deF%H/E->v:L
v.8$Ia3"2KpfL7,0{sR$I2&
KkTgDPh?$IrQ%>b>&GmZIk.6YBTqiM^Pk|C[%Ir{H>h=t}TT\ˈ3]$3j
zʑ*4#t@}`٦>zq@QL5hJQS3TV
VB+j
X5>YIr˙{OW+Gs-f[2ъbQylʤ>: v7˳N]ݷ+WF$^]9&SbW@Y>K@ٷdi;=ts*:weqt@}KH1T	^	n0ZQ/.o`)PEb.^|F9g[veűtN;OWx1j/}NWmX"sZہΝr$M=q-Oܭ[k?w>۳|wuO*FʨǭQk0#ԋ-%Kt^FIWpA|)gjVr"XiE!
lz.b=/Wu_-r5#E'JF^Ng}\[C(+(*YMnzXף_eMy7J$EyG#/6U0ݽAL4c$0-UY4
Iޅ}Ʊ6W6TW@\]S-ULWj6^.ZIo<791ҹ&4|5IS{\4AǎQJ!M)ZٖV+`U*
]~6@FGKYl21dKv۳
6RT7t,5C7|;Q(QJcgYHjEHW}٣K#ηU(O@o~WA6qL.itpqXP%X9 d,e͈떘&Gϔ7֙
eR
JeLg>SA6鸞zE!!\qk&L̉iy
L{Xsү>875k݄ג:9Caƍ|U-5^8<`Xci,``&acq|*nVtTSA6뤀རѡ3wUO9Cop˨Ά>6F^8D$2*>lbx_F-gʹt_nT#XgksDW{_?C{m*^	z5@Rb^E޿C;jjn	fƸ;IcdϋNīuE]ZKGȈzƐC	Av'Z
|hY^MN?\hAc):FA4ّcEAΝdN>M
!?lCs7#uT2h"Ccq,DvFWJAh d+4iKBVjX6]Q8a;l>&453>"cmk56l~VW	]-=^7@qLg풗Vp4?M@XZyױֶ0{X9K%1펭 mٶ5tWr0X:@Fxv_MظڦNBB-KSy9k9~JI2*J}Eez
Y1n>s+#mh+$w~OHR y1͖W4}L+E795hc[}A2O86xȪ&}ۤQ'Li?ޙ쨆z:0@|?z''zoKZ]kb&O#/oGKD̼
ɗ@ш05qaM뙧^vQGi}]Us}gKвt,Qtax#SgySCyGOAi.4  m  ! `W  ]  vE  TqzҶ36E{$FZiA=IaA+N*:w!Tx-7:ㆪj ~thѩj=Giy9/XZ6^ɪcݮ7p9%mq5&y<`>sգݛIm=ԁn_O2\x)zͮYU
V\̎5/
]~sT)`mzj'@ȟykGĔ&jJ'ZSF!]QBYl6Qۛl
;mE_{iϖ!mV~sVAQQVF@I%R)%UtfP.Fa=knt>lӅN|HVLMTYֵّSKnڰ{m!F^p1f}첝=-TϭV,WI;k{нݻ5mhb)YռWRI3qCU}Pm5`빌4Myu!j7:^ńmu	^nyZ0(0b
yZ'yuKM7@QshsE&gO~b63bq7]iLVZ9	w+}:>3
$M"㱫#pa,`s)psqnE3.J vE'X@weqt@}KH1T 5p̽˧Y>xz:z\onWbZܷdŢؔI}t|WKAvtI:=S|ps1ɽP|KȜSwBqJ6ܼJe˹8ba
4Ku|(E~z1/R~_j`1@YA&7#[j\2E'JF^~ۚ'><"–jFL=.נcǎu(>uֆjhk+iTrJ~㩸QWdB
Uu.̀6$Zժs~ݒpJW
\gePzݍZiΜVFeozǚ_9:AYb]~|}`7@)x檞MZ"-S}2mL]ꏙ,5C](2iK>\yǟR/bƾC
>RMgʉWޛcL
&8,d,`qQ2zufJWhu7>0&,ՊGGF*"Lu,
ʪgAe5p)7MT,}WMkǚ:xw2JB%XJSv沔u' w}E\qk&L͉L4y&ND!KY#\Lxy[op˨Ά>=Ѧ1WE4Yɵ#mgus\h>WR@dg.~z5Bv˃.`5ȹiBC!䧞Myhi`ūx^^v@
ꝻC'tϟT(uƘ
^B˔WvѫM>(6uB?a.q<3a!3:Oyg?צrݮihKU*١Juy-/a.Mb\:/@͠Icdϋ=g=},p
ܶVc=gAhɺi,L&WTQꑴj~
{QGx:G$w~OHR y1͖WsKnįWD].=>nNV+C7X?µ	_Bf&K)p&-;m/juo]wi@-[48`f47-6頩$5
%Mz;r$Z
 ;O"_P[5;zol`7n|-~nh#ghWSfFQVӿ鈰N86xȪʦmfFLУ77;]
=
ٱ31Q
@DD̼
ɗƿD+}~n}pp vE:Uܸ]N՝1}I]w\qځ8OdM@0p̒6٬4wfݛ$IrDbY[ZJT)fz!$}VV8U*
i9[e/~3beĸMmqݸE~FZªclM1glΫ24/REORZ?uwr䴙uei(;mMXiޗe5|1VJ H$ӯ[#VƎhKR_U((|MAiQA,dk5am܄iW oL^^ع``$͈H,*=|9滌r<7~͗H>hȁ$I+$Jl!$IN
O)ԵN]Շ_|&;''I$(m|/L?ԛ$#>]pg
8vpzIL\QEkL*4rpJi}g&I2nF*{dڇDžϕPE痎&TQs=z3o͊ܓGT1XZG+ӿ9@$9CVfT#}t(xYt~|I3B>;$Z`a6x19j
vZͪf7$ں/I',ܓNipZ%*ِFׯWYʒ1Y[u9R١LPb(JH$r.Z]wiI~X#*རwӄYl6Aa,B$ǯٺ`yrw/zM^{Zg7[Xl^vd{wtQYA!I7g\d`netܜ@gO~b63bq7]iLdž\0:r	XƂg̘@Gmqa)OeYnX|f^b¶!mUtTX|}SvY]D~O=9qցe.tFfz
(D*jCuY5h)Cn]#{=˘L3Ф_o{py:[ts]
,E"&JXܻ|5B֮}b{y߲֩vۈD;"%pɔ3>P4H˞M^,pk!|W,.m9QhN8Go\ȕ.1}=d4B[o[G;S?]}R㭣ᶑT1V/_O>@~-͜~3iFV5;B36}Vvs3{-Fi8rSOg\wkPV`|+%}iwܕXsZѨaDy%J"Uѕ(0Ai
Op35a_l_|Z^IYi[ʘO{h*=jxw[h^'&C(#׋y_}o~-pZmÞ!j6^.ZIo<{ZfdK\\S7+y5>:yBX~_jHW\[C(+(*EMnzXףE=.נcǎuUˇy-{ƅ~DM&F}j=vKٛ+^5p%S"(+(*
UE2`WTn7*$-6D-wLjC4^jDA-:PXS5vq֎M|VçA@ek)㉻oYQxxfD+zW]~6@&W.G3.=}1ñf\d?LX,6T2JB%D6r"&:AӢٛB*2,xAFKi%HRYcT(X/dY"1!]e.8ZwWOfsvFaAT&`RnZCް^yŘ
=Q8__rA΃t@"S2;JKŚW)2Xbuyk}M4oA: mkŸMx&D?X&VXԋ˛Wӝ33_Q?>V2q^c'L{Xsү>}>enjz1צ	+CU	ͷzL̉iNy+
3nн|LhXpmN}yAg>w
˓(T>d˫z# G~};Xi6i&^vT?0i*+N7!*Yxoݾ>uOM͸
#U6;hKK%T/y\1iԢ.MbĐ?}AA^ѻB™'!7}BF*-2QPc9I&:va:s	ͣ^,`5ȹi>kiūCD's&f8NNrc'{^d@vBߌcQC
9e-||m3FBoAΰ?+VΥtq,DvFWJAh dUt~4wj?ğdK<RQߍ	
z6塹g|5R0]3K^]eծctZ3V1݊j[FͶۄafƯy,1krTfbVQ$p,FjaLڶxu?
k,uQ˨zw[O
lm>=/I>kR@įWD].,
mk56l~VH/H[-:2p
f;ꊀ%9MGքSfGd܌u=Ck|w :xخ3=!qKky4[L&%ASw_љ~7RGM6Mȶ.y::ny	GzكIp]ocEڄImd!$c:W|-k
tiV8Dolњ!VR-*EYh_4fs[SsES2;hKkkz9kֹK+A4)1cg #*ǰN86xȪʦJmfFLڋRqVx8;vOw&;a1B
<?W[d"OfLdʊKOG̩^'̍Ҭ	rFI,Ȫ~	e,- Bm{R ,]GG,pTG_0ɂ}Ө7"
AAw{
o'# " `W  ]  vE  ! `W$U-LX%-H9r
Ǒ<=9,#li ұ"iaS%M1*s߱k;˩w,XzRE3
~^&34Hǭ+A=d/~
$I\[c+Kc!nWߍ[JQ[|:A6jڸ	ܶ!! +*861eCN^2YhKT7~
Ԍ=m^9ܴVÝ6oM_Iuei9.(xjH/Eu6#+
@uї>?nρ".lO=D{
cݞEdUݱn׉j8v.P*Rホ*C?qw[sG_
fsٞ+KFȉvxI3@B?Tvul$CRC_RݮVms#v_'m$޵gҲ_:(|6jU,-p3.IABYlvYg=.K{ho8M:}v#Yb3zcWG3KX;Boʹ"9*j9]Ձ*: P{
J9sh0C<{-}U?6K6C'sޝr1))r7ww{֔O㮎_ոMzx8igom:B09bz3//tY0Pa|;nu$9p̽hy/H ف
**(HqպXZRTG(( EkAjUmZ@nYlP@޻9mhx+zߒ6:7:n#1'1+ngUm$]4n#~9f*@hs0L=cd*YF6.[+CŠ9]Ckˬd7^泖eM(4tu3mFQR"y
A!,~Gd"]fLpYvHu`h/jml?Hi灣+cvV,Y6%eBnO;&`lhEYz#b}T	ں]ԗC]o"[Nu=Ǧ"wKUڐCJ7u-\)Uos8n_hCz(Cp.;MmwO"(PwQv4DwH+AA;7bԸf_<W~7շwN_OEoF"].A:U#?7MҺWUp͑==GvC&AK+=صSA?GV	yLRz/z(A8"':btBA'ҏ  U  VE  X! `U  U  VEH'*Mr>WR  X&mT7plʹu5!zTޏ	3ڙΨ|rc˨CEI><.

|؀+bUͱN:yӱoq}؍UK'\Cc#.Ga7~fpԔުš4_7kfhdBJ\\53Fy_,
q}[ֹt?x\^5ni?-c*8ș+]ɧf=0> "YƾE%`uGPUI6J8ul\$4tՙA*z4Uy;\}SZ@5f+32^Ml[U@Tp
a=*`h[ɽκ7"w^&yEQm/(ۭyhC7EѴRxlׁW3^Rt^l(w$E
bܥځ=j<^}Š]ѝ~',y$KPvƂMeDW7邊EgX}Oۮ
I-2%*[iC6*,/{AɒЧ[]ʨ7.f4n&BҊe~}Fp]U3yEMA^i%MaM+fmjmZ!Z IDATGKhjtGRVTSgꖡ]jJJku=.J[Mj6L(={[5AFj^]My"аmtjC-3m(.7%]!7g{!lF;;5ieZ;T˻)l܅SCMQa5S$ԅӂ
QMu_Uy26`9>wg `QKe(Lj
+ҿ~U{k.ݛɧѥ3ApUf^L:5"΢KDҿk[E$E2SB
HbL7}N7D?qͳu4on쪴w͵wT%@l==^O}:%JZ_EզP\f4(ߵ䘯gZxYNe(L|x53w}tXXO>NǑkEOi'}7z'	RэtcB/IIox= 71a[)q
aYPSVrUkm@5mk5:\(NU(ᜌT2tr߉4<'X؎bqm.!Pр1vbl+E)ϊCC~356!?m@Aէ+ۆ[w@owCNWH{tus/g1b1Ǫ{˃j9/J4U!K]F[%7R6xJ(KKY=ZS/.mM)tcWAVƉgmȑȐY~vOD}~ccjtGĹ3q#FnwuwD6~%?cøXC50ÑNG٪؛ΦNailĄk|׮sgO]fH}n*dy9ĄqAp`UP9w.E]xTX)RnBZO+E RWAX>+h$AA:}E  X! `U  U  VE  X0MqYwҚ׋D  ]*y%Y&?:IwFMnA) e"߽|\*F\AAzH!@fҴ?pT,QqRl2%Ñ66YѩߌR-Ée|AIt[/~_l_"$9j1.|>:r\ǵ<4}0,d0&-ZXߖ =t2׌u-ҽM۾N'ym
f`ٹ[RM*qnaN
@Tpiq|>6aoI!Ԋ'7(!HVj"WhĂ:(wrL,Ot.<~zqЊi#|蹁1i{ʓv8"z~[icqnb`6SƎE/*r΄>sQ;vcr5>~Su}3mPW-TIžE\|I^̩y25y/>TdJė)><]0lȍխe> H]+"iּW=Co\ƒ_W,	_Si!_.?Q=4gvO-2g5nuh]:J\6hN\y2Uxl{Xvm.j@J$ng`t5ј?k4g_8vgk*xs*Ś~T~gf4IXI+!hOT<	K^|;M
Y}	KS.`r8ѽ}wƬȩJϝb/[YBĶ}+vDsP!fLy>GxnPξV/HQ$erRFŶB1}gpmmTÒҞ׹̺JåwC@z?EUf)!$ls)(ߵ䘯gZxY/K}\:n=?;<}G{7RI’bJ,TR~|?%
JH*>bm|sѐ vu=C(hvI{̦2;JWAKB7TIR*SǾߡ韦z@yˆN\[_	**FXD+	D},$	$5e*AH	Db@y_tIA~T.(1S	$%9M)޺L`Xr8VԳEfAMYΪl1̀f2Tfغ_2Mv֡5WQa}'OxFB=rndlҵtSIS]b%_#_o>Mc;4U@sԵ2 +?%t_ce$Z6yNGM$YJϢvOjn<Հ;9(terMڵqŽ?SڶV4i-4,[s
{UIM%ؽ֙-[;͠ѡ[u5q2jEI
̃sFv4gkcmuUycLJ-򤝎<9%_R8\#Ϋ?jkTKC߰"	&ćoa=\;+nץe͛_{79!ZYRlȏύfmNFfYM-OYq;@0[jz3w?zlG1,TGe-C`"UEt֪Iz#_rjO }f[)@;Qx*׼;IkvJ"iж_`=tǦ"wKUڐCJ 	(Q_G/6ov׿űDF-]mldވXUB|M>SYl15ɡ9֚zy$54)Cڪr[5%鐥.,Uϑ6Rַ/]b/㙼Y;lspu|ߌo<ʼ~֚E|-`hY^o׷;KL V{.c~&lVos~s+KVsw}33O(1QjDiSzXZ7*9]ϝeƟ7B_u@A*DB^Xl*b( J؎?̩"UqRn%,+8l4g-: 䪯Bk}/XMd~p3ѵ-e9ND5B꾯-݆f0-OrjmN0~Y?a@朘;{}QQI{c.?.88'tƜCʒ%z~qGkOr^*3VNQAFh$Pi\K]T)iĕ3Ww+KضoNuF
7.A+T/@Ӱll-e V\H,qM^L.>(P$x){}X[$VU59I?>aO9S\ՃKzva׎",ƂT}sbu}wƬDA
AW`@NW+7᧋mWx$z^zACˍSVO3fV?8vsW59Xf%Yōauozk<8Z_imMְu}<cA,wp f.OIEfxl̰s?aPF#!^ &v,Y`o{}K{o# XGKY\E.vPXsMo,nLV]njPَ۰гUWƪ#loۙ466BKˡ4\zwk0JEV%[ܔt Cj0̴oGA*zHEOSBUrb`;M(hP$Oӂ(ߵ䘯gZx
18ä+1%n*AH	D-
HcA`N
N!qeke@=~J0nAm0RV&2L{2*+5,V3@Xu}qI7kY2&m_eT^ʝ2y@SGy5bcFcɅ_Ro
{ VE ykkJ;]w i8,AܙTW7FA}ca=~(
/CAVt6vJKfTWU&&\svuOϟ;7|eƟ7B_ˀ*Mr>W!]V#6Ew`U7rn]MȨPmKFSEIʨ͚S܃R*Q9]|>lRKwkN{ۖkhl7jwt޺eL|j["O@ۦ P!ruV֕vAUqfGv{'Gx(Nga<5(|R.qbIB@|suQs&|g_}.AC+.ƤS *{Ƹ-&,Fka~F|g"n	OU>i&63@B_T#K@Ө~{6H |>*fmI-bݧÐK={A
HSEPSsNܘZ-*zM[Q?yI τo_-!MLs|d:H:?~ZkĂ:(bLA21r~ΘM]y4EGiKDΘJI@[/VusǸ|Is׾aɡFrF҆@{r.[-UVC%	{sC'-{1Ua%H}]-ͬF4F#*ML^^J1O&Lϝb/[YBĶ}+vDsP,xs*Ś~T~gW=Co\ƒ_W,	_SQq|+:_l8M\]Ui},_ܥ>bY3G3+sՌs$?sQs&nb@%OQko -LlitdIP{7уf`5J2>FdNj`HI;O˄m%-["LEiЖjݙa~GB|cT{In;wdՓOIJex*(*K1o"b-9s[EgEF%.uS#$˒h@V[vqrs[#H^+qY$wuy3’MlzOfau`Sg[nsS3v܆	goq#5b>WzO7|x7Ku\}I_ͿYqMvu5gxʡZd]|*-3m()!@ma{#/,iAEQHVۑY$2k0ԺBMIi`&>Vb^AѪ.7%]8Ȑ%mY~
F*g^WksgH--7]vt.,˳[̑4DQ[m$ht"q+
ң|Fc^SSujMwH2@Fw`ɣ{L:5"΢dl=ypJL	) t}I
P4*!:mtZĖ+b1۔~kxGo92p⾪dFtNfN
n6O Sʀ|5~4,[s
{U5?9?-`*?nϼ ?98ZZ}+]T~xUq3vԶ `ecL;{$jD?L/e{ۏ~c'1n.3(dZ7N:)mJ4Idz?3tLw$6 *
yV=vEj:d,>bqm.!Pр1vbl+E@XE˗͘e?QcwE!7ocăϊCC~`=#h>?+~w%DkPkU3oW!cWAVƉgm!7b-ĺlqZ0G{Rg&|[2CVRBR;Qx*׼;٢
e&β~lpFk2[ﲃv/}W#ZAʼ~֚E|-`hY^oYJ4Qh=wÂ\| ɹ H[sgFWSv念mΘc_Y>fG?c;^x|קEHPjDiSzXZ7۠21#ߵ{zY|5 STˑs7}Ukd;1A݃]$B݃U "Υ
+E@M\i]m^x,԰|C! p
AAGu
AA>" `U  U  VE  X! `UQ"̿S\=AJSb8vDwTe3V#%mט4UccD0*S{{PJeyUέ	QiڟWRՈ}y~oA~L.|>?6J}QqRl24!F鉉kQYT|3懳wJ)ٓFg͓?c&bU{17546rnڇQB _kwS}ݞ{^I@jIԋKW}Gs(DZNVASSY(,HHcƩn
\' l]i&.ڢk+LϾ>\+VL]F
I+@Tpiq[MX[c7ffE,|ƹ"M;+l顓w߯zM[߾("Ko!|>@ugP~f˂
eM\^3uⶤrq(;N)9'fGnL$G;ƅ]GNV.?l4P[knmC>z˭g:(aLDv^ԇ/wƜX7>?lʣ)/5D7>D"7	V$O};<L6#--%+\xc?tҲsj%=MD;fhdS_rTG|(+ߤeEIȇ^+"iFUv9UEbM\?;s^~mV4^X$VU59I?>a
 {7.޸XR Wtp8T_CTEwL<7[(Vg_wi̺~ϥuߺi.%.4l'.<[K*H<=,pU65&%DmϛUVvBE괐/^Գvħ	gW:G4\zk-50UI+!hOT<	IClcԣSY-]Ewx6ʹR,sr}wF.ZXt5tGC}O~KA/4[Mq{TIY;l!+7R@ޟ{OJ&HqUsBe*(.Uc4{iAн=Kg#&@>j)f(9]J}PFku$1j"W.ɮS8g84лFʾBDRYz3@#3*$&s
Y2MJʭy(D>@UDX$PlpdZD	?]l*s&	÷ʔl8v,Y``A\{+X1}R2aH;nN,>rRFu"۳.k?%FiiӨOU).+|3A$)RTi+:),.c4uhE@]7ޟ1taeRH7j`3Ê[Izp5њȺ~J&k}r3g%\xVdT[75EBKejEVܧ!=^HΊfdKg>"\58Zʊ,]fuy3’MlzOfauGSg[nsS3v܆гUWji1}V>}2̴BOjsyvfa5ͬͻ\@{ڸc@S妤f?tIo)?i?ֆ[rg#3߫5fѦrwP
5%bP"wJtc5]E""IKj؍!ϋ#ǧr&/|-3AARd#׵)OvmHk(˝5iG,31;gF+y<+AKN*;ug@vF_gF5,)yԪH6\f*1&Zv+%XuPUY*W1xx:	e-WĔXÒ
{~vy&8oO4:	bXJ.G&|޷8Ԛv}iώA4DQ)!$$㻬56~
K(lT}5L:5"΢}sWK-%5!7N,q&z6	rOB&H)i)z-iV9ؽx{#-+q(F
]g`ซ.k~rs?4ؐٞ6_) Tn;lx?C	*XFIxjtCGKm0lJY)k#.Ź /NJ	L>&Ww&2L{pRa}'Oxh`YPS!UY`1c=,,ox
{Gf7h ME򚽼cɅ[^NZ¾{.zC]mmݵ27"\R}$֝S馨ߤAo8GJZJrڀ,'ɋ[W]qs)7r,]4:\䯟O/ӿ}2”i<)oӫdzZӐ0+JԿCg"RP|V,=ߤt-I]PkEAɕN=4G<4gkcmuUycU?Q]4n#~9f*	z^GVmey䉟
џ0PAޟq7gMQ^-C^Rt=cz#1V%Z{r!Lj3ɂgUO8(Lp3ώIs~6=d԰(mfѤq#}ݏt3U*QYţA3]w<9,tl0>JjkKvuElt3`_u'ܗmY%Tl'zw٩ 3|maHNK,g-9F^QPifښ튋?^hJgF:S=tiAgOH Sފj%ņh&擆^kn@NUf %ߤuxZB>r[5%鐥.RdވXUB|?@(Y{RyhJ;}g" ,~Gd"]fm<҂ϊCC~)*ܵ2N3.kҋW$ cE	i(+*D%hT,
RDeAďswvwfiٛ
H`
;a<S`Zjqj2A|X|uDD	[]Ao[m6n~|'
@:Ŵ7xjp{syq[wEoEEٸ/֭Ir2r}h㣻%&JܲAAr
>rM	A3~> IDATCO8[5.JtuWi+gFmѪ1m٠!\0f.=¢o)y%E1G϶-&ب@7/T˿뭼4(DݽF=u^E|shc^
7ĉ	Cl7t1lYSy+[ػ2ihMk&پ'.·4׹k,=K|_pHcҭmU&_t<;?u>ޚ'_!M4Ҭ}9[OWMPv=~qwT=,yX.˹~v;R 5̜Ɔ,S}~}>c?fOUppU!ssz'Bv^AEAB!ʐ}@x38=~kĩ\񛈁Db\BE!*+r^H.oie@ߌ>(j{|^qDvď;
4iaڡ%op!WnI)!ªv[ZXT݌\%E;$,|{1&\S33C
k}x+i5&38}1B&?&ywU_|fԪQ.GǤq>ޜ?kBaUt;z؍.Wn}5hW@Q{^=ieXBHgևx/Ya>e~D
u;^}Rֳa`\
N,[~԰[$]~׍?Ĝ[B티U# ~|>j_Sr*HMItdp*~+B!U{uC6jB>079 xxys7K@tXe/~Ke}obʇbnh/yy'CYrf+*jp-̝^ܽ5A m:ӕ\
‡:l`3,>):3ePf
&q]4-?ZfB!WE<=3ȤvYR\ә{M28;V2yV%eb)׃/	,\0Ҭg~㙥I&lshrFs*
kw/7[@/~?]l*7H'A6|lJg:.KX域yV3:f_*B!Md~MnFDC'aBQKWӚ8zLZCi:;EzRfO_&|*$ʻv_"I؃(9
+i |T08eBwTM#e~ڃ _hrدn)G]58[fڒݗY=BE_B\US!zG)VDp<*0<ר(sSӋ9=

װ9(-auKۚ I5SOlٿuΝC񷪀khgB04g&$E@BS!zG)V5r0g'o7ΊyZG
s5<"ޱIKjZ
7E1qˮ=P"k9i
Rw.!DVǗJA'`op-%gr4VWl1t:	ni]@%.:B!~UEge[#\l[תmض{ɩr}.p	MZW4߂Rd))gl1[cnȴn\Puj	Hi9v =VoEug	#BB80dF/ܾyðACSvWG펝iwBWJz,Z}
*+S/RN#qSuX!B06Mvqt&V!VE3BM4bBp!B"B!B!*B!ª!B"B!:<3aCw҄t@tCw4"VE*sD/-U\]N78GUL1l}o٥|[”]/FgSo|	-oM*1NvH8BuZO\=Ǿ лI//>vEQ5cO ~|~IC(1#$>ӳkUr@3(jOpLJt_VAQ5h|\ɫGL_?j꾇F
]Q	'g~4X}|9
O1nVFܟ(
8W
 ~p0p@v//K.J\:sܚRSvT<E,1҃(s{V+F(#[k
>̪{nH	5U
WhOrj$;͛1~dp˷(4qL-BkE$`H3YC[ZΈtXLyƖ'SoJcl$njԫO-V,QIe綀;vYSqc{@P<jϟW=8{߲M|rSXGeY([Fحۃ6Z/6={Dl Ԅ|qw駩{E;CK~}JK%R)51zfv2̈_iן(+ubZ6H֚DkdҝWrFL!.ql,x
HfKY'wFTi];͛1^4;tjvma6/\\lLJrA[3B=
}'l{(}qa ]:(#*\N\lybCL,.5n⤨ù3,dpv47*ezO'A6|l[ɵ-
s/:q6o="OL$2ϵG7;+]~T%Ox}ܫjWQ@k(SD1b<I,T6?=DՄ䶦4CkN]4f>Yqҿd/l
k=\w}bvTbGyS0}{K`I=w%ٹu[3B}X+b	UKy$@kݗHB?}Kn~euKwG+WLs3%:NBNK#gGO[>.lcl[U^7}(+(*GJ:Ϩͱϯdqtw[+LUT%vgkZ:Ry˅Æ߿O$GxfTZ/JޔSVWM**f@\/rs"ދHHWLSQ/	󳮿*$4y'TD~uK9o?n2Vo_d KA$07z#6^'hY9Au$fխߚe3C2yӲ
0RyG\.9>]Dvϛc	2~'E88M$y	Y~_мjruK^6Q/vQ/MM/X4vN[osPW9e5N^}#>J*d<)Fn\ѥDܤ|+QVr4(.B(̸߶+oouZ7Z!;уϡh1G~ߪp9WSiᎭ!͵"fchWn5ѐ[_Бvcc;ta=Pw|gM-Ycw-\*b1&s	) 2P?U
=oc]w]v0M:NwNFcY1O]!@QVG@_Su}zڊ!S3(~7!vlH@7hLvI~S=w,hN\<@6X|y>Nnkr=&23fCT/M*ժ8'ԩfwlJ1minʼn˛yzת"_	ۙ>xXQw!I1+Np!O?kB&~@goVrvlt9
wR-xsڏ"TV]HVBۛkMhCܺ|7mHuG,Fѳ?g˼	6*(oG1min s#ЛDNM2|tUeen߼aؠa(Ί2w_pam:ihMk&پ'ΎwW!?Ce^KoQQ|ѕ==u~5zg?y
ilbAf%1R=JSVs(8;Sʦ;"л"rsN~H
3!TߧwO؏YSk7#B[:4B#BE!ª!B"B!B!*B!ª]$-HR8=~kĩ\qwzh3y1/?_x"iI	[@'BaUԉywiEnl0P܉̒TZ-%Vf¿vl
TK3ϝOT՚Uir<BSVE̳?)7>W~dv[w,v
οtN=4:ogfB~?=B|GEH1KGoVPPEp'BBU$-f錬*u1waZה
RӪ@o;uR+
o>U%O~en6> Чۿn!Tn_i+|qSL3&dRRb}ΣW԰l%kN¯"RTrl7cr=CoPY}Cq][duX<1aIM4APGߺW$	?̘wS]< 4,#*3d-(^ -sXx|bXeQ!>9Sy+[)>BkUD831̐=HŔgl	X|R8ufʠ$9f͖L▻KoBժ_vnSk
079 xxys-t˧+0u*-XtIy'CYrf[d8izԽBZQwzaBM9}T"=^NA4`Z;Qɽ{
*i;}_:Ȑ*>W^Ie}obʇbE.?cg:
曌x)̌~K?:qɌݻ,@!#s_\}ܜmk##A'DU 0wbgbgqї'q'E5g$sim߽Q){=y.v`ۢ0eYO<<Ʉ
=unMxQ	zt>G5Z=Hd®%}tW. аvr2ȹ0&J5>}[Oَ^WiT
)prcgGKNͮv6V1uZ_{ͅ|Vf4)JRNZ1m$NʞMz׋iYBXi,j6huM޵IQ/ͯlDFnw'a#gGO[>.lcl[WCx4NuΟQcO__s 3os/HH
ʤ-n#ttЌJ|R"p\nrE cdz}5
B"[3^TE5Ybm~B5z{~㘎_4̿zv&d	CzfB~_/auר(/禦s,{rk;fɂ-չs(Vի噺P
5E1˰9(-au*d<)Fnּ_AUI>v "OU^+)(\f@n)Mx{hFps
0j~+BRYB=׊.zR&:z_Rr(GCnECG^
ƚv{22ΚZ.Z?T;cN&jqٓ5بXAH͑9^ ?t}73M_cF@s"z([)<BgU:a;Kr#"[bO:z/Cֵj^rnfJH
o@ֶ2[?|X|uDD	[]A.k&n+hogV_ݸ A
[F*'헝gLōA0pΪy":>^eD}6mLbc6
T"J=v"ZBա#@	jb)Lݛm_WP!D]+Lefd@~;MqaWnM3I?P:eY([Fحut8OS
Ygwh)?f9mس'ef?I&?TfFzeOgХ޸dFՔI:!7o~6 [-ّ~T!UQkl	4"l˫%F)t\7H??!ȭ
GS?[8S?͂rCQf-ȭk[8;^ulvutts`g}ˏj8L8p ~!*7OXHKȟTE5Ybm~{k䷦EfnpG!WEFe}IuP&fSM-hܾf}L[5Ee^gdH=?Xښ+["BY{ő#Y]ypxiH+G";ZWhje9/z	̮F,E禦s,{>B|`cuU@˺7myPUR%^/\C{JZ^o#d5N^+G*їfʬ}[u5WWs39!w]?|/)ҏ%[+PI
3o{6Il3.A!kE-$~pp̝Փ"wMMQ܊-5ЅddCݑ5	 \f5ߵp5w,<&xҒ%@UrV),Ouo@_Sz	vۍbQC5ץ/Y["S3F.{Zn%2vukѤs`QFQne]*vSkw!9=J|BapK3`WhI@sPNoy~͉^muP@fuچm*ۧ٩[m.Tա.R-xsڏ"TV]HVbd.EΖs!7k>"_A80dp}aWc.8+r]#|et>{G\
^R@"d^Koʊ䋮ggc%}Ss+Y/`bKB!{.˹~v;R 5̜Ɔ,Sż wB!އ;h$3B!E!ª!B"B!B!*B!ªItqzֈSx]gĘCBWEtE[޷#l0P܉̒HZtB!WE̳?)7>WMUߎ6xkRmN=BB!*jLZx1|(a{";OBQLj!4Y5cEQԠGr%/ڸcr(*\);/EKGy[Vt%K'(c?[ȑmCEa(j/7ɩA3(jOpLJDa!Pg;,"X,G3CЖ3"S%`I)5[o0[ewt6WaN}Bա#@	8OS
Ygwh@xjZ*JiA*†ĵ2w.{qA-TȌEh;ӗuN	޻CT笚'UFD*/B *<h>uY`ٖW{y.J>R0o.	`~B['A6|l_mg&Aգf	.?Rir l/^2C(6%1-A΅Yͮv6$3ZuclyNVһ>{Rg;zi'E5g$sim߽Q){jA3!BX)%SW-Mɻv_")
#/}z]0cxj	:Ϩͱϯdqm?B`L*/ZrQiOjTr3%:NBNKFΎZ1Iy5uUB!UVEFe}I5ů}mREorدn)G]58[fڪ5['hY9Au$y?BdH* !*z8(zkhgB&0bxR}p(֔>JxlG)\]Ax_%u^Q2U{s\^ˏv禦s,{r[=BW\0sg3DM$H~Ӈ/h)9k!"b#TKHR	Xl#sv±]v㬘EzT݅w `+m
B}:_)ik^nY
VV(Pā'f>NˬF1OZWPB!UQ {Z'lg=R`iZc֟qlm+!Ӻq@3p~ڲK.Gy	_ݸ A
.]5TvAZ?|iC_=}יG5Z*M}L"u?l-K\ Nyᄑmy%TOfGtV㤞;OÂ\;Mc̕Ss5/E)t\7H??!ȭ
g(
kw/7[@/~?]lu8tE9Ύ}FL^贈LQ,?Yod:&gR}1<W'+]=45AEi٦VJӏ~&?!
>nζ.Jy Gܱ;s偳
1ָ	k[8;^ulvCкn687ŲgQJM?[A֜!&@O]TGd^LgXo1^rB3jsk+Y|n5p$-:zM	UE6Ҁŕkq@ʳCTnLw1]/~rKn~%
|AYA&7#[$8FΎZ1Iy5JUE5y9GOGK*Wj^(!<y} %&Fn
0b^7lfӯ5lYªVEFelPM-hܾf}L[&;-#G~".ҐDQ$X"ql@汁u}Q9E~w$y$}IuPnbĈ$Fƣh
C#pL[mU	lyRSh<]e
A,˜Emt[D;-EpLǯqڋ{R':VΡ[Uσ哊fʬ}[u5Wk%ԅPp'O4=P[gM/bv.뇏%E~$p
L̄|ADqzY/?{[^̱iU8/5e.JKzX|jp9WSi+m66~"5;%l3.]!h]7ʾr:ڳ!kEo3wVO1ô\B
Tŏ/g@O`9튷g<-ңw.1G[iOU|l{c]w]v0M:NIݵCƁCq]['X7Rg[ ?t}7W.
O4vN$>\}AKY
y[i(/-Ycw-\*b1&=
`7E1qˮ=.'UU?J;qO&|6\N?;	Q'@*OEX]OFF=9YBG'u^J7(~d+:ت!UQ36nsm17dZ7.xO[5b%;϶87µ$Hido* lf7=_"QV~V-@08۪ѐϽ?vFQ>~ݒaz	Buv&#/BH;lj	Cl	7ov56b9BCgENk䁯ly
_ΐH2[7}eEyjEWʳwԉcB!;,B!P;P'͎k6!\+B!ª!B"B!B!*B!ª!BWEg&<Ik҂/H1<BuHZy|e~ǝrkVU榉^ktA!UQaOQR|M̈́OB6ҡp'y@!k;h+GMPӨ2*AF
(܇.s7$IQ>C)ǤH^?88v EQ5h_T?c-tQQ֤21WݷSF5vWi3Ң{AQ9t=wA	5fjeBuBoyd,Z.F6/n2~y_+079 xxyst}4=uiuQ!"
BO<8u8/)HrܴCx2{WZXhZmwJwVQkLefܯW4xV]OfH|̕:B!Z`H殡'>Kgۂ'έǽZԏޫ5}<s~.8̡u66lRҙ[Pw& v8BgSo|UWߎ6xkR/457
ԗQEgQ/=-p/WI[dzw7Vu3rі]a–P':Ff:B!^['nkĵE`z@\Qx?]rc=o>
I]3pD/{l!\AL_?j꾇]iU }zv}у((W^SEQJA	5fj>9)(Ϗn?[Q^E
`w[)w̟4(3B3Ki(q(qkJ&jZ:٫ˀq_Y1{*yRnO//>þ9DZۅQqϛ=4h
N,EQG]߰O
JQ5'8&6H`5P_o"3PhOrfcB!\+[KQ-FKoBժ_vnSku{uC6jB~˲Q :N
0߉T.DZÃP6|CߓQuGw$FcY1O!-7}F9r++:Ҏ_|z?v.J ?[1)峈U"nGizšBzc>0ReX#]W{cr'-I@*ժ8'IPqoG!wa+%lum!I1+fixyjz_+@UU"TE5iƷ{*Hjie$+у
|PmcPs!r\q{k5l~>8q2߂~Kz|6mO/qƫ$`VC%,RCr\sɧkҡ5ۚKb?Cw&rN
ni?XwZbE<Y'?k+%{v~rhR\>*"+8:T^C&q5SU#!+;+O
*[a,+2Z5cDr\.okȧQ<^!zl	‹wQu_L[v.㕕 :nk"ӊ?Hy#;H,UFf~np?E=2v-s]K)
5Y;ynT?""*J61+?6^[wvERdS'\ܳfɄK+O/^%~m-e'A]Ͼ|ܤ	V}O%ݻv?V!?kT%\?C
z6OD22-~amCf_'"Re\4r\֥G6-$^q;bGNۗǿނK9FɍTrhjWk"z˞~8\f
]5n<{iwlAKM&FQjVVٙ4x&}5<}>>f`wYY!50po4MǾc]鋖Y?'"Reryhf1O5ˀr\.p"G꺦ՏC3φSM|U6?f&mC]F*̊χ.S?VU
yP_O[%2
iѰmH
CkxҔ|MU>WeDH|ƭ0#
D|=;RҩLޛ4o>zq󢵳Zj9.^Mu9K/키K.p'gͯ%3,%'Lƅ9PTFJB^	SwRqs?vVqu|ϽOLofxi'޸l]vjEDdE]LmDbOpڈF4?%'-?fy+!=񩝉'^ũT|2AlcH'.?,bME	GO<7[/?$nliqfI
ױE3M	c#RWFoM%_ό}?"_V_Vڎ?3tBWڹ!5ki[ny"{חNqjДY/X:eY
5Yu$DDV}ZL5Qx`"":w?0TH>ᤗ>敏v:>F*j"6QUPbX5RH
Ckrr*Aqю3@Cd,RX-GWnUduD*ԍ0?.[jT|8sj:[F^̠;]~Ag'v%9mA|湵o(0!k/!̨x-Zy(0:C
[I]\]O\n[\X+z9yȈx0rg~p^y&1ȕ{=U?x4s7O$&6[˫r[;lʚh4n羸ݥœ'4YӱDyŧ֟9}y3Ϫ_ǟHfj/.կ U5^x{
 %gHxoQ#+B+s	j]VVy(-jEcn[>աT&EQ;jՏvhxUvt.$ʯXש\_ڟ7u-IHWZHu3Z.!;\MQ1(
)MBZX7(9:3	iaUPb`\J%fn攛[}EQƭZ;T:ky
	ػOwý5uoH\32)X(oS}rb"߹pQ|x۞dDnO;dj#ҽ]=dVI]c:ۛT6Yv?8{tƕTE}$=q߾'|-ש~Mi껦f](7-W9]UڨK^$oK
C;XE;,{Zj| Rؘ|g7{BEe!MSlTSf8;/&F$fIPUxb%li-?is!rǖsGcʥýuO-8n|x^OM%Fw|.:{*{k}ycK/3Z2-H7Ɔ?cmX,usC9Z)PJBW
딦S_mYo;vf'*rhk֪O3-mѲHȽq,qkJ~ֵ̮u
5c{F_T
1ӦW\㪮/,ߙ,i/RמU܍g8ZJ
@$մ#ѕQAV5MsT?ފPo"]mx܊(Σ>?GcËDQmB>`U_AFG
g8>W~-`/%l]4]2gcdjcE;4n̦Wa۴7:u^hcJaTIzOZ;k~d7sqn!,%xDok/U$f4NXփBug2GNLҙ){\-)琞㳆;jц/ZTx[@G6ew_`kԯdփdN[G)Oۨn޶e\NUgv7.s>0zܝW
r|%5"ˎ3߳fވGUD"]sF&?VkG6li}8o^6CD~OW7׽?Lۤ"K}69vNS$lK_/[5,IGsRXCה
5
m)sch8j["g_nqHo\(U}GQmDVs8'_cg=_p۩[J/ܾcmkgai zఃVoEn}ٍ[;d1W#	^

q7EyzUZxL+yC<|$R_UwNMxKho0)@UU"54~-1!>/O@7+<m[kQov44d;{GN|:Mm5U|XK$o|U"TE#oEǟ&^~@DFl|t߀R㲯D%F>G*If0x
]*8oaXeuLRdYmy $\qn9>x}.\ZdvP3O>٢uӶ&*JOyILL?~={#<|Pz&&Ay^0D"uvviu)#>QGJ^1py2.ҾeEIq"t0bTig7mvИh0ۨŔ}m߽(ɾM[m
-NKH0굫!-ZnҤo?oKKVujukrss[&/~"g"Z`'󯴮y(Ϻn7XáV!ʢ&Q]/*ao}EE ն:FH?L>uĨwf~{7=FX.㯥/ZUP!EЀ#gDaXVT|#ǞW8}
GYy~&.DX僝^NIƳaIS6	%K(m»rYLH$qDH u5jo`Csmm߹ش"ojVtl,$5P鯆/)h],ؚI|\G,
9	DTD;D|_R$y
}nmӨe{̓s/4|7&&ŇV=THPS@UE`QQQaaB(**LNN޼m|{{w9ȮQ:g={NƋAyR$T{^;'q%sfTуk\!t:O!Rb!:95=cl;>=Qf[:YIj6m&m{MJ͉kܕQ9	^REJAl
Z>:ٹk)F\{#&ĺ襷(++ksssH c#x]]{woW/D\F\JR]CFHLg~]҃GمDCPQ{zF]Ҧl""|ڵ
o
[\_wq?kh#npgfb"R>kͦCQYVGè2le[	Dz-Ѫq]vrev\2ӧߧPMkNEGZY]=GU"c{H3qIDAT{JiѪ=è88mSD&zb>iY,2a'SXtRF]aZtJsJQ>HP)grD]&yعSXvYqcIzZa'кKtZ4F݈ՒkKjsW	E
ATq<\43Y2湣p{Ӧ/11§YEвd' R>{L:*Kt_%0.]T@}TE^>^	D"{Ο?-,,dU&X]+ʳ"nXKn(y*QݞWT0^b5AWcO""|;D	8{ki6Vflӗؑ&N/5޸ψD0$b
fz޴T~[^b$&ޡ|z}\2ضYBUqĈe"j&Ai`ilo(MZ0+)X'̭"c̤dS6-;|,U>gDo
v6]DF"5@dRLfdl(H6keS[{zfdf'&14jqFugwkiJW+	)Gd-i#SaN)Xt4\뤯5Ö9Ěm*܆|q-^bToE;;W3bؤOnG'y7aDzyڧjAIجlty"jZĶg0l50])̂|s|V:zJuڙ*y2.$qze~l,fL,៞׳WMx%Қw_
:.1k۶mwv611յQr̭[
E^ƞaTfbiTrG<=5m߭j(<{ӎGV&5)FR/{uvҲBWuTO.&Gg32Ci2lַɜc&OMvkR2#wDґIx.2NaΖvoqdhIfϮߵP$^w q{N.:&u*:"ȮROڹ骒#}o8HjzZu=yy*E_rّH"FXluͻ4}벇	1.9
obQHQi߳yvYuIOC'h1hgee޽ɓ'MF;[׌@m1"v֫ ?oXwbXLD*z	F$1,[|y76+	%߄Gg&0%fT}7-~ewRUzIM,q1jFmύKc#8{EٙsbxL*lP	/K}NAX|Gj<_MoE"q-ۯRu]dֳ$nmNy*y"2r5knOpVn\{ʱTYv_hV~H֥˰ojtngÃP*@U୪l综q
@o槤$#G`gWEoC
zoDs"yxۉ9U\.GU"TEP*@UU*@UU"TE^*:u2P*@UU"TEU"TEP*@UU"TEP*+q5p^݉^yz!;}EPTHAaaS<^B@8 ?GyN'+w8N 
͉(:2+#bzoF87ԧ/IENDB`errbot-6.1.1+ds/docs/_static/screenshots/quota.png000066400000000000000000001413731355337103200222060ustar00rootroot00000000000000PNG


IHDR_r8obKGD	pHYstIME	.4 IDATx{xTա=3L..%
x!rD+rm"ZoڊVl=Q-h0 L.sݿ?&"A@Bx?Ó̾{k""""""""rL8^{51bh䋈I~#D@DDDDDDDQ""""""""r)|9C
_DDDDDDDD!/""""""""ǐcHወ1EDDM[b}ϻL~62&-\;ԸZt7Di0ʫQŚW,[DDDDNhuj].7ӓMZ}O=q'q56RV{t:?=vʱ)[DDDD=/""rHvWhooxh]Ytm~HPRX϶=uH'˸vb#L{czL:mq #>zؒOhsO	ĝײHэ_p;ZG-ň}x(l`[Ƣ(sF2eY6XcF	(=*+
61jAZ~ĘNjǟS}7}ķc<:oEDDDv$""`^<3տ7TKj&SbF7ߪ6jpc=kNeߟ|FQP%<ޜvkoJ٫wU)9-bTG6J7E?MmέCz)`#_isy9?wRO'ߙʭ˨_`[.KFߔe	vnAYV6c/SٝDg	bP$\e}+-Y^vQ)n|tޕEuȤGc_2KMZ
5_[EiUL7#1.%>4wOŅoN"nJpyV} """"Ǘn;C\.s[/)u<𿸷$E~j5B#Tkj޲FTH?3]^R+kSwz;+٪`WaDm0 埱C3*o_=Y2n?2R\S[\ݻзa;UԦ0T]EDDD|Cd1zjP^JEZ/3]MAkrۡ!޹glW:e0¬MA;7Vn
OǷ:.+=F19ʡڹq3n`=g{Ӳrc|lY.=6~̝o5z|Qj㲣)K9:[EDDD/""r%\g2SdoxP>y}ޕ`@H~LaN yeu
O%'Y$|z~t9=*ίpIAØpC.Mb	1p9l@œ}|ܺrwKNtaZ}>"'kϿz4""""ri\я41'%^iy,i''u1iGmS6d>|)l{qrvQ$<ĭenѕp۬ǀ\ƭ̅lGz
c.=*?wsRn|SM:(%?Q-(u9'-g}vrfRi*"""555ADDV[;DD0Я_?u1/""rLz[Ӄ""""9&_DDDD$IC
_DDDDDDDD!v$""""""YE<0h' jŲ nو;p{Q/HF	a#LvvlBa`xiȿۇe_||>oSg^9]5;IVL
.W72;?G0RSSd-&٬+.{t
ۭEv$""""""P,]A҉ňqli~Se


$>4ڵ+]ŰJvLvv6>4F
EDDDDDDڡX$wszlT;nantvl\zp8zĴADDDDDDڝe;|agUrR@21MÕJUH<כ(-yYDDDDDDݗUIȝE)]Ter/>.vt:0=2shh aY|ED$'P}?>3z;.˷Ar<%wyhņƘ)Y;u#pl~&,~q+˸9;cliXn+Q<䍼w\D6m,wf9Ǵ%TcL/P)Y/SI>=0/*ӦBq%QLr
oa""Ҏ:$|9#.Ӛ8"??׋n?mM=qg<T$M駰IQz*CUm<
P0l(o!~P/-Y_ǵhշgW,{˛ӗ(<*u8օzLs2)/mah"-.%"X8
1!eY|sYLȻfR\a[C7̫>DDݰlF	VIt&)[6.Fb0"!LS~^DDN6^r
sx3!ټo"%naer{!–a¿au
~x;]}6^3YEe{19¥2}%D/$gf?l"y̲*#"rmgcuY=!ofޛufn*~xxW7E3%yзII2jr	2|qK%/KzCBP-}Uzm|Hf~CM鳡|JoՋxj$EDD/0p:t99
&5Ҽilu8pKHFVxcx|!72>ͬW),BkYQ\M· Gr߂܇;b_{_5ײv
\w[!~bp/,?[*Z)Ϻ#EyPIc~ kky[Zmjq\eeU-?N3%YfYZ
9?> H`LkFD_dٰa`2[|pADo.t3eedC~$l\FOAIuTSӿϨ|/`s3Qr1$,ۗ%ɠb:o`hY@"2_@?䓟_@ᐡYl.iȖ089@"kXr}#VPFiukk,_B_[C+ĺdjqdu4s+KXɺZf{Um)a#$Z2YfZav\k+F,kW#KHvQ"0:GA$ND$Š\xx=#yx^,frȻ^EͯݺBoY|,=c>LOr4qMn{2!G^C(XBٺgxps[ڪ/LssYԗAwgqr;O|*K$ZcR!~Z2sQ,VofV\o[rua+RE5a6wHL|ys%+UМ""l60L0lkwrZniX׏nԞ455p:z(|99'|%%&u51?KgYx,}q-o%4c#f?0_zK|^%oP`e:fu	M#.#R;ox@6D!!(eŒDԅ,/g/@{7>K/?WlyHX Ol}
Cƕא.~i9y~9Kc/;Ϗ"E@>/g>.|=!ݧkKX#""EGS#˙JCnv[—D"*|fS0s҄/RAyM?^^ȣ/=y7؝f<׎.\D/Mb>2"Í}5-3hRvQG?xm5Wҧ%f4&N<{g0$o,C|L쟵_*`KUg͑T1o$zfr?#;qSV[dߑ6m6?埕c3NI D+D#ڀ$jwaO$554wp]YKDD:Hʲ5r
΢xO:B7uku#{|@~mk3{h= d9'==/)oIca
bY	FS#Tv;rluB}	^$[,rrsLZ%n;rOw:1,፭9a)	"""$o;D2NSx<.*+CDQ,:ePe=J,HxS#Xq?
``!e񔭤s`ǃ݁iG""aGϣZ>p,9&u.6lv+;ظ3|+DD"A<#
cCٛ`v[ޖR5㩫鉲{n~&4狈ka4MO!zڷqňO?`b+(FS=Ěc,+ր=mOZn
5TuV 4itۑk	mDl>v	?$%ōa46ԓF0bǰ51j?U)B;v@<.ځ'MZѨNወ_IbQ,:lFztJGV
hشGgͷUbkOc5bMISSx\o~EDDDDDD+˲75I"ðb`Bu9ݟaH,aklF;6GB """"""DžeY47~m<)VWLI$:!:(g^9ǭD,U_EGN~w>'}MMM-_4ndSu]Iq:hjj:fˉKወ7ev`ߴ|=p䞋fu2R444(|9^v;#k͗ףn`kJ~{M+gW-T$h4ێ:0=HDDDDDD0H9 G8\8ֹɰ%;.[ʷѵK'RRRC%ˈW~j;[C))46ȗNወ$EJy|O(>:GM֬31pGoW?͏3~B/2.yE`7\52|G\ͼ2mm%0Ȏix(&90Kuf9Ǵ%TcL/P)Y/SI>=0/:zEDD0l	oWl:2O9}a=6#Ə~0I\
Go`Y؍hx$s"޼ BoE81i*|=KIa$HsX!PpE%{p9^lbLWlҺ$o-yΕ[!bw8]z۷WqI<.g:hD"Æ/JDD\}F:C^}VcBnVUL'N\xO}c}'䏿^"cxf*AkJk?f]\, \ʟȇ/pP,6l&J#僑c2$DYeuYBY2|+2<$8{п+6Q+b({X#9x[_d䇸?	1Z|>>{]]Ϧ>#N!Dq7qv7vW*n4œ%""E.{P74n1MD&"uu}37G^C(XBٺgxps[l5}S\V0G(({Yӟ&?_R3>9Ps9fYj	K؊pr̥G[1Sgs[a>LL|ys%+U8,[X*o=HVGU0Oy;'VJ&s0uN:r(|Be^gůt%e"Û=V)_Ν72"/}>%/1\0qW3X?!yc+oTgb…V`KUg͑_*ߘ7u,駌|fkS86Nur&_=וЯ!8rnTQ<{ӵ<3EQNqig aJfWV9#}צ_XS3Izl3.:Aj]}a6زrq6)koO>o[֟S83IKKSrmG""r«\y/?z<}ݷsn9&uKxckzzT٭+\r9^:K/=&=hu;v,ׁcUbRF[$'=HDDNxQ󨖏ijIx`Ǭ"""'
0M=1yպD6gzyG8mYQ9n10{-Oj
۫b:aN@EDDDDDD;$89Wr5F'pDjgl;[o`űD"Nn;bv$mm~d}/[^֣Wl22m6gt/|IxnHOL
_DDDDDDkcYDHSx"v6s$jbUn;:ٱy:lTkd%
H$6u}یǓL"ڎT߱;=D{Дq*e{G͑/FJ:;
9U$Qc]S]F1EJJ
NͦDDDDDD䘳,D^B;Hq9)6O]cvau9*erg.ga|m~!haa8nw7{FhˉKወ|-+
#z#քtPS"ކ(/]~vP%Ȳ:rrI$x%92^'b455Kdњ6gVcu:444tt0zڑ|-,D{wb3lX9`UDv?Ȇ?B".VWIzwtH
_DD"g"'_d\v2njep	wvBoc̔#.kiwd3LƂu[!o-Lc3զ1YrGo\Ed;ClXpP3xCePTL%_ü@OŕD1)\BS籈a`&#LRޭoVh>lk=J'LBW
`rWy7b+V%#EyPIc[>,EJ0wyd9mziU`5-,Ӈ(+"lqwtϞ/26jȹ&xMDDPLmQ^^A$!rS9S6iwm:nuh>zCEDK
Yb"2Wuo*<;,d]<%OPj2|/]ۜWDDx7liòpvbi_*I04n_""rHX\3oⱟ\;cbvL[$/1?*#+So'qE	^=U37!Fu&2CkT1o4'X29Jx9CӉsV5s]^S,VO5_ͳ7]˳9YO]$͸01sq9anKI?9(ޙnrP%i)n;ڹs.}&K$He~9YNg'D!ޛ:5WgHDDڏ>_~-X[x:Humw3Ć],W/\OOW+`9l$K.]l:A;v$""'ʕ>{}@<කcRG70'0	OWlum	ˠ&:@'>?2zaNDO;<b6*dԑ'4>}uf˭GCv
_DDDDDD]&Ǵh][ϬcHBdѯݡdiyܴ	(|v#9N娗;jV{1Mk`?gn!mo$IMCXL'DuyJjiRVm&N"	\ͨv^x.>]G4Ų,v8W
I]0+
#\wDe;c;cx'ݸ.vjKbSH{bi'ݱ']ijLNiB:GFn7lgukR"cߵcNӕDhfOj.ߤep8tnv2'Oky """"rv(W
S0;@Zti~iDN!A̛
)	Bzj	Eؾdffҽ{wRSS5K]U/2<:ADDDDD0M
$]Ix4vv&I86!J]car▅npt:IIIo߾tܙ,RRRt$""""""˪A<B*L,iKpbqݤԹ ӉnfpfS"EDDDDDD0^]AO ڹAMDN!Bp%ݍQ;"v+iBNvq-!ʞm+{^wʁQ"""""""a)jp3'hljbw(#4'8n7O]xv=EWXQ"_wtK\qR[0y7^AWG_Ƿ>@~;۲ﻗ_	0nf=}'4='sZDDD+k`;oR\|S/a*&|\9m9+/u-eFJɺ7hػ| %%Og>;vŰl-W4Olg=ymȱp„/L~H89v̸ 㶇Dt7Ͽ6UA^NΜW62{ہO׾/׾Iͻw0ڟ1`YfYDDDȾlw|
\.p;Y|{NyY|mW:zMeōo!ԇWK
q9fh,\.Xms=4<Ky(N[z[լX)FK4lzdKd>ejX?{4ڛs/dLp$ʕwpـޜ;,1B@G<~Xh"lY3/geO\w?|5ǿagƙeBrKh_&Ƨi'xfM%wp7q>Mkѩewݖ2MX>y4,̳/{7y44wl?PI{eKwqG__d2|<:|2ճe<iQ""""rG2vL>;`sƉ!=O'س* 	+~G>n\0[Ғr(ƎR}eNΦ[ntGZZZˤN~,	f@يKȧb>#_j>~bc0߹Y_};.pVV:.뎓}1jg*v	7,s(&u+[Y8^g享~ugg:o[[?&U;&
Avan7lYďn|i,FWy^QHϚPӻBL{˓:s盌kz)+63f9i\^lO_i#Tr{s`W,7kOtÖ|7kK\V	/޽)+ڻwqhӟ+wf@
{ةe=z.6?|c%IFK-̅%g˖L|:GW\I5EDDD{sv߹H.] Z;7U3ӝDvE;{1>Zg6TFH?k8Ll64rb'ZVtȳƶ7_Y7Uݏ1J^TȯflE
Cv&t78F
*+80")ՙV,l?-M(:s}ysg2|ጙzטxhx{)lu(\z|{4W0y'D
|U.G3=2k}a/T0o&>4'sg3+0_(`CUX0jL|^Mk#[W=lPC=^]IǏS%~WjMjC*SǪ(t*YPL$+*PʠH#"FNc%{S11%AI])>V46)yp;5PTnLNjI1cP;F15ZIJk:XDB}.B!ZKͺ\9:L1Q˨Iﺉn.ޯ qd33=2ᕷ=|5h'UcFzY\,*?]ϼzc޽.jRG]&>%O>΢σeWL}=Col3!F"*a^U7jnJv`٨kI2D4Z.ݴ
2GhL:[Mn
T~@Yjh0^1d
AO{b5
bW(bm>Vz:ϖT(W-t6PF<-H0g2gצXijx2R3I(LM骜loG2pIA/o|5ܠ/QTqf@oL!BJ|Qzw-o*їPu-ǀԣ%8Zfj%&tRR;±XK˙Ȑ^{a#͔<(tNzu%@'쫾nM)7)W'"S̛9,qS8B݈Fg%@E[OhGS
ě.`R(yw'^LjAg_1Oðe6ϱ+	0]sʟcEh]J
hjx@1aR	w]H,}w/slOX0vW;tͽ$.bEirt9)Mk̍6US
URhR9M;zR1G,~5KJP62Cv~z}ȲI|olċB!^*vZU˗jr-=L?ĖXr$1тnce.6UŽ~']U+ʧ4l=%B3Qtcua@Хϵ$=4k:OS}BUtɣME vtE#ֱuLC:a%yfxG</TxOY-|>grLקa6~O{_eȹ:N׷ԩ|)^:oӖ	!BqO!ą|m㌓/Xxj}%(
5$1<>~$_B!H/B-I\<"B!m!B!B#!B!B6$!B!B6$!B!B6$!B!B6$!B!B6$!B!B6uoj	B!B%

:B!$B(]%
0EQ$B!B!D!B!VʯGY7ts_:j/6H\l=|wלdF_v(&@da_v%].5q/ 8&OzMa|oY`Lp-*t{UKumz~X-,j!='urnX`Qeg`*n4"AhE
b#ȋh,UmPǙ(4K%{P۩d/kh6Hvk9.oM9?۲So%b\ǃwĆϕ~G	!g*LF( bQs|.KDu"2#P1+)>:оQWt굯:ցN.jb^?;>ި:4E/ l(V߾Pw䟌џM=>[3"5ͼEphG6;tPI8{M.?27ls9dS<\~;)
P^LՃ'O_Vr#;^-
IɷyA4bDUsꟶOM8s|vMeZvoF+n2Xto.<MGEcP&;AAFQ՟:=][=(9}w4}ove8r!ac+l4PO[{Gki֗]KOvq>'yJy.n\`ߞk=\?^E$;Nj#ZK|lg#7vۃ#*~͚_@FvkyEXHhvIӽ>~ή3^\~f4wzz&梼o)wj%"
!8ˁXh$V_S2+% -Xc,k̩WV]o/k1`b'jm4jLix
t̉S5{-Ţ?1xmvAt۹g1Kȡ,V'3_ZpQUD
ܼ1a|L}G?ռD׳F_q>g˒Y[Ճq'`£f-Eid5N@czAŇˣE,Z8,>\>tMGigrŨkinCÜGu*kqaN?Kbco0nߝG&˪T7n
`%+S
kb5jx5=lbOGwSasXQZ#v:BW:rŞz,-7/44W;j)ٙ6[%B?z9CSwTKŸnqdd{:}X6,C3sf3JFUYs
jf:Ӎȏ M~P!ķRPE57nfz6W?9/ͷݻp|_3b:#2hr:ݢp`D[/5oݹ9kL`mշg
XJNDU;Q\5~{kto>vV1G@;YKb3yG)w䒗iF3f?Fv'cF&'^
~Lx5Lf(|u-yhXxFs&Φϧb7pX|}	!gx̅sS
7)@Tk5to٠b
ݲteԫ͠k݊-xcW^b4-:z{2} [.f-!˹4a.䶣LiLs8Qotᝅs}&!.\IH ;x;?hs[ Jr28,^Ήpi
H4L2EQ@A(@1+*yzA͊,1^^N3ω+7,prό=L$ll"
?fisp&|gՁ
N|f;0pm
>P|$TGvɲ|_?g'-lF?\9XkXd*\5gb5[0{qq+d#_`D}f:j	97gW&+#YOsJN2Bq~aKJuY.q~&\6eIj:q~f)/k/>!rQ[g[y݃(%c'NA21bˀ"lH6ZQ?9?۲0=աua7];Bq/gaqKYa:_Mzo
)\pth\ZȲ
S=.VRMJג~E[s%7 !};MDqޤ~'C,cǎJek?(zJdL5+ٳ>J?Q>_|omMmp`udaUРRK\Gv\UŒa'#/)+[OǒS@F(#Lk3Ed9p8,5h# ة&ۚA_'t3NEQ&Ϧy7fQnfXYQjD:1[tL)Heac$Q&)y73xTc80IXPaUX2d$z9V#(-v
Zڿj'0v9'gADOkI?(ύ.Gj`#ϓI=j"HbƑk%kgZs&Bs9حfrf) 3眳-wm:t0]25%B`ZLֱimႻidޙFA?	vG}95<3Sr[C_gpfJzha}eǝ7#W)#{T(bI
O	8KycZ#EQ 11mQ?A5DЊXs,}x>6̀Ŗ!WDB!DII	IF"ɗ$_.4:.L4hEYvAQQ!p!BHE%ɗw|B!B$"DےwB!B!ڐ$_B!B!ڐ$_B!B!ڐ$_B!B!ڐ$_B!B!ڐ$_B!B!ڐ$_B!B!P?>="QdW
!B!.PϑvTSϾ%\lzni)>{6rOyW0\PIq4J.p>Ր@!B?^]foou:r$1w00qqqqdC4%oS1'u%qozo%TH7uh	B!U|Rs
tIg_U?)ƴg(J;W"o_^͒?mHs"B![{iѷx(w>.=9vX]R@)NAVBw17Rƾ X.M.]g錴:*U,KGQ"Ps]OOTK/0vfʹrlƦP)H/B!B!#Gt\bMԱDWS˗ȏ-P]]}.
V>e02U(۰k0MZf?^|>%ÒOMSUYǧO'oW
+r|^/[ЋUS7$I!B!jkk֣չ8rcG'ן=U522p6~Pwk__]:}oMF1
(qpe쯉ۗIPhnM}^)d
08[MYa@.V@!B!ķծ];,]WMB|;C9(jz6gfCF?m&0|~cH޿]ٰ?[@`<JjEni1`2k,95jʞfh0ȅ %!B!Z\\{]=? KNLLL+7&~5^`woi#P9[`!
Ru4LPj:&ʖ7n}lFʵۧd 0bkJlIߒMth!H!B!ķ҉9tﻌںNj:Qy%
9iGy4IM3ɣdN
Pz1z6N"P@lW^VofM
5|9>5Xv/:Z˪D
054`*Q9xw)&˚/""I|z+YcxGN`~;\1<[sɪX|k7G#*+zr6QnJVk}a]de^8ݞUvfФ\Z=
wWe\t%%KC+XIḘ^Z6($$~I%XH4Sd˃wf[u_"! a+g\%t}xMx'^<]SQ-*q~F>U󡡵{EaSH/OzL5{~S&ȿk6u'&ARx5˾(:{3S0\ͶO54qӨxzWڠE\w:dr3/[A˛yz#ږvO%OoyFw߮'yCӚ#odax$>/1̶?ұ.B!SX#7%kcVI{kmw[c"}?J1u;X?rb{.ghڽe+Msׯ߷>R]]Muu5h߾=Mv"oKnVm?x昞3gHMhԕy+a<,27yϮBd5kץG}җ~+eyxt/6](/_İ	,]K+Wr©ܔ֗+=[8a)W+x̗Ftv1H
>9R:QOO`á`ay5rӉ{ћ?RZHwG)wpK
[Ј\9%^hA{,ުFFO&TT5ǯ	Tǚm.ؑ%B}%sI1/н	;|7^qZ\;Fs4p1p,p]%֮];bbbС]tK.tܙ:HE\λw
qɺM_?Klt-s|͢6`=D]U׏gm}i`$^QUǷGsxR
@tW%r y4Sny/i(f}l\"@U	lNy2w6R6TX&0Tϗ~6S0T.u,@Q29	*.bW`BI'mSf1Ă!cU@IX,EM$+*;x>SBC#I
?Brj*Ra9i4o[_bo4&2(I+ʒ&ZZئH:}1cP;F15ZIJk:XD^0'fc>}Ňg﬉)7)W'"S̛9,q7y[4{w#=짖h[߳s:n==L5oIzߝx-3u~mh@B
`?NJ$5YiX?>(9[It-	%*2җy"o>wbfb``vj{I.]Ċ`zrR]טmH/WKUn>>$LYs<zV2(G|tCMNbccċfWNP-xvg
|kB!y{ok㦗naw!\ƪ&y֎Q=vpV.cu}/gݮ*.N;eZW>5a/d;=.}%5qgG]yl%tNn*bհO3.Jɰg	(Y硚$ul>l]גb
;ȎR>lη7'a 11y!j4%ܹ~m4^OΠ=tې'ɿʽ,A=}Y&<8Vq<#L/Z|۞Wru3$SijyfxG</TxOYѮNŰCO9[:oӖ	!BqO!D9o/mH$!BmK/""B!H/BHB!B!ڎ$_B!B!ڐ$_B!B!ڐ$_B!B!ڐ$_B!B|;6tcK-\?OlHxB!BVˠqA<Ⱦ-#L~p3iӜkb4bY^
+),,dia!^"-ĹB|>Z-+[Ĭ?wdT^Oabs56>E"2TWOQ&泮ڇa͋ӣR+"=b,E^Ӎhll,"29ҳq||;rђj8Ss)6M1ЊZrjfB!\g.\.It/_}*7ldžo+n
mغ0R{rsWywo:y߿Lfe2\ 3_ZA\qL<9^q߲;ZTDYbﳙ2dd;UrrP#gE (ՙCtO65l݅q٤[T,FV]k"|xTEEU.7hr2pia&&L5_z83Z,TYE{rv3ZTF\7ylr҃-|(pef1_\rå^Ÿ+8
9S;	?bStcӄJŸ7Ɖ
q,\9X4!B{h&OHsՁNǨ9G>%c:||╔QHuWthߨ+rwfo:WT@k!$=jl6[u5;E51߬xf3~sۚKzJWqhЎ"~̘1=TQW9Ez|mv~Nڤ@n#Wpz;f[sdw^7S5m"*Y`|ҵ0ub=4l!8"Oh?o1F7Ӝ[x}=sszeac#
5[G<{GW&(Xufv8(Қ͉4mh~VوlQLD:3g5wzzYY.\|<^B!Q	)<5*֮PSUK16t{BXjj̩H=],]q-VzB/#>v_|DUG4ppzDŽIhn[3|gwWk(j_7KOSu-KgmUM	j
rhhn#f%T)y.kn	{P3qX|5td[B[3P5/?mYv)3.܂b<>
<jbH@eY|5,khFAݲ -[ŜINny.~ϙ7;9^=Qlt4
O[ڸ
6/_hB!;7T{`ŶFF?
lH8GH*ݹWv;w<՛!1iw_LTKtډhj Əwxzw*fj4A	!`^x#f#Z('\EQ@%~fF%Wܼ\sޣT;r85F3fF)	N0&+뚎b1DJ;34C4Msůؒ2TztiqߚnB[9\d=&,6%?ߌoaQowp}'B։\87`})*bDQOVCL
k!-KW\֞O>;L+>!2&eׯ gH`P/FDSB
w8t&!.\IH ;xmyi8^fz(5(Yȴz:;.?26v 'p9KViMS\+^]juΛyqiqӺz[Bey;lbD
dpPv&lP^||?{~|?Lnv7;BL~M	[ihm4){k=`6LZU$yw.dٖXR~I&!eHɶVFFcTd-KfS50bZmG$dUHyN&*d9w$IAڼEnfҌ3v/9!YAG/"OeGٷ$3O5+2_w7x_

2f
=Á:rQKW']Q
US[bvlZn6QCD4wkKdndjuSd=ɾ}F1euR慐d }HYMWv>j-)Fj3pX.j('~a
Pu1v*Dh/'VUdhHֳGo$ItYx1&HA1|y{}nt}^$INH2|y{$I7=)X.+I8~@ IDAT$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR
~[pVxɮ$IޢR
g3xH$Io]`HA*J6v$I$IrI$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_.__Ų,s-|0resGghMT7Xf\a)gq}?>6%Aa|֔׳$I벅.޻p]xkxnssl`Y|Ϗ5l̪ByՎoe?˿E_1K^\|lnzOŗ3A釩|lb;峩nUF)n?Ÿ/8=&Rr>ze_ydx[X/p:3m%3v!fI7yQZNIΙQJKhK?кn$v#K8uLr4+v,_gͣzB_Kb9%ymc;!忓dw:"gWބ~,y]UNg;KȐ۠2O"p ÷/ƈA^$Iz톎>sfw;Jϫsi=!g4˰u
Ě#)e;tx$O`q#
_L#e?^nG|k|dQm셂wp?8v:azjƞ{5dhn+^ģ韣F3ptύܔ%u
z6GOk)̝sr?^EP ߱bco߶O^g#ٺvϝɸGv8"gZ[\E,Ϗrϡ	˖p}O?SBdW3._7|pg=KK*N(;}Y#v
~_sn>rm>'@fхo0squG=nφBo[%|)>?!koc.k?S"E\1! 97O5B!`݃j'|l%xFmZӗ	q0ºeie-T;OMI`H.yu2oKgV
KZ)x([_ʫCg/%nfon,dd3_
xqFl=k_oX6x۹wggYe~Ҫ!ıS^|>@0

ǜ?釙VV:Y>m`XܛQgگ2kfA}1⌙Zќyxqx O]o?NK B>g-3R#pɌ(! 4#-rٱ[ZJpLh훀O|~B.2Bl|_>lD6{2}hCne	Ӝ~{Ծqxyv%/t-]wyF=lO~UջnjogFwatMYe7+fzr!

AnD/`&x'2kyK_^B3xo)?KGݔaӎ:fhqG}v|eS6ˑ`Sz~^s>7}oDw!&^r'WM\d[(4xGwyi{i.Ӌ~|(7'n-/.4Ƶ_ěQ)++/z/
Y^<>%/]Mkw{Q!O|dYċl{bL~a(}+wܮl!_7_0n3b4F,)O$O3i<-K>tn{D6	Bn	Owgf#]Sھy~`&FxR칭p[{e_ٕw.:?nh_{/X᎕Q>~b$|x'_Kg|~r$+}=ł~/$I={Y_X{]ffy}>޸&~	'L	{ݍ|{LrMAx5_~'iOD|71icJwp?m}qr??O?>χ&dsX(7`G8s}mc|̳<~ˎMi\89digqG{M1<ӌX$IC'䂉o;|ҙ|bn/_pw9c?d3~&i+¦'5w/^eO*8>p6Wtyܱ`Eg/pBe}tk
`_冏䕶y1ňg3|le(Kr'|ӳK)0Ƅ7žL=;CnOO6I$5ˤI(/\ȷC(D7ym|cA(I$)tr5˹Cip챥\{XʆI;}}?ߠr;^onzdf{m9dOQrʡi|oSoN\I$IǦ}},7|/
Sm޺{'s\juT)PT1I$IQa.THn:rfyk]mCXz73_$I$I6:;6>N[r,/9xYndu/PPs^4\z#iQpldcyR9wd8Y0Κ;qm]
a_
ţXyCj3c2&⟖2)9\WLJ՜g8v=Qٲk<7']ɬ%,n]Q|sE5vcŹ?XǏOم㙹b%|o	~ҽ*5qct0Hƹp/Z
ŒܓݣΝ4xr.$9XpB\P^L1P4:Qk6W1͆}A(Ow7xnݣ'a__0"3_$I$I^08;y;˵@PJrwtw
E@g竔L}%՝C%_2z׾pCl\=eI$IR`s_+*}˚^(.Ŕnoca#9x>Vmdya1\{\tu+KC:];g,G~EPMgPڰl5X~p-k?pTSW{"ێ$I$I^O,O=%G0;WҙjgQpf=
WYX'L>
9>
U[Y:wOqpvȎWvw$,g]*26%D[:K8롮aC()z^a:6sw6
Fqcρo֑G0K6H^x
v;C$I낻0zpջ)wQ̻p^`3.ĩEa3foc"6{|G
JA6B I$uyϕv›/u~ͼ2;$.+I$Iz]lc=#݌~(I:_$I$IzxgY:Ӟxc{]$I$I1|$I$I
$I$IR_$I$Id7LK-HHږ̎mT3uUqBDHHT=eB>5x7سTЖ͵%RIKm	RM5GÄa"*۲w~D$uB*3:⑪?o_o0XFcNFnCu8&M_ym;^GCn19$N]
H&5$Io-mb̟?{Za3v|]_@
gʔ)L9LN
vշsi#++_':YO~zlljilKL&I%	Ҳ](m9^O[2Emmټf?Rٔ;^sdu)H!7k%L4
6IՓPWWE@;UObvT+>CdW6l&Vø~JRwdScgW6-Im4̦1.qU46$8VmM}DPYЁ?+$Iɶh\"Ma4~"sC]T7y^hϲ-4GҖ$V_r^v_IS$)#4V+`mJǩ*ܚem?mMdyꝬjiKlk\+4t5(HpDC5}**4.Lk#]k[hӖLJo$),muG;R.ݞP@QM%3P,oYN"W2|;Թ	Z[[XNGP8B`c8lf~b26@y]x9(
b o}TGgɏ:pgR
{1|
mh-P:}yDՕ_VPKҖɻ-@2VMt ZI"%E6%۴P8D6m()uDj먌v[ITD{UMifob;tk=mx>AuBFĩgHf=r--I֜,'tz[HO̦lAUKf@d~_ (2w#~TVS>b>T2v6u-)J5If7i>BD+4!K[cg~s5?rt?&N?wdfв IDATu'dwk~}_?]69>5SNx7ֿYB;(
eKxL|DN9s"IJ(V̨MKs
Z!85c@#0p`mt‘]	C6H l&K(&EꨯvgB>;]֝s6aӒbfpI#{?r(^E,Թϸe=]/p__\WX(o\{ʨ]_ϰґ@7k'"@w̏gnR<,3:/O6&3+HĒ4s,L	2,#AB!Ȥ@"!(4&IS"M2	|JdKlk5hkTNYgD-L6&;H2hG"6nJ7TACf!LIeDmh:Ly49Gd絜}):9ݔCk|mSjJ8$M-ʹ*w!L6ގ%#<אzYBũoj b1g$I,zt3f`a;_>t̑Yޓ)E/IOymD3_N
2f
=Á:rQKW']AI7VSoP4NY$7P
gH6hWM-o_3BC2J?u-R 1[ 6H}2F,BtuM3-e*;#LEcT*JqLm5OEʫ&iJ
ZQb5m#R qB&mٗlpEuMPY@8y~ț5։ɒlmӀIU<ɒlMSؾ+]<K@h,4u
$˔)JEY&LY47- zD
4_
"?2ԶŨ*>πD"Uwj3Jxyi~qw~3z|/harN<`w{_ZBӝYl}'Mm/?VzFWYmܝZʼnW=#[8e,l'۾թ}]+7_ׯs۹z˰҉LyOj_ҥ@'XN/_LiIVˋ'2iGAABҥhHExx4P.!"4TE TEmXhXEvƕk6E:%6̳jHTƩ
TG*oLHĈdp;ͶR!eӦn(hn,t`_6#
uqBh'냪@6|NCߚ)|UR*"F»D/|et1$А$2'%VYEŸ=oTPS@"X
}3F]?y}<{|e<yՒhHĨU :N(D*FV\X'Fg֜SPd6
+GDV$P7ͥ_l5AMMx[i|w{h*/k)FYϋҒSeNS='eI6PHC(D(U

/$I:^onzdfIly8)L;u6,[Õv
C,31:^;2,_@wj
g[8;Y͘=3_$I$IzSxgY:Ӟx9I$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$I$E$I$)@/$I$I2|$I$I
$I$IR_$I$Id"I$I I$IH$w]_{3ip?AYQJ36cv][ȼiYL]e*D3]6ôkmhQ>rX/__/~i<|>@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_/D@" H|H$$_վkV IҥK%߶?
I H|H$$=w"@EW[k1;V5kyeEccc̛7AwYƪ^x!~뮻{,E+::bf\5kp7cپ\0vp>ֶ
/3hmk}Ӡ()`g̘N188K@"/@Ś	jz׸'_K41KŸ룦&jjj0)Nc-4};mV
/;vR0s8pJ)6a/@5p#DvT,/@5_%aGLP
b/@5p@"/@2TXBb֭100K.{'r6aG@*bܷ> |hoo7FQK/{k:3:o%nC_'LƼo!xꩧ;(qG|7Ml۷<=ݸ!^.u:H!xE;:::bɒ%QEΝ;c?3ѡY(("^
ݯơ(EDS,xpyCXGGc׮}qP4XՃm1+ݞ
}Qjhk#"Jp=s\!gϞo1c޼yq7OM|y]wg}Gw
~/V2
.&;*ֹ+Vhnnn!9VZ5sL|,3c}[=-kޞo7"*V,(ni_zg[-k~A,5G8Դ"ZovfD/@`7oSeʕŋO>9+mQ׸(ݶ(Ng/5YQhUtw3_{vVC)bc{aG@Йgy&:;;cQ*bʕKKKKSvȁ[?u_s{
Ѻ>Xx_#=?]'?Ok 1h8?|xh8.2&_uf08x`̘1#Z[[ѣqM7O?s΍رcǯx-Z?n(F#7)]PExϢEoT9纅ߎGo~qٳM!l!dl.5
LkמbTK.gmn_ivزeÎ)72&D&S|&^82gsk(Z^lr
/@5p˹/@5W|9p-''^cqOLc|$rb|jkO?ʿۑ(~0
LkP͟wo_Jլ㑑xї
˳>'YJ W^gϔnsUWű>{pլe\ƘsUX0
jΔ|W[)"LA,Yb! s$_?LZ+D{IENDB`errbot-6.1.1+ds/docs/_static/screenshots/thumb_basecamp.png000066400000000000000000000410321355337103200240160ustar00rootroot00000000000000PNG


IHDR	^f	pHYs76vtIME	1_ɺ IDATxyeuvηݯ1w0EDJrqٱ)rENR2?ISeqR%#ɉ#H"nKLoo-'`#p OM{!<uHs{uPh"HD@tkV"p)|qsDÆ~7>+w~Wj0+qQ7XG7^~LݼzOJͅE:g_Z:礒e嫔]kmlI6í<ڬm̞~vzcg>rh4;9(#r|G#4CaKxwDLfb]8ç/dUhQ;tX{Sq!CeA;/w;}kyz=x:}~\cW6:?n3?NJ2O};߳=}mGQ+^
+.^|\:t\K?sGuj/yw|ڛE~~p^lK! 'ʒԅi%}9_^=u8?K01r7o_hEZs"T[֖xwR>dc2IVK;-k
08-@)eHXuҁ~yy~kq9@B[Xe9j @@qD%q@…+X	'6P@)^D@6DJ@Yz9{2C "$$Bb߁1,{i[YľrRF1OvFB"<Q:
+0nmm.K+yApa9}1th0|_q9?""1C^zrw"ss3wd,ۿ"  "BDrslΟ|{
Gbg"9G/r~[Dr7lvpk_2Dz,o^NJhD/͊I*̹;Z+[K]/"0p@kkO=$r̹pY섵V$C+P3W.\>~VwjV@6Dtۗ{;kU{nōuv+YcvecVlvz}_1zKK;Cn/t4kX̆Iڽp:h,W& (vS0M'iV=i=Vh	 lڍɸb]ӝ
jw[뒔e[T^+[ty677GaqeEQY)`X't>%r\'hT4,Y\YٹxAW	/``CZz]8ݵFu8*,2V\
,+3AJi0O1:K3BƄ^;Gd,UU]Bb--VnJ5.,wvKX!x6$0_T>-dTR(xuVil1~KLGC~HցZܙsfM^:3rinkWAP58{?gYvԩBYǿ?Iғܿ8OG?_>/D{oA~?O]ƞYZР7,vt~nKmG|ݼp4'~;><wPJV:tyVZhyB{}=:I'@>{ vucdNnryqm~ՍN_~_<>*L^^gNcqW?3t.MT@vhѣ+.u٨U`[O|0C_Ngy=]̻.YU.Γt"ZRk,,wjMqUB!yZ~/_O}T*Yrk{{8z[RM|(OM~sGNa|a#K,WjM3fٍlUN$W"V…ˌޠ_mqj,_qNW-6뻝l[W1ДA#tyݾ|ek<9fybNK.}a=JpKg7/_F0FqC}Dͬt3L9vwdA*qz͊lv©ɵsґj%&n؈V<q'(h^=7LubC=i҈|Ƈ=lq|%F1?NӴ,7{(QxeY0PѬV~kuCDLJ)/Y;VV`圗yjQrp(
Y!s$34dJɰ8
ȵxejdq&	,,YgBa`nZ8Oh,=%=/KҸgyI8qpYV*Hxɠ2+LZa|q
Gjd:
<Dx8)o
@j萏F̦iœ8
#`74MϜ9tU8~W%oZȧ>쏷DsLf'MTa21
_#X}?{pw{sʶMNyT;cwx娺thkgS˅8<>O?dh/.i%Q1F{r\y/Tpei}Q7Ξn.Gz*/ъ{%(`_Xc[ykV(F#;W7Uz|rSy\|b9=assqy
	KVb=&
>EpkǏi_ne\N({	,
Õ{lq-/Z*`ʱ;8qgܓ81n-|gv"XZ=3K'F{3_#GHJs^a~uJ+$NrD8f`;;qy^Fފ}Egf^ϟt!ͫQn|
ٝ'wwP
fIV'9
G;[۵FuCwL'jQmQpE/OҼL!rܙ1<UXil;\cfY4j"πyY2B-..YAW_/?L̋= /\*<<]ZYYӵOP~,\ܸh|g_yutUGAo⹳,>OmCX-O?BkQ캓w 9w>`'5&W`4p*t'V^lɽsZM^ȋs;asȄv5ng?VX
#K2('ީ8{?[h'?cZ7S~AԙbMLb}bNlTR$z`
a*HKZ^;ࢽग़Pf0G)Y:+-gIWF]#W:N]a׶=\m\QZƭ̲8(bTfkƥKڋ9IR,//i]&qy^%h4/ 1lތǞ6[C7L!]Q ̕{/%f?*^l0-yM5Å;BA篤=4ZjݼsT;1,
m
[^
ךbm8R7O ysm+'o"QәN%m9WV*cO~/V~n嫽a?3~g8+:ӣ9~ʶz2͛,EPPɄjYΒt*K0,w4&YEs#pfM24gͤh//lMBº].ausxs:<&pǛGQtCzǥ[9DZRy[K޾U7~I;}}j)^F:POjCtD6 Ŧ8y#mmߏZmee&^*5bj"̝#zg/k\~LڑxzKmz$
[y5CDJ)DNJ$fQiT3 .r
teQ JJ%p{+k)nד;- dQǛq;8}zG9wI\T}lyN(VQ?:<+b޽",ۘR9.3vngm9Ⱦ}JLٱ'vǗ"^:Z9Fܻ#ΏRP/|)	iӫ{AVJLR|?XiuqWKc{yMy,5 ^AD2|m+S=׺ jhwщ+/ndx&݋'>Ăk/zA?ѓ(Yk}[O|#;cV"T|闕HRHw~ݹ.PN'Qk2j:(1q!G#Ѝ.LYTt"o,g!dSZ<Ǐ^R\yCpjZ&#/6Qsa}><
nU2/yf߭/tNG7$w;tlM{x1&Qbmt.x@V;jdfc>#3zJKKm?t閞@?ʕ"EQZ(>ptxWgzh{]Z7Nv7zIЏ'jڛ[{(?臔RKmXFz/Kތ}Vs~Cn;i&~X	C֌u`|ߖe{:qn)>~^Z:7ci_>Ckϟ7%v%'o9>(ϳ K.[tahR貔do<G?sDkǎsgHGcf}~c%JL"dEU%V~_#/Q1xJDEΚKGu>m4~kK.Şc1-CYOz3#HJQDsd	NK+\H@JȘ"B 2Θ#?J1o/>B_gj"38f:r@8w~!~{^Tc'8Vmxpfͺ3W$Qf<+|cw^Rom
PfsU~0Ɍ+S\ȲԞя+NP[dTZKL2+JRt0bGefء0#"eYъĶk~R`\ǜlbbR)B ,TkfhcL%TY9g6	QyyYֺE2`bReNɸ;c֜qkWyZQi]E)'Ɉ *YԎGJ\#á@RH՗qM۫RX~D+%ԁxGI3.^S
`)ofc#"Ԭ?蝃4]y_7ek1ʺ2߻97v8ZK
慌گc=oY2FBtP;R9͊?GxڠۙE	TqY-d3Pe>v7.EQKJgz-2KmMLf7q	}?$E\j	dG#8eDڑs!m#RI%i,XҌ\+Δ+I9u\^nZKKqU[A6<͖6yMZf0\ŐsW
,Cߑoο
TO&_UjeÐsI&# jx^21&<%AmdLENLH.ܞA(ș?BOtAz!}sƫTA 0䀀,ҩťf:-^lL`AX,P:yA ZeѤ%[ZlpCOd[`''v@dz2ZeNC\0“8y;@zp(w*p:]:D|lX:t+J{ǃ=g>ݧ_N~'"KƮ~SK7z[E>rcb{FOZFaܛL6hW^((FB=  IDATqs|-3!sa@Jq慞fWcTӎzJ<
@6ߘ8*qRsX%w>319DH`snY0t!(#@)6.rnϧEXkU26b#9iZ{?wC`!s3fct~"css
|ps/mn^K_r{}P t	2bܱ2dBq荆I/whU~RXhtlהEg(
eiWWɚ7Z
]Qg`E8
&Vp	,j4ɈՕt4TNXi7;-߯:$e*j4*NOrqqI	m/MtӸV5@A9*jqz.TZ۱Vq-GYZƕ0+F3^	vU!>{eExRˋhYg!)bޤ<" b#;k磵Մ!c[89gsDDۋ2Ƭuc9G(+c,۷
b=~Ğ"pSlsR!=s{lߜFόs6yb:eQ:s=7;,Z5"" monjHkmuYk-cqaveX5QQd,7Ɛk:i;14WCןZk?5fn	͒uu#Ea:Ghk-Q{59JYs1&>nKCv']Z]PBa\	GcIO+Z[Y_NGyaOfep}u9OSAfecpBd cPԧaQ5rI*c264*asaњ!gFMIOG^T
c`Z%yR?~2t^L"Y,4+Yw\rmhزq>/pѷroc-Aa+ͳïdmpu~cm:Ua/{}u/!Ew.7-vo5QpKmiwB u6*WP
JCJ0F`1&Z2rZE$,r
4@l{4͝J*8,AT&iV*i+\YLb=&@"q4
)9Ce]l5,&.*Z&y׮\*qRCWY$ߑn|Hpڒ)OI*cBpkd8yJ
Q%r.}YeYsۆDld.MpF[ 0Tm,TXk
3/E8x/M{)_~.l4Kf`"rĸXZZbBN{CL՘Nk[4L}1G-Q[1??*Mgl>%MTn/~Cf޵ڎٍ; O=2iߍvşy[ tv:O|O)G>w|ʹo}GC?ۧ.=密t;?ug[""t}Oq3VD4&ըv%j+-v;o|a'/i.s@y6|YDt/]|.{/׫Qg
;ƺ}lxKSֹťED>{"2d&2Y7`csËOv'K䅝n/~;ڴ%QivMvkNm<ċ߰PD%9C$WR*i`FdrZ
	L{G#tzWs$ӽUCk@.Mqws\jF}vOx}/zuy5-f
ɶ
|:v}vEd5F?ͦ4	ؓ,HғϜzVwćL):íY2Uʻ{;|*|E2\@ZɊcwFa:~ًjt8IK
0le&ZZX%c˭եt,W\%^\n(%h6EYXЫtvl0(]V֔'9FaRսZN(!Ћ.T*}`8:}տJ+Y…g+'|=_m^ټ;tgeJ*tl&q@Ww/]vami]xle;r8&Y(kdzZa C^Kr>t6kϟꁻ&gxxC^>ZMr9Iҷ~>1M&'ҫ7ϝy{zGԦL+G0b@~&|@ȁ#`,|3DwNCYoqUկn]T*v8@P{a4vA!q5&r8u&q%rJѬ/,e9@z ||v!vGC|8[{@B@kݍFnɴ> 0O9k?G FJYBMqV{:Dl4kk3$\A.|BKD}s.s#!X2@88ZD@ٜí߮kWmFM-//z	U[gI0w2uGJT=qv٧%\v/|v7LgPg^>`r
%y1$w7>RWUr{;f2L6řaY4nz07]}5/L|}sI Qqvwv9>3i1ジd/1(qW\nY
8$&	pŤI)!qE'I`_zc8t1|82I(IZ>|73ޡ:vr`@|6o~?dB2&,MͻS?n`( 3w֙_3D2@&	dtt'T
QkaL@g;St6G
dc6OUZ[Pxu+icOA%k]~^z_~N_4zӽ0l[IHaO-淞o%8`4ɒ/R?<‹/p@d:&Q7?[dt+`D&	h4Z{=<~s"
>7?R?oŋ;xGub>~tl{mfӹӝ٢ӗGvNo=9˨/;W-Iz-MON71BzoB޼W,ۺYc쨕|i.8!!}L
%Їȃ!B@R(C[BP#+:ws>Z%۲Jgfvuo&oO(׹3;s+xkrOOσ뚦;}럌1?wO.YÍB!
>c1/FT!Pu,0	(ˏ=vT2/^r)]+01!K2EQwšCO>ö+F@>R
q/ȉK׿>)@IȵvBZ?)&|(櫄]1]6d>Rt=Pm]F4;)XdBSۢrʼnEkE'28FN%#9ţR19LPGCt;Gn4'eRiu' @Pj,
-gXj;j(LB
"Xt=b+fLk4VymL]Pu]4s.30$/EHQ04Mr#B(J)cR*84m7RE붽1MFk~??#Wrឭ/kMULeK={Ϝ9#r	^o&y׫fZBd9֡Ct:(mۯt>߿HJABg!9kև^Ji.s'A4_z\.g={fggP8x<7oBl|Ν;UU4-B!)~6R(Ww?P`wB<(gBYX(~ibrγlT"

f9399#LOOBB|lφMT~G	L&MDuwwOLLbB_*\bYVRA)rرcMwoq ?ssEQvM!,,,$	J)cl"=?ز,EQt]MNn^?~xsdR4!]r,[qV-@ pᱱ1_d"w#;w4ͩwygC+R.F=}[o533|k>8sg/|g8K…G/~zĉ^xaddd``;rȎ;,:|g.r@⾔JeN~#Gd֡k׮}ßy6mISN8qѣ7o$>x7Μ9S(cB_}hhȲ,|>_<ORd2Zw}M9w]WcVXch'>^դ#꺮ao={>3BHOOO D">(\tIUUNNNڶ]T~AЋ/9眇B!7|r墤/TUMRntb5@߿(z2ڋǙػw4#sM$cBlVzTiH/&,!-Va	@P.3%l30ƦiJiшb
ם
o$)e^mVC&a٠HVl24m8tR9fYռ۫_tnC
䦪,z[.S=.9\.ER*WQ=
HQ=a]zUU=5C @20˭V˕j+(W*{:B40&1fnp]WUU>sǘ -ߋrN$?0Ƅ1?nܕ^lˍoJ(Mi!xglHŸrۖ<@ٕSƽ".^%
^1	jYZH=Qۮ6fz7IDAT%H͸T;Z4s&Pna`$*>uK
T*۶^V@$TkH\$jz4b^sMর]v}ek
[v5Zefv\ǹى~7S.]XyͶK.zW~t;5^?QEB9̿)
c,]] c&b CyszRɮxZ^aHNLv%wڡu1˕}CM
Oȃ`%!mc;$<fut]_S/
޴/ݰBg?uCk,FA8s$v獟84J	 e鸸m#`}>u!L"1z|TIENDB`errbot-6.1.1+ds/docs/_static/screenshots/thumb_help.png000066400000000000000000000431011355337103200231720ustar00rootroot00000000000000PNG


IHDRFm	pHYstIME	tEXtCommentCreated with GIMPW IDATxW%Gz&NϹ\o[xgCr䎆\H.C+BA.B
M!J+`f<nmyzݍ q*|72ҁ(JRp$_a!@ P@)@(B "^~EV%5:B+.BцV+,g$^-vh,@yg}aCdD>+"do^:7`׏[{w1+5+E^Dh8ЩG2/TZ?E%j8cYQ~n0c;m
4q
lBU!`7{sb*V8]%/Q0'P bя,r'<3"#*bXe1R#8t}cA_a	&,PM~hNB
QVā$r,0Ctqu?&Q?:DHJS.XDY8Q}Eُ
rh=:J!J)<BH)%‡^whJ0!?BP(bB}(|dhi/Z@R`?AU( CՈm$,vl\m!'b7RJ"6(1ɉ`{4;3قյqU
	|sOBޛu!~sK7!4Yg.C̓KZ*nYJGWsXNN6⏟K'Fg߽ŃwVwvFXO.?iJ
xgR<0Œ
v_uX8t{re\gB0ZAζ\p.e-PJZ8F8i빩D{Q4/	}`ِHcJsA(GP\B
hTܧ)F'qx\؎F1n!)F(Q0ƍ5[a2K\
6A."9ܾd^"euW),\͵k
FfC}tXjbdFݞ{g%:}XZ͖YRw|۫d2|3M%Atd2_3r
3=ʪquL8%,b9Ӹ|OFذLt'896twЊ"kYoڊ@޼yӣ[wڕO|wek7>kC5wp:¿u}}X\^֝L/Gp~
H͆J.„Ձ#(@mF_rw	^Gs2H.p6tMG,aY=yh(7]=5zХl&$,?zzgqe5(
dq{sճN_z0p':_iN-\{bǾxPlƳ{;pHf&)Ξ;{Dk
p>
CˬVg.c$wIHa\lZ@BV|U
l86K|F89ؑYz͍k{[c[wn^]{75wloSɹCO<ƛ$jѳ!~^Y
Xc$qA(fDFVLGQb[wh\:C#
Pׁ,2GkR	}yV'ɏz-ACM
,ˎzwTȧC
BX]x;4ARوF">0`%zb nRc*Ud@}wMDY"q2RCavRXq?ؖs<xI_PB1;C!BI`prݻw76/ܤJ)zZQJ	Գ.]f9W/~g}֠[v}~T>T//ݕ!MXU;JWݵz^?,Q{cǤ[YRxKRBκq/7FxFΦFj*pU_Y<7.o앞zW͝pb2ߵo\R@0R|anG?x尡(~D!	9o4#1s;+cQѬh	&.\:Cd"$YeOO,Gxffb2pJp\Q/16Vƿfskk9_qgK$x,v+;\t{k?5oK%e~7mlm<>lU2m?go~:5~O=(f'7ݠ(zRcf:VeY*C˷
dɽ@8Y:AcRV;^( @;'C乮x%XF79aɎ7gۙkb$.d2뫉qWX@4bu@,iV,'DL]s]Wx@ֶwcӢ(LMNnL|cLs89RϦX+
(u]G9z>?\KwrIO,ɞxZO2~I~
lَ$_}G%QjP@Ȳy,sGrGua7x[ʯ}wl"b|89B$<-xqϦrlEO=}g 1h?kF4{kgkp4D:ORBF͢3iQ
\}=Lf.FX"rtgk!eʒMD"ب7xg?~?ӟe)a6rNF3?c1GQDU=7
t+Dk?WrIg~b+a6!g.o]F_}JO?m n0m#^C?wAplgc}EUL%4ڨ\E9uC?
WKg/:MCޚz29A"Ȣ`Z.YpȒdc" BӴ9dE8^$)1D\9^011,.d໐am1p E,b(P7CgeadXӴ8zQa=BY%WRJjd壾\(ʽR"+
Ge,ؽ *)#yhhÑ(	1I"6
#U #dS20}, O0kB@d%vO1EzKB8RA8:/J{nZD6b9a_"<뺞F$"<%u}YlGETZ&GX:&Qzq^xsq:Sqŕkcvg_@FEr|,,2b㧧g;C]\۳lovṙ*q4]9Jgf]Șy3=2`\1BDb1h2wύ	2ij@_U?pA/xS&aX?K~SJJ
~5)>P=ܖkf=ap]ޗp	OiPw:{}gjxW^dCX7L!jᤔzp[>XoȬ{|>,$"ωAu<mHp(?̸mxآ݁ȶmD>zK	V,i7j91
T٩kɈG%
gj߼gf_{x>ɳrJc)ru4_t
J}a.	n1uˍ'BH\mmn3+F'Ib'}޺N@@#ӟW'-9u±޶fdXvPܑ,Ue^8{1W|uckrɩoؓg]zP4,n>=~|8`M˶3k2ܠb
kQHT|~oc8*߬mzq޵An[o4$F~h͑r4:3֣U$9jc}krj	Bx8m~Dޯ\E<BhwwxJ45^|o| !$x^z3.=W+v/k
}z>~!W*0CEL!;3a>l7=3dYӇ_
ga9=łGbXLyj,4:}l7jM-Q<r]#n8>Jg$E@Xy
*5Md4X1#HPD^0FE
j9:)uy5Z$lrFp[fXVcbaY!.eڭX=N v\s/j{S\_5ˁnsx->]
v}RlO޺z2gعW]
0i	'DSw{Z6'Xk}3'wFB0>-[޺f~rwe|owKOqz\dyftasاqO3%~Lzq̮ڠBSg{N2I[޽a=H ?yC'i6=X2}NR,e[U-G	v9?+K5p~nUMkD8}|Wޙ;>cn:^qf23iۛ~G%꺘n՞|9s̱;w2Pp011zO޼55;Ci3}rcH{kwe,Ͼ3ˋu|܏/!lysN.]Kn.'ejxu>u܋?˞,M$5_xⱳ_cTPMӃjrfV@WoڶI*jTmK|0$q#}yصjmכM~Hbw]{C]AcK:UN'[kwSc3֩WˢBN4C+nn
ÆaZat>k3,fx|M%kk'Sd}mkqi+ؼA[y_]>=iebrßzƾa#=4{AaZ_>(auA2|Lx=8)u
G\+5"k<Ǟl4[
K.Ҫױqr`ll
ڊ[yTy>Px嗣ֺy0grCǯ_ĆӃJ5l7k64NЩl}weDz3WުzJ3Qꕫ'j`}kv|/=)~mhڣǨP~()"s򥉥Q	S`Yl΍GYpB~{o^gKseX[}'6|(dϰ홐3d27D򭍓'OR	|ѯN^xM
}8Y|SТ(/|P8@ qύ&&'`$*뙩yPZNO
f"olX#cyyg8Ir3oEY'0Bn;S–'M7R8mc]2u5m>Cv
:cql=xd-~ !`?4o*ƧzZ#lll}P~%9g|p~nF
}[/gb9.P6| dLDۋ/J,$flbf~jK3˲x'e-ȶ{=hz<UE&gYmN3s+͡B{[~evrs7ύa0G!_oߏ?9EC6"g
`0{^WaxWkLMt~d^\
zVSYYOĨZΦl0q%)3R5
2HJC{0_aD	M4zV﮶m&"a|lj6Ye&sWI'-s"[ IDATNq|aݭL21Zk9{❷L-޼~uzdkkh훣S~(
B[k[q]lV;g^vOhBtllL\.z>!yn
vWԅcTyZb|&x";ll\&ptyқײl {[l(ҫoZ֨|մ/,]y[39b{=wKoP6Ю|LA|}r南	X]mK7'gRA1`Jh5 no[]k4c#5[\)㙸n<+!ok}%VB?Cqs^}3dq9M(ˉdJ5ARQGfu‰,1(hj<`[7@_6_z	Hh,<h^/ĺU8zPiI"XV-UuhajpQY!HO_M=-G.{nےy6tM	_L}pG}p*W>(:\~zV7\r-5-@	 	*mn?٩o֙VW?,~c”\_]_/?<	Й{W?ر8/ɹf1%OHJ
H|.30l^CL RD03#,/l
rJ٘*5-O?izGt|||||zn~p8t_0AQ/(wnm埚潣BA׿aÛFv_QoBWBzvޱ[ޫC4퓴Ƕmyo߃"T
<
/%zUU흳O?&S\Sn@w

&rsainV.Yꘔ8K?'y/2;cw˕zm~7/4^Ngyx:rwcŋ{[[Avټk2N4%eJ8e֙Y:ӟ񽞶|ݵX腟_ZΌ\VoWd:#["3}؃gN}P
Gxi7b s?4h8)dγzFxϱ]Z>9̀QAbȲʊy>^k`&jdop.tc<<F ZpX7D<~@J	1)MBgޠyѝaYM}vﮔyndF߽yӶmJwoo6!cR[{gfRJ<׋އW'pcEh޾y=્^jlU-!uΩvrgC+[եcNJϟ{鹿>{?KӹΫ;"k5Ngr	g/^HTp$_zJİ=!|&lϵ5;ejHjtrc>fY) ͺh=V()q@c
ubr"e~IދC>c1&@S>B0v0sÓ]SJ11KBѫOW}wU0;Z2QX#!fxۋqbnl!qD"
F;[3FiRϒBpJ$u[wcOo6وGM+EKhγ班lW~7Lb"Ȍ(渻Jj׳lon2]jMN߹Npow_1
r0 ְ9-Lıl0@a{u1'OfLf5(H"PJi9
a%|gwi2MFz8^
y-CH<ʤ²{^"裑&s!"aly(ǂB0q7fǓV	ʯc L᩾A9DEuBPC<˸hڭ37՚(RS*%y@iۈA)(b ;>"m		pLRe/+JdZ<1~j,1Wkrq[sns%9qys[D	
^-v5߱Ol&w[Spo[X=wfD
96}ѻۗjhosmrz`sR՟mҫt{;?
g*B(ʫ=ڵ%s'/ٟ?/z˯_@͡NI<T.̼xXkYj$0VSKZs+յxP-Og7lc|7޾N+VN"mң01žW/55ԱEA=דD4-ҤcQmedʲ\ui"oY6B(eu<	(޾6N)1BP"ab|A,@¾'S 4U0 bб$7QQV$J冫N"xܸ\{67wsbqw3
9pecO
EE*WomΟxһ3'jGY/eQ`Z;9J)K*/\\.n/m[v<ӧ	]@L8jmxeI&	ԏy5+I,(-ܼPljb+뺮Ked-O*B,C(xV7LA|aX2ȵc(a(,C@l%ٱLN	!	9T)͍DA8g}S>xuew~q`aXBL(qUKyZ~iqi@h0Q	ƀ8;cʏ~IKqL\W21u]pf&&',˖I:^̱RL|߇;P|~<1*X$۶(vms OR
l[.DB4
4QqBH
|zZT,&xhvDTAoQVgijtwwWQ}f>̳뷯
Jaۨl&ɘ0]#`\y$?vq€*{fsoy9"lt<5Y۩z+Oܹ{e;t_&,B:*N`L84vv,~'P>edcXXεAkN& #qec8r\c-=dAkی@#p,թq컷yayiNImQvzAf.RQɥbڹ{+;Yɓ'Dq@
N(w#Ǿ5*$ùBAS٦F1>13c-5ʇDeUd	6Gֿ
I8.ԧ/6|P4zD`gh{COH\LY	@r(z*sls=ղe;w_aPBej^.B^CjGCнI8lZ]^`}QO
MðLkwz\m۴dDmFC3`(v\)=
Uo}ӏEBƪϩg4w;% P\ǘoI>r$L=
Ec%U8/*M/,V@,?7=vwU
J$~4>9I)6ØZ!,'߸yfŻwvx2ɂeh|^GX.u_(:\^8lVd> {naAk s:|ۮ՛7^}Tć:ߨV
Ӿ+"mxYi h.*R"@F~ghMO66Ks39߶*{2q	BÈ,]s"A] ZZ72@٬W9udw-
Sfݷ_vڱp[k;tc[kj7ۣ~;BZ|aw坠kΤ/T,,,L=qdV<(4Y*hh?vϜkԵsY+@"ɩ_z56}v_sSq;ݹˌc9{c
e^HyER^h2*(jN.,D6_9c.=ߩ92`LMefcC<VjŲiZP'+);PARt4(ebj$d<9YbXFļA;҉FϘ]{Jnb_"A^	(LNM:-%7`,|:1658UxΡH\~\Udbin[v3Ωb `3 {~!XAXD5
+R|>Lg)0I1{WqE,jdY";%mU%NT*oyMU*g'QvY*KLY(@8X3f0Kw$E2-َ"K=>շO߹}fa2G~Zξy>6Pa:?qm+{Eawnņcm#X
1B"r
	v1u}l(h4_?f~}a\~@Teq*C@KE1>JJđ	'p4$"NF2)Nf_63pQ+nK>s^{c#	a+1>v>?v]ozD?ujq7l<`P*%i=NyLZmr4YޮP4$n g3VE?>{xҧ( *з-ðԤ9A!UI2&ҁr
9?9;(Lq0}H^9:9qtĀu=\+E%*"_j4n82iu+ZC␚(ObP-Tʽ/NuADiB0L_N=ϵ@V^>/=Zw+Cot520=R~K."z_,F,kȴoٲ4˟]/O~_[ZjI	N*[2VurjsՔmwνi,}1VU`4@T<:9&@t7cQY	LLK'φBщ\vICOIS2P	A5K0T~(LlE_۲{X~Z/lAtfS7Zm׵Ӕ\|lV;BP!5PPhvW]e9{O]tWFC/w{{0;?{DjG>>IbǦ^Z{2H5V6:'L1.56U>Qoeז~;.׵p$)VJ7nokN1Y;6S*éO\shqLsӞO~i"T^_VMC/[pF^B|!V7ʼ>qmV;hfyRݓߴ
cvjt]"G1[}Yz*{({J*Z#> .,$ՒOLMLoCIDAT7[5̠iZ#ӳKSfaXV~{:DI+͖"aqz[V}T/d9IFlypN&#zj;3,&:pl4G3
ɈKuCf7>4մIz0T2:2p|PkKZ%HةVApsmj@˨,~Sz)ٞy
B	qtnl/Vg0uvfrxku![ 
={$HT(	Ze[UUZKՐdǔQ^8V5EJWpN VkjTY]'Xf1 2X2꒘Ҵ`2ѬjD<*00q,r$5ꍰq(\Cڜtk:4U
H
{7A۵V zF)ݭ@``~ݻz*v`{zx\p>@)u޽6xOݗ҂=F󎢘sҧ0PFQ
'>Jxa
	os~5
snSMUm!EQ<olU$ݻ;JLemZvuMȮ!_&vԳYZu-Ow/x0hO"RIc d 09'!ϧ<vew"'`1"`(S	%bb:u`f!cdj
bUn=49`	/l3@Ǣw]CΈ0#x#o݆=DB=o$ygayC.Cc{mA|vjEt1!#;I>_@[5Hg-
Ɍsb<߯y;.eٿX<2FWyE/{.zeg_DX/|}r&QyR/ŀ:O	.n==Mc)S}w?FmIENDB`errbot-6.1.1+ds/docs/_static/screenshots/thumb_quota.png000066400000000000000000000270041355337103200233770ustar00rootroot00000000000000PNG


IHDR+bKGD	pHYstIME	0̮1 IDATxٓ]W};Dfb$HYUjI=-YzlG;/8Žp~ЃRے岭RwEĐ30W<Be>n$IA$`t}1*l6%7Gy'6)524M>_0Sb[7{cJ){eѬpdjF*GRbD+wgZ#<0׃kyh?6@Jy+mwR=	FE춆 \u~LzL{2a
&~zorbrUtcjU\!s\rNKx)h$8g-%\[nbTõ,R
{rOn0Ȳ^cBJy1}g26ǯntx"j8Icۜ2/!KN"@XV:n֘1JbZJi02D59uk98k[[[kx8t୷~w..;(^gBTe9z~Q ag<)KLOKr'_>7ė~7p°˪@2I;@0֊,syb$J18RrVOrA0H%9w"01Ɛ)!qQzuI'?DWfI~?,N;+ajQ	^p2r2ps;<'qΈ AQк}o;7DZTH)(TQ#CJ&e0
Z5J![7!7Y\(9}^[pḛmzCk6"S	JbYneDaee:\Vbrl$	GZlL6坛 J޽OY\$Cds.d%:\!BTs>|'#:K}r;o,];_$m;ͥ9'f=*y[-uAlTNiN/XKM<ֻG<>?ח1EBQlB:zWfZb3)n&瘾0(w߀ݞnybBJ	ϸA4U䎌	f8Ip8Yiͻ]ejJ"+dϳ㹎/"t[J3M}z^{0ILOM׾3/j,!P.Tp]בiˣh]~^H6o׶z
,~'n׮SO^\geګ^z;<5 :\=~ڏmg̞Z$
]LM&!+7,-_gg);K|Nַ_>[եBg8,;wX歝"o
9־ZC&mD.頋';Z1y9](w.f4Z}i(L838RPVډ	y9t9~mzYZdfssC}?("&9yiuR.E:ҨJ&&Qzj\N>05C1ߢK5gu+sK'	BΰӍr-_1IBmfxS#QBZBI>W&o?
!qg+B!*ϋ2czKE`&! JKdLNxr?a|^GR u4ɋe EJk3/R`38-3[2#4~];xd{s14|Q46~ &"H=py8X8㸞>_%nۄt8z|n"Td`oSbB2G,[ʱ `׷ꉌN|/1n&[3']jqo)BW#0ā?ٯ- pz(,(
y:#*SKHv_Ew! p盈hvҔZ{?hXB09[6{>$1F4q$yzi<$*Hjꎘ#&KS4a$IaAEqU_;NaiDRbg~E?cNN?DW?@PC~;,--"Q҉
?{mN>K9h#LP/fݭMZm>R8uXɩB}^~^7Qmx9n]zxQNg4*L,fi~3knSmԸq}:2Qy΅:ũOr㓏	N;!W9gO^a1$qg\<;H.x^+4U1QNB#K^ʵO\$pOhL{׸sykф==ȀR}l&*N%("/<1 BCVTk`os!ePR0m|x}v	Xy޻rnsR_d58sgkwyI>z}%J*S!t;=׸kw[x0ur~ǸNT0	\x>UoիԶ7K7B)O5x׹rAL)mUWL΁[E4uYݭe5!N?/~W>_2hqt&$*E"gB
&juR0XT71WouZOR];vd0
$I4L#x	|Q3'89Ye^HΫ)(5f8M22ŗF4h4zE|J7gOj
9s"+D^NWbznz[$ဝmFݍMZDeTuJ=9VV9sjtk77	r۟P$Dp0jwwXxylvTFu3gvkfgXq
7jͅ'/rS\w:FbΈ
<.,pqP,$fkgjFp0Ҙsan|HŷG~ɒ ]\#
}8f0컮BH\ُmʡH.*ϾTK"Gwxw3oGToJ~L]o\arHsa
w)ZQ~UNsg@hPJ)t\Q(N\sfrruM}ဥ ǥ؄pēu8FCw_`2KܢFO@_}]5c{+hbjNztDgW993i`ZɰEN3*nPH2:{/p|zYL?˕wޣho6鵷9\~T!6qS.ëWRNprn*Չ&ܔplСT2sꑃAqg9QOR-|^Nw
KDG}Mc}.SfjIӯBo<ş/=yt(p43%_`cK$,NR
;a܋ѠCk{'#2De.C[Q!;T9FBkgLH={5DSw/"IҼC8j+=$PZIqGBen+C>%^886΋r䳽Ǎ)c0+UjUOʹ8xЁCڭv;|yWլ;ޝ>Oܫ~0%>o
;wqL\!%Igw^'3x&#.W(G
g08hv+=bY<6sכO'x罿r_aE<!۫n6vYy-N|w_2nM/[l_w-o3Y:siQd'4^ɰ_
|k7v_}MƫLۧ"%5[ШqUづ:#T$OcH!(&PyafK!= T\}iDso'xy8=G%H ݈,Th1D,\I:}ۄs^z[SD/A~-4r^G5Ο>C{c6a9住2=w*^J`ULX
h<|j|\qVnൿ!ΐxh\'
=°Lـ۝66<4I:ܦظ~sc]Ο=CAc$IP$#MP`>Հӌ5PT{7wkk3Jȥ7"IXOm.V"]O2C$7TτĽWDepq"aLQSC뽆@\=<|ߡ=\QkL{%9n^FQ{sUb"{p*`VEqqH,Shy6ƸXݭglxm+%M5URh"0QsmI/3|%.w9;_LOmI_piʑC)pϹ@+%gƍƛFAT*Q22w>dizOj_HXe%gb6x@{0w&"h!
yqsNa0	]u+{LFڴr-b cc2M=qvn
u?	D`*Ѱ7R
?Ok)a!pu>5p]N5%>Et:Rqܷu{
~e`S|{B-EZ#D_C)ᖖ`Юd{U}.*0Qex((-2MٗRMU“%ډB$SӢJ]I&O5)Yxb6Vv5TNO!vTH)HӔNK1Aq?pox+LL
88m;V\!?K걵osuNX<1sHT㌋
C\!D3UO-uM$#sI/QAk41iyԌLQU
DQ,M$X1``ߓX'kwzU;s'(XZQ=~jԣiNn^_V}[;twvKOEm.]
rOl5Y^EyFeh.-<s]Q2`4͍UC"\rho>Dsz(IpmM63h}ΎG0ņ ""
(#ݗn7~T#8ՀI_gϕ,g9?cnFũ8D`i\%,!WfࡹaU0	[mBj0eq{((T"c$eiJ#r:;
4Z.qH☨\i391A)wT'12
a^?1aJY<FK!Y
+49}y;[8~. ㄨ\lR4T*
T XVr
~!^S:ڇWL~訊[w<*
*Oɵ$O˯ARѧztnNȴ!D??~lc7\4̖x\wovB 2EgPB)010z9ԧ?[b)&<]Q!"8!k[}5)׮]cnnHPwO]9$ezrr5ue{XFeq{N6f,z.Q##!'ۯ~|,V0-ٺ<u{˪RyWBwYUA'jkqF-X!(6n
by(T>f(faa,h6k˽JOp]!뢾}dGJpC*ToIENDB`errbot-6.1.1+ds/docs/_themes/000077500000000000000000000000001355337103200157745ustar00rootroot00000000000000errbot-6.1.1+ds/docs/_themes/LICENSE000066400000000000000000000033751355337103200170110ustar00rootroot00000000000000Copyright (c) 2010 by Armin Ronacher.

Some rights reserved.

Redistribution and use in source and binary forms of the theme, with or
without modification, are permitted provided that the following conditions
are met:

* Redistributions of source code must retain the above copyright
  notice, this list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above
  copyright notice, this list of conditions and the following
  disclaimer in the documentation and/or other materials provided
  with the distribution.

* The names of the contributors may not be used to endorse or
  promote products derived from this software without specific
  prior written permission.

We kindly ask you to only use these themes in an unmodified manner just
for Flask and Flask-related products, not for unrelated projects.  If you
like the visual style and want to use it for your own projects, please
consider making some larger changes to the themes (such as changing
font faces, sizes, colors or margins).

THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
errbot-6.1.1+ds/docs/_themes/README000066400000000000000000000004421355337103200166540ustar00rootroot00000000000000The "err" theme is a heavily modified version of the Flask Sphinx Themes
(https://github.com/mitsuhiko/flask-sphinx-themes).

You're free to modify and use this version (or the original), per the
LICENSE of the original Flask Sphinx Themes license, which is provided
alongside this readme.
errbot-6.1.1+ds/docs/_themes/err/000077500000000000000000000000001355337103200165645ustar00rootroot00000000000000errbot-6.1.1+ds/docs/_themes/err/layout.html000066400000000000000000000076431355337103200210010ustar00rootroot00000000000000{%- extends "basic/layout.html" %}
{%- block doctype %}
    
{% endblock %}
{%- block extrahead %}
    
    
    
    
    
    
    
    
    
   
   
{% endblock %}

{%- block sidebarlogo %}
    
    
{%- endblock %} {%- block relbar1 %}{{ super() }}{% endblock %} {%- block relbar2 %}
{{ super() }}
{%- endblock %} {% block header %} {{ super() }} {% if pagename == 'index' %}
{% endif %} {% endblock %} {%- block footer %} {% if pagename == 'index' %}
{% endif %} {%- endblock %} {%- block content %} {{ super() }} {%- endblock %} errbot-6.1.1+ds/docs/_themes/err/page.html000066400000000000000000000006711355337103200203720ustar00rootroot00000000000000{%- extends "layout.html" %} {% block body %} {{ body }} {% endblock %} errbot-6.1.1+ds/docs/_themes/err/pygments_style.py000066400000000000000000000051331355337103200222260ustar00rootroot00000000000000from pygments.style import Style from pygments.token import Keyword, Name, Comment, String, \ Number, Operator, Generic, Whitespace class ErrStyle(Style): """ A Pygments style based on the "friendly" theme """ background_color = "#ffffcc" default_style = "" styles = { Whitespace: "#3e4349", Comment: "#3f6b5b", # Comment.Preproc: "noitalic #007020", # Comment.Special: "noitalic bg:#fff0f0", Keyword: "bold #f06f00", # Keyword.Pseudo: "nobold", # Keyword.Type: "nobold #902000", Operator: "#3e4349", # Operator.Word: "bold #007020", Name: "#3e4349", Name.Builtin: "#007020", Name.Function: "bold #3e4349", Name.Class: "bold #3e4349", # Name.Namespace: "bold #f07e2a", # Name.Exception: "#007020", Name.Variable: "underline #8a2be2", Name.Constant: "underline #b91f49", # Name.Label: "bold #002070", Name.Entity: "bold #330000", # Name.Attribute: "#4070a0", Name.Tag: "bold #f06f00", Name.Decorator: "bold italic #3e4349", # String: "#3e4349", String: "#9a5151", # String.Doc: "italic #3f65b5", String.Doc: "italic #3f6b5b", # String.Doc: "italic #9a7851", # String.Doc: "italic #9a5151", # String.Interpol: "italic #70a0d0", # String.Escape: "bold #4070a0", # String.Regex: "#235388", # String.Symbol: "#517918", # String.Other: "#c65d09", Number: "underline #9a5151", Generic: "#3e4349", Generic.Heading: "bold #1014ad", Generic.Subheading: "bold #1014ad", Generic.Deleted: "bg:#c8f2ea #2020ff", Generic.Inserted: "#3e4349", # Generic.Error: "#FF0000", # Generic.Emph: "italic", # Generic.Strong: "bold", # Generic.Prompt: "bold #c65d09", # Generic.Output: "#888", # Generic.Traceback: "#04D", # Error: "border:#FF0000" } errbot-6.1.1+ds/docs/_themes/err/relations.html000066400000000000000000000014301355337103200214500ustar00rootroot00000000000000

Related Topics

errbot-6.1.1+ds/docs/_themes/err/static/000077500000000000000000000000001355337103200200535ustar00rootroot00000000000000errbot-6.1.1+ds/docs/_themes/err/static/err.css_t000066400000000000000000000272071355337103200217100ustar00rootroot00000000000000/* * :copyright: Copyright 2010 by Armin Ronacher. * :license: Flask Design License, see LICENSE for details. */ {% set page_width = '940px' %} {% set sidebar_width = '220px' %} {% set normal_font = "'Open Sans', sans-serif" %} {% set code_font = "'Droid Sans Mono', monospace" %} {% set normal_link_color = "#f07e2a" %} {% set normal_link_hover_color = "#f07e2a" %} {% set highlight_color = "#f07e2a" %} /* 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; */ @import url("basic.css"); @import url(//fonts.googleapis.com/css?family=Open+Sans|Droid+Sans+Mono); /* -- page layout ----------------------------------------------------------- */ body { font-family: {{ normal_font }}; font-size: 17px; background-color: white; color: #000; margin: 0; padding: 0; } div.document { width: {{ page_width }}; margin: 30px auto 0 auto; } div.documentwrapper { float: left; width: 100%; } div.bodywrapper { margin: 0 0 0 {{ sidebar_width }}; } div.sphinxsidebar { width: {{ sidebar_width }}; } hr { border: 1px solid #B1B4B6; } div.body { background-color: #ffffff; color: #3E4349; padding: 0 30px 0 30px; } img.floatingflask { padding: 0 0 10px 10px; float: right; } div.footer { width: {{ page_width }}; margin: 20px auto 30px auto; font-size: 14px; color: #888; text-align: right; } div.footer a { color: #888; } div.related { display: none; } div.sphinxsidebar a { color: #444; text-decoration: none; border-bottom: 1px dotted #999; } div.sphinxsidebar a:hover { border-bottom: 1px solid #999; } div.sphinxsidebar { font-size: 14px; line-height: 1.5; } div.sphinxsidebar ul { list-style: square inside none; } div.sphinxsidebarwrapper { padding: 0 10px; } div.sphinxsidebarwrapper p.logo { padding: 0 0 20px 0; margin: 0; text-align: center; } div.sphinxsidebarwrapper img.logo { margin-bottom: 10px; } div.sphinxsidebar a.logo { border-bottom: none; } div.sphinxsidebarwrapper .ghbuttons { padding: 5px 0 10px 0; margin: 0; } div.sphinxsidebar h3, div.sphinxsidebar h4 { font-family: {{ normal_font }}; color: #444; font-size: 24px; font-weight: normal; margin: 0 0 5px 0; padding: 0; } div.sphinxsidebar h4 { font-size: 20px; } div.sphinxsidebar h3 a { color: #444; } div.sphinxsidebar p.logo a, div.sphinxsidebar h3 a, div.sphinxsidebar p.logo a:hover, div.sphinxsidebar h3 a:hover { border: none; } div.sphinxsidebar p { color: #555; margin: 10px 0; } div.sphinxsidebar ul { margin: 10px 0; padding: 0; color: #000; } div.sphinxsidebar input { border: 1px solid #ccc; font-family: {{ normal_font }}; font-size: 1em; } /* -- body styles ----------------------------------------------------------- */ a { color: {{ normal_link_color }}; text-decoration: underline; } a:hover { color: {{ normal_link_hover_color }}; text-decoration: underline; } div.body h1, div.body h2, div.body h3, div.body h4, div.body h5, div.body h6 { font-family: {{ normal_font }}; font-weight: normal; margin: 30px 0px 10px 0px; padding: 0; } div.body h1 { margin-top: 0; padding-top: 0; font-size: 220%; font-weight: bold; } div.body h2 { font-size: 180%; font-weight: bold; } div.body h3 { font-size: 150%; } div.body h4 { font-size: 130%; } div.body h5 { font-size: 100%; } div.body h6 { font-size: 100%; } a.headerlink { color: {{ normal_link_color }}; padding: 0 4px; text-decoration: none; } a.headerlink:hover { color: {{ normal_link_color }}; background: #eaeaea; } div.body p, div.body dd, div.body li { line-height: 1.4em; } div.admonition { /*background-color: #eee;*/ background-color: #e0e0e0; margin: 20px -30px; padding: 10px 30px; border: 1px solid #ccc; } div.admonition tt.xref, div.admonition a tt { border-bottom: 1px solid #fafafa; } dd div.admonition { margin-left: -60px; padding-left: 60px; } div.admonition p.admonition-title { font-family: {{ normal_font }}; font-weight: normal; font-size: 24px; margin: 0 0 10px 0; padding: 0; line-height: 1; } div.admonition p.last { margin-bottom: 0; } div.highlight { background-color: white; } dt:target, .highlight { background: #FAF3E8; } div.note { background-color: #eee; border: 1px solid #ccc; } div.seealso { background-color: #ffc; border: 1px solid #ff6; } div.topic { background-color: #eee; } p.admonition-title { display: inline; } p.admonition-title:after { content: ":"; } pre, tt { font-family: {{ code_font }}; font-size: 0.9em; } img.screenshot { } tt.descname, tt.descclassname { font-size: 0.95em; } tt.descname { padding-right: 0.08em; } img.screenshot { -moz-box-shadow: 2px 2px 4px #eee; -webkit-box-shadow: 2px 2px 4px #eee; box-shadow: 2px 2px 4px #eee; } table.docutils { border: 1px solid #888; -moz-box-shadow: 2px 2px 4px #eee; -webkit-box-shadow: 2px 2px 4px #eee; box-shadow: 2px 2px 4px #eee; } table.docutils td, table.docutils th { border: 1px solid #888; padding: 0.25em 0.7em; } table.field-list, table.footnote { border: none; -moz-box-shadow: none; -webkit-box-shadow: none; box-shadow: none; } table.footnote { margin: 15px 0; width: 100%; border: 1px solid #eee; background: #fdfdfd; font-size: 0.9em; } table.footnote + table.footnote { margin-top: -15px; border-top: none; } table.field-list th { padding: 0 0.8em 0 0; } table.field-list td { padding: 0; } table.footnote td.label { width: 0px; padding: 0.3em 0 0.3em 0.5em; } table.footnote td { padding: 0.3em 0.5em; } dl { margin: 0; padding: 0; } dl dd { margin-left: 30px; } blockquote { margin: 0 0 0 30px; padding: 0; } ul, ol { margin: 10px 0 10px 30px; padding: 0; } pre { background: #eee; padding: 7px 30px; margin: 15px -30px; line-height: 1.3em; } dl pre, blockquote pre, li pre { margin-left: -60px; padding-left: 60px; } dl dl pre { margin-left: -90px; padding-left: 90px; } tt { background-color: #ecf0f3; color: #222; /* padding: 1px 2px; */ } tt.xref, a tt { background-color: #FBFBFB; border-bottom: 1px solid white; } a.reference { text-decoration: none; border-bottom: 1px dotted {{ normal_link_color }}; } a.reference:hover { border-bottom: 1px solid {{ normal_link_hover_color }}; } a.footnote-reference { text-decoration: none; font-size: 0.7em; vertical-align: top; border-bottom: 1px dotted #004B6B; } a.footnote-reference:hover { border-bottom: 1px solid #6D4100; } a:hover tt { background: #EEE; } div.screenshots { text-align: center; } div.screenshots a { padding: 5px; border: none; } @media screen and (max-width: 870px) { div.sphinxsidebar { display: none; } div.document { width: 100%; } div.documentwrapper { margin-left: 0; margin-top: 0; margin-right: 0; margin-bottom: 0; } div.bodywrapper { margin-top: 0; margin-right: 0; margin-bottom: 0; margin-left: 0; } ul { margin-left: 0; } .document { width: auto; } .footer { width: auto; } .bodywrapper { margin: 0; } .footer { width: auto; } .github { display: none; } html body div.indexwrapper div.related ul li.right { padding: 10px 0 20px; } } @media screen and (max-width: 875px) { body { margin: 0; padding: 20px 30px; } ul { list-style-position:inside; } div.sphinxsidebarwrapper img.logo { display: none; } div.documentwrapper { float: none; background: white; } div.sphinxsidebar { display: block; float: none; width: 102.5%; margin: 50px -30px -100px -30px; padding: 20px 24px 80px; background: #333; color: white; } div.relbar2 div.related { padding-top: 50px; padding-bottom: 20px; margin-bottom: 100px; color: white; } div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, div.sphinxsidebar h3 a { color: white; } div.sphinxsidebar ul { color: #f2f2f2; } div.sphinxsidebar a { color: #f2f2f2; } div.sphinxsidebar p.logo { display: none; } div.document { width: 100%; margin: 0; } div.related { display: block; margin: 0; padding: 10px 0 20px 0; } div.related ul, div.related ul li { margin: 0; padding: 0; } div.footer { display: none; } div.bodywrapper { margin: 0; } div.body { min-height: 0; padding: 0; } .rtd_doc_footer { display: none; } .document { width: auto; } .footer { width: auto; } .footer { width: auto; } .github { display: none; } } @media screen and (max-width: 400px) { div.screenshots img { width: 100%; } } /* scrollbars */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-button:start:decrement, ::-webkit-scrollbar-button:end:increment { display: block; height: 10px; } ::-webkit-scrollbar-button:vertical:increment { background-color: #fff; } ::-webkit-scrollbar-track-piece { background-color: #eee; -webkit-border-radius: 3px; } ::-webkit-scrollbar-thumb:vertical { height: 50px; background-color: #ccc; -webkit-border-radius: 3px; } ::-webkit-scrollbar-thumb:horizontal { width: 50px; background-color: #ccc; -webkit-border-radius: 3px; } /* misc. */ .revsys-inline { display: none!important; } body #prev_next_links { list-style-type: none; margin: 40px 0 20px 0; padding: 0; text-align: center; } /* -- Gitter sidecar button ------------------------------------------------- */ .gitter-open-chat-button { background-color: {{ highlight_color }} !important; } /* -- Read the Docs version selector ---------------------------------------- */ .rst-versions .rst-current-version { color: #fff !important; background: {{ highlight_color }} !important; } .rst-versions.rst-badge.shift-up .rst-current-version { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } .rst-versions.rst-badge.shift-up .rst-current-version .fa-book { float: none !important; } .rst-versions .rst-other-versions hr { border-color: #fafafa !important; } .rst-versions.rst-badge .rst-current-version { color: #fff !important; background: {{ highlight_color }} !important; position: fixed !important; right: 35px !important; top: 0 !important; padding: 1px 15px !important; border-bottom-left-radius: 0.5em; border-bottom-right-radius: 0.5em; } .rst-versions .rst-other-versions { color: #fff !important; background: {{ highlight_color }} !important; position: fixed !important; right: 35px !important; top: 30px !important; border-top-left-radius: 0.5em; border-bottom-left-radius: 0.5em; border-bottom-right-radius: 0.5em; } .rst-versions a { text-decoration: underline; color: #fff !important; } .rst-versions .rst-other-versions dl dt { text-decoration: underline; font-style: italic; } .rst-versions .rst-other-versions a { text-decoration: underline; } .rst-versions .rst-other-versions dl dd a { text-decoration: none; } errbot-6.1.1+ds/docs/_themes/err/theme.conf000066400000000000000000000001261355337103200205340ustar00rootroot00000000000000[theme] inherit = basic stylesheet = err.css pygments_style = pygments_style.ErrStyle errbot-6.1.1+ds/docs/changes.rst000066400000000000000000000000341355337103200165070ustar00rootroot00000000000000.. include:: ../CHANGES.rst errbot-6.1.1+ds/docs/code_examples/000077500000000000000000000000001355337103200171605ustar00rootroot00000000000000errbot-6.1.1+ds/docs/code_examples/supervisord.conf000066400000000000000000000004561355337103200224210ustar00rootroot00000000000000[program:errbot] command = /path/to/errbot/virtualenv/bin/errbot --config /path/to/errbot/config.py user = errbot stdout_logfile = /var/log/supervisor/errbot.log stderr_logfile = NONE redirect_stderr = true directory = /path/to/errbot/ startsecs = 3 stopsignal = INT environment = LC_ALL="en_US.UTF-8" errbot-6.1.1+ds/docs/code_examples/systemd.service000066400000000000000000000006211355337103200222310ustar00rootroot00000000000000[Unit] Description=Start Errbot chatbot After=network.service [Service] Environment="LC_ALL=en_US.UTF-8" Environment="PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/path/to/errbot/virtualenv/bin" ExecStart=/path/to/errbot/virtualenv/bin/errbot --config /path/to/errbot/config.py WorkingDirectory=/path/to/errbot/ User=errbot Restart=always KillSignal=SIGINT [Install] WantedBy=multi-user.target errbot-6.1.1+ds/docs/conf.py000066400000000000000000000237531355337103200156610ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Errbot documentation build configuration file, created by # sphinx-quickstart on Fri Sep 13 17:24:59 2013. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import subprocess, sys, os sys.path.append(os.path.abspath('_themes')) sys.path.append(os.path.abspath('_themes/err')) sys.path.append(os.path.abspath('../')) __import__('errbot.config-template') sys.modules['config'] = sys.modules['errbot.config-template'] from errbot.version import VERSION # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx_autodoc_annotation', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'Err' copyright = '2013, Guillaume Binet, Tali Davidovich Petrover and Nick Groenen' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = VERSION # The full version, including alpha/beta/rc tags. release = VERSION # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [ '_build', 'error_pages/*', 'errbot.backends.campfire.rst', # Broken on Python 3 'errbot.backends.graphic.rst', # Quite a pain to build, not worth it 'errbot.backends.tox.rst', # Also quite a pain to build at this stage '_gh-pages', ] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. #pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # -- Autodoc configuration ----------------------------------------------------- def autodoc_skip_member(app, what, name, obj, skip, options): if name == "__init__": return False return skip # -- Apidoc -------------------------------------------------------------------- def run_apidoc(_): subprocess.check_call("sphinx-apidoc --separate -f -o . ../errbot", shell=True) # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'err' # Add any paths that contain custom themes here, relative to this directory. html_theme_path = ['_themes'] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # # Note: Best leave this off until Sphinx issue #1496 gets resolved # (https://bitbucket.org/birkenfeld/sphinx/issue/1496/smartypants-should-not-convert-text-in-a) html_use_smartypants = False # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. html_domain_indices = True # If false, no index is generated. html_use_index = True # If true, the index is split into individual pages for each letter. html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'Errdoc' # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'Err.tex', 'Err Documentation', 'Guillaume Binet, Tali Davidovich Petrover and Nick Groenen', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'err', 'Err Documentation', ['Guillaume Binet, Tali Davidovich Petrover and Nick Groenen'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'Err', 'Err Documentation', 'Guillaume Binet, Tali Davidovich Petrover and Nick Groenen', 'Err', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False # -- Options for Epub output --------------------------------------------------- # Bibliographic Dublin Core info. epub_title = 'Err' epub_author = 'Guillaume Binet, Tali Davidovich Petrover and Nick Groenen' epub_publisher = 'Guillaume Binet, Tali Davidovich Petrover and Nick Groenen' epub_copyright = '2013, Guillaume Binet, Tali Davidovich Petrover and Nick Groenen' # The language of the text. It defaults to the language option # or en if the language is not set. #epub_language = '' # The scheme of the identifier. Typical schemes are ISBN or URL. #epub_scheme = '' # The unique identifier of the text. This can be a ISBN number # or the project homepage. #epub_identifier = '' # A unique identification for the text. #epub_uid = '' # A tuple containing the cover image and cover page html template filenames. #epub_cover = () # A sequence of (type, uri, title) tuples for the guide element of content.opf. #epub_guide = () # HTML files that should be inserted before the pages created by sphinx. # The format is a list of tuples containing the path and title. #epub_pre_files = [] # HTML files shat should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. #epub_post_files = [] # A list of files that should not be packed into the epub file. #epub_exclude_files = [] # The depth of the table of contents in toc.ncx. #epub_tocdepth = 3 # Allow duplicate toc entries. #epub_tocdup = True # Fix unsupported image types using the PIL. #epub_fix_images = False # Scale large images. #epub_max_image_width = 0 # If 'no', URL addresses will not be shown. #epub_show_urls = 'inline' # If false, no index is generated. #epub_use_index = True # -- Misc options ------------------------------------------------------------- intersphinx_mapping = {'http://docs.python.org/': None} def setup(app): app.connect("autodoc-skip-member", autodoc_skip_member) app.connect("builder-inited", run_apidoc) errbot-6.1.1+ds/docs/contributing.rst000066400000000000000000000076031355337103200176170ustar00rootroot00000000000000Contributing ============ If you would like to contribute to the project, please do not hesitate to get involved! Here you can find how best to get started. Contributing to Errbot itself ----------------------------- Clone Errbot ~~~~~~~~~~~~ All development on Errbot happens on GitHub_. If you'd like to get involved, just fork_ the repository and make changes in your own repo. When you are satisfied with your changes, just open a `pull request`_ with us and we'll get it reviewed as soon as we can! Depending on our thoughts, we might decide to merge it in right away, or we may ask you to change certain parts before we will accept the change. Run Errbot from source ^^^^^^^^^^^^^^^^^^^^^^ Clone you github fork repo locally and install errbot in development mode from the root of the repo with:: pip install -e . From there, anytime you execute `errbot` it will run from the checked out version of Errbot with all your local changes taken into account. Preparing your pull request ^^^^^^^^^^^^^^^^^^^^^^^^^^^ In order to make the process easy for everyone involved, please follow these guidelines as you open a pull request. * Make your changes on a separate branch_, preferably giving it a descriptive name. * Split your work up into smaller commits if possible, while making sure each commit can still function on its own. Do not commit work-in-progress code - commit it once it's working. * Run tox before opening your pull request, and make sure all tests pass. You can install tox with :command:`pip install tox` * If you can, please add tests for your code. We know large parts of our codebase are missing tests, so we won't reject your code if it lacks tests, though. Contributing documentation & making changes to the website ---------------------------------------------------------- `errbot.io `_ is created using Sphinx_, which also doubles as a generator for our (API) documentation. The code for it is in the same repository as Errbot itself, inside the docs_ folder. To make changes to the documentation or the website, you can build the HTML locally as follows:: # Change directory into the docs folder cd docs/ # Install the required extra dependencies pip install -r requirements.txt # Generate the static HTML make html # Then, open the generated _build/html/index.html in a browser To submit your changes back to us, please make your change in a separate branch as described in the previous section, then open a pull request with us. .. note:: You must do this with Python 3, Python 2 is unsupported. Issues and feature requests =========================== Please report issues or feature requests on the `issue tracker`_ on GitHub. When reporting issues, please be as specific as possible. Include things such as your Python version, platform, debug logs, and a description of what is happening. If you can tell us how to reproduce the issue ourselves, this makes it a lot easier for us to figure out what is going on, as well. Getting help ============ The best place to get help if you get stuck with anything is to ask for advice on our Gitter_ chat room. If nobody is around to help you, opening an issue on the `issue tracker`_ is your next best option. If you have a code-related question concerning (plugin) development it's best to ask your question on Stack Overflow, `tagged errbot `_. .. _GitHub: https://github.com/errbotio/errbot .. _fork: https://github.com/errbotio/errbot/fork .. _`pull request`: https://help.github.com/articles/using-pull-requests .. _branch: http://git-scm.com/book/en/Git-Branching .. _Sphinx: http://sphinx-doc.org/ .. _docs: https://github.com/errbotio/errbot/tree/master/docs/ .. _repos.py: https://github.com/errbotio/errbot/blob/master/errbot/repos.py .. _`issue tracker`: https://github.com/errbotio/errbot/issues/ .. _Gitter: https://gitter.im/errbotio/errbot errbot-6.1.1+ds/docs/error_pages/000077500000000000000000000000001355337103200166605ustar00rootroot00000000000000errbot-6.1.1+ds/docs/error_pages/403.rst000066400000000000000000000000231355337103200177130ustar00rootroot00000000000000403 === Forbidden errbot-6.1.1+ds/docs/error_pages/404.rst000066400000000000000000000000221355337103200177130ustar00rootroot00000000000000404 === Not Founderrbot-6.1.1+ds/docs/error_pages/500.rst000066400000000000000000000000371355337103200177160ustar00rootroot00000000000000500 === Internal Server Error errbot-6.1.1+ds/docs/features.rst000066400000000000000000000106161355337103200167240ustar00rootroot00000000000000Multiple server backends ^^^^^^^^^^^^^^^^^^^^^^^^ Errbot has support for a number of different networks and is architectured in a way that makes it easy to write new backends in order to support more. Currently, the following networks are supported: * XMPP *(Any standards-compliant XMPP/Jabber server should work - Google Talk/Hangouts included)* * Hipchat_ * IRC * Slack_ * Telegram_ * `Bot Framework`_ (maintained `separately `__) * CampFire_ (maintained `separately `__) * `Cisco Webex Teams`_ (maintained `separately `__) * Discord_ (maintained `separately `__) * Gitter_ (maintained `separately `__) * Matrix_ (maintained `separately `__) * Mattermost_ (maintained `separately `__) * Skype_ (maintained `separately `__) * Tox_ (maintained `separately `__) * VK_ (maintained `separately `__) * Zulip_ (maintained `separately `__) Core features ^^^^^^^^^^^^^ * Multi User Chatroom (MUC) support * A dynamic plugin architecture: Bot admins can install/uninstall/update/enable/disable plugins dynamically just by chatting with the bot * Advanced security/access control features (see below) * A `!help` command that dynamically generates documentation for commands using the docstrings in the plugin source code * A per-user command history system where users can recall previous commands * The ability to proxy and route one-to-one messages to MUC so it can enable simpler XMPP notifiers to be MUC compatible (for example the Jira XMPP notifier) Built-in administration and security ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * Can be setup so a restricted list of people have administrative rights * Fine-grained :ref:`access controls ` may be defined which allow all or just specific commands to be limited to specific users and/or rooms * Plugins may be hosted publicly or privately and dynamically installed (by admins) via their Git url * Plugins can be configured directly from chat (no need to change setup files for every plugin) * Configs can be exported and imported again with two commands (!export and !import respectively) * Technical logs can be logged to file, inspected from the chat or optionally :doc:`logged to Sentry ` Extensive plugin framework ^^^^^^^^^^^^^^^^^^^^^^^^^^ * Hooks and callbacks for various types of events, such as :func:`~errbot.botplugin.BotPlugin.callback_connect` for when the bot has connected or :func:`~errbot.botplugin.BotPlugin.callback_message` for when a message is received. * Local text and graphical consoles for easy testing and development * Plugins get out of the box support for subcommands * We provide an automatic persistence store per plugin * There's really simple webhooks integration * As well as a polling framework for plugins * An easy configuration framework * A test backend for unittests for plugins which can make assertions about issued commands and their responses * And a templating framework to display fancy HTML messages. Automatic conversion from HTML to plaintext when the backend doesn't support HTML means you don't have to make separate text and HTML versions of your command output yourself .. _Bot Framework: https://botframework.com/ .. _Campfire: https://campfirenow.com/ .. _Cisco Spark: https://www.ciscospark.com/ .. _Discord: https://www.discordapp.com/ .. _Gitter: http://gitter.im/ .. _Hipchat: https://www.hipchat.com/ .. _Matrix: https://matrix.org/ .. _Mattermost: https://about.mattermost.com/ .. _Skype: http://www.skype.com/en/ .. _Slack: http://slack.com/ .. _Telegram: https://telegram.org/ .. _Tox: https://tox.im/ .. _VK: https://vk.com/ .. _Zulip: https://zulipchat.com/ .. _`logged to Sentry`: https://github.com/errbotio/errbot/wiki/Logging-with-Sentry .. _irc: https://pypi.python.org/pypi/irc/ .. _jabberbot: http://thp.io/2007/python-jabberbot/ .. _jinja2: http://jinja.pocoo.org/ .. _six: https://pypi.python.org/pypi/six/ .. _sleekxmpp: http://sleekxmpp.com/ errbot-6.1.1+ds/docs/gpl-3.0.txt000066400000000000000000001045131355337103200161750ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . errbot-6.1.1+ds/docs/imgs/000077500000000000000000000000001355337103200153075ustar00rootroot00000000000000errbot-6.1.1+ds/docs/imgs/IRC.png000066400000000000000000006002441355337103200164400ustar00rootroot00000000000000PNG  IHDR^'M IDATxxTE׀- -@PI$`AP"]4Q, (IԠ#" H B DuwgS7u7$Ay)yg9;stʽd&:kgU+TP:pPպMnya{3~ ,k*r7Q2l44ul\eނ6hs[g_UUW0N8vP&UgUnڣQnm?T#)'勓+o]]H> }@}> J#GăghÝ|2b2)^t87߸[f̗.z+g) 4:ǷËE/BbQz\j7ۻ>[QrrK5gwf hE'_8|zmiT2L.s>lꉝT2( ! B@0m'̛^$Ŝdǎ WYu5:?oqYGHLQv=o7UbPyKpc1ָ) qsMhTh=]IV3&3iPJe,)`ה$|E"6i],ok mz1wgZ59"HMǐA&UyA.d@0m+ԥ:>PN.( &3;6׉YtT^_V,Z;}F`@e)D ! B@;{vW  o5]&s]?5Z} jT7=fKڪǂ@1j*((P-9B-UѢ"=i쪩ƭ:C~]w/V:}}V94^PJMW>v(2*ת!M־ziMe8Ƭ\nW1YPgרW|ݔC#o)*c@UKkr7ilkDjU);M!iFzW9䋷S\VxIjBMI=6cZZVCmUn*lriz{@iwSBy}m?PVC2k{T`@U/5cMR"lH> }5LJyw{4CL[}sIШǙZ{tT}ZNccܻ5j)aPjSv%U K(Sri7]}R1O5©U|q=/g:|3o낯lN_oϔn↏Y|&$e)T˩{҉C8.[siͪINzjFYGCiHϱ{Pve(9daǔOWDZ2g(B@! *e+3ǹ}73xcZ-9Nig ԕ:i݂f+(jbnf=foy #z){Up%sӉ&=ۚјZ(reA EeTYΏFkV\I=Af4( ;V :^i[.$⹁Syӎ?ov>kM^@cGvѫ5-n:-TFʦ\J ! B@r%Puizpὑ%%n'iQmrhma E )QQLx6=i"8q0#IScIPTuʾ jΙ /K Uщ*d^#tfM.sVm 2f$CH^fػt.{i cߺQ˂mն$ 2ˤik\ ,5'JaTQ-.\ڜOYˑ!B@! BN `>gF"=09סEg?'M3kh\[2ajf >t.gs0ڬ|>pODn~Xu;/oSROOnOMz0sMDhmOմt NL->4!5,^='n*A΃SeVφ8jof4v)R8kH5G[=i* f"G=Cl̜cHZ,T)6l=BV5dVWB$I! B@A'|} j뼺Cm?19`P"㸰;~y\x( 귴Ѭ( .gץ=H?Wߣݚ5\N̯_Ѳc40}4:Zrpeya{~c|Ɖ?R/;G?|iLJV0#rX("i 8̦ߤ]4!=KK{WBGqd&3 ]ɉbːvРiLYz_X N^w 8v `)|.,=C*%WXFwù%? ʾ[!EB@! @4͙ $64̱K~cNu'(! B@!$`Şd0ZOa[902/?62p}\߹Kt惯'XI$JB@! 4wa|ߏJ܏6C~){i=BB@! B ՎWB@! B@"T"JB@! B@qnB@! B@8^%yB@! @@[&E*wPKܡ(m)TbJ8! B@!PvZv2k5u$j츹 "(h uu|y?gjL/h6_9|ן/tNA=jok"ɿy|Em;NKb]E(,7457dpik<.'R(B@! LLO[/t*-? wɈxeT|"'n1G:\D?WR@:ht8Vo_X=.]-ʰb5ZtDdMEzEFNSA>۲m襾qM\UK ;+Ye^jȎD(SI4oMT jpYETz6U/W,ɗ:nLV_Wn-y?PWRm4ʶ 51]ZG@>Uc2TU=ՏWշ;YcA~OJjPͮ]Ԙ)/]SZ{3URL{Aj M\_:iʳE℅H> }.LJջTPPZ4:ۆjآ ̾ZCUA_ ݢeSroJJ[ ;r;53p}$ji1:jtUggO(w*rnVyyj[#yhj!p[P:C1@'=F-~"IqzbNI~{ *Z5ۣ:n*X7}=5p L-*)jjXI]b7tP9qjsg6ϰmf畻5 TUaK/E]hfXof[Ar\fLH}Tŭ%af ;9uͷW.,[+8x96-Bt43W\qK1{,ZzӧMk`&:lc*i܇F{8ec1mA3Dr"Kb)ݤHǵ7OtC'kr#x?!7|YT8VĈz7It.r%6Jr5m7LлO# Οl|l2v}Ͱf#_Q֔*N/k܈J?rC cTFeĠ4h3 .> pѣϹf^M1иdKuG}6)\n`YB̈'"d7?Z̺o{d6wm!v؞z4*48'=K{мְxf(UNcHwEc[?>1U_mNs\s'bȰZݨ\ g[ JU}%J͔u.WouŻkր7F9.u KX$4H[z8(0}Olq+G}[܏#8 oɁ1orMfA|s&F23Yo&XB@! w=7B@! B@:b޺H))B@! 2!a! B@! @ǫH! B@! 9qiHX! B@!P"R! B@!`N@/sB@! B@Tq*B@! B@Ѓb\ V<+yo86+Ӷ>Τul+-atsń'#%7xY$ B@! з3>mЩ8p'Lp]&N7.rzVg/|EOx%,FcvxhZR1¾ +6Y_|\1NGZSuB@! B@L@y?fm'#eEko-bDN@Lj!]%p.|Ёƕv7^aHNj;K陊U:bѥ}K8_#[+xB@! BEt\>1q- J@.6-s[caEm@l1smכ>rQBM+^Kfe:VΙvySTqBB"9tz!+tS&>{ūep1x?!7Vwss%,B@! OXK4{# 8KJ6.JǹOҢ/YAhma EdTj{63|hDOZN9LH-6h2bPM-$?7BeZufM.sVm kB@! GyD!c7B8Avι-:Q= du^]|g|!L iN`{y9-z91 cꗛl Wr~K͊ qv]:/c4ΚX9J_Xnb/̈?]B@! h^]D*B@! (on5,oE"O! B@!pnmyB@! 8^ (B@! Bn% Ro! B@! nqnjQ$B@! J@B@! B@6x6"=uzb,ؗ}mz2[ }҅W=Ri|]xAжU~iq5\6A=O/k;9>YZ(c;`|lһoO_meη1f|;ۊ?ZWS;;XZQZv2k5<;n.+2'X8}(Xi=>WgMJs&AAMTteՁ|Bys~\w}Ɍς5-BZh¾))-)R:wÐ:AbsyB^-b tx=ZP7'Fpͷ;ZD038ZN|q5}Ѭ8U=?XNQsq sSm,_Ιle^ײtne|ïO:[y)6ao=OMY!1<h2VNA XBƢ}u IDATVm^z>΋ݻkla&,痙#h7sWwc:T[uIiNkxp mOFL&8ŋ.9=q+׌4pp8^ 8KX>^,Z%nw} /ʼ\ 炏q:"r&=hh銎83:\jADTQq}9߶l]y -߼.pIWu8=H߾ 8S%z]X4?6r:Up+~,Np<(NrGN GyHeFOm}Po8*1ۖ >] \'ŴB~DJ1;}lk݃ˍl?IiVp^ﯤ?eg/޿Ƒ;d伟y0hU쏈Gq;Ş^ZNjˉ)7\ m0S[ڢ緦#3|hs?5̉#9cݨskFϑҬ:rHkq4MgSL(.oůi2jbW үs06xL]#;}xL+M#q^,av>Ӆpb 6;yu2ħg&jpi#dC.r/0֭a}nzp,?uWӭe]5nmR 6xOg\Ƅ3(Xp}wߚyo3/~OHSjNx+pNȈ}Zm?5256fֺٺzUQ%01Z<.{ɪ}B?k?O' KN9Cfk8{=F\͟KEӨ瀛zݙMEs׈4.bDE_آ|1j4z9M<] 讵lG΃vߡq @äY=&vrC<ĺȫ\0ѧɜZHW \>'Z~}ޭҹr?ZvA:ob-y|rO&t.A,S@Nz8+F&l2F\0SVEu1 wy> "*YYc}츯kO c?fx?9)۷H|F7x;"@a<|y>jO]U0fv dmiǣuɍc[^-CsIEfHX4-w!+/<3aw+TMO WyuD*G-S#`¶hZtz}(Mdkg>>-/L| V8B=ΰ.D]娥(bW ;yqsz.8WRi-??˘o3ah ƽ՝cs|sgӶZt 0>bat}o<é?x=Kf%5J^Z/كv5cxhm)*l}-\83~ {ez\L>cg8z /Emp,oi=]P,}7M(qhqbV"Wx }v{Bfx+rnQ\10m" ՕeWjݗiu4=ƽ_歌,g屾jSv%U K,św\ǀ:и.+ IIֹc}?_-rg)=N`~~tiߒΟȜK( mĮB@aXL}cP[(w:'[1p3W0^Ǻ/2˽0k<|%_-ek?K,/֜{p#_4{ (T%4,飱phZe/i~Pv+<UJ_ 8կ&~N_r+۲t\>1q-Xe\Yu% 7-MK4XAXU?).Ȉ9cMB9r(9:VΙvySTq҂bɡ 9]E,2i+^h\- gԨ 9աv#ggVq}{q1}Z0Iuϙ|G29.^I܊w$ KvĜ_~˶X.oM3קI7I g;NK48iSLʱ@آY0F̔*568@ZRZk[l*,N$r-!/cH4nn0#z"ݠMG\pIZW5Lq $4mN0~h)|yTԆ1)J_R,»LvDtz{|k7em'e;oےI%U&K@g_{[c8pؕxq&mven^m glv$+gQwF\ qw2? 6DB6Dڇy(8!WE>Wr -&`q|jf .:IΜhmJLіǛ[iI2)al06ؘt;V uO3m^.73`ҭt$-m .N-: hJ"tfBȾ뉞P8rӑ$ZkL&.#oЂԩ RK DF>6kB-uGwjsDehu4}Ԏ+sIg`g{?aVVКQٻ3K}^طsr*.Z۾%b4Sw:aOq~2)N2!-.n%ϯ~JQ`|VzCVr-/>2E~s [#$ךDZdJ!-ňsTsꎹֹp~u-΃SeVφ8jof4do|ac޲>u;yC89kcHZ,T)6l=BV5dIq8ͨ`8]R eURU"W"Rp2-SBnztŎ*y2Rϡw׮t-߷twimqtDJNj@s-{]ھ8UAAD24{\58eWhlKhlsͫd:KHe.AA+PR9*0O͗iS:5=oE{;dڙǺPY0eд=ko4gvۣ_V]5tzz\*xFuLuw!rXkmqwѵMzɜ?{ϟ`~:iG^Oqm5}u!zf0&Dp58K?o1ǹTAN= }[5}tQuL7;Y*/(/_amHNEyLhv?eύ,)hUj%CAEwwz{9ħL_I珥1844`%~֝J4Dҥ"n(`^|} j뼺Cm?19`y+=@MDž1M\6+G9PPf%MHqut}weKi<~4ߍ&VVl=K6Yg^*bM%m痱&؊'Ւ2O簏ѓ_EqB0slcZ@tq-d s>(j~|\w_6֏v&gm+))5w2j|܄eMk]9-sgNkCeĄW)nE|!ffs׉;3[냩%\#y9!ָy_ıratF?E=\KZcka|(W kp}HFιx+5@u7_ oA!c~ VKbKX2bkU lXNZ3e(wt) Tݕ95RoXV;u,txP_\2-Vݢ켥H7|u3/U+)_'TN !P :~oXUZx''BeŵV GY2ѻl==W-n'ESɇ>̊[smBym%j?@ džt:}ZQy0X٦gu5z\{ܴ%B@!O"p_<:s^ϺEN+םbB@! Z_۴R1! B@! x)-!v! B@!% ׿ibB@! B@);w~Ȫ>/L&eZL|M񲦿:316(YT! B@iv_x%o5 h3DʢyK}5 _̇~&#%7xY$P"C < j$s3_I%0^\D_^-msuw} 'sȷB@! LO[/t*-? wɈxeT|"'n'^pp8^ 8KX>^,Z~k8R1¾ +T\}Vts8kMESqrߟm8r2MN訕[WY"W! B@a3ov :Rv^rx*Gēs;/\7(ju~A @c#H.rۛm̞7b[ JyKpc1ֹ9:fcO~ל4`]@ӑ ORSg2k mz1wgZ597kw3DlQn CU[f=5yud윥|1`޸NԵ7 uǤ0dh:ֳ6+ۦϼ>޳n5lw3흴K߯㳞0|R6wСhɡB@! (O_޳{a)'bH߬xgՓ+sUpsa[;i#7Bh7FE(`iFPZKqd(\%u݌K•'&1JqX6y2T:]1Nt{|Aߧ*!iZkh ucUmBN^ܿ m*G-S#`¶h:wڍ eLjze9ot4>l1yV"LiXe$`C>p^rsx}Lgw a1D! BM\10mL E4+A9h3բ/Ri{{7F-%|b]MyەV3, vDh\yI{'̅tch\i{$ܱ>ZiJ'b0_ p?oka၉+>r'_|y8NÎ)ّtEO4@:{'x}W vv 0v1rKd97R Hm͹i&Ƿ $B@!pGx%af8W60soL\K%),[6-s[cayR@FqǷ]o<ʑG 5xY)CXJ:g}veRNQI &g@)!Cǯr2XJ7eWѸZrlu77 +B>} pxәxL0 u fDwR8 βX䎳6脒Yڶpv/V/gP1'I7I g;vGqr(ɷB@! @(Һ?ʹy=HĒvKqKֺ8yhma EdTj{63|hDOZN9LHKcL&.#oBs#T%MKUf#|5̉;X9r{mwB@0 K ;{~FG6 cF=I₱ 2iZ[ 9VڻD5?[d^ eWx2|.q`<.QS0]YzgNkCeĄW)?E|!ffF,|pnnaɏ'HG@āZ5csm~E^Ɯ~B@! ]rK)_~1E@zr^3ଦϟ_diB@! .Fj#B@! (/o*/M"Zӳ9??FY£Z P ! B@l5_j/B@! m [ odQ!B@! M@B@! B@xȢB! B@<\ɯR>\cj{<޳|! B@$m'Ÿ~y Wql@WFӧm}I%V[V`|/o¯0x1RrcןE%0(=^Z'ëlo<ٲ D8L/yyWZH:sTOJO KDl!( nmTZEDg/X[ieBx ! B@[&og}zSihxp mOFL&8ŋ.9=q+׌>'qpAñj}|;X-qona_TъNp.#{j칯|>o?8O:~ٍ=gE-5zb[@sDUiy o3!BdȡB@! xN7 c>˯΋Z/\x'ٱ/^酫xRtk:2v"r58Bʍ")e`"淵EoMGzg<~jrGr,:7G֌#ӥYulIڑ %8UjpQ$]_m;N-d ,_ `mzɣG8v2֙V|}/~ ,DBTPN ރe:$;nبx. nZ1Kyi):eU1L?Ty,8)\캀Ź*Ƣ-4v7]hYO%ϨE!5¬K<['[mJ6^؟Ρ!Zz 2д|S9b6\T (P5'HZRI<˥ 5싇vy8O6[/4Z[վjY#WjNQs3^OG5Ǐq.+5%d$B,*w^^j>9_ԧ7>pz'v%y+78g3yńG60y>yo.gI{O7Ə}O'0jCSϪ1Yτ6̆<po s^A:I5[VE@D@D@D@DVL$Gr8ލ#>:qo\n՘Xhxq嫘;ć6];;Ns$Ѭ~[E4"]RAi/Wdij)E;-5ht i?|_j[tnth!zjmat)Zl\lmPCxk1錓Xus[IcW{m>38iq`Sώfآ5l;,d\?g<2'MPS"1ʎNv}t?Nodj5eWD@D@D@D@B@g0=56K/ilG1) ~p,8CPS`3#_'t@=(Hҡl3c~p<̚ؿ݌fUA'5{cOK ]s-p V^!N>GRądzGmѸfw8'{6ˬ,"HH@StwJ;Pwf|b/P`K^0fYRTD@D@D@D@D&ejRZʈZʆZ'n58,3X6,z7|!wq;lZ?61Pnf_&6r;)" " " "WЂk V>Mr8Ya0`0wGӥqx VAU^lrr-_|(`LcR鉆Z'_7.1$*;jh{ٝ}]3vZ/}tB;'3ժe)," " " " V@eTw@Q+,%hn FADd{gL^{5sS¹V`m N:7MTh4 ħ||N]-c[9BKD@D@D@D@L^X0Wƿ[^΃.^Z}ơ4Tΰc DRJ?%=NUjma|83ITXsӛє˜>~I5 sC%k ,\CD)._;ym4=%cb-#teΜ8sI,OcΣ'5K?}Çn%:[>K YWQyUw+'XL=X >3gaѨ!E;Wv[\UUb`pdbv{~ 5qGʼnkM%Q+nMz˘<;UwzN˴q!YUɊ;GkW J̆;Tg%_M\>sfΧ;:y_ZNMZDktyyM'kwOfD.E"`g}M@T^9KN%ͻy 2Ə *Y%-Iypx7y:H9Um>c<LZ}aՠQ"!ӆӜ^b>vyXz5ara치n jxM.~R K,d=y^3 KqE}*?^rg-K5~X+_D.+nXv>{_ᴭ&c vܗ~swFGb孩%jʵ}k|F9S4cCZ9JaxegsϜY:e Vr:ŸFyVaGڻb܍-9'I=>~6NeZ* CC+^y4aj?*]U it+^yQ>1L^$wntĂE#ꛮ> Bz*yF-. u92g;cҺAX OY.H)d_<̷yjtlؚ8ehmQ0We_9 DBx?8N?FĹx26>m4*oR+P'4Ƨ}U/s¶ux'\xӋ2Yպ`'tL^=?>r!" " " " w($Gr8ލ#>:qo\n՘Xhxۖh;1y*񡱃 zWoI4_V忻O#.r߅-erOb[{^FאEO/S 7k޽a[6][lP,̰|5v~=|}g 6Tr plњFpoPArE@D@D@D@Dtwl|#b ^4k6NPrq?KC8n`50 68uBԃT.Z6s{? ǣsbv3U5S=U.⇂Dv}1Z9{53; lIug{etڬ xyfuK+jNw&}XY˚鼓&3=p)ړyLa*- `"jFLâU/Bn2Q`/?_A ɅrM_[鰮{o'2}D;=2a۸|4d֢@wuդ5)#" " " " " "PIPH&81{l<Ά0Oa,7pF#$t#Gu%)툀jhFE@D@D@D@D@D$+IiGD@D@D@D@D@,HeFE@D@D@D@D@DpM혎 1s-=N~`MkWtG&&Z6K" " " " "PK-(v bT)W]Њ'g/MEg+X=ޓVazAʉ^˕b1mSߏI'jqܓ~޸/nHH[}[fw_ܞ'vd[j/}t0*D@D@D@D@DO%2x*y4h7f[ "=3e&ù)\+6O{'&Ry*{Zs`[kRfxyxd++ &"Nr..;[9sKD@D@D@D@D!NB@΃.^Z}ơ42ΰc恳E ~[5z ;9 Vpd,oT:GvA߷9#LYgPЧ\uV4o48bmp)&}pn- V-#вM6hV}Ѧ9CXG9`07ھx[-" " " " w.й?SyHgX4jHQF<Ε!VZ\{ԏ%+.KbWѤyƮv;(N]Cxl*yj7&A߂eLѪ;=e8dŝ#ŵ+^%fLƝMEQI* R\>g!tgP6c<LZ}aՠQ"!ӆӜ^b>v9kUly˙?b:̊Vyq6K٦ (k2VZ_رGJL?Dr/}zto7DNYYg+vM|1EfͿ9@\]:ȅ `=<Μñt:9yuN OhR$mv .0v.NqA nc'2xU(g֡!GfϼPYӂ UE5s*M4<(CD&͇{{U}ȻMũS FFVo$-d^' Lg8JeO 1//xKok pm :!N\5Z[վjY#WjNQ#޴{:q>~sdXm 2IO!|hETj4A'4Ƨ}U/s¶đVs/pLrrQeL+Q}KkTuY۩upAO&+U43JN4Hǻsd_\9G޾4 ͢ kh;1y*񡱃 zWoI4_Z&–vO JxZ'KWSO-߁=haAkH"֩͛ͅ5D^Ұ-?}.E- ]t(zmfX}ٺک' PS`32dz}twl|#b ^4k6NPrq?KC8n_50 68u IDATBԃT.Z6s{? ǣsbv3U5S=U.⇂Dv}1Z9{53; lIug{etڬ xyOř5QK+bv\Jd޺>[[M #q< &rAz+upJ -@]8<ߌOd,wI-zշeqh3~ɬEśXT׌ _3v7{iZD@D@D@D@Rl:J[" " " " " "0ԟoN+~^c:1!L>+D zGH'6bFd" " " " "pkdq^D@D@D@D@D@/|e" " " " " F@[,$ |5nMt|X7npCnq:EgZY6]Ib(^f-J" " " " "P-H]mDwSf_C+j0U|wڥMiyz{*PXTwD/UoXrec" Q7WѪ{UJy۪VSgtpEG[uvEu4zoWPj3kGڻS;u_uV{Q]@ݸmmU0ՓQ ۱Uxqj0|m^hUIK|; w.2j`4h7f[ "=3e&ù)\+*;H4BSu(8Zu85˫%#[9_ĭL5L/qsqikq@3: ?`٪dŝ`\ݏA5_Zit=Ƃ-u?>iQD@D@D@D@J`v*Rvtr0ťt;~77(*M_hxۖ4k 89@vrVm5LbfMU{sĽm<}^:$?-V\3(SV[Ϻu+ sco}g6t8>]``4ULmV-#вM6hfyX ͞f+8,(`C!!f&7Ê sxآ4z3r>˴VS-)%}:g*}Ekt8z<3\BlQqWm KxaQX21r=K +VM'ih|ڸ\5Ǧ_wy׊~ޠo2&h՝S2m|{HVM+5HqsW0qgSQt T+j:O87{v>9crrj:CR4fi4qsVLϡ}3& c4M&mnYԑ{#M ـã~+NⱸWm2c%\4ȴADO€A|X~eryǽ#p2Aiɋ'r`Տ\VQsbٽ F|OSʈߖ03T?hiŪ/nSILK֣J Ptb`"G}LdSOj18}[&,~1ZT" " " " " 5 jҭ:ڙ*~mlHS7i)4!1bm$]D@D@D@D@DzjX䊀 V&D@D@D@D@D@Dz \a nPp}\qaAט9N)eޑ/rV/g ,dYȑ5Jʈ)nSY4 ^+eo8Kܭ%p2% j^ =i):+ F;I+,_|(`LQ{8NAϗ2v$|?~Sfګ1εkd N:7MTh4 ħ||N]÷߮_G9 -}1@[Y6:<ݍṳ2Ai#U6qjBѡAhZKa]?srCgX4jHQF<Ε!VZ\{ԏ%+.KbWѤyƮv;(N]Cxl*5]hQ} 1ywD:iCjZI9GkW J̆;TZWӹ|z?ƹCΠyD㗣縖SӁ١H4xBƌIrvlՓ6ǥsnߌ`_`c{cSo/`̨N8ev<0? ?% WLx1~ԧͶVhW>!`} Q-: PWZRc<*v^ %2m95-&cs&[6>yǽC{.Sqάh'/lˁmK>k2VZ_رGJL?Dr/}zto_U*%jlm9~x\'rn|<"j q?PKzZH39zJAWakB]W{lOL!"olýLZƺAr6澹(5fYbe$JJbd^,oxa.V a+f)pӿR.gipҭ&tfGNzPʥCf.AqxtTnF{%[PȮ8Y+Gcxf'a#l2#6h[JkQdp;ȓ}*ά\ێ=p)-ɼu} mI7^՜ǐeߟ&YZ۠J_ek3|2u;/ۣAۿiT>^Si4n8ƭdeLd{꣐O,b1lr)" " " " LZ*ד;DD*^\@a%oeQg;|DV\GjB0FO7_M{;i#dE GD@D@D@D.0]p7LOq+fِ|PzO Wwk{+_5#_gpGY#" " " " U Vê]$UD@D@D@D@D@LN8߮&# ( םxWdL" " " " " w^wɈ܉rƝxWj1&qaAޘփ<\c;8y X"L1ٵ" " " " "p@TMlªdzJۃ58wk#9$ gɂe컚W񞴔w|ʕM#4;r*ƴUK|9\I~L/y.yȰaoɶ$/_/\FHϘu!kFqɿ" " " " "pt]Oep7_z3ѭ~@2^ܔpXMy)<Z=-95_)_S3OZb< J] '9͚ʙyq('n y;cp8˭wq7kҮ|m/C,]:Cqid&aǎg J0,Fd'gUN)Vpd,_ӹqoۅ=O樗p2faҞAArz֭[IH@K{;yZ >b\"8 -`tns/n5^ 7LRF\t𦋇=˺P p호b>Ms|1sdmm_m҆CB Mh1dx" " " " "`A@ܟ}Ekt8z<3\BlQqWm KxaQX21r=K +VM'ih|ڸ\5Ǧ_Ӆ Ioз`~GNϩs6>=$svlɸ(J:IA|5˧sa=D; GL1~9zk95k:qp-GN_8s|uT481d zmhӛf_ ]_+{sqiE+7D@D@D@D@DEXp}U=1.dZ}aՠQ" a yo,2fjst<^\M{8S˴^œmr9G.e9^{wO_Jc, $bp)Z~pz'v%:{1޶0uz#0^Ϛ#C ɑ޸Z]1-n=fKU%$i,n5,Hp=GU}tK߸p,pƹb;ؠw8ΑD7JF\> [=1(-;j1,MM=ŶH;|! ?\`'͛5D^Ұ-?}.E- ]t(z)O\iD߉K< +i^kx^* yG xɿ" " " " wtb\ψ72H //Yq Y±gtJjUr :AA*gglŽCaVbv3U5S=U.⇂Dv}1Z9{53; lIugYGޚWZ"3DxY:m k8_FU@h$p}V\;Wo%ͧl,m [=q+.QəUK8UQK`f?5BΒS +N'dumôߦmI$/foY^]R76g? 恢.%FSvcf0""ۃ>Sfګ15+;H4BSu(8֦:y㑭Lȯu uYAפ3|BIť}?7Fwxi/\ÍGkcr4ΖcW%-hK̼Q8mx*0L+{K" " " "r<Ă2}S󠋗c_q(. 3;{ଃk%`F&,bIFd'gUN)VpfgvI爻7)9}'jt).8>cK׎YST]vh|ّ{KN1ph%ZG:˜9q.VY7)_j*,%iΐ nk{ǭǸCGO& 6j~?Jt)hͽZ5J\.- 7LN~!/߇KCƎnؒGҙm,Yk9?)." " " "}:g*O" F )Jpxgݹ;ؒr{ԏ%+.K\WzTƮv;(N]Cxl*5]qo-Xs\# xɪJV9R\\Ub6dT%>+j:O87{v>9crrj:*մ(vkH^ܕ ;/8kbBŭچtyyM'kwOfD. (txaCm^tq׼-v^\?KmJ1y*3C4$X)=c䫼<;߼y(o~ts(~5{>P<*RiWFLBpNszMxofe+cy˙?bzP7g9yd\ȥXv=Ȱ{`HS1fA~TBZTZ~a15~X+_D.+Oegˌp>SGQ Ħ0rAD@D@D@D@Dda]IJ;" " " " " "`A@Zd+ JR xYd+9\$oS; _|Çkrq߀,?Q]YS0˦+:#i_ |^V֖\mPTnAj'꿛*7#jZOO0Ta廣.mJV֫ޓVºE+'zz>r,Y86(W8w ᱩ6Gqo-Xs\# xɪi%)]y.*1f2gNǏgTp4ԫPSjIr\,,sIeehWRfbj[悂" "pπ 3fg|x8s9'&Ă LFcĆıѿhٓs ԙK̶ O'7`gEKu3^Ծ\*т7ִ:2x#$L)1n)ίѥb%YD@D@D@D@D {߼K{-$R#`7wSmSxժﬡnB)2un:M\K%uinD43Z'[p|>" A5mMG8ϸE3w̝􂆕\):ҿW'\8f,XҜ#]X6W3 G K" " " " "PiI/}w'k2Y'ٱ6>Kzб6YIB-ΖO݄õ;OHG% gG&?=dgeyxEpSȮhtMMtӣ5 X.;Raw(9ida]5U5t ZFdr|t}iS޾ED~8ߧ+͊T@ѝ$T8z~7guб_We^m5Tv#>C=gWgw9`TL˕ܑJLQ_hd]55uIQ|Jzsm='j> n.#"|t66:\Q ̓ƅ^ة@zѷlWh w+z|;mo+\!0aؐOr1V%(!-ńo7S2a 6V&!GW%¼cdXʵ9ݮ77j/0@BC*{UQͼXv,anag/$|vfcW 'Y{ᱬXM]j?T3IL;͊{Tޒ[,ej-K"PxG{+?6L?8}:NeTKjYURl-`bK?FE2\.g߅T5[y3@u R꒔zD@D@D@D@D@D,54#E@D@D@D@D@D$.IGD@D@D@D@D@LHeFv@u HU]w`x[@V>[˞l'l-.ݧQTY- %`TJ6E,{ޕqm_M}he" " " " 48wd_/~-*$tolCYt GoZEܧ̧|ыC(y-hD[cȚ_2qFfos:ny\%YB.ŒjYLOO/ ;l>_8fۼ'5.w:`|AS#O:XrH8/^ϩ$CTW.e@BF3{::q,}ۺ`E.a{Yt-oA& "<˅#xMY?͗2@ ;Bopz;-~$>Ь:ǦO>fch!ANMЩ$] YK ;0,?CL͜X`\jFD@D@D@ٟu@Y0Z_Eqxr/׊Jhlq`m|jW!*YUKj*wKe]dz+;k)Y L״wx6 Ӈ}GJ>̚IR 3)Hv 76IShH,3{a'ql/ydp\C8u7-hÿ;ΰ«zdaC^hC| @}i1uze GHę.S>bS_q,r\?`wh3Ǵ\SkbMkӿ#N'/ߊtǏ&2}qc:o굥U"" t(|䝢v =Iww|gl߾"HΝo *QrI槀 G-N7[̹Chwnjaӌ!E_܌\wc-kEcWt?NxVPrb9wCsmaD:8Ap&*<.Ͻəȯ'=&T=Y9(ﵻ.li;Lly(8W ׼.WXYY^ 9cQ/Ư8-]kx'"jks<O i٠sf۫xCmW^n8kEj۫!U[.4nW3AyD `qmԐi'Ak"# iÕs8c OK$cʯ{5xʔړʹ,?=tUsjd>3[<=²db!H.t{ ;45ѩI0_ iq2so[^OD@D@D@D O2 D;_Ɋ8ɎY҃uu*VhI%wx&;x=". "R馾+)@J^2Ʋ<?Čf ǖw޴m:"5l[/߹|YtƯqˌ% 7U4Wq] !" " " "G\j8z~7guб_We^mpse|5Bc'݈POٝgG~NW-R =w|8M9'M ^6:|ѷ%w-B֥|#L{sq,ޞMˆ@eZ*Jx;%JʟS%CD UD@D@D@D@Dj[tVA$" " " " " hs 8kt^.xf|o>|%cPGoIzΔ$ʖ@5 RjĔD@D@D@D@D@D<YjXj1*(O@TdT\1GUv/x.Tއky`2{_d5l,m@U4sʷUk1<Ge%% `c.j-mOۨ5/0M1jL_ΒIZ]m(mlQ+齕2iwiLYEyeMykvîԱDu@پ-\Tk;SfN}WP)NW|ǷTߝGgA9s@9sh ɐ(9#8=ƽxlL[|MG t46ѹuCtnOMT\.Ъj0'9qQ$UX7Ôkٺ#gԾ41֮hns&gX,gK״wx6 Ӈ}GJ>̚IR 31Hv 76IShH,Rͤ+i^8FlIv=9 NlK;j`<$\ jqI%Y jַ%)8umx%_+ӎp۹'s:Οd%rx׃:o&5(֏LĂu˾ɾ+9GͻB/~+#`7wXmSxժﬡnB)2un:M\K%uinD43Z'[p|>" A5mMG8ϸE3w̝􂆕\):ҿW'\8f}ߙ˭軹ȕ&Iؗ9[q``5!_b\ђPviQ>-" " " " CWY^ 9cQƯ-]kx'"j%x ^&A7xWڮ655:>܄q֬ԶWCEǙ]E̥2wfa|= I81|0mC~ '2o.bq}%#i\8pwV3%k9r|&'p\&c*$ʣ$VC\wߛlKP6FHhHe3°i|Gȥlݎ 1q?͞'2{[ӮqrR\S&x7>]M]j?T3ILZI.oN،q, $09k/ˍ6dSD@D@D@D/`(?l 1Z' iÔӧ#xTF% JVK,_eImGD@D@D@D@D@D6΋ez\ό˿ ck#|Ƿ@b=gJ%) YjhFv@u HU]RkT}p;y.փ*\c?vFl]3q >K" " " " "P Z}&˧g+~*$tolCYt GoZд V~WRy!^3βIdl-~GоV~A+㣢VlkX2owUЇ_" " " " S@:X>fh~]C\+3}U8[l5X=8h[;ZcrȹCV,r4ޥKG͍pD\lk\aR\=ӇYc?0IBf%1| c44 T3JvFgOw%:4Sg.q3ҎV0 l6AԹ/QA!rIzklm\NW@By_bsTO n_o~ G.Ip"d;aA-_N}EQog ^[} >a|= I81|0mC~ '2o.b ws}%#i\8pwV3%k9r|&'p\&c*$ʣ$VC\wgdݷW>}ü5uq85B/-哬F-=zE.exv[ɛ󭡦&+c8s>Qh/7 8v;eh#%8kp:S;3udߎ4," " " "W0L;#ٮ|39([jPOGީ KKI> L.5d0#`b(3Hpތ\ό˿ cj#|ǷF~=gJ%) YjhFv@u HU]R$hH#%hE@D@D@D@W@ dl|e2ۯ<=Xe^ĐMёM|H rͽlpڕʘvF/!xF9\_ʊ~xc*"*rT5[إ u[- ReS6 ->zj?Kϯ?*~jA@)˙# `giϐ cyʣ>tbv|3goR\l?]ː/P\q^NپbUi d}aE}'OQr+oQ~bU^J97WL_^ iFm.}V:Ֆ2YiSʎF)ͬ~_E(u^l}Qi=Nyʶ%#OŦ2teJM%픶Qv|9ANsn[ri ͜_~Tf>?*s㫠o/nC8vxCbʶKvVp?jRT(oR6Բ_qJ=?"[s@9瀺K}Wjݘ.njs"&0 \hLSe|5fVFulpr)e-pSõM/vmyļ %j<<{le˖ cK]o$aڊoh 5Tw3Qд@FG u6Hxo%[uF?]:ºq,^o=K  fr;m+ WTN~ٵ_ 'l!fYBIjtzV'A˯+ܬ]#ѩd$CUߌچn9ʯ{ssDWZߩTؑ1ٳGԨCsW GNdpx,Fͨ]`r%;E@D@D@D*h]V -ܯҢk8]~ȗk,kd_E$eՒ8CUr/ۣR{Tuih{<[ι#Bᅱ4.xM{g0}wD)ì1$E!3>1b MAt% Lj ;cγ';ĩ3miG-L9y4c@) 5c1ӦԦ/h ^v=יp$&iH7BF~1naOŴhk5a~.\vܯ$Xi--T+_:,hLf&b 矙۪r~~̌uToIDǗGRg=i[$XKV"%" " " "(1=TGuMC:|6jt}#`7cHmSxժYC8j6;{0u3c[0E[-9{ ;]0aƓs8qY_ѻUY}-25 }!DFGq%2;1($GS{'*2p|UMT;27$0#ݼ;mTȁ9a y ])Pٜ284z'#H|ܰ%}S[aEyu8ϞEAV[?Jccc\ßiR ZJTM鴋e{A79Psu fMɼS/`~XY^ 9cQfr.71k^DTk0Cڄs6*PզFׇ2ΚujHՃ8sV%ͦUixyЪn'M؍kj0>{ҹVV~z"ԲSm-xf.4WիsҐ\X;U&3֠ɸI;SI'+촉lM!p}krH.SQ~S 7xwr i_;eU]LXW̸:v8=x1dL 7ΐD-y WP2y#" " " I/}w'k2Y'ٱ6>Kzб%~ IDAT6Y VhГ[%R ?_ttplH7>?=dgeyxr ]ED& ZњJ,w 02},g>=4ٵµ_j{gHG];' }FK!-S'CVPOGѻ;;MʻuiQb3 k ߸ba੪ٚޣ5I ZSU4Ӫxa;|F췅΋G1WwMOItNݞ!Z@IsgqsZu^U.5Tv#>C=gWgw9P_R =w|8M9'go5]A[*mmMO^?bQ:t2gr1]__B@z>RӲ~u713nܿg|x7( ~ ;|Ä1z6q} J3|0mC~ '2o.bΖqQWjGʵvި 68.Ʊ`Xll~тk*(iQR^1v~oQ*eRyk2#M_盨x~X.?J6v`ɏĽq?͞'2{[ӮqrR\S&乼2>[Ki:KL>5P[y)9կMa/`O*xP,yV{;cn}h|l4$U|~Pᎍ: NU? ??jgsS~HM%_[+Lo'q% > B02俁m;}9 exN N!TɥՀ#KQ?6 ]_K" " " "g?Q>dou8e0" " " " " F GE$" " " " "ou8e0" " " " " F GE$" " " " "ou8e0" " " " " F-hp>Sl/!:!ݛ#@V-]yLo>m5w+%;^BV^gnu<Ғz6YĜ d_˹5CzZQ&?yqb,{4fKy}44~˲e\ Sa.j\U;~2C?{Yl5G,(ES=C&)XӉ ډ͜]@Q^-" " " "P5m!3ҽ1%tM*{ڍ{g5yf4fr3t4:hI9W.sW`W)~ Ш%nnMџyfdm+!\IaQhh)k<?9JbCQhkS\3mc}ěȫs0cg˜W~cgAlZ*p $=f̥9~fۯ|WQf$>tfOoN3if/}%oO,;{LcܻIxV%" " " " f}u'X곟)tqSΟ1d$qoGQx/ Q42گ2);C#Xt:&7M6]xjKص JiB- s4N [e|gݱh Ǯ 7V0mf7 4Uw3Qд@FG U8(P1+V}8Oqvn4jOs ΓriJ ^xWjҩ`׊~4XYd a7')Z,|{ :NX:߹u +|=f̥5dM+]U}j͊9JFB(?^ͨmydԨCsW GNdpx,Fͨ]HA~Tv?uP0Z_Eqxr/L_-'[ V`j/')|/|r.bUm*wxvEs#sG6x-zort6Ao.;fsOqMlT>9KFq}dn"ւϟGs-ӓA;!uO‰/%}-wLS=ܭXlbW}/jVQّ݃s3r):my͑lyJ]*7l26޽7j3nqfg !2:+]be.`J.މ6Hm%|d֖/yjdW(U~+rȫC|. riQ\EVzW3/ѯuq s燹tWs_uvCSƾǸ?_ӤE▴aJ,@|n+[-d!6]$2[#" " " "EKgY8|8z-6l2 MDYA\o7~iZue=1-@,lv#/Qd;QI'+촉lM q}krH˾ӿ2|R^wu&L7 <ٜ XJf4\qoϟn 7xwr i_-s*jƐ3y$h#8Cw c5\1⸤L^j'"ߝ߯dEd@,AǺۈfiSBR[AOn勖TrgKɎ'n'^ÅA_#i@~z)y %/JԤAKw<=ZP¹!±%*m[:'vYFĝbwA:"Տy×\\rƟw Ӣ5f\f۰ٿq(0̍f#U4A$NХrug|䇲wǧMר}sf/l*ޣٚޣ5R%&s's2C'InOp%TG9kB9Bl:J̫\1ݴk;1}F|zRϮ6<;'s:zWp4qs6O{()jMAxVJ[bEFqoh]iDEԡoYc, ))Al=pG3 nQSxMWz7^a\;Wkſ'Eϼ⠫`4>MO^?bfxd$t'%xnͺ3 >?|}H.s燹V_e~C)sx&g3?lU~R.tjCW#u922T " " " U@@@+:ⷽkw cxؐOr1! mgx4n? }#{O!.VLP`Yߑ4.DZ{I؎& UI1: ϧ}i$Ĕ2^*Ke=FO`xQG'94gӡ=\MAUn2? }Թ6q-owi^[-9?#Ҹ{(k*7l2A8gF7jx>gloߍ23w VrIPJ^hs ,+SD@D@D@D@D/%`>8kAaY7gl"\sQsk^;訊?[Ih -tD("!ґ"(M'EPc ]" RT@5 McCQxwޙ<3;smBCE C!vt'( ]Do~3^[WzΛK lur^ZH3R(c":_"kHnx5^vybtWB@! xi}b̗8@Bur7͌'hRa%1qΦ8!h0{M/+)Rgھ߽H;Dsx^D' 1'jHa;},*e E[r])* $a}Dxn@EɆB@! 'Ipt!݉~yWIT<ʼnYkhJ7epu Y\b}; {N1=<5miTCC̯K%&H! B@'~zٕo0i2>_a7qD̛0?B&tAh'/0rz #_`ܞT/4\ijF bkܡb6wLI2m"~3iq_¦M_uJ[.U"dV_Ew|JD窋_VܴʣnRDµz7J)TRKi^/*ս.+Qe'Sͽ,]ko:DTFxU [{*Zz1fYqQCz};U~n -m:Va)|{V}&N^kjVY^9S%ګqڌ W5Wer}5qnZնҹvWryZ oP*bnGUU~Pv"RLZ ܮ(>}E8ZWfU- nI_9\|_qi#B@!p(ZڣE!L[cdYks!"Y3'҄~8oOL5;[]XpÐ| BQۻTz.s9wsI:DnQCJԣ9_uO#5U~:԰0N4zJWRB 楆rsJ&^Ih=QJ^`v IDATM2g2;&2.fg0 >Gi<5\(\h$bfrGӌҥkvl?cJF'O/7l oX*(F! B#`3>ń/qK!nV/XGOФJb i0[TS>Ə6ofYm*,Q7uixGxˁD&.)O{>{|)Dwo5ɗާ*dYo԰>UTo"lm.ٺ8rSvxP&/I5uTpաB6+_3Uv;ˎpԢy q#Z=vB@! w.D;я}#7j4iל8q"p M馌\δnATt7ҍ[QCF_=`| sU;UqEgRE37*7ۗP:1m!RdY {_3/sΏVaZ:xj7b/c2Zvol ! B@wl~zٕo0i2>_a7qD̛0'*e730B;y)ӻV05$Aۈ^я+Dil؆E#PR5Pb;/YBBz*~  pg< e y19PjT7vg^8ZLV6 VW]I1sy&LI2m"~3iq_ r.1 ~"h,R RO`5\쵣&B@! w.§$ܹ: uG`<G orTB@! B@X'Ě< oR=5c^}{9e q7;i_$Ō=tPB@! 3^B>x>|%Noz~f=hB@! B@ wuu! B@! ]6jhB@! Bix9J* ! B@! $^MZ ! B@! & ӨJ5u\Oi.fPɉ> grnG'{Ll ! BN&5_pÒ<_6Eyjb_Utq;/ /#"X<, ڴ+UrVnܮ|ϮOְ`W8fkWO4k۞) }GQ{sޘ0UYh>F-sx.4 hr ޞB@! B.fNYEǭ:]Ʈ42i_WҖ!"7-i gC\iJElM##)םJ)g'kzo4.ؼ&,Q+Ӑ.cx /_1 ,]Uj(|B{RvY7{' 'jD.x~&.dؙc/>Di7q|+>rk].7dF.%  }Aѝb֥KPx~Y-9|FvOxgH<&lRdGۂr<]"9/Iu WH8#)]>z.?\j6mKW,#U鑣inSi5j:G?hh\6n:3_ɚ Y^'#|*;_ؚz$SB7tl 'N'S#y"KJ~XLD6A$(K~k0ɲ=ei?YLh\7~!6[rزw봤+1%}INVb'JYO-4 =>BQaH:Pο/, {~&^y u^xA562~꼾6)U&U3`u[R8lt2U+ZnG1 J_͝3iR$m_Ɠss{q l}zmgy޹4;A] }{e*gCYR:o'/%زjQyi!JyŬEeGrsU`ƫ!*= ŘgIgr>|\*2nߋt]˔LN~d?S%oZLs`¤4K4nT ÀNͨ5j,#@ 7dÔ]nwRL(}UjěU]2-Cdr1pH̢>_ŅSq䴯M~41/S1 ȸQ0v4K o! B@;Kfz,Y`mf)&K);L<(cM#9gra7D}kCxQIl:EȆB@! ]O:ӅHv'Ѻs]&S1'.Zd56)ݔ ™-.\'9=IFЕб^ ӆABk>]@ ]1u,Zwfm\К3/9gXG7->j:&igpma?n ! B@ܙ~ܿ3CB@!  ! B@! `x+ ! B@! ׿ ! B@! `x+ ! B@! ׿ ! B@! `x+ ! B@! ׿xZD,Jw;n7*bs﫾k xoY[8_Y:o;~/[FkE n]w|ZbM! tÒ<_%6EDD,㫉}iVIKf˿(4iWL?~'ݸI9_U eZ~uq#FVL-z_roGkz[B~V# 7Uؿfo{S ! 7YtmY^ѷ Át5ߊ%z:ΙyGOx-8Wh' ϫw31ɬAְ8:z猎_)S.0g WVJ6N8Ht|"3~?;ocT7cF軓kc-֑DIg+\g,ڿo{ ! 7.fN;Ɛ)͋wN-{ cW|#l;VfRJ|%}=W1t,,׸ Y2zOW"-bkY$?;EkSάj.HL`siLC:Oq%s";k)RY'G;9oyU7r~ŠQmt ?w=1#*#q0:4\\m&3;ѯ|JdVWӉ|8>!υ5y+ pޚC24N,m4+6xxÌx}^^?S@lKW='D_t)G^BԠY1k_2N4ʑF]y^LntVϤh]k!fZQˡِ`8_yʔi}_Y{|ʚ7۠WrI>og.`98w9:[.5:F/ˢ3ڍjkݱ˥ryyX/&C?EqDǏCf/x<״#7x?uy|+ڈ.=9qugp$w0e_{;ϯ KB@E5gl|eFk4W.Uszݫ-*Y*ުE[}UҜU꫷IVUjkIf} ĥj[cҖŶƭh;եo?սCsPGhWO=5qZ2Q͸p^sU@k^Uߨ'Zj{jWNli\TjΪ#u:CrP2χ?P"f.u}Sիt\]-'+_F+R,[>ny+g>Y.J*BEP5]OF,Eq髪sFgx+KpzF+K=2iZ0rώ\_-_W#TJZIX7icӪU7ɝw]㫐#SUVO Wg>t3SWqOjnqz/\UoAY_Pmk{)j7y~J*g>>#Er[>WyL᫚/;?yWp A){eo9U-(6Ɵ|k|Y_t+sr [c#b,!*|1ފ,5 ~IԘHĠB^2KQB+FcXLm+rY]Y;Uv2;t&h<PMa,a cS1ON~4>[ ɹ롤x2cN"nG);6:nūN3Z<|/.퓾s˖"GǏvNkJ7M\v,iFLYqpՀYh8_ǟ#ʝw=Cƿ}"B@;K܌}LҢNQK8F'8C-EfO q2>-~~ۙ6kvwb `H>Ďwk(OT\Ri=QJMdҌʘȟ[_.Hn֫S}<Ԑz Ey /@k4„IiȻָQ-:5"רų5xiH(H)Eq:c0q1d_yjIK<&Lɤ:nfBVEUnԪ9?2jk2%ok-wHb'yiE:./ғIOo,4ڼTV[kYv'ǔN9^n$@߰RSc:/_$YOɶwH^!g>.ϿZ}~՟_lOEql ! ]Df} 3_ =Bݬ^63I9}RNFr jJէq>?#<Uv}-QxGxˁD/_oZdJO"ՐºwYT:y@4[r])* $a}D˶KH[2dJϟVsPX2 IO>}4gh(y8h=Ny9vJD g! ړ;)tϮd| I\R^2Ϯ?D勗%SBs7N"N`G:Z45#sdP98}9:~\1d\^N;~̿?88]ž]yJϔc)@^1? WqӶ:::j/r! 3 ^ut!݉~yWIT<ʼnY"w%M i݂邻o yٓd]yZ ˳9 =ǫJ9kH#>r?cٖXtk3,jR>ҍ[QCF_=`|oJ@_ jhu ֲKAjJ5pb:'5?Ã5x7ޝV )!z>6Hum(rLx׬COWܽ^Gr M[h^ک+ǃm#wN[v߅{$y H2?f_~Ǘ(ҨCږ$ IDATtJ9(7Ԃ4nh`ja6Xg>_~j7LXO TErLFm~>FNORSƵ__➼wm-q,~9ߡ!?'#;mPRA! Hz; ?XEo7y} ~40c"vMKn-K~݄\>#TNZא`o#B{F?g O{g6,rwq1%˴ Ϥ}P$LH+mw 0$n9dG$ u&/_i~ߨ+| ؽt&_ͯAc0=]pf/&p/ :o>MVn2+.[)$j*gB"&0vq(ɍNwd=7S~"щՖ}^ϖbu$Xq$fhqrbcv7/{ǟ#Yodu#gL~udZB@!p0g8w3)UT۝GSVYUR=_Oݏv~ƣ+P2%cvwl)w:gg+^ '-2 _!pxG7^ 'X:+b_QWK%\u_4B@$ :zFm҆o2Ux,£=nۺR)7@ͷsZ7z ! GjqK@B@! B@[ES obG! B@!pNaO! B@$^xB@! B@${T .oQ'ūWZD,J+eۭBBi{U8w}UV o~+&,m_W6F6Uj8ݿ/ o14+SSuۮ4c69\&B@!&-GٰJ:U]={m0][›l\Ƿ3LQ8|6,V/g'iѵlfXPDr㦞 ))/O5'7۠WrI>og.`98?@m;1~};go:?s#1ʟ֣ΐxLe)&ŷ=!vxMEDrD$M@Гty4Ƚg4粝uԩhT,ݐ+(+um) m>IWa&[+M~1]NPywhOS)ƃ#ff`6 >ܸ南/͆~D.pha'^}kѬ_/:;Wof$+Iu-7790Oe I ;~W.5t$W&r.:煳ehtҎo @61~eUr([<.XqʓWQVy).:fnS|?U^:SeЧUSG]F(û#NUը yGH+k:8_}~m\ N;l9-B@!ppp χxoJNL ?V1A!b1euKVHb;{ `{}C tey8W=XQʎҗGJ)?l`U$],Î R8~]c`kʿȘqL/?%tANpt29I#y*͉ lJqe7g"ir6LǘȟQe+7ThՃ:/ˠ?quBI޷/VZw-iso1UL1lVfU- nI_;tiY~Mlۅm!ɨ0$x(_:MپAo7I9Ce"_zH|`K\*'k j#j{Y[hz22*lt2U+ZnG1 J_͝xK~+\U緔=-oj]JL uعb*'-~'NKXow_gwږ.U"7u KE! 8=x/-a$GR9 BhH4"Ndk~Fߧv А| BQۻ(W=ZryȨVf!mɍ\8GNTG/4zJ峈9Lz,57#'y|n"s.MɤkPS 7X|X_eSaO\?U^~3 ! Kfz,Y`mf:Ewܐ[+vE^c:h=NN`w;V&pM9gra7D}kCxQIl:K1<J(k4$qIyW<8/^K$gQ+{[B)ݔ ™-.\'9=IFЕбuz4I^?RrSkЪ;b8O>AbR\ o!e4jvp<]R!ʍ/[5Z09Z8Oȱ(~}~"#ʒkφR,w8廅_7pv囼>?LWMw1rr;&e-K~݄\>#TNZא`o#B{F?g O{g6,ANj!*Vlsǔ/&7?Cetd3ao#lMCv~E to0/'L޺L_n/Qvc'!T(* j|Y|A >a_eŰ}*ς.a O_37[1q38C,dť8v/W+1qX>Lywު˝y{6:鍦^ظSɭLȨ|*\ *bc/`Hn$~w2%NNڲRl+#99gXG7->j:&ig̳g딗U&K]tOw`=~lˏs&Y~~3-?.*W3if̿_akv*~kã5 [k/B@e." !p#hJ=[_'Ur}R7Št50{' Z+EB&-&yd&/{-gKmB@M{(B@ܶԥ|$^Om#&Rt-ZIX7$^7(B um|3 7IV jL{c {2ka5/^ǹ_IVKTK@@-{~bYZP_QOVE-vB@! F@{3m앉[uhٻ0]id$aӦ:tA׽mM㎏'d]jOW"-bklITM9KnoЌgc{8\+Ӑ.cx /_1 ,]RCXړz0|2"""XqufO 'jD.x~&.^YVh'[Tc+揋3L_42LĆi<_ݲ *Z4ї0f'fW(k9֪B)B@! w-/~FbIYGn=u.q%J6GA5_d¼`/gBz5@1y:?=Qȥ$8_V5"?3Qߺuqevt~LGb?GO! JMo z Nw$Ձ\]"vtx I:~ +UM IsR*B@! @)&|$X Y'wz:x&VWxA?#<9Me%jZ~ƿn H}{9HƔD!u峨lfl{mu\78akIzl}CwogKS oJR CnUYj0l5ʘ`HT)".U=;NH! B@+\sfD?Zy_wܼѤ]s*fE wДnLDEO}yϓȞ$#bXY)?^UU,C1l˶ĢՈ_w'pQ{?Ì,{, 2?d^~X_ޔWyN,>9}%(wdGD@D@D@D@DJI}%Q1 |mSo›]3 k _ /Y%(`;F h-2|d >N,;+eLI@,Ξ4ciJ!WOqr1!NƲ+lЖ=F/"PٴdKE`$n;*l]yI&ѿ\$PBC??)g+[d KY;"8gƿ싀S@mlhZʖgǧ~!29g-i 'Ӑm*sG~<Ƶ;s8ӂ3?`+N+9^ӶBl'VR;iܕ>p!2؄L_4 9 '9P,i3V5U7~Um4}K<ܪ9\,8*# (aϞ+ { $dRx}>0bq5C8Œfw%hMc6*bў綮>M9}Q9j7NKFXG +6n*U{,dՈ]Dw|hjEULWHŹ}]DΜ}q]̯Z\Ulu5-3 bǫٛX3~HNtٜM$ѿʪ!m{;gb8y`W3(4 a3}GL;9_)ƕX3|5.` ,RVL+Y$ Q>ͳ/1k/$m[\+?K)#wm7~6cТƼW2n~s|̈́SG+ _ݰ9vp!]/bNKf;f$" " " " " hLF}G sE*mOB7 ] ī~/}c72q1cuu}^|12I4=C{.e,3r|E+Sup(SA}#?&kVLOƒ:&ݳ3wϐʞCSR?%ѐRВzfUٻJLhl/D@D@D@D@DQ0xe`5z&t fm"ԊEyFyTu;鷬nN!\Lu/ޙp 1,L=š(k=0} '$ƸU:^Uxe枥EA'˩&ʍT/7jXH|̛cٰʊfԍV z j2"Hj[G,nLIz2p|׹q$noBFa:iL5S ɫŪuiwv<$rBw$xUcLؓ+0rw"?tSjoTW S*BJ" " " " @2Es4ɉޣ_+ftAK\LVc| գh]=O*\owi`k_8ct2o`jt;$D`6Gݪ.U擥MS슶cp/ZڨPi6r9_ј+qrC q&8tK!?׀]64Zbms#-ƓE!YݤP_y^E[ZN%=g<%Zk-PY6[mx W_6m*(IV=`a,O:S嵍a2/VR m YyAir*C+" " " " kFOڊrHRED@D@D@D@D@j# Wm䶚 ck} D2cBng?toԭCJ_3" " " " 8ͧSqd~՗Ơ>Aڣvq=*8xNָNCm*Y{c(rMkNƻ]@9O_(s5)Z'f9;_IAeE2_Yym>s KY;~j ~,~zSϒWr\D@D@D@Dt:=V֭ٱi%smNY xgZ0bq5C;8Œfw%hMc6*bў綮>M9}Q9j7NKFXG +6n*U{,dՈ]Dw|hjEULWHŹ}]DΜ}q]̯Z\Ulu5-3 bǫٛX3~HNtٜM$ѿʪ!m{;gb8y`W30 f}NxN 9;gz 'M1tĒ؝ufVgZg\"tgbiu!] >%9@͚M4%gnj1֓Hp9v/'مO}^ARq۔2v9tѭw(h@1o~_b'o3QfJl`+٫eq fjX?xi/A?\/$tV) ī~/}c72q1>/gYf$Ǟ=wbVhr|E+$Wٷ0#>Sup(SA}#?&kVL]FJ'<߫3/]`o=ZZUH>3ѓ/?a $\R(.nt:"/pp&ߐʞCS2dJ$?':ۣ!⭭%Z_GU~J#!`:>ñk$݇Mf"DU")voY'ܜBp뛿ޙp AQ8eWJǫ~Bۀj;<o³lܳ4L=( >_Nݨt51UnxTB#m֭zemٿeǮ`ߺҼ>Kop"ll .`FnlbZh|uBH޳Z␎D@D@D@D@*/ Ǘ x&t.܋Gv}8|ܓN w65z {|zXnj;WnׯnKɻNLb~.׋$pq"ci"3L!0w}4)UwbU4i늻[;W9}r;oWeJ^"66^+<ޘ˔7831+5?ªM`Ğ\9ɷx8FjJzx|!-IN.X5S_\oeVЭE2ARwf{H[kGW^=-|Wda& .4QꀋcdiS%܋6*T\s0bq5Cߛ8Œfw%hMc6*bў綮>M9}Q9j7NKFXG +6n*U{,dՈ]Dw|hjEULWHŹ}]DΜca=fJvR^^YگZ*ߨl|C_܏e7Uak+{(-PhbifyX:}3WEXJ.m'+UeWIyuN 6mS֌ݲP)=){L׾[ iTyʿ?<)7mP&V0M3e`NJֶYx%JhtYUYVԾץ[_UYoTUiF19M/]$Nr 5 ׀\r 5'L?PB3ڕt9{.*(yWٷ0zOAOdAzkTj|OD@D@D@D@D_qOtp=IaYȆi6WrK'hAپn'u) %4>`;a=8e\0TUx1gi`kzP}Q}ʍ47}gxa!GN3oR9:7ʯ9W6ت͚_10e@AN>  cZc}qpٿe*AeQԾ*2oMn[nrSզ(1$Mo>#" " " " f TxY8_\7s^<ʷytj5RQ8* K)(n}c3f(Zy23nD x8NJO'26&nQN>|SM6WRZU4i늻[;W9}r;j/s[sYJ=7L6-_=SʻL!Kũqu/,l5dm0’V" " " " R*o54Es4ɉޣ_+ftAK\LVc| գBv֎6%c)zP?F)s[f/FIWsUIBA fxԭ:h[q]Vx EK*MF.98{'`@h\BEܾ A&]^mG5_\v-Pk΍TyіAR#>Ɗίw U3z4wa~οL()" " " " ht:])è\ E7M5/F,3%(`;F h-2|d >N,;+eLi G#'eRHSz*w )_ӄ,%3?ARU7QPt?MϺ$X ڲE{֗aؔbxMgfӍ ?sן7N/uv/'N::JO~ˢĘTUL[@e%OLsںH" " " " " "{e" " " " " $^ #" " " " "{e" " " " " $^ #" " " " "{e" " " " " 7 N0@~{YNqAE@D@D@D@DQЀtjc6Lد>Aڣvq=*8x k\󡇶\ݬ1zyʦ5}|'].Ws ɜu+;w!ux.Rs)kGE ~,~zSϚ~irxj۾Br@D@D@D@D@J@mlhZcCe˳cf?K|ۜδxbgl05zRMe|yTZڏǸvG{rZLzI\CmqqiN_/4iܕ>p!2؄Lh4y_ %ն}I?WD@D@D@D@j'cwsL^Q'tsP$dpzu! IDAT={0x`s5\IhØ⻖%UBnZ~oLGKCB8o?<5 Wڨ|E{;ۺ4MHcD8a,aI'tDd1TaﱐU#2wqgW1q_&ʓ#9{AtQ9sQdZDUh`졸[DZsB>?\g%4ϯwm煬|}>x+xN_Ҷ3gZ յN ~ 7':~.SOlF r6/6\דrkյߘO?s[L%TӰr_ SuxO3NXR@] Xϡ;TeWD@D@D@D(n4*= }J]. r}\-I*}Dm˖ bvMȇs?L}':lNN&_eՐw3k1<*vKxͰþ#Nqlޝ~X3|5.` ,RVL+Y$ Q>ͳ/1k/$#/{LZ[Cw,h}>&gX~ߦN%p'N`֔ K-}΄B%m֑9AWxƾٙgFA!ChS[ moåou(xա_#/јWgΦovӽù4׬Lyf~GÀ f)s~y߲阰~UʣOB7 7ˬv - S7FۼL}0-K[P}0#ӌD3tq'qO(_mOT| .zV=^nϡs9WAɻʾ͇ѻ橺7Q;bG}8.\N%8>SE(G+BLkgWPծA~U̜_5M( XNħMr"D|%cBJ7p&$~H@ȗ|Ǧbݴ ZxC8VuqnfMJ5 \l{SP3ymzоޭ:?)f`5z&t fm"ԪTyFyT:Z4߲N9p1֒ޙp  SOq(w! mÉ'1xWuvx.ބg٪gi`kzP}Qv2M4+^n<հ#D7G |]sqgל+3~5fͯ6C^ƅ; | ƴ9 d]lPXEQjBEz.]%_kpS^c] E[nu\nrSզ(sx.D@D@D@D@~*/ Ǘ x&t.܋Gv}8|ܓN w65z {|zXnj;Vn׮~KɻNLb~.׋$pq"cyrnQN>|SM6WUҤ+nx\I=Homa[*Cu͞_jmZEæSW}4Ynh_U k&3=bžk SRlqk\2 [Gl Yf|̽ SxDА$'z~ Gkѩr.q2Y+tV*w ʾ33o۝F:X;藨wAݧo%] 3I>wlU\MW2N+ hiBi|1Gs,H+Z5;8]Ӥ|5ݫ櫐kej-s¹*/ HU߸Dzh8z(ˇ"ѾԓsУG}_NSK*f hN"*=+#D@D@D@D@NW:Q1 |mSo›]3 k _ /Yx*G ΄3d .‚϶ xuŊ1nSI3=FFq*wC {4a,F#̏~v2yeM&E?&*bn?/¡4,.ejXb>601y+^9|ƙYct8O#F|W5[]XUR畔0o5\g4i,cq,G|D@D@D@D@D&Ɵ0éIRWD@D@D@D@D@DJD@D@D@D@D@D$+6i$" " " " "  Heؤ/ VRSD@D@D@D@D@J@bF" " " " " "`$^[=r5vcN]nchcM i2005/qDD@D@D@D@[5=fl7_oV}'1G+zT8kqZxָNCmY{c(rMkNƻ]@9VvB \¡R֎{=X5em}ɶܽ _-ώO,CdnsZ;≝N|{jliH6A繣wPii?z9iA0m'vWJh״-..녕jw;\"6!?6kzKĢ=m]qs}&$r1RKsn 0n$:Wl"2ݘUXȪg8Ԋ8KǯzɑsŽ (9yD2 x*m0sPܭعb!LܳWI;U޶BV>>C^uXY^4bUyk2gҧK[JUApR^]StB&)M5c~,TC)ak(mʞ?/|V(C4U^OF*O5MILGLSbҧtV.ޢ{Ҡ<*]mUU1kEf*mc4TzQc/qLZ2ߓ8\r 5 ׀\r ׀kh-[ Y1 xYɨ> D/,e,FM)lyכAm^O?w>A]|3|tL%ayؓ''*>B3ڕt9{.*(yWٷ0z=)$z܄Hn$iI}&)ś%"~4a 䃛}>}A2ASǯ<1^D@D@D@D07KǮѓt6l6kq%V5"W>I[Z+9,,Qn;̧u̘h\p%:1;7^/ljM⦉p3tӘjҪ*WץI[W{ݑPx)d7RZ(aiwjo|C@ E@D@D@D@j+P孆h&9{k8Zce׌N}=hs jXCzT[U~#4ѕFD?p,E>e.%(*7L]h0ǣnGꕌS슶cp/ZڨPi6r9_;R~GJ*}Mp"N4*_Mj;?*kنZKpnʋRmW)SɆB@c|yngaoo_`ۢԛ&s # x)G ΄3d .‚϶ xuŊ1nSG#'eRHSz*w )_ӄ,%3?ARU7QPt?MϺ$X ڲE۸֗aؔbxMgfӍ ?sן7^m_܌M͏{}U<Ɵ0yg!!,`][$$4x$zώ&" " " " "HHHF, ׃|v$6GB@G4$D@D@D@D@D@dI#<x=Ih2005Akh6(][WOk]wP[t:ӭC˻J<Pc6Nmq ۃU';~C{ŽЖ=F/"PٴdKE`$nd;Z:2Yk*,g{+)쐞B&W++mYw/=v#-E@D@D@D@}Mڣ9jo*[;6Y;w;+d)564l;=q崠6 gg|;35PqzM ^hfSMӸ+}Bd h9pC4w>c,8J؅9d+M̜BNI'Yw5H.`?pDʪ9\,8!# daϞ+{M8ՍZCzjrr4LGKCB8IvJ4/A4(*ܾ=Z\>FTyi!sV7[曯3W/U{,bsHj Wn.yѤ޽ #9b䐾tm8v5W}Pͬ,nS@an~7s(EUug5|ǐuNVށn3*Wɭ*6n #,kFС~7.ގ~ª+LY-;}ha]*?TU>GXpZ֩Yjw}wYտJG" " " " f8_>}q]̯Zabs68بl9`Oi` -fk?^ބ|?ǚCn7T;ѡgsr7hF*}Q_M_Q;9=^3cS=۟w''뷟4ŸRKcwOFL:ZEjQj\"tgbiu!] >%9@͚M4%gnj1֓Hp9v/'مO}^ARq۔2v9tѭw(h@1o~_b'o3QfJ|l=bǷc9." " " "0 ^>_ΣRo_I&zsEW)_^ Lorez.Myg>c?e4#'< ]\칸;]>@U=^nϡ9s98Um>̈zTTg G Z;SE#*GS|4ǟδtIʕ{ 7|&z8 c⠁J!P]suD^2RZoHe!)!%ѐR֒zeuԯ潸ƫWM" " " " +KǮѓt6l6kqeʦQ^E}N-넛Sn}7>`;a2#(0q18~WxoXhPWmMx{EA'˩&ʍT/7jXH|̛cٰn@oű+u soB,D xي3߳5캔S|+EuU-WYs4V@oAMFd b2ՠUUCxL,_/u.ϛ@йtr/qO:5JUsS>* K)(n}c3f(^y]-%:1;綮8NJO'26&r2 ׇOcJ)$*WץI[W{ݑ@f٪>(yzxc.SfP܃yԦz0ybOn݉[<|HTuP0(֨SU lYS @Sܳ@R." " " "PT=Ґ$'z~ Gkѩr.q2YM?Wwf{H[kGW^=-|Wla& .4QꀋcdiS%܋6*T\sW.FwQUVu($뷛+٫ckevrTm$C ˆ/зkcTEWmˢ{v}UtRCD@D@D@DA_Zy[[ضh1&5ð@9eGXJQ3a ق!G$±^mb [ƔI3=FFgq*wC {4a,F#̏~v2yeM&E?&*Ŗa2/a[7]&|=I!qD}<B2.b혳y'ȗ;_dʜ lɺMZטaլcuJ:G}͙پ|e,i1;Y侮B'^86oom 25,^_ʆC!`O 3b^<0UjD(C. C~%|_@I" " " " " $^ E@D@D@D@D@|Is$<x='Px$zϑD(" " " " " h@c|:1M&oViI ;ڀuV`ƴq̇ruQ*wi^́$s/׭숀+6t6C{4GRe˳cf?K|ۜδxbgl(_zRMe|yTZڏǸvG{rZLzIU#5mK+vrza~]"M䏍#" " " " "PE#;)tsP$dz={Ss5eR7kQ,s\eM=-PiUǙ.Dp >hv_ƭiP2]Vù}7: z@|TCo7_8g0%w_X树<܌NyDm+ IDATHA9/]=]MUd(n4*ƞ{Ou\yx9@VX 6j,[/ؓgHڏW7!ϱf NtٜM$ѿʪ!m{;gb8y`W3(TnwY a3}GL;9_) 9 ;vgko]Y%&ʕ,OGx(MYҵX YHC `z F<9H9~U:7}^JoXX%^7~y-3eS߿ɕX6R}0˗#ӌD3tqP_;ùs_Uυsh\5x}3}tCL2d)"-f ^pĨw㗾5hM]%;ag;P_Mk; Kʏ%+o|NV]X%?}C vnu%{ wU4\ǜO?S!" L@uhV5O^2DD@D@D@D0e}%{%V̉&_̱LSϲoUppVP7gi(+Ӷd8KMA#\M^dD;4o~ƱcW^*Q.Gr*Ϋ36nbu,miUkѡL]xn>FWM ԛ..-ź os+-[d+(Y%ӸU:[EV}&;ZY1qtѡ뒴8/WfW2;M+D/zѴm6 e\1#U eZ@n4*/2,s^)8$W^ڂFjL[c~7.eן@b>gAx>DNtIFW5x8!%dPhL͠Uw[CsEq_3'&2n!X1t57c¹ ,RTJIgI:mGu) >8?ˍ:d,xgK ̿@p3m-IYFmNRw]&.6O0u 70,T:-1սT&v4T[0! .2ΪE m2Y咆uY4>>L Ңf+- c]hٲ%-[:F\XDɾȎ'Oڪ->x#d+:?}c)ڑ>$އDVusq,?|܁~=m_]ݐpwęޑHf٪ILwZ!/-_MUxժ\}x5Ly\؎kWwLX|+Yr|3_s( C-PWQZ4W{s̬so/\-kldz̲n {Lт &5-^;VIbP<6W[O U2N+ BJc|JوhtVq . cu#7n U4jܘF映s+3Jf7jC'$ݱ9?h1\aoBr"c׶# ٸp7}xXtZ ht߱U3- o~׃.uBlƏd!SDz\8As0c47.MaSٗfxAp +EQ aG~T NlWގ8{K'Yͥa'GВzf'K*ӿYt]g| ٞ0? oI`ȁeQ/bŕLmwQ/Ƈ^@Բ©9TRÊ@]𪫜#2J@]𪫜#2J@]𪫜#2J@]4ƾMn&oVY k~*5onjsܦyÈŧTm5n"ew4/)G" " " " " " hcpw'J~f=ĢqD:g<ޞO0nU^  )['36wE%G5'2Ǚ>2eUq3lZյ ȝ^XeꤦyzyŨS&ff[ZJ1݅]]M8&cde߾F/9>KC ]1zUXBnZUP jJڵ$;%ay[녬PccǮ0U{9\Tqe;n&6n\rٖ^}kѡL]P6~C . oDytAڬI?hU=,s^)8$Tھ<,Ԙ o+"-f_cT;9!$_e֔OxZ 'AR#Sߡo*ő~̜ȸHSrcI׈< 2PH-.R +Y$ 'l=ԥK,7MUH@l<5S ן/?lmdB!DɁyqոi g2?ϙ^f3\}s5qŕ151-H2I4M)W[vs(.7N*WsG=\M GyenO:4<̏ 1Gkv ϩoEg㥋H6nWOp}ź_6d",`ʌNU;},kf۪QgxD6^|{6C 70,JAai~iƭQ?-9 T[x?%/syxϧwxgt3W-qqT&RP2TysysӦ(y*_ӑwdc{7ܟOg%3'mM2 9bJ)$kW7E{7<]h$q>w$Y{s?@$*%[MD=G3Gۋf9xK*'z=4l}G^"SnBIx) ~ת誐la&GĆCxj_m=Y6T8ͮh3>Kk *7(Z*N2HD@D@D@D@ݓJozE.XZ9sH 'hrg KI p6ƏF+(M|IZ0ħffѥ)|$D@D@D@D@~7 glۃU=v< %8rq{^fxO ,kD|"hi_!ƒki I; _|l,֒rs\jС04]1j:UV<>f=ĢqD:g<ޞO0nXxʒ#fKoLs2*¸Q^]MA W?4ͻ.F"61F9M':7ژȔ6UIk{|3QMW3QXdJ:o2 ]˜Og>nՐ߅7IwW:RD@D@D@D-`x f6H)ߟUlm2oZvļj43}LIm8JݘØGYQ$\%iO;CqYܽaww:wVٸK>?rn8+s{ҡa~Tf$Gm^?\sgxN(~x;/<$/]@qrw}kfhlcpqO/AQf b_途(~JsECJ5毤<ܙN 8Q( k Pnʡ\9gyj3u(82ђ/?\hL+i0*ϳ#gB!%|#{n%_Q(޹im_YYSp}ź_6d",`ʌNU-},kf۪QgxgKBߞPpM[ SO)s<1c '$FU6_&v4T[0o簳2L-( 6_N߬Tt1ԮҔxӡi!QLjN3niU}e*67s.]Ź' J+ dDE)UUX䒜]-鿦WtwԒ\e"'TP\x}+dN".Qou<P *ZPqϯ˒a" " " " @s_2g'|:q|:?yM7s%|M*Sh)(j9ivyo#I &tŒ,44 Me_9Le$p }Qq?Farv?!Uvp~oS=6kΧ俆MY D,UyY.>_e_`p6UI:zָE.ВJ_2= ne墝w2\[ט_F#Zro'057˝Cd0Zgj^r(" " " " neH8[ʧcW\4t)^a=E~d]@M㛕yۑ3eA_'" " " "  j3s hq(,\#snϵhYI@.5 RC㭤I@ : 0^@ /㭤I@ : 0^@jm"[~-`e;zm&z*\+0bsܦeYaSOSY׸E 'BXy!" " " " " `?wwBWLRec0C,OT}f)FQ_ѐu2c/pWtPYql^s "s3}.S&__7ʡKC ]1zS̱ܴ8Քk9IvJ4.A Yǎ]yaЫrFɩTu#w/cMlܸq-R6C発llЇj]&5ވ" 6azť%ֵYW" " " " " D8{Y>{JSq׃I}Uy8j Y1mݏA9΋>>IK9lܮyk?+ umD>X`!\/ZwXͶU;yθ;%.ll:0uGoVyabY҃xA É'xv0𷉥 x/ [o9L S M7+]C :4%;^thZH|1ӌ[~Zr," " " " "2K^O'7.V'йf$[LLQd *MQT޿#%:1;n?ݟJ"gN'*6j)d6sU?RH^M/V׮nHnxRI}H$l&-" " " " " 'ITJ(-dz苫9f֏йr.q^US5TO2}z|=hfs{7ͅDhA݄nfRmUU!L=8 Qվzlq]fx |*T;܇-KoQֵN7UG(U ܌)$! =5;~-1uOdk9%#T<63wN=xc53v'" " " "'tNRY9zDx{J<3¸QT{Wj,l4l Tt1\"xßgL˔W9 m5`Ӫ=mFšsyzyŨS&f[gKv465A5=b3/n#]Y+j"2ʺ56ʆ?Ү҃0 Gؼ@>ƒ\2s s,1ԳwnPBW y96C5TG:v-3Nfw%h;z!ǘ{>#oh<ٺ'6Xaz]6M%=+OjAK'5Ő6?q,j42nyտ*}JJ[SV˶(_=QYEY8ixJCu;)*8s̚*V 㥸>bhW>o翨6W)*Vs+R1mՖ*cuUZ?⬴kD15x.U7iʘQ^OWTU譯,{>g<]=o~,;1mPxNi`Zt$^%/5H?ot LRRWuR\JCy8jGiʵV TiJQ(j]N+^хo*)-MYͱuOeKV9%JxXf(=qVFRnn4Y"o7*Yπ|3  ?',of㩁ԟ<n&wע&4PӸi g2?ϙ^f3\}s5qQs(?$SMxՖ ;QٸK>?rnn8+s{ҡa~Tf$Gm^?\sg8(~x;/<$/]@qrqoE\ݸ7) OQIBMϧѿ1l }[}񵉬~1K#gk,Lm@ZK[P&Ä:R0d~5MWbQW2iOQΠkeDe9z9+" " " "'vǫ(-dz苫9f֏йr.q^US5TO2}z|=hfs{7ͅDhA݄nfRmUU0#lbáxm6}iP H8zj8,J@8ÂuPH9ϏWWƦp~_c3?M^?d +~:Fz9P+̌!K1bĭA^''-}7zLCuxO#,܏&8ƫ-*w%ꅐC?ZV85J" " " " " " T{L." " " " " ")Do,ED@D@D@D@D|_$+?^7S"" " " " "` H`/H@ ?ћ)Kx0z0JD@D@D@D@DO$5lrsֿ6-{ʲN`p6XSa|dGA` 7;X|\@eі^&[AK8lL a< _1IèGh?QN1;#EW~FCɌ ]AeIy%7qL|s~a(*Vqum6r'GV:iޅ^p1VyD@D@D@D@D@0e}%{~w!eDWW Xb&٩gٷ*88a nPBW y96C5TG:v-3Nfw%h;z!zp9 *GmA# 51?țHן@b>gAx>DNtIFW5x8!%dPHw[CsEq_3'&2n!X1t57c¹ ,RTJIgI:mGu) >8?ˍFL=dYiti @,ʾ4s N8]t/R8>ǰ`(,&z9I(M@jm"[~-`e;zm&z*\+0"asܦeYaS*7߲ZdcW`暹0%'BY4t]U}z&Qx2#W9#_oDy nxJIM"ӸԪXhէs&''=7k0]̩̈'&ծ L. -ɕ\]Qf xs^9U騐uؗ0 1~g|?[@z_Q|o>XwrJv`/͓|YF5 pۊ4pf)g‹'^_6f_N" " "_ IDAT " hUN3,s^)8$WGQ[Bi~1"bv'+8Y9v^*(*^=W(6]O77۔ż57omn\4W,𚶄xr( .ZLKW)Ȕ/ubd@B#1#dό}4!{ߕx4|wR dg&e'JL̋ MSX>ӗy4ꛫwtՍǨ9iyEbUr&-q9+Oes.过;@ pWC;I~ViP{JQv^yI:^ȁR7 m;zN7<(c߬A+]wJ?%9ڢ!WRkoL( HdžеSg(~ɞҭ_B9B2GLX`!\1+>J" " " "p \I(-dz苫9f֏йr.q^U{PBW m٣Ae$W'׃f 0w\KdM6i/oZ]-$1ۃp(uO\'ˆ*mgimB}b޾x*EWR:d~4wg b Ál]#rN2Am1VPc6`bO'+MaSٗfxA;S 9pj_p }ƏaQX(yOGHի+o09T|i&aSV!KjUss勵;xY:ܦMcՆlNqe5nQ$FeLngOGYh 5&bQ,ȇzܹ4 L;z 4ϟ3JS@wJ-+3]Po)>6Rr _մҥxc+;V2RD@D@D@D@~c:]It5MoVN&jLJoG2Dߗ}F/تT%ơ Bb[TrI\χkU?עe5" " " " "P'԰Nl2HD@D@D@D@D@K " " " " " "P')&D@D@D@D@D@Dx)" " " " " "P')&D@D@D@D@D@Dx glۃU=v< %8rq{^fxO?Neі^&[AK8lL a< _1IèGh?QN1;#EW~FCɌ ]AeIy%7qL|s~a(*Vqum6r'GV:iޅ^p1{?f*` jb϶A#M=l 94$3o;`xIS2z"uZ"jD*"*\D\g? wf }ߵoy;fkZOl1~څ3}\8>cife+}oR{Ey)TYCqNP-bN DSϾKо ǯC-NwW r,M+sԭ Zƭغu#!A/j_y {El ]gڪ-DDD_<6dx63dw}ӻ[lf].V.܏OqV7ⳬ{Js/y;qRl_ڊVVjƂ0/h''a/_|9J{7'8P ./R+&l “]FJq~9Smt(" " " " "p߃~9_C?`gY.IDܹPq`fY^x=6Y,(Ġ)s:W&#zq~ïQ/%߄D zؓ/CIްNs u+Hз\.6L[ʻyeGt7(R;`] ^sg|Nܔ8~Hc  /]`irsOxUxF¶ݟr,⭙I^8շ &N[<;e=qs@R xrf9zD7gEY;q^2M&lA Kɍ!4x'c:y\>w3{`BMLH؟c|j@ђz5.7x҅8hx |aՕ 7Ҡ͈$" " " " "  pE=hV{6\@ @/" " " " "Rx5 " " " " " HE@D@D@D@D@_@ 7D@D@D@D@D@p)?Y@ H4CMkxdDݲ9(;~s,1E@D@D@D@4 qʩZ;V}{1>:cK OEEZ_+3iai]owŀʪ &{'^VaJa X5AO歞zB= ]ґERZ_{S5wx+wfr_ cꌴg{R9{7-z^r|J=EZ*(h ظ3z vq@}$M(Q`G8N<?>$ްYn݃W|6XJ:OoS~_@&NVP×,[bZtbp3_O}$dcih_I@>4NNW||o_Wiu0o'~BjeqUwTԘ?:a^V]g]) L4gctO2RωdI:iϕ=Kl`2te\0 `=  Cy(qϔӫΔߊZ{Dfo֚>^xmZ737?ÌY=Z9]уObDj+7qh꯷Xr&" " " " [4Sr.1|S b7Y^"d/ J:1h\ιŕIv yu8eU zؓ/CIV~*;Vʑol wx(+ oPv@BVgX{J)q<2_d뫖[S[w!C2z@M+2lڇO_'*"p plaeYqd,J]-tstʐ >̍5_;ŵ*E@D@D@D@~ |0jLߗ`oL] &ٹv/,뉛n37<`ㅷ21> #jOs%^>'I0xݙ3ִTx3^ջ@QPt|F|vC'bˍjI;FIUOzfa^wMӲ-9,) ge5T[hb|xe>tQ(әaJM^\\Vsq($~˜k<DZcV9m,cA\O\bz]$,=n'9j*3s(j xc-ӧyؑRr$X ƙljKM+iƖC);{`=ygwx4GT4qOphh@G'EXnHUKȀkX:OX@K)7)nզWYLK[hUt;WƂ_45ش_MFFJOgd;o]E5/cܤhpBw?v:#l@cۘz':0s L dۄjM؟c|j 8'/4Qk\nD qcX)%d=@dW+oA76lYָ̛x:Ӿ Dہ乬i-yvNLLm|oL Z϶Tb6,4rƢj+kC]z^͐h(wY4a6*" " " " " q*K*),HD@D@D@D@D@E@ fa" " " " " "P%`U}ЕhR\%QʿU! " " " " " "Ќra3Jh0H%#Cvbh{}BĆ)t,{4ơ_ +gkZ*dF-%\?k9|̄-q{5|OQ 0hbw%lEQį;rgwyƼrZrb'+|9[%udEf||O;4uOS`" " " " ,32Q+ulxot%+vfV 3#LozjE5n1,}#آN .oM¹H2?cUkbpqMӾ7b)il.Ǽcsר?> &_>goV($GL}z5u2@" " " " f1'`g,b9YKÝ@fe%n2zS&~ARu%vPSD5T 'zaQ)싼Kt!~jX~ũ+na9s"S٦9Vn c[;)!D!+6k*U{.W1{qCgG J}Ƥ\iό#6#襤s N%oZj,Kb )*Ju[9>ė`LWx?gKb AlE@D@D@D@sM'8 ⳬ{Js/y;qR+hea,zrGs0Jϳujx֙ͤ_eіOy NFm&25=jGPZfdʼnKQ ;!:e5Rg<w+&l “]FJq~9S"2z@Rf3UצEqӡ??~1؛#nKx*}~i۟7r }Қ_Skc]5Ϗފo-̀ļT:seϒF$[9=7805 {7L{Xx/j*zzsޟĈVβtDL&΅3롶YeAI'M92i]Θ!?GY,SӴy^.$ PRF|΅纕r$[.\ IDAT@oő|] X~Hk/R)#7%R9'C aH ol- ,+@vu N VV1 N3VXRH^e_$[x*5Y>' G%s'.qfpD|>.wm*gnK}igKW݅ҁ } \뮊jI?̮0JA;C썷s;K@e+3-7oUY^E  +OeU;'O7._>H3q}H b#o\3Xh&"Yc>7gEY;q^2M&lA Urc މ鄎}mcꝤ6X0m!GOc9 *ߙK=MT{>ވRHHe4"@GZL6g}hy5KںDcTE@D@D@D@D@o2_:atd cۛ|kMk_7],LD@D@D@D@L[ M" " " " " "V_&D@D@D@D@D@Dt)L" " " " " "El2HD@D@D@D@D@Lt+)" " " " " H@jrj{6U~OfdRS|Liai]owTTV]41;" S+'" " " " " "~ΨҚIlxot%+vfV 3#^LozjE5n1,}#آN .oM¹H2꿺뽢cW\\:z{Z5{3.Ɲ"1-6_k2@}fl;.,bK˧0,_Gg=+"| ]̫MsjDs7l *%¬xE^]h]=~jlqއpN(c9mZnO*6n֭ z Wʻ/U{.bKhZ]@vyj]) L4gctO2RωdhF@Sr.1Η>tDL&΅3롶YeAI'M92iIՋ+C<=~~|Y,&$6OŞ}J*]tj\x[)GraTrW-8+A Z;cs*eCJ<dȀg~"3Lە |0jLߗ`oLr©u^ t7wٹv/,뉛n߀hx oec4K}ޘx'J64GNY 8~ÎWe#Yś޷m _7]C:4;^nv.uaAubȌwykFy 5u슋Kgt{8z]k,fm״ wwĴ|~lu/$v>3TX۫n1PhnmBQIv/5w{cSbz~OJo & h#xyx dL3α|β^2++)u{?;2 *,"ꭡZ8Ŝ J1ďg_%^z T33-N]]qs Ι6QrcCaN '"Y\CUs!rً3x<;ZPz3&͌H{fɮA/%'Op*1|Rg%]q!SSdxǯ-VAA5@chTƝ _d'%l/@<8C [?=xewkӻd~1叧㫰p)x>֊J>e\@Nxns+`X ϖB=1ɸ1Y}3'1% h>هiXݑgY9u[^ v!VWuGU]Q[JX$勏>`Z)g>;3EI7!ʢ-]k Ldji0T/"Yqb`Rf1q!rNH"}|.>#JgI?}ǿ{|z~N$^"4tʞ%Hsz0oyq`j2.o_^ Cy(qϔӫΔZ{Dfo֚>^xmZ737?ÌY=Z9]уObDj+7qܸf9a4mvgUz_3_Q|ߡbΗs@,tZ]̲zm!|YP҉AS2u-LZW3f~ïQ/%߄76OŞ}J*蝏Sٹ\R}e|(;[Fq$_AWx":;w˧TM㇔x?ɐEf< [_k*d¾ (u2nZ)a>}:Qq9l-Va異{p^,xk,GB Vj⪯Z 3G!cT<\ kPoF=EsJ?_"13X"z>^=K϶IN5ĩJKefeCU-;oe;OU;RJ+>T8s8q2Roe%rxV&4<4Ԯn]]qwF%3';WàE)IȦ&PuL~¹rukJ|6,VMatJ:_0,MPVCNKWPMfB+śժ5VDWͥ6םv-tpe],wlQ%誚]al9. ֓w*yoKxJJ4 ƿXN4xtRW&]yuڑװtV.*RnRݛU-2RpZ5<_U]#4<0füH2(EWp"" " " $t֘Ov`S7gEY;q^2M&lA Kɍ!4x'c:y\>w3{`BMD>=96rROzOY\G?mRB>LvF|o#AϪe޴c7}5^sY5Z.a򝘲urߘml泵Y8i6ᝍտh"PD@D@D@D)`8]V8HY4qS%/E@D@D@D@D@D~~-I" " " " " ^O~K}- }E@D@D@D@D@)ߒ(" " " " "p_ Hu_5CӴȈ"6L=gÜ$o&5Y9=^ `ժoVY?'3_gl)H^ke&$l0ѷ;]~b@eՅA/a+ˆ0%~މ;ֿ3Kגs[>]*Y#+j5.sh頲cJ͓wv57b V(J;4z^*ߓehWvy%N#xw\]HY8Ō9OaYken">C;/,K쬡8zkNp1'~R)̊g_%h߅WC-NwW r,M+sԭ Zƭغu#!A/j_y {El ]gڪ-DDD_<\~o[8bsLnuջغw-om!ڕ>_3e:}Am x :Y*4)-; fڪ|WY2C>`gV41~N~` ?YQ z6^[OzU]i!Sέ-:1|~Z 3̾'8gdHD@D@D@D>}Ӱ(>q4:bW!Uᨭhea,zrGs0JϳujGZ]@vyj]) L4gctO2Rωd4 :Ju τWy*^M O0cozol-'O m|w)Kkb|EO te\a倬8~LvGCVd7>޲=-GV@l?2}zTNH~W|JJ-F!;p]lv0B-컐~!_GZi0ʳ#߄BVVKb~NCp >X~f<ˏѭv2oUB1^'`(l/"+ޚS}M蝶xv z渁[l\F9Ftat<64GNY 8~Îם?0nMK-^7Uk9ZۘAEϧo)ʇk7tRi*vxc瘶i۴l-o,%^Y˹'XX&Cg2֭UXǫm1+&r-:rtjBYϨ oR3*(/,SA37S IDATU7"V[/<_Ձ.f9z~fo-_S^$!%0߅@cA\O\bz]$,=n'9j*3s(j xc-ӧyؑRr$hSWW6JgN'.1[@A9k<+JeRSjWᮮuΙ ߝFݳ5 ]PY~Lz[0z_:fBRgh );ĩ{خaiaʪ!t+adj.r(" " " @:Cqqz]%.ެV4p 3Xn+Cwf@݆Sf.uWEWdfWx[쁋C䝡Ji[RҴmr> ¡i_ U>N*DEם{hЪwj ҫE2: ,qCjNSEhh*CjPxˤX5oZ3axWol$]w 7" " " "p4'Udcafb1{X'7)!/0Wɍ!4x'c:y\>w3{`BMLH؟c|jwROzOY\G?mRB>LvF|o#Yq/)AVJ`>[۞f^Yw27:}:k~*.9EБMY21Zde͒۷an4Ϫe޴k7}5_ӭjȡ(`@]V8"ٸa+OJGk#W3r"S/g#E@D@D@D@Dw*`]g%V@M5SF͟D{cۮMf{+m2~Бy(nob2b7j~$UD@D@D@D@I@n5l&X +" " " " " ra+" " " " " $ W3JX«RBf«`%T h@C@VNmϦqۃU֏1Ɍ[J~*/rZYFuZyZSp.?U;UM N#*Ô5ʉn4}F23jf*?b ⊝2s>oBH25=Sce!뛩zZAeMsy[ K"Cf[Sp.0̪nzhbp^EM rqHLZ#" " " " "P c/ ) gq|}8)>Wn">CjS,"ꭡZ8Ŝ Jɧ0+}}ZW_-d[>2ܳXNeV[[uFB^վK:ǻ<ϴU[`뿼yXmȤvmfйF wͺ]\4AC4ngY9u[^ v!پ;Ԙ?:a^V]^iW H?M;'ӻs"%&*QD@D@D@D@D>0cs eϺ6]s̲zm!|YP҉AS2u-LZGRJO_,_K 5r'i_8 aW;*;Vʑo\l0wx(+ oPv@BVgX{J)q<2_ vj-" " " " " «m?>Xd[37\poݥM蝶xv z渁7 sisF7&މRy>͑S{0{'$Uȿf֭ikf->Gk3ׁ2:EWcN*MŎՒwX=-9,cA\O\bz]$,=n'9 GefeCU-;oe;OU;RJmq߆SỈ%fpHmE6YYPT_l]ݒ֍J:gN'|wwpn"" " " " "p_T>INxb28v(.X>B+śժ6םv-tpe],wlQ%誑6 cˡԝ=pqh3T;x <_V*Tָ^ΧA^8?hPaœofLCE*7YrLi*,2q/d٪=$+Т#?^[ǜ /֟}% ӴcE" " " " w) daue8Ul{1ۉ߇ZY\UuEmE++5cAO֓/>iPz5Tdzo&݄*ޝEY.nn䒑GKejf.YTr쨿ʊ4G\p!7ETYBdf_یys]Żb9řKY+(㫝봿oNuLmFe$ w`Gc[0rx'vNEʭyzǐ=,PP홱j&ZH>ܵ'4ƏAօWI,:y5n&gdqe̊ҕ1c?M=1MxQ2Щڱ}QB~*wii;l\ =x~f(S8cv^rg|Ji1a x+<݉.'Y^~W!zv\@O&FcٔZH0hP]ݣ]5](?-b|C2`WqIa]ђD~ᚖBRO\ġiR̺QE? 8G-" " " "pL^'x`>HWx N^GU  #$p~Yue5Qq7>Y`f2%PO܅uɧ9x/ l'0xW։:{|÷ls8٩!COJEWaS獍Tڢ/Oj#&0f] W6궁E K<͛m>W`Av2]H>_~K9~_e}տ - jljH+;2gBg05:McТfIډܚ@.y3Gp>ܨP~^LOcDǚ[&R[AOAQWU6 3)˙8qkSikDl%H=^OLG%3'@͐D.#<"y5]XyM˳ MxΜŚq=^Ճ%/kkAק3nb gǬyVJVwSd}1!;W7Oѧ(v 3>e[Yz܌@2Ba}qw>:a%.^/S\ciAe*NLZhhk}u¯M҃>]MLY妊Q]qز&bдݹz~V4mTNxY|qR%1jкxLEo?f+!gi\xzw,|~lUK17kaxWYF#z:-|QGvVTi;|UUΈmPPYCڄJk?3W@ZTsq0/ѠLn\XƮ&d&dŀ:hБ~ 9;>a7=ɸi+ِK%|i3x23MSQ"$`&+9UOǔ}^n! ߡ.cSRxS/LVD@D@D@D@DP1E@D@D@D@D@))[&+" " " " "wHw˘.m6o#nyZ뿌hk}iDJ"ʆ], ݦF}ӟM>=Xeۊoe`ؓǵS|d91ek<&m{gS*Ϡ \s {x1ul:YO]G}kDfيYL֓K]JƵP" " " " ) FSeGq>FX}&wcLTl$m[T67ۄiiFIywM &35Py-M[m3im8b)"2=(]d%''vXO/tq"4<+7VuP2wFt(3^eu^:O*$Gg@o1E3}ȿ!0>^rVbȇ/MIwk3X%,z6cSBq)cь-33<'`XmF|c3_D@D@D@D"*O',tJ[goˈ-.**=#NR#߲'.[ʴw)7roF|UZƫ+ܿtt.h\>} ˙WzLYT8FGb\ $͹+G_%fƞKG$py%3!ğ űô΀ ?"I37R"[Z++v9a?ם3}:Iۓ0`"S5 > /enRt}TNa(-W -hÃ-FkubM;@Y7- >H_v"d^գ[ >`]ŀ`I;1KK9;'"ŬT&ґ][@k2}X5o-$ `g'4ƏEj«^$Mܼf732fEʘ&X(\%ڱ}QB~*wii;\oǃcAlxw:MJ_ `%|-ꈧjn,I`f2%PɥˆE5)Jm=E]Uu0h,z,gĭE+OkR#Ex=28Μ8NXdY@5A YI:#ie\БW՝ԡqkM.fuU gM?dxޝ5M m|^d8^iQ!]L|Pa ot./!^jgo:a,FYEɐ J5=FN+S鱧{jw IY!.G2(y$͚/\6DxZU۞dܴlȌ%tYވL烴|emĉ,_M`~}͛?zBy|׌oE![[k|`_,AVݸ4DZWt˄2ia24dsWO4[gOb؟(ϏmKEd;]@CtAm }]8^++w%?[`f,=YoV%Z\ &-?% )6$" " " " "  ȥ[IKSRx)6$" " " " "  He?% ןbN" " " " " "`48wM6Q`m+z5Z`ONZ`xLrm3ϰŧ NeӒ^T;Kٟ`NraeGD@D@D@D@D@XmS _>IFbX0jan<ލ!rr0+iqВyKA};ePY` 9TYCYݒիAvR8;/A8_&dZ{\vO3G9l^ɀi_OX6<T3CxeO&| ~5Lj>K h4ڟ!zx&̼Ӿځֵ_sA7ػu+JW#NR#߲'.[J;j\~ʍuě_eՀjj'#86R- ާa9^BJ) ǨHUr"IsW8h2I.,RMW2?BP>L; x~,ӅW ><U>gû]pjG@ɹp~Yue5Qq7.@4~sdK '|ˡ*/L,RK>Sx``N?Iqūj['h߲msd =( >cS*]]L76RiV:a%.^/ST*NLZhhk}u¯M҃>]MLY妊r2 ?57֓%]l畧inBusbsg4SZGl%] R:?@@k⟡_mb(pm;'"ap*H !p2g KI %`/6v`H vƯV oDɐ J5=FNH=WcHbq=y?F#n|y2ueM%&i{B@dsO$E@D@D@D@D@D԰F@" " " " " "p HuE@D@D@D@D@n^XFd" " " " " _@ o,#Rx2}/ 76eXڷ5?Z}zm tnw[O>߈oҧl[ {v*,3nDŽ@>-6s [|\@eӒ^T;Kٟ`Nrae5Lkg|L\?*NTuHO/w1+Uc;WM/AL 4pcR1d'sly๷;Їfv ivW5rXD@D@D@n) FVeGq>FX}&wcLTRl$m[Caa3yM( ߞќf4w_ܔ`K?Sд5-ơk:3ݞfF ."2.73fs ЪQaCdg1'lu9d+^BNIŕ;X;;~-zST8vÌWY7s0l^ݛ &M)Mi," " " *e% |,+79ep]n8j!4aq'[M͡•^cpv_Zd_e)iqm큧4&3'r*ټ2GSדGgC,#D˖#,XUpo^; yo>M? c&0F{յ?xΞq'X3#GfenG]s}8F']c׭E" " " " R@;h]U{χq\ޭ3\eW{PFe~ 'oeڻ97#ʪ{N_Gpl:fh\>} ˙WzLYT8FGb\ $͹+G_%fƞKG$py%3!ğ űô΀ ?"I37Qfs6Ҭ?Kxg//Qʿxp</'g~g /eأmv9a?ם3}:Iۓ0(tKZ?߁l]z^9y)aلvMt$j|[yi[/cʞ uضgƪxk!Hsמ/V@?*Q[^$l 5׸ŕ1+JWƌ4xl,F69EG0C#tvw$jEeb yM>qX~[f:9N!GSyʝC*ń7&|t'^Ȟ{d髂{]q!=adSj!䢕BAvvvcvFpw >t))#q#Z/,}B~ר,r,$Oc쵐bm*N{/H@cK:uhE1?͙1W8?:y |%Slt@A\ d4-qw,骤f{>hvI|>r[dpfja5vf.E@D@D@D(`&+"ID@D@D@D@D@n_rK_"/ םI" " " " " w^w (鋀Rxd(" " " " "p Hu/ םI" " " " " w^w xϦm6o#nyZ뿌hk]_-$D@D@D@D@6-hp67bl*V~k,< %9p>fyaO lZk?Jo'pi ̉_.콷cԕdYf+LbigjZݽ8tMgfLqzyŰSDep{Q8x8gcFy{h y.yBNIř׺Zp0Xn`9ټ7$M^ڣH" " " "ph{f/)]f 2P8ˮ]Wxe7X\Ii׌ĸQU|\e-Pe eJwKWf1~8;/k-q2ƯS aϙG9l^ˣGdz!x"eKf*U8|7c7V1Co|bG=ڟJd%''vXO/tq"4<+7VuP2wFtS*b̤yU!9b'??-1x @P] Yb~SC>|hRN XiƊ-aI2Kf lAqx?kl3Ԑ>qlLCO>ƮE@D@D@D@ai_@گrA7ػu+r#hlޏL; x~,]MLY妊r2 ?57֓%]l畧inBusb=p TD#krTZG,t*[<ˣ*,쬨E/d+iOiTX>_{2j4 N=|#\2ٻ =^;bx?=u彜L g" " " " AAwg jCk?3W@ZTsq<_%-%0zDہ!ˇ70_׃vX6Q&H>2]( 鱧{jw IY!.G2(y$͚/\9 IDAT6DxZU۞dܴlȌ%tYވL烴|emĉ,_M`~}͛?zBy|׌oE![[k|`_,AVݸ4DZWt˄2cSO8h%#-u$7w6#;i"" " " "PUn©$?E@k|.F[MyyR|G1%wG" " " " "sߒ z hhV}75+gkeN'" " " " 7#plP%'q_ ˮ]W DzO4oiAe$5C5+- _1d'#4jSeBVǵm:/4C|S ?~e^ñ'>X0Zd Ѹ𒬚8 -gȀ<ަ 73*'[R†'9hkLׂxC ?wrqCK)}]N-YKβ}bé~$>ђE} E?}],l3‹<[5^ {27.W k8ʍ]%_? }' ʏR6Zғ^$?"Foe>՗#.즥#=Ng @Ȝ/ M[UtI޹X cϔlsn /XGʅ{o/jQSM_kӘ>ً_žD=+z ql׸2UI%ɨL?x՗B$!" " " "pi_@گrܳ|>|Ѝ*-n-#Uhlky?򈓁Ԉ|ˇ|րҎr#'|fWY5u{oڸMGg װIn!ZqǔLc}* 9q9welsh4$&+ğ !l(MWw<^@Lq~?IbURyEϘu!J uLeC?Oze1\i HLKمpFF lc^lΜO||*j+MDqXI]S=kYTs,Kԙ N)(0Swv=9q]*Y=kӯs4a8fX3,iׄDoOeːr'6_R7:;!1$_cHu|mêVL}.i],c-U԰F?3+hi cT8.LR|;ՕZsdg8'/U:U@ߍ5 ۴gm6g~%m{,2*lLSI{l*sN6E@D@D@D"PFٶgƆ^:q+Y|Y% oD-օWIl:CsSJ1c M=1MYQ2L}#}˸3m9v'sX~[Gs_)hvrL4/'_FJi1a 5щj.[ /4gE%{bELv"3%㏮ -vCHXzr):_KŻ8\ِHŠVTsб-*P+ȽWYX5`MW8Eɏ]ύ'-.PǾJ6XAKLr9b"^@>mq(u_qѤlEj+;x2a:Ex "t*;w⧎n:vUV1 ?3 Zʂ$g+TM xƳ8e.t VCo'@zM؉\~:+]U ^SSdOql!PJR>ǃcAlxw:YU˿@P?J΅m<#.)Ͻ7.!,-RtK>Sx``N?Iqūd7ԶN;eN j=cSJ mM7UiV0-Oa`IC 7oDz\ig y*4(P lKʩ!V|l%.24]aY]:Q4E}=#" " " "R;?%/qyhΧ+StlXsKR( z WMaМYHYĉ[ WJ[׼]#"d+Gqm/Q̉E&e1d%K#x>ȫ~0EG^M/Vw^Sƭ=lC%3'wfk7V}n3J? g㶶aI[m3oFڱ4I񾪧{2EBNimT|䷒s8S W_ |2)QSβ-]A6)XҸzcmQ1[BEcU|~sA"ċW]l yųwf$_h[C<ZiuQg3߿KUTeVm~oi\t&Mgʑ] !;^oBU%&D@D@D@D.ydpB\1/XG>4̹e>V*NLZhhk}u¯M2^ U|J,rSEWI ]qز&bдݹz~V4mTNxY|q+.x7SۏJșPțZc@Jn* ܛ_V*-tDe'24vE!?eZ&Poʘ6QR̐>qLq`CLĞrz8 /y*J;Tt =#eee_D@D@D.gW \ yq=߽5ҢB5_dFH@;0s| zP;{+`ن7S"dvL'PtǞfr1$gᇸɼF`tv7k<@ $|BU>W)ȼ´(7!'?-Ω:j߅]y{ytj!̔3r;z#l`ii:w&Eo\bFT*/P(L,;|7v~+Eky!4«9k i Xv`dN$u}BZ 9xJad'I¬*`aیN(k's7vdU1IEɋ@K#צ%F3$v?vlr6,K{4#_V #[" " " " "p h nnhh7r#ǂQu9n I4o^yOy+ʖffP=93i6¹)$CWyDCָ@tlW6z^^p1ql.άCYPoDww@e ;J>NCB :`B^HtaaUFDVPQypP*QP\@E KXȾv[};{HҍAxr{:zroWzbys"t װo v%r͛ѷwcuZIi=3vbNW0D)G=[m q|W>ь GjOEΗDFMC9Ks5xRDX_lGVu=qi3S9~ya>g'r< {FкKS9z`?uke 10_}>O`OH2f2v :Էvz5M~Ѷy)t>?iBEqwF@Ps9Oy?_s1|}<;OJXO<&k W@zEqDž$&WU~Dk ųi/f, ;Ǿa;C'~ж[cLqHv#UmҺE 8ms{G !j‹g t8FDe\,)a.[" " " " 7)c^e f<~V-g-;P;i,|a3cˌ{)s~\>3樮 a0▝y{n$zp_?]y\X_808㜡I;y峍rUl迟Bw.sUlRp&ı|-=)d@ -'//-8^[Ȗ4;J`ߩ IB6m@Ը[|Ʋ5v~ {Il֏ͥ$aHs.-Guq Z8N.XTH/v S_2gUn_ͷ's>:$* %" " " "ī`? 7N ì7 [b` Вsfm>W+xk I)(}>uŽe|NAVAI*v[ #MpKK 33/"D6wǛxn8P4ha)9Ui=c)'n,y*_ۖZtc;6rx7$KkbO#ǚͦj9&=ն~r G:9.;^BQ~>gã'.т+0`,}cP_Jjp qN1=hue̳X6BzV<ݱ]Ukx/~>7/f: +($=3)!t8z|o# 5ʩ Ycp0*<\W2{@kB#x|=i6֣i`̮LKm nKt;oµ+ $ON`s6*YI<̶Õ׸cOٯ2jo,A-"V;Q5^5e_|^]j.yJY0Qo."_&&fh7lI:=Ƒ}c#iw:_q㑺&5e ~Ny^5EKx-%^D>m.? 1q"KW|hknu񗛀LJݽH-( Gn ]#zϟOM#)[uMςn5z Wn|Uj\j;/͈@ Z?~4ixQa" " " " "pUdAUaW.`#uVE>ǘ1Yɿ?9^uWސD.,5Ҁ, K o/c]$]Y@E@D@D@D@D@~I~3X]nsG`Xǭ}l,-G{7)~ bi/d8nƐR " " " " WE@ZNq X֟5o瑧GӿmRCX}763n$]ZŔtIqDr՞ zFwӪh!!n'MYc{vlyY$Ы1ML-Y6e`lB*ƕox[9@ Ø bf)7`% clH܊K=K^Z?AgB%K ]ķ 7kA.D?\2cQUju*6vO]'^yR}6f3 gT>/ƸҬC;Fܲ1{u=O,b+.#rg` 3?3*jQ"?މ-;w?VI|w3 g9u&sI\;T orVlI!0-e|WoIkt>6s {w$69Ks-_wz`/Bk!)U i0;DGMĔճ;S|ղ%gV7\߂g֠AIc'⬫CIbGS68\5ݞȐeN*^Kxƣ_fPfO1CzZGcwcj5sQ'[6PUT[ {gT\U#+V".ӽ1Vo۶SUPw:H[JSs||]ʆݖS,RN#4cnd!hh#ߊʹBYյWl@5&^1yy1')ϢJwwJSʚz$E@D@D@D@@K qN {+LWNeUjטK쇃+-!T;0КPr?~'gO 2v?a[.+e%)n;V,a%<|T50yѹ45(( "S6Q8g,YG:R0xAN:*+uj)h  OXnv9ChŨwd~ 3hJs ˯v4;*(tڒlN9͙ۨpVr-p5a넌ˇaPH;m'B^Y. );+XT|s,Zh>e}hΜ5=3E.Ï0hꗕUŋ2>\x^ S^ Am''"@s4!AP -5$~%p$!#[&$JÔ:ZCal!/ge,24a_9`O`Y+Q3{ %͒cůpadSD@D@D@D/\c5(0ݐE@D@D@D@D@Dƽ22D@BH7D@D@D@D@D@n\Ink+#N$N.tCD@D@D@D@Dƽ22D@B\ntXj]<;}763n']MŔtIԕ:thQ.Z9J{W;~-K@ zbys^aߢJE(7o 46 z 3gŜ.=3BajK=hIܲelKpďMf9W{R.:t5 2n̑{8^  œ"RDzp*f;JίLB E<;96s6]p9L{]T,|(& B/%hBo#`Y1MV1A'8bf Kc*FJĈ$_a`;f"o~W.3h:򄅌6o%mr>6#Q~#(3?svI{YP}0kQ$/ލ+q#GqH_f-}O7XgEU<#CsX܈_\_.ץ*<ʻCWt6^aq!եg0hlڋK'NoX·d >z_!S*݈xէyn1l[Ŧ9[?&Ljݧ1IL1Ovp@º>VGsEG$ݍi~u_ƓZnGkjjcI W1bh -Y6e`lҎW~UUj1+91& jDQͨ%}W,؃a]Iq;Vw?!o{. :S8JB6 yaz3 o&,VF^F~ V]jyfrg?{37N<,,zʾu-6vO]'^yQR}6f3 gT>/ƸҬC;Fܲ1{uu }$o^/r(2.|_808S%"s 6sgrL:*wQ-d'俱Y% Ԗ G'Tv)ݑ_oZ~f+U͖^WS ,&Uբ|Mwf V,glʼn'*+M@(bZ]apU^%ێ|L`RZ4uF|'8ea6 n|C" " " " ׷[̂4;2p 0Uu5+e>IhbǗKѷIk xt 3ý5tHO~oyp1U^c>Z_f"byk ׆_pF%];*Ho%.2cnUm o*V  icg\њW[c4dvQQ~r̰l6W9nԠh5fbNcFE$5^,1]ԑC.a䓾>Ad[D@D@D@Dz1?Ȍyˣ9M|&zl)N0'ZlXJNU|Z0`+tXĉ3OkR.rlF\đ8B{X{~9l60wܘj5k}h<֑-EMȁؘͬtKC~5u֪8&FK~4 Izmu|;vR9V,Q?]񽮾)G!-eK-ru$PRC{fSB>qƒxJG:jSY5JKLx%&>8LJ܏ٓfm=cOֆJ*YsIΆKX5mm v}W8/:}EDs`juPA& g%+6&] o||1Y3Z$/k %)x>F Jcˀ1cS05sLy4x>Y׿Ue*j!hz5*(Zt%ɣkpܷsɝg%k_Su)s\g \\Zr/ޘߨLh zd ٗW CiQC&9ٵ}I 4mnYvx6gnI8Er-p5a넌ˇaPH;m'B^Y. );+j+օ9ro9f>@@Y͙sI߯G2#;e0sUˤF3ꦙ*TR?ӂ<<'P!y[FmvٙhGb;j"'un^[%e*5'v,d|lAu?DzaY)E^2&X-T~wq tVzrc zD|_WQ*kQ$h Gޞt؏XJf n2TJFǧűTpO>s0\$\#I4+" " " " "pRÛZHE@D@D@D@D@$^^y$yTD@D@D@D@D Huேf}:`ml,w.Jǭ}l,-ՕA) ^Ow<:4?ϒw+In_AhX8 G]_' " " " "p@Kp);+/PRӣ6|)M,,n鉘:+E9D!c%-ŝ|;F_%/Xv0o4mFڶxT.5Ց5 ذG%<{4.(h Ss^y|BzHdO@שwmVrWxj+ c+o<'?yvWXZ ~:Ҿ1'#-S-v315Wy~9ODjy窡r~~=_N5]h ">):5Qkh\ɷ)p#r*Lr YQ@E?۸^=y\jǿ^]_" " " D]]̛}1out װo v%r͛ѷwcuZIi=3vbNW0D9G=[m q|W>ь GjOEΗDFMC9ݻD{zFIcY8YYqLatn"yԃ9A.}OMO&׽U3ЪT,|,S)B/ф &2vG:>hb曬/pc1OXqL:8TǍĩM$>#0zPKN*v;xDۗd`1>s2`#l˚CTҪ Jk|3~39뎟(b5}JVXՒLB߀=(fl d-zGO}3s1WLBj=624 g ~"7k.e+WͿ7$-?Р5M^>)ſ)M}t&dۚ73şkZWE< Ɠ=sX׷|j_G'" " " T@7oхGyw+:?.$4#Z-M{1cI< `>GOP?c[E4oӖ-^8Ɓmؔr.mQ^qR& Y5Sf˜$FJ'; F] atFA#9\#ƿݴj}?:ZH/{I-x^Pg[ݓ揵c˛'1^Ljnf2\6o̲D/ctLq[%%!^ě_܀0 O"5s+jE3q-'Y?c;x"(A-(}m M|~!-*y#I5W֯n_N߂̧ȱb_ƜfXkw9᭞%/֠ON!.qxM"_ӟC.y˱8_Wc" " " "p\?\؆֮c K+O8Ue[ F=lPw&^X ER_U}6{ˆ[v f.ջI޼_lPe$] pla槕qPE-J;Et. 20i?q&,ΤZ*wؒB`DZjކQ%m?͜I uB\i˵wǷh1z)xIMĔճ;~x4|TAgQ(Bk!)]R [jL\PS)2M#IǔяY[RdΟUTG';*f #ՙt:+=80T];tp5q_n_!+/c$`əEՍʸ$" " " [4;2p 0Uuuc>IhbǗKѷIk xt 3ý5tHO~oyp1U^c>Z_f"byk ;3_.WѕxErW}+ w+vm)*(黊jES8Ϫ9[U!h%q+GnLՎa6출ej*8/g^fG.txqZ IDAT:|:s~IIb/ܛDt|#?# K{r֕Xu|q$ʼnm^Wv+i9>0kI/JHUjL42c?8(bNdSxz7F8nS>`ES 32qFSyڷԢ۱c?|/i&qd>ƧОF5M/ cE57Z)qt5k}h<֑-EMȁؘͬTC۾5u֞]ͦ c9KR'b@Gdp&X¿*e߈ϳG,O/Q9AD@D@D@@K qN {+LWNeUjטK쇃+-!T;0КPr?~'gO 2v?a[.+e%)n;V,a%<]so]OჩQJ@]Hm(3Ȏ#ut)xƠS@oghU+d@@%O9FQKge3X)GR|PQ13/*9-CG~1Vw|ǬVel{SAusLދQxY2:WQU;hOM:mI2k8f/%7k9vzVP%~Z)" " " ׃ҟg0pqsk)xc&~2豓}zKf,d_W5{71dԐ{gve{_lMp[yp^ ON`s6*lK<\p;4~:!Fa"Ҏna{Iw޹`M_RJtamNx=Ǣ@{\[惙P֏Yݨ_dGv4aEIfg.f<7 ShG: ZͲԦ$=л6mX~d}^jdB^_D 瑓ۀ i_"#$>I_:ga׋$?lW74X |]m;9Yx&$Ҝzךxyr?8-_[p[Wz_/5홰0Ч@R0i"6 6XrFc9~!&;^GcIXmkk#P_6VEVK^km)*PڌW@E@D@D@D@D@~'Yj;AK3" " " " " 7ݼ62k(РAC_a7?@JJmU@}7wS%?2z"/" " " " " u, 3^u G aK_h_0N@ǭ}nq K\aw߿<.Û@w*~G&= @Kp);+wvyz4ކ/E\<ōqꉘ:+E9]141 ( K/a[;+vt= KJ_ai=Z!,?QmƩ\]tq<;A|k}i\_z& 6FVVi5ݱFN`aczNTt}1cNR%:(FZyZf3ScjBIsUC%:5'<6l{fE]jN:W.jLp `WR.*Gټ}{7_s?`q=cG.tqE 3MTCy.ܓeؖǷ~rį]t| kAd4$#p(ݽ4GIQ#'EeUvd _gǙ63ѹxvr,m.Sl>5s(>\V@Rb`-$?/jMm"cp<6}?+fz5A'FK ݈_o ; ?4;ci"qw67$Q3 CL6- WOd|_gp>z43_1/ct?[!V/vDMd1:oz 2p3Q?t, 2X%o]zxܿOՋ#ɭysnߕKtS]XKGUxw(>m|8BKK*?5`ٴ3DqOc߰! |t Chۭ1U$_O6mi" cضM98r~!DMxK4pg0O c9c+c'. uu}<6t r5#;H>FwӪh!!n'ݎ5K+vvO?֎-o>Kzu#vɛpټ3F ^1Up_ʷJJ*Rb O5Y3mjQ5mH?gwS._/2 _ЯFoCs$OGTLl4 (zFwqjmDμBZj|J>l56E=obAC5G\h=DtöOIןc缱KEjhlKkױl%}'*Sl4C+v9 5zgX?/ |^ԍq/ie@Ƚ^a-;Y\W7=O,b+.#r̩` 3?3*jQ"?މ-;wϘ 20i?q&,Τc.韫rU orVlI!0-6TI۱Oc3wGj`C|*wǷ笁%q9hB^b ~+~l#VϺb >A[Bj1[;@W'q͏O5+<5$dOĨ9u"nSwj_ re{b%UKz p͗h5qvpjdξҔvM>4)W Zag֠AIc'⬫Y)IbGS7/~ͣo'2d:>dg{k 鐞Qcƫ74 |D\ETŪ @n[EpF%];*Ho%.2cnm;Y%}WZmhpjoh?ےC>b,Yz0+ @)wB WPaO*ıZdNNb?e7XK"KDZaw J!9سTT֣@Əq-k/YdagqƒxJG:jSY5JKLx%&>8LJ܏ٓfm=cOֆJ*̚KRv6X hokKxq50yѹ45(( "Sv}\ B"hD쏟dFvK7:=>>x<ݚuyn|vFx"%y܀Oӭ-t*.wo`gSR$e ^ԀV4whAfH#O0+X9f7η8r!5E;*etڪ,ϰNCk9w;_ECf'VݷӫmA-=!6vs]|.+NE@D@D@DƖ7 .{-9ooP}4 O`Ɍ+(;ʆy_2jDbx=32/I&-b8 pv4gΚx$?_V3W/Ll4?s1YGYTp;y)ͫi~u(g=3HlMəvv:~Sp|O>{UMC+&,:g$oq2B~D@D@D@DqK"#~h7}9I\]_ϰ64< IW_: (" " "pe?ܐ767} M}ە/ t Rw|'?que$" " " dYjGz7p˖c\jx#P&" " " ) K /L ȌI}LL0." " " " " + 4ug!G7j;[؛FȖh|4-u]#z/\Bd3X]seKOKX>!7?C_H@ZNa;lx;˜7_Y0:T' +oJobcY>.} bh#|d ٤?ctΧaݐ+t}#U7C-0+ /o4Oq*s1س;l21CkL(5E@D@D@D+ o xj+ ##w+|كG</+:9DK1r<=3C^[χy~\!?7uu5jIҡr5Hiji?d=hafz!jxkt9~ [1`_CU_5hX 0pﺚԄ숀B@wfE{z5S}`WR.Gټ4ƿb&m@Xb?LK=~F(4Ur0څ{lr)HMBfU_[| kى';7A=CKUw@IaYS_U*w~1OrWٻ5xbrkAۺ~б-^θpcjA<;2ScOpONNJo?9XboWi _g[hjWcŚ]f hoo 1?2k?NC}Y?_5_VeT oV.ДDW~t]ǸO3+FjU42N5Og3CNc}<5ADԄmdϴٻY7p%͛Az35FiiJ iYnsKSțZZQbW\n("6uXg 2^8<9Sy0q&C7qݰY 9A<7O\^si|> IS 0y&IEf (0?jy:=:%-^iϟoT ^h >td}1gj~SN?3kObL應k K3T~5#1o5#"0l2ML/VY9'V4e\N+%Dj_Kg[YyVM<&c_o!BSF^U-a]b8r*" " " @N}6fGwrb;+0ֹW:qlE!ؚf+2fhYSg(_ C[y~gܜ/r&.NGG7Ⱥ=\PP28(9=hivt3{?oF^xg*=J6!cޑ5^2($M\ttOSwVEޟA$.GA¯PթpYdžQM2{\+CGT0? >rNt(SpǣEG7'2SAɺ-ټ Ǻa}l-U Fą"+&H%'+߃Qj7kМ͈'+xy$&Oʵ i|AntB?3*PmG}Wkݢp!윥~≭u[#Q{+-boan%SkX˕!P|oXYcJ;Ap]a<>k>f]o'~u}Om\+u6z `|'|;~՞W.,GX֣?Zqi +^U|0ΚqZvjHEAɉwVe2J7dUiWвȐc&7F='fogj9UV4ʈijƶ f&gZ¥#-'mUs7`\iw ?N)>5TUsj*$\"w35qn?y Rv׮%g@Oݴ|{.TՌTph#fZ_HM=_<TZgE~[ J CK&WxnV9$giwH!V .| *yc𫞢[ ^Kܝ9K̑]L$#8[WPg3ע q UjK4䐝_Te ٳqg%',\M)*QKXN~u;m}J4N$$㥽? IDATMm+Y`=]:K&:0;p0Mv9OUβ+Ԋ.^*m- ^7%[qgY6ԯMd{KiG;RBkgz 䮗$'̊ot U#ivq&I!sB7-Ǐ\l׸dtYWLϯ vØMl%.\KZɼ YziN[K\pkk_+܌q[r ¹c [_5δWpK5*^c{Ң2Y?Jˡ<ZG-x z C=l7Ǒ0X'1"99b7JqlgЉ}WmbW۹jvxÊa,4 @8m]yJ:ˡ.{c\\F0o76J&q&ùY* `Bg;m5R8~ ߮Q8qhLöu|?.G}FpB?_E 4 ] u^ .} B$V-CMV,wyzR䌪/mak0jx0fSb4',?1?m>|n8d%8.xF>_:k~\?vifs%A35YXh7Sfܣ.hoOvq+[/Pph-XѰ^pemaGe:=HnɁz2~Ù?;tg j_WCNV}&=*`~KSD6o jU .YK]`%n8DAeK38ODzL]” n.dž-pskJΉ8+3׉mr£k{sF-툀%p%{-HY58LZy.qU@B3K*km!#!2c( WڻYf "I ew׌ZUY֝yۼ٥ 7/hCiXq7e~;TeӲًLX@6~I}'np4ڗ{ѩUe\eV.TGwu}t3;^yUZ?랮7H5p\07ǩ $hk?3nND`D %Jvэ82mW3 Fq`Q-q$Y!'gNoϭR+wsTI PN>څ>/t1oUPaM^" ?HML^iX8hu1,gs8p* /~C<+L eʟYڸ!":odK027&Rp?ˑ3ִπa:y0ÊWA?ն81=y3.PN 9((91~N+tC&6ū - 9Fhyc4T_r/sw,y3Gw1l]ċC\27)Q-ѐCv~Q}+fϢǝL3(EGJf,a; yM:(ќ;uRM68tM˰RtdVt=[ӶM+(78wz;Iw&)" " " " " BK*G? $<Ӆ{un%.5j m0vĊ-*L\:|,q'r@ScJj.A}.D;kKv'ު8Qβ+ ~/cNeIih;Sʼ.k"§VʃYx+9GflGk{6o>#aFObD0ss2-ȡk$쯶s#loXiB8mEGRYE-v}}2҇ykQ2;3_&AM]L0Wn " " " " "7PtDz"LJvC 1aU2pҤ" " " h(*ED@D@D@D@D@D@%#" " " " " xRTD@D@D@D@D@%#" " " " " xRTD@D@D@D@D@%#" " " " " xGQ-.'pXZ[?#cPb6/Y?V2<z.Z"nSr\= ÖE=Xe(̀nMq 3AX÷5m)?d3d*f1wԟC1_Z9))vEyyɄK|1d< 9yU2oeU6f dKu6mzb[p GaNƼ*Z»w_xt2t" " "  h;€n(%>uxg`4B2glI n|n/f48j6w.QvPzL:8ބ>>q׹0%Qc5%OՕ۳׉mr£kz61 ziƓ}%pCV p(wm\gӏq&M.C62j.p%KX5p,:w~o6I ")M=Γml"CO4EDU{5#\?1%k71>xlo"g:qv92fD"fDVÒ)ynjT G[QU{$q㧒'v)E03/j IDv:;q_'|\ɩK+͏ ^AD)ya"&L{v-iJ{~d}jGKy#{&ڄoe9L!Iy/PWeo3~I;-ǜu8ʓeֹW:q,Ŝ&;q3R>jUʘ&O{§Q}vd3bm'x͉Hx\ɮurt{,L(l8[!$+!])U6BSٔl#C%2v i}2c >ջ±w7ʖQZÊǧ?;X\g.QGNUׂg+@\FsuBK0ߜ ްpC1MM)?sb9}n؇]0{X,B ~8CE^l͑ilɡχp(xD9h7B\FFCGl+5D<'%" " "PL^iX8hu1,gsԪT%xW?qYCDt^B6z `|'|;1şkzg@0NoEǾVʲ_ͣ`HfE7Rʪ4s8dz/) {+'E@D@D@ m``ѷ|N=l7Ǒ0X'1"99b-ȡk$쯶s#loXi|%nèPCRYE-v}}2҇ykQ2;3_OK+rYÛeyRǨ6]\uퟯmMbxk.E ԆD٪έ%}x{/:[nK-ڰhe¹zߛkc&>l?_c/cٽ\FI[3ߛ`w}+Kbb~,+rW4,m/`{zz_PrV j1MZ'e4ZXD@D@(r>PΈ@5Ot]D@D@D@D@DzHU=Iz)" " " " "P$Ɠ']xUy^Tc IE@D@D@D@D@^c" " " " " X@ MqX7| [n:jKe(̀nMq 3AX÷ T |ŶX2t2M3zWa_ϡs/V+<` 5Fdʎ'}f FL#$1}&fOⶾxglmǻ~(Q;li=t&[iHoBI\te^ذnnM9Gcue_:ѳ-\9Cxt2moר=w/%)tvSsruǢI?Ͼ}1NZ4\HwChfit]e-d$Sf eJ{7KB׬Pd2iqQ˸~*<:ں3/74Az39m?mk7nbƵ,wݗ*c`Z6{ 6O=1tN\]2/:jýE(n{y|+JCgxӕUjtq46԰`Zzvbw΂|n=ט0~UZ<Վ\ E%SHDŽyUIwpMq_fĜ$( $:we[Dn Ih4)&ҕn 85|'>ONDùinG+$Tk0O1㇙B~Yw1Ycˉ!p'ֹW:q,Ŝ&;q3R>j&O{§Q}vd3bm'x͉H]skΨdQ<" " "P){6o>#aFObD0ss2-ȡk$쯶s#loXib8mEGRus IDATYE-v}}2҇ykQ2;3_&AM]L0Wn " " " " "7Pto"'V*edIWE@D@D Ѫ?X\˔@%$$Sxt^" " " " " "`J@/SB." " " " " R8M:p0[An2/sLD@D@D@*Pn1 ^)[e(/[̚v7ZS5}h-qnXR4W̭%%_@?q3`LOUKEn|D bTJ?t oeMmSņghjYs?d®j緮^–u?**U%ǟX4{(eTzE+<`  hQV,tbeOYdlm랞x li=t&[]7#v[]Sa!-hɯx/J~4&Uvм[ 4+SuVND@D@D@DRX⻗X}zMN&-<]8si V,5^jw$tE&+P/ѭRZ\[wof&(WOp&^W*[Y452nƍkY>NQ .7^d² w?$oatN\]2/:jý5C[i@WI\o[5iQ#/n["`&6m\7sK~'-0,|olY17%wMŢ ^7Gm{~ʆU^PfYo_Yp+OSI/<كr͡_8 d'ry/5Z=G8 ~$MQȎŇʍavGHߒwa{a۝繓AzgQ[*"es_a*\D@D@D@DOP+˶|V?w˰UJ84颌X^Ycq4P nҨƲ-ʷ(*[ G(\-]Pczeʨ?UVuz))tQ9*Z3MtAqNokj8knFf~=V2w|ܔG5Q}bifzn[* ũ ǻlc,+(ToZ]NSOyy&eݬFV*Ee@>cem#o&*=)KWMx( gmVVhX)_ozUJ>U47 3n^6skb%ZҪh$oJQCyg]%H +)ҏʻmk(6-d(C>P^۟,7?MXyg_gnVT-UUW879~S+OtDɊ\igS0n㟦ҍ{6w;{@@N1Sش5K'>z5.m,(C(OodoD*9Y1RY+w}+W`jWTcW[ҹ^jx0C*[4%"?n':=?]/ (S`ɲ1].l=!)z0/tYp+h:7MT ." " " U+`z[)C:p \3S%xW?qYCDt =~&\GJ tg9rƚ:S'Oy Am[ {QX+qZvjH~?{풦 85|'۶e!M0oݺc}]ұn0~;RRO'U՜Zԓv7-N*uV42^M4979 uI1$ca~K?BԀ$pN;Bw7度xCsHI*Lql@Mm-HaCQ8i?^qer/sw,y3Gw1l]ċC\*@gn*%r/oYw<[KXN~u;m}J4N$$<Ԣx*q$ ͗aeɽȬh)t=[ӶM+(78wz;IwVAJV).yD#b]iŰo ܉#Ež胻;} ieT\$yCL?C2"p4i,];EsZ^,>d̸&Mr0wkk6Q&ft?n$qY=%Y:WL?D*@D@D@D@MܭPǸc諸9[cЈP7 }p+*LZ_ԵٝW>㥽Mm+Y`=]ņK&:0;p0Mv9T$eW]#6*TZoy\;.kځItaͺ 4>=J-yix Y4TjD=.$,]։BVGSk\ڒJ|˯fjUҿb-ߓ?3㣣 _hkgZTXqĖWxp>פXqz2Rr{Gݘǚ:bx{-oerˁidoU~&1MJgAD@D@D@f{6o>#aFObD0ss2s%8~K3rDځ>61s;X(Ğʼ?c{~:ap~_Y aKd5y35ߺh5 i}rue mQʪ Dkf1?r&wU:_̱%X)i\^{{UWVR8ɧo\Jh~=>7rX*MYqP҈ ^ͬ6^M {{.Bv:r`%n8DAeK38ODzL]” n.dž-pskJΉ8+3׉mr£k{sF-툀%p%{-HY58LZy.qU@B3K*km!#!2c( WڻYf "I ew׌ZUY֝yۼ٥ 7/hCiXq7e~;TeӲًLX@6~I}'np4ڗ{ѩUe\eV.TGwu}t3;^yUZ?랮7H5p\07ǩ $hk?3nND`D %Jvэ82mW3 Fq`Q-q$Y!'gNoϭR+wsTI PN>څ>/t1oUd\D@D@D@D@D Np >FׁcX8φ&qTָ.7´Pޟu"6 {L;;)scba-9cM[ hƩ 3xd0Sm[ {a7Ԑg rJ7dRiWвȐc&7Fn [ >s+#r/sw,y3Gw1l]ċC\27)BV-ѐCv~Q}+fϢǝL3(EGJf,a; yM:(ќ;uRM68tM˰RtdVt=[ӶM+(78wz;Iw¾yzz%`d~9H}/ZWD@D@@$J^z }7gkѡW_]k:2i:|Rkgw^WuB <Zߴz C=l7Ǒ0X'1"99bKXJqlgЉ}WmbW۹jvxÊa,4#O`_6 #),\>sqü(ĝoW[D.&m3IE@D@D@D@D@*#7HيMIvHJ9]e2nʤ" " "pn5皤@x"E@D@D@D@D@D$:KID@D@D@D@D@L(" " " " " U' WYJM" " " " " "P^eE:iڒDZ wʩObau6 RVL唣QJ$}xpWuǰf_V>JwG3[S$L+l3Fd>b[,o ʦ=G]/P9VNP;^u&7Cva3Om_,V+9-d[b*t*RM%M6P+9~Sg*$ ;G)Y\eǓ>n^2ݖ=.fmP`%n8^3߀ -dx|~n˗`.}űak:M:B^'D+Wިyl',x!X> _>wPɺEi5ٝ5~&6>Mvx.XM]X#u`ф_|F/8-z9?EOb%Kjͧl׫G3zU kVA95 翢4'[CO@Ѻ,Zu/W*Ф2bzeۏ)z@%!J3Krچ˶(ߎߪnQV.ts,^wA]:J땵+*z KTYQZv Eqkh(W86}9GUʪuʺ]X6{[vEw_pSiDyڊm,Fʳ~JgZ(.V JS SUjjQv9M=兛u^RYeufߗz7*[lfh,]^7)]*tSYY5bU|+[JW)5Tٲb.lT{Ў̯yN6.y{K{@=`zӠSش5K'>z5.~?0>͉!p'v԰PNzw4f>ǸhfYX:@Sg(_ *xik?3nNm9Όrժ*#p5CAɌ*7[N5X=gyWoTzn%J6!cޑ5l_pJT[;`E&Yջ/u w_mN?" " " iX8hu1,gs`Kay>Y_az(WD:e y{ =~&\GP#giџ8u4aQIOm[ {QG Ԑg]T^d6#2 Ѹ[zOK:8ZoG*S񤪚S0~CzVr@&/,G&}+Wŭ979 ̯_8ҴOKܝ9K̑]L$#8[WPg3ע qPUjK4䐝_Te ٳqg%']y*]KXN~׳oAܩǐZOY>5$]"AS2):2+ZJ+/]cOmӊ ΝGS\H5PGĒeӊa&Gb!0{;} ie YEyTxM?vWv5}&L Pz }7gkѡW_]p~7BSG&ZW[Z`ΫC^N倦6]N畬LY㞂bK&:0;p0Mv9T$eW]#6*TZoy\;.kځItaͺ 4>=J-yix Y4TjD=.$gS!+C#ͩck um1TU[~NLOTLOO}W\Je" " "  m```@|!@e͛w@ѓL1\I<ߒ:vOMj;7r@vX1囆|%nè(:r(l;` IDAT>[덍I 0FqeM$b?ld\+=&fϤ/oeOpoJ&2ob󞁟ۻʵ3(a4 4\L余ejifRrኖU3SQQQdD[9Z7kw}ܸS=Y6Ì51k<7ߨW-ڍcżn)56.|ª1Lo5^[*;al~r~;~K8҅$Pi\ `WSg%T)'" " @޿SE@D@D@D@D@D:( xReJ" " " " " I8BhD@ _#q*S7cXtl@eْc3]/g)VvD@D@D@D@D@Dv͠M(eTY,F>cݔSgXpl{,m$mb9*+ڍmjF)3gCVU#Zlɩ#; _i?렶Q'zDŧFgZL@yS:;96Cd&fϞK;Wrh$o`VE9T-Gs2#" d&E;4jI= tSkcμrϲr҆ڿД$пʼik[G4W"9=!qi+]Vq}҇)X͔!ۉQi㳏E!+>T. {ͳw& &$HA©PNƮ<";q~ZV; )X=Gͤ'lY%s%%6WINON3zNG7 +M]9 GXMIy';w/: v)u⥶#ˆ0;mj`.:Y{,~+Vb#8ʍ:y&*W>n'^X0t(6 ː,kĩ1L*w9{WR;y8D 6^`&CBY8[®WzcbI/'9xW jɱljԯxW0[mUݰnY!]K+$]M+E+^.iOl!"RLaXm"־|lD@D@D@DPKm>_̱M%;0? Ǘ/СfbLMqJSР#v[ϞE뫙9ݿXU6[;ҖGNCxkV|anէHJD@D@D@D@D^(~D8 R"8@}pQ:ra0jxʮ#Sp+ jaaL/b{'$@S.fjZoQU&t#=П bѴh}dIS%$"qЍ*Tz [Ċ<:8Ӿ_?pxF" " " " " _oa8pusa;-fF a)[#& Ҹxho#AjxK5+#lyVQFmu0L=C΃1)@%΃Ե)ܧf5Oybc?f:M=u6L%jj *! Zz1zFYtd5u3!\qŶIkZ;ew:· *>; p޿q !xe[!+8a0tļ0w<\ڞ6X,绕yS:;9M Q8͞=Я)vZVIi FZma˜UDWY`k)Yr9'3"?!FK7èθ8?Ac8u'MKs4u\7֓. 1#kǂ]T}Vm߼qHܚ{ޛekqƵKW8}'H7-碒=Efq6W^Urf ^?q ٦n"~8>-~sw<ӱ6*)Q?캘B-^L߃[__,Qa޴;c& ƭUj)\w|l'1w&)>>} X2lrGs8?OPD 3r~uӟKzR~._4 /Oz*zQqU{+P}e[RXD\J4sSWbaXGHX;\iifVDy}+sP(Q:U_ߟ}zeݔA?cl(m:R 8=jhMhW2?Ҝ >(-TʼcVRo*lYY4OIih3Ucj)v:+x|``~%qVuLHyy&%x2wgƵa]J?`%J&ʬ{ÒG5JO+A{+hEcV) QZgPD0Aigapw@eT7^R )oآ|^QƉJgʖU[Mnoc6VA_z(e_X{cX+ݬZ1/cb0WZ w~{o5|<b(r 5 ׀\r <׀hg }7ZHg%%6ԱPAӌfM.teL_]SgG0}~t)1$;;}\*['^j?rp&_6iS{?tc7~?^rgpH%pVn֑vγ76t.Wz.Ù= +;Hna7~f_~*m9=-7~a\.ws.]Br_ g9*w 3c {+$k<-Io=-go lk6\::21x6sWUog,>#S!/3;٫?ρGrU'" " " " %`<<ƂCi8]g\K&N]OL-U {GmN^.DߺIW>PPNA|',puȠv;zȸ4R[գyq7> ՠO6E'WH +WRikOЦA>ᇈ0pn+9WOeސg^d@P3鉥&7Ȣ)6*Ե=6$ev>*2Qߙ y:5Vu4WhMEN@wMkiU=ۿ>s93ʰټhN Z]njZR"" " " "pL/u.TÚz|l&&+6*t5Unٳ~}5'(Zy2mJU"Cwk3^G%SǎM#B$S 6ռON''%5iW<$pw߱g+)9Wmz"^]ZPxcY:u=ڪdP[UPq{2ߕe;\#K_znIUq~vקp04-p{s&_{\@鱿mޗlχSGd:M[W+{j] c$9{w$sֽ:Ƚ|m$>歿uf~滝`lʈ|sޛ'1HO׍:%[h~=Lª1xmI55gmՔ݊(ȍ[8=ՓAoCn >Xs۽>uIق븙X ]q*S7cXtseKzwğ_sGm^ކ9\͆-ϮKUI3|:r@*tak{;ؽIߙ >e\T5o*Nܼĥ+D@y76EԬ)Y|lL'<)惉DM Zx5XjIhsTV1f9ՌS>f%xp436PQ&qrjN®F ONQYO[<+?]š4Gz筭#Wkc"c)d', Mh`F'1s`WݱO?:Rkv ;sj5iΐT0M@yS:;96Cd&fϞK;5XLiif0[+Nɪl-Gs2#" d&E;4jI= tSkcμYBM}9l ~+`#s^]&΢#oz1iz~{VE ͛׉֮ la`FwǾglЏ2ϰNm+Z9q(-#z}> W`}&rϲr҆ڿД$пʼik[G4W"9=!qizIҧ`5Sl'FqBg>RJN]: g3LMɅIr%S$>]'xEv#6(ŵIZ%':⚺[p|ML?& !NiL4 LNR@;}']Cx|/ ƫOql[l9 02?0z+jQ<?E7f 'f]9.N ү:R\l%'_6sNڷVtcx1LnQ| VH 9iW֊UIP[`N7s/Eު{l6%6GAM`ߖ_y+lKFM$lod6iOKkJfCRx8Qף3Ɍ[4i~1ٴhs8=+y ceµ}.]5~_ P+TuJ? nu" " " " @ S+HpGsGˍY8 *LY43}F8M}Lk񞿽FIW`m+WIDATӉ8@6EӢ=NU%M0m@7[Pi2l+cTnGLf*b~2=* wSo؆R6Jj8?F.41S2=.N߫¾cw6jtůJW 3-6=v7ϼx PWxϫ0oڃ ְ`/^=ifQTV3n`~ E+ߺOۄ~[Xҧ\5=4/\t~~`d5ޯsb8c5CX_OY@?O` ٸCn&>Ago*X̡K$=-  6f0nr5e?jYx>ƢϏnBN$;;}_+*['^j?rp"_6iS{?tc7~?^rgpl%pVn֑vγ7єO* 77MA2{f dOp~;ڡ%;)wy|#CQХFq,6 ds=t3XW-o]D7~f_\6 miiԢص*66jc켽:ٶ{.މPrXD@D@D@D0xecС4|.CƳ`%'s $mwpq :^WX+dC( '}K[KU|',puȠv;zHWV[գyq7,>C=k5Wt~zr}%h˅6 ?DDis5M"6&<QʜGG0gZX+Xѐ^Z嶷߄ r2/ueP@2PGzb5Qy,bcЄJ@3ZG*~kO4cݤUP D@D@D@D@R*/K,~3w6&_@35)UР#v[ϞE뫙æVu }KeɣvԌ|gO,[VtjA] Dp8с#doͣtFì aTr]G b`WZޙ>#^OIҁ>]&ܵx^8G`{?Ţi'*ɒJIvE6-UpݱCoU,pCLf*b~cIWag$liq'w*w ػpc=n$JI&p *)O ekaiPmBF&v_;UQ3eSF/Ս >Iל%,"7nTO )D0c9rTcz');YSdEmBelGzռn)Wkmԯ)\X5- ߾YTXsNt\͆tVԺ;Iـ?3S˚ŬHX{^={п.B0M\"P孆JxgPV{I" " " " " xgP$O(" " " " "p HuA_D@D@D@D@D?E. ]YՈdzr˪Zk8um 9\=ªū9p%τy[<ɟOܬ!# PY .KٟhJe}w48e~1}(SD@y76Ed)Y|lL'<)惉DM Z1! Zz1zFYtd5u3!\+ TTŶIkZ;&;մ:· *>63;ګʂF~#yAsʹKg" " " " "W h{b³V6tvRs@ŧp={.1_Sp84ÕVE9T-Gs2#" #roJ pOkckg\1 :vɦ9:.Ioõc.YOx>Ta6o8Ìy`$nM= M Hp8ڥ?+ >~Q Z%xAS9GrI:[-\cGHU習R7U%rg⋫rLD@D@D@D@?wu[1,}k*-6M#%ΔKXj0kwW@J.V}-{s!,+G,W@`oL{\: K#_)->Oj NHwL}( YQwa+n͸3ih4$)W2H8J5y\_d`ƢϏn‡om'yɎ݁蝏S:R\NeCo|ܝ6s0]Ay,=?y +w TH mFniw<{oo˔2~3]|$J//XFUV5{`4_^[1xI-`<<ƂCi8]g\K&N]OUs 'mwpq :nlL:pҷ]7., -" " " " @%|řc;Jva~XB/_CęND6Cm<{ݯfE+ORr_G?:(:vDn n&Jȴ,EJ>9ݿXU6[;ҖGNC;)+9  @c7g0auNȥ|0V^Gۏ8Yg6ϧI3av" " " " "' T=^)Nt>8[`n(z0od5+, &4+Zeב)kX8ؕVwψ=GtO 3x5w-(*O'>ٰ/M8WO4UN+hnB˰E:8Ӿ_?p`ҥ6ʺ6kLF8mJ{ +/s!: Scμojs% w17ڎmx!ڑ{X@\t0}?ֹ3VRCh%0~1vd[CAmbm$@m1gFEŰII1>A#PI;eWSޅspu#TrH:3lWVHA~b([k Ū/3w5Y޻|c,$BluL |.dmZ4Vn0䟱dxl;+m3F1D@D@D@D@DOP?uTLD@D@D@D@D@"*o5| d" " " " " "pW$񺫼ҹ$^r].K" " " " " " \" " " " " "p$ҽH%׀e-hRF=-K=XeՊ^1k lV-^́+y&e$>q*S7cXtl@eْc3]/g)VvD@D@D@D@D@Dv͠M(eTY,F>cݔSgXpl{,m$mb9*+ڍmjF)3gCVU#Zlɩ#; _i?렶Q'zDŧFgZL@yS:;96Cd&fϞK;AWFx, &438 S4sNfD?6̤v\F-gؿAnjmplיI?f(p"ٴ4GSDžӗn&6n\8}m.Ѧe&-@pp0?FI9-g^tj65W" " " " " v׻:{i5qÑ˿Wabs4Ա`ּ/>2d;1#ݽ2m|dnD @цڢ0 iiF 3&[Car˳#gH>?B 9A"9v8 F~Q[c>C=k5@QPt~zr}%h˅6 ?DDis4 KE@D@D@D@D@_*/K,~3w6&_@35)QР#v[ϞE뫙9ݿXU6[;ҖGNCxkVulR"" " " " "p_IB)Nt>8[`n(z0od5U\Ceב)kX8ؕVwψ=GtO 3x5w-(*l~:φ}hZɾ|v] FsK*m=\-btw _QRmhiLE/St!Hi$" " " " V_oa8pusa;-fF a)[#& Ҹxho#Ajx^\:d@ADQd@B`Nvm !Iggnc:u@@¢(@kJadp \AWadp \Q @k@; @  +0 2 @ }/Z'1uv0Q@z,ND۾uEsak4D!=b}hv0g3rеoz D3G]3PqR,c#g=_雽#qDaNr ѱòc***X5LYYY:..qEEEJ{mtxIIɹs,VMMM0`@6ٳn{̘1}iBOvh߿=ohX 87o^cV'f3M#Fv M؉cd&WU_&#BWDQDX$(d^'EQ0AG}Bرb0 CQTqqquu3zΝ;uxaa}DQjp=Zzoi:t jueeeUUՄ RSSwsssO:u744|>OTz .M>b /bTjH3 [[*C\(H(J70cq+((xbDD]wݕp8jjj~JJ˲(Atp3Koeqƍ7.99f9--M.K;$i2,(IIIFZ(j֬YÆ S%%%B!.]TraҫW;3==bh4 @WNc:DqH)aL A:"/s*oYeGۺpB(==iPff&Bz#F (--u*w9Bz~ĈĻB[nET:R#F3{<B4MW vqx qزÊNY'Z+DwP<6npEc,S eDtͳ#bCCB(66V*rlfMB111t7yFJ'/**jhh:V JREI-6%8sq̗mUor H9 >x ZF9cCE- +)H\.WK4:6P~v:M\*o]Azf360Q0&ؚBݾY/ ?]+^LP.6T 96dX҃1NυmTBŶ1DUUUI% >$F 7In6HOI# oҍEQ:yKz <jJH.3 c 7bc*HEY6~XԒ#" `-G!$ sHEM4ᵑJT[HTv8\mxKo߾sIxSé-LLLh4nŋ!RZZ1yׯBDXPPxZm|||gң'OD ]^ۏKV^ĤBH!.C(4M"$bm ,"‚Hz\ :ZjUD&2kFQm/^4͛6miv+JQ-Puu?~Sx6oތ6lXZZxĈ{9xӧN  h4wﮭ ~SN)ʙ3g6{iVۿg~jn1|pgAl"mHر QF!233+**\RUUŲ,˲111={lï:60?^Dzq>uj#"His"ɰWSEM+lՂF(($P>/0MP5mڴGJ ><BeEQzqT}ѫWn;psK#FR v{\\6dvQШE^?cƌǏWWW+^z >$k.T0mx:/ S 11TAJtZa}+M1xS(υ8eHȉ,Js9Fb{O ܵq;!sԲ6Z(_PY4S2L+DI <0I)"(|n, Lzl=FW_= m傳^DfTXA "B"E$x[$$]uNC!k~ nt`G2bIQEH@O7E*'yQEr >n Ќv/cVʋ( 0B+6_$9c,MNPy( /P{B(-C4=2 #|F XZPG{=FO/cIDHƈE s W벣_!VQa(R"k$ #F'~bC 2ґH$Sjn]2Ly$QdOH O^BV"zK2 dJFzb@. !D-OR>u,iR1D*JoTdVto h^?܈XhM' HD=0-מH'iT"HK8 ܔH V;Odp͙3gɒ%ݝ;wfee;vgeemܸ]k]2L]hQ1,7o*++ Un˳>F>oĉYYYyyy!ta;vm`ΝbC >|ju |wG+rS233KJJ  < azk֬>~?''g…ƍ[xŋ|ocǎ_">^ٳoUVqt:_{;;A?v؅ ?i۷Ν;%vСCGMţG;w??>t~ٷ~Z3gΜ?J^o1gΜI&j}#Gv~駳JJJ%{>2YYY:w܊+&M4wܯ*gIIɬY}ݖo{e\.?~Z\\=#k֬IOO_lYttUV\fٳgO2E*zGϞ={w|oҥKfϞV{}I;.\ˣG^lL&{饗͜9s7n\vFWy~ʕR T*7oޜtRN /B:u*ɓ'i駟~CNu˗+|W^y@zҥKn蒺n{ѢE۷o?[o];w矟9s&Bh޼yo|m?^ڧ<&&>i:pj={#<1{M駟>3C͘1cƌĉBQQQ}QjjT;cڴi۶m3fLK>~뭷~G鐝;wN<6nܸ-[H/322}ѝ;ww}}>͛7Lsz+!!A*ټyK~ J%cǎ}Z$%%J;;wc}s"N> ΰgϞR={Gt+Vh7誺W;'xѣM{vswJ,YBw}˃,Ir<--MVK6́Ҥy7L-qh1b7|˛6mZY&(*`~_&!]eMؾ}>ç~z]v>WQ~{YYY.\p 4ĉ`ѓ'O*'O !$2!0sν[ZaS曁_v qŽz"I2Phbcc\|_B| T*In/\txi2}\ݻw'''5ks=cǎ]bBUm`eee/B(88+))իE5voɷ7|pϜ9:qDffNC7xka~@73 ?}asu#Ƹ@f5yv}}?3qO?tϞ=neرci޿޽{MvϞ=`C=Cvjz+99yf<OM7d$sss^3gd4O8v/\ܑؒaӧ[.8(JJJ*,, @t:RSS}YaaaNN΂ bcc[,'Mq&G;׿u:]ϬgX,999R!EQ111%%%׊|{*Ν;wIC}:W^-߶m˗[?K^Ƈz.8/={6--ɓ{_=ZmBBBffѣ5 s(<ԩSM&Shwv~a%Mmgee=3F1=@n;wK Zm޽{V y<_V5˖-9rBmh[a뫭]n7|(afsObbb+~l6ۖ-[oܻx뭷>Cɡm 2 tqgΜٲeˑ#G äΙ3gҤIX8r֭[ ThѢ#F4k ]˲{}7GDD̜9>]j<7o.-- >sh4!m2 tM믿裏%Ç={CXP)++[vO?ZξB[1ndREEE֭;pTH f͚՝g||6mTWW'dff.]fa 2 tR^ٳRa||;u֭3Eѣ֭ LIIyڊjaɓ999/^ V\y-nJYYkve\\ҥKǏJJ/:p|;aÆ/IMM m:N7bĈZiE4q}L4 0`yK% ӟڊuN bȑ!۝ _;1)g7lؐ'_ +]0/Ҹq㤗UUUof mQa+ضm޽{˗w!-D^zI/KKKW\*p @۷oƍmrp#ϟ?o>0.^vZir[Rr=zRaVE a"om}M&ܳgυ B[%(|͕+WBJn &ڄR4iZmmÇ^o+@zo.mcĉOWҳg sB]nXUUJd޽5%ǖ-[l3?>`T2LiaÆ}w R\@9} !ј}֭[wC$}2dXN2 _y9**jԥ 0@޵kW`g:!Ka#Je`]^y啃^fff8=ׯ_ kIIInd3 aO?6rgJLL\pa x{]aĘL&'~HKK+@0SVV&mh4Vvr BEI3 h/bÆ V5Pq;v={N8q̙ C%...66!}7 IDATTPPz!@‰ RcRT*UγaÆ>d2{wbZk׮uݍ +++J?m۶)))muxT VU1 x<iJuqddd_ fB/WX1lذF{^ѣG,Y@ %ot҆'A0N|>˲!ZcO>n:i;й'bccɣ^}}9@\x1.MӣF{mxxzJrn ApzWc\~C5*EGƻ 55[o}'??}zpI;EQ@‰ R c,;^zVtw :?0N(FËnōvH"&O|3 twe뇅?hKᄦiTś,x8O>?ꫯnٲ%W (6蜠‰\.i!$]ʕ+wޝ)ZҥKQFImÚnw@'n( U;]K&IFX__ ;H!B6MVC8| 78[MnA#"ԑ^.az}YY(eٶj"ͤΉ۷_|h4Ξ=Z ,7 0 @}&wn$ |dOǸ%V[BBZ_:IpHqn!rAY0 ӯ_@d~_}Ν׬YsCflccclO(b޿SR YwEu_UU=zѩo>-))N= 5q{.x߿?]ZZZTTԻwfj5555551cZ=/% %c}^Jp i+E<[QQq3.oȑR pZMf@[YaV_-!!!M.KJJ***픔vjRpOKWHE毅fL|>ۯSN*P\\|Zt ,A_333o6?j=\޽ 0ӟn~X .\ǏqCW#c }>IF#NEQZ" _:GyUQ#.7==]նӥ-[d[dI{\:u+"X|ӡ:-"eu}P}U\OEVEZKTFZ2O/W(7>..Nj|l~meټ<)4Mrr2௳+}^KzCQ'5{;y4MLI rqqqTV0a]TTtСVwSZZ{ni{቉^G~.ba(&b&F(W)) Awut{"m3gTVV"(=zt-Ȱ.c.~Jo: ƘiR&Q 7YM&ӂ 'OٳTVVnܸQ3fC[0WE,ZH+n{|qL&c[EcZu F-#\.˲=*$ɓ'KGݻwƂx<֭yj41chPW f@uE 1Q)q񃹘R24M,AQ4O"fBaKF$޽o5Q +AdSN2Vh:Q9/M,vXAƄqaΝ;W...޲enKw~s파FЙAuRk T!;=1F!(#)G%C3~s;vo]nꘫw|INNl2X- Ȱ.A8jhy961r&=OG6ŌFwߝ,ܶm۞={|>_\=;vm0{챔V ֥`1A(YIS #Zz2X}edd,Yh4"0oƗ_~ sP]?I!J;1bD+úJ&hL2g\vs0L M2IEF,W%bu.W_!r!#XQU*U{D5f۽zj~OԩSa o߾W_}UzI]wXhCa] XaFmV-e9^A{yc>FN8ҳԙRa&fsr L6:{rJ`h'L֮]+ػk6. _Noɑ6[x1kV ú1E3>:T0_̸ߦ@JI/yg~sn;q <+wU($)u ( QvEG{{Y_DigYxTp(rF ^رcB.ĉsO7̰>̙3G9o޼V }JX c]VwZz~[)PB9 B94EzގyW^?X[[m@Aݻzꆆ@)S-ZŠV ’(.S_}!G갵l(2pYA3e+bM Bbqȑ 6n$**ꡇ[ye@Q-Sӟ았F7p)J.M ˜figRޗ$jkk_y ./6hР{,---umA8YPW5dJ OڵKڈ̩؅_~o78 ?>f̘xЂ ?,b9Եh%Q'M0L{_N0q||| o +_-Ax/--9(C]dX!IWGF.D󵅢Q.&҆V5kwǏO0A1 9cǎm۶0-JSO3T@  u-B+ !0$EB;kNRF7jN8!mGDDZ{:N^PP#z٩jZVw0s:nj߾}}]`B VdXtac 9Gd'@ }<51<:*RP LF=Λ/%u c"m! T6N}Qfsii}8hɱC:t`0 8pذa!|}}j>rHAAA)۷IBRI:dXcф.JSTf*Zڿ7}v܃M| "euw}WQ)""{ ~]p_,!Bγo߾}$>`ԨHR3t:JKKϜ9s̙:uj=گ>tBaቒ 0LEEBK!e7`n|~!z E?=6 qz ^Zz, ٳg'O<cǤjAzꕐp墢/ y^de111ZVDDDDDDVvQ___[[[SSSVV&͘Ո\.ׯ_RRR>}z}/ @ KBi㨊 BEB@DBF.pfG!DF5?4~^V!ƕwVӔQZ$ہ=*<&&f…!r@BƎ5_~Cj &Xֳg\p8x l66.TRt:q0DS BT4MJeYxnjuݢ([,jٮZ6&&fiii i~0M MObH$ >Mr =`:Srg^TU5O^wL7FOʐnAarg-fR'0ifFy`ϝ;n:)KrʥK"h8qbzzzbb 1cƌ3bTUUUTT=zTzh:r\ߵe2L&S(2( H$BzeY^o@mEQNJJNMMTLȰ02.Q|Ku͇!zGB:}J2%W}Q#GדtT5CE:u~{ ̨BiuIf?裢"evvW_}uԩfB7n܄ #5#!!!!!!vY\.ٳgϜ9cX(#rZ3h ߲@ ƘDU Ցq\P_,ah$B&1-HY(!*\ZW!8)*llf]hmTTTL&s\W駟%Nf ѣǼyRSS5 ah4B Rgr86M^TTTYYiZ[xZFhjVUT*JP' dXX"B$~CHe4{ԕ+waL\_jnpG aM" 2̬R*n~/˾XK3"v EQQQQ7H(ׯ,% 3fdgg_~EP(,+ 8.pv~<04MT*iH1EQE4M4 .2,,aA2m`-UwCBS& ٽ B$aCm-$m6{59D0eyUuS)6_lnSs___IlO 6m쳦{D'O͛׮]ۨ\P,_LW@{ [Jƒ+m}&'6sf̙ v$vDYK`B5~pU2-~k֬ !ŋ'M<A%1-S{bBC9s5Ѫ&ՏJ۸q/iN7dȐ'xx W4uk VgXKa89j&sV4i, J# :($?gUUUNNξ}B4MGEEM:;lv<+ 34#Aom\Njϯ5!BoM+^1bog|k)((xWT*UBB°a̙c0ZW=@ (Niuk {NG=qD*n 2, ,k-/\"\D*6"LvVh\#z߭&GMp  ʅCf*)+h*|͗_~ٷoѣGgggC!dXg'&z&$$"'hb+#I2 jaa Jb4~ 4M+JRiYU.W5M>QKa$IJT*9T1 1$vdqM0p1&$IH$cd&6Uk%Qzx=dXS`'GPel~ Ӭ.K^NtaЗ@RT<OPyZx,Q dXx#$ hQaDoMN 8ע?XylD;>>n2GyQ'&&T*Q%v/曣F ܿΝ;o?gff Fq|MMMѣyZ-jnٲ%77PM0G_r_~b6sˁ IDATl[BO>drr2BG} իW!_1M3vxD&N$rXAe0n9rAgFTeEEEFBQ%Úegg'&&ڵo^/..?~|AA7|Vy(>;vlvvɓ'7n(SO=%^}rss׮];sI&%$$ *++|ŋ3 _/ZO?ZOvкdn]\[}-'* :Z>M/.ٽrVǗVQ(\.i$I$ifF.SE, u 8nƍ* !dَ=:eʔ4-=K10 Bhܹ _|OC\ׯXh4VƘi$׵w 1 jj9uy.wzYkB5ThRT( HDQM4MK+$hrV6|)BGAq߾}w#?(uuuFFW8p*Z~n:b )B4M6@z9~ A j ,XPFixAm)9 .=rn^pOr<*:BjZd2VW (i ІuS*M~)x?|;rss^4&iԨQ6m2dH^?Ν;W:Oaa!BhժUV >djc( jan"%z5:)<"<^yye9$I\݀ H博VҢҁ *@ տ%ǽK{Y`ҥKSRR>5kvx-ZOva̙l2-iҥKǎ|~[*z{ԇB0FAc4>u_'a9r=~[Y@hZ) R \.'ܴQJA\B2Evڵr;̛7逸3 # SSSB1V"j{|OYN@<˲EQr\oЩT*J1o[=4!DQTddL&KIIc #,]dX׿{GyD=c_}ڵv=P8t7xC&%$$lٲe{ݲeK=E^߿$Zc0^CV0ܞSPP^z!A7nܸuַ~{ᡮ tM09BryFF =z6@_ u˲_}UaaZ%%%_|Ś5ko@_b8t'|r%ݳgϹs5  dp+0 2 @  +0 2 @  +0 2 @  +0 2 @  a˗/k3,[Z;>ykk Kz +Ȱ1uB]f6?=:uJBT"t92IgJ y1acqPDe$2)*4"#EutT:wt!}Zg^޸q]N{}Ylق l6!$))کCa .\b!$((MEߕH=[mݺBjjjΜ9CIMM511mll$̙3Zx)))Q˲qƵ*..*]v鄐u)NaTА*BcǎmVmu|`bbn& 푺 y*tn& ++=s {.U'b˲YHH$  YYYTExy/gBLB ill>C5}bK.7E"o6sLGxeQQs{&EFO<)--9rsrr!]L4iRccH$"\8pj0o<{{m۶UTTL8133:JeqSܹsgݺu ?~~Ғa"ѣGss2333sss͎; !SN3gΐ!Ccǎm9 !ƍ˗/3gݝ͖GH˽җ/_1ğl%%%}WTBY,élhhسg `&-#BHل:!111CBHMMӧO.6tWVVMdǏY,ְaè222t>B &xBK_闆}3fY[[8qb׮]&&&?ԤwWWWWTT+((4pҤIÆ ۹sgYY{RRRN:;%Ǐܹ3&&;cƌ?رcjjjl6p믪}YZZ?)..o`K(:C*aQBS!aT0`*d02 L BS!aT0`*d02 L BS!aT0`*dm۶444zqܹBP1cbbOMsJKK9΁2CFcއӧOm=s挞^#B>fB筪|& CCC 0ssuQ7oΛ7O[[ӧ[GPPPx"!DNNNzzzΜ9SQQS0g󃂂 atRaT͛#G J/ 03z3x?":ȑ# /^?~~Iջjhh=zx޽Ǐ3f…͆ټy޽{'MDy޽+FE7N>S]]]oo3fH2f3xbxxxqq'|2|uuEݿaÆ 6((($%% K:tH;=X,ҥKBgbccY,ݻ[,**700X~}MMիW#""֯_BpʕUUU/VRRJNN>t萅E?5bKKKggWnٲEMMm۷O>׷ܹsF6m… oݺ1fBHUUY,˗/{ݡ dDfffԊRUUЫWf-V\ijjJquu}nbFqttlv_~CeXggg'' .t4n޼٫WNAAMk„ ^7nXSSs׮], ̂… #P矖ͨ}QQQԳ%2߿bRRǣj2l0*BaEEVaaaG/DOOŋo޼i\4kk뎞g!ڤDoyd7otvv駟:ݻkkk߻w_?-Zdcc3o޼W^utsqqqٻw~NCZ ŋ-u떛۱c:ř3g~ŋ^Z 4kSQQpS9s&>>')}ݹslmm?gtb2TTT͛wE++]deeOlٲ7o&$$4kr===uttѷ0`uΟ?wޮ BuFiiiyy9Up8mjllrTbYZZBZ£PAAuIIV$Zr'aMM#FAoxvN¾سgOZZqUUUpp# NٳirrrNRVVLccc׮]Kt%UUUBݻw={6x`C+WTRRrtt2dHnnnBButĈG߾}+**ϟ_UU7߰XWbk"023l|y55)S,Y*wwwuu˗/8p@$9rÆ z6s̒߿?!!… -TRRp۷eѣG;vڵ"--ŋPGUTT~~~455nj#++ֵ/b5PxL BS!aT0`*d02 L BS!aT0`*d02 L f111'99'CI@7@I! .UUU==n W^'&&8;;/ZC#466߸q`'OD㘘͛7'&&DEE盘lܸQSSW\)++\f =`vvÇ>|pVXJruuHMMvvv+V&MBl"(66622OQQل.]f͚ѣGw ͛7޷rBHMMݻwE"%KRRR\]]kjj80tP}}}BMHHppp4h\nbbu߾}dMNNNKK'!!___(&&&..EFFQYY] >|xԩk,N}zHHH$vښ5k!555vrttܴiw„ 7nܰj\֭[===⼼ddd333SSSmmm544ZNrppزe Ţ_jU@@UfuuuN{'|/yXIII\ɻ_rJ|aaii:xTBFE񑓓#X,ccʆBHZZϟ3gWOOo999tC-++Ү]Fbeeell+ӓ9rHB}}9]$0mmf1fhh(a_@\.iv\WWӍ)JJJּyW^Ooۯ}Ö̓BQٳgWVV~'gϞ4hP{mh2lv@@@hhhBBB|||~ƍH055wss=zt;ぁC5Ϟ=o>SSfFGy!---www??;M CI08 aT0`*d02 L BS!aT0`*d02p8䞞&] jpgϾ)t2L!&$$888 4H\.711ںo߾T&''ﯯmdd"##C SVV2eJeeƌCӫ-..o&NC_7l0u2ZZZT b 2dȑ/_ݻ?755%owuuUSS1c|qݬ&))OṹTM>] >|xԩk,N}zHHH$vښ5k!555vrttܴiw„ 7nܰj\֭[===⼼ddd333SSSmmm544MUSSѣj!CdddP嬬,eoo777B !TԩS[laXVZj* ]< IDAT^I󰒒f5wr劕IKKKWWW׹ƳgϦ2j(B!bWVV644B|9szzzɡkTTT:thYYWy5!$##cرǏOKK w***RӾv!Ϗ 0BqlllW&i\ikk71CCC r{kuuu;ݘj͛7ozBG6|> +))Qi&L'Nfaa1vؚG5*33ԔfB󕔔((''w]@KҘaAAA5j*ޘ^Z#!{ׯxuuu fIfff&&&ŋ CD-/>s%IILLvvva]0@y_XX؝;w$0CAAATTx%pF/;$i,9ӻw΍FiPTT#ξ{qWWWN6r믬,"Ha]㓔}L33ıc.]BT+..DYY~ 6̚5KSS3///>>>44>V Bjoa۹sQ!'000**nonn^WWwsssj[?!dᕕ|Iqqٳg V*@uX쀀ŋs]vEFF8::RG-,,LMM ڸlmm?n``{,___z[[._d߾}W3f̸yfppǏʑ#Gѿ4hPYY}#_oݺիWJIIqww?qzaT0`*d02 L BS!aT0`*d02 L f111'99'm>+2L׷gϞ}S4dtyAPPP; @ITWWt'^zhѢ|ƍRO<cbb6oޜobbqFMMӧO_rr͚5***ه~𡪪*Yb*u; 55U ٭XBNNN L4jfooOڳg=ljj+2!P5$srr֬Y3zhжm۞}@ 8|ԩS;X ikkGFFQ5wӧϱc!_|EHHȺuz@BքFԔ K;p\BΝ;w)FKKG$'$^cmmݡ/^ܬR[[Y,V;5"gϞ~QWW`ݦ$hѢ)S˿(1<== !yyyΒ? cك...622z?j, d}}û2NSSSJbСVQI0UUU??;wDEEI`(JP(ZjHXrwށZӠ544&NEl6HNa]㓔}L33ıc.]BT+..DYY~ 6̚5KSS3///>>>44>V B77 ݻsQFiiiYYYubk.X`/^^n݄ z@BȰc 7n#u4>>m7[[Ǐ:חV˗/?{}LMM[fo޼ Ս8t#""looOf BYYY [UUUYYY]]-T@rRa֭7oޜ7o6UӧOpEB9sbǧ o'fhhhhhHn޼ikk;r ҡ!<kȑ#SN]z5ϧ]]],X@wޒ%Klmm?P>xbÆ vvv˗/o۱cǜ9sLuVr8۷o|㥧s8sΉpۺ^H`kkk//P50{{{BH\\Ӎ7དuX,ҥKBgbccY,ݻ[,**700X~}MMիW#""֯_BpʕUUU/VRRJNN>t萅E?5bKKKggWnٲEMMmɭXXXL8qРA+>}mG)++[f ɓN2d!$==gddv\ D"ҥKedd!UUUIII zj2==aʕWW׷W,..x~~~nnnGGG@f7:tPEqvvvrrpB[v}M8SWLNƨ*BjF_QQ1))Q5mu6l`BBKKaN#}qttk>PG̟?0BfO ]k!!իWRΝh w'M4o޼Ƿױ/_~葜@ fҪ}OXȰwK$mذ!''޴iSb߾}IIIuuu6m~ '>zO?h/ vuuu%%%G$#ӝ@ (..?`ҤISN 6Ֆr~._Θqqq!!!OZ[[woBLCwRUUUJJJjjjjj*V̅ {vQPP`hh嗚隚~OLL̯ӳ ?~/^\z*{{{7k-gWQQ햡Z%++Na SPPXtit:[6h AO=z… rYYYTTT:G*2{)))B^zٳF]]O?[AAY-[{ Ν|f ^~QXXXZZoeeB}[nʣG>zhMMMqqq׮]+,,,..VTTљ8qˀ$/^ǧ=}\FFFKKӳ5+} :w!ŋׯ_/**jK{ƌ3fdC!:LII)++{]r\nbbCTTT$-99y˖-t iii'NXlcW<|͚5Kkhh9y򤏏ϼyޚ!!!/_ <]paƍNNNmu/++D^|IwKa'`GEFFJ>۰ax{ʮk׶cǎeff?HAAfF[lyA[#444ر0ZG4aAaX&L8p`QQQjj*}̙3 uׯ_Se hkk?}gϞmhhضm[;=?377*kii-_||>?;;ٳ%%%⿸j[!:::cǎ1bbVVVtt4F$|ʕr̙۷oDttٹ/;a{ixx%K?LHH={vߡ}~aĈ?ꫯ:H$oݺE§~lٲ_ʪ34z҂ Ν{֭H%o޼ٸq'qrrrrrzadddll,` :t];ݣ¨2^r3~_u؇f}^tBm!!zXKK,?u}sl'|,##3y䷎ zR\\ܒ%KՋnl'!!Y҈n,W^TI>>NNNSN4h@ իWZZZ=JLLxB-+Ѧa[[C 1/D"˗"Ʀ_޲̀Zuf +**ڶmQuuͰ<|pͭ ⇖-[ ^bٸqŋ#6666m^vn޽zIc;f̘U_tiPPPuX999__ٳgݾ}ѣG媪 rss;p[u͛7߿_VV&##kkkkgg'I@X~OA '_~%w:::ӦM5kRͫB>fBl_]]~[n}w...~ i0ʴiӬAuu{~_͖pEBܻ?(((""] @CBB>|8nܸXxGEYrݻRN2Oe;}tvvӧOuuug̘A7vuuuuuݺu77ֆD"ĉÇou .nkktR:l\]]LpLUUU++W^rrr|>? 55U ٭Xոruu8z([ԉN8>w\Չ]$${:rss ! "?^^^yyy˖-իז-[n޼)WoqpwwB\\\߯"hҥW\>|W_}ell|CDuy IDAT!66OUUuO^~xҹsVTT̜9SGG'<<<22R/޽{͓orr_4{솆۷?zHt Z>/ +++SRRƌCׯ_hhСCY,!… 'O/X`ɒ%tM;]JJJn߾pٳgBy<^FFw}΀;v0`uTէO}ull˗=z$'''m֦VHtҚ4iҩS ܹsU77 oߎȨF3B?%+))5 NIW^^fys'0==I&zbUUUsٺuҥKy<^;ΝbŊ/_˻.[-[===-ZѠvՑ)Ժ} kGdd$=<]r޽{ęM%-E___ޗ(PAAA[[XRR :I~X,MMMIޅA=K2dNZAHHR[Ka5~صkZ[[?{ҥK ]"++iuuuKcli̘1,kǎ...-_fXXXg)))yyyk׮۷o/{z֭[BM= 3gDGG?|t .\J_Kpp˗/Jss_UWɴaÆQ,YW_?~YN:aÆڵƦJKK7oLtҥK!SNE@7bu G-\СCcǎ%455u԰aÚ5yիrrr2{Յ nܸQTT}}}ccLH*2=XjwttsLLLllli[|&;;;;;ʕ+] >dRa5kii)TTT/^ܕIddd\]]%|HMM믩Ū?~W^_555uuu?Qtttzz! 1bk>B{;}ed7nٝ;wV\I}|>ŋuuu'NXvmOO>DRa]H  ]]]O.̙3w&?Diii<DCCc...mzyyQK./>}STT````oo"`%@p 6lNNN,KiӪΜ9S[[{/^hii3fʕ]|*_~%++̌Ԝ3gή]!~R8 B^^>000((w޽{|[۷/,,v~4n:z~CC5{0^111s)))o޾}{#fggoܸyٳg222N<&%ԫW/u3@KQ<LSSS__?vX}}}}3221cƲe>3jLJY?`˖-6 4H[[eeeTihhL0AWW:زK]]݂ deeTUU .H>.{;)0 ao7p@jgźzM!/_h !$==*|g=UKJJ_~Mݿ,!&Lh硝gvvmllfo޼Yzujj*!ԩSn}ljj7oޗ_~\SStǏB͛'Ʉׯ_߫Wfd[$PcJ 2-444;F?_+++ !eeeWZ<^AAY$"TVVQddd޺\^^~Νjݩ xZx/UVQQqqqMJ8gϞIزUw!Μ9+CG BEEE|# ɢSLb_|ő#G:hgȑTnΞ={ǎ>999,,l˖-;UYUUE-itOޝ2:w,Y.6o555p۶m۶m)--=rHPP߈#:zҽ{~7Ϟ=p… >o޼O? qO<9{իWkkk[}[ޡ іׯ__8~Yfu ={N9}BD"ѭ[͛wҥԩk 6e866vΜ9Ν֞;wO?cǎN_;͛kfeeBttt~|i {tK.m߾b'vAﻓ@z/Fttt|||+++n*!證@S޽{ {OliӂHP,dΝސV[y2d#7!K[|}>}j*.Kװlyyy֧P׮]H6dXʭz5?p@GkhhXn{zRx\.fff#FPSSKNN.(( 3F~FFFcƌ?&Mjsb'NHKKkusGOo[}}ׯGEE~l7 {WYAAP(LOOL߾};DÆ SWWm@???SSV;zxxDDDB?Nݻve:!\.W|J100xf@+***WvwwsNII ;vܹs;Y,Sdd[x<^cc3rv~.\x &&&ƍ[vao@[Xm`BS!aT0`*d02 L BS!aT0`*d02 L B}T\]],X Iƹsvyr8t|݉xOKc޽uttM6k,%%W |>f BYYn?111444//O]]t޽Ŕ?t"ɖ !,TBQmE,>|uYʆJr-V6Qh+ĨdRi8_$>Ǽ>לss 899lPٳ;wرC^^!bBzgX}Ǐ9r0%%E6.@n333fP ,ؽ{wBBBqqEOxzQ W_}5sL<"""##ã72K2kk넄wRVQQ|֭J]]]ɧ0!s8ׯZݽ& >ŒCta}}#G\nyy}HH$l8ɓO_TTfCCC$+((K. BGG 80>>^jgg#M[jG7o?**윝-)//m}}}ll>utAAA999 !ƍf ғ LҥK%7 HT[[UUUնF4_~oiiC===jA,?}TCCVCC*:t(`.Μ9s5IS!DQVV600xRwjD!dԨQ׫Q76?^aHI2lΜ9 cРA:::mx"++ԩS%%% Br>|d+:q4Ʉ7پ}Dze˞>}֟eff_~֬YR?bx˖-999k׮}nymUUUX!CP<t4DEEE CCCCwaPFA677>ٳg ***k!!$z6nܸ;;n~駟 +vС};6 222---ϟ?4;wm$VVV;vhd CCï:66vϞ=jjjm7-))p֭SLB***6lж\[[=хז4JJJcbbƎKyKLLLMM0aBlf a~cXGVL !ZZZ}:4% Ym /^<|dt%R@0x`}}}www.B]y2 t B]!a@W0+d2 t Bw1kkkkk={t^.&&˻KMMupp?~EEE;M2ò֭[j?7o$O#'.;vo%.Yk׮B:dbbBfsss___4A2aؘZ>|J:133|2!ԴFjkklryIݻw޽N2F0===___˗KJJ!SƍKLL0aŻ}]^^EzTVVBFmmm=hРbxÆ ˻jDK!۶m2lԩfzS+irb9::vA&L:s ?_xQ\\'C 5ϟ?߷o MEE!b߾}+W$&&޹sxر>>>p/_~evv6!$::z„ TP(/8|RR_fiibŊ6p8mWLIyqd2썪|}}>|H޼y/$t~$@),,,,,<~x\\bW_}믿JJ3228zz믿>}X,J^z`v>|;vLRӥ]#VBZ&d/SSS+WPW$ÚoNQVV/--=v.~\.`ÇTSSzɓ']]];c2 C,R;}6um?g /\@QUUnnn'O4IAJӧ)))o&8;;HWo!ވbEFFBaX,.**믿޽+ rI $M:6CCQF+i⋹s>~8++ի/_>pA޺/BIKrӹ+W:4..N lܸ166/dRbvD6lxӬDDDB : D7yy666M6|pAklll\\ܣG۷zjw &|||N:fU|>eeewv_~ߟN*..^|ySSꂃ,Yyȑ:3|jʕ+};` z2III0;w]; NOO"LȰ^__O-3L'''jYRVǎ7B'OL-?{|Μ93f̐ruO8QRRr޽A/\pܸq6QRRڳgOtt+WB/j! IDAToҤIiii>|Ⅾ~ >H Hc B]!a@W0+d2 t B]!a@W0+d2 t p8ijt០X[[>@$xOnݺh"ɪJߍψD">////z?S,52NV2fBg wYPPcyy:NQPPΏ9m۶G 駟{|_ dlƌ vޝP\\laaѷ^ɓ'+**2 Bŋ?r^#&ʰv޽KeXEEErr[*++uuu}||fΜ)p9NTTWZyƄbICLLL:찾ȑ#\.\MM>$$D6gӧO/**RUUe١JJJ|~llKBcxxxqp/Y-7o:t媩P$5MLL ƣG2=2l0Bȓ'Olmm”"##sss֯^rP(򲴴|ku֥xxx޽; ab8$$&&&fff111mǐxb}}5kִPSS[[[ꪭrQ)OHaam/^|rOOϖ7tX͛bTaHI|H$+((ؿ1c!400n͚5&M4t҄zzzB***\ڵk;p:::wwp&o"(..ȈaÆ3gܸq: BϏrpp xyy9;;ggg{{{Ks~˥o[__K=Ⲳ iT555׮]۷od+e&tRɍ8CCCjA$jiiUUUmnz'M`ׯr[ZZ:POOZO>hjjРʇJ 773g\vMaTB x<:99IhPskjjVؽ{#G!۷oWUUrR 3g4hNO/^dee:uDAAA(v?d I&ZZZ666IIIǏ722|ٳg;P$gddpf7@ ɰvttt!o:dv)=***˿|meUUUFF-[6Vtvɒ% .2dHHHHEEE'ɒ%K|}}9NXXX' Ȝ//>lȑISS﫽B]w5l0YG``322$7EO2G-//OKKL~۷{xx,[ӧo)qQQQffg͚%i)#G~`X?>\SSb 2ZxܠH$***d0Ҽ z6bj~Ϟ=kllTHHHPQQye PrY,f ><۸q㲲"""߿I՝&rrr#GLNNf2jjj CYYYWWW2ucƌa07ovqqinnxeUUUgVQQ,++裏y=1///..fB233KJJ-[&C^ϔxbKK?~;MΝm۶IՎ;*Y믿ݳgyppZhhh:ƍsssKJJ444ܺu)S{={INN>{l^^-[zhat cB^zSSS'LЅ>۽Y@yXoX,ѣU&3|pBV {k֬qss%݁{(///%%T  >>eee|={8@4hٳghڒ YAAA411166~׶NNN1ƍ?S_dLd،3>j˖-#P,Kl6HF!HXlnn^\\%J-K a}u7noĉ+Vׯo_LfAAU" ߿?`###777[[_3g+Ɲm !555wDFFF}ٸq$/[,>>>33NOO? l~)SaЇd"չs碣_zEyɓ'l;w|aRFjjj QS[EECI1N8qΜ9}H$k[9!!S:jԨt>7n|뀷mLMM?3iWa6l$*+O?%$%%yyy455Iwm`0/e˖'NBD"ѓ'OVc2G}L&:7_z.\8uꔒRTT<@'[Ha~ѣG'$$ܽ{w~~~r[X?&''777X,ccÇ߾}CŝMRJ&Lؼy[:(((̟??--mڵ;wLOOٶqqq >}zOJg|?$LAKK Aj#rqq9x𠞞!$77gfggBN:w\eeeIѣŋ ! |7dXw544߿ǏB Գm*--hhhHOO߿O yXwegg߹sΝ;{UTT|%!DYYY!SO?466޽{Tnccc úᘛ_:tѣ=ϏrzSS3f888L:6w/ ]!a@W0+d2 t B]!a@W0+d2 t B}P845[[[}}}OPkjjw @O'nZhdU]]][[MEEgD"ϗDrrr+Vhjj:uIV2fBg wYPPcyy:NQPP载<|ӦM emff6c jywNHH(..ہ^M/ۙL} k:!!ݻTUTT$''ߺuRWWg̙ޞDEE]~}ժU7illLHH~X,:tĤ9rCBB$ap&O<}"UUU6$\AA^tI(:::wWgU;;;iR;yC\K yyyNڸqiRBȰa!O<.++ SRRm[zʕBM֭[{BKttn␐ӧO%&&ĴCVVVPPŋ׬YӶBMMommvJJѣG@rvvK߶>66V__:SSSj;D"իd+e&tRɍ8CCCjA$jiiUUUmnz'M~I*߿XS Z |СTQpss;s̵k$fllL!DYYIy~ީ`QFBjjjU.֭[ե5@V͙3` 4HGGGUU/^dee:uDAAA(v?dȐvS:iecc4~x##˗/={ݽE"Q~~~FFmnnz kGGGR]]CVQQiғ˗/IǏ9!U,?\AAbum0 ӳpSmm%K.\5dȐNz{k%K744(**r8N: 999^^^}ȑ#I&W{}Er0'';wܹsm+L:u̙_}U_>La8zhyyyZZdEl߾cٲeO>UWWO233ׯ_?k,)@MK9r6nʾ}ݻiӦ7]Mt 2oUUU,kȐ!*4MD"QQQ-z6bjL?ʳg 愄7]\ MMMMMͶ%|8!DKKObaaq5kָ <ŋ^RZZ*ncc/^"@[0+<B]!a@W0+d2 t B]!a@W2aoVQQAILLuVV8qdj?ɓ}||6o:FwRB᧟~jmmnӊ+ϟ?߮:mŊ'O~!,,L߶;cdL0++իWS/^2dU2hР.Ҳt[nM2ϟʊ133\.nذaI^sO>DIIG:@п`Ije2aFFFFFFr}}}nnQsnn͛7zxxP%ɓllsss6}СfvYyyyrrE~HڒA{bll,)߿ۿksھxŋ* 0`޼yO<±{C /O_UTTBBBzmVVn[8`'<{LMM]>}Ν;]>ƫWD}=u2 t B]!a@W0+d2 t B]!a@W0+d2 t p8ijDwKMM?I}=֭[-kkk;99ݸH$"HNN?{ږ_, 899lPٳ;wرC^^!bBzϟ5YR;Y&[fff3f̠,X{b XSzxx.d+ڱNHH{.aɷnݪ9s2ñp8QQQׯ__j{M>|(%211#Gpr555{{Ip8ɓ'O>=>>HUUf*))I:WPP.] 8p`||dGԎn޼y!.BmjmmmnnVWW@J2=2l0Bȓ'Olmm”"##sss֯^rP(򲴴|ku֥xxx޽; ab8$$&&&fff111mǐxb}}5kִPSS[[[ꪭrQ)OHaam/^|rOOϖ7P!dРAW^Iww%[a|>_$߿x̘1MM̓0 BȬY?>i$I`II'Mx<^~~~``'!ʪ]7oެHqwwKKK g2!⌌!6l8s̍7 (Bsvv4秼\h! IDATS.++SSSBHCC!$###&&Æ ?koLY&[G-3LGGǥKJnR "VKKms v3;iB[~$yzzzԂX,~FSSSmmU>tP*]9sڵk 366Bl``}֧AEEEׯ5kc9R}2`|Pd:UUU,kȐ!*4MD"QQQ&Аf:,mĈjssdijgϨ<*$$$ؼXTfeejjjÃ밿7.+++""'OdXi"''7rd&`0uuu%_7fyf挌_fXUU1{l̲>߃?~…D6mnn.7mC 2o</##2::ǏNsn۶-..kNڱcG_%K~ױ{177VSS m[gܸqnnnIII[n2eJwG 0 ..YYY.\PQQ=z.N S]xm9H$000&&fرW^x &tvoqx[~75zhjd >էC@^bo8p5kpÇ{yyIfm@w^b/KII)--wwwTýDa@Wxt B]!a@W0+d2 t B]!a@W0+d2 t B]!a@W0+d2 t B]!a@W0+d2 t B]!a@W0+d2 t%immնӧ..._~emf̘QTTԛއ5k֜;w]ɓ';Ovg}}}}}}SSS [[˗/S'N|IZtF.d"LMM_|YWWW\\b==k׮}wM~ÇE"q׻W^Q%"իWӦM۰aH$S@O0bŊCBݻv˗/ !JJJ+Vسg|T1{'NhiiI61˖-7o!b4eʔ@Bݻw_HJ=zD/Y?2B>#BHkkkMSN=wܼyK.O߿V__//_o>(rRWWg0Ԍv;\&р!p8 , ܾ}'N]!nfbEEE>|XOO/55?|\pazzԩS^z1ɦׯXĉއpo2rHB5&}sLʕ+&L8:ٛ7o:u'O(u[VVVNNNKKK7clllmm}UVݼySNNnpkk|EvvvUU̙#ٴpBSSӊÇO8oNrqF}} @_ mu2 t B]!a@W0+d2 t B]!a@W0+d2 t B]!a@W0+d2 t B]!a@W0+d2 t B]!a@W0+d2 t B]!a/MV__߀H{}=IܺukѢEUuuummm'''777WD|>_^^^$.=zk׮7o=?722̒ 899lPٳ;wرC^^!bBz/bM>]EE~[z#Gziw dlƌ vޝP\\laaѷ'bWSSۻwA! .|) zleX; wޥ2"99֭[>>>3gΔTp8'**VrwwIcccBBBvvÇbСC&&&vX__.[^^foo"p8'O>}z|||QQ* URRtccc/]$ ; 3pxɪ4mݼyСC\.WMMŅr_a*!ZZZ'L(--% 6ooﲲ2[[۰0%%ܶW\) ,--dݺu)))w DGGvءX, 9}IhhYbbbLLL1dee.^X__?99y͚5m+ֺjkk=zTRXX'eۋ/._\EEӳeƍ%%%ԦׯB!^rJ|H$+((ؿ1c!400`0Yf9;;?~|ҤI999Nx@OOOBUEE]vm'n޼YGGGQQ^WWdmC$Q#<==7lp̙7nPGAQQQT~xyy9;;ggg{{{Ks~˥o[__O]PPPNN)!ZCC￿|20`4leX\\\\\d2.]*ghhH-DZ--544\ lT߿KKKQ bӧMMMTСC% ۙ3g]&0ccc*!<ONm#5RSSC>zH$9;;=ydǎiasqpp`0 QUUmŋYYYN*))QPP 2BMRR㍌._|Yww;D\.M H2BHuuYEE]pJO/_V Fccw}7qDBX,{A:zgcc' {:88999ԵE'dɒ%uuu666',,B ֬Y3lذ… | MMM俯J , &HJƏOw^ >\u։GI&\t=<<-[Suu(33sf͒r Դ#GJY1bD}}Ç%gDSVVqF:QUUb BxNnID$ijj2 iޅA=K1b,'ٳFI{4k,()9<5:oƍʊɓ'Y,Vwɍ9299d1 eee]]]<׍3`l޼ť9##VUUyxx̞=[EE%33,""⣏>~;wlnn8q7RSS t +(..>ǻdܹ۶mkhhZYYر% ={3n877JCCí[N2 |||9BZ`F^[()) ;v,!իW</111555::|{oXѣGSL&SGGgu G^bo8p5kpÇ{yyIfm@w^b/KII)--www*p/-d]!a@W0+d2 t B]!a@W0+d2 t BҾEgϟ?ףBa=|g_bɓ{o<4u۷wkk~]wsssotDV2֭[SLyaaa}20w޽{{s<>>fl=O?UTTsLےr MxΝSPP{;w믾 ?jkk%%gΜ5jzjAAAnnnvjmmlp8>'Nxرwdmm!)r!!!ܾ}/6mڬYj\,Ϟ=_R֭͛?ښB??}888\j֬YӦM[bEEEUf%x1͎ GMӦM^b#yY[[S]UTTl޼y…'O>uԛFr5 vgΝ;ᕕI'4t//ɓ'ϟ?U_OO>***k.WWt>YaGVUUʢVE"QffiX, 9}IhhYbbbLLL~W\) ,--ڵs֬YTIVVVhh([`/rܹMΝ;xj9syyyB [^x!_AAʕ+{YJJJcƌV=zggggcce˖OHuuwiiŋ/#Xd~T})((1of„ _|͂ _~?aÆ=yۻ6,,LII)22277ܸqĤmI^^… gϞz:$Mvڸq띝ϝ;weIe?PgT|DƍǏ۴iUXPP`eeUSS``I;w455QB0((fR%...Ǐ߽{w]\\E"ѣG[Z{>%(K`FQT@@*)Xq -VŽPPʨXA= $K1U,F{ιO.6OHħ---̙ЀK:::<<}!%%KeddVO>mhhXp8.QPPppp-B!/emmmmmFFFܑ,]T@:::Ѷ.XB2lllLN\\\4449`a ɓ'shhhH$nhhPPPOABB-ZWBBB8ԏޱx#xUUU&yN|u>%%%ϟO&?zHU-***wYtiJJʆ zaiiinJOOg0bbb6*+h ZÇhjjr|JKKB=6YbUUÇkii篴'LS77ϟs89bо}] /P~~~\\{AAALL w!AA5s6XGGGzz ӻ$$$z$U.[??~sܼCaҥgΜ_I!Bww7BM=O oڴ)66… ?&ξ;Őa!wޅYZZrDz7n7f^\ruXXXMM .WRRB~C ߣ"ހ7zh|Hlvnnnff!Bؘ`ggg'wРOjjjByy}||&Nx!/N3vZqq3g\]]I L&sgJ|[ܿFnݺ8)))b Byذa|iC1h4##";;g >yn544/^Zz5!3@HHի SccciiK.c*++<+++''0%%%ٳ~&!--=y7nY;f) > #=|xܸqVVV+Tjpp0 IDAT^300:~G>w}hѢЊ>ǾQvv[[pWFEE 6СCxִ;&&&ZRRKx(ʹs m߾=))iŊ˗/[[[311QTT- $ 4&ikk8k֬#;3g v uCtB"""_w鶷߾}{޼yr,Z௿@###ý,0.=3f qPiiiGGY\+K W+a0 r~9 _A }ӧ v&((N30t C yyy '''ii *9 255nmmΎxQXXX??. 5r ~rœ'OO:zggV|YYrssO:KIII:'))Oq8ׯ~ZCCcϟ?}ٳg;w.++KRRGDDWhjjzjzzzqq7L&ϟ?Νϟ?_vcM޿?ݻwܿ!y%MM͠SN'''ٳ 9r̙`x)))6l69,??!SRR6l0u5kִFGG^xQPP!X==˗o۶޽geex⌌+Wyȑ#!>}tkk댌(O4XfGl޼9==GCC#===""rȑBss={?>o<^:Dzy ,x]||ϯ]&!!+H$ooo6=cƌ7oܽ{D"""n…!!!&L 6t<}ÃZr%N`!**$;%%%B$i޼y s<8N}}=Jhhh ST///iWʼnw BӧO'؈sWXt)tttB@Ck6w\ [?~x-))A9;;hRWW*++QUUK1!TQQlvZZڭ[ BXLh8i0eʔ˗/3ɓ'wqttèQxϧ@&*++?q _ C+N2!yf§>Çq7BX,وс*X;v$%%.[LMMܹsgϞGzzz ϟ?ժUa F=ب Fpss=y3\B`hjj?bĈ~^WXXH\%+++11q֭sG>|߿~Ĝ'w[]]]]]N>|iCk= **rʪ_~Ç9sD56L<!EzI_Lt`0.^(&&Go߾Eg~߄fgeeɑH$*{ UUU GY[[so qBʕ+.\6l7nܸpyQԢHiiiKK+W/'N,((>żOo߾urrKLL,** E[ ƭ[d!7TSSr劀D9rѣ?4c sQVV~fΜ0| 8 !$((z3gss ./Yd ^8y򤣣c~~s ǏO~~~>P() ]v1̰'O\rƍG&}SSSDD۷mjժ y!44޽ ?N/>:`Νo.t & bbbBBB&M }ݸq#55Hz qط޽{ eܸqP@@`Ĉʈ[H> Vf讇}K&Nxϛ7O^^ÇѮĮ @L2ÿ˶m80eaW+a0 r~9 _A W+a0 r~9 LκeeE;W~?l0WVR(Qt0 c`\`bwwC㑨ַj:~ |XZZjggwر/}۷oUVVy6.. Pat:=**Ӛ766 lT遃<($48 Ο/--|y/9{:x2nܸ$ 2a#H$ҠpQynnŋ$aPK/w['xipf̘gY[ݺuD|Izjs̲^2'Ǟwanf|2PЮ]xMn%u=|jժٳfϙcZ\9.;w͛yN<w MCz;uT~mݚZZZ<$$ʕ+...eee555?3/= O4}ƌ))P($JKK$&&:ĠEsVTTVUUUUUUBB!}ɓ&O޻w/.111YdIbbBO:eddǏKIIƏG#tuueee?!:YYYCPuu4OLL!*좢"^zw>}z۷֧L3ssaaa&d2I$~vvghkkcmȑ /:_lF۵sgXhdϿ.`0nBBBҼa=rϜp6wB[lQL<Ya+}|>9 9ѣG{QԯJ 콋eڴi%M֭4>|BTGWw  }jjjT*Ojjj1`uU4aaaջd0CaFCus8<>F()!D)~:/,,p{Ff={Ξ= 7aEBǏNz'~ZZZɕn(G9Nʽ{))))}}1YUUcT!^3W\ά]]]ĵ /ް~}}]ɿ?У_ 0޹E鹻GGG̜9L&Ԅ2xippVʽ{>V`fn~…3g^A)SpIXXئM >{o%K*+?~_WW_ \x1m7m|)2^rkךrc... njiiQUU%r|GꚚ?ɱ={;Vꡡ.\qF{{ vy*,ĉ߿=zو|j˗.]p83-- BOQF%0 r~9l0 t0`2`=`=l01;;k?u" /+i| W0_A W+a|_`btvs=4$yYYy{Sye%B@^`0F~O%&vw7$!*"†,C䰾UWWNciiݱc7*܆JͥQQQּf`\|Ç_ ϗ~| tdܸqIII e]|ܜMpQ9OA㕨`h?|Kӥ<==cssΝk׮())͞=]PP|RRRBѼ;u66o> !!)Snذ}aTttIq1L622YJNNhիg:;;555WX;n>u'!a߾}̔rtt\~#G7o޼y?q󦤤G/qƂsIIK#W=!!!sǎ{Avڅ=zو^b|ÆhbbbrssNo޽[SSSKKk@\rť,<<Ϻk׊-^`ݻbѱg988477?8/_=.6~:{,---vssILL ;u{vnnn UUS366識:u2Bh  ,K]v?=zt_^}呣G%$$Bϝ[QQqOOO=}}bBh簲S-_<))O󜜜m۶4m޽~I555zW{|GG|hkgjoo?yɓ݋MLL.Y'BBDEEO>FvvQ.qllmqeuuT^rJR!!!!~ _z?]eq9'g'ⶪ8)0d }}" :::i(##cccCT611aEEE3MMMB}Vcoߣ0##Ύ#G*((Fdff $IO_?1fh`p'>K?v,h׮QF-Y q7aaa!!i^RR0y狀F_O8]MN^/W{ںeKr+$c2?`^xUO<))) ?{L&uz҆Pg_( rNReeeUCyyOR3fێPRBR(:h4P7/K>xϞ &hjiEDD=g|zGq8Okhmm:~8wFFFo^ƇSRx+W2vuu L)))}}1YUU%>J\\|)/ ill \nɒ%JJJz늉!>6 R`7}.##͛7x=_sww9s&LNOO <7n trrb0q\3ݷoʕbbb_zҥKg |V-]^N^cƎ]~}2nܸ_UUdhh#F 6lj*gFRcbb$%%Ȉ?~)a}suuOKK_CCCEEE555/^VT*!!!Nhk:$'662W._t(**δ{zhh nܸ.''7~„7~o6/455!8sÇ;vB.L< عTX؉'޿q ;יem= 56() v}uW^dgwvv0/\WgӧM3vlccctT`_#aIP@Sz'\vB޽? z`2`=C &fggm]]yYYH 5+_A W+a0 r~9 _A W+a0 r~9 ַj:~ |XZZjggwرj@~Nomjj͟{x:Uشi@D6 Kӣ>y{{{cccMMF5(Bmyyy{{MrssF yyy'B?HJJ"##+'*937.)))((h EMMm`uqqINN622I~~.DlKKKyy8αc^z]8q`|~WO9Wz/yyyBBB޳ Lfqqq?,<ȸTWWH>$HZZZ RSSsrr֭[giixc4p8 ^^^3fpssd꿠`Æ {~ky[ZYY9::>{ !???333??F?p͛7̝;7668f ƍhaa䔕LVVֆ f͚cǎS ĉsεXzunn..ooorӦMֶ۶mlŋ/^lff_UUET9999Y5mڴÇs\ppOOO|zEBЦ}999YXXիWt:l6{Φp83f̠>_aXLLL@@ ܽ{7^+!!!;wٴiӌ3Ï9g?7nXl͛-[v݈|8//oJJJNNNUUU?~xxxiLLL_~gXEEE=ZnݨQ~G*o߾l\͛7{]`۷o?Nsufooӧoٲݻlppo`]vٳb! B-++>w\gϟɓݻwF)%%jooWL&1jll\b>o޼`EEŀ&+r8={7nȑNNN77+Wnٲz!wMCDDDVVUTTBCC-,,pWR~B:uJ]]!ddd|$ާir${ IDATm6;;;\FݻS^^{ܹ߽{wժURUQQKMM9{ܹsIPBϞ=#*BH@@ȨАXc0[nXf +Zrecc Baǎeee555w)S%@0+++.<11QFFƆlbbfztraZYYvvܹ'0F۶m0BL&wwwST|RTT#f$$$ۇBHPPPVV!p=fBT!((8lذS8PAAB_Ν;]]]Uko&&&DCЀ^9{'""JLL\nN`! "!!^ nkk%; W^ݾ}; q7aaa!!i^RR0yz XLAAA+++dd2c{޾}b """D߷+++C544DUWWgffOd28GKFBH={lڴ 5kQ@[[GnݺÇgffرq-()).h׆oWPP}*T!0 h:z1#GtX^^bS&BHZZ:888**榦%%%l6G3fŋ7lذ~KKKIII=3b,s9zꕩ)QdžE"=n)GVXXCmkjj֮]z ///lv~~ܹs?zE5>dǏ& {p8Dovtt]RPP_X+;΋OoooŽh;v,Bh!bܲe ɩ? N2-//u&Mf{Ƽ<2܌FoBnn.ᚚWQQyf5d(,,2mVYYwj`$i̘1iD[vQF\޾ѣGDH +qX!##͛7bݣkjjfΜI&kjj¸)ʢE"""dyGG͛7MMMKyxyyyJJJx9{4@p.?I$?~L̹JHHCB***8S(#G/ֺ{.^©`ΝiiiVVV W\hx @BBbĈ.Z //ҥKfffh4wwׯwuu/eٲeXoþKƌ3؁r7͛7 !r7om_8 _A W+a0 r~9 _A W+a`01:;뻻;H"ƒC~`*P(""tL&8؁0\`bwwCw""lI0H N8q;vlp_iio+8̝KThoo#5TrXnn.N퍍555RTTKd)))ИclND[[zrrrZj1/'&&fgg,pr% Z[[cKKȋT*Bž/WJHH66.7n&WWWA褦-k'7aVVV8!tuu4OLL!*lAmmˁESTnojhh$''755YY[F#ebOd2=d!!P rrrxɮP(Z1׮={L^^xGp ) _^542}ȑW?'Nc>lmih[^L6->.(1~/9^JԩSĸsRSS$*a}DeddyFYY6sww9s&LNOO ebjӧ>|I@Ϙ8qٳ\kCC6UXTzyYJ3s~mwP 33H### #7o0>>--rB_WW_ \x1mT~FFYScƌӧNQDE-Zckĉ~WzBIsĮ!!Gf۶o9lذaG;}T̵k]]]cpK[[262}2p {?><<\DDhʕψNHHhSݓ4iR:<0{%ՕtAw|k6^{G>S֭+//!d]]]͛>}NQEIi|}=}j͵|6Y;@įׯ~~]XX~$e@dee~ daIP@)紴3gFuV99Aq9::==]K[{d $6uuݟ| eew3+_A W+a0 r~9 _A W+a0 r~9 |]bbb ; |W; @ /[ӧOr7o>|WWW'N|rnEEEaaa ?0TrXnn.N733c0=NEFFUV J`|̙3og:wرcUx啒RPP0(Wb0?QxΝA xٳ]ڒΝE»QF޸qcPazzz %EEE'NTSSӀL&tʿbiiy5a>dh尩S>z(IHH>|8wӧO/_b޼yǏ"Ο??$$ݻw+V,,,HKKPo`촴]\\Buuu։D 1b8::666 _OJJZreKlܸpոСC4… RRR!˗[[[߯|yqqq={Ο?/((8iҤ7o>!ڵk%%%L===blnjj?LHh<(##sy ={ .^n:|Ǐ?~FݸqR!kk묬\ZZèǏO<EFF=D"!̙ckk?mڴp8QFC>KM<<<.\(**mmmﷱٺu+.4i{,--B#Ggg̘nݺqX,;;2PBB JࡪéR3T*{rٹ~zQQݻw O666zxx!Z=}a…8!߾}266FeddKdeeY[[+((}ZWWA*))%Æ knnƏ"` qBHWWWEEΝ;K.MIIٰaC:l6;--֭[ CLL !ֆG!!{A !t᜜MMMb/>1BY\\:|p---òB&Lx9N {w~Y߷o߾}!!'OY#GQ~۷od] !Dd~!AII !TYYI̵JJJB=ʹEEE߿Ѩb0dkk{ƍs8ޓZ,kǎIII˖-SSS;wٳg?s~~ݣcbbt%!$((}8C NHOO0aё#G$$$u-[$ᴴ|~444655yxxܹFy{{8k~!!Dq;pa999rbČz|Qːa!0KKonJ\rWmmmXX~ׯ_kkkP]]!T\\=V+**B=L?<~x5`ܺuExN^^~ɒ%'OLMM555UTTtvv^b̙3kkkwaÆn޼ykfbRkU.#6\%K$Vlm6[ZUۄnXE"_%Z 2IZ82Iv:￞̙|x=99OO{{{G@\vL&kiiD266g٣afUUƔݻwfeeRT 5k.]322 ?|}}۟3g͛7}}}---_. e$ feeyzzZYYXxsNtiuuu3f̐سgW\)##۷oGFFU!\.x}a3aؚ0@AF d?{lN>ѣh%w:fK, LHHսtZOH055miiF%&&&---SNEkccyL mmmj~W222F mtɼ_b{yyٳgN:Ʊ/bddTRR?$z(#iСCKJJҺx"0ydWWj/++/^|رceeeó|yyyb?FSPPð4Al퇎͛7/b10#$""/_Cmʞ>}0lX8awwʂ/b ðK>.yyyPnnY>H0 aax1 ð 0 0l9 0 p0 +0 ð 0 0l9 0 p0 +0 ð 0 0l9 0 p0 +0 ð 0 0l9 0 p0 +s011111a2:$X}>tǏo(77199RSSݕGwUVΝ;srroKKˣG;zjFFԴ_pᇎk4a&\LQQ!;;{޼yJJJk577m`36e˖GݞwHa`7)))ZL&d:w"Ġ6mڴ pܢ&cc30cO>d/XL&@ttɓ' 77ʕ+IIIJJJw܉RNNN?CJJ j*=====}WHh㥤$%%UUUoݺULL }WRRRSSCPfϞm6UUUmmm'N(**P(-y … """ZZZ_φҥKϞ={={{]vmڵkɒ%p͛7^ ///oiiQVVvtt\jդI544`9;;S:::?SXXX^^^{{={Ps;wzPNNntbIJJͭUQQqww$>Ϟ=`04446l`ff9s8q̙3wܙ6mZttt ƋՉ'\ ~~~?#""k a<￿y&ڭ-,,inn^jړ«W@KKˆ OK^?D|||pp0ܜFVWWoݺM6 )%%%MMM 2$ /谵xѶmzzzGeeeeeeYYY'O$ԸףL|ÇGw|GHp===kjjeee}]pp":@inn޻w]0l9h"tΕ+W$$$ϟ/###$$ʏ9"%%uM---55s;îܼyի?^[[{ڵkX[[KII%''3 6oܹN:F[reMMMbb>AL9eʔcǎCko l6`@UU dffL QPPʲ2t@w_5??CM6 wO;rHppp{{;?СCzzzo͛7NիQ˥Ϟ=Cؿ?D244tqq.rrr%hjj@|}}mmmfGFFS`ooogg',,)//pjjj&M֨JKK<ۻZKKibb?z5w?R˗/'xxxɓm2tܹOHHd$&&F,[>|%@R DMM㝝ǎ#似FaOO@pB#4*^^^N4 H4N婩o|\\\** Sp̙EII Z<**Jӷo+++ ;rrr/_^bFP(?T>>lkk+##3emmQO*HCHJJJJJݻ٣$&&fjjz!ps9s… WPPGٳgh4ŋG ذaChhv@@zIKK+66vٲeJJJZZZ>>>C^lɿҥK奥~~W ϨTٳ;vkTqqq˗/WVV9s݋HcD0 xaW8aaaaxsa6^aW8aaaaxsa6^aW8aaaaxsa6^aW8aaaaxsa6^aW8aiii&&&oSttIxx8*ٹs'*)..aظ&0cbbݻWQQA&̙奬S''kג/ׯgeeUVVvwwh4ggKH:DPPPRRljjX ---˖-{:99 YTTtڵ'O466***Z[[a0***{zz ϟ?1&yfee氶_~EUUU@:{ *++NJ c8MMMxQRZZZZZZPP?VGinn5559~k׈&6eʔ"0 j0X]]`gg Y,i4XիWc!BaaSN Q޹sgUUYXX444,Yd 4d3 ---osرDmaaTVVVVV6o<0p9֭[>tss#YZyys?~LPtuumۦ>jnnvtt *((oPMؾ}?<<Ν;ͪ7n?>Fӳ޽{Ǐ+++ڵK[[K~~~~~~T*5;;?ځG}D 177WPP"A.\ UUUg"& rrr%dff͘1cݺul6;11ܹs讇\zk׮MLL TSS;xO?0k,r[l[n'|?,ZhNWTT,ZHCC#..'99YDDdӦM999...:::LiӦ ש0:ІJ`@PأGmӃv &++ɓDؾ>)%%~z놴\TTTTTDm޼mn߾=99Jvttxvpٲe(D;w.QÃdQ=zz߾}0}Ld2uȑ3fDDDC"""nmmmhh(z:eddy۷okjj\plllf/_ɓHr)bĠ)qqvuuqv$t|$`yy9&F?C4ࠥd2 ݉ϟ?6mgЯ[XXڵ $%%<l6߿GTTCCC#<<`>}zT*h8_pC\rmpuu}[;'N@ 233311199y?}0 [.555)((PXXK///@^^~{9vښX^1{lxۊ7nXXXX,QQQTJ@Q#yTCZFF=AAiig@OOo$RSSsDEE666BASPTT2jy{{WWWkiiͮZjϞ=܌5 ~wTGUUh_gg'ڐ{c9΍7lr qð΄a *' hbUUS(!!!3\\.dVUU?>Aە^ɓf  //mmmcc.)))zO ]]]^EEE\\d'Q2,++  8'Oƙ3gΜ9QCCCq]  @^^ސ3ŋðQp9l̙ t/9-( jHpwz ݗQFHHHHYY /^>H$;P(%ĸmo#DWaaa"!Kl0 s.xyy!B# @3lЌ3^x1k֬1|痠8}M617XYYEDD@hh_jBQWWG5KJJx<Deed N6 ^Xr~;70*!!mmmuuuQyZZ Lh}ߺ0&_;;;--K.eff'$$666F批k׮͚5kƌ#>=4!Fl?ѡVBĜ:u(''ѣ=a@5? Z߿e˖={?~|ƍt:ۻCDD)//߽{wNNNaaaDDDRRjbEcV T0 {'nF&t={gϞQTTގ~_KHH۷ޞ$$$|ÇZZZׯ *..644ݺuHKLL~ƍߵpႋ\EEŽ{X,[FFؐ]III8qb߾}Ϟ=GPJt>55`ll,%!Л[VVVt:}QQQQQQfeeeXXG-駟i4y Ht:=77aXRRR>>>?BYZZJII룒ԯ-~#b6Bt&%%H&:--@}&UUU33'O9rHt钞 .̜9XIDATg0 9992loo/,,7n0Lzj4{~UKKKrry Q\\ennp###cc㶶GH?-F&tIENDB`errbot-6.1.1+ds/docs/imgs/hipchat.png000066400000000000000000002610241355337103200174420ustar00rootroot00000000000000PNG  IHDRa; IDATx xٓLvC!ȦԀ E TEEVmYlۢ?l?\pEA*BEP"hM!d2:g2Jd&뚾}ߙJyEf$@$@$@$@$@$@$$.B6H&f jWM9 @P4HHHHHHPk@$@$@$@$@$@$T(djh, ,$@$@$@$@$@$@AEB6ƒ P;@$@$@$@$@$@$T(djh, ,$@$@$@$@$@$@AEB6ƒ P;@$@$@$@$@$@$TAe-%  ?lg  ]#BJ(JyRVѨh`   h-V YKnl 69b+mdLœy7`\}Yq`]?l}kk;CuhX=Imٷmҧ݃E@oƖ_ƓE\:t?:L< \xrﱩ^u yH]iwoDq^`û7~^ @#`ZaXQU]R Wg_P"֊Z|tEBVWRV+ūD\$$@$DFgC z=t: t}8b  .DBr|2ƽoD=Ly{鵷'dg<-sDUӹHL]0ZQ|b;^Y żnOiï٭X61:b&ß'4ALtdi5kXccϓu>iu>$ Mnle0P-**l;q 55v DoZ>O/ >.Q7`4#]g tX-cz{~X6 ™OwbܬszD8c'ӷAv>SO5IHH@E!k9֜׉XOt,xEX.v߱ -lEk;q0@ wbB>gţqW! >VٵgD 3xXVDb;hcmdg A_wL)mY OuL]& h@Aaĩl9?eT晖_}aҰBaa AZF,+a.{5X-ըAUU,PRRapG<%9"W2RSWy @B1k^FFAҶkY-OEX<,x|X޹amSMPsw<+6/v.'Ǧ ĭ'}ObSXh+'3͊M E_ͥHѬ7VZ ͛^ăjkע5  ")Ӯ#RɇnHJGGDe!:)^CtVZ?ޮ0X{+lyǙWCdd*$@$@$@Mh_mk^%`b;uwj 1[W%vn? 3zLg`ΠRX! <2%EҠ\{$3# p ߊkd>67=~_ܷ_&ŻgEq$ZX[[7v<<1MgZ^۸( ^ @W!{~4+^E{`~"6Rx_"W"Shh4צk./7C-0!/,xBڟNsqϷmv yKs}> tg- Ym0ԛ8k^ZZn]b -3.fLڵ?OBZ.B c'f&Y1!& (duf`Fvx#4Ef:CZZ)[^T,b絛`Xύp{U-'ުG]1-9bms'->s/=O7_4$Z7v㱉z{96.t50h`= pm!~m)ֈ-GabjWa| A-|=4+b+q`nf ֒lY x^]͔CزpO语j>~_iZ b0'>vyH:*ڽ"!/~qD,^}[B#jPKq `#wHXQ_$ɣ^}}lk,|ӷ)IHHhYȊgiX[*6mriZ 3Jrgp;zMMX]?1RX jØ`9)C;d[xt_1I܇x>O޽Vh:N,[8I>3M cOӦdLW?džS Bexts{J;^ ) }o#>v(w>CzuE\mb-V=՛?oGl?4pދMHH4:۽A^&G$@$Wcx58v$!RѴ~}G~Vfе "sϯG:Z_w;Ho1r&ё t7m9^ $gϖUO>g{݄QS3śM^^ mz`6{7fU˄Pb=1  (d s|$@$~8̼+z!`/ӧ]kǏG0"(sfOLj+/F#   IBk+GE$@A@l#틯Ϟl"vԉ5} asZ-۾7zu ̘v=F& $@$@]lJH@QQ 6~oaW\*W\V>/Ώd8ws$'_|HHlOM# `# ̻2?A6tõg X,l}#z쒁[!   DB+&B$@H#b<^k1ir8=K@~oFhC܉CF$@$@HBk *<"vCxij=v;x,ZcP%"sp_GxnL$@$@AMB6Ɠ @'2W+_"z&uȂrmlbwԱ3gއ, @P4HH 5Y2;A#ElOX boY!o=sg  F8kH@Eo!pkՕ_?/x ȣs}zo1nL׀BmJX   `$@!FIH z!=|3)J<LJѿ_o5b?=w5 $@$@$(dqh3 /vֈE !>.{'ʋO#1!kȡ#fHHl% ! þ{ҢPLkbdpp8k^iP$@$@$(dbh$  76;'km^Xz*Jvk 2 5$@$@$ *$@$@ | Vy1qxh4=uFd=0z_KIHH hPTP  gϖS#b?Yڳ5OKsHHH@HHE"mi;B>,z}X[ VzW,25_olF:V>n+sϯw{-G[gOG: 1"y(+3jy.y&  $dO9-4HdLBX~ç"V\iuTw4M|\LfDoehӷ@-GyMJ T[i {@$@$" [m"Bؖs{< qy4y^1|c.W=v^}FQ#ѣG4ťyJRBz=lIވ^vrl4& $@!/ @j,YGِmp!>M&6y3663O|oW]}/%Ŭ(_7aoUYn6u7BjPY WXr  " @D$@DtIq1I0!i=:=0S'_SN׍wfknzjۍ  C$@F@${9qGžx<ߢE~3=q~hHd  @0HZn#jn#vj.0lY'B)b۫ ŚXmUzo@$@$@DYh2h "ZυbQ67pES׿5luОD zh zd*$ D@$w!lUU_hClЫg.dg{ ǎ׽RpՈt3Kא4y_lHH/= @B,uCx<Ͻ<$G!:٥Ľ}lS5w*3{S)d4_0Ad*s$@$@$(dyvh @#`;Q^RYV>PBa4Bx:#ђVgLvȚ̕2 mI uun^}ut6mʼnx|Kʼ]GE#4${ " @D$- X垅w`<p9 #)I[&2v!Ė0*QxI [͞o=zzQTT"ۭ0֯w4վF.{ @{ٰsϯǟ~;`g!m(o/8)1\#   D$ y~(z' :ҐOs1|niH u:UBvᢥғ?{m-?_p*L_ȸ&k63inSYaߣm%su0%$@$@$:g@HH جP{4#3yY_obK}׻5^{\g)"LGlts[u|FNn{y{  06!4Hcx*J#;+*ԤMjdFMkSTlrXi_uqݿ@OXrsz_ׄP'^sUGuGW&wkdϧMǣu?:V1> Yݹs4lHZAj V % Aɔ38,+@yړ;vdU0 +! !<)6>r%a3~sf8*}E3Q?2L| =:aHHKY/ He(:ŎByrN 'P ICg>иee"ͨ qrMjhhǯHJzA-+>&{\DXр>S(bki- t[v9p~6;KМ ꜽ0n_ C˞AeeU=\;ArJvz+ xDG]PV Kxǎ6s*+;w{Bp?#=, @  l,04\Oq'Ag\}God5ѹe{ FL[8aԂ8oⅦn׸ⶏlj 7Oaiuc8<%2_QVBYEe۹5qH F 1:Sz3 瓽rlkԜ{|f ȃAn;ɓZƨ8j IDAT{=l#  6m3nW`:erka3Gn9k:t;aOxJP\R iBV&Ĭ̪CB4CcEF話{GftCyW&WUk6xmzHl  t>C`6ze cӄaq۹a!=\؎-aـ`FCv%~7\!.M'fɐep0k p͂(8FOQmsˡqM3Հohhӿbچ'k?ªqͯhwôosŤ_cus޲0BOÐ{ +' ; "))FBuQ+a06,IYxZ <׳!,lb UkHHH P,uxl6,ÛM3o)6a"V-e(n=5h`D†7y ϖLŠk8yl³W,fL{C˱7NxE,ɆM}›[3*ZNA+`>t%[Y69 :Oz#Vlۀexr]]t̞Z6W㼈= _'ނekeA=K>[1ꝗja:W<SXa@||4*m6Xb@i.@Nؘ(8,,So)Ӫ OjƁZ,c\Æx~gGk6" ? Li 4MBi.ۀ@yx_!\~x.Ӿ5bEO)0F,yf4s z@5{-nD: tk0|B{$I㑆2w`ѓspUjVH۱4cOowt,Iи=&O ^lYO<]p00$&,TyҀԌ8h;^a<8k ӝɷbrFN݃xj;2fp2X 1 ~ QHm#'5c/[-@{z/2 +rd׎_T=Y'Kab&j!y

wZ)[51GsOqZw4t݂qad$)@t6iSkt+.k} X9e,}KH߁9o?axk~.Pc3G}/ &ݗ%ז]M4ҡO왍U_L5{wpԯOWPE@PE@P@oޱy [~^즯 g,:4f~yvsEoX;\h3z jOxIyIHw%rzN?} sɺ_~}WdzYPk#^GƮ-ܐBtdXsp W,dw&y(/],^Tॾ9[cD,@Zġg׺٭뵭ovLi[zj#W5)=eN[ o*o;{8%缡NzY唿U6=Ç'4>ߍ *\Cjm7~D:L9 ӣDҠS5+*Qe(_6UIPE@PE@PEJ2]8.Y7k(/9Ei+.f +Yk{߰b&ǥ05ەfFOk=7玧䓹m=?,:T\IC=5ŬqƊW7mE@PE@PE@PVwcVRPE@PE@PE@PE@I%E@PE@PE@PE@P pC:("("("("bJ"("("("(7J UXV0[ 7+[A8pѬ'F|[?FQEUw ߶OD_w/ڟ ܐ6vη? ^Ç:b43U؍ģ.WE@PE@PE@G-ߧh1]o^ėco>nAǟ_Eܞ-'䑛kCtYX W翞V:)4z~#ecײZ݃qg~lj grp9u6F{Reۥc4MG"8~_Q R;|{P6ܽ|3uxu)-'K2_鵍^!>!u7|1|g1[\73?ʚ]NE@PE@PE@PH̋-=)kf*|#W 2p(iX$1\X(V`_t&ҡu\m|g 9o;^>__M2bj~,W~*h>q444444444p5j|Rߨ;&a|>CBA?Jq5$/چ~k/Oq5 %{ 8&1_zoÚY٭CcPeіE_ l^?/֒-H=6 x+n'<Ծ\sfNcPF{u[Hd3YI\Kπ. BVH X(V,<ŗа/T)}l19:@XCǜ]=-e =Lz`IR$ev%إ ,`|]I;B TOLS ˶f\#PCPMs[~*70󤜃+Bi9"VpDȯ)V}1Wo=S|{ț4b͔<0E6ŽlE?op8rƚ¶!r   s_QØO̐wc2seE@PE@PE@P G2kuQ-&GG!qJo﬍s9u`]ckLy{#{a\._?v/}jOPE@PE@Pk3O!}?iw5}$}sW{^r1a~0=+H ~_Z.e7IN“~xJ~:.4Q䶩깔sl*o~bj)sԒT%`=,ЂaLrxc`#Fc +\3m biuv˱m}pA$h0OZ(bb1cɍÒ|Ćx$9>dd6-9ZH8T^OE8n~&^OZi Y_&?O,?ϻJW 6P:(R`H7VUlQQ?;%SٸUWYS=+7WՎ"("("(Mi|e]A+;&6PF@U]DR3=#`iqdb.˾:1ͥS Ձ\ ijeبTJK1IF7S$ARJJ4}LԊ ?>=MoZ2O=~ go  #Ohl<}=cjɧv *DžqDѽ꧁OjFѭC]$0p s ^YƗs*~(%׿\*yǚto՜[k9r혉]k"ng닸=uG_.c? y?#>J=S+"("("p(=f&5ly]_+PYthZ ݉Z)7{ IDATvhB;_P{«<<<M Kį/|E+q(s8= e8MM=cS9 y0:#kj{y3xdG ڧzg!cZv\151?ξz -_xp|,84 })cKᙥ|h= YH z 8|Ҫ^~~UA*"("("pBFH˯}k)/YW/CfJ/{IלWgǏvb_ϼvU|}/D<$D\ rQYi@i@i@i@i@i@i@i@i@i4)j]% |h,}AT-(&onKJ<7t9>szG AjӫWUҥF,x$ ӣ h#ܝH4"("("(&p .=}P¥EKkN]W.敯a%k|~o?Qd&y^y坻 ,i t|2EG`t.߅տb8Vjce;U-X9YK_̤) Q7*аjJPE@PE@PE 1Nj7_ATӊ"("("("(@%P'+2("("("("'b1P("("("("T2dʜ"("("("(@ێgUrl+ҭ>Ã[A8P׻qtوo0J%w_÷4 Am4v'R{5;I7fFv4 L$mKWe4jO/k'=mCq_Lzv&ѷYϱj!hGsXIb~zXw|~,]xkmBo:z' ?ZTĶ Cwr۟ؽ'nO@t}=ۿnH{ͻziU:pKs,f= WuaSy] ́t~iK{5KV {C˧{4.1>Yb8 [NaZ9nR}b~O*r }MQ9NiWT|G3ՠn\oI]':+73><l3<4ub舾thdrEa}ks?϶_>$Ç#.y޾Y.t&0w>^BQi$\*NvÖo:{l_}O![@ֆL1`!uZXʙ YY>P;"("("Tтc$WߖɃ5GܹFJ˯dQҰI4cԟP%nLRC낹LVEsv|x431JqK_&QSfK1S#RwgFn7 H&H J^.M>]&*&M0ՒKHBQ%cDg ɀŝI.M-G$M'u}%pOυe:,a̳B`j(>{ . -ԏ^7_t򤴻F֔*чHرJ}|եkˣ?}jH˙eGq} եq2`b+w_hw^خe_'G"5}N_&?ɈeLE_SO\!'zZw1-j;@`hc1^X >/ujzM|»%Ȕصt" 󞐆G<teY4?^^߇Ƥh|_Oi@i@i@i@i@i@i@i@i@i5X>oԝk0>!{~J7CHImR5U'63?f〡$s/$%mX3 04c4C| ڲӰ0+vaRbZҠeɳ'ݞb>o턇|^9dc3p|1^ =?:-||$?g@phjes!`F$ ,@Hgh +A@~Khqo}v Gw¡cήÞbJ=0դNT)kɲc;HܒQR[N}0>.cM!Ps|'e[IOi؍_y(wߦ9pЌ-?@hsyRẁƴi+8] uKWΈ8)|5fc?K'x<&l|#GX3T0#j=c'PaVw6go$( k˻h?9gWô~sɡ#P*ΩבMOX >&Bwasc3>:("("(ep,&_b{zdƓvɠ(V&YiX`* :Av2 w絃[H:z;j l摴7;"P±g!.JZ`(JwC2ΐ5/PW۰)+m|Lf2[1'gn~@НsĊp,*> ZkCZѨ^׫?` *_fǠ'}+%XS9;yͿ ? ƿAwwkh>㐈 KZmņ= ~eg}۽k bש.?]'/EBK^ [Wql1=/J,IGiFt~^`ѓ{9uJ)G=s8\S_@}'.{=>pA)luB) *o S&Ső,ɯJٜv?`N%[kHҊ%w/_ܿ=U{"("("T,GPLqxXקJ7wW%vkg@#%`F}=h${: Ob%'H~:.4Q䶩깔sy]ĊR,(L{FF zYW dz8s_QpDN!{KB!>K+] DB·\(bb1cɍÒ|Ć-PdG+"l[r/2ݧ8n~&^Oi޵b=uk2_YM_Ӆut" \w|]m>^+LR3ÉϢ$b3d@y迨eb>T%kϐ%Tr>pXPE@PE@PEE)͙Orӹ6UcEw MitZJD8|l!5Siڃ:8v/Kvz+֏\?_Υ9`OHuD 3<ԝnl|3ERpt[!%R:Oe!oOnO۫yy򍂧qJC|_0h`A;.~bx@+e1 k)3P뎆N>[дuP e$%݆P? |P0znEQj^[g? -1\.;e_BΩ~ k\^sg;<(BM¹hK7[?!l#(!5 }Ԍ :MOT 0:ʽƖugv`jtdbm}glJwJgX^t~&?kx:V(HoiD44c:ki__KէJQE@PE@PEb82J4e3Ykc%};M;_]\<-ʥ>g8Ԟ*@6 _JNO~4N-xuSx~FN"p`oNڸ^L*:,ٯ$)Erز+ݫ䥥wAqx?o?çâ@ 38 c?ȭvYǒO7/_߭ٵ!i7䑱k ;7]l-Y9mgLVwn$,!" 2"#"0ȇ,""KaS@  WDٗ@ !@t:{;t!)S]9WUOvw+6vmd{[&y(/^8,\㥾μ>[D<stoJ;Z "ٕnzv`-?îe&&qoe3 sI9/Mefka;_dv-ͫ9;_͢y4bgVc32pF҉n% \G&x C8ecLz 6?o~H>Ic^?$;ߓDOqC>b~|K ")U `ҧ,m^Sp~)w޹{tV*߇J@ (%PJ@ ( JP!͖/{v<;nIo,;f 0]zMo1%V{ .x;Oy^_SFO j@5P TՀj@5Ҁ9Pk Lp3FP4uAOTpi=f-ڹ߫G1.1KC4%PJ@ (%.,Y:0ncݜ3Ҋc%mW;+f`ڮ]H{$|;9K]e\.߯`KC{&ejsH΅LNo0c` ΕG+%PJ@ (%@ț2w5UۨPJ@ (%PJ@ (%t A (%PJ@ (%PJ#I˵J@ (%PJ@ (%PS (%PJ@ (%PJ@ \u5)z K.es[NU1_u/RoXqbESYs;7YmҼVǡ<7m$FTW,*/h5ҹil->?1]j¬PJ@ (%PJkR({׼ܣ+]v2}A.wx}.TLv>3L zo*7=ddbl2s7ya6K>GHT %d.g9ywJ/u6N3UĔ%sL .y8cohΉ{'/`vR|]moJ@ (%PJ@ (%@c3 +ADwGҲYɷ|6U)-Ak[&Ld!sIiZm=>9t KWekUG&> 7}\L:{_|YPJ@ (%PJ PxR*BGn%.`l&!n~!8d ;gcJצJ\Log*?`TSe>F6i?`q;բSxz=wfs,]nVI:?bHSxK~֕1c#Ul6Gs?3}h: 4h[;'NNCTi5GX~LNfy!ipC0l?f< a v~Ӈ񳦴ܴ&!!⟃aS*3c +#9;,הBvZ~ |/t"{M,j'm֛Y9$ M__d8&!4{ :'ug\ًR}i>)/[̱a`LI{4a.[:CfGOy\/9֢״x Qo"ur?<&`×^Ә:YL+ϐ( ͉m7!"RƷOyAPJ@ (%PJ! FpDǏKöDŽܵFJqwPW*%\L-}v`_LVخmV8]:>A*v[sKc[T<8|¤Ȇ#rͬϤLjncnGo@lDB֒01{wH%ݹ{]䃐/JL1Y#$f3wp2K ۷H!qwU/ 31留k R >0wa\Gx#S`1hS{&:!vכ  :" QOBwm* &/@_CIwo.m:֑jU?5JzF u􏥊>BzTpܧ4>N)1ub"1cFKq-Qmo˒Iwyv=^_MHyhX](#[{/*dʒ2 M=̔w,@b50F,+iriĐ[&JǏIBlOϔn0h,m_d#z5QB?8zRҿՀj@5P T' IDATIsδ]bq}"99+!e$D괤RzL|u}]K,9z=%7R}4Sx~G$enZ26se|⦱~6;@$FX36p1-(?@N uu_j1{wȑUJu,vc#f ߱}I/#oF Rs s/L_9%>ů-ܛG\ [!ᰫ;?|agZ!W~VfWl'&ofOg ~9~>O$)ጫ*! 6蟬[ܦS5P^?~}f'qB;g@ Gs|"z<#H`KP-7__dwd3㛧%QPJ@ (%PJ@ ;I{eNMǿM_%Y9{d:C~LR5?ix+fY5]{<FҴMAá#ie!}l12&9pҙrdG*\$gNmԥB N9v"=a ϜIJj~};~@M0r% }$\_;- _x_ GΪ|_ ?L٤\3NvT_]E;)_GZiuOT1$,̣\@~!N%+q7( 8fVGndsڴ{Up|AOc|\ %PJ@ (%%I ?K .~˪NX:L=@Æ~yz;ի\I)O.\{C#3h~X!*%7W!X:?Q_J$Sv5=]ԯK`/>!U_4Rȡ$me& ;oHoiW$&{qSv# Vw fsT[\O%:vVf~ւ' k{pݰCzMޅ3*{L9[q#ޱO4jT>գ HOmX ੼s;⵾M~TpvW/!RX~[ \^+un;_;iBhD c+H*dgU~XOm­>bRz`O9Cur ;J@ (%PJ@ (sXf ~a'kTJ׊_?f"V9A›Sc=?dzUz~{'pl:IH]5/uMtsR͡53?dDuLSF 0T3 H͓a,כ!c9ku.df|Oj^] g|E=1/3~bD:x-4&"ɮ9n|FڮlzokD9ūK㥾μ>D<sUv*h7gWֿ͌Z|n9GbD>0?苙looNc)3ص0/ yCv2Y{Tu:nK7T>+\Ź|?Ozewe4^tՀj@5P TՀj5iԕ, Lp3FP4uA7SL|^NFL֟?A03F@$uoL^=rjwmPJ@ (%PJ&p ν&=c /8F s7w԰b&[څLRϷ=/ݼDZpq%liރch/٤l\ͷs) _T3\QJ@ (%PJ@ (%-WJ@ (%PJ@ (%ɫ˵J@ (%PJ@ (%PS (%PJ@ (%PJ@ \u4)vu6X (%PJ@ (%PJ\bǰdR:ELTAaĉk~ec[+w=n4z yr9Ҋܿ~Qa]ǃ'`lcd+˰>|`Knj8޺EO]{+.j>QKJ (%PJ@ \e5)`kO{tk<_o4e哊)C 3ШrSIHF*6&3={o~)dO5ЎPxIrֿc+{׮t2_h2e i4Z8߼ȱsK9ey{)}2uƏ7a:;zH (%PJ@ (@I1J:ݑlf~?iSO;Gj]߲zL22ĕs_>I/_}X0Mg'>=t~uW?ؽ'Nx 7} +{G"_/[ /u,DŽ8:~}\TgPJ@ (%P“U~_?r+l'>v;we54_6ȸ9v4A!Sر=MW6V?gfz;%FIߟ-1Iy_ߏ?µvA33cXMvr4Ojױ`K^FJ[|Ĵ鿮bð9wvJMe=fr2ˋήx 74aƳ M`0}rJJ i <#LC&C??æUfƖAVFsvX)4 >@:UCH۽MGҦ?6MწpCv//X2{h=مFC{O_3C4՗-؍wҰI0=l0-ySJXh4)>^?j|(侓KOtke˶Lgazi~I¡sHsz<=.#㛖2l9(5n7>yu\"]]ݱŧCuR'24z1!k8vB :Onf۞(~07M- ;kdLtw5,߾5_y8w6/?g{r)⮏}*BwPJ@ (%-/"@Ұm519So4['}WzubX¥δgi7 u;dfJӥ$z,;X[gXmߌ&&/F6ݠ^\331۱?>;>+m7к$,&L̆^4t<l5ku~wˡ0@L@̽E`B-gvGyu]KBuZwsMaT0Y!G.L#8od ,-rJQ$_'NzSxaAaBBdS!*Bpi<[bB7Cık98sN}KrǥM:YM¢*"ѣFC(TGC4 ׽hڪݙFnf"GV*ձ8עfO֓-' e‚XH́̽0-~l@H@@ *sosM/^ oî -f i\ZrH\T`??)tjnl`|^H=3XF6.@or^O@CyA {eđO@h ERʗ<14)8^WJ4Wlŷe{j;po-יٴ|+gwŦ(u"!S7˞~ seW[I8tv}-F@|}}Z&)b2$DV4r⇅ޗ=;?FB&kH.4(O8gڛv R$!>o76_;G)Zz3V3j'KGthPJ@ (%D|_~DoӭWl` {A^#vq.ErdlD|sމ&+A7J%YGfbk=4mkS{pHZFYH5GL Cr9 ŏTROCΜ ۨK@rDz^]!7;1\7Ԇ5?s&)dSɈ)]ĎHwfLU(H#8Ix2t飷.;T3f;ߋ&+'W'nW6BJ/בCs8<| M?uP_I~1Lz|K ?G)grN+߰UŒuu&n+֦#2|SzjO;AP%PT A]ii1Ј=BI(Z^?_V31#x98&nz_z5{>xOPJ@ (%PW,apXVuQ`6kASi"z4wO$>p-&NyVb\cD=|M{*NafitR.TbK!/=,\3;fY#ӿ _\4VD!$XZ* Qmits"xjY[ Hw&a?t~X}wÎ5wZ{o(f1!ls1zz.>5ҨeP8.3GTVV0"5z?Ea-0wm? - o| ՗bf^_Ig?c\7[~AGB6Vn[YX}W:Pّ1WQ1G= eSd纏?%6LaIc%[;=}Ҷ2wnj>]AXx|z(s~A-lSioJ'cY#qb&.t kJ{_8R4YOEJ@ (%PJ@ \ }2 ~X:c\ 5z~~vkE߯r`~2{N*<q#28Aǧ>=?dzUz~{'pl:IH]5/uM*:,#oǯi%[ [S=b?'|OL<=Lad6˵G060Y,f6^߭Eٵ ~WTړ8'M$ZRٕ;gߍH۵Mm͛(硼xpQ6:nYS9P RWY6U ]g73[skyf]zIܝ9C=Ic^?$;ߓDK'aV L:f;^NݟwݕnQ[47UޠʠLtűKx}N8{ԧߗ`ȱ{˓Eq1>xjX*=՟~WJ@ (%PJ&P!͖/{&&OR^J9c\l].&W^qU%V{ .̿*['ǘ2}*k@P TՀj@54ie+? V#( k<Ntt)%liރch/٤l\ͷs)K; տ`+cъJ@ (%PJ@ (%#`Mgl=K (%PJ@ (%PJ@ \tЉ%PJ@ (%PJ@ (s#Isg+%PJ@ (%P4: IDATJ@ \4)vt6A (%PJ@ (%PJkR̿,]R綈b>xܰ ĵNN8͵{h:\g#JZnܥo'0?OdQVA} LctdPJ@ (%PO\b0)_rtڝ͓|R1qEj 3ǁlEFF.ML-e7m|؏:eFڑ9J/w9αӽkWxS^6e8Ƶà7o(MNp9>;coȴ]tϫՉPJ@ (%P3Ifo}6U9Sŕ ?Z,+3IFb')VZoilާ.]9?w%ڒwB#Ϛujb8'lJ2o H)"]?7h:~{^T~2%PJ@ (%N*kUׅƏJ8ۉ]]w ͗M"2C 1C?qvl g26ӕMl#ٹd;rɣ\}fi9?[cd ֿ _->kk;쌧׃.{gfmч0M&eK#:|>DE iVz黋wd*x 74aƳ M`0}/2xg750a  W[YYa.\sm_DT !m6}IX N 7=FrI`ɰqL2Bid5uOY?} Gϸ|ST_c7I&i\uN)񗇾 :E-Hy9_VC$)$ڨd-=!N;>+m7к$,&L̆^4t<l5ku~w ˡ0@L@̽E`B-gvGyu]KBgAye¨TaB`' ]>Fp5?.X /Zx0iIhN"~ƒoȦBTx'l'ncq7Jc-$sD+q%4O?ڝ—䎉Ku$4EUt9DG^QBc"^i&y**ͦ3zJLH̘g\ w/aD}%۲~Rǝ{]W3RZ3@rD~nm6sV^,ABϕ Wvʻ=St~T6~YL ػb(ieU7|ksӟSfzݨTՀj@5P \p.4L5H|){'Sh㿓!e$D괤RzL|u}]Kh <Ş_>@)qXhɿ iwv#ueqo89ym ̈́:OH[;LȪP:\;ZԌzRؾ܊oF Rs s/L_9 %OEs K {|_f~5뾰z-/÷-vrUk5j!qvRmftЩgzW#Dθb8`Ɏ:ξE;mz=U 7ݗInvG~t s_HJr3 f9>e aBJǰGP%⫓5625B$~'v!~: "/O{qiPJ@ (%PJ%\>i/ += uGN֡3$5YFJЍ{nőYصAm$M4:Q7~+ûDo8LqW2C#3'6R!gb;WWE WBаg$%5̃gqYh?&Nb q/Oo^/$H)r𯈟ٟc^{8o`^S"ؒ lrqL;O.=1Yߎ(uGݬ2 NW|WKwNPc IdL>{>ezz5X^lX |-݄3[:޿o|=Fi%%PJ@ (%U@{hL w>SɈ)]ĎHw&LU(H#8Ix2t飷.;T3\3NGvT_]E;)Cs8<| ϲyVCq}&-]D*un$N0γB;٦7u]3vǼ!U_4Rȡ$me& ;oHoiW$&M1v# Vw fsT[\i%8vVf~ւ' k{pݰCyM^Q7Z{o(f1! &GQc=Mi2h%3GTVV0"5z?Ea-0z}k}$00o G%_Bz8~Wv$(3&:MI?m8@b`X`!T9?uEo`i~\QkP" ?u-iPJ@ (%PJ*$)Vݹ~X*usz ykUtt=??ҵ׏Y9?pN*<!9d//,tġ q 3c^g佞;f?! ۟c,R-ck]<9!~Ts(=~MO+ٺQݼD`?'|dL<=Lad6f~ngK>>Sz}e~_Q}hOz̶غ6^z=;ɳ|+wξ_k#ۚ7QCy*9xo#3f7<Ŝ"uoJ;Z "ٕnzv3c-?ۮ-}~'؍gQ>l`O\ÜfQ`31Wsz8JU7&3ص0/ yCv2Y{Dz^ =bU 9} 2_˧o:oj@5P TՀj@5Os7x(\3ZfhS;H^zNt\ 4A1lR69_%eBoj710Jml,ԘPJ@ (%PJ@ (%7EKPJ@ (%PJ@ (%B +cYJ@ (%PJ@ (%P%ФXlD (%PJ@ (%PJ %I+cYJ@ (%PJ@ (%P%(פ1,Ym;U\rlZr.nXqb߹Ts͵{h:\g#JZequlwvV<_h /v->OraD9 xȢ#˯>[VIj_IM=)%PJ@ (%P5)h=k^ѕ]|yҠBQ;>e)θ oo0 2ssSIHF*6&3=|C7SRUOE n)}2uƏ7a=τO5ЎPxْsltڕ=^sx_)C1]EΦtX KzyO)%PJ@ (%.=' +ADwGҲc>wIL}v1'?Z,*3IFbW۹TKKkGV~D-&_̿_HJilާ.# -}' 4"Y^f*qv!eڿW ~mPJ@ (%PG*kUׅƏJ8ۉ]]w ͗M"2C 1C?qvl g26ӕMl#ٹd;bkpIߟ-1Iy_ߏ?µvA33cXMvr4Ojױ`K^FJ[|Ĵ鿮bð9wvJMe=fr2ˋx 74aƳ M`0}8Pj}nZ)xyekJ!;-lgƄ-gIӄ=ڽ&rٓ]h~&MoHvNҜOKpxk[NO0_ϣ C:f21WjM!Q#{oeCoKon,}vD OH ٻcyed4gM+MvҎbx1K_y>u*%PJ@ (%ʊ+r߯ ?. V^q&FJqwPW*%\L-}v`_LVخmV8]:>A*. ⹃խxEHV>aRkmdCqYIݎ!}JM$n- . >] [ Z]ߝ+%A>nr4 5Bbf/>so's4XPz}bQn^'}WP?cpYwsMaT0Y!G.L#8od ,-rJQ$_'NzSxaAaBBdS!*Bpi<[bB7GKmmpjM_yhvO4sD%v:[H#WIR@{S4xc<4 v,ѯ&Afxa<8Yg_Co}X/A~яJھnmI) Uϕr}G6sVŲ,t,ABϕ Wvʻ=smg2e5İD#ɼ$9>DJKe/m5bU̓%gOϔn0wseib'' ~uظd[JTZR7*L l)/[,:VM&a,dp\?dIeP5G5P TՀj@5({ 8Owe$ɟ L9?!e$D괤RzL|u}]Kh/=Ş_>@)qXhɿ iwv#ueqo8ݖym ̈́:N[;LȪP:\;ZԌzRؾBLX 9EӯciWH@@ *sosM/^ oî -f i\%}?V~OMAD]7Qэl\d[ܦS5M_|K\+N]kNxE;g / T ^koa!BUAwj@ Gs|"~\%+u^;6N?=8*ͨ_ɵb+6F٬ d74p_L$(/@Fq trjIg;;t#¾=$XWOჯp%$- YM >JFszR9fwqΫ۵PJ@ (%PJ ˜ n=bKسr\:yfd:SsIMF$~·htNkf|`:J۝tw[IVr(6d!Q"$3%{W"/Envvw8?f`13kgz뱏p뺦rut=t& _\Hniuj6];R=#e@V,G6"}) ~$G8O!!f8i'3 9"E ;);`c ϒFRr6977B~쀘ӂ.N{<0n tl=7;2mdqj$lzJd AU z"Z"m'U|8{:/@jJBπ }n<(z} SݧŲ.zH{^yU0,1IρVrD$  H@) =5vO\SW&Ώ>Ϙd/eƷZ`79Ic)SC{n| ?;,k1\"({CJwLL .b!<](۰aU |85՝ fz64xUֶ{R QV׫p l%e_Ȱg?ܧ_p؎SN?0~_]R)|B3Rx(:* H@$  H1]eO#C_6ޔi:棿a}ʨRO<&|cFЭ_0&Lj]m$ ,Ji:l~o'ɛT`lq81iKoF:˚uvdfl tӿeic;o=NO9v΁7^s[;0g2`-ax?ѽ%pNdֵ¯weInc;h^W~u~kgNnDMMȳ~w4Co!0px`aKZo'fM`x_}ϞiLȚɛ7$OR#da3N%$%ك/r39ucgNO2u-]N7hŭOr`|=lp~ 6lJ}$8xk 5Eo_Šf|-O|8n%ǜ5yR>  Bcք/9&}dpj2>59_p]=J?IEKK!_Q) H@$  HM^btY;ܜ_@z} H^g1*GkOq'˻U9(t_.g,t?Np7Kz !]Fhײ` .'}ӟx$%dilx6\u+$  H@$P\O,ZB[3v;29-\|Z D|[[F%ev!?L~f%_FqMAwߖYƊWm1-ΙT"z H@$  H@+Hu<$  H@$  H@*OVU$  H@$  H@\ ((E;%  H@$  H@*buU7 H@$  H@$  dN H@$  H@$ ,XEn]M$  H@$  Hb.YS$  H@$  H" ((V[Wu$  H@$  H@p)K픀$  H@$  H@ UU$  H@$  H@\ ((E;%  H@$  H@*buU7 H@$  H@$  dN H@$  H@$ ,XEn]M$  H@$  Hb.YS$  H@$  H" ((V[Wu$  H@$  H@p)K픀$  H@$  H@ UU$  H@$  H@\ ((E;%  H@$  H@*buU7 H@$  H@$  dN H@$  H@$ ,XEn]M$  H@$  Hb.YS$  H@$  H" ((V[Wu$  H@$  H@p)K픀$  H@$  H@ UU$  H@$  H@\ ((E;%  H@$  H@*buU7 H@$  H@$  dN H@$  H@$ ,XEn]M$  H@$  Hb.YS$  H@$  H" ((V[Wu$  H@$  H@p)K픀$  H@$  H@ UU$  H@$  H@\ ((E;%  H@$  H@*buU7 H@$  H@$  dN H@$  H@$ ,XEn]M$  H@$  Hb.YS$  H@$  H" xrAG3xLD39-y3 JBͶ_66ȍ JMI@$  H@$  oՌxjrah|ߔ*ݛ{Hٯ/oo6wo=wؑƑ;)? Ɵ,Qp2R81)QI"j`۽1dLX\䡾m\" v ]ƙ$% H@$  H@$ R-JvNjYi$'–BrrVIcׂ|Q{gЏxyn/wHQ$  H@$  H@.]rоW},crvjإuUs H@$  H@$pΑb]FΘ_2tx#`eR9zܱ{$nk]x)Wh7hPPzAFKeΟg`ScؒeC ,_Ƥ= 0yp$K}s/ y46.1FtεY?@Y嵈% 3?A#|W*~EHݽwL#앐$  H@$  H@%"P*>陥k3ﬓ30I>i#~fmXH9X?u"B$  H@$  H@ r|F5͜xj,VJԀDx=Z * H@$  H@$ &PO5G$  H@$  Hb x}%5c29-]t; 5ێcZT#|e颗I$  H@$  H@X^>y*|%  H@$  H@$PM,Ε$  H@$  H@ UfT%$  H@$  H@"PĠuM#fn4ON[ʔkc)eZ{\ ?1k|Cg\wi|_ 6JeK򟅚ӺRV+t\r%>ϵS"]g磳I/ݞ\*n[[{%9cCg]os̕i F[3]:. H@@0p#/,XV87Fz.x`6|'p(Jks1{syuIB^p"G)=&,/&(.EB6xO37K-wʲDb_PJgF[&-s\sMM"/ȿt \i\ؔa# /q%RYy>'s(W݃_"rǔN|tn ,&f?9{옚MybL:[XxlKmx [nkH\ ?99iS51;gڜ#j2+>0'?չ( ܺj:o}  sޘ=^1e VDE,6To6~ʁLHO7Hرy?^3d𳟰/}=.ރ퓰cDZxY>w>yD;OT'pQϰT{I̜y6ɻ3kyk9FqOVcNF iUǟh Gm? `#|g6>wRfsFK H@.2 Z5 GI$VmJ@?x2("""xxVw< saYZ>.sb|W@Nyg<ٚZ[jzK<5LA hIѾc]:ұs_>9u'4wm׻i?N͗mor$:Gp?qP-50L-ϰawFݴa'G96?:G1=1>3un굸˾k=Hq_0뱝)6 y;с,là!9님7>u>39HY C}77}'iB !]>Խzۇ.0};Kq|t 9y7K/"7vD_n?c.ExoL_?g7RW7["- H@JG8l Fp\*{3nS08?vAe38bݓd6p{dHX`Up9-^OWpmȏK2ȴfܬ%-姷{SN=M__8IXSbX{1!_n:V #g*<ԧyLYϱLNJlZ݋L_uuYVJ&Z8[E[,1#vݯDp;S֜+ oPp株XBi=v7IN!)s_f<1$l_ł?fT_ITFRzT0(Cm"6lN#֚[G`꺳헕e9'TG7 ֌bR+G\_$b*Af8COխ%WdY85tR9eRSySH/3_ Gߎ&77w 럅~)Xr=29{ /̹ , 5sUK.<_#5ˣd\+b5Tw ;: sQђeoݙf[rc)xW6>_r`(6NV8?} ?@qܖ?ɑ? .Y|~1 [,8q^wۿjd yo'w:. H@ΠXf>N 9~czN*Zδs:+z3uǹdHu &nX*]A#|׻@8>GXP*sǒ2X) {j#te% *~SJU: J۳:o>4X3/ň.aW@]wM>?=Cv޵gvRI6 '`cvyۿ0_Nw>' {z9YT+4OS¯Iɤ@3Ӛ|ChWx/n*z߰aٰgǚ:b޿y(n%u\_^65G1v-fqΈNk74 ü/.;x=OI/3gOG|q:܅a:OXߞ<~|Qxݥ|quϟbѶ~)O^nF.nW_M0dZW^! H@@yp_a=ʡhqu [L5-zX6d mp5Ax/>Ѕoi=44aOL᭩f=>歮Wd[Qu -{▻=-7Y# IDATФűj3mF_ɷ0R8@ _~5okcrX#xT [zT*NԠ00R8z$zi\a#(8@ :7_iLuޝ^Xß]E?&6|\ L!H>B)Ѭ `g7`nmׂZ&[UA8A7j lRrcVU›FL{~f_>_nm~T6݉j)~oӢ\dW~a5G8/掃$HAn='w?%};sc}xS8j]}^ x[j% )a91x>}>z~9; i_7$] cO; +q|<+x?nϳ/'S `=bBSbы+=O%  H@(k}g&m¶ i'Cixj =W潷#ceOD< XC#ddGkoʗL6=C~>7F:0x"g/s0,dq:fMQP6lHa2au|XO-s|8iĄ%qFgF1pQ6ZHNY$fi|< ̬V}K= mnzt{\XlTj/X˜R7QHޚNܖeb0;)%fâ|46c`t˼4{_N͵cgϑksW彾.z)Q­|k>e˷Rgc6 ,dp|7LҘ.o$_3+v9)L\7c氤^fogW?#V[ͪ7PooC9. m\،=Öo~>_q?S };YžWׁepP:>aԵ,gu7䭟mc篻H3.[M<gj珹 XW?2_ ؽ//}͗6%  H@WS>uӖSmXJ2-FxS;ᖲӶf sbZvWAs/jd[gjfyfy/`63X2w4ؤxQ>7ϫ;z~>E) Xyh|<V`[綜pM;xC[~,RZ#UrP&Qo҄GsՓe/w$ KJpD+$|uZ"Xk\HQLAWѾ<ڲ.AF-eʤ~SL񷉞.u$  HB/4nW[#$  H@N$  H@$  H@.9M\$  H@$  H@PPL}@$  H@$  HPPkrUX$  H@$  H(AG3x70Ӣ<΃-l;iQufhcӨh-J%xuMaˉ!VY)i H@$  H@$ .PՌxjBxɼwFɾǜW|*aR UZ垔Fܟ?ܟeo H@$  H@$ I[yaɇ/U?';$  H@$  H@(]r˦1_1ǩ_p1\O䖸yFjQ?KuZAXG,74țo̭#yw1X󦂺h N͗mor$:Gp?yK$  H@$  H(dcyV?v-S`$z#=_C!7soL =IU^{~@OQ#q ͑';)0e+?HxΑ$  H@$  H@(@9 l߃등T;z071d sI`L0 fXjN!>𯕴u`:RȲ{[OJ=BS8VqUK@$  H@$  &PO6l6)ٱf)kZUM<5l)$^ #;F=+º35YnǒFJ,f&0,0g106+T/ H@$  H@$m~ߙ]v_Uwws ,Oe ~v4l72vZt+̘jُ4̷=`q0fl)OZn q19˭-Ф,Uivo3ޔ33[$  H@$  H xuZ;^._bYԿ/sfw&1 s.O?̌ߒ2a7w=S>K>̆EhVmėyinV1}{3f^͙$-dy Z ֠gm~>ȆY b|0ku7;Ƒ >C6|<b yN$  H@$  Hy!Oj>9$9B4&zƫMȍ I]SSjE_Ģ8Xk"H$  H@$  )k[$  H@$  H@eT'-5iQ ]( 5ێcZT#|66QK$  H@$  H idP %  H@$  H@.MZYu$  H@$  H@8K@A8B$  H@$  HRPPRheQ$  H@$  H,"-|47Y՝g_ԡ)ZE,9B H@$  H@$ PՌxjBxɼwFɾǜW|*a9ݑw\KtnKq$pwMci;IU`S}*-LLe![E8$  H@$  H@JP|L4p߳Lxhz75-/09[nU1G0:e^tRٱd)ۇF2uA8Ɏ1{i ^{7QHޚNܖe3* ?tnz- H@$  H@$Pq\# j>9$*a{0r9+ H@$  H@$ ,$  H@$  H@<}ښZq~BZvӢ4=Ԗ$  H@$  H@JUH'KdL$  H@$  H@%$%d%  H@$  H@ʮbemT2 H@$  H@$ PP`$  H@$  H@@(BAG3x {QZfdBZƬa7e@g:{2<,ONBQDxN$  H@$  H@("%ŮDDtbP7OZ4e3zqrڈ[>NDt}I6W'/)Ö,c!9瘨j< /+ʑ#сw2vh7h%a+ H@$  H@$P+s5(q ( VjH$  H@$  @OX)%f͵#c6e[ zmNaIao  ֧)t w&f_rds<7Η71]XNlaћY-rp_! H@$  H@$51RRփ^AXgt>2l@K8@3W=%n:}C}frddn##S1 4"=|W@Nyg<ٚZ$  H@$  H@@9)fyxOWJ73X7|#6\#X=V^_C!OϽo?-=IHfG!bW$  H@$  H[((f~ِYp&f0U%J뱋h_&m'U͐COխ%WdY85ݶc=eYn˞YTu$  H@$  H@QP 2OrdvP̖ĞDV )À&ho2̗i?L퐀$  H@$  H@K>̆EhVmė[[{ay%jĄ%qdgF1pHeǒl} $;b^c=?6-/!mH@$  H@$  A8.r5͜xj<=U&`F9R$  H@$  H@%/%_^  H@$  H@$ b y%5c91Ӣ<. +m1-ll*t$  H@$  H@@ id CIK@$  H@$  H4}Ԩ$  H@$  H@@YPP! H@$  H@$Pj 2$  H@$  H@(+EZh?hoc!b/?;#7ZK!kvSB e`(앑$  H@$  H@@(H1[j^ADD'}NSY6WADYdsuR2l2sJƳln ,G׏SDzLeR$  H@$  H@MAH@$  H@$  \zE>Yy,a7ӵ_$oô)ؚhw_lt:[WMgrߵacaX'} :C$  H@$  H TbͰ:ӥ`yaZR%{›İia&IΑ1&lX8J@$  H@$ KI|L4:ҍ _Ł4Ǹx ׆Ⱥ$wciIJˍXGʕs$ m H@$  H@$ .Pbǘ ɀJ-3W69*!PZ]Di;l{SRPP3j[$  H@$  wr䓜<%gS9Ȋa#=o6bSiȳ%  H@$  H@ʶw3BHHOp ~UL#OA OUn~boMuGˣ&hC*Tٽ<+{S4J#>$ H@$  H@ʑWGYU?E2Q]zooFH^כ@#kO$ s|8rlĄ%"όblِ>d`_!֖w6$  H@$  H@*@ j>9$]@@cgJhD|ONBQD>$  H@$  H@@2T1E$  H@$  H@ y%5c91Ӣ<΃UjǴF:KbcSA%~ H@$  H@$  @OpY$  H@$  H@JE@'KYH@$  H@$  %Rk,$  H@$  H@"X0+ H@$  H@$ $Pf&8"+B3r>6;yIlK;|Hט5>ٓ3gjK$  H@$  H y-i5/v "yBx,ы+\؈[>NDt}I6W'/)Ö,cG IDAT!wX`$^?g#y}b %Aevܷc|!rk"&C}^G$  H@$  H@ʚ@bf>ļ&+f+A-߸${^4lo8hnx!e%! H@$  H@$PYv :zyfoK!;,K-<;MhsO^@fg]bXwkhYϏ)pCz^w%f͵#c6e[Q$  H@$  H@*PFBn=5&7 2s[?vBaλ~-ߟ&$B={u5,5h=%auK| J)祧$  H@$  H@ML\X#T)1nvN-MXcLn?gn*5iõ!]ޭ%  H@$  H@@Z*N &LKݛc YtI-|wQav9*!PZ]DYjж$  H@$  H@QP_vp:-|Eqyt֜użns~i~y#_Gs:+z3uGi H@$  H@$ "xwُBBr~+كi{W: M%A`6Z=| M|rpxk[N$  H@$  H@(q3Uk+t,e|ik$j9Ƽf5%=vQD# c{ay1aI{G833 9>dSޅ7|jy 4҉߾D%+ H@$  H@$pì Oj>9$yz94&zƫMȍs$  H@$  H@J^'KA$  H@$  H@(IKhk&fsZC~m1-"p7#5S$  H@$  H@(@H' HC%  H@$  H@$P4}\5 + H@$  H@$ ż4$  H@$  H@ʕb媹TX H@$  H@$ oi棙? Ƞ ȍVoFHט5>ٓsn)LɋOMb[Wz$  H@$  H@!P@Ռ8/ d!d޻#Gdy(qi9ИV6eا/,N^>])@t07#]b/V|+㟏8agzd 돔F^C$  H@$  H՚b4RS>U/ -CiK`m,γTo;c3E,[?=`G=9:͗ݧ37ϣ,vws,aQt&(g)X6XR.]zpkKI$  H@$  H@ Pbgyネ FYjrsOsa[:\aL>s[: a'G96?:G1=1>Ղ*Ѿc]:ұs_>9u'4"ldZ$  H@$  HdUPdϟsw|-_&>d48r:"g?/})2b*꜠ji$kM{S%  H@$  H@њb&4%Ίgp,vz!d %؜FՖj#h"Λ"풀$  H@$  H@@n;  !$$'8?ZISp<j5&jG񞛩b1~TλR{F}[]"wQU 3^ t4 KPY\D1]"HQD'EE)U 4uEaW@X)Bg23BIH!;?>ÂABN̛1O4]sB %_^)~H:{}D<3wh[2QʏHF< E5wR|Baa <=5D"vvm8=^ ߇v+ᆕ}?}![Ϥ_z}$  H@$  H@ (f翿[-a͎8;>'{<_Q;x3g~gl,<\W2ݵ1,&0s>0w7q0H89hOeqP6jUST7x"q.{qMaӼ2D:3%  H@$  H@!pC4aQ8ݦ=EF\݄͛ɢ-\6YKalZ"iV7yLel>sIr=iͽ>g`K@yo300ls b דx箇{2dT:e&3{CpkY$  H@$  H7LX)ޠ^UWp(fВ#av/|' 4Fyab,ͅއYxFJƴˢ4ll6l gؿ1M]*,_MM%}0wEIlej$  H@$  H@EI^>dy{pK-'>0w^z&<\q}O<Žb2HMNZTr÷Ug9L^l$Mʚpvwlob#8^l={ƅ=NgZq Ky0GPގϗOOVhY)#bI;iQ0Weq}W:^W>n$  H@$  H(Ҟb O2n9"YѿOAL2^/OCEw ;z+"-3$ e߰ f- .#"%r#}EI~^1OTel9wcVeSV̠Ш-u1qy& 'OsgX*ĸ%}4R޷n!&#MeЧ,ό)~6}vaq|* b \vf?H%  H@$  H@_cX40Tkyy#d;Mɨ$  H@$  H@@vd%  H@$  H@$P2>i ` 3̰5QWyb XnsXmcgZ$  H@$  H@@ 8/$  H@$  H@O^"P$  H@$  H@JZ@b%-$  H@$  H@Ů{($  H@$  H@%-Pa鈆'"+D{0j{b ׅf0+o4{x@̿w.R/nג$  H@$  H@#Pb 5N ~NX=Er5H`7gNW]gԎi3G>uxZWʂ cOt+2njs5xoI9@>;\}$  H@$  H@ Stg̍g{AR.E(u$  H@$  H@+ps4֤tcMde (V\tgnO:coJAA[wS*{GS\p΂v H@$  H@$ ps4Y8;>'<)`Z98`mK6;GĹ벙*l)x$  H@$  H@@q0b`xe>W0o^b7hzo=>uGM/ H@$  H@$ Gh3RIƃ^lYWrx'R2MRFʟl]6RNZIȐ뿔i҂$  H@$  H@ `nUt/^>dy{△w5d$+ :~?7!KL~$<)+Pt|Ee/7<|+_ŋ"ɠFʤ2-GNĒnS/,~K@$  H@$ HH{*<ɸO~$+c1kuװ'_OEʙ2ezUĬe}!eCa@*z'*^);g̠S0faw,qVL% H@$  H@$pq{aƓ{{~S{2j{tJ@$  H@$  DE2&PV$  H@$  H@n1ʁ9 [EƫYnsΫ $  H@$  H@(^ ,ޤ(t H@$  H@$  Ob$  H@$  H@(Ej+EH@$  H@$  JYH@$  H@$  "5y50_0]e,ʌ}|}l(בk'wCy`\uN(3:TͧwAӖT_4/ H@$  H@J@>i؛3;Mgy-x>9ٗmDL5G=B潃_]R?ٓo{.OyBCG`fy 67ȸq,H/<֑KN[H6 $  H@$  H@-Pb.϶(}gFn'KAAR.m'.=GZ hxW5L&{UX]/ H@$  H@.(pOB( DޓEͳ4r;bOY 7)593V}-IIТ;>0NR)=ѦnMD7MYs7W13r7rTxЮ~%ܰo/d4pɳ3'(l&'hPK̯,?19#޳bٔϩ;!u6uލyalv+&Dz-N Jd,1&;]oy.sNTg;$|}$  H@$  H@Yݸ?n2Ƹ `D7 ßLCʥ6.luW[hY[qrhtc~|Adg`yfnr*m|Ő VZ[3xh9tݨTSNO̍dz9Z>w{x\vԿxs6dusfˆ?nLPU=ɜR#乎H0dcn2c)Axu%#_hsS\q$  H@$  H@7Dyv [rU.klo~2 {T.[d#$] [hÖx$KEKNJaP'm+É#O6DJ+cA><[j$;(w.`ŝRipfJ;z?#|5\TmYٺ;HO>i/+8rh&k~豫,*pZ- H@$  H@ 0|.cg*-p'41=,^0~9!P q60 [$;%]S9-K?^M/wbgهHRvוֹ_d*7 rzB Zo?̸ l`2co2KO% ʸۻ}] X.=>ݖ1,Ծ=oq94O]xJR Ji$  H@$  H@~7Hv-jM0Z$  H@$  H(m IDAT@@hO'ыEDӃߺ?H ոu{iL+4ar)O*鍖J)S9Kji/1{Yߝf] {uZvz}|Wo,^v}Tr/m_}~fq8ߞӬeV|—sGp:z ' s!ѓYβ$  H@$  H iO1S'ydE~,:{*džkZL>N_1(x)D=O}^1ڢqa(>XHѭ,5'>̞Ha_1P3b,>wOҀ &- H!jj}hhHVN~AQOѱ+ J!rg c94Q,a쒾xMXN%s3h|p7ad~YL V$  H@$  H QR& H@$  H@$PLj+&X+ H@$  H@$Pz 4ѾW1,DdPȉG`qz <7Wt$  H@$  H@B=lqxk I fzދv|وZ3Nv} q+tu x}Y([lux\\2njSsi¯2sog H@$  H@$ J@=ŮKέ1GC-X@Zv",m^d )b"ǽuW E$  H@$  H@J@or,Jj~ygM!5m&k&8-Ո<[%+wn%bskMzLA扬l[ X||V %  H@$  H@,y5+GbqR O4F;M<_QsS G*df̘9zez'5bHJ$  H@$  H@(S +gDa{ԟJyn=ӛp_$8s,ilٝ Y`ѻ)/ \6}X~z$  H@$  H@%nF1H:TKi{yϤɝ/ܭ9wzXmf,\}K2- H@$  H@$DhƄs>7u7G8I'vVZ3c$$|;?C{'f H@$  H@$ [Sh3탏O柷'n8|I#$ͩtd4cH3yQœ4)k۽&{G-*{[ *^8T$  H@$  H@%#P=Ld܂'s<X!h! S|;6+"7G9_ğdAUɘoG:99R6~E'L&U(1kY_H=w)9Ӣ$  H@$  H@/`e7^Mǰh`4#{Ocor~ʵG=B潃Ԟڞk>J@$  H@$  HvdW1H@$  H@$  H>i c衕s& dXnsNh H@$  H@$  @OO$  H@$  H@JV@'K[I@$  H@$  5BP$  H@$  H@JV@b%$  H@$  H@J@&j:#b 9ڞX2.:c-Ud> P2R,$  H@$  H@G@b$604&Jٗ{H壘G vЭD$Z3Nkz{\_G^l4fMX8[&[dw H@$  H@$  (&j??zULo4 aǩlcA?Zcre4( enD[ $  H@$  H@Qۘogァ>eS>p)ضXEZ)SS9#lߩ`rϿ 8o_#͒U_jxesf\AzUaa 9,OXe(˙nO(w1yolqՆWfcb YL{]r,-e <Z0z*}# ~Eu$  H@$  H@r]3kqmś)vfh%H,U*Y&8_15<(_ՓsIT"`tpuK!|ҁsʅ̾tܗ9'OH[nw{S~J~]:ұs?>}u'iI9_Q%q_f@ t_S_hӤf5yzQ ɫ%  H@$  H@-(P$bf_I!!5=?5?[Uv?gVe9l`+akؚ{}.:c5?HH=ŖIޔ Vk5kS[&!hا*3#|[Y,~s8|4To>HyMaM0 ~1+H H@$  H@$pS x+i'ƒeݮ 3Rr:Rvǽ58u3c%` r|fHq!iϒH5|f^%v/;1ֳOYw$yq60 [$;@2YǙmhZq=kh{G$kb,Nϙi-K@$  H@$  8(F_9H:~ãp{4-*ܣ!1uM9GRɺe{tS4γvxf73z9ri1Y'\p<?ٺl*[̝5!g)'FJZcfmSnv{\[s[xhK//Ƥ% H@$  H@$ gE2|X՟GP^ھ:kgHx)DՌ#tw%\No 8͏ CiK@$  H@$  H 4|Ҥh$  H@$  H@$P>Y \$  H@$  H4 Q4$ H@$  H@$Pj+V^. H@$  H@$P(VKEi$  H@$  H@(V5+$  H@$  H@(j+4I@$  H@$  ŊWK@$  H@$  F5RQ$  H@$  H@U@bʫ%  H@$  H@JJc(M$  H@$  H@*FbU$  H@$  H@Q@bT& H@$  H@$ bPX*p H@$  H@$ (FX*J$  H@$  H@@ QXy$  H@$  H@@iPXi,I$  H@$  HX(V \$  H@$  H4 Q4$ H@$  H@$Pj+V^. H@$  H@$P(VKEi$  H@$  H@(V5+$  H@$  H@(.E(cX:!&GV fؚ(lE5er x;G`k MI@$  H@$  E(fm`diMgB>cV)jlߡ٩iQwsGx2{J2{Sn?,$Ddͼ,ͥ]g9\q^ɀW>FԚtZx#d;U%  H@$  H@n>i/mipzKGۺ>=2Ӂ q_x͗&ځ]Sz^&H tPw}7bJg^H@$  H@$  %(fٰ Kf(op)s=`L2I7N'|L,~K4kR $LB|< Iط% H@$  H@$ %(f*ӈv#n?i}9[g6~:]͒3q.H@$  H@$  H Oǜb]RbGft؀2f02I:u{;s~,t:̔wG|ƯxvMʱeY#'Dm Æ9Eͨ~nӞ?"_kndіHRsZEs&t5ߙ]#/WϏi$  H@(GS]fW<<<7yaa8roԦ:pf@'Sv}>jd>HL#_~G4nD=l^ 捡#I|5eǯ}0Ʌߙ]#/WϏ|?\r'$  H@(zIsϫ!F=KòT 4ljX,ǟ['+Z <×7v3T2LXbz}pP>KNwScthnS2Xe}x3B2F=q0> St*8n{ 5NofWK=+Ԥߪ^:?--~9zi4KC5)ኛw{)5Ixb}|t E! H@$  H@$p d$VpՈNrcbhzw _]<]ƾyӿ ˓P;z+" \ql 5>Y^ɀW>赏 P$  H@$  H@7)sWdɫ fdiM. >z{==W$  H@$  H@^Oz%P$  H@$  H(Ư̰5Q5y}|-Tn79upu$* H@$  H@$P td)ȏ  H@$  H@$ >H;H@$  H@$  ljJT$  H@$  H@p*PF1wCy8 .:'b薊;KUCi._f2ܮk!6\c"T%|:ޒ/ _Jkկ|%sdֿgy/ɸU`)>:֫E[o0%ږx/r~9mW?4 %\,Tm$  H hBߢ (|6e F75㩹3e¯d7HýoI9@1> @ UZw-|0o獬CmMc ˮkYJ݂Y"wKcdIfgz4>ßm>Wie޳ J혶z1^,|Xz +ݾdΟXBpBh:p\kz٘Yv4 Nw/\Kō{oefԹxK4~%C3{RP˷ݍ ܙn/7r0W`by~jf`*ȗ─$  H!`Cx4/B 0,-]lN\zֺw7nO4(q3U~şo}q7,巄X1H7j5UnjC6"17%u{DZhI"$(?\ؖl~ ?MAx;yM=U, 5Xb5r){U.Fm(sg5TH9.t],jA?!_T)^ H@B[,"懋)G[@y/ބR#q=3Fqx4&g&&= cDVX|%MƼ0 O4.VL|e{q2ga?k ˾~GEOa$1k *f*ܧw+sj£f;1׳d=g:/ggNQL~[Oa/_Y:~+&:';6()rrY УկVbcl=c%܄{~qag˱d{6y?<*?y/_Wܵ&꧳$g`f%.4scݤ UͰNd?]NWDzTZ)9;\^ Cl;%3?f˯͉<9C'|Kesy~*T&fD:~~WUYcp bؾ-;ϒw+} ίy_\1?e!^a~3RNme{:< _!\?y^\]̩OSLZDfQ+|#N_ҝm1Oz`HڿއI= qVtb}//} ^{,U 1}B ONBG H@DŽJ%<=kФR7zrp4X98@)cqpwI#v`: nB͖ 9= :?ᙠgtVa÷T"`tpuK!|ҁS;Gץ#;G^NwR~F:V+wnH :ubz8 nmI@Wl^??$3s^ 8)?#pI IDATt7<Քӭs0s#lfJ.]NfKGA,͚K]s<OCztXim࡙^5O?}s ^}:iZv;3g[?z3? %s_``>2rz,h9_Q%q_f@ ֟ zԅ{V/QӒ$  H@%#ba9{$ ۣTOsYބU$ƙ#g/oٲa#?#='Ad><ʫګ9sFwZsfXx7iΣFyaLÖHMew2~Ʀ lz;./8V\mGMaeעm_97g*OF2;h.p֚OЮm8MRwLH;?Ȟbw15$N2ۆ)ɦПxݖ+ b0;"OY\SVKd;sX92pV>>+^w;Fx=Nwg418׊LĬp!)ۈ6l')6O{Yqe{Rӛ\{FZyX]mv IFPT#6VxaCscϙK~N8c5?b<)w|şoerr+8~sA^c[_x$KE<O_2/UW#S 8n'h$k`zK@$PURr:Rvǽ58u3E<̛Ɗ᛽c%` rI{!.Ënw'fz})$وK5u}S3WZ6mN&21+6Ο:ɍᕎtݥ~ii603WMxBjbj!s{iK⓽IL|/'Đ`E9/3ĺP=%5No͌ 3Rg,)Ĭ:aL۝O3y_24<eo`W+o?/.ƟW;B٭6m+..4VQcXo*VdĶx, >ba'!&<wE| D8RK.V m%)(mξ?NL.T  ;Rt"tc۹_/;WYpz}ɜ*gq$Rq`v;8ia_)R$/ad瞳8_~52a\pw/K3@m$  HF1kL8g]~SwN}s鑔.ng~qBGvHOByìg3H'[Me2F2,/D>w73~^~xȅK~jZ(cŒHeƳG<{ZZ"ɸS#kVm en+ W ;9~x+n% n^nWu ʣkגa{~p]=B[B47|cY2&H2{W;=tZ2c$$|;?C SH?{eLsV?M>>3k~KO:GCgEwp>9 Fjx)DdD4?. Y i\8S ΎO6lD߆̠+zf-< SV̠S`9/8[..Ӷ?v‚ZVmIgMQD9ע$  H@U>uN1CURdm1;6Bgu6-lM>M0F,gZzwC/2^װwnr+_:WWPȫ~TJ 2{To:{&3u Ӯk"ϗmưARG- =Ny/| <@5i H@$  H@$ B @M6& [BXVv#71=\O(fİadw6dmOY\ZmY:;g?i/9kwOjuzk~Q92ƦD$~$|~N$  H@$  H@Qj3^OFlQɐSi ݚsf…r]xJRZAlE^ -cI! K\AK@$  H@$  d.a 6Ԅ3ʦi`*d`ǰ =Oņ([B47޽E] wDG"r\ȵ2oeQ^X-)5MKSӔ4I[Vfe7B̼$"(2=f@DE5>w9 Ux"k|qϼ5>̐ݐ,n3uYV3$U^r6;ߨ    jמ)f1!E_,.%o/oԹB:/UvZ6(#5S&5nwZ1ә2v8OucɣZ?v_]WL1{F&ւu +L2u,Mj&vw29z$&oIu-_i%CɹS5    py\}GJ(Sqk^^iiQc{5t7hgw*bWa}lޝ& ‚ &|8@|RoOZo(gM^JS"юAi~Rō Ӊ=iMݑ6%|;F՗-zϔxA;@@@@K(N'OZ8$YQ+Njx5WܱN1[.&BkH=gT5(Q#bD    @Inn,k@@@@@OZB4qY|GkrAIo*wM۪ #C@@@ Th܂!   A>yL@@@@I\!   AHA4D@@@8-P}ZrDv1[NR̙j&\%K錶    5]BI1dmobsJN,D}tR(sRHu[-ɫ"掕d\t]]o7W\WnVBQc˚ܒ-[jc͜b,*Ay\!   T@>i Oҫ}i<kGܭWxʛyp>^={>4tqpޞ)@@@@p@WE3#](}{\hlDZ7()ǐ|[ =te9C&%2d5[Hٟc{c1Sq&kyq%,*hedK9mj!%dR6_o Vf,c*m3Asާuo튎E!Cz,=    .+ޤk=RtGX/ );nj!XG QnO5t,2}uk x=4`N5L''@!- Dw(uuz*44T%bE8{ӟ ;|Gֻ?:T;)r=冭@jCZ轈״^;kJk@@@@ ԈOUf33tlr}/]c!X gOGJ*Hީ$:*R9P111h{uG v i-˿ԯߴqw Me0dK۫&+HrvD>5]P   T?.Yʖj{:]ZvJ**5d9hɔSW<^mu|Y>VN<;ƅ~qr_Z x#JVnOu/_eX3Si r& ;zf(א6d.a:5 2@@@@ ԈeHyUO75nwtLһ *sz4d]_1LO<<EڈYf -_۵T!G#wg-3Yj>t[_ dUVyq\    Tkbܭ[2wiђx]szK5Q|^w|o~)YKefW:&zNP^]~MC^~ lST7vnQAgi;nhF&E'*[{VT܈0Z2@;=1/+j>=.[G/}o>w&&)`p,Qyr@@@@Rn{OZ8$YQ+Zܫ"掕uZٚUz"    PX#4    I_&. pXtAE :ORtxSMm B@@@@J*}JGFg    T'+     WX}6 @@@@HU,a@@@@@i=^Gq%?lͪ62ڼn)|@:`sxnsL[rƥ    @Tx-}F Uhh7Ey,P^ӦՑО#>VZnQ+ViTݫZln>dlRgZyZ&Y;wAӳ"    P*v3yʛEB@@@4 mQ B=KPmQeڙfd5[HA IDATٟPQv{L tT $,*Ӑ|[ =te9C&%*Ge    .}($=F=]=zGnjԐ8쯶OխIs4Ѐy:0 HJ]m /N9wyCԫ[z8,D -.{VB@@@p@ Z)fR*s69j߬7tй밾ZE}vҍzDOMީM*yбMtٔm_=nRbP+=>w@@@@\%Pb9Ppnff P`C /uD@@@ .])f R#A%5JX{2Aʇm*9nI)6Hܠݩ_zw0(J^Ը:͉e-bSM]XGfp ]l֞+7"L NhOˊZOM+    @5p(|Z!Ɋ7]9muV=折;Vi}4fkYD@@@@`scO@@@@@OZB4q׈IEo\P5VZnQ+ViTݫZln>%[-_el5    MIA@@@ ThduX[0uiPձ1L;e۽zϚQm'W _Ue    5HRXw=L=QCڪnh~$]6w])Xg[0yrݤ /L@)    GA)P1%N&/Sfy~dX    3T\* K=Y_Naz6rAڤ|ޙQM5uE5cGM ߧQhu~l{3%ڊq    ;ЁY>kdE؜jceGcf]d7sk3gݚpE|>    P`sc5-CA@@@@@ROZB4q׈IXYeQΓTNv    e4FE@@@@lt)'@@@@jI#    KH`    5ABţ8[cfMWYobsNeuKA)k9ڗW5B@@@^)fK_=CMo4ox'u;,Wi$6%TP*wKZJUX}C+=Wٔ[zeZ~)r4nϴRߖyrƏ靘-J>xk\>@@@@&P埀Yֿktw7żeSe )UN}RE_,P2    .AI1CwNdhݤazt=YE犹܀IpQݲܞiZ3f4]@@@@kOZrzȣn&k{pN-zn6S&&≩zgt[*4    P%.])f R#A%5uErih$=6 iW&AndLX:XC5{bGY˛j. Q{+÷7,ž/LP0ayJ۠omR&E@@@|eVFyi=^ $+t攷Y+bXYјYg@@@@͍?hz@@@@@R*}­׈I*{E :ORtxS;GkSY;/eRE@@@@B>y@!    PS>YSD@@@pI1Q@@@@)Oq"   LBţ8Lă֘Y.H6/k[ OXCѾ>BύatGZ*>vhKBUE    %P(;;K'Ѻ˵'Xm,ƭzFY۬iq$)r2ZT:vyM+?9WIh~EWnѷW;"&F+S|JzrZZrG-MH\"   T@J)7ٔgs*1K=i[ q@>p< :(\zxN1I]v5}^5'FuyGSǬ\9zi@@@@rjTRdq|龧c`x5V&7ȜJv؟)[^} snP5/ߠj jyB^ﯰ?*+U@@@@.N)fRZg^)/W J&?se+R)HN*]YYEm $hd~^^<1N}=NKzOkd;_P>    3;F~q6 -n$Y޵tjQyש;վvc])Mߴy4m^"Yj_v}1-/,%@@@@\&p* 䚀ܭ[.nLKI&y4]qL~tsk1#o *%@ǽ)=clGtTm+͋{    @% t)0Hr(v=_z5w&v8tSM]yׁy򈾟Tq#tbgGpq7} 6Lg]kbAXzǗ&. qJX:X˖oT/i#dQNܬSe4-%    .pFpHMWŞ\s:lr4\Ƣ3]5p %Tp1Wx    .]< gQ{k{Cz}o!J1V)$.6    @I_H|GkrѢ'):ܝ6mV,6[)#G?,u      >yFK~     PC>YCF@@@xboGK@@@@*@R>8    p:hߧx-,Ad%?lͺe6/k[ _}y:qսfjV+<"F @@@@)Pc7]g,D}tR(sRHu[-ɫ"掕B\&* e&loj֔_d&ED+B-Qj4O_juX8(GI?~o2NݾKCS   jI? {3=ԃ=wt_jSnMЕ7ʳ> CqSR^Ww5PMyI=@@@@*RһxW]S;[?m٥9vIGu`=<({c1S'ky KkʗItRxDo*w#]^gC9[uƨݯҽ/Lc4|gJȓ,90L]Z4rul{g.4xMn|k䩼w5hD(;;K ;f~~jYݤCm=: +%nX 2   jDRHߣ~tӀGIJ{$hۤxCZ>iW֤ۧR߭7kd9VX趧1gBLD5OtTS4jH_ި섽J^<_=NObP9Y|RB[Q-W#u_E^ƕ8b"ճ?QbY/䨈   1jDRLD}:9I{ܓw*fZ)QS>?US%H֑n= JVj߬7W˷N7ڜnȖu\ٖb;z:Wۤ#>T̩߉5:iHfkBh˨/kceoڸ;=r5O\jS@@@ Ԍ$#7Q[c8&φGFR-%䩫CRxP,E'w^٥=FҾ.82O!)dxtd2lڶx) 1G#C-xOa_SZ/_eX^3Si R]ts    P@I{T[W.Slj~TFR\ K#)|.%HBF|I#zc=3Y' ҴfTs^-L;K&&[1ٰR{NSjde equ63֗=C)Y<',7@@@@p'-f[=Qku{0VT=ƪ.߆tqʽc(/.&Aj!/6{g-y _,? :[ &,saz2Ow_'wkm=QMM2Sw1{x[>uPC@KUAN6=ګI&ϫաrqv9W:?9    pJ1S` *5u) e윯SߧJu3 }PP_XadwB{b^VԂ}Mܠݩ_zw0(J^Ը:͉e-ySM]XGfp ]T\⸗w&/m)hǠd}9eUr-~kRʽѤ?}OKz=G~QsHa&)`p,\/1z.@@@@TЩ>kdEX[/\s:lͺA@@@@.ys%Nc@@@@.@OZB4q׈IybXԠ$E7MJs    @ 8ω @@@@.    @U jqC@@@$.#`    U-P}ZrDv1[xnL ~sZm^Q c 0GNs    J1[zn,~Y~;ۡ`YJñ)iu*HOV5}" nv:WZj:;5;pyH6%^_JxelRgZ|9ަy(@@@@Z)V+o mvhKB C@@@\GR̽zϘ^Z޹-a` ^tPrW34Xvɲ-ghiVO<%DoO]'.++,    A?Eh+(בz::^g`p.-rFa9~xGSǬ9z7JQ}GjUhrĢ     }$*]P~V|[UۓΟT    @eԘ!TB}2`ΌWzOS@OĭӒr    @I1#/C9RK_]y۳[5))#7m^2MHת](E ;AKAZpIDAT@@@@VXrzȣn& z02_us{uSCRhegG.^4g|L-ZGRᚓ\k{ÛtG~ܟ^X/ )q`7D?]Bб[+$2JݼǺj0x5K6F/8be/>   \fw֑jiOw!j߬7%t0ǐ{X_-"[p'X˙-븲-tYK=(yd|UoPzQV3[4G[_s yiAk;wtb(blymWM2{S̻ОLC6-e?m_ݿneTiK?׾t9G'г uѼ jMXtL}KA    pjItǪ0SAӌ•Uղ)d2̉YJI!ÖmګUjV/q詔uZ{Z{ xg^}L8ٞLS8wԷ++5oC6d#efJUTӱt3{9V-Qoq/?sK؅,{蟍<}]p[ZoC@@@.@ IIFF>AZt)z=3Y' ҴfTړ{~M@%=%+iiڼDԾVF)bq Z_/YdLVᫀZV[_ d]hWa MM2Or&fzzb_e<5;;㷑ƍFF-+/ˊ_u@@@@kOZrzȣn&zReV'e'wkm=QMM2S/-8=M$dK?*n+scFN6=ګI&ϫաrqv9wܥEKucϩ-F;y] e1<զY&j}u]3 :[ &,saz2ϹeMr@@@@ t)0Hc(vW٫J4:eξ/"̞/LP0ayJ۠omR9رoT/i#dQNܬS5P5(c KkI )n*i*%;vCë^ФԵ˳擫5|FrUV2矤uMs55+eϗz{R?L1 ]K[iJ31u}{}Kq   TSk,>kdEز0ӫ"掕uZZE@@@@R\RFGP@@@@*Ayti Q|Gk/tctrw޴iB@@@@R mґ    $J%,    @ )V} #C@@@$bKX@@@@+P}ZrD&]Azk֬*qսfjVhF؜*@@@@@WkdPvSd'IUڼlJZn 9RmU*z~JMlW(wr>O;}eS7oi1%+;ߐyv@@@@~WZ)vYfRP`YtRŃUV)yoqa(;a$VCW( J=@@@@/PbTJWFV2ta'#7X[yf3N vYn t(ǐ&ghF'XVr{c1Sœsa5xA;V7#D@@@TUl ok* ]i-u։gW֤ۧ9zhUj)خvQ2D~wL%fB@@@@, 'J hr_&t7w~#/ޮD+)ڟ, #վ*%3     .5e nP/ wnԬeW=[mW4Pt( .XhKա|nd)Cw:_z2rxHi6bmԥECy(WǶ(z2L7ds]\W:Y_ϖct34L4'OSw..ߤ:&>񊚘.WFʋWF|ӻC̚or{PU$;ԣEzGܫi,Ofղ)d2/_Lt2 YǕFt9O?/zQf?J[%oˎxqaȰ%j㥼2G?V^Gs$QWcfY/3~Is    PKb9jOYU7Ya5zTTY)Sd)veV)ѱ]nT;I&7xb3LߦZ$K}Vyج}d PqiJ $[5Uyv -QmW{jJ):%1     ddמiuۡVZZۦwok+OdSqlxu/ɞ/LP0ayJ۠omRcYY|E2m .@nG7Ikud}`Z~I % tf͟vʈ_MR~QbBt&ܜMS @@@@%T+%OZ8$YQ+q|+bXYјY6    \kO^h    @ Tx/D9pXTϞE :ORtxS;lvv~#    PE>YEc@@@@*U퓕Kp@@@@(@R:>Ƅ    P$*    QXu|* @@@@RHU*/@@@@IT    @ T^#   TG>p՚kIENDB`errbot-6.1.1+ds/docs/index.rst000066400000000000000000000107341355337103200162160ustar00rootroot00000000000000Errbot ====== *Errbot is a chatbot, a daemon that connects to your favorite chat service and brings your tools into the conversation.* The goal of the project is to make it easy for you to write your own plugins so you can make it do whatever you want: a deployment, retrieving some information online, trigger a tool via an API, troll a co-worker,... Errbot is being used in a lot of different contexts: chatops (tools for devops), online gaming chatrooms like EVE, video streaming chatrooms like `livecoding.tv `_, home security, etc. Screenshots ----------- .. raw:: html

Simple to build upon -------------------- Extending Errbot and adding your own commands can be done by creating a plugin, which is simply a class derived from :class:`~errbot.botplugin.BotPlugin`. The docstrings will be automatically reused by the :ref:`\!help ` command:: from errbot import BotPlugin, botcmd class HelloWorld(BotPlugin): """Example 'Hello, world!' plugin for Errbot.""" @botcmd def hello(self, msg, args): """Say hello to the world.""" return "Hello, world!" Once you said "!hello" in your chatroom, the bot will answer "Hello, world!". Batteries included ------------------ We aim to give you all the tools you need to build a customized bot safely, without having to worry about basic functionality. As such, Errbot comes with a wealth of features out of the box. .. toctree:: :maxdepth: 2 features Sharing ------- One of the main goals of Errbot is to make it easy to share your plugin with others as well. Errbot features a built-in *repositories command* (`!repos`) which can be used to install, uninstall and update plugins made available by the community. Making your plugin available through this command only requires you to publish it as a publicly available Git repository. You may also discover plugins from the community on our `plugin list`_ that we update from plugins found on github. Community --------- Errbot has a `Google plus community`_, which is the best place to discuss anything related to Errbot as well as promote your own creations ! This is also the place where you will find announcements of new versions and other news related to the project. You can also interact directly with the community online from the "Open Chat" button at the bottom of this page. Don't be shy and feel free to ask any question there, we are more than happy to help you. If you think you hit a bug or the documentation is not clear enough, you can `open an issue`_ or even better, open a pull request. User guide ---------- .. toctree:: :maxdepth: 2 user_guide/setup user_guide/administration user_guide/plugin_development/index user_guide/flow_development/index user_guide/backend_development/index user_guide/storage_development/index user_guide/sentry Getting involved ---------------- .. toctree:: :maxdepth: 3 contributing API documentation ----------------- .. toctree:: :maxdepth: 3 errbot Release history --------------- .. toctree:: :maxdepth: 2 changes License ------- Errbot is free software, available under the GPL-3 license. Please refer to the :download:`full license text ` for more details. .. _`Google plus community`: https://plus.google.com/communities/117050256560830486288 .. _`GitHub page`: http://github.com/errbotio/errbot/ .. _`plugin list`: https://github.com/errbotio/errbot/wiki .. _`open an issue`: https://github.com/errbotio/errbot/issues errbot-6.1.1+ds/docs/modules.rst000066400000000000000000000000671355337103200165550ustar00rootroot00000000000000errbot ====== .. toctree:: :maxdepth: 4 errbot errbot-6.1.1+ds/docs/requirements.txt000066400000000000000000000004201355337103200176300ustar00rootroot00000000000000sphinx>=1.2 # Necessary until https://github.com/hsoft/sphinx-autodoc-annotation/issues/2 # is fixed and included into a new release. https://github.com/zoni/sphinx-autodoc-annotation/archive/issue-2.zip -e . sleekxmpp irc pyfire python-telegram-bot slackclient hypchat errbot-6.1.1+ds/docs/user_guide/000077500000000000000000000000001355337103200165035ustar00rootroot00000000000000errbot-6.1.1+ds/docs/user_guide/administration.rst000066400000000000000000000150641355337103200222700ustar00rootroot00000000000000Administration ============== This document describes how to configure, administer and interact with errbot. Configuration ------------- There is a split between two types of configuration within errbot. On the one hand there is "setup" information, such as the (chat network) backend to use, storage selection and other settings related to how errbot should run. These settings are all configured through the `config.py` configuration file as explained in :ref:`configuration `. The other type of configuration is the "runtime" configuration such as the plugin settings. Plugins can be dynamically configured through chatting with the bot by using the :code:`!plugin config ` command. There are a few other commands which adjust the runtime configuration, such as the :code:`!plugin blacklist ` command to unload and blacklist a specific plugin. You can view a list of all these commands and their help documentation by using the built-in help function. .. _builtin_help_function: The built-in help function ^^^^^^^^^^^^^^^^^^^^^^^^^^ To get a list of all available commands, you can issue:: !help If you just wish to know more about a specific command you can issue:: !help Installing plugins ------------------ Errbot plugins are typically published to and installed from `GitHub `_. We periodically crawl GitHub for errbot plugin repositories and `publish the results `_ for people to browse. You can have your bot display the same list of repos by issuing:: !repos Searching can be done by specifying one or more keywords, for example:: !repos search hello To install a plugin from the list, issue:: !repos install You aren't limited to installing public plugins though. You can install plugins from any git repository you have access to, whether public or private, hosted on GitHub, BitBucket or elsewhere. The `!repos install` command can take any git URI as argument. If you're unhappy with a plugin and no longer want it, you can always uninstall a plugin again with:: !repos uninstall You will probably also want to update your plugins periodically. This can be done with:: !repos update all Dependencies ^^^^^^^^^^^^ Please pay attention when you install a plugin as it may have additional dependencies. If the plugin contains a `requirements.txt` file then Errbot will automatically check the requirements listed within and warn you when you are missing any. Additionally, if you set :code:`AUTOINSTALL_DEPS` to :code:`True` in your **config.py**, Errbot will use pip to install any missing dependencies automatically. If you have installed Errbot in a virtualenv, this will run the equivalent of :code:`pip install -r requirements.txt`. If no virtualenv is detected, the equivalent of :code:`pip install --user -r requirements.txt` is used to ensure the package(s) is/are only installed for the user running Err. Extra plugin directory ^^^^^^^^^^^^^^^^^^^^^^ Plugins installed via the :code:`!repos` command are managed by errbot itself and stored inside the `BOT_DATA_DIR` you set in `config.py`. If you want to manage your plugins manually for any reason then errbot allows you to load additional plugins from a directory you specify. You can do so by specifying the setting `BOT_EXTRA_PLUGIN_DIR` in your `config.py` file. See the :download:`config-template.py` file for more details. .. _disabling_plugins: Disabling plugins ----------------- You have a number of options available to you if you need to disable a plugin for any reason. Plugins can be temporarily disabled by using the :code:`!plugin deactivate ` command, which deactivates the plugin until the bot is restarted (or activated again via :code:`!plugin activate `. If you want to prevent a plugin from being loaded at all during bot startup, the :code:`!plugin blacklist ` command may be used. It's also possible to strip errbot down even further by disabling some of its core plugins which are otherwise activated by default. You may for example want to this if you're building a very specialized bot for a specific purpose. Disabling core plugins can be done by setting the `CORE_PLUGINS` setting in `config.py`. For example, setting `CORE_PLUGINS = ()` would disable all of the core plugins which even removes the plugin and repository management commands described above. .. _access_controls: Restricting access ------------------ Errbot features a number of options to limit and restrict access to commands of your bot. All of these are configured through the `config.py` file as explained in :ref:`configuration `. The first of these is `BOT_ADMINS`, which sets up the administrators for your bot. Some commands are hardcoded to be admin-only so the people listed here will be given access to those commands (the users listed here will also receive warning messages generated by the :func:`~errbot.botplugin.BotPlugin.warn_admins` plugin function). More advanced access controls can be set up using the `ACCESS_CONTROLS` and `ACCESS_CONTROLS_DEFAULT` options which allow you to set up sophisticated rules. The example :download:`config.py ` file contains more information about the format of these options. If you don't like encoding access controls into the config file, a member of the errbot community has also created a `dynamic ACL module `_ which can be administered through chat commands instead. .. note:: Different backends have different formats to identify users. Refer to the backend-specific notes at the end of the :ref:`configuration ` chapter to see which format you should use. Command filters ^^^^^^^^^^^^^^^ If our built-in access controls don't fit your needs, you can always create your own easily using *command filters*. Command filters are functions which are called automatically by errbot whenever a user executes a command. They allow the command to be allowed, blocked or even modified based on logic you implement yourself. In fact, the restrictions enforced by `BOT_ADMINS` and `ACCESS_CONTROLS` above are implemented using a command filter themselves so they can serve as a good :mod:`example ` (be sure to view the module source). You can add command filters to your bot by including them as part of any regular errbot plugin, it will find and register them automatically when your plugin is loaded. Any method in your plugin which is decorated by :func:`~errbot.cmdfilter` will then act as a command filter. errbot-6.1.1+ds/docs/user_guide/backend_development/000077500000000000000000000000001355337103200224745ustar00rootroot00000000000000errbot-6.1.1+ds/docs/user_guide/backend_development/index.rst000066400000000000000000000072221355337103200243400ustar00rootroot00000000000000[Advanced] Backend development ============================== A backend is the glue code to connect Errbot to a chatting service. Starting with Errbot 2.3.0, backends can be developed out of the main repository. This documentation is there to guide you making a new backend for a chatting service but is also interesting to understand more core concepts of Errbot. It is important to understand the core concepts of Errbot before starting to work on a backend. Architecture ------------ Backends are just a specialization of the bot, they are what is instanciated as the bot and are the entry point of the bot. Following this logic a backend must inherit from :class:`~errbot.errBot.ErrBot`. :class:`~errbot.errBot.ErrBot` inherits itself from :class:`~errbot.backends.base.Backend`. This is where you can find what Errbot is expecting from backend. You'll see a series of methods simply throwing the exception :class:`NotImplementedError`. Those are the one you need to implement to fill up the blanks. Identifiers ----------- Every backend have a very specific way of addressing who, where and how to speak to somebody. Lifecycle: identifiers are either created internally by the backend or externally by the plugins from :func:`~errbot.backends.base.Backend.build_identifier`. There are 2 types of identifiers: - a person - a person in a chatroom Identifier for a person ----------------------- It is important to note that for some backends you can infer what a person is from what a person in a chatroom is, but for privacy reason you cannot on some backends ie. you can send a private message to a person in a chatroom but if the person leaves the room you have no way of knowing how to contect her/him personally. Backends must implement a specific Identifier class that matches their way of identifying those. For example Slack has a notion of userid and channeid you can find in the :class:`~errbot.backends.slack.SlackIdentifier` which is completely opaque to ErrBot itself. But you need to implement a mapping from those private parameters to those properties: - person: this needs to be a string that identifies a person. This should be enough info for the backend to contact this person. This should be a *secure* and sure way to identify somebody. - client: this will identify optionally as a string additional information or channel from where this person is sending a message. For example, some backends open a one to one room to chat, or some backends identifies the current peripheral from which the person is sending a message from (mobile, web, ...) Some of those strings are completely unreadable for humans ie. `U00234FBE` for a person. So you need to provide more human readable info: - nick: this would be the short name refering to that person. ie. `gbin` - displayName: (optionally) this would give for example a full name ie. `Guillaume Binet`. This is often found in professional chatting services. Identifier for a person in a chatroom ------------------------------------- This is simply an Identifier with an added property: room. The string representation of room should give a charoom identifier (see below). See for example :class:`~errbot.backends.slack.SlackMUCIdentifier` Chatrooms / MUCRooms -------------------- In order to implement the various MUC related APIs you'll find from :class:`~errbot.backends.base.Backend`, you'll need to implement a Room class. To help guide you, you can inherit from :class:`~errbot.backends.base.MUCRoom` and fill up the blanks from the NotImplementedError. Lifecycle: Those are created either internally by the backend or externally through :func:`~errbot.backends.base.Backend.join_room` from a string identifier. errbot-6.1.1+ds/docs/user_guide/config-template.py000077700000000000000000000000001355337103200275012../../errbot/config-template.pyustar00rootroot00000000000000errbot-6.1.1+ds/docs/user_guide/configuration/000077500000000000000000000000001355337103200213525ustar00rootroot00000000000000errbot-6.1.1+ds/docs/user_guide/configuration/hipchat.rst000066400000000000000000000047121355337103200235300ustar00rootroot00000000000000HipChat backend configuration ============================= This backend lets you connect to the `HipChat `_ messaging service. To select this backend, set `BACKEND = 'Hipchat'`. Extra Dependencies ------------------ You need to install this dependency before using Errbot with Hipchat:: pip install sleekxmpp pyasn1 pyasn1-modules hypchat Account setup ------------- You will first need to create a regular user account for the bot to use. Once you have an account for errbot to use, login at HipChat and go into the account settings for the user. You will need to create an API token under **API access**. Make sure it has all available scopes otherwise some functionality will be unavailable, which may prevent the bot from working correctly at all. With the API token created, continue on to **XMPP/Jabber info**. You will be needing the `Jabber ID` which is listed here. You can now configure the account by setting up `BOT_IDENTITY` as follows:: BOT_IDENTITY = { 'username' : '12345_123456@chat.hipchat.com', 'password' : 'changeme', # Group admins can create/view tokens on the settings page after logging # in on HipChat's website 'token' : 'ed4b74d62833267d98aa99f312ff04', # If you're using HipChat server (self-hosted HipChat) then you should set # the endpoint below. If you don't use HipChat server but use the hosted version # of HipChat then you may leave this commented out. # 'endpoint' : 'https://api.hipchat.com', # If your self-hosted Hipchat server is using SSL, and your certificate # is self-signed, set verify to False or hypchat will fail # 'verify': False, Bot admins ---------- You can set `BOT_ADMINS` to configure which Hipchat users are bot administrators. Make sure to include the `@` sign. For example: `BOT_ADMINS = ('@gbin', '@zoni')` Rooms ----- You can let the bot join rooms (that it has access to) by setting up `CHATROOM_PRESENCE`. For example: `CHATROOM_PRESENCE = ('General', 'Another room')` You must also set the correct value for `CHATROOM_FN`. This **must** be set to the value of `Room nickname` which can be found in the HipChat account settings under **XMPP/Jabber info**. @mentions --------- To make the bot respond when it is mentioned (such as with *"@errbot status"*) we recommend also setting `BOT_ALT_PREFIXES = ('@errbot',)` (assuming `errbot` is the username of the account you're using for the bot). errbot-6.1.1+ds/docs/user_guide/configuration/irc.rst000066400000000000000000000053411355337103200226640ustar00rootroot00000000000000IRC backend configuration ========================= This backend lets you connect to any IRC server. To select this backend, set `BACKEND = 'IRC'`. Extra Dependencies ------------------ You need to install this dependency before using Errbot with IRC:: pip install irc Account setup ------------- Configure the account by setting up `BOT_IDENTITY` as follows:: BOT_IDENTITY = { 'nickname' : 'err-chatbot', # 'username' : 'err-chatbot', # optional, defaults to nickname if omitted # 'password' : None, # optional 'server' : 'irc.freenode.net', # 'port': 6667, # optional # 'ssl': False, # optional # 'ipv6': False, # optional # 'nickserv_password': None, # optional ## Optional: Specify an IP address or hostname (vhost), and a ## port, to use when making the connection. Leave port at 0 ## if you have no source port preference. ## example: 'bind_address': ('my-errbot.io', 0) # 'bind_address': ('localhost', 0), } You will at a minimum need to set the correct values for `nickname` and `server` above. The rest of the options can be left commented, but you may wish to set some of them. Bot admins ---------- You can set `BOT_ADMINS` to configure which IRC users are bot administrators. For example: `BOT_ADMINS = ('gbin!gbin@*', '*!*@trusted.host.com')` .. note:: The default syntax for users on IRC is `{nick}!{user}@{host}` but this can be changed by adjusting the `IRC_ACL_PATTERN` setting. Channels -------- If you want the bot to join a certain channel when it starts up then set `CHATROOM_PRESENCE` with a list of channels to join. For example: `CHATROOM_PRESENCE = ('#errbotio',)` .. note:: You may leave the value for `CHATROOM_FN` at its default as it is ignored by this backend. Flood protection ---------------- Many IRC servers have flood protection enabled, which means the bot will get kicked out of a channel when sending too many messages in too short a time. Errbot has a built-in message ratelimiter to avoid this situation. You can enable it by setting `IRC_CHANNEL_RATE` and `IRC_PRIVATE_RATE` to ratelimit channel and private messages, respectively. The value for these options is a (floating-point) number of seconds to wait between each message it sends. Rejoin on kick/disconnect ------------------------- Errbot won't rejoin a channel by default when getting kicked out of one. If you want the bot to rejoin channels on kick, you can set `IRC_RECONNECT_ON_KICK = 5` (to join again after waiting 5 seconds). Similarly, to rejoin channels after being disconnected from the server you may set `IRC_RECONNECT_ON_DISCONNECT = 5`. errbot-6.1.1+ds/docs/user_guide/configuration/slack.rst000066400000000000000000000040231355337103200232000ustar00rootroot00000000000000Slack backend configuration =========================== This backend lets you connect to the `Slack `_ messaging service. To select this backend, set `BACKEND = 'Slack'`. Extra Dependencies ------------------ You need to install this dependency before using Errbot with Slack:: pip install slackclient Account setup ------------- You will need to have an account at Slack for the bot to use, either a bot account (recommended) or a regular user account. We will assume you're using a bot account for errbot, which `may be created here `_. Make note of the **API Token** you receive as you will need it next. With the bot account created on Slack, you may configure the account in errbot by setting up `BOT_IDENTITY` as follows:: BOT_IDENTITY = { 'token': 'xoxb-4426949411-aEM7...', } Proxy setup ------------- In case you need to use a Proxy to connect to Slack, you can set the proxies with the token config. BOT_IDENTITY = { 'token': 'xoxb-4426949411-aEM7...', 'proxies': {'http': 'some-http-proxy', 'https': 'some-https-proxy'} } Bot admins ---------- You can set `BOT_ADMINS` to configure which Slack users are bot administrators. Make sure to include the `@` sign:: BOT_ADMINS = ('@gbin', '@zoni') Bot mentions using @ -------------------- To enable using the bot's name in `BOT_ALT_PREFIXES` for @mentions in Slack, simply add the bot's name as follows:: BOT_ALT_PREFIXES = ('@botname',) Channels/groups --------------- If you're using a bot account you should set `CHATROOM_PRESENCE = ()`. Bot accounts on Slack are not allowed to join/leave channels on their own (they must be invited by a user instead) so having any rooms setup in `CHATROOM_PRESENCE` will result in an error. If you are using a regular user account for the bot then you can set `CHATROOM_PRESENCE` to a list of channels and groups to join. .. note:: You may leave the value for `CHATROOM_FN` at its default as it is ignored by this backend. errbot-6.1.1+ds/docs/user_guide/configuration/telegram.rst000066400000000000000000000044631355337103200237130ustar00rootroot00000000000000Telegram backend configuration ============================== This backend lets you connect to `Telegram Messenger `_. To select this backend, set `BACKEND = 'Telegram'`. Extra Dependencies ------------------ You need to install this dependency before using Errbot with Telegram:: pip install python-telegram-bot Account setup ------------- You will first need to create a bot account on Telegram for errbot to use. You can do this by talking to `@BotFather `_ (see also: `BotFather `_). Make sure you take note of the token you receive, you'll need it later. Once you have created a bot account on Telegram you may configure the account in errbot by setting up `BOT_IDENTITY` as follows:: BOT_IDENTITY = { 'token': '103419016:AAbcd1234...', } Bot admins ---------- You can setup `BOT_ADMINS` to designate which users are bot admins, but on Telegram this is a little more difficult to do. In order to configure a user here you will have to obtain their user ID. The easiest way to do this is to start the bot with no `BOT_ADMINS` defined. Then, have the user for which you want to obtain the user ID message the bot and send it the `!whoami` command. This will print some info about the user, including the following: `string representation is '123669037'`. It is this number that needs to be filled in for `BOT_ADMINS`. For example: `BOT_ADMINS = (123669037,)` Rooms ----- Telegram does not expose any room management to bots. As a group admin, you will have to add a bot to a groupchat at which point it will automatically join. By default the bot will not receive any messages which makes interacting with it in a groupchat difficult. To give the bot access to all messages in a groupchat, you can use the `/setprivacy` command when talking to `@BotFather `_. .. note:: Because Telegram does not support room management, you must set `CHATROOM_PRESENCE = ()` otherwise you will see errors. Slash commands -------------- Telegram treats messages which `start with a / `_ differently, which is designed specifically for interacting with bots. We therefor suggest setting `BOT_PREFIX = '/'` to take advantage of this. errbot-6.1.1+ds/docs/user_guide/configuration/xmpp.rst000066400000000000000000000034071355337103200230740ustar00rootroot00000000000000XMPP backend configuration ========================== This backend lets you connect to any Jabber/XMPP server. To select this backend, set `BACKEND = 'XMPP'`. Extra Dependencies ------------------ You need to install this dependency before using Errbot with XMPP:: pip install sleekxmpp pyasn1 pyasn1-modules Account setup ------------- You must manually register an XMPP account for the bot on the server you wish to use. Errbot does not support XMPP registration itself. Configure the account by setting up `BOT_IDENTITY` as follows:: BOT_IDENTITY = { 'username': 'err@server.tld', # The JID of the user you have created for the bot 'password': 'changeme', # The corresponding password for this user # 'server': ('host.domain.tld',5222), # server override } By default errbot will query SRV records for the correct XMPP server and port, which should work with a properly configured server. If your chosen XMPP server does not have correct SRV records setup, you can also set the `server` key to override this. A random resource ID is assigned when errbot starts up. You may fix the resource by appending it to the user name:: BOT_IDENTITY = { 'username': 'err@server.tld/resource', ... Bot admins ---------- You can set `BOT_ADMINS` to configure which XMPP users are bot administrators. For example: `BOT_ADMINS = ('gbin@someplace.com', 'zoni@somewhere.else.com')` MUC rooms --------- If you want the bot to join a certain chatroom when it starts up then set `CHATROOM_PRESENCE` with a list of MUCs to join. For example: `CHATROOM_PRESENCE = ('err@conference.server.tld',)` *Note: don't omit the comma under any circumstance!* You can configure the username errbot should use in chatrooms by setting `CHATROOM_FN`. errbot-6.1.1+ds/docs/user_guide/flow_development/000077500000000000000000000000001355337103200220545ustar00rootroot00000000000000errbot-6.1.1+ds/docs/user_guide/flow_development/advanced.rst000066400000000000000000000067331355337103200243640ustar00rootroot00000000000000Advanced Flow Definitions ========================= Storing something in the flow context ------------------------------------- Flows have a state the plugins can use to store some contextual information. Let's take back out simple linear flow: .. code-block:: python @botflow def example(self, flow: FlowRoot): first_step = flow.connect('first') second_step = first_step.connect('second') third_step = second_step.connect('third') You can represent this flow like this: .. figure:: basics_1.svg :align: center You can store something in the context, for example in ``!first`` and retrieve it in ``!second``. Like this: .. code-block:: python @botcmd def first(self, msg, args): msg.ctx['mydata'] = 'Hello' return 'First done!' @botcmd def second(self, msg, args): return msg.ctx['mydata'] + ' World!' ``msg.ctx`` is a dictionary created every time a flow starts. Making a step execute automatically ----------------------------------- In our previous example, if ``msg.ctx['mydata']`` is populated, we can arguably expect that Errbot should not wait for the user to enter ``!second`` to execute it and just proceed by itself. You can do that by defining a **predicate**, which is a simple function that returns ``True`` if the conditions to proceed to the next step are met. The function takes only one parameter, the context, the same one you get from ``msg.ctx``. .. code-block:: python @botflow def example(self, flow: FlowRoot): first_step = flow.connect('first') second_step = first_step.connect('second', predicate=lambda ctx: 'mydata' in ctx) third_step = second_step.connect('third') Now, after starting the flow with ``!flows start example``, the state will be: .. figure:: basics_3.svg :align: center Errbot will wait for ``!first``. But then, once the user executes ``!first``, it will see that the predicate between ``!first`` and ``!second`` is verified, so will go on and execute ``!second``, displaying 'Hello World' and proceed to wait for ``!third``: .. figure:: basics_4.svg :align: center Branching in the graph ---------------------- It is perfectly possible to branch out to several possibilities (possibly with different predicates). .. code-block:: python @botflow def example(self, flow: FlowRoot): first_step = flow.connect('first') second_step = first_step.connect('second', predicate=lambda ctx: 'mydata' in ctx) other = first_step.connect('other_second', predicate= lambda ctx: 'otherdata' in ctx) This will do something like that: .. figure:: advanced_1.svg :align: center In manual mode, the bot will tell the user about his 2 possible options to continue. Making a looping graph ---------------------- You can also perfectly reexecute a part of a graph in a "loop". You can branch directly the node object instead of the command name in that case. .. code-block:: python @botflow def example(self, flow: FlowRoot): first_step = flow.connect('first') second_step = first_step.connect('second') third_step = second_step.connect(first_step, predicate=...) final_step = third_step.connect('final', predicate=...) You can represent this flow like this: .. figure:: advanced_2.svg :align: center The typical use case is to repeatedly ask something to the user. errbot-6.1.1+ds/docs/user_guide/flow_development/advanced_1.svg000066400000000000000000000435731355337103200245760ustar00rootroot00000000000000 errbot-6.1.1+ds/docs/user_guide/flow_development/advanced_2.svg000066400000000000000000000567231355337103200246000ustar00rootroot00000000000000 errbot-6.1.1+ds/docs/user_guide/flow_development/basics.rst000066400000000000000000000071631355337103200240610ustar00rootroot00000000000000Basic Flow Definition ===================== Flows are like plugins ---------------------- They are defined by a ``.flow`` file, similar to the plugin ones: .. code-block:: ini [Core] Name = MyFlows. Module = myflows.py [Documentation] Description = my documentation. [Python] Version = 2+ Now in the ``myflows.py`` file you will have pretty familiar structure with a ``BotFlow`` as type and @botflow as flow decorator: .. code-block:: python from errbot import botflow, FlowRoot, BotFlow class MyFlows(BotFlow): """ Conversation flows for Errbot""" @botflow def example(self, flow: FlowRoot): """ Docs for the flow example comes here """ # [...] Errbot will pass the root of the flow as the only parameter to your flow definition so you can build your graph from there. Making a simple graph --------------------- Within your flow, you can connect commands together. For example, to make a simple linear flow between ``!first``, ``!second`` and ``!third``: .. code-block:: python @botflow def example(self, flow: FlowRoot): first_step = flow.connect('first') # first is a command name from any loaded plugin. second_step = first_step.connect('second') third_step = second_step.connect('third') You can represent this flow like this: .. figure:: basics_1.svg :align: center O is the state "not started" for the flow ``example``. You can start this flow manually by doing ``!flows start example``. The bot will tell you that it expects a ``!first`` command: .. figure:: basics_3.svg :align: center Once you have executed ``!first``, you will be in that state: .. figure:: basics_2.svg :align: center The bot will tell you that it expects ``!second``, etc. .. figure:: basics_4.svg :align: center Making a flow start automatically --------------------------------- Now, usually flows are linked to a first action your users want to do. For example: ``!poll new``, ``!vm create``, ``!report init`` or first commands like that that suggests that you will have a follow-up. To trigger a flow automatically on those first commands, you can use ``auto_trigger``. .. code-block:: python @botflow def example(self, flow: FlowRoot): first_step = flow.connect('first', auto_trigger=True) second_step = first_step.connect('second') third_step = second_step.connect('third') You can still represent this flow like this: .. figure:: basics_1.svg :align: center BUT, when a user will execute a ``!first`` command, the bot will instantly instantiate a Flow in this state: .. figure:: basics_2.svg :align: center And tell the user that ``!second`` is the follow-up. Flow ending ----------- If a node has no more children and a user passed it, it will automatically end the flow. Sometimes, with loops etc., you might want to explicitly mark an END FlowNode with a predicate. You can do it like this, for example for a guessing game plugin: .. figure:: end.svg :align: center In the flow code... .. code-block:: python from errbot import botflow, FlowRoot, BotFlow, FLOW_END class GuessFlows(BotFlow): """ Conversation flows related to polls""" @botflow def guess(self, flow: FlowRoot): """ This is a flow that can set a guessing game.""" # setup Flow game_created = flow.connect('tryme', auto_trigger=True) one_guess = game_created.connect('guessing') one_guess.connect(one_guess) # loop on itself one_guess.connect(FLOW_END, predicate=lambda ctx: ctx['ended']) errbot-6.1.1+ds/docs/user_guide/flow_development/basics_1.svg000066400000000000000000000436011355337103200242650ustar00rootroot00000000000000 errbot-6.1.1+ds/docs/user_guide/flow_development/basics_2.svg000066400000000000000000000436011355337103200242660ustar00rootroot00000000000000 errbot-6.1.1+ds/docs/user_guide/flow_development/basics_3.svg000066400000000000000000000436011355337103200242670ustar00rootroot00000000000000 errbot-6.1.1+ds/docs/user_guide/flow_development/basics_4.svg000066400000000000000000000436011355337103200242700ustar00rootroot00000000000000 errbot-6.1.1+ds/docs/user_guide/flow_development/concept.svg000066400000000000000000001250051355337103200242330ustar00rootroot00000000000000 errbot-6.1.1+ds/docs/user_guide/flow_development/concepts.rst000066400000000000000000000030561355337103200244300ustar00rootroot00000000000000Flows Concepts ============== Static structure ---------------- Flows are represented as graphs. Those graphs have a root (FlowRoot), which is basically their entry point, and are composed of nodes (FlowNodes). Every node represents an Errbot command. .. figure:: concept.svg :align: center Example of a flow construction. This defines a simple flow where for example this sequence of commands is possible:: !command1 !command2 !command3 !command1 !command2 !command3 !last_command On the connections of those nodes (⟶), you can attach **predicates**, predicates are simple conditions to allow the flow to continue without any user intervention. Execution --------- At execution time, Errbot will keep track of who started the flow, and at what step (node) it is currently. On top of that, Errbot will initialize a context for the entire conversation. The context is a simple Python dictionary and it is attached to only one conversation. Think of this like the persistence for plugins, but linked to a conversation only. If you don't specify any predicate when you build your flow, every single step is "manual". It means that Errbot will wait for the user to execute one of the possible commands at every step to advance along the graph. Predicates can be used to trigger a command automatically. Predicates are simple functions saying to Errbot, "this command has enough in the context to be able to execute without any user intervention". At any time if a predicate is verified after a step is executed, Errbot will proceed and execute the next step. errbot-6.1.1+ds/docs/user_guide/flow_development/end.svg000066400000000000000000000434171355337103200233540ustar00rootroot00000000000000 errbot-6.1.1+ds/docs/user_guide/flow_development/index.rst000066400000000000000000000020241355337103200237130ustar00rootroot00000000000000Flow development ================ Flows are a feature in Errbot to enable plugin designers to chain several plugin commands together into a "conversation". For example, imagine interacting with a bot that needs more that one command, like setting up a poll in a chatroom:: User: !poll new Where do we go for lunch ? Bot: Flow poll_setup started, you can continue with: !poll newoption User: !poll newoption Greek Bot: Option added, current options: - Greek Bot: You can continue with: !poll newoption !poll start User: !poll newoption French Bot: Option added, current options: - Greek - French Bot: You can continue with: !poll newoption !poll start User: !poll start [...] In this guide we will explain the underlying concepts and basics of writing flows. Prerequesite: you need to be familiar with the normal errbot plugin development. .. toctree:: :maxdepth: 2 :numbered: concepts basics advanced errbot-6.1.1+ds/docs/user_guide/plugin_development/000077500000000000000000000000001355337103200224035ustar00rootroot00000000000000errbot-6.1.1+ds/docs/user_guide/plugin_development/backend_specifics.rst000066400000000000000000000115061355337103200265570ustar00rootroot00000000000000Backend-specifics ================= Errbot uses external libraries for most backends, which may offer additional functionality not exposed by Errbot in a generic, backend-agnostic fashion. It is possible to access the underlying client used by the backend you are using in order to provide functionality that isn't otherwise available. Additionally, interacting directly with the bot internals gives you the freedom to control Errbot in highly specific ways that may not be officially supported. .. warning:: The following instructions describe how to interface directly with the underlying bot object and clients of backends. We offer no guarantees that these internal APIs are stable or that a given backend will continue to use a given client in the future. The following information is provided **as-is** without any official support. We can give **no** guarantees about API stability on the topics described below. Getting to the bot object ------------------------- From within a plugin, you may access `self._bot` in order to get to the instance of the currently running bot class. For example, with the Telegram backend this would be an instance of :class:`~errbot.backends.telegram.TelegramBackend`: .. code-block:: python >>> type(self._bot) To find out what methods each bot backend has, you can take a look at the documentation of the various backends in the :mod:`errbot.backends` package. Plugins may use the `self._bot` object to offer tailored, backend-specific functionality on specific backends. To determine which backend is being used, a plugin can inspect the `self._bot.mode` property. The following table lists all the values for `mode` for the official backends: ============================================ ========== Backend Mode value ============================================ ========== :class:`~errbot.backends.hipchat` hipchat :class:`~errbot.backends.irc` irc :class:`~errbot.backends.slack` slack :class:`~errbot.backends.telegram_messenger` telegram :class:`~errbot.backends.test` test :class:`~errbot.backends.text` text :class:`~errbot.backends.xmpp` xmpp ============================================ ========== Here's an example of using a backend-specific feature. In Slack, emoji reactions can be added to messages the bot receives using the `add_reaction` and `remove_reaction` methods. For example, you could add an hourglass to messages that will take a long time to reply fully to. .. code-block:: python from errbot import BotPlugin, botcmd class PluginExample(BotPlugin): @botcmd def longcompute(self, mess, args): if self._bot.mode == "slack": self._bot.add_reaction(mess, "hourglass") else: yield "Finding the answer..." time.sleep(10) yield "The answer is: 42" if self._bot.mode == "slack": self._bot.remove_reaction(mess, "hourglass") Getting to the underlying client library ---------------------------------------- Most of the backends use a third-party library in order to connect to their respective network. These libraries often support additional features which Errbot doesn't expose in a generic way so you may wish to make use of these in order to access advanced functionality. Backends set their own attribute(s) to point to the underlying libraries' client instance(s). The following table lists these attributes for the official backends, along with the library used by the backend: ============================================ ========================= ================================================ Backend Library Attribute(s) ============================================ ========================= ================================================ :class:`~errbot.backends.hipchat` `sleekxmpp`_ + `hypchat`_ ``self._bot.conn`` ``self._bot.conn.hypchat`` :class:`~errbot.backends.irc` `irc`_ ``self._bot.conn`` ``self._bot.conn.connection`` :class:`~errbot.backends.slack` `slackclient`_ ``self._bot.sc`` :class:`~errbot.backends.telegram_messenger` `telegram-python-bot`_ ``self._bot.telegram`` :class:`~errbot.backends.xmpp` `sleekxmpp`_ ``self._bot.conn`` ============================================ ========================= ================================================ .. _hypchat: https://pypi.python.org/pypi/hypchat/ .. _irc: https://pypi.python.org/pypi/irc/ .. _`telegram-python-bot`: https://pypi.python.org/pypi/python-telegram-bot .. _slackclient: https://pypi.python.org/pypi/slackclient/ .. _sleekxmpp: https://pypi.python.org/pypi/sleekxmpp errbot-6.1.1+ds/docs/user_guide/plugin_development/basics.rst000066400000000000000000000153731355337103200244120ustar00rootroot00000000000000Hello, world! ============= On the :doc:`homepage `, we showed you the following *"Hello world!"* plugin as an example: .. code-block:: python from errbot import BotPlugin, botcmd class HelloWorld(BotPlugin): """Example 'Hello, world!' plugin for Errbot""" @botcmd def hello(self, msg, args): """Say hello to the world""" return "Hello, world!" In this chapter, you will learn exactly how this plugin works. I will assume you've configured the `BOT_EXTRA_PLUGIN_DIR` as described in the previous chapter. To get started, create a new, empty directory named `HelloWorld` inside this directory. Create a new file called `helloworld.py` inside the `HelloWorld` directory you just created. This file contains all the logic for your plugin, so copy and paste the above example code into it. Anatomy of a BotPlugin ---------------------- Although this plugin is only 9 lines long, there is already a lot of interesting stuff going on here. Lets go through it step by step. .. code-block:: python from errbot import BotPlugin, botcmd This should be pretty self-explanatory. Here we import the :class:`~errbot.botplugin.BotPlugin` class and the :func:`~errbot.decorators.botcmd` decorator. These let us build a class that can be loaded as a plugin and allow us to mark methods of that class as bot commands. .. code-block:: python class HelloWorld(BotPlugin): """Example 'Hello, world!' plugin for Errbot""" Here we define the class that makes up our plugin. The name of your class, `HelloWorld` in this case, is what will make up the name of your plugin. This name will be used in commands such as `!status`, `!plugin load` and `!plugin unload` The class' docstring is used to automatically populate the built-in command documentation. It will be visible when issuing the `!help` command. .. warning:: A plugin should only ever contain a single class inheriting from :class:`~errbot.botplugin.BotPlugin` .. code-block:: python @botcmd def hello(self, msg, args): """Say hello to the world""" return "Hello, world!" This method, `hello`, is turned into a bot command which can be executed because it is decorated with the :func:`~errbot.decorators.botcmd` decorator. Just as with the class docstring above, the docstring here is used to populate the `!help` command. The name of the method, `hello` in this case, will be used as the name of the command. That means this method creates the `!hello` command. .. note:: The method name must comply with the usual Python naming conventions for `identifiers `_ , that is, they may not begin with a digit (like ``911`` but only with a letter or underscore, so ``_911`` would work) and cannot be any of the `reserved keywords `_ such as ``pass`` (instead use ``password``) etc. .. note:: Should multiple plugins define the same command, they will be dynamically renamed (by prefixing them with the plugin name) so that they no longer clash with each other. If we look at the function definition, we see it takes two parameters, `msg` and `args`. The first is a :class:`~errbot.backends.base.Message` object, which represents the full message object received by Errbot. The second is a string (or a list, if using the `split_args_with` parameter of :func:`~errbot.decorators.botcmd`) with the arguments passed to the command. For example, if a user were to say `!hello Mister Errbot`, `args` would be the string `"Mister Errbot"`. Finally, you can see we return with the string `Hello, world!`. This defines the response that Errbot should give. In this case, it makes all executions of the `!hello` command return the message *Hello, world!*. .. note:: If you return `None`, Errbot will not respond with any kind of message when executing the command. Plugin metadata --------------- We have our plugin itself ready, but if you start the bot now, you'll see it still won't load your plugin. What gives? As it turns out, you need to supply a file with some meta-data alongside your actual plugin file. This is a file that ends with the extension `.plug` and it is used by Errbot to identify and load plugins. Lets go ahead and create ours. Place the following in a file called `helloworld.plug`: .. code-block:: ini [Core] Name = HelloWorld Module = helloworld [Python] Version = 2+ [Documentation] Description = Example "Hello, world!" plugin .. note:: This INI-style file is parsed using the Python `configparser `_ class. Make sure to use a `valid `_ file structure. Lets look at what this does. We see two sections, `[Core]` , and `[Documentation]`. The `[Core]` section is what tells Errbot where it can actually find the code for this plugin. The key `Module` should point to a module that Python can find and import. Typically, this is the name of the file you placed your code in with the `.py` suffix removed. The key `Name` should be identical to the name you gave to the class in your plugin file, which in our case was `HelloWorld`. While these names can differ, doing so is not recommended. .. note:: If you're wondering why you have to specify it when it should be the same as the class name anyway, this has to do with technical limitations that we won't go into here. The `[Documentation]` section will be explained in more detail further on in this guide, but you should make sure to at least have the `Description` item here with a short description of your plugin. Wrapping up ----------- If you've followed along so far, you should now have a working *Hello, world!* plugin for Errbot. If you start your bot, it should load your plugin automatically. You can verify this by giving the `!status` command, which should respond with something like the following:: Yes I am alive... With these plugins (A=Activated, D=Deactivated, B=Blacklisted, C=Needs to be configured): [A] ChatRoom [A] HelloWorld [A] VersionChecker [A] Webserver If you don't see your plugin listed or it shows up as unloaded, make sure to start your bot with *DEBUG*-level logging enabled and pay close attention to what it reports. You will most likely see an error being reported somewhere along the way while Errbot starts up. Next steps ---------- You now know enough to create very simple plugins, but we have barely scratched the surface of what Errbot can do. The rest of this guide will be a recipe-style set of topics that cover all the advanced features Errbot has to offer. errbot-6.1.1+ds/docs/user_guide/plugin_development/botcommands.rst000066400000000000000000000123011355337103200254400ustar00rootroot00000000000000Advanced bot commands ===================== Automatic argument splitting ---------------------------- With the `split_args_with` argument to :func:`~errbot.decorators.botcmd`, you can specify a delimiter of the arguments and it will give you an array of strings instead of a string: .. code-block:: python @botcmd(split_args_with=None) def action(self, mess, args): # if you send it !action one two three # args will be ['one', 'two', 'three'] .. note:: `split_args_with` behaves exactly like :func:`str.split`, therefore the value `None` can be used to split on any type of whitespace, such as multiple spaces, tabs, etc. This is recommended over `' '` for general use cases but you're free to use whatever argument you see fit. Subcommands ----------- If you put an _ in the name of the function, Errbot will create what looks like a subcommand for you. This is useful to group commands that belong to each other together. .. code-block:: python @botcmd def basket_add(self, mess, args): # Will respond to !basket add pass @botcmd def basket_remove(self, mess, args): # Will respond to !basket remove pass .. note:: It will still respond to !basket_add and !basket_remove as well. Argparse argument splitting ---------------------------- With the :func:`~errbot.decorators.arg_botcmd` decorator you can specify a command's arguments in `argparse format`_. The decorator can be used multiple times, and each use adds a new argument to the command. The decorator can be passed any valid `add_arguments()`_ parameters. .. _`argparse format`: https://docs.python.org/3/library/argparse.html .. _`add_arguments()`: https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument .. code-block:: python @arg_botcmd('first_name', type=str) @arg_botcmd('--last-name', dest='last_name', type=str) @arg_botcmd('--favorite', dest='favorite_number', type=int, default=42) def hello(self, mess, first_name=None, last_name=None, favorite_number=None): # if you send it !hello Err --last-name Bot # first_name will be 'Err' # last_name will be 'Bot' # favorite_number will be 42 .. note:: * An argument's `dest` parameter is used as its kwargs key when your command is called. * `favorite_number` would be `None` if we removed `default=42` from the :func:`~errbot.decorators.arg_botcmd` call. Commands using regular expressions ---------------------------------- In addition to the fixed commands created with the :func:`~errbot.decorators.botcmd` decorator, Errbot supports an alternative type of bot function which can be triggered based on a regular expression. These are created using the :func:`~errbot.decorators.re_botcmd` decorator. There are two forms these can be used, with and without the usual bot prefix. In both cases, your method will receive the message object same as with a regular :func:`~errbot.decorators.botcmd`, but instead of an `args` parameter, it takes a `match` parameter which will receive an :class:`re.MatchObject`. .. note:: By default, only the first occurrence of a match is returned, even if it can match multiple parts of the message. If you specify `matchall=True`, you will instead get a list of :class:`re.MatchObject` items, containing all the non-overlapping matches that were found in the message. With a bot prefix ~~~~~~~~~~~~~~~~~ You can define commands that trigger based on a regular expression, but still require a bot prefix at the beginning of the line, in order to create more flexible bot commands. Here's an example of a bot command that lets people ask for cookies: .. code-block:: python from errbot import BotPlugin, re_botcmd class CookieBot(BotPlugin): """A cookiemonster bot""" @re_botcmd(pattern=r"^(([Cc]an|[Mm]ay) I have a )?cookie please\?$") def hand_out_cookies(self, msg, match): """ Gives cookies to people who ask me nicely. This command works especially nice if you have the following in your `config.py`: BOT_ALT_PREFIXES = ('Err',) BOT_ALT_PREFIX_SEPARATORS = (':', ',', ';') People are then able to say one of the following: Err, can I have a cookie please? Err: May I have a cookie please? Err; cookie please? """ yield "Here's a cookie for you, {}".format(msg.frm) yield "/me hands out a cookie." Without a bot prefix ~~~~~~~~~~~~~~~~~~~~ It's also possible to trigger commands even when no bot prefix is specified, by passing `prefixed=False` to the :func:`~errbot.decorators.re_botcmd` decorator. This is especially useful if you want to trigger on specific keywords that could show up anywhere in a conversation: .. code-block:: python import re from errbot import BotPlugin, re_botcmd class CookieBot(BotPlugin): """A cookiemonster bot""" @re_botcmd(pattern=r"(^| )cookies?( |$)", prefixed=False, flags=re.IGNORECASE) def listen_for_talk_of_cookies(self, msg, match): """Talk of cookies gives Errbot a craving...""" return "Somebody mentioned cookies? Om nom nom!" errbot-6.1.1+ds/docs/user_guide/plugin_development/configuration.rst000066400000000000000000000077561355337103200260230ustar00rootroot00000000000000Configuration ============= Plugin configuration through the built-in `!config` command ----------------------------------------------------------- Errbot can keep a simple python object for the configuration of your plugin. This avoids the need for admins to configure settings in some kind of configuration file, instead allowing configuration to happen directly through chat commands. In order to enable this feature, you need to provide a configuration template (ie. a config example) by overriding the :meth:`~errbot.botplugin.BotPlugin.get_configuration_template` method. For example, a plugin might request a dictionary with 2 entries: .. code-block:: python from errbot import BotPlugin class PluginExample(BotPlugin): def get_configuration_template(self): return {'ID_TOKEN': '00112233445566778899aabbccddeeff', 'USERNAME':'changeme'} With this in place, an admin will be able to request the default configuration template with `!plugin config PluginExample`. He or she could then give the command `!plugin config PluginExample {'ID_TOKEN' : '00112233445566778899aabbccddeeff', 'USERNAME':'changeme'}` to enable that configuration. It will also be possible to recall the configuration template, as well as the config that is actually set, by issuing `!plugin config PluginExample` again. Within your code, the config that is set will be in `self.config`: .. code-block:: python @botcmd def mycommand(self, mess, args): # oh I need my TOKEN ! token = self.config['ID_TOKEN'] .. warning:: If there is no configuration set yet, `self.config` will be `None`. Supplying partial configuration ------------------------------- Sometimes you want to allow users to only supply a part of the configuration they wish to override from the template instead of having to copy-paste and modify the complete entry. This can be achieved by overriding :meth:`~errbot.botplugin.BotPlugin.configure`: .. code-block:: python from itertools import chain CONFIG_TEMPLATE = {'ID_TOKEN': '00112233445566778899aabbccddeeff', 'USERNAME':'changeme'} def configure(self, configuration): if configuration is not None and configuration != {}: config = dict(chain(CONFIG_TEMPLATE.items(), configuration.items())) else: config = CONFIG_TEMPLATE super(PluginExample, self).configure(config) What this achieves is that it creates a Python dictionary object which contains all the values from our `CONFIG_TEMPLATE` and then updates that dictionary with the configuration received when calling the `!config` command. `!config` must be passed a dictionary for this to work. If you wish to reset the configuration to its defaults all you need to do is pass an empty dictionary to `!config`. You'll now also need to override :meth:`~errbot.botplugin.BotPlugin.get_configuration_template` and return the `CONFIG_TEMPLATE` in that function: .. code-block:: python def get_configuration_template(self): return CONFIG_TEMPLATE Using custom configuration checks --------------------------------- By default, Errbot will check the supplied configuration against the configuration template, and raise an error if the structure of the two doesn't match. You need to override the :meth:`~errbot.botplugin.BotPlugin.check_configuration` method if you wish do some other form of configuration validation. This method will be called automatically when an admin configures your plugin with the `!config` command. .. warning:: If there is no configuration set yet, it will pass `None` as parameter. Be mindful of this situation. Using the partial configuration trick as shown above requires you to override :meth:`~errbot.botplugin.BotPlugin.check_configuration`, so at a minimum you'll need this: .. code-block:: python def check_configuration(self, configuration): pass We suggest that you at least do some validation instead of nothing but that is up to you. errbot-6.1.1+ds/docs/user_guide/plugin_development/dependencies.rst000066400000000000000000000034251355337103200255670ustar00rootroot00000000000000Plugin Dependencies =================== Sometimes you need to be able to share a plugin feature with another. For example imagine you have a series of plugin configured the same way, you might want to make them depend on a central plugin taking care of the configuration that would share it with all the others. Declaring dependencies ---------------------- If you want to be able to use a plugin from another, the later needs to be activated before the former. You can ask Errbot to do so by adding a comma separated name list of the plugins your plugin is depending on in the **Core** section of your plug file like this: .. code-block:: ini [Core] Name = MyPlugin Module = myplugin DependsOn = OtherPlugin1, OtherPlugin2 Using dependencies ------------------ Once a dependent plugin has been declared, you can use it as soon as your plugin is activated. .. code-block:: python from errbot import BotPlugin, botcmd class OtherPlugin1(BotPlugin): def activate(self): self.my_variable = 'hello' super().activate() If you want to use it from MyPlugin: .. code-block:: python from errbot import BotPlugin, botcmd class MyPlugin(BotPlugin): @botcmd def hello(self, msg, args): return self.get_plugin('OtherPlugin1').my_variable Important to note: if you want to use a dependent plugin from within activate, you need to be in activated state, for example: .. code-block:: python from errbot import BotPlugin, botcmd class MyPlugin(BotPlugin): def activate(self): super().activate() # <-- needs to be *before* get_plugin self.other = self.get_plugin('OtherPlugin1') @botcmd def hello(self, msg, args): return self.other.my_variable errbot-6.1.1+ds/docs/user_guide/plugin_development/development_environment.rst000066400000000000000000000045661355337103200301160ustar00rootroot00000000000000Development environment ======================= Before we dive in and start writing our very first plugin, I'd like to take a moment to show you some tools and features which help facilitate the development process. Loading plugins from a local directory -------------------------------------- Normally, you manage and install plugins through the built-in `!repos` command. This installs plugins by cloning them via git, and allows updating of them through the `!repos update` command. During development however, it would be easier if you could load your plugin(s) directly, without having to commit them to a Git repository and instructing Errbot to pull them down. This can be achieved through the `BOT_EXTRA_PLUGIN_DIR` setting in the `config.py` configuration file. If you set a path here pointing to a directory on your local machine, Errbot will (recursively) scan that directory for plugins and attempt to load any it may find. Local test mode --------------- You can run Errbot in a local single-user mode that does not require any server connection by passing in the :option:`--text` (or :option:`-T`) option flag when starting the bot. In this mode, a very minimal back-end is used which you can interact with directly on the command-line. It looks like this:: $ errbot -T [...] INFO:Plugin activation done. Talk to me >> _ If you have `PySide `_ installed, you can also run this same mode in a separate window using :option:`--graphic` (or :option:`-G`) instead of :option:`--text`. The advantage of this is that you do not have the bot's responses and log information mixed up together in the same window. Plugin scaffolding ------------------ Plugins consist of two parts, a special `.plug` file and one or more Python (`.py`) files containing the actual code of your plugin (both of these are explained in-depth in the next section). Errbot can automatically generate these files for you so that you do not have to write boilerplate code by hand. To create a new plugin, run `errbot --new-plugin` (optionally specifying a directory where to create the new plugin - it will use the current directory by default). It will ask you a few questions such as the name for your plugin, a description and which versions of errbot it will work with and generate a plugin skeleton from this with all the information filled out automatically for you. errbot-6.1.1+ds/docs/user_guide/plugin_development/dynaplugs.rst000066400000000000000000000043511355337103200251460ustar00rootroot00000000000000Dynamic plugins (advanced) ========================== Sometimes the list of commands the bot wants to expose is not known at plugin development time. For example, you have a remote service with commands that can be set externally. This feature allows you to define and update on the fly plugins and their available commands. Defining new commands --------------------- You can create a commands from scratch with :class:`~errbot.Command`. By default it will be a :func:`~errbot.botcmd`. .. code-block:: python # from a lambda my_command1 = Command(lambda plugin, msg, args: 'received %s' % msg, name='my_command', doc='documentation of my_command') # or from a function def my_command(plugin, msg, args): """ documentation of my_command. """ return 'received %s' % msg my_command2 = Command(my_command) .. note:: the function will by annotated by a border effect, be sure to use a local function if you want to derive commands for the same underlying function. Registering the new plugin -------------------------- Once you have your series of Commands defined, you can package them in a plugin and expose them on errbot with :func:`~errbot.BotPlugin.create_dynamic_plugin`. .. code-block:: python # from activate, another bot command, poll etc. self.create_dynamic_plugin('my_plugin', (my_command1, my_command2)) Refreshing a plugin ------------------- You need to detroy and recreate the plugin to refresh its commands. .. code-block:: python self.destroy_dynamic_plugin('my_plugin') self.create_dynamic_plugin('my_plugin', (my_command1, my_command2, my_command3)) Customizing the type of commands and parameters ----------------------------------------------- You can use other type of commands by specifying cmd_type and pass them parameters with cmd_args and cmd_kwargs. .. code-block:: python # for example a botmatch re1 = Command(lambda plugin, msg, match: 'fffound', name='ffound', cmd_type=botmatch, cmd_args=(r'^.*cheese.*$',)) # or a split_args_with saw = Command(lambda plugin, msg, args: '+'.join(args), name='splitme', cmd_kwargs={'split_args_with': ','}) errbot-6.1.1+ds/docs/user_guide/plugin_development/exceptions.rst000066400000000000000000000057711355337103200253300ustar00rootroot00000000000000Exception Handling ================== Properly handling exceptions helps you build plugins that don't crash or produce unintended side-effects when the user or your code does something you did not expect. Combined with logging, exceptions also allow you to get visibility of areas in which your bot is failing and ultimately address problems to improve user experience. Exceptions in Errbot plugins should be handled slightly differently from how exceptions are normally used in Python. When an unhandled exception is raised during the execution of a command, Errbot sends a message like this: .. code-block:: none Computer says nooo. See logs for details: The above is neither helpful nor user-friendly, as the exception message may be too technical or brief (notice there is no traceback) for the user to understand. Even if you were to provide your own exception message, the "Computer says nooo ..." part is neither particularly attractive or informative. When handling exceptions, follow these steps: * trap the exception as you usually would * log the exception inside of the ``except`` block * ``self.log.exception('Descriptive message here')`` * import and use the `logging module `_ directly if you don't have access to ``self`` * ``self.log`` is just a convenience wrapper for the standard Python ``logging`` module * send a message describing what the user did wrong and recommend a solution for them to try their command again * do not re-raise your exception in the ``except`` block as you normally would. This is usually done in order to produce an entry in the error logs, but we've already logged the exception, and by not re-raising it, we prevent that automatic "Computer says nooo. ..." message from being sent Also, note that there is a ``errbot.ValidationException`` class which you can use inside your helper methods to raise meaningful errors and handle them accordingly. Here's an example: .. code-block:: python from errbot import BotPlugin, arg_botcmd, ValidationException class FooBot(BotPlugin): """An example bot""" @arg_botcmd('first_name', type=str) def add_first_name(self, message, first_name): """Add your first name if it doesn't contain any digits""" try: FooBot.validate_first_name(first_name) except ValidationException as exc: self.log.exception( 'first_name=%s contained a digit' % first_name ) return 'Your first name cannot contain a digit.' # Add some code here to add the given name to your database return "Your name has been added." @staticmethod def validate_first_name(first_name): if any(char.isdigit() for char in first_name): raise ValidationException( "first_name=%s contained a digit" % first_name ) errbot-6.1.1+ds/docs/user_guide/plugin_development/index.rst000066400000000000000000000012171355337103200242450ustar00rootroot00000000000000Plugin development ================== Plugins form the heart of Errbot. From the ground up, it is designed to be extended entirely through plugins. In this guide we will explain the basics of writing simple plugins, which we then follow up on further with sets of recipes on a range of topics describing how to handle more advanced use-cases. .. toctree:: :maxdepth: 2 :numbered: intro development_environment basics botcommands messaging presence mentions persistence configuration streams dependencies dynaplugs scheduling webhooks testing logging exceptions plugin_compatibility_settings backend_specifics errbot-6.1.1+ds/docs/user_guide/plugin_development/intro.rst000066400000000000000000000013561355337103200242750ustar00rootroot00000000000000Intro ===== Before we get started I would like to make sure you have all the necessary requirements installed and give you an idea of which knowledge you should already possess in order to follow along without difficulty. Requirements ------------ This guide assumes that you've already installed and configured Errbot and have successfully managed to connect it to a chatting service server. See :doc:`/user_guide/setup` if you have not yet managed to install or start Errbot. Prior knowledge --------------- You can most definitely work with Errbot if you only have basic Python knowledge, but you should know about data structures such as dictionaries, tuples and lists, know what docstrings are and have a basic understanding of decorators. errbot-6.1.1+ds/docs/user_guide/plugin_development/logging.rst000066400000000000000000000011761355337103200245700ustar00rootroot00000000000000Logging ------- Logging information on what your plugin is doing can be a tremendous asset when managing your bot in production, especially when something is going wrong. Errbot uses the standard Python `logging `_ library to log messages internally and provides a logger for your own plugins to use as well as `self.log`. You can use this logger to log status messages to the log like this: .. code-block:: python from errbot import BotPlugin class PluginExample(BotPlugin): def callback_message(self, message): self.log.info("I just received a message!") errbot-6.1.1+ds/docs/user_guide/plugin_development/mentions.rst000066400000000000000000000020561355337103200247740ustar00rootroot00000000000000Mentions ======== Depending on the backend used, users can mention and notify other users by using a special syntax like `@gbin`. With this feature, a plugin can listen to the mentioned users in the chat. How to use it ------------- Here is an example to listen to every mention and report them back on the chat. .. code-block:: python from errbot import BotPlugin class PluginExample(BotPlugin): def callback_mention(self, message, mentioned_people): for identifier in mentioned_people: self.send(message.frm, 'User %s has been mentioned' % identifier) Identifying if the bot itself has been mentioned ------------------------------------------------ Simply test the presence of the bot identifier within the `mentioned_people`: .. code-block:: python from errbot import BotPlugin class PluginExample(BotPlugin): def callback_mention(self, message, mentioned_people): if self.bot_identifier in mentioned_people: self.send(message.frm, 'Errbot has been mentioned !') errbot-6.1.1+ds/docs/user_guide/plugin_development/messaging.rst000066400000000000000000000116501355337103200251150ustar00rootroot00000000000000Messaging ========= Returning multiple responses ---------------------------- Often, with commands that take a long time to run, you may want to be able to send some feedback to the user that the command is progressing. Instead of using a single `return` statement you can use `yield` statements for every line of output you wish to send to the user. In the following example, the output will be "Going to sleep", followed by a 10 second wait period and "Waking up" in the end. .. code-block:: python from errbot import BotPlugin, botcmd from time import sleep class PluginExample(BotPlugin): @botcmd def longcompute(self, mess, args): yield "Going to sleep" sleep(10) yield "Waking up" Sending a message to a specific user or room -------------------------------------------- Sometimes, you may wish to send a message to a specific user or a groupchat, for example from pollers or on webhook events. You can do this with :func:`~errbot.botplugin.BotPlugin.send`: .. code-block:: python self.send( self.build_identifier("user@host.tld/resource"), "Boo! Bet you weren't expecting me, were you?", ) :func:`~errbot.botplugin.BotPlugin.send` requires a valid :class:`~errbot.backends.base.Identifier` instance to send to. :func:`~errbot.botplugin.BotPlugin.build_identifier` can be used to build such an identifier. The format(s) supported by `build_identifier` will differ depending on which backend you are using. For example, on Slack it may support `#channel` and `@user`, for XMPP it includes `user@host.tld/resource`, etc. Templating ---------- It's possible to send `Markdown `_ responses using `Jinja2 `_ templates. To do this, first create a directory called *templates* in the directory that also holds your plugin's *.plug* file. Inside this directory, you can place Markdown templates (with a *.md* extension) in place of the content you wish to show. For example this *hello.md*: .. code-block:: jinja Hello, {{name}}! .. note:: See the Jinja2 `Template Designer Documentation `_ for more information on the available template syntax. Next, tell Errbot which template to use by specifying the `template` parameter to :func:`~errbot.decorators.botcmd` (leaving off the *.md* suffix). Finally, instead of returning a string, return a dictionary where the keys refer to the variables you're substituting inside the template (`{{name}}` in the above template example): .. code-block:: python from errbot import BotPlugin, botcmd class Hello(BotPlugin): @botcmd(template="hello") def hello(self, msg, args): """Say hello to someone""" return {'name': args} It's also possible to use templates when using `self.send()`, but in this case you will have to do the template rendering step yourself, like so: .. code-block:: python from errbot import BotPlugin, botcmd from errbot.templating import tenv class Hello(BotPlugin): @botcmd(template="hello") def hello(self, msg, args): """Say hello to someone""" response = tenv().get_template('hello.md').render(name=args) self.send(msg.frm, response) Cards ----- Errbot cards are a canned format for notifications. It is possible to use this format to map to some native format in backends like Slack (Attachment) or Hipchat (Cards). Similar to a `self.send()` you can use :func:`~errbot.botplugin.BotPlugin.send_card` to send a card. The following code demonstrate the various available fields. .. code-block:: python from errbot import BotPlugin, botcmd class Travel(BotPlugin): @botcmd def hello_card(self, msg, args): """Say a card in the chatroom.""" self.send_card(title='Title + Body', body='text body to put in the card', thumbnail='https://raw.githubusercontent.com/errbotio/errbot/master/docs/_static/errbot.png', image='https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png', link='http://www.google.com', fields=(('First Key','Value1'), ('Second Key','Value2')), color='red', in_reply_to=msg) Trigger a callback with every message received ---------------------------------------------- It's possible to add a callback that will be called on every message sent either directly to the bot, or to a chatroom that the bot is in: .. code-block:: python from errbot import BotPlugin class PluginExample(BotPlugin): def callback_message(self, mess): if mess.body.find('cookie') != -1: self.send( mess.frm, "What what somebody said cookie!?", ) errbot-6.1.1+ds/docs/user_guide/plugin_development/persistence.rst000066400000000000000000000023271355337103200254650ustar00rootroot00000000000000Persistence =========== Persistence describes the ability for the plugins to persist data even if Errbot is restarted. How to use it ------------- Your plugin *is* the store, simply use self as a dictionary. Here is a simple example for storing and retreiving a value from the store. .. code-block:: python from errbot import BotPlugin, botcmd class PluginExample(BotPlugin): @botcmd def remember(self, msg, args): self['TODO'] = args @botcmd def recall(self, msg, args): return self['TODO'] Caveats ------- The storing occurs when you *assign the key*: .. code-block:: python # THIS WON'T WORK d = {} self['FOO'] = d d['subkey'] = 'NONONONONONO' What you need to do instead: (manual method) .. code-block:: python # THIS WORKS d = {} self['FOO'] = d # later ... d['subkey'] = 'NONONONONONO' self['FOO'] = d # restore the full key if something changed in memory. Or use the mutable contex manager: .. code-block:: python # THIS WORKS AND IS CLEANER d = {} self['FOO'] = d # later ... with self.mutable('FOO') as d: d['subkey'] = 'NONONONONONO' # it will save automatically the key errbot-6.1.1+ds/docs/user_guide/plugin_development/plugin_compatibility_settings.rst000066400000000000000000000016671355337103200313160ustar00rootroot00000000000000Plugin compatibility settings ============================= Errbot compatibility -------------------- Sometimes when your plugin breaks under a specific version of Errbot, you might want to warn the user of your plugin and not load it. You can do it by adding an **Errbot** section to your plug file like this: .. code-block:: ini [Core] Name = MyPlugin Module = myplugin [Documentation] Description = my plugin [Errbot] Min=2.4.0 Max=2.6.0 If the **Errbot** section is omitted, it defaults to "compatible with any version". If the **Min** option is omitted, there is no minimum version enforced. If the **Max** option is omitted, there is no maximum version enforced. Versions need to be a 3 dotted one (ie 2.4 is not allowed but 2.4.0 is). And it understands those suffixes: - "-beta" - "-rc1" - "-rc2" - etc. For example: 2.4.0-rc1 note: -beta1 or -rc are illegal. Only rc can get a numerical suffix. errbot-6.1.1+ds/docs/user_guide/plugin_development/presence.rst000066400000000000000000000026121355337103200247420ustar00rootroot00000000000000Presence ======== Presence describes the concept of a person's availability state, such as *online* or *away*, possibly with an optional message. Callbacks for presence changes ------------------------------ Plugins may override :meth:`~errbot.botplugin.BotPlugin.callback_presence` in order to receive notifications of presence changes. You will receive a :class:`~errbot.backends.base.Presence` object for every presence change received by Errbot. Here's an example which simply logs each presence change to the log when it includes a status message: .. code-block:: python from errbot import BotPlugin class PluginExample(BotPlugin): def callback_presence(self, presence): if presence.get_message() is not None: self.log.info(presence) Change the presence or status of the bot ---------------------------------------- You can also, depending on the backend you use, change the current status of the bot. This allows you to make a moody bot that leaves the room when it is in a bad mood ;) .. code-block:: python from errbot import BotPlugin, botcmd, ONLINE, AWAY class PluginExample(BotPlugin): @botcmd def grumpy(self, mess, args): self.change_presence(AWAY, 'I am tired of you all!') @botcmd def happy(self, mess, args): self.change_presence(ONLINE, 'I am back and so happy to see you!') errbot-6.1.1+ds/docs/user_guide/plugin_development/scheduling.rst000066400000000000000000000022551355337103200252660ustar00rootroot00000000000000Scheduling ========== Calling a function at a regular interval ---------------------------------------- It's possible to automatically call functions at regular intervals, using the :meth:`~errbot.botplugin.BotPlugin.start_poller` and :meth:`~errbot.botplugin.BotPlugin.stop_poller` methods. For example, you could schedule a callback to be executed once every minute when your plugin gets activated: .. code-block:: python :emphasize-lines: 10 from errbot import BotPlugin class PluginExample(BotPlugin): def my_callback(self): self.log.debug('I am called every minute') def activate(self): super().activate() self.start_poller(60, self.my_callback) It is also possible to specify the `times` parameter, which denotes how many times the function should be called, for instance: .. code-block:: python :emphasize-lines: 10 from errbot import BotPlugin class PluginExample(BotPlugin): def my_callback(self): self.log.debug('I got called after a minute (and just once)') def activate(self): super().activate() self.start_poller(60, self.my_callback, times=1) errbot-6.1.1+ds/docs/user_guide/plugin_development/streams.rst000066400000000000000000000027571355337103200246260ustar00rootroot00000000000000Streams ======= Streams are file transfers. It can be used to store documents, index them, send generated content on the fly etc. Waiting for incoming file transfers ----------------------------------- The bot can be sent files from the users. You only have to implement the :func:`~errbot.botplugin.BotPlugin.callback_stream` method on your plugin to be notified for new incoming file transfer requests. Note: not all backends supports this, check if it has been correctly implemented from the backend itself. For example, getting the initiator of the transfer and the content, see :class:`~errbot.backends.base.Stream` for more info about the various fields. .. code-block:: python from errbot import BotPlugin, botcmd class PluginExample(BotPlugin): def callback_stream(self, stream): self.send(stream.identifier, "File request from :" + str(stream.identifier)) stream.accept() self.send(stream.identifier, "Content:" + str(stream.fsource.read())) Sending a file to a user or a room ---------------------------------- You can use :func:`~errbot.botplugin.BotPlugin.send_stream_request` to initiate a transfer: .. code-block:: python stream = self.send_stream_request(msg.frm, open('/tmp/myfile.zip', 'r'), name='bills.zip', stream_type='application/zip') The returned stream object can be used to monitor the progress of the transfer with `stream.status`, `stream.transfered` etc... See :class:`~errbot.backends.base.Stream` for more details. errbot-6.1.1+ds/docs/user_guide/plugin_development/testing.rst000066400000000000000000000253731355337103200246240ustar00rootroot00000000000000Testing your plugins ==================== Just as Errbot has tests that validates that it behaves correctly so should your plugin. Errbot is tested using Python's py.test_ module and because we already provide some utilities for that we highly advise you to use `py.test` too. We're going to write a simple plugin named `myplugin.py` with a `MyPlugin` class. It's tests will be stored in `test_myplugin.py` in the same directory. Interacting with the bot ------------------------ Lets go for an example, *myplugin.py*: .. code-block:: python from errbot import BotPlugin, botcmd class MyPlugin(BotPlugin): @botcmd def mycommand(self, message, args): return "This is my awesome command" And *myplugin.plug*: .. code-block:: ini [Core] Name = MyPlugin Module = myplugin [Documentation] Description = my plugin This does absolutely nothing shocking, but how do you test it? We need to interact with the bot somehow, send it `!mycommand` and validate the reply. Fortunatly Errbot provides some help. Our test, *test_myplugin.py*: .. code-block:: python pytest_plugins = ["errbot.backends.test"] extra_plugin_dir = '.' def test_command(testbot): testbot.push_message('!mycommand') assert 'This is my awesome command' in testbot.pop_message() Lets walk through this line for line. First of all, we specify our pytest fixture location :class:`~errbot.backends.test` in the backends tests, to allow us to spin up a bot for testing purposes and interact with the message queue. To avoid specifying the module in every test module, you can simply place this line in your conftest.py_. Then we set `extra_plugin_dir` to `.`, the current directory so that the test bot will pick up on your plugin. After that we define our first `test_` method which simply sends a command to the bot using :func:`~errbot.backends.test.TestBot.push_message` and then asserts that the response we expect, *"This is my awesome command"* is in the message we receive from the bot which we get by calling :func:`~errbot.backends.test.TestBot.pop_message`. You can assert the response of a command using the method assertCommand of the testbot. `testbot.assertCommand('!mycommand', 'This is my awesome command')` to achieve the equivalent of pushing message and asserting the response in the popped message.` Helper methods -------------- Often enough you'll have methods in your plugins that do things for you that are not decorated with `@botcmd` since the user never calls out to these methods directly. Such helper methods can be either instance methods, methods that take `self` as the first argument because they need access to data stored on the bot or class or static methods, decorated with either `@classmethod` or `@staticmethod`: .. code-block:: python class MyPlugin(BotPlugin): @botcmd def mycommand(self, message, args): return self.mycommand_helper() @staticmethod def mycommand_helper(): return "This is my awesome command" The `mycommand_helper` method does not need any information stored on the bot whatsoever or any other bot state. It can function standalone but it makes sense organisation-wise to have it be a member of the `MyPlugin` class. Such methods can be tested very easily, without needing a bot: .. code-block:: python import myplugin def test_mycommand_helper(): expected = "This is my awesome command" result = myplugin.MyPlugin.mycommand_helper() assert result == expected Here we simply import `myplugin` and since it's a `@staticmethod` we can directly access it through `myplugin.MyPlugin.method()`. Sometimes however a helper method needs information stored on the bot or manipulate some of that so you declare an instance method instead: .. code-block:: python class MyPlugin(BotPlugin): @botcmd def mycommand(self, message, args): return self.mycommand_helper() def mycommand_helper(self): return "This is my awesome command" Now what? We can't access the method directly anymore because we need an instance of the bot and the plugin and we can't just send `!mycommand_helper` to the bot, it's not a bot command (and if it were it would be `!mycommand helper` anyway). What we need now is get access to the instance of our plugin itself. Fortunately for us, there's a method that can help us do just that: .. code-block:: python extra_plugin_dir = '.' def test_mycommand_helper(testbot): plugin = testbot._bot.plugin_manager.get_plugin_obj_by_name('MyPlugin') expected = "This is my awesome command" result = plugin.mycommand_helper() assert result == expected There we go, we first grab our plugin using a helper method on :mod:`~errbot.plugin_manager` and then simply execute the method and compare the result with the expected result. You can also access `@classmethod` or `@staticmethod` methods this way, but you don't have to. Sometimes a helper method will be making HTTP or API requests which might not be possible to test directly. In that case, we need to mock that particular method and make it return the expected value without actually making the request. .. code-block:: python URL = 'http://errbot.io' class MyPlugin(BotPlugin): @botcmd def mycommand(self, message, args): return self.mycommand_helper() def mycommand_helper(self): return (requests.get(URL).status_code) What we need now is to somehow replace the method making the request with our mock object and `inject_mocks` method comes in handy. Refer `unittest.mock `_ for more information about mock. .. code-block:: python from unittest.mock import MagicMock extra_plugin_dir = '.' def test_mycommand_helper(testbot): helper_mock = MagicMock(return_value='200') mock_dict = {'mycommand_helper': helper_mock} testbot.inject_mocks('MyPlugin', mock_dict) testbot.push_message('!mycommand') expected = '200' result = testbot.pop_message() assert result == expected Pattern ------- It's a good idea to split up your plugin in two types of methods, those that directly interact with the user and those that do extra stuff you need. If you do this the `@botcmd` methods should only concern themselves with giving output back to the user and calling different other functions it needs in order to fulfill the user's request. Try to keep as many helper methods simple, there's nothing wrong with having an extra helper or two to avoid having to nest fifteen if-statements. It becomes more legible, easier to maintain and easier to test. If you can, try to make your helper methods `@staticmethod` decorated functions, it's easier to test and you don't need a full running bot for those tests. All together now ---------------- *myplugin.py*: .. code-block:: python from errbot import BotPlugin, botcmd class MyPlugin(BotPlugin): @botcmd def mycommand(self, message, args): return self.mycommand_helper() @botcmd def mycommand_another(self, message, args): return self.mycommand_another_helper() @staticmethod def mycommand_helper(): return "This is my awesome command" def mycommand_another_helper(self): return "This is another awesome command" *myplugin.plug*: .. code-block:: ini [Core] Name = MyPlugin Module = myplugin [Documentation] Description = my plugin *test_myplugin.py*: .. code-block:: python import myplugin extra_plugin_dir = '.' def test_mycommand(testbot): testbot.push_message('!mycommand') assert 'This is my awesome command' in testbot.pop_message() def test_mycommand_another(testbot): testbot.push_message('!mycommand another') assert 'This is another awesome command' in testbot.pop_message() def test_mycommand_helper(): expected = "This is my awesome command" result = myplugin.MyPlugin.mycommand_helper() assert result == expected def test_mycommand_another_helper(): plugin = testbot._bot.plugin_manager.get_plugin_obj_by_name('MyPlugin') expected = "This is another awesome command" result = plugin.mycommand_another_helper() assert result == expected You can now simply run :command:`py.test` to execute the tests. PEP-8 and code coverage ----------------------- If you feel like it you can also add syntax checkers like `pep8` into the mix to validate your code behaves to certain stylistic best practices set out in PEP-8. First, install the pep8 for py.test_: :command:`pip install pytest-pep8`. Then, simply add `--pep8` to the test invocation command: `py.test --pep8`. You also want to know how well your tests cover you code. To that end, install coverage: :command:`pip install coverage` and then run your tests like this: :command:`coverage run --source myplugin -m py.test --pep8`. You can now have a look at coverage statistics through :command:`coverage report`:: Name Stmts Miss Cover ------------------------------- myplugin 49 0 100% It's also possible to generate an HTML report with :command:`coverage html` and opening the resulting `htmlcov/index.html`. Travis and Coveralls -------------------- Last but not least, you can run your tests on Travis-CI_ so when you update code or others submit pull requests the tests will automatically run confirming everything still works. In order to do that you'll need a `.travis.yml` similar to this: .. code-block:: yaml language: python python: - 2.7 - 3.3 - 3.4 install: - pip install -q errbot pytest pytest-pep8 --use-wheel - pip install -q coverage coveralls --use-wheel script: - coverage run --source myplugin -m py.test --pep8 after_success: - coveralls notifications: email: false Most of it is self-explanatory, except for perhaps the `after_success`. The author of this plugin uses Coveralls.io_ to keep track of code coverage so after a successful build we call out to coveralls and upload the statistics. It's for this reason that we `pip install [..] coveralls [..]` in the `.travis.yml`. The `-q` flag causes pip to be a lot more quiet and `--use-wheel` will cause pip to use wheels_ if available, speeding up your builds if you happen to depend on something that builds a C-extension. Both Travis-CI and Coveralls easily integrate with Github hosted code. .. _py.test: http://pytest.org .. _conftest.py: http://doc.pytest.org/en/latest/writing_plugins.html#conftest-py-local-per-directory-plugins .. _Coveralls.io: https://coveralls.io .. _Travis-CI: https://travis-ci.org .. _wheels: http://www.python.org/dev/peps/pep-0427/ errbot-6.1.1+ds/docs/user_guide/plugin_development/webhooks.rst000066400000000000000000000167771355337103200250000ustar00rootroot00000000000000Webhooks ======== Errbot has a small integrated webserver that is capable of hooking up endpoints to methods inside your plugins. You must configure the *Webserver* plugin before this functionality can be used. You can get the configuration template using `!plugin config Webserver`, from where it's just a simple matter of plugging in the desired settings. .. note:: There is a `!generate certificate` command to generate a self-signed certificate in case you want to enable SSL connections and do not have a certificate. .. warning:: It is not recommended to expose Errbot's webserver directly to the network. Instead, we recommend placing it behind a webserver such as `nginx `_ or `Apache `_. Simple webhooks --------------- All you need to do for a plugin of yours to listen to a specific URI is to apply the :func:`~errbot.webhook` decorator to your method. Whatever it returns will be returned in response to the request: .. code-block:: python from errbot import BotPlugin, webhook class PluginExample(BotPlugin): @webhook def test(self, request): self.log.debug(repr(request)) return "OK" This will listen for POST requests on http://yourserver.tld:yourport/test/, and return *"OK"* as the response body. .. note:: If you return `None`, an empty 200 response will be sent. You can also set a custom URI pattern by providing the `uri_rule` parameter: .. code-block:: python from errbot import BotPlugin, webhook class PluginExample(BotPlugin): @webhook('/example///') def test(self, request, name, action): return "User %s is performing %s" % (name, action) Refer to the documentation on Flask's `route `_ for details on the supported syntax (Errbot uses Flask internally). Handling JSON request --------------------- If an incoming request has the MIME media type set to `application/json` the request will automatically be decoded as JSON. You will receive the result of calling `json.loads()` on `request` automatically so that you won't have to do this yourself. Handling form-encoded requests ------------------------------ Form-encoded requests (those with an *application/x-www-form-urlencoded* mimetype) are very simple to handle as well, you just need to specify the `form_param` parameter. A good example for this is the GitHub format which posts a form with a *payload* parameter: .. code-block:: python from errbot import BotPlugin, webhook class Github(BotPlugin): @webhook('/github/', form_param = 'payload') def notification(self, payload): for room in self.bot_config.CHATROOM_PRESENCE: self.send( self.build_identifier(room), 'Commit on %s!' % payload['repository']['name'], ) The raw request --------------- The above webhooks are convenient for simple tasks, but sometimes you might wish to have more power and have access to the actual request itself. By setting the `raw` parameter of the :func:`~errbot.decorators.webhook` decorator to `True`, you will be able to get the `bottle.BaseRequest `_ which contains all the details about the actual request: .. code-block:: python from errbot import BotPlugin, webhook class PluginExample(BotPlugin): @webhook(raw=True) def test(self, request): user_agent = request.get_header("user-agent", "Unknown") return "Your user-agent is {}".format(user_agent) Returning custom headers and status codes ----------------------------------------- Adjusting the response headers, setting cookies or returning a different status code can all be done by manipulating the `flask response `_ object. The Flask docs on `the response object `_ explain this in more detail. Here's an example of setting a custom header: .. code-block:: python from errbot import BotPlugin, webhook from flask import after_this_request class PluginExample(BotPlugin): @webhook def example(self, incoming_request): @after_this_request def add_header(response): response.headers['X-Powered-By'] = 'Errbot' return "OK" Flask also has various helpers such as the `abort()` method. Using this method we could, for example, return a 403 forbidden response like so: .. code-block:: python from errbot import BotPlugin, webhook from flask import abort class PluginExample(BotPlugin): @webhook def example(self, incoming_request): abort(403, "Forbidden") Testing a webhook through chat ------------------------------ You can use the `!webhook` command to test webhooks without making an actual HTTP request, using the following format: `!webhook test [endpoint] [post_content]` For example:: !webhook test github payload=%7B%22pusher%22%3A%7B%22name%22%3A%22gbin%22%2C%22email%22%3A%22gbin%40gootz.net%22%7D%2C%22repository%22%3A%7B%22name%22%3A%22test%22%2C%22created_at%22%3A%222012-08-12T16%3A09%3A43-07%3A00%22%2C%22has_wiki%22%3Atrue%2C%22size%22%3A128%2C%22private%22%3Afalse%2C%22watchers%22%3A0%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fgbin%2Ftest%22%2C%22fork%22%3Afalse%2C%22pushed_at%22%3A%222012-08-12T16%3A26%3A35-07%3A00%22%2C%22has_downloads%22%3Atrue%2C%22open_issues%22%3A0%2C%22has_issues%22%3Atrue%2C%22stargazers%22%3A0%2C%22forks%22%3A0%2C%22description%22%3A%22ignore%20this%2C%20this%20is%20for%20testing%20the%20new%20err%20github%20integration%22%2C%22owner%22%3A%7B%22name%22%3A%22gbin%22%2C%22email%22%3A%22gbin%40gootz.net%22%7D%7D%2C%22forced%22%3Afalse%2C%22after%22%3A%22b3cd9e66e52e4783c1a0b98fbaaad6258669275f%22%2C%22head_commit%22%3A%7B%22added%22%3A%5B%5D%2C%22modified%22%3A%5B%22README.md%22%5D%2C%22timestamp%22%3A%222012-08-12T16%3A24%3A25-07%3A00%22%2C%22removed%22%3A%5B%5D%2C%22author%22%3A%7B%22name%22%3A%22Guillaume%20BINET%22%2C%22username%22%3A%22gbin%22%2C%22email%22%3A%22gbin%40gootz.net%22%7D%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fgbin%2Ftest%2Fcommit%2Fb3cd9e66e52e4783c1a0b98fbaaad6258669275f%22%2C%22id%22%3A%22b3cd9e66e52e4783c1a0b98fbaaad6258669275f%22%2C%22distinct%22%3Atrue%2C%22message%22%3A%22voila%22%2C%22committer%22%3A%7B%22name%22%3A%22Guillaume%20BINET%22%2C%22username%22%3A%22gbin%22%2C%22email%22%3A%22gbin%40gootz.net%22%7D%7D%2C%22deleted%22%3Afalse%2C%22commits%22%3A%5B%7B%22added%22%3A%5B%5D%2C%22modified%22%3A%5B%22README.md%22%5D%2C%22timestamp%22%3A%222012-08-12T16%3A24%3A25-07%3A00%22%2C%22removed%22%3A%5B%5D%2C%22author%22%3A%7B%22name%22%3A%22Guillaume%20BINET%22%2C%22username%22%3A%22gbin%22%2C%22email%22%3A%22gbin%40gootz.net%22%7D%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fgbin%2Ftest%2Fcommit%2Fb3cd9e66e52e4783c1a0b98fbaaad6258669275f%22%2C%22id%22%3A%22b3cd9e66e52e4783c1a0b98fbaaad6258669275f%22%2C%22distinct%22%3Atrue%2C%22message%22%3A%22voila%22%2C%22committer%22%3A%7B%22name%22%3A%22Guillaume%20BINET%22%2C%22username%22%3A%22gbin%22%2C%22email%22%3A%22gbin%40gootz.net%22%7D%7D%5D%2C%22ref%22%3A%22refs%2Fheads%2Fmaster%22%2C%22before%22%3A%2229b1f5e59b7799073b6d792ce76076c200987265%22%2C%22compare%22%3A%22https%3A%2F%2Fgithub.com%2Fgbin%2Ftest%2Fcompare%2F29b1f5e59b77...b3cd9e66e52e%22%2C%22created%22%3Afalse%7D .. note:: You can get a list of all the endpoints with the `!webstatus` command. errbot-6.1.1+ds/docs/user_guide/provisioning.rst000066400000000000000000000040251355337103200217640ustar00rootroot00000000000000Provisioning (advanced) ======================= Plugins can be configured by talking to the bot with:: !plugin config my_plugin {'key': 'value'} Also plugins can store values in the storage as key value pairs. Sometimes, you need to inject those values either config or plugin state ahead of time for example at errbot installation (also called provisioning). It is useful for installation scripts and deployments. .. note:: When using the default "Shelf storage", errbot may not be running when attempting to get or set data. Any attempt to do so while the bot is running will result in the error "Storage does not appear to have been opened yet" being returned. This is caused by the fact that the python `shelve` module only allows a single process to hold the database open. For further details, see the `shelve docs `_. Reading stored values --------------------- To read the current stored plugin configs you can do from the command line:: errbot --storage-get core It will give you on stdout a python dictionary of the core namespace like:: {'configs': {'Webserver': {'PORT': 8888}}} To read the values from a plugin storage, for example here from alimac/err-factoid you can do:: errbot --storage-get Factoid It will give you on stdout a similar output:: {'FACTOID': {'fire': 'burns', 'water': 'wet'}} Writing values -------------- To add or change specific values without touching others you can merge a dictionary like that:: echo "{'configs': {'Webserver': {'PORT': 9999}}}" | errbot --storage-merge core Checking back:: errbot --storage-get core {'configs': {'Webserver': {'PORT': 9999}}} Changing facts in Factoid (note the merge is only on the first level so we change all FACTOID here):: echo "{'FACTOID': {'errbot': 'awesome'}}" | errbot --storage-merge Factoid >>> !errbot? errbot is awesome You can use --storage-set in the same fashion but it will erase first the namespace before writing your values. errbot-6.1.1+ds/docs/user_guide/sentry.rst000066400000000000000000000044121355337103200205620ustar00rootroot00000000000000Logging to Sentry ================= According to the `official website `_... Sentry is an event logging platform primarily focused on capturing and aggregating exceptions. It was originally conceived at DISQUS in early 2010 to solve exception logging within a Django application. Since then it has grown to support many popular languages and platforms, including Python, PHP, Java, Ruby, Node.js, and even JavaScript. Come again? Just what is Sentry, exactly? ----------------------------------------- The `official documentation `_ explains it better: Sentry is a realtime event logging and aggregation platform. At its core it specializes in monitoring errors and extracting all the information needed to do a proper post-mortem without any of the hassle of the standard user feedback loop. If that sounds like something you'd want to gift your precious Errbot instance with, then do keep on reading :) Setting up Sentry itself ------------------------ Installing and configuring sentry is beyond the scope of this document. However, there are two options available to you. You can either get a `hosted account `_, or grab the code and `run your own server `_ instead. Configuring Errbot to use Sentry -------------------------------- Once you have an instance of Sentry available, you'll probably want to create a team specifically for Errbot first. When you have, you should be able to access a page called "Client configuration". There, you will be presented with a so-called DSN value, which has the following format: http://0000000000000000:000000000000000000@sentry.domain.tld/0 To setup Errbot with Sentry: * Open up your bot's config.py * Set **BOT_LOG_SENTRY** to *True* and fill in **SENTRY_DSN** with the DSN value obtained previously * Optionally adjust **SENTRY_LOGLEVEL** to the desired level * Optionally adjust **SENTRY_TRANSPORT** to the desired transport * Restart Errbot You can find a list of Sentry transport classes `here `_. You should now see Exceptions and log messages show up in your Sentry stream. errbot-6.1.1+ds/docs/user_guide/setup.rst000066400000000000000000000147341355337103200204060ustar00rootroot00000000000000Setup ===== Prerequisites ------------- Errbot runs under Python 3.3+ on Linux, Windows and Mac. Installation ------------ Option 1: Use the package manager of your distribution (if available) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ On some distributions, Errbot is also available as a package via your usual package manager. In these cases, it is generally recommended to use your distribution's package instead of installing from PyPi but note that the version packaged with your distribution may be a few versions behind. Example of packaged versions of Errbot: Gentoo: https://gpo.zugaina.org/net-im/errbot Arch: https://aur.archlinux.org/packages/python-err/ Docker: https://hub.docker.com/r/rroemhild/errbot/ Juju: https://jujucharms.com/u/onlineservices-charmers/errbot Option 2: Installing Errbot in a virtualenv (preferred) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Installing into a `virtualenv`_ is **strongly** recommended. If you have virtualenv installed, you can do for example:: virtualenv --python `which python3` ~/.errbot-ve ~/.errbot-ve/bin/pip install errbot If you have virtualenvwrapper installed it is even simpler:: mkvirtualenv -p `which python3` errbot-ve pip install errbot Option 3: Installing Errbot at the system level (not recommended) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Errbot may be installed directly from PyPi using `pip`_ by issuing:: pip3 install errbot .. note:: Some of errbot's dependencies need to build C extensions which means you need to have development headers for some libraries installed. On Debian/Ubuntu these may be installed with `apt-get install python3-dev libssl-dev libffi-dev` Package names may differ on other OS's. .. _configuration: First run ^^^^^^^^^ You can quickly configure Errbot by first creating a working directory and calling `errbot --init`:: mkdir ~/errbot-root cd ~/errbot-root errbot --init This will create a minimally working errbot in text (development) mode. You can try it right away:: errbot [...] >>> `>>>` is a prompt, you can talk to errbot directly. You can try:: !tryme !help !about Configuration ------------- Once you have installed errbot and did `errbot --init`, you will have to tweak the generated `config.py` to connect to your desired chat network. You can use :download:`config-template.py` as a base for your `config.py`. We'll go through the options that you absolutely must check now so that you can quickly get started and make further tweaks to the configuration later on. Open `config.py` in your favorite editor. The first setting to check or change if `BOT_DATA_DIR` if correct. This is the directory where the bot will store configuration data. The first setting to check or change `BOT_LOG_FILE` to be sure it point to a writeable directory on your system. The final configuration we absolutely must do is setting up a correct `BACKEND` which is set to `Text` by `errbot --init` but you can change to the name of the chat system you want to connect to (see the template above for valid values). You absolutely need a `BOT_IDENTITY` entry to set the credentials Errbot will use to connect to the chat system. You can find here more details about configuring Errbot for some specific chat systems: .. toctree:: :maxdepth: 1 configuration/xmpp configuration/irc configuration/hipchat configuration/slack configuration/telegram Starting the daemon ------------------- The first time you start Errbot, it is recommended to run it in foreground mode. This can be done with:: errbot If you installed errbot into a virtualenv (as recommended), call it by prefixing the virtualenv `bin/` directory:: /path/to/my/virtualenv/bin/errbot Please pass -h or --help to errbot to get a list of supported parameters. Depending on your situation, you may need to pass --config (or -c) pointing to the directory holding your `config.py` when starting Errbot. If all that worked out, you can now use the -d (or --daemon) parameter to run it in a detached mode:: errbot --daemon If you are going to run your bot all the time then using some process control system such as `supervisor`_ is highly recommended. Installing and configuring such a system is outside the scope of this document, however, we do provide some sample daemon configurations below. .. note:: There are two ways to gracefully shut down a running bot. You can use the :code:`!shutdown` command to do so via chat or you can send a `SIGINT` signal to the errbot process to do so from the commandline If you're running errbot in the foreground then pressing Ctrl+C is equivalent to sending `SIGINT`. Daemon Configurations ^^^^^^^^^^^^^^^^^^^^^ These are a few example configurations using common init daemons: **supervisord** (`/etc/supervisor/conf.d/errbot.conf`) .. literalinclude:: ../code_examples/supervisord.conf :language: sh **systemd** (`/etc/systemd/system/errbot.service`) .. literalinclude:: ../code_examples/systemd.service :language: sh .. note:: Running errbot within a daemon process can have security implications if the daemon is started with an account containing elevated privileges. We encourage errbot **not** be run under a `root` or `administrator` account but under a non-privileged account. The command below creates a non-privileged `errbot` account on Linux:: $ useradd --no-create-home --no-user-group -g nogroup -s /bin/false errbot Upgrading --------- Errbot comes bundled with a plugin which automatically performs a periodic update check. Whenever there is a new release on PyPI, this plugin will notify the users set in `BOT_ADMINS` about the new version. Assuming you originally installed errbot using pip (see `installation`_), you can upgrade errbot in much the same way. If you used a virtualenv:: /path/to/my/virtualenv/bin/pip install --upgrade errbot Or if you used pip without virtualenv:: pip install --upgrade errbot It's recommended that you review the changelog before performing an upgrade in case backwards-incompatible changes have been introduced in the new version. The changelog for the release you will be installing can always be found on `PyPI `_. Provisioning (advanced) ----------------------- See the `provisioning documentation `_ .. _virtualenv: https://virtualenv.pypa.io/en/latest/ .. _pip: https://pip.pypa.io/en/stable/ .. _supervisor: http://supervisord.org/ errbot-6.1.1+ds/docs/user_guide/storage_development/000077500000000000000000000000001355337103200225515ustar00rootroot00000000000000errbot-6.1.1+ds/docs/user_guide/storage_development/index.rst000066400000000000000000000047671355337103200244300ustar00rootroot00000000000000[Advanced] Storage Plugin development ===================================== A storage plugin is the glue code that tells Errbot how to store the persistent data the plugins and the bot itself are producing. Starting with Errbot 3.3.0, storage plugins can be developed out of the main repository. This documentation is there to guide you making a new storage plugin so you can connect Errbot to your favorite database. Architecture ------------ Storage plugins are instantiated in 2 stages. The first stage is storage plugin discovery and is similar to normal bot plugins: * Errbot scans `errbot/storage` and `config.BOT_EXTRA_STORAGE_PLUGINS_DIR` for `.plug` files pointing to plugins implementing :class:`~errbot.storage.base.StoragePluginBase`. * Once the correct plugin from `config.STORAGE` is found, it is built with the bot config as its `__init__` parameter. * By calling `super().__init__` on :class:`~errbot.storage.base.StoragePluginBase`, Errbot will populate `self._storage_config` from `config.STORAGE_CONFIG`. This configuration should contain the custom parameters needed by your plugin to be able to connect to your database/storage ie. url, port, path, credentials ... You need to document them clearly so your users can set `config.STORAGE_CONFIG` correctly. * As you can see in :class:`~errbot.storage.base.StoragePluginBase`, you just have to implement the `open` method there. The second stage is opening the storage, which is done using the `open` method: * Various parts of Errbot may need separate key/value storage, the `open` method has a namespace to track those. For example, the internal :class:`~errbot.plugin_manager.BotPluginManager` will open the namespace `core` to store the bot plugins and their config, the installed repos, etc. * `open` needs to return a :class:`~errbot.storage.base.StorageBase`, which exposes the various actions that Errbot can call on the storage (set, get, ...). * You don't need to track the lifecycle of the storage, it will be enforced externally: no double-close, double-open, `get` after close, etc. Plugins are :class:`collections.MutableMapping` and use :class:`~errbot.storage.StoreMixin` as an adapter from the mapping accessors to the :class:`~errbot.storage.base.StorageBase` implementation. Testing ------- Storage plugins are completely independent from Errbot itself. It should be easy to instantiate and test them externally. Example ------- You can have a look at the internal shelf implementation :class:`~errbot.storage.shelf.ShelfStorage` errbot-6.1.1+ds/errbot/000077500000000000000000000000001355337103200147155ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/__init__.py000066400000000000000000000547641355337103200170460ustar00rootroot00000000000000import argparse from functools import wraps import logging import re import shlex import inspect import sys from typing import Callable, Any, Tuple from .core_plugins.wsview import WebView from .backends.base import Message, ONLINE, OFFLINE, AWAY, DND # noqa from .botplugin import BotPlugin, SeparatorArgParser, ShlexArgParser, CommandError, Command, ValidationException # noqa from .flow import FlowRoot, BotFlow, Flow, FLOW_END from .core_plugins.wsview import route from . import core __all__ = ['BotPlugin', 'CommandError', 'Command', 'webhook', 'webroute', 'cmdfilter', 'botcmd', 're_botcmd', 'arg_botcmd', 'botflow', 'botmatch', 'BotFlow', 'FlowRoot', 'Flow', 'FLOW_END', ] log = logging.getLogger(__name__) webroute = route # this allows plugins to expose dynamic webpages on Errbot embedded webserver # Some clients automatically convert consecutive dashes into a fancy # hyphen, which breaks long-form arguments. Undo this conversion to # provide a better user experience. # Same happens with quotations marks, which are required for parsing # complex strings in arguments # Map of characters to sanitized equivalents ARG_BOTCMD_CHARACTER_REPLACEMENTS = {'—': '--', '“': '"', '”': '"', '’': '\'', '‘': '\''} class ArgumentParseError(Exception): """Raised when ArgumentParser couldn't parse given arguments.""" class HelpRequested(Exception): """Signals that -h/--help was used and help should be displayed to the user.""" class ArgumentParser(argparse.ArgumentParser): """ The python argparse.ArgumentParser, adapted for use within Err. """ def error(self, message): raise ArgumentParseError(message) def print_help(self, file=None): # Implementation note: Only easy way to do this appears to be # through raising an exception which we can catch later in # a place where we have the ability to return a message to # the user. raise HelpRequested() def _tag_botcmd(func, hidden=None, name=None, split_args_with='', admin_only=False, historize=True, template=None, flow_only=False, _re=False, syntax=None, # botcmd_only pattern=None, # re_cmd only flags=0, # re_cmd only matchall=False, # re_cmd_only prefixed=True, # re_cmd_only _arg=False, command_parser=None, # arg_cmd only re_cmd_name_help=None): # re_cmd_only """ Mark a method as a bot command. """ if not hasattr(func, '_err_command'): # don't override generated functions func._err_command = True func._err_command_name = name or func.__name__ func._err_command_split_args_with = split_args_with func._err_command_admin_only = admin_only func._err_command_historize = historize func._err_command_template = template func._err_command_syntax = syntax func._err_command_flow_only = flow_only func._err_command_hidden = hidden if hidden is not None else flow_only # re_cmd func._err_re_command = _re if _re: func._err_command_re_pattern = re.compile(pattern, flags=flags) func._err_command_matchall = matchall func._err_command_prefix_required = prefixed func._err_command_syntax = pattern func._err_command_re_name_help = re_cmd_name_help # arg_cmd func._err_arg_command = _arg if _arg: func._err_command_parser = command_parser # func._err_command_syntax is set at wrapping time. return func def botcmd(*args, hidden: bool = None, name: str = None, split_args_with: str = '', admin_only: bool = False, historize: bool = True, template: str = None, flow_only: bool = False, syntax: str = None) -> Callable[[BotPlugin, Message, Any], Any]: """ Decorator for bot command functions :param hidden: Prevents the command from being shown by the built-in help command when `True`. :param name: The name to give to the command. Defaults to name of the function itself. :param split_args_with: Automatically split arguments on the given separator. Behaviour of this argument is identical to :func:`str.split()` :param admin_only: Only allow the command to be executed by admins when `True`. :param historize: Store the command in the history list (`!history`). This is enabled by default. :param template: The markdown template to use. :param syntax: The argument syntax you expect for example: '[name] '. :param flow_only: Flag this command to be available only when it is part of a flow. If True and hidden is None, it will switch hidden to True. This decorator should be applied to methods of :class:`~errbot.botplugin.BotPlugin` classes to turn them into commands that can be given to the bot. These methods are expected to have a signature like the following:: @botcmd def some_command(self, msg, args): pass The given `msg` will be the full message object that was received, which includes data like sender, receiver, the plain-text and html body (if applicable), etc. `args` will be a string or list (depending on your value of `split_args_with`) of parameters that were given to the command by the user. """ def decorator(func): return _tag_botcmd(func, _re=False, _arg=False, hidden=hidden, name=name or func.__name__, split_args_with=split_args_with, admin_only=admin_only, historize=historize, template=template, syntax=syntax, flow_only=flow_only) return decorator(args[0]) if args else decorator def re_botcmd(*args, hidden: bool = None, name: str = None, admin_only: bool = False, historize: bool = True, template: str = None, pattern: str = None, flags: int = 0, matchall: bool = False, prefixed: bool = True, flow_only: bool = False, re_cmd_name_help: str = None) -> Callable[[BotPlugin, Message, Any], Any]: """ Decorator for regex-based bot command functions :param pattern: The regular expression a message should match against in order to trigger the command. :param flags: The `flags` parameter which should be passed to :func:`re.compile()`. This allows the expression's behaviour to be modified, such as making it case-insensitive for example. :param matchall: By default, only the first match of the regular expression is returned (as a `re.MatchObject`). When *matchall* is `True`, all non-overlapping matches are returned (as a list of `re.MatchObject` items). :param prefixed: Requires user input to start with a bot prefix in order for the pattern to be applied when `True` (the default). :param hidden: Prevents the command from being shown by the built-in help command when `True`. :param name: The name to give to the command. Defaults to name of the function itself. :param admin_only: Only allow the command to be executed by admins when `True`. :param historize: Store the command in the history list (`!history`). This is enabled by default. :param template: The template to use when using markdown output :param flow_only: Flag this command to be available only when it is part of a flow. If True and hidden is None, it will switch hidden to True. This decorator should be applied to methods of :class:`~errbot.botplugin.BotPlugin` classes to turn them into commands that can be given to the bot. These methods are expected to have a signature like the following:: @re_botcmd(pattern=r'^some command$') def some_command(self, msg, match): pass The given `msg` will be the full message object that was received, which includes data like sender, receiver, the plain-text and html body (if applicable), etc. `match` will be a :class:`re.MatchObject` containing the result of applying the regular expression on the user's input. """ def decorator(func): return _tag_botcmd(func, _re=True, _arg=False, hidden=hidden, name=name or func.__name__, admin_only=admin_only, historize=historize, template=template, pattern=pattern, flags=flags, matchall=matchall, prefixed=prefixed, flow_only=flow_only, re_cmd_name_help=re_cmd_name_help) return decorator(args[0]) if args else decorator def botmatch(*args, **kwargs): """ Decorator for regex-based message match. :param *args: The regular expression a message should match against in order to trigger the command. :param flags: The `flags` parameter which should be passed to :func:`re.compile()`. This allows the expression's behaviour to be modified, such as making it case-insensitive for example. :param matchall: By default, only the first match of the regular expression is returned (as a `re.MatchObject`). When *matchall* is `True`, all non-overlapping matches are returned (as a list of `re.MatchObject` items). :param hidden: Prevents the command from being shown by the built-in help command when `True`. :param name: The name to give to the command. Defaults to name of the function itself. :param admin_only: Only allow the command to be executed by admins when `True`. :param historize: Store the command in the history list (`!history`). This is enabled by default. :param template: The template to use when using Markdown output. :param flow_only: Flag this command to be available only when it is part of a flow. If True and hidden is None, it will switch hidden to True. For example:: @botmatch(r'^(?:Yes|No)$') def yes_or_no(self, msg, match): pass """ def decorator(func, pattern): return _tag_botcmd(func, _re=True, _arg=False, prefixed=False, hidden=kwargs.get('hidden', None), name=kwargs.get('name', func.__name__), admin_only=kwargs.get('admin_only', False), flow_only=kwargs.get('flow_only', False), historize=kwargs.get('historize', True), template=kwargs.get('template', None), pattern=pattern, flags=kwargs.get('flags', 0), matchall=kwargs.get('matchall', False)) if len(args) == 2: return decorator(*args) if len(args) == 1: return lambda f: decorator(f, args[0]) raise ValueError("botmatch: You need to pass the pattern as parameter to the decorator.") def arg_botcmd(*args, hidden: bool = None, name: str = None, admin_only: bool = False, historize: bool = True, template: str = None, flow_only: bool = False, unpack_args: bool = True, **kwargs) -> Callable[[BotPlugin, Message, Any], Any]: """ Decorator for argparse-based bot command functions https://docs.python.org/3/library/argparse.html This decorator creates an argparse.ArgumentParser and uses it to parse the commands arguments. This decorator can be used multiple times to specify multiple arguments. Any valid argparse.add_argument() parameters can be passed into the decorator. Each time this decorator is used it adds a new argparse argument to the command. :param hidden: Prevents the command from being shown by the built-in help command when `True`. :param name: The name to give to the command. Defaults to name of the function itself. :param admin_only: Only allow the command to be executed by admins when `True`. :param historize: Store the command in the history list (`!history`). This is enabled by default. :param template: The template to use when using markdown output :param flow_only: Flag this command to be available only when it is part of a flow. If True and hidden is None, it will switch hidden to True. :param unpack_args: Should the argparser arguments be "unpacked" and passed on the the bot command individually? If this is True (the default) you must define all arguments in the function separately. If this is False you must define a single argument `args` (or whichever name you prefer) to receive the result of `ArgumentParser.parse_args()`. This decorator should be applied to methods of :class:`~errbot.botplugin.BotPlugin` classes to turn them into commands that can be given to the bot. The methods will be called with the original msg and the argparse parsed arguments. These methods are expected to have a signature like the following (assuming `unpack_args=True`):: @arg_botcmd('value', type=str) @arg_botcmd('--repeat-count', dest='repeat', type=int, default=2) def repeat_the_value(self, msg, value=None, repeat=None): return value * repeat The given `msg` will be the full message object that was received, which includes data like sender, receiver, the plain-text and html body (if applicable), etc. `value` will hold the value passed in place of the `value` argument and `repeat` will hold the value passed in place of the `--repeat-count` argument. If you don't like this automatic *"unpacking"* of the arguments, you can use `unpack_args=False` like this:: @arg_botcmd('value', type=str) @arg_botcmd('--repeat-count', dest='repeat', type=int, default=2, unpack_args=False) def repeat_the_value(self, msg, args): return arg.value * args.repeat .. note:: The `unpack_args=False` only needs to be specified once, on the bottom `@args_botcmd` statement. """ argparse_args = args if len(args) >= 1 and callable(args[0]): argparse_args = args[1:] def decorator(func): if not hasattr(func, '_err_command'): err_command_parser = ArgumentParser( prog=name or func.__name__, description=func.__doc__, ) @wraps(func) def wrapper(self, msg, args): # Attempt to sanitize arguments of bad characters try: sanitizer_re = re.compile('|'.join(re.escape(ii) for ii in ARG_BOTCMD_CHARACTER_REPLACEMENTS)) args = sanitizer_re.sub(lambda mm: ARG_BOTCMD_CHARACTER_REPLACEMENTS[mm.group()], args) args = shlex.split(args) parsed_args = err_command_parser.parse_args(args) except ArgumentParseError as e: yield f"I couldn't parse the arguments; {e}" yield err_command_parser.format_usage() return except HelpRequested: yield err_command_parser.format_help() return except ValueError as ve: yield f"I couldn't parse this command; {ve}" yield err_command_parser.format_help() return if unpack_args: func_args = [] func_kwargs = vars(parsed_args) else: func_args = [parsed_args] func_kwargs = {} if inspect.isgeneratorfunction(func): for reply in func(self, msg, *func_args, **func_kwargs): yield reply else: yield func(self, msg, *func_args, **func_kwargs) _tag_botcmd(wrapper, _re=False, _arg=True, hidden=hidden, name=name or wrapper.__name__, admin_only=admin_only, historize=historize, template=template, flow_only=flow_only, command_parser=err_command_parser) else: # the function has already been wrapped # alias it so we can update it's arguments below wrapper = func wrapper._err_command_parser.add_argument(*argparse_args, **kwargs) wrapper.__doc__ = wrapper._err_command_parser.format_help() fmt = wrapper._err_command_parser.format_usage() wrapper._err_command_syntax = fmt[len('usage: ') + len(wrapper._err_command_parser.prog) + 1:-1] return wrapper return decorator(args[0]) if callable(args[0]) else decorator def _tag_webhook(func, uri_rule, methods, form_param, raw): log.info(f"webhooks: Flag to bind {uri_rule} to {getattr(func, '__name__', func)}") func._err_webhook_uri_rule = uri_rule func._err_webhook_methods = methods func._err_webhook_form_param = form_param func._err_webhook_raw = raw return func def _uri_from_func(func): return r'/' + func.__name__ def webhook(*args, methods: Tuple[str] = ('POST', 'GET'), form_param: str = None, raw: bool = False) -> Callable[[BotPlugin, Any], str]: """ Decorator for webhooks :param uri_rule: The URL to use for this webhook, as per Bottle request routing syntax. For more information, see: * http://bottlepy.org/docs/dev/tutorial.html#request-routing * http://bottlepy.org/docs/dev/routing.html :param methods: A tuple of allowed HTTP methods. By default, only GET and POST are allowed. :param form_param: The key who's contents will be passed to your method's `payload` parameter. This is used for example when using the `application/x-www-form-urlencoded` mimetype. :param raw: When set to true, this overrides the request decoding (including form_param) and passes the raw http request to your method's `payload` parameter. The value of payload will be a Bottle `BaseRequest `_. This decorator should be applied to methods of :class:`~errbot.botplugin.BotPlugin` classes to turn them into webhooks which can be reached on Err's built-in webserver. The bundled *Webserver* plugin needs to be configured before these URL's become reachable. Methods with this decorator are expected to have a signature like the following:: @webhook def a_webhook(self, payload): pass """ if not args: # default uri_rule but with kwargs. return lambda func: _tag_webhook(func, _uri_from_func(func), methods=methods, form_param=form_param, raw=raw) if isinstance(args[0], str): # first param is uri_rule. return lambda func: _tag_webhook(func, args[0] if args[0] == '/' else args[0].rstrip('/'), # trailing / is also be stripped on incoming. methods=methods, form_param=form_param, raw=raw) return _tag_webhook(args[0], # naked decorator so the first parameter is a function. _uri_from_func(args[0]), methods=methods, form_param=form_param, raw=raw) def cmdfilter(*args, **kwargs): """ Decorator for command filters. This decorator should be applied to methods of :class:`~errbot.botplugin.BotPlugin` classes to turn them into command filters. These filters are executed just before the execution of a command and provide the means to add features such as custom security, logging, auditing, etc. These methods are expected to have a signature and tuple response like the following:: @cmdfilter def some_filter(self, msg, cmd, args, dry_run): \"\"\" :param msg: The original chat message. :param cmd: The command name itself. :param args: Arguments passed to the command. :param dry_run: True when this is a dry-run. Dry-runs are performed by certain commands (such as !help) to check whether a user is allowed to perform that command if they were to issue it. If dry_run is True then the plugin shouldn't actually do anything beyond returning whether the command is authorized or not. \"\"\" # If wishing to block the incoming command: return None, None, None # Otherwise pass data through to the (potential) next filter: return msg, cmd, args Note that a cmdfilter plugin *could* modify `cmd` or `args` above and send that through in order to make it appear as if the user issued a different command. """ def decorate(func): if not hasattr(func, '_err_command_filter'): # don't override generated functions func._err_command_filter = True func.catch_unprocessed = kwargs.get('catch_unprocessed', False) return func if len(args): return decorate(args[0]) return lambda func: decorate(func) def botflow(*args, **kwargs): """ Decorator for flow of commands. TODO(gbin): example / docs """ def decorate(func): if not hasattr(func, '_err_flow'): # don't override generated functions func._err_flow = True return func if len(args): return decorate(args[0]) return lambda func: decorate(func) errbot-6.1.1+ds/errbot/backend_plugin_manager.py000066400000000000000000000034541355337103200217340ustar00rootroot00000000000000import logging import sys from pathlib import Path from typing import Any, Type from errbot.plugin_info import PluginInfo from .utils import collect_roots log = logging.getLogger(__name__) class PluginNotFoundException(Exception): pass def enumerate_backend_plugins(all_plugins_paths): plugin_places = [Path(root) for root in all_plugins_paths] for path in plugin_places: plugfiles = path.glob('**/*.plug') for plugfile in plugfiles: plugin_info = PluginInfo.load(plugfile) yield plugin_info class BackendPluginManager: """ This is a one shot plugin manager for Backends and Storage plugins. """ def __init__(self, bot_config, base_module: str, plugin_name: str, base_class: Type, base_search_dir, extra_search_dirs=()): self._config = bot_config self._base_module = base_module self._base_class = base_class self.plugin_info = None all_plugins_paths = collect_roots((base_search_dir, extra_search_dirs)) for potential_plugin in enumerate_backend_plugins(all_plugins_paths): if potential_plugin.name == plugin_name: self.plugin_info = potential_plugin return raise PluginNotFoundException(f'Could not find the plugin named {plugin_name} in {all_plugins_paths}.') def load_plugin(self) -> Any: plugin_path = self.plugin_info.location.parent if plugin_path not in sys.path: sys.path.append(plugin_path) plugin_classes = self.plugin_info.load_plugin_classes(self._base_module, self._base_class) if len(plugin_classes) != 1: raise PluginNotFoundException(f'Found more that one plugin for {self._base_class}.') _, clazz = plugin_classes[0] return clazz(self._config) errbot-6.1.1+ds/errbot/backends/000077500000000000000000000000001355337103200164675ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/backends/__init__.py000066400000000000000000000000001355337103200205660ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/backends/base.py000066400000000000000000000604051355337103200177600ustar00rootroot00000000000000import io import logging import random import time from typing import Any, Mapping, BinaryIO, List, Sequence, Tuple from abc import ABC, abstractmethod from collections import deque, defaultdict log = logging.getLogger(__name__) class Identifier(ABC): """This is just use for type hinting representing the Identifier contract, NEVER TRY TO SUBCLASS IT OUTSIDE OF A BACKEND, it is just here to show you what you can expect from an Identifier. To get an instance of a real identifier, always use the properties from Message (to, from) or self.build_identifier to make an identifier from a String. The semantics is anything you can talk to: Person, Room, RoomOccupant etc. """ pass class Person(Identifier): """This is just use for type hinting representing the Identifier contract, NEVER TRY TO SUBCLASS IT OUTSIDE OF A BACKEND, it is just here to show you what you can expect from an Identifier. To get an instance of a real identifier, always use the properties from Message (to, from) or self.build_identifier to make an identifier from a String. """ @property @abstractmethod def person(self) -> str: """ :return: a backend specific unique identifier representing the person you are talking to. """ pass @property @abstractmethod def client(self) -> str: """ :return: a backend specific unique identifier representing the device or client the person is using to talk. """ pass @property @abstractmethod def nick(self) -> str: """ :return: a backend specific nick returning the nickname of this person if available. """ pass @property @abstractmethod def aclattr(self) -> str: """ :return: returns the unique identifier that will be used for ACL matches. """ pass @property @abstractmethod def fullname(self) -> str: """ Some backends have the full name of a user. :return: the fullname of this user if available. """ pass class RoomOccupant(Identifier): @property @abstractmethod def room(self) -> Any: # this is oom defined below """ Some backends have the full name of a user. :return: the fullname of this user if available. """ pass class Room(Identifier): """ This class represents a Multi-User Chatroom. """ def join(self, username: str = None, password: str = None) -> None: """ Join the room. If the room does not exist yet, this will automatically call :meth:`create` on it first. """ raise NotImplementedError("It should be implemented specifically for your backend") def leave(self, reason: str = None) -> None: """ Leave the room. :param reason: An optional string explaining the reason for leaving the room. """ raise NotImplementedError("It should be implemented specifically for your backend") def create(self) -> None: """ Create the room. Calling this on an already existing room is a no-op. """ raise NotImplementedError("It should be implemented specifically for your backend") def destroy(self) -> None: """ Destroy the room. Calling this on a non-existing room is a no-op. """ raise NotImplementedError("It should be implemented specifically for your backend") @property def exists(self) -> bool: """ Boolean indicating whether this room already exists or not. :getter: Returns `True` if the room exists, `False` otherwise. """ raise NotImplementedError("It should be implemented specifically for your backend") @property def joined(self) -> bool: """ Boolean indicating whether this room has already been joined. :getter: Returns `True` if the room has been joined, `False` otherwise. """ raise NotImplementedError("It should be implemented specifically for your backend") @property def topic(self) -> str: """ The room topic. :getter: Returns the topic (a string) if one is set, `None` if no topic has been set at all. .. note:: Back-ends may return an empty string rather than `None` when no topic has been set as a network may not differentiate between no topic and an empty topic. :raises: :class:`~MUCNotJoinedError` if the room has not yet been joined. """ raise NotImplementedError("It should be implemented specifically for your backend") @topic.setter def topic(self, topic: str) -> None: """ Set the room's topic. :param topic: The topic to set. """ raise NotImplementedError("It should be implemented specifically for your backend") @property def occupants(self) -> List[RoomOccupant]: """ The room's occupants. :getter: Returns a list of occupant identities. :raises: :class:`~MUCNotJoinedError` if the room has not yet been joined. """ raise NotImplementedError("It should be implemented specifically for your backend") def invite(self, *args) -> None: """ Invite one or more people into the room. :*args: One or more identifiers to invite into the room. """ raise NotImplementedError("It should be implemented specifically for your backend") class RoomError(Exception): """General exception class for MUC-related errors""" class RoomNotJoinedError(RoomError): """Exception raised when performing MUC operations that require the bot to have joined the room""" class RoomDoesNotExistError(RoomError): """Exception that is raised when performing an operation on a room that doesn't exist""" class UserDoesNotExistError(Exception): """Exception that is raised when performing an operation on a user that doesn't exist""" class Message(object): """ A chat message. This class represents chat messages that are sent or received by the bot. """ def __init__(self, body: str = '', frm: Identifier = None, to: Identifier = None, parent: 'Message' = None, delayed: bool = False, partial: bool = False, extras: Mapping = None, flow=None): """ :param body: The markdown body of the message. :param extras: Extra data attached by a backend :param flow: The flow in which this message has been triggered. :param parent: The parent message of this message in a thread. (Not supported by all backends) :param partial: Indicates whether the message was obtained by breaking down the message to fit the ``MESSAGE_SIZE_LIMIT``. """ self._body = body self._from = frm self._to = to self._parent = parent self._delayed = delayed self._extras = extras or dict() self._flow = flow self._partial = partial # Convenience shortcut to the flow context if flow: self.ctx = flow.ctx else: self.ctx = {} def clone(self): return Message(body=self._body, frm=self._from, to=self._to, parent=self._parent, delayed=self._delayed, partial=self._partial, extras=self._extras, flow=self._flow) @property def to(self) -> Identifier: """ Get the recipient of the message. :returns: A backend specific identifier representing the recipient. """ return self._to @to.setter def to(self, to: Identifier): """ Set the recipient of the message. :param to: An identifier from for example build_identifier(). """ self._to = to @property def frm(self) -> Identifier: """ Get the sender of the message. :returns: An :class:`~errbot.backends.base.Identifier` identifying the sender. """ return self._from @frm.setter def frm(self, from_: Identifier): """ Set the sender of the message. :param from_: An identifier from build_identifier. """ self._from = from_ @property def body(self) -> str: """ Get the plaintext body of the message. :returns: The body as a string. """ return self._body @body.setter def body(self, body: str): self._body = body @property def delayed(self) -> bool: return self._delayed @delayed.setter def delayed(self, delayed: bool): self._delayed = delayed @property def parent(self): return self._parent @parent.setter def parent(self, parent: 'Message'): self._parent = parent @property def extras(self) -> Mapping: return self._extras @property def flow(self): """ Get the conversation flow for this message. :returns: A :class:`~errbot.Flow` """ return self._from def __str__(self): return self._body @property def is_direct(self) -> bool: return isinstance(self.to, Person) @property def is_group(self) -> bool: return isinstance(self.to, Room) @property def is_threaded(self) -> bool: return self._parent is not None @property def partial(self) -> bool: return self._partial @partial.setter def partial(self, partial): self._partial = partial class Card(Message): """ Card is a special type of preformatted message. If it matches with a backend similar concept like on Slack or Hipchat it will be rendered natively, otherwise it will be sent as a regular message formatted with the card.md template. """ def __init__(self, body: str = '', frm: Identifier = None, to: Identifier = None, parent: Message = None, summary: str = None, title: str = '', link: str = None, image: str = None, thumbnail: str = None, color: str = None, fields: Tuple[Tuple[str, str]] = ()): """ Creates a Card. :param body: main text of the card in markdown. :param frm: the card is sent from this identifier. :param to: the card is sent to this identifier (Room, RoomOccupant, Person...). :param parent: the parent message this card replies to. (threads the message if the backend supports it). :param summary: (optional) One liner summary of the card, possibly collapsed to it. :param title: (optional) Title possibly linking. :param link: (optional) url the title link is pointing to. :param image: (optional) link to the main image of the card. :param thumbnail: (optional) link to an icon / thumbnail. :param color: (optional) background color or color indicator. :param fields: (optional) a tuple of (key, value) pairs. """ super().__init__(body=body, frm=frm, to=to, parent=parent) self._summary = summary self._title = title self._link = link self._image = image self._thumbnail = thumbnail self._color = color self._fields = fields @property def summary(self): return self._summary @property def title(self): return self._title @property def link(self): return self._link @property def image(self): return self._image @property def thumbnail(self): return self._thumbnail @property def color(self): return self._color @property def text_color(self): if self._color in ('black', 'blue'): return 'white' return 'black' @property def fields(self): return self._fields ONLINE = 'online' OFFLINE = 'offline' AWAY = 'away' DND = 'dnd' class Presence(object): """ This class represents a presence change for a user or a user in a chatroom. Instances of this class are passed to :meth:`~errbot.botplugin.BotPlugin.callback_presence` when the presence of people changes. """ def __init__(self, identifier: Identifier, status: str = None, message: str = None): if identifier is None: raise ValueError('Presence: identifiers is None') if status is None and message is None: raise ValueError('Presence: at least a new status or a new status message mustbe present') self._identifier = identifier self._status = status self._message = message @property def identifier(self) -> Identifier: """ Identifier for whom its status changed. It can be a RoomOccupant or a Person. :return: the person or roomOccupant """ return self._identifier @property def status(self) -> str: """ Returns the status of the presence change. It can be one of the constants ONLINE, OFFLINE, AWAY, DND, but can also be custom statuses depending on backends. It can be None if it is just an update of the status message (see get_message) """ return self._status @property def message(self) -> str: """ Returns a human readable message associated with the status if any. like : "BRB, washing the dishes" It can be None if it is only a general status update (see get_status) """ return self._message def __str__(self): response = '' if self._identifier: response += f'identifier: "{self._identifier}" ' if self._status: response += f'status: "{self._status}" ' if self._message: response += f'message: "{self._message}" ' return response def __unicode__(self): return str(self.__str__()) STREAM_WAITING_TO_START = 'pending' STREAM_TRANSFER_IN_PROGRESS = 'in progress' STREAM_SUCCESSFULLY_TRANSFERED = 'success' STREAM_PAUSED = 'paused' STREAM_ERROR = 'error' STREAM_REJECTED = 'rejected' DEFAULT_REASON = 'unknown' class Stream(io.BufferedReader): """ This class represents a stream request. Instances of this class are passed to :meth:`~errbot.botplugin.BotPlugin.callback_stream` when an incoming stream is requested. """ def __init__(self, identifier: Identifier, fsource: BinaryIO, name: str = None, size: int = None, stream_type: str = None): super().__init__(fsource) self._identifier = identifier self._name = name self._size = size self._stream_type = stream_type self._status = STREAM_WAITING_TO_START self._reason = DEFAULT_REASON self._transfered = 0 @property def identifier(self) -> Identifier: """ The identity the stream is coming from if it is an incoming request or to if it is an outgoing request. """ return self._identifier @property def name(self) -> str: """ The name of the stream/file if it has one or None otherwise. !! Be carefull of injections if you are using this name directly as a filename. """ return self._name @property def size(self) -> int: """ The expected size in bytes of the stream if it is known or None. """ return self._size @property def transfered(self) -> int: """ The currently transfered size. """ return self._transfered @property def stream_type(self) -> str: """ The mimetype of the stream if it is known or None. """ return self._stream_type @property def status(self) -> str: """ The status for this stream. """ return self._status def accept(self) -> None: """ Signal that the stream has been accepted. """ if self._status != STREAM_WAITING_TO_START: raise ValueError("Invalid state, the stream is not pending.") self._status = STREAM_TRANSFER_IN_PROGRESS def reject(self) -> None: """ Signal that the stream has been rejected. """ if self._status != STREAM_WAITING_TO_START: raise ValueError("Invalid state, the stream is not pending.") self._status = STREAM_REJECTED def error(self, reason=DEFAULT_REASON) -> None: """ An internal plugin error prevented the transfer. """ self._status = STREAM_ERROR self._reason = reason def success(self) -> None: """ The streaming finished normally. """ if self._status != STREAM_TRANSFER_IN_PROGRESS: raise ValueError("Invalid state, the stream is not in progress.") self._status = STREAM_SUCCESSFULLY_TRANSFERED def clone(self, new_fsource: BinaryIO) -> 'Stream': """ Creates a clone and with an alternative stream """ return Stream(self._identifier, new_fsource, self._name, self._size, self._stream_type) def ack_data(self, length: int) -> None: """ Acknowledge data has been transfered. """ self._transfered = length class Backend(ABC): """ Implements the basic Bot logic (logic independent from the backend) and leaves you to implement the missing parts. """ cmd_history = defaultdict(lambda: deque(maxlen=10)) # this will be a per user history MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. ' \ 'An unexpected error occurred.' def __init__(self, _): """ Those arguments will be directly those put in BOT_IDENTITY """ log.debug("Backend init.") self._reconnection_count = 0 # Increments with each failed (re)connection self._reconnection_delay = 1 # Amount of seconds the bot will sleep on the # # next reconnection attempt self._reconnection_max_delay = 600 # Maximum delay between reconnection attempts self._reconnection_multiplier = 1.75 # Delay multiplier self._reconnection_jitter = (0, 3) # Random jitter added to delay (min, max) @abstractmethod def send_message(self, msg: Message) -> None: """Should be overridden by backends with a super().send_message() call.""" @abstractmethod def change_presence(self, status: str = ONLINE, message: str = '') -> None: """Signal a presence change for the bot. Should be overridden by backends with a super().send_message() call.""" @abstractmethod def build_reply(self, msg: Message, text: str = None, private: bool = False, threaded: bool = False): """ Should be implemented by the backend """ @abstractmethod def callback_presence(self, presence: Presence) -> None: """ Implemented by errBot. """ pass @abstractmethod def callback_room_joined(self, room: Room) -> None: """ See :class:`~errbot.errBot.ErrBot` """ pass @abstractmethod def callback_room_left(self, room: Room) -> None: """ See :class:`~errbot.errBot.ErrBot` """ pass @abstractmethod def callback_room_topic(self, room: Room) -> None: """ See :class:`~errbot.errBot.ErrBot` """ pass def serve_forever(self) -> None: """ Connect the back-end to the server and serve forever. Back-ends MAY choose to re-implement this method, in which case they are responsible for implementing reconnection logic themselves. Back-ends SHOULD trigger :func:`~connect_callback()` and :func:`~disconnect_callback()` themselves after connection/disconnection. """ while True: try: if self.serve_once(): break # Truth-y exit from serve_once means shutdown was requested except KeyboardInterrupt: log.info('Interrupt received, shutting down..') break except Exception: log.exception('Exception occurred in serve_once:') log.info('Reconnecting in %d seconds (%d attempted reconnections so far).', self._reconnection_delay, self._reconnection_count) try: self._delay_reconnect() self._reconnection_count += 1 except KeyboardInterrupt: log.info('Interrupt received, shutting down..') break log.info('Trigger shutdown') self.shutdown() def _delay_reconnect(self): """Delay next reconnection attempt until a suitable back-off time has passed""" time.sleep(self._reconnection_delay) self._reconnection_delay *= self._reconnection_multiplier if self._reconnection_delay > self._reconnection_max_delay: self._reconnection_delay = self._reconnection_max_delay self._reconnection_delay += random.uniform(*self._reconnection_jitter) # nosec def reset_reconnection_count(self) -> None: """ Reset the reconnection count. Back-ends should call this after successfully connecting. """ self._reconnection_count = 0 self._reconnection_delay = 1 def build_message(self, text: str) -> Message: """ You might want to override this one depending on your backend """ return Message(body=text) # ##### HERE ARE THE SPECIFICS TO IMPLEMENT PER BACKEND @abstractmethod def prefix_groupchat_reply(self, message: Message, identifier: Identifier): """ Patches message with the conventional prefix to ping the specific contact For example: @gbin, you forgot the milk ! """ @abstractmethod def build_identifier(self, text_representation: str) -> Identifier: pass def is_from_self(self, msg: Message) -> bool: """ Needs to be overridden to check if the incoming message is from the bot itself. :param msg: The incoming message. :return: True if the message is coming from the bot. """ # Default implementation (XMPP-like check using an extra config). # Most of the backends should have a better way to determine this. return (msg.is_direct and msg.frm == self.bot_identifier) or \ (msg.is_group and msg.frm.nick == self.bot_config.CHATROOM_FN) def serve_once(self) -> None: """ Connect the back-end to the server and serve a connection once (meaning until disconnected for any reason). Back-ends MAY choose not to implement this method, IF they implement a custom :func:`~serve_forever`. This function SHOULD raise an exception or return a value that evaluates to False in order to signal something went wrong. A return value that evaluates to True will signal the bot that serving is done and a shut-down is requested. """ raise NotImplementedError("It should be implemented specifically for your backend") def connect(self) -> Any: """Connects the bot to server or returns current connection """ @abstractmethod def query_room(self, room: str) -> Room: """ Query a room for information. :param room: The room to query for. :returns: An instance of :class:`~Room`. """ @abstractmethod def connect_callback(self) -> None: pass @abstractmethod def disconnect_callback(self) -> None: pass @property @abstractmethod def mode(self) -> str: pass @property @abstractmethod def rooms(self) -> Sequence[Room]: """ Return a list of rooms the bot is currently in. :returns: A list of :class:`~errbot.backends.base.Room` instances. """ errbot-6.1.1+ds/errbot/backends/graphic.plug000066400000000000000000000001561355337103200207770ustar00rootroot00000000000000[Core] Name = Graphic Module = graphic [Documentation] Description = This is the graphic backend for Errbot. errbot-6.1.1+ds/errbot/backends/graphic.py000066400000000000000000000220351355337103200204600ustar00rootroot00000000000000import logging import os import re import sys from jinja2 import Environment, FileSystemLoader import errbot from errbot.backends.base import Message, ONLINE from errbot.backends.text import TextBackend # we use that as we emulate MUC there already from errbot.rendering import xhtml CARD_TMPL = Environment(loader=FileSystemLoader(os.path.dirname(__file__)), autoescape=True).get_template('graphic_card.html') log = logging.getLogger(__name__) try: from PySide import QtCore, QtGui, QtWebKit from PySide.QtGui import QCompleter from PySide.QtCore import Qt except ImportError: log.exception("Could not start the graphical backend") log.fatal(""" To install graphic support use: pip install errbot[graphic] """) sys.exit(-1) class CommandBox(QtGui.QPlainTextEdit, object): newCommand = QtCore.Signal(str) def reset_history(self): self.history_index = len(self.history) def __init__(self, history, commands, prefix): self.history_index = 0 self.history = history self.reset_history() self.prefix = prefix super().__init__() # Autocompleter self.completer = None self.updateCompletion(commands) self.autocompleteStart = None def updateCompletion(self, commands): if self.completer: self.completer.activated.disconnect(self.onAutoComplete) self.completer = QCompleter([(self.prefix + name).replace('_', ' ', 1) for name in commands], self) self.completer.setCaseSensitivity(Qt.CaseInsensitive) self.completer.setWidget(self) self.completer.activated.connect(self.onAutoComplete) def onAutoComplete(self, text): # Select the text from autocompleteStart until the current cursor cursor = self.textCursor() cursor.setPosition(0, cursor.KeepAnchor) # Replace it with the selected text cursor.insertText(text) self.autocompleteStart = None # noinspection PyStringFormat def keyPressEvent(self, *args, **kwargs): event = args[0] key = event.key() ctrl = event.modifiers() == QtCore.Qt.ControlModifier alt = event.modifiers() == QtCore.Qt.AltModifier # don't disturb the completer behavior if self.completer.popup().isVisible() and key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab, Qt.Key_Backtab): event.ignore() return if self.autocompleteStart is not None and not event.text().isalnum() and \ not (key == Qt.Key_Backspace and self.textCursor().position() > self.autocompleteStart): self.completer.popup().hide() self.autocompleteStart = None if key == Qt.Key_Space and (ctrl or alt): # Pop-up the autocompleteList rect = self.cursorRect(self.textCursor()) rect.setSize(QtCore.QSize(300, 500)) self.autocompleteStart = self.textCursor().position() self.completer.complete(rect) # The popup is positioned in the next if block if self.autocompleteStart: prefix = self.toPlainText() cur = self.textCursor() cur.setPosition(self.autocompleteStart) self.completer.setCompletionPrefix(prefix) # Select the first one of the matches self.completer.popup().setCurrentIndex(self.completer.completionModel().index(0, 0)) if key == Qt.Key_Up: if self.history_index > 0: self.history_index -= 1 self.setPlainText(f'{self.prefix}{" ".join(self.history[self.history_index])}') return elif key == Qt.Key_Down: if self.history_index < len(self.history) - 1: self.history_index += 1 self.setPlainText(f'{self.prefix}{" ".join(self.history[self.history_index])}') return elif key == QtCore.Qt.Key_Return and (ctrl or alt): self.newCommand.emit(self.toPlainText()) self.reset_history() super().keyPressEvent(*args, **kwargs) urlfinder = re.compile(r'http([^.\s]+\.[^.\s]*)+[^.\s]{2,}') backends_path = os.path.join(os.path.dirname(errbot.__file__), 'backends') images_path = os.path.join(backends_path, 'images') prompt_path = os.path.join(images_path, 'prompt.svg') icon_path = os.path.join(images_path, 'errbot.svg') bg_path = os.path.join(images_path, 'errbot-bg.svg') style_path = os.path.join(backends_path, 'styles') css_path = os.path.join(style_path, 'style.css') demo_css_path = os.path.join(style_path, 'style-demo.css') TOP = f'' BOTTOM = '' class ChatApplication(QtGui.QApplication): newAnswer = QtCore.Signal(str) def __init__(self, bot): self.bot = bot super().__init__(sys.argv) self.mainW = QtGui.QWidget() self.mainW.setWindowTitle('Errbot') self.mainW.setWindowIcon(QtGui.QIcon(icon_path)) vbox = QtGui.QVBoxLayout() help_label = QtGui.QLabel("ctrl or alt+space for autocomplete -- ctrl or alt+Enter to send your message") self.input = CommandBox(bot.cmd_history[str(bot.user)], bot.all_commands, bot.bot_config.BOT_PREFIX) self.demo_mode = hasattr(bot.bot_config, 'TEXT_DEMO_MODE') and bot.bot_config.TEXT_DEMO_MODE font = QtGui.QFont("Arial", QtGui.QFont.Bold) font.setPointSize(30 if self.demo_mode else 15) self.input.setFont(font) self.output = QtWebKit.QWebView() css = demo_css_path if self.demo_mode else css_path self.output.settings().setUserStyleSheetUrl(QtCore.QUrl.fromLocalFile(css)) # init webpage self.buffer = "" self.update_webpage() # layout vbox.addWidget(self.output) vbox.addWidget(self.input) vbox.addWidget(help_label) self.mainW.setLayout(vbox) # setup web view to open liks in external browser self.output.page().setLinkDelegationPolicy(QtWebKit.QWebPage.DelegateAllLinks) # connect signals/slots self.output.page().mainFrame().contentsSizeChanged.connect(self.scroll_output_to_bottom) self.output.page().linkClicked.connect(QtGui.QDesktopServices.openUrl) self.input.newCommand.connect(lambda text: bot.send_command(text)) self.newAnswer.connect(self.new_message) if self.demo_mode: self.mainW.showFullScreen() else: self.mainW.show() def new_message(self, text, receiving=True): size = 50 if self.demo_mode else 25 user = f'' bot = f'' self.buffer += f'
{bot if receiving else user}' \ f'
{text}
' self.update_webpage() def update_webpage(self): self.output.setHtml(TOP + self.buffer + BOTTOM) def scroll_output_to_bottom(self): self.output.page().mainFrame().scroll(0, self.output.page().mainFrame().scrollBarMaximum(QtCore.Qt.Vertical)) def update_commands(self, commands): self.input.updateCompletion(commands) class GraphicBackend(TextBackend): def __init__(self, config): super().__init__(config) # create window and components self.md = xhtml() self.app = ChatApplication(self) def connect_callback(self): super().connect_callback() self.app.update_commands(self.all_commands) def send_command(self, text): self.app.new_message(text, False) msg = Message(text) msg.frm = self.user msg.to = self.bot_identifier # To me only self.callback_message(msg) # implements the mentions. mentioned = [self.build_identifier(word[1:]) for word in re.findall(r"@[\w']+", text) if word.startswith('@')] if mentioned: self.callback_mention(msg, mentioned) self.app.input.clear() def build_message(self, text): msg = Message(text) msg.frm = self.bot_identifier return msg # rebuild a pure html snippet to include directly in the console html def send_message(self, msg): if hasattr(msg, 'body') and msg.body and not msg.body.isspace(): content = self.md.convert(msg.body) log.debug("html:\n%s", content) self.app.newAnswer.emit(content) def send_card(self, card): self.app.newAnswer.emit(CARD_TMPL.render(card=card)) def change_presence(self, status: str = ONLINE, message: str = '') -> None: pass def serve_forever(self): self.connect_callback() # notify that the connection occured try: self.app.exec_() finally: self.disconnect_callback() self.shutdown() exit(0) @property def mode(self): return 'graphic' def prefix_groupchat_reply(self, message, identifier): super().prefix_groupchat_reply(message, identifier) message.body = f'@{identifier.nick} {message.body}' errbot-6.1.1+ds/errbot/backends/graphic_card.html000066400000000000000000000036601355337103200217700ustar00rootroot00000000000000{# Template for a card for the graphic backend. #} {% if card.summary %} {% endif %} {% if card.link or card.thumbnail or card.title %} {% endif %} {% if card.image or card.body %} {% if card.image %} {% endif %} {% if card.body %} {% endif %} {% endif %} {% if card.fields %} {% endif %}
{{ card.summary }}
{% if card.link %} {% endif %} {% if card.thumbnail %} {% endif %} {% if card.title %} {{ card.title }} {% endif %} {% if card.link %} {% endif %}
{{ card.body }}
{% for name, _ in card.fields %} {% endfor %} {% for _, value in card.fields %} {% endfor %}
{{ name }}
{{ value }}
errbot-6.1.1+ds/errbot/backends/hipchat.plug000066400000000000000000000001561355337103200210020ustar00rootroot00000000000000[Core] Name = Hipchat Module = hipchat [Documentation] Description = This is the hipchat backend for Errbot. errbot-6.1.1+ds/errbot/backends/hipchat.py000066400000000000000000000516731355337103200204750ustar00rootroot00000000000000import logging import re import sys from functools import lru_cache from errbot.backends.base import Room, RoomDoesNotExistError, RoomOccupant, Stream from errbot.backends.xmpp import XMPPRoomOccupant, XMPPBackend, XMPPConnection, split_identifier from markdown import Markdown from markdown.extensions.extra import ExtraExtension from markdown.extensions import Extension from markdown.treeprocessors import Treeprocessor from email.mime.multipart import MIMEMultipart import email.mime.application import requests log = logging.getLogger(__name__) try: import hypchat except ImportError: log.exception("Could not start the HipChat backend") log.fatal( "You need to install the hipchat support in order to use the HipChat backend.\n " "You should be able to install this package using:\n" "pip install errbot[hipchat]" ) sys.exit(1) COLORS = { 'blue': 'purple', 'white': 'gray', 'black': 'gray', } # best effort to map errbot colors to hipchat ones, # Rendering customizations class HipchatTreeprocessor(Treeprocessor): def run(self, root): def recurse_patch(element): t = element.tag if t == 'h1': element.tag = 'strong' element.text = element.text.upper() elif t == 'h2': element.tag = 'em' elif t in ('h3', 'h4', 'h5', 'h6'): element.tag = 'p' elif t == 'hr': element.tag = 'p' element.text = '─' * 80 for elems in element: recurse_patch(elems) recurse_patch(root) class HipchatExtension(Extension): """Removes the unsupported html tags from hipchat""" def extendMarkdown(self, md, md_globals): md.registerExtension(self) md.treeprocessors.add("hipchat stripper", HipchatTreeprocessor(), '" def __str__(self): return self.room['xmpp_jid'] def join(self, username=None, password=None): """ Join the room. If the room does not exist yet, this will automatically call :meth:`create` on it first. """ if not self.exists: self.create() room = self.jid self.xep0045.joinMUC(room, username, password=password, wait=True) self._bot.conn.add_event_handler(f'muc::{room}::got_online', self._bot.user_joined_chat) self._bot.conn.add_event_handler(f'muc::{room}::got_offline', self._bot.user_left_chat) self._bot.callback_room_joined(self) log.info('Joined room %s.', self.name) def leave(self, reason=None): """ Leave the room. :param reason: An optional string explaining the reason for leaving the room """ if reason is None: reason = "" room = self.jid try: self.xep0045.leaveMUC(room=room, nick=self.xep0045.ourNicks[room], msg=reason) self._bot.conn.del_event_handler(f'muc::{room}::got_online', self._bot.user_joined_chat) self._bot.conn.del_event_handler(f'muc::{room}::got_offline', self._bot.user_left_chat) log.info('Left room %s', self) self._bot.callback_room_left(self) except KeyError: log.debug('Trying to leave %s while not in this room.', self) def create(self, privacy='public', guest_access=False): """ Create the room. Calling this on an already existing room is a no-op. :param privacy: Whether the room is available for access by other users or not. Valid values are "public" and "private". :param guest_access: Whether or not to enable guest access for this room. """ if self.exists: log.debug('Tried to create the room %s, but it has already been created', self) else: self.hypchat.create_room( name=self.name, privacy=privacy, guest_access=guest_access ) log.info('Created room %s.', self) def destroy(self): """ Destroy the room. Calling this on a non-existing room is a no-op. """ try: self.room.delete() log.info('Destroyed room %s.', self) except RoomDoesNotExistError: log.debug("Can't destroy room %s, it doesn't exist.", self) @property def exists(self): """ Boolean indicating whether this room already exists or not. :getter: Returns `True` if the room exists, `False` otherwise. """ try: self.hypchat.get_room(self.name) return True except hypchat.requests.HttpNotFound: return False @property def joined(self): """ Boolean indicating whether this room has already been joined or not. :getter: Returns `True` if the room has been joined, `False` otherwise. """ return self.jid in self.xep0045.getJoinedRooms() @property def topic(self): """ The room topic. :getter: Returns the topic (a string) if one is set, `None` if no topic has been set at all. """ return self.room['topic'] @topic.setter def topic(self, topic): """ Set the room's topic. :param topic: The topic to set. """ self.room.topic(topic) log.debug('Changed topic of %s to %s', self, topic) @property def occupants(self): """ The room's occupants. :getter: Returns a list of :class:`~HipChatMUCOccupant` instances. """ participants = self.room.participants(expand='items')['items'] occupants = [] for p in participants: occupants.append(HipChatRoomOccupant(hipchat_user=p)) return occupants def invite(self, *args): """ Invite one or more people into the room. :param args: One or more people to invite into the room. May be the mention name (beginning with an @) or "FirstName LastName" of the user you wish to invite. """ room = self.room users = self._bot.conn.users for person in args: try: if person.startswith('@'): user = [u for u in users if u['mention_name'] == person[1:]][0] else: user = [u for u in users if u['name'] == person][0] except IndexError: logging.warning('No user by the name of %s found.', person) else: if room['privacy'] == 'private': room.members().add(user) log.info('Added %s to private room %s.', user['name'], self) room.invite(user, 'No reason given.') log.info('Invited %s to %s.', person, self) def notify(self, message, color=None, notify=False, message_format=None): """ Send a notification to a room. See the `HipChat API documentation `_ for more info. """ self.room.notification(message=message, color=color, notify=notify, format=message_format) class HipchatClient(XMPPConnection): def __init__(self, *args, **kwargs): self.token = kwargs.pop('token') self.endpoint = kwargs.pop('endpoint') self._cached_users = None verify = kwargs.pop('verify') if verify is None: verify = True if self.endpoint is None: self.hypchat = hypchat.HypChat(self.token, verify=verify) else: # We could always pass in the endpoint, with a default value if it's # None, but this way we support hypchat<0.18 self.hypchat = hypchat.HypChat(self.token, endpoint=self.endpoint, verify=verify) super().__init__(*args, **kwargs) @property def users(self): """ A list of all the users. See also: https://www.hipchat.com/docs/apiv2/method/get_all_users """ if not self._cached_users: result = self.hypchat.users(guests=True) users = result['items'] next_link = 'next' in result['links'] while next_link: result = result.next() users += result['items'] next_link = 'next' in result['links'] self._cached_users = users return self._cached_users class HipchatBackend(XMPPBackend): room_factory = HipChatRoom roomoccupant_factory = HipChatRoomOccupant def __init__(self, config): self.api_token = config.BOT_IDENTITY['token'] self.api_endpoint = config.BOT_IDENTITY.get('endpoint', None) self.api_verify = config.BOT_IDENTITY.get('verify', True) self.md = hipchat_html() super().__init__(config) def create_connection(self): # HipChat connections time out with the default keepalive interval # so use a lower value that is known to work, but only if the user # does not specify their own value in their config. if self.keepalive is None: self.keepalive = 60 return HipchatClient( jid=self.jid, password=self.password, feature=self.feature, keepalive=self.keepalive, ca_cert=self.ca_cert, token=self.api_token, endpoint=self.api_endpoint, server=self.server, verify=self.api_verify, ) def _build_room_occupant(self, txtrep): node, domain, resource = split_identifier(txtrep) return self.roomoccupant_factory(node, domain, resource, self.query_room(node + '@' + domain), aclattr=self._find_user(resource, 'name')) def callback_message(self, msg): super().callback_message(msg) possible_mentions = re.findall(r'@\w+', msg.body) people = list( filter(None.__ne__, [self._find_user(mention[1:], 'mention_name') for mention in possible_mentions]) ) if people: self.callback_mention(msg, people) @property def mode(self): return 'hipchat' def rooms(self): """ Return a list of rooms the bot is currently in. :returns: A list of :class:`~HipChatRoom` instances. """ xep0045 = self.conn.client.plugin['xep_0045'] rooms = {} # Build a mapping of xmpp_jid->name for easy reference for room in self.conn.hypchat.rooms(expand='items').contents(): rooms[room['xmpp_jid']] = room['name'] joined_rooms = [] for room in xep0045.getJoinedRooms(): try: joined_rooms.append(HipChatRoom(rooms[room], self)) except KeyError: pass return joined_rooms @lru_cache(1024) def query_room(self, room): """ Query a room for information. :param room: The name (preferred) or XMPP JID of the room to query for. :returns: An instance of :class:`~HipChatRoom`. """ if room.endswith('@conf.hipchat.com') or room.endswith('@conf.btf.hipchat.com'): log.debug("Room specified by JID, looking up room name") rooms = self.conn.hypchat.rooms(expand='items').contents() try: name = [r['name'] for r in rooms if r['xmpp_jid'] == room][0] except IndexError: raise RoomDoesNotExistError(f'No room with JID {room} found.') log.info('Found %s to be the room %s, consider specifying this directly.', room, name) else: name = room return HipChatRoom(name, self) def build_reply(self, msg, text=None, private=False, threaded=False): response = super().build_reply(msg=msg, text=text, private=private, threaded=threaded) if msg.is_group and msg.frm == response.to: # HipChat violates the XMPP spec :( This results in a valid XMPP JID # but HipChat mangles them into stuff like # "132302_961351@chat.hipchat.com/none||proxy|pubproxy-b100.hipchat.com|5292" # so we request the user's proper JID through their API and use that here # so that private responses originating from a room (IE, DIVERT_TO_PRIVATE) # work correctly. response.to = self._find_user(response.to.client, 'name') return response def send_card(self, card): if isinstance(card.to, RoomOccupant): card.to = card.to.room if not card.is_group: raise ValueError('Private notifications/cards are impossible to send on 1 to 1 messages on hipchat.') log.debug('room id = %s', card.to) room = self.query_room(str(card.to)).room data = {'message': '-' if not card.body else self.md.convert(card.body), 'notify': False, 'message_format': 'html'} if card.color: data['color'] = COLORS[card.color] if card.color in COLORS else card.color hcard = {'id': f'FF{card.__hash__():0.16X}'} # Only title is supported all across the types. if card.title: hcard['title'] = card.title else: hcard['title'] = ' ' # title is mandatory, more that 1 chr. # Go from the most restrictive type to the less resctrictive to find the most appropriate. if card.image and not card.summary and not card.fields and not card.link: hcard['style'] = 'image' hcard['thumbnail'] = {'url': card.image if not card.thumbnail else card.thumbnail} hcard['url'] = card.image if card.body: data['message'] = card.body # We don't have a card body field so retrofit it to the main body. elif card.link and not card.summary and not card.fields: hcard['style'] = 'link' hcard['url'] = card.link if card.thumbnail: hcard['icon'] = {'url': card.thumbnail} if card.image: hcard['thumbnail'] = {'url': card.image} if card.body: hcard['description'] = card.body else: hcard['style'] = 'application' hcard['format'] = 'medium' if card.image and card.thumbnail: log.warning('Hipchat cannot display this card with an image.' 'Remove summary, fields and/or possibly link to fallback to an hichat link or ' 'an image style card.') if card.image or card.thumbnail: hcard['icon'] = {'url': card.thumbnail if card.thumbnail else card.image} if card.body: hcard['description'] = card.body if card.summary: hcard['activity'] = {'html': card.summary} if card.fields: hcard['attributes'] = [{'label': key, 'value': {'label': value, 'style': 'lozenge-complete'}} for key, value in card.fields] if card.link: hcard['url'] = card.link data['card'] = hcard log.debug("Sending request:" + str(data)) room._requests.post(room.url + '/notification', data=data) # noqa def send_stream_request(self, identifier, fsource, name='file.txt', size=None, stream_type=None): """Starts a file transfer. note, fsource used to make the stream needs to be in open/rb state """ stream = Stream(identifier=identifier, fsource=fsource, name=name, size=size, stream_type=stream_type) result = self.thread_pool.apply_async(self._hipchat_upload, (stream,)) log.debug('Response from server: %s', result.get(timeout=10)) return stream def _hipchat_upload(self, stream): """ Uploads file in a stream """ try: stream.accept() room = self.query_room(str(stream.identifier)).room headers = { 'Authorization': f'Bearer {self.api_token}', 'Accept-Charset': 'UTF-8', 'Content-Type': 'multipart/related', } raw_body = MIMEMultipart('related') img = email.mime.application.MIMEApplication(stream.read()) img.add_header( 'Content-Disposition', 'attachment', name='file', filename=stream.name ) raw_body.attach(img) raw_headers, body = raw_body.as_string().split('\n\n', 1) boundary = re.search('boundary="([^\"]*)"', raw_headers).group(1) headers['Content-Type'] = f'multipart/related; boundary="{boundary}"' resp = requests.post(room.url + '/share/file', headers=headers, data=body) log.info('Request ok: %s.', resp.ok) if resp.ok: log.info('Request status: %s.', resp.status_code) stream.success() else: log.error('Request status: %s.', resp.status_code) log.error('Request reason: %s.', resp.reason) log.error('Request text: %s.', resp.text) stream.error() except Exception: log.exception(f'Upload of {stream.name} to {stream.identifier.channelname} failed.') @lru_cache(1024) def _find_user(self, name, criteria): """ Find a specific hipchat user with a simple criteria like 'name' or 'mention_name' and returns its jid. :param name: the value you seek. :param criteria: 'name' or 'mention_name' :return: the matching XMPPPerson or None if not found. """ users = [u for u in self.conn.users if u[criteria] == name] if not users: log.debug('Failed to find user %s', name) return None userdetail = self.conn.hypchat.get_user(users[0]['id']) identifier = self.build_identifier(userdetail['xmpp_jid']) return identifier def prefix_groupchat_reply(self, message, identifier): message.body = f'@{identifier.nick}: {message.body}' def __hash__(self): return 0 # it is a singleton anyway errbot-6.1.1+ds/errbot/backends/images/000077500000000000000000000000001355337103200177345ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/backends/images/errbot-bg.svg000066400000000000000000000167021355337103200223460ustar00rootroot00000000000000 image/svg+xmlerrbot-6.1.1+ds/errbot/backends/images/errbot.svg000066400000000000000000000200201355337103200217440ustar00rootroot00000000000000 image/svg+xmlerrbot-6.1.1+ds/errbot/backends/irc.plug000066400000000000000000000001421355337103200201320ustar00rootroot00000000000000[Core] Name = IRC Module = irc [Documentation] Description = This is the IRC backend for Errbot. errbot-6.1.1+ds/errbot/backends/irc.py000066400000000000000000000647171355337103200176350ustar00rootroot00000000000000from __future__ import absolute_import import logging import sys import threading import subprocess import struct import re from markdown import Markdown from markdown.extensions.extra import ExtraExtension from errbot.backends.base import Message, Room, RoomError, \ RoomNotJoinedError, Stream, \ RoomOccupant, ONLINE, Person from errbot.core import ErrBot from errbot.utils import rate_limited from errbot.rendering.ansiext import AnsiExtension, enable_format, \ CharacterTable, NSC log = logging.getLogger(__name__) IRC_CHRS = CharacterTable(fg_black=NSC('\x0301'), fg_red=NSC('\x0304'), fg_green=NSC('\x0303'), fg_yellow=NSC('\x0308'), fg_blue=NSC('\x0302'), fg_magenta=NSC('\x0306'), fg_cyan=NSC('\x0310'), fg_white=NSC('\x0300'), fg_default=NSC('\x03'), bg_black=NSC('\x03,01'), bg_red=NSC('\x03,04'), bg_green=NSC('\x03,03'), bg_yellow=NSC('\x03,08'), bg_blue=NSC('\x03,02'), bg_magenta=NSC('\x03,06'), bg_cyan=NSC('\x03,10'), bg_white=NSC('\x03,00'), bg_default=NSC('\x03,'), fx_reset=NSC('\x03'), fx_bold=NSC('\x02'), fx_italic=NSC('\x1D'), fx_underline=NSC('\x1F'), fx_not_italic=NSC('\x0F'), fx_not_underline=NSC('\x0F'), fx_normal=NSC('\x0F'), fixed_width='', end_fixed_width='', inline_code='', end_inline_code='') IRC_NICK_REGEX = r'[a-zA-Z\[\]\\`_\^\{\|\}][a-zA-Z0-9\[\]\\`_\^\{\|\}-]+' IRC_MESSAGE_SIZE_LIMIT = 510 try: import irc.connection from irc.client import ServerNotConnectedError, NickMask from irc.bot import SingleServerIRCBot except ImportError: log.fatal("""You need the IRC support to use IRC, you can install it with: pip install errbot[IRC] """) sys.exit(-1) def irc_md(): """This makes a converter from markdown to mirc color format. """ md = Markdown(output_format='irc', extensions=[ExtraExtension(), AnsiExtension()]) md.stripTopLevelTags = False return md class IRCPerson(Person): def __init__(self, mask): self._nickmask = NickMask(mask) @property def nick(self): return self._nickmask.nick @property def user(self): return self._nickmask.user @property def host(self): return self._nickmask.host # generic compatibility person = nick @property def client(self): return self._nickmask.userhost @property def fullname(self): # TODO: this should be possible to get return None @property def aclattr(self): return IRCBackend.aclpattern.format(nick=self._nickmask.nick, user=self._nickmask.user, host=self._nickmask.host) def __unicode__(self): return str(self._nickmask) def __str__(self): return self.__unicode__() def __eq__(self, other): if not isinstance(other, IRCPerson): log.warning("Weird you are comparing an IRCPerson to a %s.", type(other)) return False return self.person == other.person class IRCRoomOccupant(IRCPerson, RoomOccupant): def __init__(self, mask, room): super().__init__(mask) self._room = room @property def room(self): return self._room def __unicode__(self): return self._nickmask def __str__(self): return self.__unicode__() def __repr__(self): return f'<{self.__unicode__()} - {super().__repr__()}>' class IRCRoom(Room): """ Represent the specifics of a IRC Room/Channel. This lifecycle of this object is: - Created in IRCConnection.on_join - The joined status change in IRCConnection on_join/on_part - Deleted/destroyed in IRCConnection.on_disconnect """ def __init__(self, room, bot): self._bot = bot self.room = room self.connection = self._bot.conn.connection self._topic_lock = threading.Lock() self._topic = None def __unicode__(self): return self.room def __str__(self): return self.__unicode__() def __repr__(self): return f"<{self.__unicode__()} - {super().__repr__()}>" def cb_set_topic(self, current_topic): """ Store the current topic for this room. This method is called by the IRC backend when a `currenttopic`, `topic` or `notopic` IRC event is received to store the topic set for this channel. This function is not meant to be executed by regular plugins. To get or set """ with self._topic_lock: self._topic = current_topic def join(self, username=None, password=None): """ Join the room. If the room does not exist yet, this will automatically call :meth:`create` on it first. """ if username is not None: log.debug("Ignored username parameter on join(), it is unsupported on this back-end.") if password is None: password = "" # nosec self.connection.join(self.room, key=password) log.info('Joined room %s.', self.room) def leave(self, reason=None): """ Leave the room. :param reason: An optional string explaining the reason for leaving the room """ if reason is None: reason = "" self.connection.part(self.room, reason) log.info('Leaving room %s with reason %s.', self.room, reason if reason is not None else '') def create(self): """ Not supported on this back-end. Will join the room to ensure it exists, instead. """ logging.warning('IRC back-end does not support explicit creation, joining room instead to ensure it exists.') self.join() def destroy(self): """ Not supported on IRC, will raise :class:`~errbot.backends.base.RoomError`. """ raise RoomError('IRC back-end does not support destroying rooms.') @property def exists(self): """ Boolean indicating whether this room already exists or not. :getter: Returns `True` if the room exists, `False` otherwise. """ logging.warning('IRC back-end does not support determining if a room exists. ' 'Returning the result of joined instead.') return self.joined @property def joined(self): """ Boolean indicating whether this room has already been joined. :getter: Returns `True` if the room has been joined, `False` otherwise. """ return self.room in self._bot.conn.channels.keys() @property def topic(self): """ The room topic. :getter: Returns the topic (a string) if one is set, `None` if no topic has been set at all. """ if not self.joined: raise RoomNotJoinedError('Must join the room to get the topic.') with self._topic_lock: return self._topic @topic.setter def topic(self, topic): """ Set the room's topic. :param topic: The topic to set. """ if not self.joined: raise RoomNotJoinedError('Must join the room to set the topic.') self.connection.topic(self.room, topic) @property def occupants(self): """ The room's occupants. :getter: Returns a list of occupants. :raises: :class:`~MUCNotJoinedError` if the room has not yet been joined. """ occupants = [] try: for nick in self._bot.conn.channels[self.room].users(): occupants.append(IRCRoomOccupant(nick, room=self.room)) except KeyError: raise RoomNotJoinedError('Must be in a room in order to see occupants.') return occupants def invite(self, *args): """ Invite one or more people into the room. :*args: One or more nicks to invite into the room. """ for nick in args: self.connection.invite(nick, self.room) log.info('Invited %s to %s.', nick, self.room) def __eq__(self, other): if not isinstance(other, IRCRoom): log.warning('This is weird you are comparing an IRCRoom to a %s.', type(other)) return False return self.room == other.room class IRCConnection(SingleServerIRCBot): def __init__(self, bot, nickname, server, port=6667, ssl=False, bind_address=None, ipv6=False, password=None, username=None, nickserv_password=None, private_rate=1, channel_rate=1, reconnect_on_kick=5, reconnect_on_disconnect=5): self.use_ssl = ssl self.use_ipv6 = ipv6 self.bind_address = bind_address self.bot = bot # manually decorate functions if private_rate: self.send_private_message = rate_limited(private_rate)(self.send_private_message) if channel_rate: self.send_public_message = rate_limited(channel_rate)(self.send_public_message) self._reconnect_on_kick = reconnect_on_kick self._pending_transfers = {} self._rooms_lock = threading.Lock() self._rooms = {} self._recently_joined_to = set() self.nickserv_password = nickserv_password if username is None: username = nickname self.transfers = {} super().__init__([(server, port, password)], nickname, username, reconnection_interval=reconnect_on_disconnect) def connect(self, *args, **kwargs): # Decode all input to UTF-8, but use a replacement character for # unrecognized byte sequences # (as described at https://pypi.python.org/pypi/irc) self.connection.buffer_class.errors = 'replace' connection_factory_kwargs = {} if self.use_ssl: import ssl connection_factory_kwargs['wrapper'] = ssl.wrap_socket if self.bind_address is not None: connection_factory_kwargs['bind_address'] = self.bind_address if self.use_ipv6: connection_factory_kwargs['ipv6'] = True connection_factory = irc.connection.Factory(**connection_factory_kwargs) self.connection.connect(*args, connect_factory=connection_factory, **kwargs) def on_welcome(self, _, e): log.info("IRC welcome %s", e) # try to identify with NickServ if there is a NickServ password in the # config if self.nickserv_password: msg = f'identify {self.nickserv_password}' self.send_private_message('NickServ', msg) # Must be done in a background thread, otherwise the join room # from the ChatRoom plugin joining channels from CHATROOM_PRESENCE # ends up blocking on connect. t = threading.Thread(target=self.bot.connect_callback) t.setDaemon(True) t.start() def _pubmsg(self, e, notice=False): msg = Message(e.arguments[0], extras={'notice': notice}) room_name = e.target if room_name[0] != '#' and room_name[0] != '$': raise Exception(f'[{room_name}] is not a room') room = IRCRoom(room_name, self.bot) msg.frm = IRCRoomOccupant(e.source, room) msg.to = room msg.nick = msg.frm.nick # FIXME find the real nick in the channel self.bot.callback_message(msg) possible_mentions = re.findall(IRC_NICK_REGEX, e.arguments[0]) room_users = self.channels[room_name].users() mentions = filter(lambda x: x in room_users, possible_mentions) if mentions: mentions = [self.bot.build_identifier(mention) for mention in mentions] self.bot.callback_mention(msg, mentions) def _privmsg(self, e, notice=False): msg = Message(e.arguments[0], extras={'notice': notice}) msg.frm = IRCPerson(e.source) msg.to = IRCPerson(e.target) self.bot.callback_message(msg) def on_pubmsg(self, _, e): self._pubmsg(e) def on_privmsg(self, _, e): self._privmsg(e) def on_pubnotice(self, _, e): self._pubmsg(e, True) def on_privnotice(self, _, e): self._privmsg(e, True) def on_kick(self, _, e): if not self._reconnect_on_kick: log.info("RECONNECT_ON_KICK is 0 or None, won't try to reconnect") return log.info('Got kicked out of %s... reconnect in %d seconds... ', e.target, self._reconnect_on_kick) def reconnect_channel(name): log.info('Reconnecting to %s after having beeing kicked.', name) self.bot.query_room(name).join() t = threading.Timer(self._reconnect_on_kick, reconnect_channel, [e.target, ]) t.daemon = True t.start() def send_private_message(self, to, line): try: self.connection.privmsg(to, line) except ServerNotConnectedError: pass # the message will be lost def send_public_message(self, to, line): try: self.connection.privmsg(to, line) except ServerNotConnectedError: pass # the message will be lost def on_disconnect(self, connection, event): self._rooms = {} self.bot.disconnect_callback() def send_stream_request(self, identifier, fsource, name=None, size=None, stream_type=None): # Creates a new connection dcc = self.dcc_listen("raw") msg_parts = map(str, ( 'SEND', name, irc.client.ip_quad_to_numstr(dcc.localaddress), dcc.localport, size, )) msg = subprocess.list2cmdline(msg_parts) self.connection.ctcp("DCC", identifier.nick, msg) stream = Stream(identifier, fsource, name, size, stream_type) self.transfers[dcc] = stream return stream def on_dcc_connect(self, dcc, event): stream = self.transfers.get(dcc, None) if stream is None: log.error('DCC connect on a none registered connection') return log.debug('Start transfer for %s.', stream.identifier) stream.accept() self.send_chunk(stream, dcc) def on_dcc_disconnect(self, dcc, event): self.transfers.pop(dcc) def on_part(self, connection, event): """ Handler of the part IRC Message/event. The part message is sent to the client as a confirmation of a /PART command sent by someone in the room/channel. If the event.source contains the bot nickname then we need to fire the :meth:`~errbot.backends.base.Backend.callback_room_left` event on the bot. :param connection: Is an 'irc.client.ServerConnection' object :param event: Is an 'irc.client.Event' object The event.source contains the nickmask of the user that leave the room The event.target contains the channel name """ leaving_nick = event.source.nick leaving_room = event.target if self.bot.bot_identifier.nick == leaving_nick: with self._rooms_lock: self.bot.callback_room_left(self._rooms[leaving_room]) log.info('Left room {}.', leaving_room) def on_endofnames(self, connection, event): """ Handler of the enfofnames IRC message/event. The endofnames message is sent to the client when the server finish to send the list of names of the room ocuppants. This usually happens when you join to the room. So in this case, we use this event to determine that our bot is finally joined to the room. :param connection: Is an 'irc.client.ServerConnection' object :param event: Is an 'irc.client.Event' object the event.arguments[0] contains the channel name """ # The event.arguments[0] contains the channel name. # We filter that to avoid a misfire of the event. room_name = event.arguments[0] with self._rooms_lock: if room_name in self._recently_joined_to: self._recently_joined_to.remove(room_name) self.bot.callback_room_joined(self._rooms[room_name]) def on_join(self, connection, event): """ Handler of the join IRC message/event. Is in response of a /JOIN client message. :param connection: Is an 'irc.client.ServerConnection' object :param event: Is an 'irc.client.Event' object the event.target contains the channel name """ # We can't fire the room_joined event yet, # because we don't have the occupants info. # We need to wait to endofnames message. room_name = event.target with self._rooms_lock: if room_name not in self._rooms: self._rooms[room_name] = IRCRoom(room_name, self.bot) self._recently_joined_to.add(room_name) def on_currenttopic(self, connection, event): """ When you Join a room with a topic set this event fires up to with the topic information. If the room that you join don't have a topic set, nothing happens. Here is NOT the place to fire the :meth:`~errbot.backends.base.Backend.callback_room_topic` event for that case exist on_topic. :param connection: Is an 'irc.client.ServerConnection' object :param event: Is an 'irc.client.Event' object The event.arguments[0] contains the room name The event.arguments[1] contains the topic of the room. """ room_name, current_topic = event.arguments with self._rooms_lock: self._rooms[room_name].cb_set_topic(current_topic) def on_topic(self, connection, event): """ On response to the /TOPIC command if the room have a topic. If the room don't have a topic the event fired is on_notopic :param connection: Is an 'irc.client.ServerConnection' object :param event: Is an 'irc.client.Event' object The event.target contains the room name. The event.arguments[0] contains the topic name """ room_name = event.target current_topic = event.arguments[0] with self._rooms_lock: self._rooms[room_name].cb_set_topic(current_topic) self.bot.callback_room_topic(self._rooms[room_name]) def on_notopic(self, connection, event): """ This event fires ip when there is no topic set on a room :param connection: Is an 'irc.client.ServerConnection' object :param event: Is an 'irc.client.Event' object The event.arguments[0] contains the room name """ room_name = event.arguments[0] with self._rooms_lock: self._rooms[room_name].cb_set_topic(None) self.bot.callback_room_topic(self._rooms[room_name]) @staticmethod def send_chunk(stream, dcc): data = stream.read(4096) dcc.send_bytes(data) stream.ack_data(len(data)) def on_dccmsg(self, dcc, event): stream = self.transfers.get(dcc, None) if stream is None: log.error("DCC connect on a none registered connection") return acked = struct.unpack("!I", event.arguments[0])[0] if acked == stream.size: log.info('File %s successfully transfered to %s', stream.name, stream.identifier) dcc.disconnect() self.transfers.pop(dcc) elif acked == stream.transfered: log.debug('Chunk for file %s successfully transfered to %s (%d/%d).', stream.name, stream.identifier, stream.transfered, stream.size) self.send_chunk(stream, dcc) else: log.debug('Partial chunk for file %s successfully transfered to %s (%d/%d), wait for more', stream.name, stream.identifier, stream.transfered, stream.size) def away(self, message=''): """ Extend the original implementation to support AWAY. To set an away message, set message to something. To cancel an away message, leave message at empty string. """ self.connection.send_raw(' '.join(['AWAY', message]).strip()) class IRCBackend(ErrBot): aclpattern = '{nick}!{user}@{host}' def __init__(self, config): if hasattr(config, 'IRC_ACL_PATTERN'): IRCBackend.aclpattern = config.IRC_ACL_PATTERN identity = config.BOT_IDENTITY nickname = identity['nickname'] server = identity['server'] port = identity.get('port', 6667) password = identity.get('password', None) ssl = identity.get('ssl', False) bind_address = identity.get('bind_address', None) ipv6 = identity.get('ipv6', False) username = identity.get('username', None) nickserv_password = identity.get('nickserv_password', None) compact = config.COMPACT_OUTPUT if hasattr(config, 'COMPACT_OUTPUT') else True enable_format('irc', IRC_CHRS, borders=not compact) private_rate = getattr(config, 'IRC_PRIVATE_RATE', 1) channel_rate = getattr(config, 'IRC_CHANNEL_RATE', 1) reconnect_on_kick = getattr(config, 'IRC_RECONNECT_ON_KICK', 5) reconnect_on_disconnect = getattr(config, 'IRC_RECONNECT_ON_DISCONNECT', 5) self.bot_identifier = IRCPerson(nickname + '!' + nickname + '@' + server) super().__init__(config) self.conn = IRCConnection(bot=self, nickname=nickname, server=server, port=port, ssl=ssl, bind_address=bind_address, ipv6=ipv6, password=password, username=username, nickserv_password=nickserv_password, private_rate=private_rate, channel_rate=channel_rate, reconnect_on_kick=reconnect_on_kick, reconnect_on_disconnect=reconnect_on_disconnect, ) self.md = irc_md() config.MESSAGE_SIZE_LIMIT = IRC_MESSAGE_SIZE_LIMIT def send_message(self, msg): super().send_message(msg) if msg.is_direct: msg_func = self.conn.send_private_message msg_to = msg.to.person else: msg_func = self.conn.send_public_message msg_to = msg.to.room body = self.md.convert(msg.body) for line in body.split('\n'): msg_func(msg_to, line) def change_presence(self, status: str = ONLINE, message: str = '') -> None: if status == ONLINE: self.conn.away() # cancels the away message else: self.conn.away(f'[{status}] {message}') def send_stream_request(self, identifier, fsource, name=None, size=None, stream_type=None): return self.conn.send_stream_request(identifier, fsource, name, size, stream_type) def build_reply(self, msg, text=None, private=False, threaded=False): response = self.build_message(text) if msg.is_group: if private: response.frm = self.bot_identifier response.to = IRCPerson(str(msg.frm)) else: response.frm = IRCRoomOccupant(str(self.bot_identifier), msg.frm.room) response.to = msg.frm.room else: response.frm = self.bot_identifier response.to = msg.frm return response def serve_forever(self): try: self.conn.start() except KeyboardInterrupt: log.info("Interrupt received, shutting down") finally: self.conn.disconnect("Shutting down") log.debug("Trigger disconnect callback") self.disconnect_callback() log.debug("Trigger shutdown") self.shutdown() def connect(self): return self.conn def build_message(self, text): text = text.replace('', '*') # there is a weird chr IRC is sending that we need to filter out return super().build_message(text) def build_identifier(self, txtrep): log.debug('Build identifier from %s.', txtrep) # A textual representation starting with # means that we are talking # about an IRC channel -- IRCRoom in internal err-speak. if txtrep.startswith('#'): return IRCRoom(txtrep, self) # Occupants are represented as 2 lines, one is the IRC mask and the second is the Room. if '\n' in txtrep: m, r = txtrep.split('\n') return IRCRoomOccupant(m, IRCRoom(r, self)) return IRCPerson(txtrep) def shutdown(self): super().shutdown() def query_room(self, room): """ Query a room for information. :param room: The channel name to query for. :returns: An instance of :class:`~IRCMUCRoom`. """ with self.conn._rooms_lock: if room not in self.conn._rooms: self.conn._rooms[room] = IRCRoom(room, self) return self.conn._rooms[room] @property def mode(self): return 'irc' def rooms(self): """ Return a list of rooms the bot is currently in. :returns: A list of :class:`~IRCMUCRoom` instances. """ with self.conn._rooms_lock: return self.conn._rooms.values() def prefix_groupchat_reply(self, message, identifier): super().prefix_groupchat_reply(message, identifier) message.body = f'{identifier.nick}: {message.body}' errbot-6.1.1+ds/errbot/backends/null.plug000066400000000000000000000001451355337103200203320ustar00rootroot00000000000000[Core] Name = Null Module = null [Documentation] Description = This is the Null backend for Errbot. errbot-6.1.1+ds/errbot/backends/null.py000066400000000000000000000033631355337103200200200ustar00rootroot00000000000000import logging from time import sleep from errbot.backends.base import ONLINE from errbot.backends.test import TestPerson from errbot.core import ErrBot log = logging.getLogger(__name__) class ConnectionMock(object): def send(self, msg): pass def send_message(self, msg): pass class NullBackend(ErrBot): conn = ConnectionMock() running = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.bot_identifier = self.build_identifier('Err') # whatever def serve_forever(self): self.connect() # be sure we are "connected" before the first command self.connect_callback() # notify that the connection occured try: while self.running: sleep(1) except EOFError: pass except KeyboardInterrupt: pass finally: log.debug("Trigger disconnect callback") self.disconnect_callback() log.debug("Trigger shutdown") self.shutdown() def connect(self): if not self.conn: self.conn = ConnectionMock() return self.conn def build_identifier(self, strrep): return TestPerson(strrep) def shutdown(self): if self.running: self.running = False super().shutdown() # only once (hackish) def change_presence(self, status: str = ONLINE, message: str = '') -> None: pass def build_reply(self, msg, text=None, private=False, threaded=False): pass def prefix_groupchat_reply(self, message, identifier): pass def query_room(self, room): pass def rooms(self): pass @property def mode(self): return 'null' errbot-6.1.1+ds/errbot/backends/slack.plug000066400000000000000000000001501355337103200204510ustar00rootroot00000000000000[Core] Name = Slack Module = slack [Documentation] Description = This is the slack backend for Errbot. errbot-6.1.1+ds/errbot/backends/slack.py000066400000000000000000001304761355337103200201510ustar00rootroot00000000000000import collections import copyreg import json import logging import re import sys import pprint from functools import lru_cache from typing import BinaryIO from markdown import Markdown from markdown.extensions.extra import ExtraExtension from markdown.preprocessors import Preprocessor from errbot.backends.base import Identifier, Message, Presence, ONLINE, AWAY, Room, RoomError, RoomDoesNotExistError, \ UserDoesNotExistError, RoomOccupant, Person, Card, Stream from errbot.core import ErrBot from errbot.utils import split_string_after from errbot.rendering.ansiext import AnsiExtension, enable_format, IMTEXT_CHRS log = logging.getLogger(__name__) try: from slackclient import SlackClient except ImportError: log.exception("Could not start the Slack back-end") log.fatal( "You need to install the slackclient support in order to use the Slack backend.\n" "You can do `pip install errbot[slack]` to install it" ) sys.exit(1) # The Slack client automatically turns a channel name into a clickable # link if you prefix it with a #. Other clients receive this link as a # token matching this regex. SLACK_CLIENT_CHANNEL_HYPERLINK = re.compile(r'^<#(?P(C|G)[0-9A-Z]+)>$') # Empirically determined message size limit. SLACK_MESSAGE_LIMIT = 4096 USER_IS_BOT_HELPTEXT = ( "Connected to Slack using a bot account, which cannot manage " "channels itself (you must invite the bot to channels instead, " "it will auto-accept) nor invite people.\n\n" "If you need this functionality, you will have to create a " "regular user account and connect Errbot using that account. " "For this, you will also need to generate a user token at " "https://api.slack.com/web." ) COLORS = { 'red': '#FF0000', 'green': '#008000', 'yellow': '#FFA500', 'blue': '#0000FF', 'white': '#FFFFFF', 'cyan': '#00FFFF' } # Slack doesn't know its colors MARKDOWN_LINK_REGEX = re.compile(r'(?[^\]]+?)\]\((?P[a-zA-Z0-9]+?:\S+?)\)') def slack_markdown_converter(compact_output=False): """ This is a Markdown converter for use with Slack. """ enable_format('imtext', IMTEXT_CHRS, borders=not compact_output) md = Markdown(output_format='imtext', extensions=[ExtraExtension(), AnsiExtension()]) md.preprocessors['LinkPreProcessor'] = LinkPreProcessor(md) md.stripTopLevelTags = False return md class LinkPreProcessor(Preprocessor): """ This preprocessor converts markdown URL notation into Slack URL notation as described at https://api.slack.com/docs/formatting, section "Linking to URLs". """ def run(self, lines): for i, line in enumerate(lines): lines[i] = MARKDOWN_LINK_REGEX.sub(r'<\2|\1>', line) return lines class SlackAPIResponseError(RuntimeError): """Slack API returned a non-OK response""" def __init__(self, *args, error='', **kwargs): """ :param error: The 'error' key from the API response data """ self.error = error super().__init__(*args, **kwargs) class SlackPerson(Person): """ This class describes a person on Slack's network. """ def __init__(self, sc, userid=None, channelid=None): if userid is not None and userid[0] not in ('U', 'B', 'W'): raise Exception(f'This is not a Slack user or bot id: {userid} (should start with U, B or W)') if channelid is not None and channelid[0] not in ('D', 'C', 'G'): raise Exception(f'This is not a valid Slack channelid: {channelid} (should start with D, C or G)') self._userid = userid self._channelid = channelid self._sc = sc @property def userid(self): return self._userid @property def username(self): """Convert a Slack user ID to their user name""" user = self._sc.server.users.find(self._userid) if user is None: log.error('Cannot find user with ID %s', self._userid) return f'<{self._userid}>' return user.name @property def channelid(self): return self._channelid @property def channelname(self): """Convert a Slack channel ID to its channel name""" if self._channelid is None: return None channel = self._sc.server.channels.find(self._channelid) if channel is None: raise RoomDoesNotExistError(f'No channel with ID {self._channelid} exists.') return channel.name @property def domain(self): return self._sc.server.domain # Compatibility with the generic API. client = channelid nick = username # Override for ACLs @property def aclattr(self): # Note: Don't use str(self) here because that will return # an incorrect format from SlackMUCOccupant. return f'@{self.username}' @property def fullname(self): """Convert a Slack user ID to their user name""" user = self._sc.server.users.find(self._userid) if user is None: log.error('Cannot find user with ID %s', self._userid) return f'<{self._userid}>' return user.real_name def __unicode__(self): return f'@{self.username}' def __str__(self): return self.__unicode__() def __eq__(self, other): if not isinstance(other, SlackPerson): log.warning('tried to compare a SlackPerson with a %s', type(other)) return False return other.userid == self.userid def __hash__(self): return self.userid.__hash__() @property def person(self): # Don't use str(self) here because we want SlackRoomOccupant # to return just our @username too. return f'@{self.username}' class SlackRoomOccupant(RoomOccupant, SlackPerson): """ This class represents a person inside a MUC. """ def __init__(self, sc, userid, channelid, bot): super().__init__(sc, userid, channelid) self._room = SlackRoom(channelid=channelid, bot=bot) @property def room(self): return self._room def __unicode__(self): return f'#{self._room.name}/{self.username}' def __str__(self): return self.__unicode__() def __eq__(self, other): if not isinstance(other, RoomOccupant): log.warning('tried to compare a SlackRoomOccupant with a SlackPerson %s vs %s', self, other) return False return other.room.id == self.room.id and other.userid == self.userid class SlackBot(SlackPerson): """ This class describes a bot on Slack's network. """ def __init__(self, sc, bot_id, bot_username): self._bot_id = bot_id self._bot_username = bot_username super().__init__(sc=sc, userid=bot_id) @property def username(self): return self._bot_username # Beware of gotcha. Without this, nick would point to username of SlackPerson. nick = username @property def aclattr(self): # Make ACLs match against integration ID rather than human-readable # nicknames to avoid webhooks impersonating other people. return f'<{self._bot_id}>' @property def fullname(self): return None class SlackRoomBot(RoomOccupant, SlackBot): """ This class represents a bot inside a MUC. """ def __init__(self, sc, bot_id, bot_username, channelid, bot): super().__init__(sc, bot_id, bot_username) self._room = SlackRoom(channelid=channelid, bot=bot) @property def room(self): return self._room def __unicode__(self): return f'#{self._room.name}/{self.username}' def __str__(self): return self.__unicode__() def __eq__(self, other): if not isinstance(other, RoomOccupant): log.warning('tried to compare a SlackRoomBotOccupant with a SlackPerson %s vs %s', self, other) return False return other.room.id == self.room.id and other.userid == self.userid class SlackBackend(ErrBot): @staticmethod def _unpickle_identifier(identifier_str): return SlackBackend.__build_identifier(identifier_str) @staticmethod def _pickle_identifier(identifier): return SlackBackend._unpickle_identifier, (str(identifier),) def _register_identifiers_pickling(self): """ Register identifiers pickling. As Slack needs live objects in its identifiers, we need to override their pickling behavior. But for the unpickling to work we need to use bot.build_identifier, hence the bot parameter here. But then we also need bot for the unpickling so we save it here at module level. """ SlackBackend.__build_identifier = self.build_identifier for cls in (SlackPerson, SlackRoomOccupant, SlackRoom): copyreg.pickle(cls, SlackBackend._pickle_identifier, SlackBackend._unpickle_identifier) def __init__(self, config): super().__init__(config) identity = config.BOT_IDENTITY self.token = identity.get('token', None) self.proxies = identity.get('proxies', None) if not self.token: log.fatal( 'You need to set your token (found under "Bot Integration" on Slack) in ' 'the BOT_IDENTITY setting in your configuration. Without this token I ' 'cannot connect to Slack.' ) sys.exit(1) self.sc = None # Will be initialized in serve_once compact = config.COMPACT_OUTPUT if hasattr(config, 'COMPACT_OUTPUT') else False self.md = slack_markdown_converter(compact) self._register_identifiers_pickling() def api_call(self, method, data=None, raise_errors=True): """ Make an API call to the Slack API and return response data. This is a thin wrapper around `SlackClient.server.api_call`. :param method: The API method to invoke (see https://api.slack.com/methods/). :param raise_errors: Whether to raise :class:`~SlackAPIResponseError` if the API returns an error :param data: A dictionary with data to pass along in the API request. :returns: A dictionary containing the (JSON-decoded) API response :raises: :class:`~SlackAPIResponseError` if raise_errors is True and the API responds with `{"ok": false}` """ if data is None: data = {} response = self.sc.api_call(method, **data) if not isinstance(response, collections.Mapping): # Compatibility with SlackClient < 1.0.0 response = json.loads(response.decode('utf-8')) if raise_errors and not response['ok']: raise SlackAPIResponseError(f"Slack API call to {method} failed: {response['error']}", error=response['error']) return response def update_alternate_prefixes(self): """Converts BOT_ALT_PREFIXES to use the slack ID instead of name Slack only acknowledges direct callouts `@username` in chat if referred by using the ID of that user. """ # convert BOT_ALT_PREFIXES to a list try: bot_prefixes = self.bot_config.BOT_ALT_PREFIXES.split(',') except AttributeError: bot_prefixes = list(self.bot_config.BOT_ALT_PREFIXES) converted_prefixes = [] for prefix in bot_prefixes: try: converted_prefixes.append(f'<@{self.username_to_userid(prefix)}>') except Exception as e: log.error('Failed to look up Slack userid for alternate prefix "%s": %s', prefix, e) self.bot_alt_prefixes = tuple(x.lower() for x in self.bot_config.BOT_ALT_PREFIXES) log.debug('Converted bot_alt_prefixes: %s', self.bot_config.BOT_ALT_PREFIXES) def serve_once(self): self.sc = SlackClient(self.token, proxies=self.proxies) log.info('Verifying authentication token') self.auth = self.api_call("auth.test", raise_errors=False) if not self.auth['ok']: raise SlackAPIResponseError(error=f"Couldn't authenticate with Slack. Server said: {self.auth['error']}") log.debug("Token accepted") self.bot_identifier = SlackPerson(self.sc, self.auth["user_id"]) log.info("Connecting to Slack real-time-messaging API") if self.sc.rtm_connect(): log.info("Connected") # Block on reads instead of using the busy loop suggested in slackclient docs # https://github.com/slackapi/python-slackclient/issues/46#issuecomment-165674808 self.sc.server.websocket.sock.setblocking(True) self.reset_reconnection_count() # Inject bot identity to alternative prefixes self.update_alternate_prefixes() try: while True: for message in self.sc.rtm_read(): self._dispatch_slack_message(message) except KeyboardInterrupt: log.info("Interrupt received, shutting down..") return True except Exception: log.exception("Error reading from RTM stream:") finally: log.debug("Triggering disconnect callback") self.disconnect_callback() else: raise Exception('Connection failed, invalid token ?') def _dispatch_slack_message(self, message): """ Process an incoming message from slack. """ if 'type' not in message: log.debug("Ignoring non-event message: %s.", message) return event_type = message['type'] event_handlers = { 'hello': self._hello_event_handler, 'presence_change': self._presence_change_event_handler, 'message': self._message_event_handler, 'member_joined_channel': self._member_joined_channel_event_handler, } event_handler = event_handlers.get(event_type) if event_handler is None: log.debug('No event handler available for %s, ignoring this event', event_type) return try: log.debug('Processing slack event: %s', message) event_handler(message) except Exception: log.exception(f'{event_type} event handler raised an exception') def _hello_event_handler(self, event): """Event handler for the 'hello' event""" self.connect_callback() self.callback_presence(Presence(identifier=self.bot_identifier, status=ONLINE)) def _presence_change_event_handler(self, event): """Event handler for the 'presence_change' event""" idd = SlackPerson(self.sc, event['user']) presence = event['presence'] # According to https://api.slack.com/docs/presence, presence can # only be one of 'active' and 'away' if presence == 'active': status = ONLINE elif presence == 'away': status = AWAY else: log.error(f'It appears the Slack API changed, I received an unknown presence type {presence}.') status = ONLINE self.callback_presence(Presence(identifier=idd, status=status)) def _message_event_handler(self, event): """Event handler for the 'message' event""" channel = event['channel'] if channel[0] not in 'CGD': log.warning("Unknown message type! Unable to handle %s", channel) return subtype = event.get('subtype', None) if subtype in ("message_deleted", "channel_topic", "message_replied"): log.debug("Message of type %s, ignoring this event", subtype) return if subtype == "message_changed" and 'attachments' in event['message']: # If you paste a link into Slack, it does a call-out to grab details # from it so it can display this in the chatroom. These show up as # message_changed events with an 'attachments' key in the embedded # message. We should completely ignore these events otherwise we # could end up processing bot commands twice (user issues a command # containing a link, it gets processed, then Slack triggers the # message_changed event and we end up processing it again as a new # message. This is not what we want). log.debug( "Ignoring message_changed event with attachments, likely caused " "by Slack auto-expanding a link" ) return if 'message' in event: text = event['message'].get('text', '') user = event['message'].get('user', event.get('bot_id')) else: text = event.get('text', '') user = event.get('user', event.get('bot_id')) text, mentioned = self.process_mentions(text) text = self.sanitize_uris(text) log.debug('Saw an event: %s', pprint.pformat(event)) log.debug('Escaped IDs event text: %s', text) msg = Message( text, extras={ 'attachments': event.get('attachments'), 'slack_event': event, }, ) if channel.startswith('D'): if subtype == "bot_message": msg.frm = SlackBot( self.sc, bot_id=event.get('bot_id'), bot_username=event.get('username', '') ) else: msg.frm = SlackPerson(self.sc, user, event['channel']) msg.to = SlackPerson(self.sc, self.username_to_userid(self.sc.server.username), event['channel']) channel_link_name = event['channel'] else: if subtype == "bot_message": msg.frm = SlackRoomBot( self.sc, bot_id=event.get('bot_id'), bot_username=event.get('username', ''), channelid=event['channel'], bot=self ) else: msg.frm = SlackRoomOccupant(self.sc, user, event['channel'], bot=self) msg.to = SlackRoom(channelid=event['channel'], bot=self) channel_link_name = msg.to.name msg.extras['url'] = f'https://{self.sc.server.domain}.slack.com/archives/' \ f'{channel_link_name}/p{self._ts_for_message(msg).replace(".", "")}' self.callback_message(msg) if mentioned: self.callback_mention(msg, mentioned) def _member_joined_channel_event_handler(self, event): """Event handler for the 'member_joined_channel' event""" user = SlackPerson(self.sc, event['user']) if user == self.bot_identifier: self.callback_room_joined(SlackRoom(channelid=event['channel'], bot=self)) def userid_to_username(self, id_): """Convert a Slack user ID to their user name""" user = self.sc.server.users.get(id_) if user is None: raise UserDoesNotExistError(f'Cannot find user with ID {id_}.') return user.name def username_to_userid(self, name): """Convert a Slack user name to their user ID""" name = name.lstrip('@') user = self.sc.server.users.find(name) if user is None: raise UserDoesNotExistError(f'Cannot find user {name}.') return user.id def channelid_to_channelname(self, id_): """Convert a Slack channel ID to its channel name""" channel = [channel for channel in self.sc.server.channels if channel.id == id_] if not channel: raise RoomDoesNotExistError(f'No channel with ID {id_} exists.') return channel[0].name def channelname_to_channelid(self, name): """Convert a Slack channel name to its channel ID""" name = name.lstrip('#') channel = [channel for channel in self.sc.server.channels if channel.name == name] if not channel: raise RoomDoesNotExistError(f'No channel named {name} exists') return channel[0].id def channels(self, exclude_archived=True, joined_only=False): """ Get all channels and groups and return information about them. :param exclude_archived: Exclude archived channels/groups :param joined_only: Filter out channels the bot hasn't joined :returns: A list of channel (https://api.slack.com/types/channel) and group (https://api.slack.com/types/group) types. See also: * https://api.slack.com/methods/channels.list * https://api.slack.com/methods/groups.list """ response = self.api_call('channels.list', data={'exclude_archived': exclude_archived}) channels = [channel for channel in response['channels'] if channel['is_member'] or not joined_only] response = self.api_call('groups.list', data={'exclude_archived': exclude_archived}) # No need to filter for 'is_member' in this next call (it doesn't # (even exist) because leaving a group means you have to get invited # back again by somebody else. groups = [group for group in response['groups']] return channels + groups @lru_cache(1024) def get_im_channel(self, id_): """Open a direct message channel to a user""" try: response = self.api_call('im.open', data={'user': id_}) return response['channel']['id'] except SlackAPIResponseError as e: if e.error == "cannot_dm_bot": log.info('Tried to DM a bot.') return None else: raise e def _prepare_message(self, msg): # or card """ Translates the common part of messaging for Slack. :param msg: the message you want to extract the Slack concept from. :return: a tuple to user human readable, the channel id """ if msg.is_group: to_channel_id = msg.to.id to_humanreadable = msg.to.name if msg.to.name else self.channelid_to_channelname(to_channel_id) else: to_humanreadable = msg.to.username to_channel_id = msg.to.channelid if to_channel_id.startswith('C'): log.debug("This is a divert to private message, sending it directly to the user.") to_channel_id = self.get_im_channel(self.username_to_userid(msg.to.username)) return to_humanreadable, to_channel_id def send_message(self, msg): super().send_message(msg) if msg.parent is not None: # we are asked to reply to a specify thread. try: msg.extras['thread_ts'] = self._ts_for_message(msg.parent) except KeyError: # Gives to the user a more interesting explanation if we cannot find a ts from the parent. log.exception('The provided parent message is not a Slack message ' 'or does not contain a Slack timestamp.') to_humanreadable = "" try: if msg.is_group: to_channel_id = msg.to.id to_humanreadable = msg.to.name if msg.to.name else self.channelid_to_channelname(to_channel_id) else: to_humanreadable = msg.to.username if isinstance(msg.to, RoomOccupant): # private to a room occupant -> this is a divert to private ! log.debug("This is a divert to private message, sending it directly to the user.") to_channel_id = self.get_im_channel(self.username_to_userid(msg.to.username)) else: to_channel_id = msg.to.channelid msgtype = "direct" if msg.is_direct else "channel" log.debug('Sending %s message to %s (%s).', msgtype, to_humanreadable, to_channel_id) body = self.md.convert(msg.body) log.debug('Message size: %d.', len(body)) limit = min(self.bot_config.MESSAGE_SIZE_LIMIT, SLACK_MESSAGE_LIMIT) parts = self.prepare_message_body(body, limit) timestamps = [] for part in parts: data = { 'channel': to_channel_id, 'text': part, 'unfurl_media': 'true', 'link_names': '1', 'as_user': 'true', } # Keep the thread_ts to answer to the same thread. if 'thread_ts' in msg.extras: data['thread_ts'] = msg.extras['thread_ts'] result = self.api_call('chat.postMessage', data=data) timestamps.append(result['ts']) msg.extras['ts'] = timestamps except Exception: log.exception(f'An exception occurred while trying to send the following message ' f'to {to_humanreadable}: {msg.body}.') def _slack_upload(self, stream: Stream) -> None: """ Performs an upload defined in a stream :param stream: Stream object :return: None """ try: stream.accept() resp = self.api_call('files.upload', data={ 'channels': stream.identifier.channelid, 'filename': stream.name, 'file': stream }) if 'ok' in resp and resp['ok']: stream.success() else: stream.error() except Exception: log.exception(f'Upload of {stream.name} to {stream.identifier.channelname} failed.') def send_stream_request(self, user: Identifier, fsource: BinaryIO, name: str = None, size: int = None, stream_type: str = None) -> Stream: """ Starts a file transfer. For Slack, the size and stream_type are unsupported :param user: is the identifier of the person you want to send it to. :param fsource: is a file object you want to send. :param name: is an optional filename for it. :param size: not supported in Slack backend :param stream_type: not supported in Slack backend :return Stream: object on which you can monitor the progress of it. """ stream = Stream(user, fsource, name, size, stream_type) log.debug('Requesting upload of %s to %s (size hint: %d, stream type: %s).', name, user.channelname, size, stream_type) self.thread_pool.apply_async(self._slack_upload, (stream,)) return stream def send_card(self, card: Card): if isinstance(card.to, RoomOccupant): card.to = card.to.room to_humanreadable, to_channel_id = self._prepare_message(card) attachment = {} if card.summary: attachment['pretext'] = card.summary if card.title: attachment['title'] = card.title if card.link: attachment['title_link'] = card.link if card.image: attachment['image_url'] = card.image if card.thumbnail: attachment['thumb_url'] = card.thumbnail if card.color: attachment['color'] = COLORS[card.color] if card.color in COLORS else card.color if card.fields: attachment['fields'] = [{'title': key, 'value': value, 'short': True} for key, value in card.fields] limit = min(self.bot_config.MESSAGE_SIZE_LIMIT, SLACK_MESSAGE_LIMIT) parts = self.prepare_message_body(card.body, limit) part_count = len(parts) footer = attachment.get('footer', '') for i in range(part_count): if part_count > 1: attachment['footer'] = f'{footer} [{i + 1}/{part_count}]' attachment['text'] = parts[i] data = { 'text': ' ', 'channel': to_channel_id, 'attachments': json.dumps([attachment]), 'link_names': '1', 'as_user': 'true' } try: log.debug('Sending data:\n%s', data) self.api_call('chat.postMessage', data=data) except Exception: log.exception(f'An exception occurred while trying to send a card to {to_humanreadable}.[{card}]') def __hash__(self): return 0 # this is a singleton anyway def change_presence(self, status: str = ONLINE, message: str = '') -> None: self.api_call('users.setPresence', data={'presence': 'auto' if status == ONLINE else 'away'}) @staticmethod def prepare_message_body(body, size_limit): """ Returns the parts of a message chunked and ready for sending. This is a staticmethod for easier testing. Args: body (str) size_limit (int): chunk the body into sizes capped at this maximum Returns: [str] """ fixed_format = body.startswith('```') # hack to fix the formatting parts = list(split_string_after(body, size_limit)) if len(parts) == 1: # If we've got an open fixed block, close it out if parts[0].count('```') % 2 != 0: parts[0] += '\n```\n' else: for i, part in enumerate(parts): starts_with_code = part.startswith('```') # If we're continuing a fixed block from the last part if fixed_format and not starts_with_code: parts[i] = '```\n' + part # If we've got an open fixed block, close it out if part.count('```') % 2 != 0: parts[i] += '\n```\n' return parts @staticmethod def extract_identifiers_from_string(text): """ Parse a string for Slack user/channel IDs. Supports strings with the following formats:: <#C12345> <@U12345> <@U12345|user> @user #channel/user #channel Returns the tuple (username, userid, channelname, channelid). Some elements may come back as None. """ exception_message = ( 'Unparseable slack identifier, should be of the format `<#C12345>`, `<@U12345>`, ' '`<@U12345|user>`, `@user`, `#channel/user` or `#channel`. (Got `%s`)' ) text = text.strip() if text == '': raise ValueError(exception_message % '') channelname = None username = None channelid = None userid = None if text[0] == '<' and text[-1] == '>': exception_message = 'Unparseable slack ID, should start with U, B, C, G, D or W (got `%s`)' text = text[2:-1] if text == '': raise ValueError(exception_message % '') if text[0] in ('U', 'B', 'W'): if '|' in text: userid, username = text.split('|') else: userid = text elif text[0] in ('C', 'G', 'D'): channelid = text else: raise ValueError(exception_message % text) elif text[0] == '@': username = text[1:] elif text[0] == '#': plainrep = text[1:] if '/' in text: channelname, username = plainrep.split('/', 1) else: channelname = plainrep else: raise ValueError(exception_message % text) return username, userid, channelname, channelid def build_identifier(self, txtrep): """ Build a :class:`SlackIdentifier` from the given string txtrep. Supports strings with the formats accepted by :func:`~extract_identifiers_from_string`. """ log.debug('building an identifier from %s.', txtrep) username, userid, channelname, channelid = self.extract_identifiers_from_string(txtrep) if userid is None and username is not None: userid = self.username_to_userid(username) if channelid is None and channelname is not None: channelid = self.channelname_to_channelid(channelname) if userid is not None and channelid is not None: return SlackRoomOccupant(self.sc, userid, channelid, bot=self) if userid is not None: return SlackPerson(self.sc, userid, self.get_im_channel(userid)) if channelid is not None: return SlackRoom(channelid=channelid, bot=self) raise Exception( "You found a bug. I expected at least one of userid, channelid, username or channelname " "to be resolved but none of them were. This shouldn't happen so, please file a bug." ) def is_from_self(self, msg: Message) -> bool: return self.bot_identifier.userid == msg.frm.userid def build_reply(self, msg, text=None, private=False, threaded=False): response = self.build_message(text) if threaded: response.parent = msg elif 'thread_ts' in msg.extras['slack_event']: # If we reply to a threaded message, keep it in the thread. response.extras['thread_ts'] = msg.extras['slack_event']['thread_ts'] response.frm = self.bot_identifier if private: response.to = msg.frm else: response.to = msg.frm.room if isinstance(msg.frm, RoomOccupant) else msg.frm return response def add_reaction(self, msg: Message, reaction: str) -> None: """ Add the specified reaction to the Message if you haven't already. :param msg: A Message. :param reaction: A str giving an emoji, without colons before and after. :raises: ValueError if the emoji doesn't exist. """ return self._react('reactions.add', msg, reaction) def remove_reaction(self, msg: Message, reaction: str) -> None: """ Remove the specified reaction from the Message if it is currently there. :param msg: A Message. :param reaction: A str giving an emoji, without colons before and after. :raises: ValueError if the emoji doesn't exist. """ return self._react('reactions.remove', msg, reaction) def _react(self, method: str, msg: Message, reaction: str) -> None: try: # this logic is from send_message if msg.is_group: to_channel_id = msg.to.id else: to_channel_id = msg.to.channelid ts = self._ts_for_message(msg) self.api_call(method, data={'channel': to_channel_id, 'timestamp': ts, 'name': reaction}) except SlackAPIResponseError as e: if e.error == 'invalid_name': raise ValueError(e.error, 'No such emoji', reaction) elif e.error in ('no_reaction', 'already_reacted'): # This is common if a message was edited after you reacted to it, and you reacted to it again. # Chances are you don't care about this. If you do, call api_call() directly. pass else: raise SlackAPIResponseError(error=e.error) def _ts_for_message(self, msg): try: return msg.extras['slack_event']['message']['ts'] except KeyError: return msg.extras['slack_event']['ts'] def shutdown(self): super().shutdown() @property def mode(self): return 'slack' def query_room(self, room): """ Room can either be a name or a channelid """ if room.startswith('C') or room.startswith('G'): return SlackRoom(channelid=room, bot=self) m = SLACK_CLIENT_CHANNEL_HYPERLINK.match(room) if m is not None: return SlackRoom(channelid=m.groupdict()['id'], bot=self) return SlackRoom(name=room, bot=self) def rooms(self): """ Return a list of rooms the bot is currently in. :returns: A list of :class:`~SlackRoom` instances. """ channels = self.channels(joined_only=True, exclude_archived=True) return [SlackRoom(channelid=channel['id'], bot=self) for channel in channels] def prefix_groupchat_reply(self, message, identifier): super().prefix_groupchat_reply(message, identifier) message.body = f'@{identifier.nick}: {message.body}' @staticmethod def sanitize_uris(text): """ Sanitizes URI's present within a slack message. e.g. , :returns: string """ text = re.sub(r'<([^|>]+)\|([^|>]+)>', r'\2', text) text = re.sub(r'<(http([^>]+))>', r'\1', text) return text def process_mentions(self, text): """ Process mentions in a given string :returns: A formatted string of the original message and a list of :class:`~SlackPerson` instances. """ mentioned = [] m = re.findall('<@[^>]*>*', text) for word in m: try: identifier = self.build_identifier(word) except Exception as e: log.debug("Tried to build an identifier from '%s' but got exception: %s", word, e) continue # We only track mentions of persons. if isinstance(identifier, SlackPerson): log.debug('Someone mentioned') mentioned.append(identifier) text = text.replace(word, str(identifier)) return text, mentioned class SlackRoom(Room): def __init__(self, name=None, channelid=None, bot=None): if channelid is not None and name is not None: raise ValueError("channelid and name are mutually exclusive") if name is not None: if name.startswith('#'): self._name = name[1:] else: self._name = name else: self._name = bot.channelid_to_channelname(channelid) self._id = None self._bot = bot self.sc = bot.sc def __str__(self): return f'#{self.name}' @property def channelname(self): return self._name @property def _channel(self): """ The channel object exposed by SlackClient """ id_ = self.sc.server.channels.find(self.name) if id_ is None: raise RoomDoesNotExistError(f"{str(self)} does not exist (or is a private group you don't have access to)") return id_ @property def _channel_info(self): """ Channel info as returned by the Slack API. See also: * https://api.slack.com/methods/channels.list * https://api.slack.com/methods/groups.list """ if self.private: return self._bot.api_call('groups.info', data={'channel': self.id})["group"] else: return self._bot.api_call('channels.info', data={'channel': self.id})["channel"] @property def private(self): """Return True if the room is a private group""" return self._channel.id.startswith('G') @property def id(self): """Return the ID of this room""" if self._id is None: self._id = self._channel.id return self._id channelid = id @property def name(self): """Return the name of this room""" return self._name def join(self, username=None, password=None): log.info("Joining channel %s", str(self)) try: self._bot.api_call('channels.join', data={'name': self.name}) except SlackAPIResponseError as e: if e.error == 'user_is_bot': raise RoomError(f'Unable to join channel. {USER_IS_BOT_HELPTEXT}') else: raise RoomError(e) def leave(self, reason=None): try: if self.id.startswith('C'): log.info('Leaving channel %s (%s)', self, self.id) self._bot.api_call('channels.leave', data={'channel': self.id}) else: log.info('Leaving group %s (%s)', self, self.id) self._bot.api_call('groups.leave', data={'channel': self.id}) except SlackAPIResponseError as e: if e.error == 'user_is_bot': raise RoomError(f'Unable to leave channel. {USER_IS_BOT_HELPTEXT}') else: raise RoomError(e) self._id = None def create(self, private=False): try: if private: log.info('Creating group %s.', self) self._bot.api_call('groups.create', data={'name': self.name}) else: log.info('Creating channel %s.', self) self._bot.api_call('channels.create', data={'name': self.name}) except SlackAPIResponseError as e: if e.error == 'user_is_bot': raise RoomError(f"Unable to create channel. {USER_IS_BOT_HELPTEXT}") else: raise RoomError(e) def destroy(self): try: if self.id.startswith('C'): log.info('Archiving channel %s (%s)', self, self.id) self._bot.api_call('channels.archive', data={'channel': self.id}) else: log.info('Archiving group %s (%s)', self, self.id) self._bot.api_call('groups.archive', data={'channel': self.id}) except SlackAPIResponseError as e: if e.error == 'user_is_bot': raise RoomError(f'Unable to archive channel. {USER_IS_BOT_HELPTEXT}') else: raise RoomError(e) self._id = None @property def exists(self): channels = self._bot.channels(joined_only=False, exclude_archived=False) return len([c for c in channels if c['name'] == self.name]) > 0 @property def joined(self): channels = self._bot.channels(joined_only=True) return len([c for c in channels if c['name'] == self.name]) > 0 @property def topic(self): if self._channel_info['topic']['value'] == '': return None else: return self._channel_info['topic']['value'] @topic.setter def topic(self, topic): if self.private: log.info('Setting topic of %s (%s) to %s.', self, self.id, topic) self._bot.api_call('groups.setTopic', data={'channel': self.id, 'topic': topic}) else: log.info('Setting topic of %s (%s) to %s.', self, self.id, topic) self._bot.api_call('channels.setTopic', data={'channel': self.id, 'topic': topic}) @property def purpose(self): if self._channel_info['purpose']['value'] == '': return None else: return self._channel_info['purpose']['value'] @purpose.setter def purpose(self, purpose): if self.private: log.info('Setting purpose of %s (%s) to %s.', self, self.id, purpose) self._bot.api_call('groups.setPurpose', data={'channel': self.id, 'purpose': purpose}) else: log.info('Setting purpose of %s (%s) to %s.', str(self), self.id, purpose) self._bot.api_call('channels.setPurpose', data={'channel': self.id, 'purpose': purpose}) @property def occupants(self): members = self._channel_info['members'] return [SlackRoomOccupant(self.sc, m, self.id, self._bot) for m in members] def invite(self, *args): users = {user['name']: user['id'] for user in self._bot.api_call('users.list')['members']} for user in args: if user not in users: raise UserDoesNotExistError(f'User "{user}" not found.') log.info('Inviting %s into %s (%s)', user, self, self.id) method = 'groups.invite' if self.private else 'channels.invite' response = self._bot.api_call( method, data={'channel': self.id, 'user': users[user]}, raise_errors=False ) if not response['ok']: if response['error'] == 'user_is_bot': raise RoomError(f'Unable to invite people. {USER_IS_BOT_HELPTEXT}') elif response['error'] != 'already_in_channel': raise SlackAPIResponseError(error=f'Slack API call to {method} failed: {response["error"]}.') def __eq__(self, other): if not isinstance(other, SlackRoom): return False return self.id == other.id errbot-6.1.1+ds/errbot/backends/styles/000077500000000000000000000000001355337103200200125ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/backends/styles/style-demo.css000066400000000000000000000063201355337103200226070ustar00rootroot00000000000000body { background-repeat: no-repeat; background-position: center center; background-attachment:fixed; background-size: contain; background-color: #FFFFFF; margin:0; } .receiving { background-color: #f8ebdf; border-color: #ed9d53; margin:10px; padding-top:10px; padding-left:20px; padding-right:20px; padding-bottom:20px; border-style:solid; border-width: 2px; border-bottom-left-radius:10px; border-bottom-right-radius:10px; border-top-right-radius:10px; border-top-left-radius:10px; opacity:0.8; } .sending { background-color: #dfdef7; border-color: #8fa9e6; margin:10px; padding-top:10px; padding-left:20px; padding-right:20px; padding-bottom:20px; border-style:solid; border-width: 2px; border-bottom-left-radius:10px; border-bottom-right-radius:10px; border-top-right-radius:10px; border-top-left-radius:10px; font-weight:bold; opacity:0.8; } pre { white-space: pre-line; font-size:20px; border-width:2px; background-color:#DDDDDD; border-color: #555555; border-bottom-left-radius:10px; border-bottom-right-radius:10px; border-top-right-radius:10px; border-top-left-radius:10px; } table { margin:20px; border-bottom-left-radius:10px; border-bottom-right-radius:10px; border-top-right-radius:10px; border-top-left-radius:10px; border-collapse: collapse; width: 80%; } td{ vertical-align:middle; font-size:28px; text-align:left; margin-left: 60px; font-family:Arial, sans-serif; color:#333333; } tr th{ background:#9667b2; text-align:center; font-size:32px; font-family:Arial, sans-serif; font-weight:bold; color:#FFFFFF; } tr:last-child td:last-child { border-bottom-right-radius:10px; } table tr:first-child th:first-child { border-top-left-radius:10px; } table tr:first-child th:last-child { border-top-right-radius:10px; } tr:last-child td:first-child{ border-bottom-left-radius:10px; } tr:nth-child(odd) td { background-color:#FFF3DC; } tr:nth-child(even) td { background-color:#FFF7EC; } *[color="red"] { color: red; } *[color="green"] { color: green; } *[color="yellow"] { color: orange; } *[color="blue"] { color: blue; } *[color="white"] { color: #FFFFFF; } *[bgcolor="red"] { background-color: lightsalmon; } *[bgcolor="green"] { background-color: lightgreen; } *[bgcolor="yellow"] { background-color: lightgoldenrodyellow; } *[bgcolor="cyan"] { background-color: aqua; } *[bgcolor="blue"] { background-color: lightblue; } *[bgcolor="white"] { background-color: azure; } *[bgcolor="black"] { background-color: black; } h1 { color: #111; font-family: 'Helvetica Neue', sans-serif; font-size: 100px; font-weight: bold; letter-spacing: -1px; line-height: 1; text-align: center; } h2 { color: #111; font-family: 'Open Sans', sans-serif; font-size: 60px; font-weight: 300; line-height: 32px; margin: 0 0 20px; text-align: center; } h3 { color: #111; font-family: 'Arial Unicode MS', sans-serif; font-size: 50px; font-weight: 200; line-height: 27px; margin: 0 0 10px; text-align: center; } body { color: #111; font-family: 'Arial Unicode MS', sans-serif; font-size: 28px; line-height: 48px; margin: 0 0 24px; text-align: justify; text-justify: inter-word; } errbot-6.1.1+ds/errbot/backends/styles/style.css000066400000000000000000000062131355337103200216660ustar00rootroot00000000000000body { background-repeat: no-repeat; background-position: center center; background-attachment:fixed; background-size: contain; background-color: #FFFFFF; margin:0; } .receiving { background-color: #f8ebdf; border-color: #ed9d53; margin:5px; padding-top:5px; padding-left:10px; padding-right:10px; padding-bottom:10px; border-style:solid; border-width: 1px; border-bottom-left-radius:5px; border-bottom-right-radius:5px; border-top-right-radius:5px; border-top-left-radius:5px; opacity:0.8; } .sending { background-color: #dfdef7; border-color: #8fa9e6; margin:5px; padding-top:5px; padding-left:10px; padding-right:10px; padding-bottom:10px; border-style:solid; border-width: 1px; border-bottom-left-radius:5px; border-bottom-right-radius:5px; border-top-right-radius:5px; border-top-left-radius:5px; font-weight:bold; opacity:0.8; } pre { white-space: pre-line; font-size:10px; border-width:1px; background-color:#DDDDDD; border-color: #555555; border-bottom-left-radius:5px; border-bottom-right-radius:5px; border-top-right-radius:5px; border-top-left-radius:5px; } table { margin:10px; border-bottom-left-radius:5px; border-bottom-right-radius:5px; border-top-right-radius:5px; border-top-left-radius:5px; border-collapse: collapse; width: 80%; } td{ vertical-align:middle; font-size:14px; text-align:center; margin-left: 30px; font-family:Arial, sans-serif; color:#333333; } tr th{ background:#9667b2; text-align:center; font-size:16px; font-family:Arial, sans-serif; font-weight:bold; color:#FFFFFF; } tr:last-child td:last-child { border-bottom-right-radius:5px; } table tr:first-child th:first-child { border-top-left-radius:5px; } table tr:first-child th:last-child { border-top-right-radius:5px; } tr:last-child td:first-child{ border-bottom-left-radius:5px; } tr:nth-child(odd) td { background-color:#FFF3DC; } tr:nth-child(even) td { background-color:#FFF7EC; } *[color="red"] { color: red; } *[color="green"] { color: green; } *[color="yellow"] { color: orange; } *[color="blue"] { color: blue; } *[color="white"] { color: #FFFFFF; } *[bgcolor="red"] { background-color: lightsalmon; } *[bgcolor="green"] { background-color: lightgreen; } *[bgcolor="yellow"] { background-color: lightgoldenrodyellow; } *[bgcolor="cyan"] { background-color: aqua; } *[bgcolor="blue"] { background-color: lightblue; } *[bgcolor="white"] { background-color: azure; } *[bgcolor="black"] { background-color: black; } h1 { color: #111; font-family: 'Helvetica Neue', sans-serif; font-size: 50px; font-weight: bold; letter-spacing: -1px; line-height: 1; text-align: center; } h2 { color: #111; font-family: 'Open Sans', sans-serif; font-size: 30px; font-weight: 300; line-height: 32px; margin: 0 0 20px; text-align: center; } h3 { color: #111; font-family: 'Arial Unicode MS', sans-serif; font-size: 25px; font-weight: 200; line-height: 27px; margin: 0 0 10px; text-align: center; } body { color: #111; font-family: 'Arial Unicode MS', sans-serif; font-size: 14px; line-height: 24px; margin: 0 0 24px; } errbot-6.1.1+ds/errbot/backends/telegram_messenger.plug000066400000000000000000000001731355337103200232310ustar00rootroot00000000000000[Core] Name = Telegram Module = telegram_messenger [Documentation] Description = This is the Telegram backend for Errbot. errbot-6.1.1+ds/errbot/backends/telegram_messenger.py000066400000000000000000000363541355337103200227240ustar00rootroot00000000000000import logging import sys from errbot.backends.base import RoomError, Identifier, Person, RoomOccupant, Stream, ONLINE, Room from errbot.core import ErrBot from errbot.rendering import text from errbot.rendering.ansiext import enable_format, TEXT_CHRS log = logging.getLogger(__name__) TELEGRAM_MESSAGE_SIZE_LIMIT = 1024 UPDATES_OFFSET_KEY = '_telegram_updates_offset' try: import telegram except ImportError: log.exception("Could not start the Telegram back-end") log.fatal( "You need to install the telegram support in order " "to use the Telegram backend.\n" "You should be able to install this package using:\n" "pip install errbot[telegram]" ) sys.exit(1) class RoomsNotSupportedError(RoomError): def __init__(self, message=None): if message is None: message = ( "Room operations are not supported on Telegram. " "While Telegram itself has groupchat functionality, it does not " "expose any APIs to bots to get group membership or otherwise " "interact with groupchats." ) super().__init__(message) class TelegramBotFilter(object): """ This is a filter for the logging library that filters the "No new updates found." log message generated by telegram.bot. This is an INFO-level log message that gets logged for every getUpdates() call where there are no new messages, so is way too verbose. """ @staticmethod def filter(record): if record.getMessage() == "No new updates found.": return 0 class TelegramIdentifier(Identifier): def __init__(self, id): self._id = str(id) @property def id(self): return self._id def __unicode__(self): return str(self._id) def __eq__(self, other): return self._id == other.id __str__ = __unicode__ aclattr = id class TelegramPerson(TelegramIdentifier, Person): def __init__(self, id, first_name=None, last_name=None, username=None): super().__init__(id) self._first_name = first_name self._last_name = last_name self._username = username @property def id(self): return self._id @property def first_name(self): return self._first_name @property def last_name(self): return self._last_name @property def fullname(self): fullname = self.first_name if self.last_name is not None: fullname += " " + self.last_name return fullname @property def username(self): return self._username @property def client(self): return None person = id nick = username class TelegramRoom(TelegramIdentifier, Room): def __init__(self, id, title=None): super().__init__(id) self._title = title @property def id(self): return self._id @property def title(self): """Return the groupchat title (only applies to groupchats)""" return self._title def join(self, username: str = None, password: str = None): raise RoomsNotSupportedError() def create(self): raise RoomsNotSupportedError() def leave(self, reason: str = None): raise RoomsNotSupportedError() def destroy(self): raise RoomsNotSupportedError() @property def joined(self): raise RoomsNotSupportedError() @property def exists(self): raise RoomsNotSupportedError() @property def topic(self): raise RoomsNotSupportedError() @property def occupants(self): raise RoomsNotSupportedError() def invite(self, *args): raise RoomsNotSupportedError() class TelegramMUCOccupant(TelegramPerson, RoomOccupant): """ This class represents a person inside a MUC. """ def __init__(self, id, room, first_name=None, last_name=None, username=None): super().__init__(id=id, first_name=first_name, last_name=last_name, username=username) self._room = room @property def room(self): return self._room @property def username(self): return self._username class TelegramBackend(ErrBot): def __init__(self, config): super().__init__(config) config.MESSAGE_SIZE_LIMIT = TELEGRAM_MESSAGE_SIZE_LIMIT logging.getLogger('telegram.bot').addFilter(TelegramBotFilter()) identity = config.BOT_IDENTITY self.token = identity.get('token', None) if not self.token: log.fatal( "You need to supply a token for me to use. You can obtain " "a token by registering your bot with the Bot Father (@BotFather)" ) sys.exit(1) self.telegram = None # Will be initialized in serve_once self.bot_instance = None # Will be set in serve_once compact = config.COMPACT_OUTPUT if hasattr(config, 'COMPACT_OUTPUT') else False enable_format('text', TEXT_CHRS, borders=not compact) self.md_converter = text() def serve_once(self): log.info("Initializing connection") try: self.telegram = telegram.Bot(token=self.token) me = self.telegram.getMe() except telegram.TelegramError as e: log.error("Connection failure: %s", e.message) return False self.bot_identifier = TelegramPerson( id=me.id, first_name=me.first_name, last_name=me.last_name, username=me.username ) log.info("Connected") self.reset_reconnection_count() self.connect_callback() try: offset = self[UPDATES_OFFSET_KEY] except KeyError: offset = 0 try: while True: log.debug("Getting updates with offset %s", offset) for update in self.telegram.getUpdates(offset=offset, timeout=60): offset = update.update_id + 1 self[UPDATES_OFFSET_KEY] = offset log.debug("Processing update: %s", update) if not hasattr(update, 'message'): log.warning("Unknown update type (no message present)") continue try: self._handle_message(update.message) except Exception: log.exception("An exception occurred while processing update") log.debug("All updates processed, new offset is %s", offset) except KeyboardInterrupt: log.info("Interrupt received, shutting down..") return True except Exception: log.exception("Error reading from Telegram updates stream:") finally: log.debug("Triggering disconnect callback") self.disconnect_callback() def _handle_message(self, message): """ Handle a received message. :param message: A message with a structure as defined at https://core.telegram.org/bots/api#message """ if message.text is None: log.warning("Unhandled message type (not a text message) ignored") return message_instance = self.build_message(message.text) if message.chat['type'] == 'private': message_instance.frm = TelegramPerson( id=message.from_user.id, first_name=message.from_user.first_name, last_name=message.from_user.last_name, username=message.from_user.username ) message_instance.to = self.bot_identifier else: room = TelegramRoom(id=message.chat.id, title=message.chat.title) message_instance.frm = TelegramMUCOccupant( id=message.from_user.id, room=room, first_name=message.from_user.first_name, last_name=message.from_user.last_name, username=message.from_user.username ) message_instance.to = room message_instance.extras['message_id'] = message.message_id self.callback_message(message_instance) def send_message(self, msg): super().send_message(msg) body = self.md_converter.convert(msg.body) try: self.telegram.sendMessage(msg.to.id, body) except Exception: log.exception( f'An exception occurred while trying to send the following message to {msg.to.id}: {msg.body}') raise def change_presence(self, status: str = ONLINE, message: str = '') -> None: # It looks like telegram doesn't supports online presence for privacy reason. pass def build_identifier(self, txtrep): """ Convert a textual representation into a :class:`~TelegramPerson` or :class:`~TelegramRoom`. """ log.debug('building an identifier from %s.', txtrep) if not self._is_numeric(txtrep): raise ValueError('Telegram identifiers must be numeric.') id_ = int(txtrep) if id_ > 0: return TelegramPerson(id=id_) else: return TelegramRoom(id=id_) def build_reply(self, msg, text=None, private=False, threaded=False): response = self.build_message(text) response.frm = self.bot_identifier if private: response.to = msg.frm else: response.to = msg.frm if msg.is_direct else msg.to return response @property def mode(self): return 'telegram' def query_room(self, room): """ Not supported on Telegram. :raises: :class:`~RoomsNotSupportedError` """ raise RoomsNotSupportedError() def rooms(self): """ Not supported on Telegram. :raises: :class:`~RoomsNotSupportedError` """ raise RoomsNotSupportedError() def prefix_groupchat_reply(self, message, identifier): super().prefix_groupchat_reply(message, identifier) message.body = f'@{identifier.nick}: {message.body}' def _telegram_special_message(self, chat_id, content, msg_type, **kwargs): """Send special message.""" if msg_type == 'document': msg = self.telegram.sendDocument(chat_id=chat_id, document=content, **kwargs) elif msg_type == 'photo': msg = self.telegram.sendPhoto(chat_id=chat_id, photo=content, **kwargs) elif msg_type == 'audio': msg = self.telegram.sendAudio(chat_id=chat_id, audio=content, **kwargs) elif msg_type == 'video': msg = self.telegram.sendVideo(chat_id=chat_id, video=content, **kwargs) elif msg_type == 'sticker': msg = self.telegram.sendSticker(chat_id=chat_id, sticker=content, **kwargs) elif msg_type == 'location': msg = self.telegram.sendLocation(chat_id=chat_id, latitude=kwargs.pop('latitude', ''), longitude=kwargs.pop('longitude', ''), **kwargs) else: raise ValueError(f'Expected a valid choice for `msg_type`, got: {msg_type}.') return msg def _telegram_upload_stream(self, stream, **kwargs): """Perform upload defined in a stream.""" msg = None try: stream.accept() msg = self._telegram_special_message(chat_id=stream.identifier.id, content=stream.raw, msg_type=stream.stream_type, **kwargs) except Exception: log.exception(f'Upload of {stream.name} to {stream.identifier} failed.') else: if msg is None: stream.error() else: stream.success() def send_stream_request(self, identifier, fsource, name='file', size=None, stream_type=None): """Starts a file transfer. :param identifier: TelegramPerson or TelegramMUCOccupant Identifier of the Person or Room to send the stream to. :param fsource: str, dict or binary data File URL or binary content from a local file. Optionally a dict with binary content plus metadata can be given. See `stream_type` for more details. :param name: str, optional Name of the file. Not sure if this works always. :param size: str, optional Size of the file obtained with os.path.getsize. This is only used for debug logging purposes. :param stream_type: str, optional Type of the stream. Choices: 'document', 'photo', 'audio', 'video', 'sticker', 'location'. If 'video', a dict is optional as {'content': fsource, 'duration': str}. If 'voice', a dict is optional as {'content': fsource, 'duration': str}. If 'audio', a dict is optional as {'content': fsource, 'duration': str, 'performer': str, 'title': str}. For 'location' a dict is mandatory as {'latitude': str, 'longitude': str}. For 'venue': TODO # see: https://core.telegram.org/bots/api#sendvenue :return stream: str or Stream If `fsource` is str will return str, else return Stream. """ def _telegram_metadata(fsource): if isinstance(fsource, dict): return fsource.pop('content'), fsource else: return fsource, None def _is_valid_url(url): try: from urlparse import urlparse except Exception: from urllib.parse import urlparse return bool(urlparse(url).scheme) content, meta = _telegram_metadata(fsource) if isinstance(content, str): if not _is_valid_url(content): raise ValueError(f'Not valid URL: {content}') self._telegram_special_message(chat_id=identifier.id, content=content, msg_type=stream_type, **meta) log.debug('Requesting upload of %s to %s (size hint: %d, stream type: %s).', name, identifier.username, size, stream_type) stream = content else: stream = Stream(identifier, content, name, size, stream_type) log.debug('Requesting upload of %s to %s (size hint: %d, stream type: %s)', name, identifier, size, stream_type) self.thread_pool.apply_async(self._telegram_upload_stream, (stream,)) return stream @staticmethod def _is_numeric(input_): """Return true if input is a number""" try: int(input_) return True except ValueError: return False errbot-6.1.1+ds/errbot/backends/test.plug000066400000000000000000000001451355337103200203370ustar00rootroot00000000000000[Core] Name = Test Module = test [Documentation] Description = This is the test backend for Errbot. errbot-6.1.1+ds/errbot/backends/test.py000066400000000000000000000457561355337103200200410ustar00rootroot00000000000000import importlib import logging import sys import unittest import textwrap from os.path import sep, abspath from queue import Queue from tempfile import mkdtemp from threading import Thread import pytest from errbot.rendering import text from errbot.backends.base import Message, Room, Person, RoomOccupant, ONLINE from errbot.core_plugins.wsview import reset_app from errbot.core import ErrBot from errbot.bootstrap import setup_bot log = logging.getLogger(__name__) QUIT_MESSAGE = '$STOP$' STZ_MSG = 1 STZ_PRE = 2 STZ_IQ = 3 class TestPerson(Person): """ This is an identifier just represented as a string. DO NOT USE THIS DIRECTLY AS IT IS NOT COMPATIBLE WITH MOST BACKENDS, use self.build_identifier(identifier_as_string) instead. Note to back-end implementors: You should provide a custom Identifier object that adheres to this interface. You should not directly inherit from SimpleIdentifier, inherit from object instead and make sure it includes all properties and methods exposed by this class. """ def __init__(self, person, client=None, nick=None, fullname=None): self._person = person self._client = client self._nick = nick self._fullname = fullname @property def person(self): """This needs to return the part of the identifier pointing to a person.""" return self._person @property def client(self): """This needs to return the part of the identifier pointing to a client from which a person is sending a message from. Returns None is unspecified""" return self._client @property def nick(self): """This needs to return a short display name for this identifier e.g. gbin. Returns None is unspecified""" return self._nick @property def fullname(self): """This needs to return a long display name for this identifier e.g. Guillaume Binet. Returns None is unspecified""" return self._fullname aclattr = person def __unicode__(self): if self.client: return f'{self._person}/{self._client}' return f'{self._person}' __str__ = __unicode__ def __eq__(self, other): if not isinstance(other, Person): return False return self.person == other.person # noinspection PyAbstractClass class TestOccupant(TestPerson, RoomOccupant): """ This is a MUC occupant represented as a string. DO NOT USE THIS DIRECTLY AS IT IS NOT COMPATIBLE WITH MOST BACKENDS, """ def __init__(self, person, room): super().__init__(person) self._room = room @property def room(self): return self._room def __unicode__(self): return self._person + '@' + str(self._room) __str__ = __unicode__ def __eq__(self, other): return self.person == other.person and self.room == other.room class TestRoom(Room): def invite(self, *args): pass def __init__(self, name, occupants=None, topic=None, bot=None): """ :param name: Name of the room :param occupants: Occupants of the room :param topic: The MUC's topic """ if occupants is None: occupants = [] self._occupants = occupants self._topic = topic self._bot = bot self._name = name self._bot_mucid = TestOccupant(self._bot.bot_config.BOT_IDENTITY['username'], self._name) @property def occupants(self): return self._occupants def find_croom(self): """ find back the canonical room from a this room""" for croom in self._bot._rooms: if croom == self: return croom return None @property def joined(self): room = self.find_croom() if room: return self._bot_mucid in room.occupants return False def join(self, username=None, password=None): if self.joined: logging.warning('Attempted to join room %s, but already in this room.', self) return if not self.exists: log.debug("Room %s doesn't exist yet, creating it.", self) self.create() room = self.find_croom() room._occupants.append(self._bot_mucid) log.info('Joined room %s.', self) self._bot.callback_room_joined(room) def leave(self, reason=None): if not self.joined: logging.warning('Attempted to leave room %s, but not in this room.', self) return room = self.find_croom() room._occupants.remove(self._bot_mucid) log.info('Left room %s.', self) self._bot.callback_room_left(room) @property def exists(self): return self.find_croom() is not None def create(self): if self.exists: logging.warning('Room %s already created.', self) return self._bot._rooms.append(self) log.info('Created room %s.', self) def destroy(self): if not self.exists: logging.warning("Cannot destroy room %s, it doesn't exist.", self) return self._bot._rooms.remove(self) log.info('Destroyed room %s.', self) @property def topic(self): return self._topic @topic.setter def topic(self, topic): self._topic = topic room = self.find_croom() room._topic = self._topic log.info('Topic for room %s set to %s.', self, topic) self._bot.callback_room_topic(self) def __unicode__(self): return self._name def __str__(self): return self._name def __eq__(self, other): return self._name == other._name class TestBackend(ErrBot): def change_presence(self, status: str = ONLINE, message: str = '') -> None: pass def __init__(self, config): config.BOT_LOG_LEVEL = logging.DEBUG config.CHATROOM_PRESENCE = ('testroom',) # we are testing with simple identfiers config.BOT_IDENTITY = {'username': 'err'} # we are testing with simple identfiers self.bot_identifier = self.build_identifier('Err') # whatever super().__init__(config) self.incoming_stanza_queue = Queue() self.outgoing_message_queue = Queue() self.sender = self.build_identifier(config.BOT_ADMINS[0]) # By default, assume this is the admin talking self.reset_rooms() self.md = text() def send_message(self, msg): log.info("\n\n\nMESSAGE:\n%s\n\n\n", msg.body) super().send_message(msg) self.outgoing_message_queue.put(self.md.convert(msg.body)) def send_stream_request(self, user, fsource, name, size, stream_type): # Just dump the stream contents to the message queue self.outgoing_message_queue.put(fsource.read()) def serve_forever(self): self.connect_callback() # notify that the connection occured try: while True: print('waiting on queue') stanza_type, entry = self.incoming_stanza_queue.get() print('message received') if entry == QUIT_MESSAGE: log.info("Stop magic message received, quitting...") break if stanza_type is STZ_MSG: msg = Message(entry) msg.frm = self.sender msg.to = self.bot_identifier # To me only self.callback_message(msg) # implements the mentions. mentioned = [self.build_identifier(word[1:]) for word in entry.split() if word.startswith('@')] if mentioned: self.callback_mention(msg, mentioned) elif stanza_type is STZ_PRE: log.info("Presence stanza received.") self.callback_presence(entry) elif stanza_type is STZ_IQ: log.info("IQ stanza received.") else: log.error("Unknown stanza type.") except EOFError: pass except KeyboardInterrupt: pass finally: log.debug("Trigger disconnect callback") self.disconnect_callback() log.debug("Trigger shutdown") self.shutdown() def connect(self): return def build_identifier(self, text_representation): return TestPerson(text_representation) def build_reply(self, msg, text=None, private=False, threaded=False): msg = self.build_message(text) msg.frm = self.bot_identifier msg.to = msg.frm return msg @property def mode(self): return 'test' def rooms(self): return [r for r in self._rooms if r.joined] def query_room(self, room): try: return [r for r in self._rooms if str(r) == str(room)][0] except IndexError: r = TestRoom(room, bot=self) return r def prefix_groupchat_reply(self, message, identifier): super().prefix_groupchat_reply(message, identifier) message.body = f'@{identifier.nick} {message.body}' def pop_message(self, timeout=5, block=True): return self.outgoing_message_queue.get(timeout=timeout, block=block) def push_message(self, msg): self.incoming_stanza_queue.put((STZ_MSG, msg), timeout=5) def push_presence(self, presence): """ presence must at least duck type base.Presence """ self.incoming_stanza_queue.put((STZ_PRE, presence), timeout=5) def zap_queues(self): while not self.incoming_stanza_queue.empty(): msg = self.incoming_stanza_queue.get(block=False) log.error('Message left in the incoming queue during a test: %s.', msg) while not self.outgoing_message_queue.empty(): msg = self.outgoing_message_queue.get(block=False) log.error('Message left in the outgoing queue during a test: %s.', msg) def reset_rooms(self): """Reset/clear all rooms""" self._rooms = [] class ShallowConfig(object): pass class TestBot(object): """ A minimal bot utilizing the TestBackend, for use with unit testing. Only one instance of this class should globally be active at any one time. End-users should not use this class directly. Use :func:`~errbot.backends.test.testbot` or :class:`~errbot.backends.test.FullStackTest` instead, which use this class under the hood. """ bot_thread = None def __init__(self, extra_plugin_dir=None, loglevel=logging.DEBUG, extra_config=None): self.setup(extra_plugin_dir=extra_plugin_dir, loglevel=loglevel, extra_config=extra_config) def setup(self, extra_plugin_dir=None, loglevel=logging.DEBUG, extra_config=None): """ :param extra_config: Piece of extra configuration you want to inject to the config. :param extra_plugin_dir: Path to a directory from which additional plugins should be loaded. :param loglevel: Logging verbosity. Expects one of the constants defined by the logging module. """ tempdir = mkdtemp() # This is for test isolation. config = ShallowConfig() config.__dict__.update(importlib.import_module('errbot.config-template').__dict__) config.BOT_DATA_DIR = tempdir config.BOT_LOG_FILE = tempdir + sep + 'log.txt' config.STORAGE = 'Memory' if extra_config is not None: log.debug('Merging %s to the bot config.', repr(extra_config)) for k, v in extra_config.items(): setattr(config, k, v) # reset logging to console logging.basicConfig(format='%(levelname)s:%(message)s') file = logging.FileHandler(config.BOT_LOG_FILE, encoding='utf-8') self.logger = logging.getLogger('') self.logger.setLevel(loglevel) self.logger.addHandler(file) config.BOT_EXTRA_PLUGIN_DIR = extra_plugin_dir config.BOT_LOG_LEVEL = loglevel self.bot_config = config def start(self): """ Start the bot Calling this method when the bot has already started will result in an Exception being raised. """ if self.bot_thread is not None: raise Exception("Bot has already been started") self._bot = setup_bot('Test', self.logger, self.bot_config) self.bot_thread = Thread(target=self.bot.serve_forever, name='TestBot main thread') self.bot_thread.setDaemon(True) self.bot_thread.start() self.bot.push_message("!echo ready") # Ensure bot is fully started and plugins are loaded before returning for i in range(60): # Gobble initial error messages... if self.bot.pop_message(timeout=1) == "ready": break else: raise AssertionError('The "ready" message has not been received (timeout).') @property def bot(self) -> ErrBot: return self._bot def stop(self): """ Stop the bot Calling this method before the bot has started will result in an Exception being raised. """ if self.bot_thread is None: raise Exception("Bot has not yet been started") self.bot.push_message(QUIT_MESSAGE) self.bot_thread.join() reset_app() # empty the bottle ... hips! log.info("Main bot thread quits") self.bot.zap_queues() self.bot.reset_rooms() self.bot_thread = None def pop_message(self, timeout=5, block=True): return self.bot.pop_message(timeout, block) def push_message(self, msg): return self.bot.push_message(msg) def push_presence(self, presence): """ presence must at least duck type base.Presence """ return self.bot.push_presence(presence) def exec_command(self, command, timeout=5): """ Execute a command and return the first response. This makes more py.test'ist like: assert 'blah' in exec_command('!hello') """ self.bot.push_message(command) return self.bot.pop_message(timeout) def zap_queues(self): return self.bot.zap_queues() def assertCommand(self, command, response, timeout=5, dedent=False): """Assert the given command returns the given response""" if dedent: command = '\n'.join(textwrap.dedent(command).splitlines()[1:]) self.bot.push_message(command) msg = self.bot.pop_message(timeout) assert response in msg, f'{response} not in {msg}.' def assertCommandFound(self, command, timeout=5): """Assert the given command exists""" self.bot.push_message(command) assert 'not found' not in self.bot.pop_message(timeout) def inject_mocks(self, plugin_name: str, mock_dict: dict): """Inject mock objects into the plugin mock_dict = { 'field_1': obj_1, 'field_2': obj_2, } testbot.inject_mocks(HelloWorld, mock_dict) assert 'blah' in testbot.exec_command('!hello') """ plugin = self.bot.plugin_manager.get_plugin_obj_by_name(plugin_name) if plugin is None: raise Exception(f'"{plugin_name}" is not loaded.') for field, mock_obj in mock_dict.items(): if not hasattr(plugin, field): raise ValueError(f'No property/attribute named "{field}" attached.') setattr(plugin, field, mock_obj) class FullStackTest(unittest.TestCase, TestBot): """ Test class for use with Python's unittest module to write tests against a fully functioning bot. For example, if you wanted to test the builtin `!about` command, you could write a test file with the following:: from errbot.backends.test import FullStackTest class TestCommands(FullStackTest): def test_about(self): self.push_message('!about') self.assertIn('Err version', self.pop_message()) """ def setUp(self, extra_plugin_dir=None, extra_test_file=None, loglevel=logging.DEBUG, extra_config=None): """ :param extra_plugin_dir: Path to a directory from which additional plugins should be loaded. :param extra_test_file: [Deprecated but kept for backward-compatibility, use extra_plugin_dir instead] Path to an additional plugin which should be loaded. :param loglevel: Logging verbosity. Expects one of the constants defined by the logging module. :param extra_config: Piece of extra bot config in a dict. """ if extra_plugin_dir is None and extra_test_file is not None: extra_plugin_dir = sep.join(abspath(extra_test_file).split(sep)[:-2]) self.setup(extra_plugin_dir=extra_plugin_dir, loglevel=loglevel, extra_config=extra_config) self.start() def tearDown(self): self.stop() @pytest.fixture def testbot(request) -> TestBot: """ Pytest fixture to write tests against a fully functioning bot. For example, if you wanted to test the builtin `!about` command, you could write a test file with the following:: def test_about(testbot): testbot.push_message('!about') assert "Err version" in testbot.pop_message() It's possible to provide additional configuration to this fixture, by setting variables at module level or as class attributes (the latter taking precedence over the former). For example:: extra_plugin_dir = '/foo/bar' def test_about(testbot): testbot.push_message('!about') assert "Err version" in testbot.pop_message() ..or:: extra_plugin_dir = '/foo/bar' class Tests(object): # Wins over `extra_plugin_dir = '/foo/bar'` above extra_plugin_dir = '/foo/baz' def test_about(self, testbot): testbot.push_message('!about') assert "Err version" in testbot.pop_message() ..to load additional plugins from the directory `/foo/bar` or `/foo/baz` respectively. This works for the following items, which are passed to the constructor of :class:`~errbot.backends.test.TestBot`: * `extra_plugin_dir` * `loglevel` """ def on_finish(): bot.stop() # setup the logging to something digestable. logger = logging.getLogger('') logging.getLogger('MARKDOWN').setLevel(logging.ERROR) # this one is way too verbose in debug logger.setLevel(logging.DEBUG) console_hdlr = logging.StreamHandler(sys.stdout) console_hdlr.setFormatter(logging.Formatter("%(levelname)-8s %(name)-25s %(message)s")) logger.handlers = [] logger.addHandler(console_hdlr) kwargs = {} for attr, default in (('extra_plugin_dir', None), ('extra_config', None), ('loglevel', logging.DEBUG),): if hasattr(request, 'instance'): kwargs[attr] = getattr(request.instance, attr, None) if kwargs[attr] is None: kwargs[attr] = getattr(request.module, attr, default) bot = TestBot(**kwargs) bot.start() request.addfinalizer(on_finish) return bot errbot-6.1.1+ds/errbot/backends/text.plug000066400000000000000000000001451355337103200203440ustar00rootroot00000000000000[Core] Name = Text Module = text [Documentation] Description = This is the text backend for Errbot. errbot-6.1.1+ds/errbot/backends/text.py000066400000000000000000000304661355337103200200360ustar00rootroot00000000000000import logging import sys from time import sleep import re import copyreg from ansi.color import fg, fx from pygments import highlight from pygments.formatters import Terminal256Formatter from pygments.lexers import get_lexer_by_name from errbot.rendering import ansi, text, xhtml, imtext from errbot.rendering.ansiext import enable_format, ANSI_CHRS, AnsiExtension from errbot.backends.base import Message, Person, Presence, ONLINE, OFFLINE, Room, RoomOccupant from errbot.core import ErrBot from errbot.logs import console_hdlr from markdown import Markdown from markdown.extensions.extra import ExtraExtension log = logging.getLogger(__name__) ENCODING_INPUT = sys.stdin.encoding ANSI = hasattr(sys.stderr, 'isatty') and sys.stderr.isatty() enable_format('borderless', ANSI_CHRS, borders=False) def borderless_ansi(): """This makes a converter from markdown to ansi (console) format. It can be called like this: from errbot.rendering import ansi md_converter = ansi() # you need to cache the converter ansi_txt = md_converter.convert(md_txt) """ md = Markdown(output_format='borderless', extensions=[ExtraExtension(), AnsiExtension()]) md.stripTopLevelTags = False return md class TextPerson(Person): """ Simple Person implementation which represents users as simple text strings. """ def __init__(self, person, client=None, nick=None, fullname=None): self._person = person self._client = client self._nick = nick self._fullname = fullname @property def person(self): return self._person @property def client(self): return self._client @property def nick(self): return self._nick @property def fullname(self): return self._fullname @property def aclattr(self): return str(self) def __str__(self): return '@' + self._person def __eq__(self, other): if not isinstance(other, Person): return False return self.person == other.person def __hash__(self): return self.person.__hash__() class TextRoom(Room): def __init__(self, name, bot): self._topic = '' self._joined = False self.name = name self._bot = bot # fill up the room with a coherent set of identities. self._occupants = [TextOccupant('somebody', self), TextOccupant(TextPerson(bot.bot_config.BOT_ADMINS[0]), self), TextOccupant(bot.bot_identifier, self)] def join(self, username=None, password=None): self._joined = True def leave(self, reason=None): self._joined = False def create(self): self._joined = True def destroy(self): self._joined = False @property def exists(self): return True @property def joined(self): return self._joined @property def topic(self): return self._topic @topic.setter def topic(self, topic): self._topic = topic @property def occupants(self): return self._occupants def invite(self, *args): pass def __str__(self): return '#' + self.name def __eq__(self, other): return self.name == other.name def __hash__(self): return self.name.__hash__() class TextOccupant(TextPerson, RoomOccupant): def __init__(self, person, room): super().__init__(person) self._room = room @property def room(self): return self._room def __str__(self): return f'#{self._room.name}/{self._person.person}' def __eq__(self, other): return self.person == other.person and self.room == other.room def __hash__(self): return self.person.__hash__() + self.room.__hash__() INTRO = """ --- You start as a **bot admin in a one-on-one conversation** with the bot. ### Context of the chat - Use `!inroom`{:color='blue'} to switch to a room conversation. - Use `!inperson`{:color='blue'} to switch back to a one-on-one conversation. - Use `!asuser`{:color='green'} to talk as a normal user. - Use `!asadmin`{:color='red'} to switch back as a bot admin. ### Preferences - Use `!ml`{:color='yellow'} to flip on/off the multiline mode (Enter twice at the end to send). --- """ class TextBackend(ErrBot): def __init__(self, config): super().__init__(config) log.debug("Text Backend Init.") if hasattr(self.bot_config, 'BOT_IDENTITY') and 'username' in self.bot_config.BOT_IDENTITY: self.bot_identifier = self.build_identifier(self.bot_config.BOT_IDENTITY['username']) else: # Just a default identity for the bot if nothing has been specified. self.bot_identifier = self.build_identifier('@errbot') log.debug('Bot username set at %s.', self.bot_identifier) self._inroom = False self._rooms = [] self._multiline = False self.demo_mode = self.bot_config.TEXT_DEMO_MODE if hasattr(self.bot_config, 'TEXT_DEMO_MODE') else False if not self.demo_mode: self.md_html = xhtml() # for more debug feedback on md self.md_text = text() # for more debug feedback on md self.md_borderless_ansi = borderless_ansi() self.md_im = imtext() self.md_lexer = get_lexer_by_name("md", stripall=True) self.md_ansi = ansi() self.html_lexer = get_lexer_by_name("html", stripall=True) self.terminal_formatter = Terminal256Formatter(style='paraiso-dark') self.user = self.build_identifier(self.bot_config.BOT_ADMINS[0]) self._register_identifiers_pickling() @staticmethod def _unpickle_identifier(identifier_str): return TextBackend.__build_identifier(identifier_str) @staticmethod def _pickle_identifier(identifier): return TextBackend._unpickle_identifier, (str(identifier),) def _register_identifiers_pickling(self): """ Register identifiers pickling. """ TextBackend.__build_identifier = self.build_identifier for cls in (TextPerson, TextOccupant, TextRoom): copyreg.pickle(cls, TextBackend._pickle_identifier, TextBackend._unpickle_identifier) def serve_forever(self): self.readline_support() if not self._rooms: # artificially join a room if None were specified. self.query_room('#testroom').join() if self.demo_mode: # disable the console logging once it is serving in demo mode. root = logging.getLogger() root.removeHandler(console_hdlr) root.addHandler(logging.NullHandler()) self.connect_callback() # notify that the connection occured self.callback_presence(Presence(identifier=self.user, status=ONLINE)) self.send_message(Message(INTRO)) try: while True: if self._inroom: frm = TextOccupant(self.user, self.rooms[0]) to = self.rooms[0] else: frm = self.user to = self.bot_identifier print() full_msg = '' while True: prompt = '[␍] ' if full_msg else '>>> ' if ANSI or self.demo_mode: color = fg.red if self.user.person in self.bot_config.BOT_ADMINS[0] else fg.green prompt = f'{color}[{frm} ➡ {to}] {fg.cyan}{prompt}{fx.reset}' entry = input(prompt) else: entry = input(f'[{frm} ➡ {to}] {prompt}') if not self._multiline: full_msg = entry break if not entry: break full_msg += entry + '\n' msg = Message(full_msg) msg.frm = frm msg.to = to self.callback_message(msg) mentioned = [self.build_identifier(word) for word in re.findall(r'(?<=\s)@[\w]+', entry)] if mentioned: self.callback_mention(msg, mentioned) sleep(.5) except EOFError: pass except KeyboardInterrupt: pass finally: # simulate some real presence self.callback_presence(Presence(identifier=self.user, status=OFFLINE)) log.debug("Trigger disconnect callback") self.disconnect_callback() log.debug("Trigger shutdown") self.shutdown() def readline_support(self): try: # Load readline for better editing/history behaviour import readline # Implement a simple completer for commands def completer(txt, state): options = [i for i in self.all_commands if i.startswith(txt)] if txt else list(self.all_commands.keys()) if state < len(options): return options[state] readline.parse_and_bind("tab: complete") readline.set_completer(completer) except ImportError: # Readline is Unix-only log.debug("Python readline module is not available") def send_message(self, msg): if self.demo_mode: print(self.md_ansi.convert(msg.body)) else: bar = '\n╌╌[{mode}]' + ('╌' * 60) super().send_message(msg) print(bar.format(mode='MD ')) if ANSI: print(highlight(msg.body, self.md_lexer, self.terminal_formatter)) else: print(msg.body) print(bar.format(mode='HTML')) html = self.md_html.convert(msg.body) if ANSI: print(highlight(html, self.html_lexer, self.terminal_formatter)) else: print(html) print(bar.format(mode='TEXT')) print(self.md_text.convert(msg.body)) print(bar.format(mode='IM ')) print(self.md_im.convert(msg.body)) if ANSI: print(bar.format(mode='ANSI')) print(self.md_ansi.convert(msg.body)) print(bar.format(mode='BORDERLESS')) print(self.md_borderless_ansi.convert(msg.body)) print('\n\n') def add_reaction(self, msg: Message, reaction: str) -> None: # this is like the Slack backend's add_reaction self._react('+', msg, reaction) def remove_reaction(self, msg: Message, reaction: str) -> None: self._react('-', msg, reaction) def _react(self, sign, msg, reaction): self.send(msg.frm, f'reaction {sign}:{reaction}:', in_reply_to=msg) def change_presence(self, status: str = ONLINE, message: str = '') -> None: log.debug("*** Changed presence to [%s] %s", (status, message)) def build_identifier(self, text_representation): if text_representation.startswith('#'): rem = text_representation[1:] if '/' in text_representation: room, person = rem.split('/') return TextOccupant(TextPerson(person), TextRoom(room, self)) return self.query_room('#' + rem) if not text_representation.startswith('@'): raise ValueError('An identifier for the Text backend needs to start with # for a room or @ for a person.') return TextPerson(text_representation[1:]) def build_reply(self, msg, text=None, private=False, threaded=False): response = self.build_message(text) response.frm = self.bot_identifier if private: response.to = msg.frm else: response.to = msg.frm.room if isinstance(msg.frm, RoomOccupant) else msg.frm return response @property def mode(self): return 'text' def query_room(self, room): if not room.startswith('#'): raise ValueError('A Room name must start by #.') text_room = TextRoom(room[1:], self) if text_room not in self._rooms: self._rooms.insert(0, text_room) else: self._rooms.insert(0, self._rooms.pop(self._rooms.index(text_room))) return text_room @property def rooms(self): return self._rooms def prefix_groupchat_reply(self, message, identifier): message.body = f'{identifier.person} {message.body}' errbot-6.1.1+ds/errbot/backends/xmpp.plug000066400000000000000000000001451355337103200203440ustar00rootroot00000000000000[Core] Name = XMPP Module = xmpp [Documentation] Description = This is the XMPP backend for Errbot. errbot-6.1.1+ds/errbot/backends/xmpp.py000066400000000000000000000512701355337103200200320ustar00rootroot00000000000000import logging import sys from functools import lru_cache from threading import Thread from time import sleep from errbot.backends.base import Message, Room, Presence, RoomNotJoinedError, Identifier, RoomOccupant, Person from errbot.backends.base import ONLINE, OFFLINE, AWAY, DND from errbot.core import ErrBot from errbot.rendering import text, xhtml, xhtmlim log = logging.getLogger(__name__) try: from sleekxmpp import ClientXMPP from sleekxmpp.xmlstream import resolver, cert from sleekxmpp import JID from sleekxmpp.exceptions import IqError except ImportError: log.exception("Could not start the XMPP backend") log.fatal(""" If you intend to use the XMPP backend please install the support for XMPP with: pip install errbot[XMPP] """) sys.exit(-1) # LRU to cache the JID queries. IDENTIFIERS_LRU = 1024 class XMPPIdentifier(Identifier): """ This class is the parent and the basic contract of all the ways the backends are identifying a person on their system. """ def __init__(self, node, domain, resource): if not node: raise Exception('An XMPPIdentifier needs to have a node.') if not domain: raise Exception('An XMPPIdentifier needs to have a domain.') self._node = node self._domain = domain self._resource = resource @property def node(self): return self._node @property def domain(self): return self._domain @property def resource(self): return self._resource @property def person(self): return self._node + '@' + self._domain @property def nick(self): return self._node @property def fullname(self): return None # Not supported by default on XMPP. @property def client(self): return self._resource def __str__(self): answer = self._node + '@' + self._domain # don't call .person: see below if self._resource: answer += '/' + self._resource return answer def __unicode__(self): return str(self.__str__()) def __eq__(self, other): if not isinstance(other, XMPPIdentifier): log.debug("Weird, you are comparing an XMPPIdentifier to a %s", type(other)) return False return self._domain == other._domain and self._node == other._node and self._resource == other._resource class XMPPPerson(XMPPIdentifier, Person): aclattr = XMPPIdentifier.person def __eq__(self, other): if not isinstance(other, XMPPPerson): log.debug("Weird, you are comparing an XMPPPerson to a %s", type(other)) return False return self._domain == other._domain and self._node == other._node class XMPPRoom(XMPPIdentifier, Room): def __init__(self, room_jid, bot): self._bot = bot self.xep0045 = self._bot.conn.client.plugin['xep_0045'] node, domain, resource = split_identifier(room_jid) super().__init__(node, domain, resource) def join(self, username=None, password=None): """ Join the room. If the room does not exist yet, this will automatically call :meth:`create` on it first. """ room = str(self) self.xep0045.joinMUC(room, username, password=password, wait=True) self._bot.conn.add_event_handler(f'muc::{room}::got_online', self._bot.user_joined_chat) self._bot.conn.add_event_handler(f'muc::{room}::got_offline', self._bot.user_left_chat) # Room configuration can only be done once a MUC presence stanza # has been received from the server. This HAS to take place in a # separate thread because of how SleekXMPP processes these stanzas. t = Thread(target=self.configure) t.setDaemon(True) t.start() self._bot.callback_room_joined(self) log.info('Joined room %s.', room) def leave(self, reason=None): """ Leave the room. :param reason: An optional string explaining the reason for leaving the room """ if reason is None: reason = "" room = str(self) try: self.xep0045.leaveMUC(room=room, nick=self.xep0045.ourNicks[room], msg=reason) self._bot.conn.del_event_handler(f'muc::{room}::got_online', self._bot.user_joined_chat) self._bot.conn.del_event_handler(f'muc::{room}::got_offline', self._bot.user_left_chat) log.info('Left room %s.', room) self._bot.callback_room_left(self) except KeyError: log.debug('Trying to leave %s while not in this room.', room) def create(self): """ Not supported on this back-end (SleekXMPP doesn't support it). Will join the room to ensure it exists, instead. """ logging.warning( "XMPP back-end does not support explicit creation, joining room " "instead to ensure it exists." ) self.join(username=str(self)) def destroy(self): """ Destroy the room. Calling this on a non-existing room is a no-op. """ self.xep0045.destroy(str(self)) log.info('Destroyed room %s.', self) @property def exists(self): """ Boolean indicating whether this room already exists or not. :getter: Returns `True` if the room exists, `False` otherwise. """ logging.warning( 'XMPP back-end does not support determining if a room exists. Returning the result of joined instead.') return self.joined @property def joined(self): """ Boolean indicating whether this room has already been joined. :getter: Returns `True` if the room has been joined, `False` otherwise. """ return str(self) in self.xep0045.getJoinedRooms() @property def topic(self): """ The room topic. :getter: Returns the topic (a string) if one is set, `None` if no topic has been set at all. :raises: :class:`~RoomNotJoinedError` if the room has not yet been joined. """ if not self.joined: raise RoomNotJoinedError("Must be in a room in order to see the topic.") try: return self._bot._room_topics[str(self)] except KeyError: return None @topic.setter def topic(self, topic): """ Set the room's topic. :param topic: The topic to set. """ # Not supported by SleekXMPP at the moment :( raise NotImplementedError("Setting the topic is not supported on this back-end.") @property def occupants(self): """ The room's occupants. :getter: Returns a list of :class:`~errbot.backends.base.MUCOccupant` instances. :raises: :class:`~MUCNotJoinedError` if the room has not yet been joined. """ occupants = [] try: for occupant in self.xep0045.rooms[str(self)].values(): room_node, room_domain, _ = split_identifier(occupant['room']) nick = occupant['nick'] occupants.append(XMPPRoomOccupant(room_node, room_domain, nick, self)) except KeyError: raise RoomNotJoinedError("Must be in a room in order to see occupants.") return occupants def invite(self, *args): """ Invite one or more people into the room. :*args: One or more JID's to invite into the room. """ room = str(self) for jid in args: self.xep0045.invite(room, jid) log.info('Invited %s to %s.', jid, room) def configure(self): """ Configure the room. Currently this simply sets the default room configuration as received by the server. May be extended in the future to set a custom room configuration instead. """ room = str(self) affiliation = None while affiliation is None: sleep(0.5) affiliation = self.xep0045.getJidProperty( room=room, nick=self.xep0045.ourNicks[room], jidProperty='affiliation' ) if affiliation == "owner": log.debug('Configuring room %s: we have owner affiliation.', room) form = self.xep0045.getRoomConfig(room) self.xep0045.configureRoom(room, form) else: log.debug("Not configuring room %s: we don't have owner affiliation (affiliation=%s)", room, affiliation) class XMPPRoomOccupant(XMPPPerson, RoomOccupant): def __init__(self, node, domain, resource, room): super().__init__(node, domain, resource) self._room = room @property def person(self): return str(self) # this is the full identifier. @property def real_jid(self): """ The JID of the room occupant, they used to login. Will only work if the errbot is moderator in the MUC or it is not anonymous. """ room_jid = self._node + '@' + self._domain jid = JID(self._room.xep0045.getJidProperty(room_jid, self.resource, 'jid')) return jid.bare @property def room(self): return self._room nick = XMPPPerson.resource class XMPPConnection(object): def __init__(self, jid, password, feature=None, keepalive=None, ca_cert=None, server=None, use_ipv6=None, bot=None): if feature is None: feature = {} self._bot = bot self.connected = False self.server = server self.client = ClientXMPP(jid, password, plugin_config={'feature_mechanisms': feature}) self.client.register_plugin('xep_0030') # Service Discovery self.client.register_plugin('xep_0045') # Multi-User Chat self.client.register_plugin('xep_0199') # XMPP Ping self.client.register_plugin('xep_0203') # XMPP Delayed messages self.client.register_plugin('xep_0249') # XMPP direct MUC invites if keepalive is not None: self.client.whitespace_keepalive = True # Just in case SleekXMPP's default changes to False in the future self.client.whitespace_keepalive_interval = keepalive if use_ipv6 is not None: self.client.use_ipv6 = use_ipv6 self.client.ca_certs = ca_cert # Used for TLS certificate validation self.client.add_event_handler("session_start", self.session_start) def session_start(self, _): self.client.send_presence() self.client.get_roster() def connect(self): if not self.connected: if self.server is not None: self.client.connect(self.server) else: self.client.connect() self.connected = True return self def disconnect(self): self.client.disconnect(wait=True) self.connected = False def serve_forever(self): self.client.process(block=True) def add_event_handler(self, name, cb): self.client.add_event_handler(name, cb) def del_event_handler(self, name, cb): self.client.del_event_handler(name, cb) XMPP_TO_ERR_STATUS = {'available': ONLINE, 'away': AWAY, 'dnd': DND, 'unavailable': OFFLINE} def split_identifier(txtrep): split_jid = txtrep.split('@', 1) node, domain = '@'.join(split_jid[:-1]), split_jid[-1] if domain.find('/') != -1: domain, resource = domain.split('/', 1) else: resource = None return node, domain, resource class XMPPBackend(ErrBot): room_factory = XMPPRoom roomoccupant_factory = XMPPRoomOccupant def __init__(self, config): super().__init__(config) identity = config.BOT_IDENTITY self.jid = identity['username'] # backward compatibility self.password = identity['password'] self.server = identity.get('server', None) self.feature = config.__dict__.get('XMPP_FEATURE_MECHANISMS', {}) self.keepalive = config.__dict__.get('XMPP_KEEPALIVE_INTERVAL', None) self.ca_cert = config.__dict__.get('XMPP_CA_CERT_FILE', '/etc/ssl/certs/ca-certificates.crt') self.xhtmlim = config.__dict__.get('XMPP_XHTML_IM', False) self.use_ipv6 = config.__dict__.get('XMPP_USE_IPV6', None) # generic backend compatibility self.bot_identifier = self._build_person(self.jid) self.conn = self.create_connection() self.conn.add_event_handler("message", self.incoming_message) self.conn.add_event_handler("session_start", self.connected) self.conn.add_event_handler("disconnected", self.disconnected) # presence related handlers self.conn.add_event_handler("got_online", self.contact_online) self.conn.add_event_handler("got_offline", self.contact_offline) self.conn.add_event_handler("changed_status", self.user_changed_status) # MUC subject events self.conn.add_event_handler("groupchat_subject", self.chat_topic) self._room_topics = {} self.md_xhtml = xhtml() self.md_text = text() def create_connection(self): return XMPPConnection( jid=self.jid, # textual and original representation password=self.password, feature=self.feature, keepalive=self.keepalive, ca_cert=self.ca_cert, server=self.server, use_ipv6=self.use_ipv6, bot=self ) def _build_room_occupant(self, txtrep): node, domain, resource = split_identifier(txtrep) return self.roomoccupant_factory(node, domain, resource, self.query_room(node + '@' + domain)) def _build_person(self, txtrep): return XMPPPerson(*split_identifier(txtrep)) def incoming_message(self, xmppmsg): """Callback for message events""" if xmppmsg['type'] == "error": log.warning("Received error message: %s", xmppmsg) return msg = Message(xmppmsg['body']) if 'html' in xmppmsg.keys(): msg.html = xmppmsg['html'] log.debug("incoming_message from: %s", msg.frm) if xmppmsg['type'] == 'groupchat': msg.frm = self._build_room_occupant(xmppmsg['from'].full) msg.to = msg.frm.room else: msg.frm = self._build_person(xmppmsg['from'].full) msg.to = self._build_person(xmppmsg['to'].full) msg.nick = xmppmsg['mucnick'] msg.delayed = bool(xmppmsg['delay']._get_attr('stamp')) # this is a bug in sleekxmpp it should be ['from'] self.callback_message(msg) def _idd_from_event(self, event): txtrep = event['from'].full return self._build_room_occupant(txtrep) if 'muc' in event else self._build_person(txtrep) def contact_online(self, event): log.debug('contact_online %s.', event) self.callback_presence(Presence(identifier=self._idd_from_event(event), status=ONLINE)) def contact_offline(self, event): log.debug('contact_offline %s.', event) self.callback_presence(Presence(identifier=self._idd_from_event(event), status=OFFLINE)) def user_joined_chat(self, event): log.debug('user_join_chat %s', event) self.callback_presence(Presence(identifier=self._idd_from_event(event), status=ONLINE)) def user_left_chat(self, event): log.debug('user_left_chat %s', event) self.callback_presence(Presence(identifier=self._idd_from_event(event), status=OFFLINE)) def chat_topic(self, event): log.debug("chat_topic %s.", event) room = event.values['mucroom'] topic = event.values['subject'] if topic == "": topic = None self._room_topics[room] = topic room = XMPPRoom(event.values['mucroom'], self) self.callback_room_topic(room) def user_changed_status(self, event): log.debug('user_changed_status %s.', event) errstatus = XMPP_TO_ERR_STATUS.get(event['type'], None) message = event['status'] if not errstatus: errstatus = event['type'] self.callback_presence(Presence(identifier=self._idd_from_event(event), status=errstatus, message=message)) def connected(self, data): """Callback for connection events""" self.connect_callback() def disconnected(self, data): """Callback for disconnection events""" self.disconnect_callback() def send_message(self, msg): super().send_message(msg) log.debug('send_message to %s', msg.to) # We need to unescape the unicode characters (not the markup incompatible ones) mhtml = xhtmlim.unescape(self.md_xhtml.convert(msg.body)) if self.xhtmlim else None self.conn.client.send_message(mto=str(msg.to), mbody=self.md_text.convert(msg.body), mhtml=mhtml, mtype='chat' if msg.is_direct else 'groupchat') def change_presence(self, status: str = ONLINE, message: str = '') -> None: log.debug('Change bot status to %s, message %s.', status, message) self.conn.client.send_presence(pshow=status, pstatus=message) def serve_forever(self): self.conn.connect() try: self.conn.serve_forever() finally: log.debug("Trigger disconnect callback") self.disconnect_callback() log.debug("Trigger shutdown") self.shutdown() @lru_cache(IDENTIFIERS_LRU) def build_identifier(self, txtrep): log.debug('build identifier for %s', txtrep) try: xep0030 = self.conn.client.plugin['xep_0030'] info = xep0030.get_info(jid=txtrep) disco_info = info['disco_info'] if disco_info: # Hipchat can return an empty response here. for category, typ, _, name in disco_info['identities']: if category == 'conference': log.debug('This is a room ! %s', txtrep) return self.query_room(txtrep) if category == 'client' and 'http://jabber.org/protocol/muc' in info['disco_info']['features']: log.debug('This is room occupant ! %s', txtrep) return self._build_room_occupant(txtrep) except IqError as iq: log.debug('xep_0030 is probably not implemented on this server. %s.', iq) log.debug('This is a person ! %s', txtrep) return self._build_person(txtrep) def build_reply(self, msg, text=None, private=False, threaded=False): response = self.build_message(text) response.frm = self.bot_identifier if msg.is_group and not private: # stripped returns the full bot@conference.domain.tld/chat_username # but in case of a groupchat, we should only try to send to the MUC address # itself (bot@conference.domain.tld) response.to = XMPPRoom(msg.frm.node + '@' + msg.frm.domain, self) elif msg.is_direct: # preserve from in case of a simple chat message. # it is either a user to user or user_in_chatroom to user case. # so we need resource. response.to = msg.frm elif hasattr(msg.to, 'person') and msg.to.person == self.bot_config.BOT_IDENTITY['username']: # This is a direct private message, not initiated through a MUC. Use # stripped to remove the resource so that the response goes to the # client with the highest priority response.to = XMPPPerson(msg.frm.node, msg.frm.domain, None) else: # This is a private message that was initiated through a MUC. Don't use # stripped here to retain the resource, else the XMPP server doesn't # know which user we're actually responding to. response.to = msg.frm return response @property def mode(self): return 'xmpp' def rooms(self): """ Return a list of rooms the bot is currently in. :returns: A list of :class:`~errbot.backends.base.XMPPMUCRoom` instances. """ xep0045 = self.conn.client.plugin['xep_0045'] return [XMPPRoom(room, self) for room in xep0045.getJoinedRooms()] def query_room(self, room): """ Query a room for information. :param room: The JID/identifier of the room to query for. :returns: An instance of :class:`~XMPPMUCRoom`. """ return XMPPRoom(room, self) def prefix_groupchat_reply(self, message, identifier): super().prefix_groupchat_reply(message, identifier) message.body = f'@{identifier.nick} {message.body}' def __hash__(self): return 0 errbot-6.1.1+ds/errbot/bootstrap.py000066400000000000000000000213001355337103200173000ustar00rootroot00000000000000from os import path, makedirs import importlib import logging import sys from errbot.core import ErrBot from errbot.plugin_manager import BotPluginManager from errbot.repo_manager import BotRepoManager from errbot.backend_plugin_manager import BackendPluginManager from errbot.storage.base import StoragePluginBase from errbot.utils import PLUGINS_SUBDIR from errbot.logs import format_logs log = logging.getLogger(__name__) HERE = path.dirname(path.abspath(__file__)) CORE_BACKENDS = path.join(HERE, 'backends') CORE_STORAGE = path.join(HERE, 'storage') PLUGIN_DEFAULT_INDEX = 'https://repos.errbot.io/repos.json' def bot_config_defaults(config): if not hasattr(config, 'ACCESS_CONTROLS_DEFAULT'): config.ACCESS_CONTROLS_DEFAULT = {} if not hasattr(config, 'ACCESS_CONTROLS'): config.ACCESS_CONTROLS = {} if not hasattr(config, 'HIDE_RESTRICTED_COMMANDS'): config.HIDE_RESTRICTED_COMMANDS = False if not hasattr(config, 'HIDE_RESTRICTED_ACCESS'): config.HIDE_RESTRICTED_ACCESS = False if not hasattr(config, 'BOT_PREFIX_OPTIONAL_ON_CHAT'): config.BOT_PREFIX_OPTIONAL_ON_CHAT = False if not hasattr(config, 'BOT_PREFIX'): config.BOT_PREFIX = '!' if not hasattr(config, 'BOT_ALT_PREFIXES'): config.BOT_ALT_PREFIXES = () if not hasattr(config, 'BOT_ALT_PREFIX_SEPARATORS'): config.BOT_ALT_PREFIX_SEPARATORS = () if not hasattr(config, 'BOT_ALT_PREFIX_CASEINSENSITIVE'): config.BOT_ALT_PREFIX_CASEINSENSITIVE = False if not hasattr(config, 'DIVERT_TO_PRIVATE'): config.DIVERT_TO_PRIVATE = () if not hasattr(config, 'DIVERT_TO_THREAD'): config.DIVERT_TO_THREAD = () if not hasattr(config, 'MESSAGE_SIZE_LIMIT'): config.MESSAGE_SIZE_LIMIT = 10000 # Corresponds with what HipChat accepts if not hasattr(config, 'GROUPCHAT_NICK_PREFIXED'): config.GROUPCHAT_NICK_PREFIXED = False if not hasattr(config, 'AUTOINSTALL_DEPS'): config.AUTOINSTALL_DEPS = True if not hasattr(config, 'SUPPRESS_CMD_NOT_FOUND'): config.SUPPRESS_CMD_NOT_FOUND = False if not hasattr(config, 'BOT_ASYNC'): config.BOT_ASYNC = True if not hasattr(config, 'BOT_ASYNC_POOLSIZE'): config.BOT_ASYNC_POOLSIZE = 10 if not hasattr(config, 'CHATROOM_PRESENCE'): config.CHATROOM_PRESENCE = () if not hasattr(config, 'CHATROOM_RELAY'): config.CHATROOM_RELAY = () if not hasattr(config, 'REVERSE_CHATROOM_RELAY'): config.REVERSE_CHATROOM_RELAY = () if not hasattr(config, 'CHATROOM_FN'): config.CHATROOM_FN = 'Errbot' if not hasattr(config, 'TEXT_DEMO_MODE'): config.TEXT_DEMO_MODE = True if not hasattr(config, 'BOT_ADMINS'): raise ValueError('BOT_ADMINS missing from config.py.') if not hasattr(config, 'TEXT_COLOR_THEME'): config.TEXT_COLOR_THEME = 'light' if not hasattr(config, 'BOT_ADMINS_NOTIFICATIONS'): config.BOT_ADMINS_NOTIFICATIONS = config.BOT_ADMINS def setup_bot(backend_name: str, logger, config, restore=None) -> ErrBot: # from here the environment is supposed to be set (daemon / non daemon, # config.py in the python path ) bot_config_defaults(config) if hasattr(config, 'BOT_LOG_FORMATTER'): format_logs(formatter=config.BOT_LOG_FORMATTER) else: format_logs(theme_color=config.TEXT_COLOR_THEME) if config.BOT_LOG_FILE: hdlr = logging.FileHandler(config.BOT_LOG_FILE) hdlr.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)-25s %(message)s")) logger.addHandler(hdlr) if hasattr(config, 'BOT_LOG_SENTRY') and config.BOT_LOG_SENTRY: try: from raven.handlers.logging import SentryHandler except ImportError: log.exception( "You have BOT_LOG_SENTRY enabled, but I couldn't import modules " "needed for Sentry integration. Did you install raven? " "(See http://raven.readthedocs.org/en/latest/install/index.html " "for installation instructions)" ) exit(-1) try: if hasattr(config, 'SENTRY_TRANSPORT') and isinstance(config.SENTRY_TRANSPORT, tuple): mod = importlib.import_module(config.SENTRY_TRANSPORT[1]) transport = getattr(mod, config.SENTRY_TRANSPORT[0]) sentryhandler = SentryHandler(config.SENTRY_DSN, level=config.SENTRY_LOGLEVEL, transport=transport) else: sentryhandler = SentryHandler(config.SENTRY_DSN, level=config.SENTRY_LOGLEVEL) logger.addHandler(sentryhandler) except ImportError: log.exception(f'Unable to import selected SENTRY_TRANSPORT - {config.SENTRY_TRANSPORT}') exit(-1) logger.setLevel(config.BOT_LOG_LEVEL) storage_plugin = get_storage_plugin(config) # init the botplugin manager botplugins_dir = path.join(config.BOT_DATA_DIR, PLUGINS_SUBDIR) if not path.exists(botplugins_dir): makedirs(botplugins_dir, mode=0o755) plugin_indexes = getattr(config, 'BOT_PLUGIN_INDEXES', (PLUGIN_DEFAULT_INDEX,)) if isinstance(plugin_indexes, str): plugin_indexes = (plugin_indexes, ) backendpm = BackendPluginManager(config, 'errbot.backends', backend_name, ErrBot, CORE_BACKENDS, getattr(config, 'BOT_EXTRA_BACKEND_DIR', [])) log.info(f'Found Backend plugin: {backendpm.plugin_info.name}') repo_manager = BotRepoManager(storage_plugin, botplugins_dir, plugin_indexes) try: bot = backendpm.load_plugin() botpm = BotPluginManager(storage_plugin, config.BOT_EXTRA_PLUGIN_DIR, config.AUTOINSTALL_DEPS, getattr(config, 'CORE_PLUGINS', None), lambda name, clazz: clazz(bot, name), getattr(config, 'PLUGINS_CALLBACK_ORDER', (None, ))) bot.attach_storage_plugin(storage_plugin) bot.attach_repo_manager(repo_manager) bot.attach_plugin_manager(botpm) bot.initialize_backend_storage() # restore the bot from the restore script if restore: # Prepare the context for the restore script if 'repos' in bot: log.fatal('You cannot restore onto a non empty bot.') sys.exit(-1) log.info(f'**** RESTORING the bot from {restore}') restore_bot_from_backup(restore, bot=bot, log=log) print('Restore complete. You can restart the bot normally') sys.exit(0) errors = bot.plugin_manager.update_plugin_places(repo_manager.get_all_repos_paths()) if errors: log.error('Some plugins failed to load:\n' + '\n'.join(errors.values())) bot._plugin_errors_during_startup = "\n".join(errors.values()) return bot except Exception: log.exception("Unable to load or configure the backend.") exit(-1) def restore_bot_from_backup(backup_filename, *, bot, log): """Restores the given bot by executing the 'backup' script. The backup file is a python script which manually execute a series of commands on the bot to restore it to its previous state. :param backup_filename: the full path to the backup script. :param bot: the bot instance to restore :param log: logger to use during the restoration process """ with open(backup_filename) as f: exec(f.read(), {'log': log, 'bot': bot}) bot.close_storage() def get_storage_plugin(config): """ Find and load the storage plugin :param config: the bot configuration. :return: the storage plugin """ storage_name = getattr(config, 'STORAGE', 'Shelf') extra_storage_plugins_dir = getattr(config, 'BOT_EXTRA_STORAGE_PLUGINS_DIR', None) spm = BackendPluginManager(config, 'errbot.storage', storage_name, StoragePluginBase, CORE_STORAGE, extra_storage_plugins_dir) log.info(f'Found Storage plugin: {spm.plugin_info.name}.') return spm.load_plugin() def bootstrap(bot_class, logger, config, restore=None): """ Main starting point of Errbot. :param bot_class: The backend class inheriting from Errbot you want to start. :param logger: The logger you want to use. :param config: The config.py module. :param restore: Start Errbot in restore mode (from a backup). """ bot = setup_bot(bot_class, logger, config, restore) log.debug(f'Start serving commands from the {bot.mode} backend.') bot.serve_forever() errbot-6.1.1+ds/errbot/botplugin.py000066400000000000000000000670271355337103200173060ustar00rootroot00000000000000import logging import shlex from threading import Timer, current_thread from types import ModuleType from typing import Tuple, Callable, Mapping, Sequence from io import IOBase import re from .storage import StoreMixin, StoreNotOpenError from errbot.backends.base import Message, Presence, Stream, Room, Identifier, ONLINE, Card log = logging.getLogger(__name__) class ValidationException(Exception): pass def recurse_check_structure(sample, to_check): sample_type = type(sample) to_check_type = type(to_check) # Skip this check if the sample is None because it will always be something # other than NoneType when changed from the default. Raising ValidationException # would make no sense then because it would defeat the whole purpose of having # that key in the sample when it could only ever be None. if sample is not None and sample_type != to_check_type: raise ValidationException(f'{sample} [{sample_type}] is not the same type as {to_check} [{to_check_type}].') if sample_type in (list, tuple): for element in to_check: recurse_check_structure(sample[0], element) return if sample_type == dict: for key in sample: if key not in to_check: raise ValidationException(f'{to_check} doesn\'t contain the key {key}.') for key in to_check: if key not in sample: raise ValidationException(f'{to_check} contains an unknown key {key}.') for key in sample: recurse_check_structure(sample[key], to_check[key]) return class CommandError(Exception): """ Use this class to report an error condition from your commands, the command did not proceed for a known "business" reason. """ def __init__(self, reason: str, template: str = None): """ :param reason: the reason for the error in the command. :param template: apply this specific template to report the error. """ self.reason = reason self.template = template def __str__(self): return str(self.reason) class Command(object): """ This is a dynamic definition of an errbot command. """ def __init__(self, function, cmd_type=None, cmd_args=None, cmd_kwargs=None, name=None, doc=None): """ Create a Command definition. :param function: a function or a lambda with the correct signature for the type of command to inject for example `def mycmd(plugin, msg, args)` for a botcmd. Note: the first parameter will be the plugin itself (equivalent to self). :param cmd_type: defaults to `botcmd` but can be any decorator function used for errbot commands. :param cmd_args: the parameters of the decorator. :param cmd_kwargs: the kwargs parameter of the decorator. :param name: defaults to the name of the function you are passing if it is a first class function or needs to be set if you use a lambda. :param doc: defaults to the doc of the given function if it is a first class function. It can be set for a lambda or overridden for a function with this. """ if cmd_type is None: from errbot import botcmd # TODO refactor this out of __init__ so it can be reusable. cmd_type = botcmd if name is None: if function.__name__ == '': raise ValueError('function is a lambda (anonymous), parameter name needs to be set.') name = function.__name__ self.name = name if cmd_kwargs is None: cmd_kwargs = {} if cmd_args is None: cmd_args = () function.__name__ = name if doc: function.__doc__ = doc self.definition = cmd_type(*((function,) + cmd_args), **cmd_kwargs) # noinspection PyAbstractClass class BotPluginBase(StoreMixin): """ This class handle the basic needs of bot plugins like loading, unloading and creating a storage It is the main contract between the plugins and the bot """ def __init__(self, bot, name=None): self.is_activated = False self.current_pollers = [] self.current_timers = [] self.dependencies = [] self._dynamic_plugins = {} self.log = logging.getLogger(f'errbot.plugins.{name}') self.log.debug('Logger for plugin initialized...') self._bot = bot self.plugin_dir = bot.repo_manager.plugin_dir self._name = name super().__init__() @property def name(self) -> str: """ Get the name of this plugin as described in its .plug file. :return: The plugin name. """ return self._name @property def mode(self) -> str: """ Get the current active backend. :return: the mode like 'tox', 'xmpp' etc... """ return self._bot.mode @property def bot_config(self) -> ModuleType: """ Get the bot configuration from config.py. For example you can access: self.bot_config.BOT_DATA_DIR """ # if BOT_ADMINS is just an unique string make it a tuple for backwards # compatibility if isinstance(self._bot.bot_config.BOT_ADMINS, str): self._bot.bot_config.BOT_ADMINS = (self._bot.bot_config.BOT_ADMINS,) return self._bot.bot_config @property def bot_identifier(self) -> Identifier: """ Get bot identifier on current active backend. :return Identifier """ return self._bot.bot_identifier def init_storage(self) -> None: log.debug(f'Init storage for {self.name}.') self.open_storage(self._bot.storage_plugin, self.name) def activate(self) -> None: """ Override if you want to do something at initialization phase (don't forget to super(Gnagna, self).activate()) """ self.init_storage() self._bot.inject_commands_from(self) self._bot.inject_command_filters_from(self) self.is_activated = True def deactivate(self) -> None: """ Override if you want to do something at tear down phase (don't forget to super(Gnagna, self).deactivate()) """ if self.current_pollers: log.debug('You still have active pollers at deactivation stage, I cleaned them up for you.') self.current_pollers = [] for timer in self.current_timers: timer.cancel() try: self.close_storage() except StoreNotOpenError: pass self._bot.remove_command_filters_from(self) self._bot.remove_commands_from(self) self.is_activated = False for plugin in self._dynamic_plugins.values(): self._bot.remove_command_filters_from(plugin) self._bot.remove_commands_from(plugin) def start_poller(self, interval: float, method: Callable[..., None], times: int = None, args: Tuple = None, kwargs: Mapping = None): """ Starts a poller that will be called at a regular interval :param interval: interval in seconds :param method: targetted method :param times: number of times polling should happen (defaults to``None`` which causes the polling to happen indefinitely) :param args: args for the targetted method :param kwargs: kwargs for the targetting method """ if not kwargs: kwargs = {} if not args: args = [] log.debug(f'Programming the polling of {method.__name__} every {interval} seconds ' f'with args {str(args)} and kwargs {str(kwargs)}') # noinspection PyBroadException try: self.current_pollers.append((method, args, kwargs)) self.program_next_poll(interval, method, times, args, kwargs) except Exception: log.exception('Poller programming failed.') def stop_poller(self, method: Callable[..., None], args: Tuple = None, kwargs: Mapping = None): if not kwargs: kwargs = {} if not args: args = [] log.debug(f'Stop polling of {method} with args {args} and kwargs {kwargs}') self.current_pollers.remove((method, args, kwargs)) def program_next_poll(self, interval: float, method: Callable[..., None], times: int = None, args: Tuple = None, kwargs: Mapping = None): if times is not None and times <= 0: return t = Timer(interval=interval, function=self.poller, kwargs={'interval': interval, 'method': method, 'times': times, 'args': args, 'kwargs': kwargs}) self.current_timers.append(t) # save the timer to be able to kill it t.setName(f'Poller thread for {type(method.__self__).__name__}') t.setDaemon(True) # so it is not locking on exit t.start() def poller(self, interval: float, method: Callable[..., None], times: int = None, args: Tuple = None, kwargs: Mapping = None): previous_timer = current_thread() if previous_timer in self.current_timers: log.debug('Previous timer found and removed') self.current_timers.remove(previous_timer) if (method, args, kwargs) in self.current_pollers: # noinspection PyBroadException try: method(*args, **kwargs) except Exception: log.exception('A poller crashed') if times is not None: times -= 1 self.program_next_poll(interval, method, times, args, kwargs) def create_dynamic_plugin(self, name: str, commands: Tuple[Command], doc: str = ''): """ Creates a plugin dynamically and exposes its commands right away. :param name: name of the plugin. :param commands: a tuple of command definition. :param doc: the main documentation of the plugin. """ if name in self._dynamic_plugins: raise ValueError('Dynamic plugin %s already created.') # cleans the name to be a valid python type. plugin_class = type(re.sub(r'\W|^(?=\d)', '_', name), (BotPlugin,), {command.name: command.definition for command in commands}) plugin_class.__errdoc__ = doc plugin = plugin_class(self._bot, name=name) self._dynamic_plugins[name] = plugin self._bot.inject_commands_from(plugin) def destroy_dynamic_plugin(self, name: str): """ Reverse operation of create_dynamic_plugin. This allows you to dynamically refresh the list of commands for example. :param name: the name of the dynamic plugin given to create_dynamic_plugin. """ if name not in self._dynamic_plugins: raise ValueError("Dynamic plugin %s doesn't exist.", name) plugin = self._dynamic_plugins[name] self._bot.remove_command_filters_from(plugin) self._bot.remove_commands_from(plugin) del self._dynamic_plugins[name] def get_plugin(self, name) -> 'BotPlugin': """ Gets a plugin your plugin depends on. The name of the dependency needs to be listed in [Code] section key DependsOn of your plug file. This method can only be used after your plugin activation (or having called super().activate() from activate itself). It will return a plugin object. :param name: the name :return: the BotPlugin object requested. """ if not self.is_activated: raise Exception('Plugin needs to be in activated state to be able to get its dependencies.') if name not in self.dependencies: raise Exception(f'Plugin dependency {name} needs to be listed in section [Core] key ' f'"DependsOn" to be used in get_plugin.') return self._bot.plugin_manager.get_plugin_obj_by_name(name) # noinspection PyAbstractClass class BotPlugin(BotPluginBase): def get_configuration_template(self) -> Mapping: """ If your plugin needs a configuration, override this method and return a configuration template. For example a dictionary like: return {'LOGIN' : 'example@example.com', 'PASSWORD' : 'password'} Note: if this method returns None, the plugin won't be configured """ return None def check_configuration(self, configuration: Mapping) -> None: """ By default, this method will do only a BASIC check. You need to override it if you want to do more complex checks. It will be called before the configure callback. Note if the config_template is None, it will never be called. It means recusively: 1. in case of a dictionary, it will check if all the entries and from the same type are there and not more. 2. in case of an array or tuple, it will assume array members of the same type of first element of the template (no mix typed is supported) In case of validation error it should raise a errbot.ValidationException :param configuration: the configuration to be checked. """ recurse_check_structure(self.get_configuration_template(), configuration) # default behavior def configure(self, configuration: Mapping) -> None: """ By default, it will just store the current configuration in the self.config field of your plugin. If this plugin has no configuration yet, the framework will call this function anyway with None. This method will be called before activation so don't expect to be activated at that point. :param configuration: injected configuration for the plugin. """ self.config = configuration def activate(self) -> None: """ Triggered on plugin activation. Override this method if you want to do something at initialization phase (don't forget to `super().activate()`). """ super().activate() def deactivate(self) -> None: """ Triggered on plugin deactivation. Override this method if you want to do something at tear-down phase (don't forget to `super().deactivate()`). """ super().deactivate() def callback_connect(self) -> None: """ Triggered when the bot has successfully connected to the chat network. Override this method to get notified when the bot is connected. """ pass def callback_message(self, message: Message) -> None: """ Triggered on every message not coming from the bot itself. Override this method to get notified on *ANY* message. :param message: representing the message that was received. """ pass def callback_mention(self, message: Message, mentioned_people: Sequence[Identifier]) -> None: """ Triggered if there are mentioned people in message. Override this method to get notified when someone was mentioned in message. [Note: This might not be implemented by all backends.] :param message: representing the message that was received. :param mentioned_people: all mentioned people in this message. """ pass def callback_presence(self, presence: Presence) -> None: """ Triggered on every presence change. :param presence: An instance of :class:`~errbot.backends.base.Presence` representing the new presence state that was received. """ pass def callback_stream(self, stream: Stream) -> None: """ Triggered asynchronously (in a different thread context) on every incoming stream request or file transfert requests. You can block this call until you are done with the stream. To signal that you accept / reject the file, simply call stream.accept() or stream.reject() and return. :param stream: the incoming stream request. """ stream.reject() # by default, reject the file as the plugin doesn't want it. def callback_botmessage(self, message: Message): """ Triggered on every message coming from the bot itself. Override this method to get notified on all messages coming from the bot itself (including those from other plugins). :param message: An instance of :class:`~errbot.backends.base.Message` representing the message that was received. """ pass def callback_room_joined(self, room: Room): """ Triggered when the bot has joined a MUC. :param room: An instance of :class:`~errbot.backends.base.MUCRoom` representing the room that was joined. """ pass def callback_room_left(self, room: Room): """ Triggered when the bot has left a MUC. :param room: An instance of :class:`~errbot.backends.base.MUCRoom` representing the room that was left. """ pass def callback_room_topic(self, room: Room): """ Triggered when the topic in a MUC changes. :param room: An instance of :class:`~errbot.backends.base.MUCRoom` representing the room for which the topic changed. """ pass # Proxyfy some useful tools from the motherbot # this is basically the contract between the plugins and the main bot def warn_admins(self, warning: str) -> None: """ Send a warning to the administrators of the bot. :param warning: The markdown-formatted text of the message to send. """ self._bot.warn_admins(warning) def send(self, identifier: Identifier, text: str, in_reply_to: Message = None, groupchat_nick_reply: bool = False) -> None: """ Send a message to a room or a user. :param groupchat_nick_reply: if True the message will mention the user in the chatroom. :param in_reply_to: the original message this message is a reply to (optional). In some backends it will start a thread. :param text: markdown formatted text to send to the user. :param identifier: An Identifier representing the user or room to message. Identifiers may be created with :func:`build_identifier`. """ if not isinstance(identifier, Identifier): raise ValueError("identifier needs to be of type Identifier, the old string behavior is not supported") return self._bot.send(identifier, text, in_reply_to, groupchat_nick_reply) def send_card(self, body: str = '', to: Identifier = None, in_reply_to: Message = None, summary: str = None, title: str = '', link: str = None, image: str = None, thumbnail: str = None, color: str = 'green', fields: Tuple[Tuple[str, str], ...] = ()) -> None: """ Sends a card. A Card is a special type of preformatted message. If it matches with a backend similar concept like on Slack or Hipchat it will be rendered natively, otherwise it will be sent as a regular formatted message. :param body: main text of the card in markdown. :param to: the card is sent to this identifier (Room, RoomOccupant, Person...). :param in_reply_to: the original message this message is a reply to (optional). :param summary: (optional) One liner summary of the card, possibly collapsed to it. :param title: (optional) Title possibly linking. :param link: (optional) url the title link is pointing to. :param image: (optional) link to the main image of the card. :param thumbnail: (optional) link to an icon / thumbnail. :param color: (optional) background color or color indicator. :param fields: (optional) a tuple of (key, value) pairs. """ frm = in_reply_to.to if in_reply_to else self.bot_identifier if to is None: if in_reply_to is None: raise ValueError('Either to or in_reply_to needs to be set.') to = in_reply_to.frm self._bot.send_card(Card(body, frm, to, in_reply_to, summary, title, link, image, thumbnail, color, fields)) def change_presence(self, status: str = ONLINE, message: str = '') -> None: """ Changes the presence/status of the bot. :param status: One of the constant defined in base.py : ONLINE, OFFLINE, DND,... :param message: Additional message :return: None """ self._bot.change_presence(status, message) def send_templated(self, identifier: Identifier, template_name: str, template_parameters: Mapping, in_reply_to: Message = None, groupchat_nick_reply: bool = False) -> None: """ Sends asynchronously a message to a room or a user. Same as send but passing a template name and parameters instead of directly the markdown text. :param template_parameters: arguments for the template. :param template_name: name of the template to use. :param groupchat_nick_reply: if True it will mention the user in the chatroom. :param in_reply_to: optionally, the original message this message is the answer to. :param identifier: identifier of the user or room to which you want to send a message to. """ return self._bot.send_templated(identifier=identifier, template_name=template_name, template_parameters=template_parameters, in_reply_to=in_reply_to, groupchat_nick_reply=groupchat_nick_reply) def build_identifier(self, txtrep: str) -> Identifier: """ Transform a textual representation of a user identifier to the correct Identifier object you can set in Message.to and Message.frm. :param txtrep: the textual representation of the identifier (it is backend dependent). :return: a user identifier. """ return self._bot.build_identifier(txtrep) def send_stream_request(self, user: Identifier, fsource: IOBase, name: str = None, size: int = None, stream_type: str = None): """ Sends asynchronously a stream/file to a user. :param user: is the identifier of the person you want to send it to. :param fsource: is a file object you want to send. :param name: is an optional filename for it. :param size: is optional and is the espected size for it. :param stream_type: is optional for the mime_type of the content. It will return a Stream object on which you can monitor the progress of it. """ return self._bot.send_stream_request(user, fsource, name, size, stream_type) def rooms(self) -> Sequence[Room]: """ The list of rooms the bot is currently in. """ return self._bot.rooms() def query_room(self, room: str) -> Room: """ Query a room for information. :param room: The JID/identifier of the room to query for. :returns: An instance of :class:`~errbot.backends.base.MUCRoom`. :raises: :class:`~errbot.backends.base.RoomDoesNotExistError` if the room doesn't exist. """ return self._bot.query_room(room) def start_poller(self, interval: float, method: Callable[..., None], times: int = None, args: Tuple = None, kwargs: Mapping = None): """ Start to poll a method at specific interval in seconds. Note: it will call the method with the initial interval delay for the first time Also, you can program for example : self.program_poller(self, 30, fetch_stuff) where you have def fetch_stuff(self) in your plugin :param interval: interval in seconds :param method: targetted method :param times: number of times polling should happen (defaults to``None`` which causes the polling to happen indefinitely) :param args: args for the targetted method :param kwargs: kwargs for the targetting method """ super().start_poller(interval, method, times, args, kwargs) def stop_poller(self, method: Callable[..., None], args: Tuple = None, kwargs: Mapping = None): """ stop poller(s). If the method equals None -> it stops all the pollers you need to regive the same parameters as the original start_poller to match a specific poller to stop :param kwargs: The initial kwargs you gave to start_poller. :param args: The initial args you gave to start_poller. :param method: The initial method you passed to start_poller. """ super().stop_poller(method, args, kwargs) class ArgParserBase(object): """ The `ArgSplitterBase` class defines the API which is used for argument splitting (used by the `split_args_with` parameter on :func:`~errbot.decorators.botcmd`). """ def parse_args(self, args: str): """ This method takes a string of un-split arguments and parses it, returning a list that is the result of splitting. If splitting fails for any reason it should return an exception of some kind. :param args: string to parse """ raise NotImplementedError() class SeparatorArgParser(ArgParserBase): """ This argument splitter splits args on a given separator, like :func:`str.split` does. """ def __init__(self, separator: str = None, maxsplit: int = -1): """ :param separator: The separator on which arguments should be split. If sep is None, any whitespace string is a separator and empty strings are removed from the result. :param maxsplit: If given, do at most this many splits. """ self.separator = separator self.maxsplit = maxsplit def parse_args(self, args: str): return args.split(self.separator, self.maxsplit) class ShlexArgParser(ArgParserBase): """ This argument splitter splits args using posix shell quoting rules, like :func:`shlex.split` does. """ def parse_args(self, args): return shlex.split(args) errbot-6.1.1+ds/errbot/cli.py000077500000000000000000000316021355337103200160430ustar00rootroot00000000000000#!/usr/bin/env python # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import argparse import locale import logging import os import sys from os import path, sep, getcwd, access, W_OK from platform import system import ast from errbot.bootstrap import CORE_BACKENDS from errbot.logs import root_logger from errbot.plugin_wizard import new_plugin_wizard from errbot.utils import collect_roots from errbot.version import VERSION log = logging.getLogger(__name__) # noinspection PyUnusedLocal def debug(sig, frame): """Interrupt running process, and provide a python prompt for interactive debugging.""" d = {'_frame': frame} # Allow access to frame object. d.update(frame.f_globals) # Unless shadowed by global d.update(frame.f_locals) i = code.InteractiveConsole(d) message = 'Signal received : entering python shell.\nTraceback:\n' message += ''.join(traceback.format_stack(frame)) i.interact(message) ON_WINDOWS = system() == 'Windows' if not ON_WINDOWS: from daemonize import Daemonize import code import traceback import signal signal.signal(signal.SIGUSR1, debug) # Register handler for debugging def get_config(config_path): config_fullpath = config_path if not path.exists(config_fullpath): log.error(f'I cannot find the config file {config_path}.') log.error('You can change this path with the -c parameter see --help') log.info(f'You can use the template {os.path.realpath(os.path.join(__file__, os.pardir, "config-template.py"))}' f' as a base and copy it to {config_path}.') log.info('You can then customize it.') exit(-1) try: config = __import__(path.splitext(path.basename(config_fullpath))[0]) log.info('Config check passed...') return config except Exception: log.exception(f'I could not import your config from {config_fullpath}, please check the error below...') exit(-1) def _read_dict(): import collections new_dict = ast.literal_eval(sys.stdin.read()) if not isinstance(new_dict, collections.Mapping): raise ValueError(f'A dictionary written in python is needed from stdin. ' f'Type={type(new_dict)}, Value = {repr(new_dict)}.') return new_dict def main(): execution_dir = getcwd() # By default insert the execution path (useful to be able to execute Errbot from # the source tree directly without installing it. sys.path.insert(0, execution_dir) parser = argparse.ArgumentParser(description='The main entry point of the errbot.') parser.add_argument('-c', '--config', default=None, help='Full path to your config.py (default: config.py in current working directory).') mode_selection = parser.add_mutually_exclusive_group() mode_selection.add_argument('-v', '--version', action='version', version=f'Errbot version {VERSION}') mode_selection.add_argument('-r', '--restore', nargs='?', default=None, const='default', help='restore a bot from backup.py (default: backup.py from the bot data directory)') mode_selection.add_argument('-l', '--list', action='store_true', help='list all available backends') mode_selection.add_argument('--new-plugin', nargs='?', default=None, const='current_dir', help='create a new plugin in the specified directory') mode_selection.add_argument('-i', '--init', nargs='?', default=None, const='.', help='Initialize a simple bot minimal configuration in the optionally ' 'given directory (otherwise it will be the working directory). ' 'This will create a data subdirectory for the bot data dir and a plugins directory' ' for your plugin development with an example in it to get you started.') # storage manipulation mode_selection.add_argument('--storage-set', nargs=1, help='DANGER: Delete the given storage namespace ' 'and set the python dictionary expression ' 'passed on stdin.') mode_selection.add_argument('--storage-merge', nargs=1, help='DANGER: Merge in the python dictionary expression ' 'passed on stdin into the given storage namespace.') mode_selection.add_argument('--storage-get', nargs=1, help='Dump the given storage namespace in a ' 'format compatible for --storage-set and ' '--storage-merge.') mode_selection.add_argument('-T', '--text', dest="backend", action='store_const', const="Text", help='force local text backend') mode_selection.add_argument('-G', '--graphic', dest="backend", action='store_const', const="Graphic", help='force local graphical backend') if not ON_WINDOWS: option_group = parser.add_argument_group('optional daemonization arguments') option_group.add_argument('-d', '--daemon', action='store_true', help='Detach the process from the console') option_group.add_argument('-p', '--pidfile', default=None, help='Specify the pid file for the daemon (default: current bot data directory)') args = vars(parser.parse_args()) # create a dictionary of args if args['init']: try: import jinja2 import shutil base_dir = os.getcwd() if args['init'] == '.' else args['init'] if not os.path.isdir(base_dir): print(f'Target directory {base_dir} must exist. Please create it.') base_dir = os.path.abspath(base_dir) data_dir = os.path.join(base_dir, 'data') extra_plugin_dir = os.path.join(base_dir, 'plugins') example_plugin_dir = os.path.join(extra_plugin_dir, 'err-example') log_path = os.path.join(base_dir, 'errbot.log') templates_dir = os.path.join(os.path.dirname(__file__), 'templates', 'initdir') env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True) config_template = env.get_template('config.py.tmpl') os.mkdir(data_dir) os.mkdir(extra_plugin_dir) os.mkdir(example_plugin_dir) with open(os.path.join(base_dir, 'config.py'), 'w') as f: f.write(config_template.render(data_dir=data_dir, extra_plugin_dir=extra_plugin_dir, log_path=log_path)) shutil.copyfile(os.path.join(templates_dir, 'example.plug'), os.path.join(example_plugin_dir, 'example.plug')) shutil.copyfile(os.path.join(templates_dir, 'example.py'), os.path.join(example_plugin_dir, 'example.py')) print('Your Errbot directory has been correctly initialized !') if base_dir == os.getcwd(): print('Just do "errbot" and it should start in text/development mode.') else: print(f'Just do "cd {args["init"]}" then "errbot" and it should start in text/development mode.') sys.exit(0) except Exception as e: print(f'The initialization of your errbot directory failed: {e}.') sys.exit(1) # This must come BEFORE the config is loaded below, to avoid printing # logs as a side effect of config loading. if args['new_plugin']: directory = os.getcwd() if args['new_plugin'] == "current_dir" else args['new_plugin'] for handler in logging.getLogger().handlers: root_logger.removeHandler(handler) try: new_plugin_wizard(directory) except KeyboardInterrupt: sys.exit(1) except Exception as e: sys.stderr.write(str(e) + "\n") sys.exit(1) finally: sys.exit(0) config_path = args['config'] # setup the environment to be able to import the config.py if config_path: # appends the current config in order to find config.py sys.path.insert(0, path.dirname(path.abspath(config_path))) else: config_path = execution_dir + sep + 'config.py' config = get_config(config_path) # will exit if load fails if args['list']: from errbot.backend_plugin_manager import enumerate_backend_plugins print('Available backends:') roots = [CORE_BACKENDS] + getattr(config, 'BOT_EXTRA_BACKEND_DIR', []) for backend in enumerate_backend_plugins(collect_roots(roots)): print(f'\t\t{backend.name}') sys.exit(0) def storage_action(namespace, fn): # Used to defer imports until it is really necessary during the loading time. from errbot.bootstrap import get_storage_plugin from errbot.storage import StoreMixin try: with StoreMixin() as sdm: sdm.open_storage(get_storage_plugin(config), namespace) fn(sdm) return 0 except Exception as e: print(str(e), file=sys.stderr) return -3 if args['storage_get']: def p(sdm): print(repr(dict(sdm))) err_value = storage_action(args['storage_get'][0], p) sys.exit(err_value) if args['storage_set']: def replace(sdm): new_dict = _read_dict() # fail early and don't erase the storage if the input is invalid. sdm.clear() sdm.update(new_dict) err_value = storage_action(args['storage_set'][0], replace) sys.exit(err_value) if args['storage_merge']: def merge(sdm): new_dict = _read_dict() if list(new_dict.keys()) == ['config']: with sdm.mutable('configs') as conf: conf.update(new_dict['configs']) else: sdm.update(new_dict) err_value = storage_action(args['storage_merge'][0], merge) sys.exit(err_value) if args['restore']: backend = 'Null' # we don't want any backend when we restore elif args['backend'] is None: if not hasattr(config, 'BACKEND'): log.fatal("The BACKEND configuration option is missing in config.py") sys.exit(1) backend = config.BACKEND else: backend = args['backend'] log.info(f'Selected backend {backend}.') # Check if at least we can start to log something before trying to start # the bot (esp. daemonize it). log.info(f'Checking for {config.BOT_DATA_DIR}...') if not path.exists(config.BOT_DATA_DIR): raise Exception(f'The data directory "{config.BOT_DATA_DIR}" for the bot does not exist.') if not access(config.BOT_DATA_DIR, W_OK): raise Exception(f'The data directory "{config.BOT_DATA_DIR}" should be writable for the bot.') if (not ON_WINDOWS) and args['daemon']: if args['backend'] == 'Text': raise Exception('You cannot run in text and daemon mode at the same time') if args['restore']: raise Exception('You cannot restore a backup in daemon mode.') if args['pidfile']: pid = args['pidfile'] else: pid = config.BOT_DATA_DIR + sep + 'err.pid' # noinspection PyBroadException try: def action(): from errbot.bootstrap import bootstrap bootstrap(backend, root_logger, config) daemon = Daemonize(app="err", pid=pid, action=action, chdir=os.getcwd()) log.info("Daemonizing") daemon.start() except Exception: log.exception('Failed to daemonize the process') exit(0) from errbot.bootstrap import bootstrap restore = args['restore'] if restore == 'default': # restore with no argument, get the default location restore = path.join(config.BOT_DATA_DIR, 'backup.py') bootstrap(backend, root_logger, config, restore) log.info('Process exiting') if __name__ == "__main__": main() errbot-6.1.1+ds/errbot/config-template.py000066400000000000000000000444351355337103200203570ustar00rootroot00000000000000########################################################################## # # # This is the config-template for Err. This file should be copied and # # renamed to config.py, then modified as you see fit to run Errbot # # the way you like it. # # # # As this is a regular Python file, note that you can do variable # # assignments and the likes as usual. This can be useful for example if # # you use the same values in multiple places. # # # # Note: Various config options require a tuple to be specified, even # # when you are configuring only a single value. An example of this is # # the BOT_ADMINS option. Make sure you use a valid tuple here, even if # # you are only configuring a single item, else you will get errors. # # (So don't forget the trailing ',' in these cases) # # # ########################################################################## import logging ########################################################################## # Core Errbot configuration # ########################################################################## # BACKEND selection. # This configures the type of chat server you wish to use Errbot with. # # The current choices: # Debug backends to test your plugins manually: # 'Text' - on the text console # 'Graphic' - in a GUI window # Commercial backends: # 'Campfire' - see https://campfirenow.com/ (follow instructions from https://github.com/errbotio/err-backend-campfire) # 'Hipchat' - see https://www.hipchat.com/ # 'Slack' - see https://slack.com/ # 'Gitter' - see https://gitter.im/ (follow instructions from https://github.com/errbotio/err-backend-gitter) # Open protocols: # 'TOX' - see https://tox.im/ (follow instructions from https://github.com/errbotio/err-backend-tox) # 'IRC' - for classic IRC or bridged services like https://gitter.im # 'XMPP' - the Extensible Messaging and Presence Protocol (https://xmpp.org/) # 'Telegram' - cloud-based mobile and desktop messaging app with a focus # on security and speed. (https://telegram.org/) # BACKEND = 'XMPP' # defaults to XMPP # STORAGE selection. # This configures the type of persistence you wish to use Errbot with. # # The current choices: # Debug: # 'Memory' - local memory storage to test your bot in memory: # Filesystem: # 'Shelf' - python shelf (default) # STORAGE = 'Shelf' # defaults to filestorage (python shelf). # BOT_EXTRA_STORAGE_PLUGINS_DIR = None # extra search path to find custom storage plugins # The location where all of Err's data should be stored. Make sure to set # this to a directory that is writable by the user running the bot. BOT_DATA_DIR = '/var/lib/err' ### Repos and plugins config. # Set this to change from where errbot gets its installable plugin list. # By default it gets the index from errbot.io which is a file generated by tools/plugin-gen.py. # BOT_PLUGIN_INDEXES = 'http://version.errbot.io/repos.json' # # You can also specify a local file: # BOT_PLUGIN_INDEXES = 'tools/repos.json' # # Or a list. note: if some plugins exists in 2 lists, only the first hit will be taken into account. # BOT_PLUGIN_INDEXES = ('/data/repos.json', 'https://my.private.tld/errbot/myrepos.json') # Set this to a directory on your system where you want to load extra # plugins from, which is useful mostly if you want to develop a plugin # locally before publishing it. Note that you can specify only a single # directory, however you are free to create subdirectories with multiple # plugins inside this directory. BOT_EXTRA_PLUGIN_DIR = None # If you use an external backend as a plugin, # this is where you tell Errbot where to find it. # BOT_EXTRA_BACKEND_DIR = '/opt/errbackends' # If you want only a subset of the core plugins that are bundled with errbot, you can specify them here. # CORE_PLUGINS = None # This is default, all core plugins. # For example CORE_PLUGINS = ('ACLs', 'Backup', 'Help') you get those names from the .plug files Name entry. # For absolutely no plug: CORE_PLUGINS = () # Defines an order in which the plugins are getting their callbacks. Useful if you want to have plugins do # pre- or post-processing on messages. # The 'None' tuple entry represents all the plugins that aren't to be explicitly ordered. For example, if # you want 'A' to run first, then everything else but 'B', then 'B', you would use ('A', None, 'B'). PLUGINS_CALLBACK_ORDER = (None, ) # Should plugin dependencies be installed automatically? If this is true # then Errbot will use pip to install any missing dependencies automatically. # # If you have installed Errbot in a virtualenv, this will run the equivalent # of `pip install -r requirements.txt`. # If no virtualenv is detected, the equivalent of `pip install --user -r # requirements.txt` is used to ensure the package(s) is/are only installed for # the user running Err. #AUTOINSTALL_DEPS = True # To use your own custom log formatter, uncomment and set BOT_LOG_FORMATTER # to your formatter instance (inherits from logging.Formatter) # For information on how to create a logging formatter and what it can do, see # https://docs.python.org/3.5/library/logging.html#formatter-objects # BOT_LOG_FORMATTER = # The location of the log file. If you set this to None, then logging will # happen to console only. BOT_LOG_FILE = BOT_DATA_DIR + '/err.log' # The verbosity level of logging that is done to the above logfile, and to # the console. This takes the standard Python logging levels, DEBUG, INFO, # WARN, ERROR. For more info, see http://docs.python.org/library/logging.html # # If you encounter any issues with Err, please set your log level to # logging.DEBUG and attach a log with your bug report to aid the developers # in debugging the issue. BOT_LOG_LEVEL = logging.INFO # Enable logging to sentry (find out more about sentry at www.getsentry.com). # This is optional and disabled by default. BOT_LOG_SENTRY = False SENTRY_DSN = '' SENTRY_LOGLEVEL = BOT_LOG_LEVEL # Set an optional Sentry transport other than the default Threaded. # For more info, see https://docs.sentry.io/clients/python/transports/ # SENTRY_TRANSPORT = ('RequestsHTTPTransport', 'raven.transport.requests') # Execute commands in asynchronous mode. In this mode, Errbot will spawn 10 # separate threads to handle commands, instead of blocking on each # single command. # BOT_ASYNC = True # Size of the thread pool for the asynchronous mode. # BOT_ASYNC_POOLSIZE = 10 ########################################################################## # Account and chatroom (MUC) configuration # ########################################################################## # The identity, or credentials, used to connect to a server BOT_IDENTITY = { # XMPP (Jabber) mode 'username': 'err@localhost', # The JID of the user you have created for the bot 'password': 'changeme', # The corresponding password for this user # 'server': ('host.domain.tld',5222), # server override ## HipChat mode (Comment the above if using this mode) # 'username': '12345_123456@chat.hipchat.com', # 'password': 'changeme', ## Group admins can create/view tokens on the settings page after logging ## in on HipChat's website # 'token': 'ed4b74d62833267d98aa99f312ff04', ## If you're using HipChat server (self-hosted HipChat) then you should set ## the endpoint below. If you don't use HipChat server but use the hosted version ## of HipChat then you may leave this commented out. # 'endpoint': 'https://api.hipchat.com' ## Slack mode (comment the others above if using this mode) # 'token': 'xoxb-4426949411-aEM7...', ## you can also include the proxy for the SlackClient connection # 'proxies': {'http': 'some-http-proxy', 'https': 'some-https-proxy'} ## Telegram mode (comment the others above if using this mode) # 'token': '103419016:AAbcd1234...', ## IRC mode (Comment the others above if using this mode) # 'nickname': 'err-chatbot', # 'username': 'err-chatbot', # optional, defaults to nickname if omitted # 'password': None, # optional # 'server': 'irc.freenode.net', # 'port': 6667, # optional # 'ssl': False, # optional # 'ipv6': False, # optional # 'nickserv_password': None, # optional ## Optional: Specify an IP address or hostname (vhost), and a ## port, to use when making the connection. Leave port at 0 ## if you have no source port preference. ## example: 'bind_address': ('my-errbot.io', 0) # 'bind_address': ('localhost', 0), } # Set the admins of your bot. Only these users will have access # to the admin-only commands. # # Unix-style glob patterns are supported, so 'gbin@localhost' # would be considered an admin if setting '*@localhost'. BOT_ADMINS = ('gbin@localhost',) # Set of admins that wish to receive administrative bot notifications. #BOT_ADMINS_NOTIFICATIONS = () # Chatrooms your bot should join on startup. For the IRC backend you # should include the # sign here. For XMPP rooms that are password # protected, you can specify another tuple here instead of a string, # using the format (RoomName, Password). # CHATROOM_PRESENCE = ('err@conference.server.tld',) # The FullName, or nickname, your bot should use. What you set here will # be the nickname that Errbot shows in chatrooms. Note that some XMPP # implementations, notably HipChat, are very picky about what name you # use. In the case of HipChat, make sure this matches exactly with the # name you gave the user. # CHATROOM_FN = 'Errbot' ########################################################################## # Prefix configuration # ########################################################################## # Command prefix, the prefix that is expected in front of commands directed # at the bot. # # Note: When writing plugins,you should always use the default '!'. # If the prefix is changed from the default, the help strings will be # automatically adjusted for you. # # BOT_PREFIX = '!' # # Uncomment the following and set it to True if you want the prefix to be # optional for normal chat. # (Meaning messages sent directly to the bot as opposed to within a MUC) #BOT_PREFIX_OPTIONAL_ON_CHAT = False # You might wish to have your bot respond by being called with certain # names, rather than the BOT_PREFIX above. This option allows you to # specify alternative prefixes the bot will respond to in addition to # the prefix above. #BOT_ALT_PREFIXES = ('Err',) # If you use alternative prefixes, you might want to allow users to insert # separators like , and ; between the prefix and the command itself. This # allows users to refer to your bot like this (Assuming 'Err' is in your # BOT_ALT_PREFIXES): # "Err, status" or "Err: status" # # Note: There's no need to add spaces to the separators here # #BOT_ALT_PREFIX_SEPARATORS = (':', ',', ';') # Continuing on this theme, you might want to permit your users to be # lazy and not require correct capitalization, so they can do 'Err', # 'err' or even 'ERR'. #BOT_ALT_PREFIX_CASEINSENSITIVE = True ########################################################################## # Access controls and message diversion # ########################################################################## # Access controls, allowing commands to be restricted to specific users/rooms. # Available filters (you can omit a filter or set it to None to disable it): # allowusers: Allow command from these users only # denyusers: Deny command from these users # allowrooms: Allow command only in these rooms (and direct messages) # denyrooms: Deny command in these rooms # allowprivate: Allow command from direct messages to the bot # allowmuc: Allow command inside rooms # Rules listed in ACCESS_CONTROLS_DEFAULT are applied by default and merged # with any commands found in ACCESS_CONTROLS. # # The options allowusers, denyusers, allowrooms and denyrooms support # unix-style globbing similar to BOT_ADMINS. # # Command names also support unix-style globs and can optionally be restricted # to a specific plugin by prefixing the command with the name of a plugin, # separated by a colon. For example, `Health:status` will match the `!status` # command of the `Health` plugin and `Health:*` will match all commands defined # by the `Health` plugin. # # Please note that the first command match found will be used so if you have # overlapping patterns you must used an OrderedDict instead of a regular dict: # https://docs.python.org/3.4/library/collections.html#collections.OrderedDict # # Example: # #ACCESS_CONTROLS_DEFAULT = {} # Allow everyone access by default #ACCESS_CONTROLS = {'status': {'allowrooms': ('someroom@conference.localhost',)}, # 'about': {'denyusers': ('*@evilhost',), 'allowrooms': ('room1@conference.localhost', 'room2@conference.localhost')}, # 'uptime': {'allowusers': BOT_ADMINS}, # 'help': {'allowmuc': False}, # 'help': {'allowmuc': False}, # 'ChatRoom:*': {'allowusers': BOT_ADMINS}, # } # Uncomment and set this to True to hide the restricted commands from # the help output. #HIDE_RESTRICTED_COMMANDS = False # Uncomment and set this to True to ignore commands from users that have no # access for these instead of replying with error message. #HIDE_RESTRICTED_ACCESS = False # A list of commands which should be responded to in private, even if # the command was given in a MUC. For example: # DIVERT_TO_PRIVATE = ('help', 'about', 'status') DIVERT_TO_PRIVATE = () # A list of commands which should be responded to in a thread if the backend supports it. # For example: # DIVERT_TO_THREAD = ('help', 'about', 'status') DIVERT_TO_THREAD = () # Chat relay # Can be used to relay one to one message from specific users to the bot # to MUCs. This can be useful with XMPP notifiers like for example the # standard Altassian Jira which don't have native support for MUC. # For example: CHATROOM_RELAY = {'gbin@localhost' : (_TEST_ROOM,)} CHATROOM_RELAY = {} # Reverse chat relay # This feature forwards whatever is said to a specific user. # It can be useful if you client like gtalk doesn't support MUC correctly # For example: REVERSE_CHATROOM_RELAY = {_TEST_ROOM : ('gbin@localhost',)} REVERSE_CHATROOM_RELAY = {} ########################################################################## # Miscellaneous configuration options # ########################################################################## # Define the maximum length a single message may be. If a plugin tries to # send a message longer than this length, it will be broken up into multiple # shorter messages that do fit. #MESSAGE_SIZE_LIMIT = 10000 # XMPP TLS certificate verification. In order to validate offered certificates, # you must supply a path to a file containing certificate authorities. By # default, "/etc/ssl/certs/ca-certificates.crt" is used, which on most Linux # systems holds the default system trusted CA certificates. You might need to # change this depending on your environment. Setting this to None disables # certificate validation, which can be useful if you have a self-signed # certificate for example. #XMPP_CA_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt" # Influence the security methods used on connection with XMPP-based # backends. You can use this to work around authentication issues with # some buggy XMPP servers. # # The default is to try anything: #XMPP_FEATURE_MECHANISMS = {} # To use only unencrypted plain auth: #XMPP_FEATURE_MECHANISMS = {'use_mech': 'PLAIN', 'unencrypted_plain': True, 'encrypted_plain': False} # Modify the default keep-alive interval. By default, Errbot will send # some whitespace to the XMPP server every 300 seconds to keep the TCP # connection alive. On some servers, or when running Errbot from behind # a NAT router, the default might not be fast enough and you will need # to set it to a lower value. # # It has been reported that HipChat also times out without setting this # to a lower value (60 seems to work well with HipChat) # # If you're having issues with your bot getting constantly disconnected, # try to gradually lower this value until it no longer happens. #XMPP_KEEPALIVE_INTERVAL = 300 # Modify default settings for IPv6 usage. This key affect both # XMPP and HipChat backend. #XMPP_USE_IPV6 = False # XMPP supports some formatting with XEP-0071 (http://www.xmpp.org/extensions/xep-0071.html). # It is disabled by default because XMPP clients support has been found to be spotty. # Switch it to True to enable XHTML-IM formatting. # XMPP_XHTML_IM = False # Message rate limiting for the IRC backend. This will delay subsequent # messages by this many seconds (floats are supported). Setting these # to a value of 0 effectively disables rate limiting. #IRC_CHANNEL_RATE = 1 # Regular channel messages #IRC_PRIVATE_RATE = 1 # Private messages #IRC_RECONNECT_ON_KICK = 5 # Reconnect back to a channel after a kick (in seconds) # Put it at None if you don't want the chat to # reconnect #IRC_RECONNECT_ON_DISCONNECT = 5 # Reconnect back to a channel after a disconnection (in seconds) # The pattern to build a user representation from for ACL matches. # The default is "{nick}!{user}@{host}" which results in "zoni!zoni@ams1.groenen.me" # for the user zoni connecting from ams1.groenen.me. # Available substitution variables: # {nick} -> The nickname of the user # {user} -> The username of the user # {host} -> The hostname the user is connecting from #IRC_ACL_PATTERN = "{nick}!{user}@{host}" # Allow messages sent in a chatroom to be directed at requester. #GROUPCHAT_NICK_PREFIXED = False # Disable table borders, making output more compact (supported only on IRC, Slack and Telegram currently). # COMPACT_OUTPUT = True # Disables the logging output in Text mode and only outputs Ansi. # TEXT_DEMO_MODE = False # Prevent ErrBot from saying anything if the command is unrecognized. # SUPPRESS_CMD_NOT_FOUND = False errbot-6.1.1+ds/errbot/core.py000066400000000000000000000727741355337103200162400ustar00rootroot00000000000000# This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import difflib import inspect import logging import re import traceback from datetime import datetime from threading import RLock import collections from multiprocessing.pool import ThreadPool from errbot import CommandError from errbot.flow import FlowExecutor, FlowRoot from .backends.base import Backend, Room, Identifier, Message from .storage import StoreMixin from .streaming import Tee from .templating import tenv from .utils import split_string_after log = logging.getLogger(__name__) # noinspection PyAbstractClass class ErrBot(Backend, StoreMixin): """ ErrBot is the layer taking care of commands management and dispatching. """ __errdoc__ = """ Commands related to the bot administration """ MSG_ERROR_OCCURRED = 'Computer says nooo. See logs for details' MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". ' startup_time = datetime.now() def __init__(self, bot_config): log.debug("ErrBot init.") super().__init__(bot_config) self.bot_config = bot_config self.prefix = bot_config.BOT_PREFIX if bot_config.BOT_ASYNC: self.thread_pool = ThreadPool(bot_config.BOT_ASYNC_POOLSIZE) log.debug('created a thread pool of size %d.', bot_config.BOT_ASYNC_POOLSIZE) self.commands = {} # the dynamically populated list of commands available on the bot self.re_commands = {} # the dynamically populated list of regex-based commands available on the bot self.command_filters = [] # the dynamically populated list of filters self.MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". ' \ 'Type "' + bot_config.BOT_PREFIX + 'help" for available commands.' if bot_config.BOT_ALT_PREFIX_CASEINSENSITIVE: self.bot_alt_prefixes = tuple(prefix.lower() for prefix in bot_config.BOT_ALT_PREFIXES) else: self.bot_alt_prefixes = bot_config.BOT_ALT_PREFIXES self.repo_manager = None self.plugin_manager = None self.storage_plugin = None self._plugin_errors_during_startup = None self.flow_executor = FlowExecutor(self) self._gbl = RLock() # this protects internal structures of this class def attach_repo_manager(self, repo_manager): self.repo_manager = repo_manager def attach_plugin_manager(self, plugin_manager): self.plugin_manager = plugin_manager def attach_storage_plugin(self, storage_plugin): # the storage_plugin is needed by the plugins self.storage_plugin = storage_plugin def initialize_backend_storage(self): """ Initialize storage for the backend to use. """ log.debug("Initializing backend storage") assert self.plugin_manager is not None assert self.storage_plugin is not None self.open_storage(self.storage_plugin, f'{self.mode}_backend') @property def all_commands(self): """Return both commands and re_commands together.""" with self._gbl: newd = dict(**self.commands) newd.update(self.re_commands) return newd def _dispatch_to_plugins(self, method, *args, **kwargs): """ Dispatch the given method to all active plugins. Will catch and log any exceptions that occur. :param method: The name of the function to dispatch. :param *args: Passed to the callback function. :param **kwargs: Passed to the callback function. """ for plugin in self.plugin_manager.get_all_active_plugins(): plugin_name = plugin.name log.debug('Triggering %s on %s.', method, plugin_name) # noinspection PyBroadException try: getattr(plugin, method)(*args, **kwargs) except Exception: log.exception('%s on %s crashed.', method, plugin_name) def send(self, identifier, text, in_reply_to=None, groupchat_nick_reply=False): """ Sends a simple message to the specified user. :param identifier: an identifier from build_identifier or from an incoming message :param in_reply_to: the original message the bot is answering from :param text: the markdown text you want to send :param groupchat_nick_reply: authorized the prefixing with the nick form the user """ # protect a little bit the backends here if not isinstance(identifier, Identifier): raise ValueError("identifier should be an Identifier") msg = self.build_message(text) msg.to = identifier msg.frm = in_reply_to.to if in_reply_to else self.bot_identifier msg.parent = in_reply_to nick_reply = self.bot_config.GROUPCHAT_NICK_PREFIXED if isinstance(identifier, Room) and in_reply_to and (nick_reply or groupchat_nick_reply): self.prefix_groupchat_reply(msg, in_reply_to.frm) self.split_and_send_message(msg) def send_templated(self, identifier, template_name, template_parameters, in_reply_to=None, groupchat_nick_reply=False): """ Sends a simple message to the specified user using a template. :param template_parameters: the parameters for the template. :param template_name: the template name you want to use. :param identifier: an identifier from build_identifier or from an incoming message, a room etc. :param in_reply_to: the original message the bot is answering from :param groupchat_nick_reply: authorized the prefixing with the nick form the user """ text = self.process_template(template_name, template_parameters) return self.send(identifier, text, in_reply_to, groupchat_nick_reply) def split_and_send_message(self, msg): for part in split_string_after(msg.body, self.bot_config.MESSAGE_SIZE_LIMIT): partial_message = msg.clone() partial_message.body = part partial_message.partial = True self.send_message(partial_message) def send_message(self, msg): """ This needs to be overridden by the backends with a super() call. :param msg: the message to send. :return: None """ for bot in self.plugin_manager.get_all_active_plugins(): # noinspection PyBroadException try: bot.callback_botmessage(msg) except Exception: log.exception("Crash in a callback_botmessage handler") def send_card(self, card): """ Sends a card, this can be overriden by the backends *without* a super() call. :param card: the card to send. :return: None """ self.send_templated(card.to, 'card', {'card': card}) def send_simple_reply(self, msg, text, private=False, threaded=False): """Send a simple response to a given incoming message :param private: if True will force a response in private. :param threaded: if True and if the backend supports it, sends the response in a threaded message. :param text: the markdown text of the message. :param msg: the message you are replying to. """ reply = self.build_reply(msg, text, private=private, threaded=threaded) if isinstance(reply.to, Room) and self.bot_config.GROUPCHAT_NICK_PREFIXED: self.prefix_groupchat_reply(reply, msg.frm) self.split_and_send_message(reply) def process_message(self, msg): """Check if the given message is a command for the bot and act on it. It return True for triggering the callback_messages on the .callback_messages on the plugins. :param msg: the incoming message. """ # Prepare to handle either private chats or group chats frm = msg.frm text = msg.body if not hasattr(msg.frm, 'person'): raise Exception(f'msg.frm not an Identifier as it misses the "person" property.' f' Class of frm : {msg.frm.__class__}.') username = msg.frm.person user_cmd_history = self.cmd_history[username] if msg.delayed: log.debug('Message from history, ignore it.') return False if self.is_from_self(msg): log.debug("Ignoring message from self.") return False log.debug('*** frm = %s', frm) log.debug('*** username = %s', username) log.debug('*** text = %s', text) suppress_cmd_not_found = self.bot_config.SUPPRESS_CMD_NOT_FOUND prefixed = False # Keeps track whether text was prefixed with a bot prefix only_check_re_command = False # Becomes true if text is determed to not be a regular command tomatch = text.lower() if self.bot_config.BOT_ALT_PREFIX_CASEINSENSITIVE else text if len(self.bot_config.BOT_ALT_PREFIXES) > 0 and tomatch.startswith(self.bot_alt_prefixes): # Yay! We were called by one of our alternate prefixes. Now we just have to find out # which one... (And find the longest matching, in case you have 'err' and 'errbot' and # someone uses 'errbot', which also matches 'err' but would leave 'bot' to be taken as # part of the called command in that case) prefixed = True longest = 0 for prefix in self.bot_alt_prefixes: length = len(prefix) if tomatch.startswith(prefix) and length > longest: longest = length log.debug('Called with alternate prefix "%s"', text[:longest]) text = text[longest:] # Now also remove the separator from the text for sep in self.bot_config.BOT_ALT_PREFIX_SEPARATORS: # While unlikely, one may have separators consisting of # more than one character length = len(sep) if text[:length] == sep: text = text[length:] elif msg.is_direct and self.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT: log.debug('Assuming "%s" to be a command because BOT_PREFIX_OPTIONAL_ON_CHAT is True', text) # In order to keep noise down we surpress messages about the command # not being found, because it's possible a plugin will trigger on what # was said with trigger_message. suppress_cmd_not_found = True elif not text.startswith(self.bot_config.BOT_PREFIX): only_check_re_command = True if text.startswith(self.bot_config.BOT_PREFIX): text = text[len(self.bot_config.BOT_PREFIX):] prefixed = True text = text.strip() text_split = text.split(' ') cmd = None command = None args = '' if not only_check_re_command: i = len(text_split) while cmd is None: command = '_'.join(text_split[:i]) with self._gbl: if command in self.commands: cmd = command args = ' '.join(text_split[i:]) else: i -= 1 if i == 0: break if command == self.bot_config.BOT_PREFIX: # we did "!!" so recall the last command if len(user_cmd_history): cmd, args = user_cmd_history[-1] else: return False # no command in history elif command.isdigit(): # we did "!#" so we recall the specified command index = int(command) if len(user_cmd_history) >= index: cmd, args = user_cmd_history[-index] else: return False # no command in history # Try to match one of the regex commands if the regular commands produced no match matched_on_re_command = False if not cmd: with self._gbl: if prefixed or (msg.is_direct and self.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT): commands = dict(self.re_commands) else: commands = {k: self.re_commands[k] for k in self.re_commands if not self.re_commands[k]._err_command_prefix_required} for name, func in commands.items(): if func._err_command_matchall: match = list(func._err_command_re_pattern.finditer(text)) else: match = func._err_command_re_pattern.search(text) if match: log.debug('Matching "%s" against "%s" produced a match.', text, func._err_command_re_pattern.pattern) matched_on_re_command = True self._process_command(msg, name, text, match) else: log.debug('Matching "%s" against "%s" produced no match.', text, func._err_command_re_pattern.pattern) if matched_on_re_command: return True if cmd: self._process_command(msg, cmd, args, match=None) elif not only_check_re_command: log.debug("Command not found") for cmd_filter in self.command_filters: if getattr(cmd_filter, 'catch_unprocessed', False): try: reply = cmd_filter(msg, cmd, args, False, emptycmd=True) if reply: self.send_simple_reply(msg, reply) # continue processing the other unprocessed cmd filters. except Exception: log.exception("Exception in a command filter command.") return True def _process_command_filters(self, msg, cmd, args, dry_run=False): try: for cmd_filter in self.command_filters: msg, cmd, args = cmd_filter(msg, cmd, args, dry_run) if msg is None: return None, None, None return msg, cmd, args except Exception: log.exception("Exception in a filter command, blocking the command in doubt") return None, None, None def _process_command(self, msg, cmd, args, match): """Process and execute a bot command""" # first it must go through the command filters msg, cmd, args = self._process_command_filters(msg, cmd, args, False) if msg is None: log.info('Command %s blocked or deferred.', cmd) return frm = msg.frm username = frm.person user_cmd_history = self.cmd_history[username] log.info(f'Processing command "{cmd}" with parameters "{args}" from {frm}') if (cmd, args) in user_cmd_history: user_cmd_history.remove((cmd, args)) # Avoids duplicate history items with self._gbl: f = self.re_commands[cmd] if match else self.commands[cmd] if f._err_command_admin_only and self.bot_config.BOT_ASYNC: # If it is an admin command, wait until the queue is completely depleted so # we don't have strange concurrency issues on load/unload/updates etc... self.thread_pool.close() self.thread_pool.join() self.thread_pool = ThreadPool(self.bot_config.BOT_ASYNC_POOLSIZE) if f._err_command_historize: user_cmd_history.append((cmd, args)) # add it to the history only if it is authorized to be so # Don't check for None here as None can be a valid argument to str.split. # '' was chosen as default argument because this isn't a valid argument to str.split() if not match and f._err_command_split_args_with != '': try: if hasattr(f._err_command_split_args_with, "parse_args"): args = f._err_command_split_args_with.parse_args(args) elif callable(f._err_command_split_args_with): args = f._err_command_split_args_with(args) else: args = args.split(f._err_command_split_args_with) except Exception as e: self.send_simple_reply(msg, f"Sorry, I couldn't parse your arguments. {e}") return if self.bot_config.BOT_ASYNC: result = self.thread_pool.apply_async( self._execute_and_send, [], {'cmd': cmd, 'args': args, 'match': match, 'msg': msg, 'template_name': f._err_command_template} ) if f._err_command_admin_only: # Again, if it is an admin command, wait until the queue is completely # depleted so we don't have strange concurrency issues. result.wait() else: self._execute_and_send(cmd=cmd, args=args, match=match, msg=msg, template_name=f._err_command_template) @staticmethod def process_template(template_name, template_parameters): # integrated templating # The template needs to be set and the answer from the user command needs to be a mapping # If not just convert the answer to string. if template_name and isinstance(template_parameters, collections.Mapping): return tenv().get_template(template_name + '.md').render(**template_parameters) # Reply should be all text at this point (See https://github.com/errbotio/errbot/issues/96) return str(template_parameters) def _execute_and_send(self, cmd, args, match, msg, template_name=None): """Execute a bot command and send output back to the caller :param cmd: The command that was given to the bot (after being expanded) :param args: Arguments given along with cmd :param match: A re.MatchObject if command is coming from a regex-based command, else None :param msg: The message object :param template_name: The name of the jinja template which should be used to render the markdown output, if any """ private = cmd in self.bot_config.DIVERT_TO_PRIVATE threaded = cmd in self.bot_config.DIVERT_TO_THREAD commands = self.re_commands if match else self.commands try: with self._gbl: method = commands[cmd] # first check if we need to reattach a flow context flow, _ = self.flow_executor.check_inflight_flow_triggered(cmd, msg.frm) if flow: log.debug("Reattach context from flow %s to the message", flow._root.name) msg.ctx = flow.ctx elif method._err_command_flow_only: # check if it is a flow_only command but we are not in a flow. log.debug("%s is tagged flow_only and we are not in a flow. Ignores the command.", cmd) return if inspect.isgeneratorfunction(method): replies = method(msg, match) if match else method(msg, args) for reply in replies: if reply: self.send_simple_reply(msg, self.process_template(template_name, reply), private, threaded) else: reply = method(msg, match) if match else method(msg, args) if reply: self.send_simple_reply(msg, self.process_template(template_name, reply), private, threaded) # The command is a success, check if this has not made a flow progressed self.flow_executor.trigger(cmd, msg.frm, msg.ctx) except CommandError as command_error: reason = command_error.reason if command_error.template: reason = self.process_template(command_error.template, reason) self.send_simple_reply(msg, reason, private, threaded) except Exception as e: tb = traceback.format_exc() log.exception(f'An error happened while processing a message ("{msg.body}"): {tb}"') self.send_simple_reply(msg, self.MSG_ERROR_OCCURRED + f':\n{e}', private, threaded) def unknown_command(self, _, cmd, args): """ Override the default unknown command behavior """ full_cmd = cmd + ' ' + args.split(' ')[0] if args else None if full_cmd: msg = f'Command "{cmd}" / "{full_cmd}" not found.' else: msg = f'Command "{cmd}" not found.' ununderscore_keys = [m.replace('_', ' ') for m in self.commands.keys()] matches = difflib.get_close_matches(cmd, ununderscore_keys) if full_cmd: matches.extend(difflib.get_close_matches(full_cmd, ununderscore_keys)) matches = set(matches) if matches: alternatives = ('" or "' + self.bot_config.BOT_PREFIX).join(matches) msg += f'\n\nDid you mean "{self.bot_config.BOT_PREFIX}{alternatives}" ?' return msg def inject_commands_from(self, instance_to_inject): with self._gbl: plugin_name = instance_to_inject.name for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod): if getattr(value, '_err_command', False): commands = self.re_commands if getattr(value, '_err_re_command') else self.commands name = getattr(value, '_err_command_name') if name in commands: f = commands[name] new_name = (plugin_name + '-' + name).lower() self.warn_admins(f'{plugin_name}.{name} clashes with {type(f.__self__).__name__}.{f.__name__} ' f'so it has been renamed {new_name}') name = new_name value.__func__._err_command_name = new_name # To keep track of the renaming. commands[name] = value if getattr(value, '_err_re_command'): log.debug('Adding regex command : %s -> %s.', name, value.__name__) self.re_commands = commands else: log.debug('Adding command : %s -> %s.', name, value.__name__) self.commands = commands def inject_flows_from(self, instance_to_inject): classname = instance_to_inject.__class__.__name__ for name, method in inspect.getmembers(instance_to_inject, inspect.ismethod): if getattr(method, '_err_flow', False): log.debug('Found new flow %s: %s', classname, name) flow = FlowRoot(name, method.__doc__) try: method(flow) except Exception: log.exception("Exception initializing a flow") self.flow_executor.add_flow(flow) def inject_command_filters_from(self, instance_to_inject): with self._gbl: for name, method in inspect.getmembers(instance_to_inject, inspect.ismethod): if getattr(method, '_err_command_filter', False): log.debug('Adding command filter: %s', name) self.command_filters.append(method) def remove_flows_from(self, instance_to_inject): for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod): if getattr(value, '_err_flow', False): log.debug('Remove flow %s', name) # TODO(gbin) def remove_commands_from(self, instance_to_inject): with self._gbl: for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod): if getattr(value, '_err_command', False): name = getattr(value, '_err_command_name') if getattr(value, '_err_re_command') and name in self.re_commands: del self.re_commands[name] elif not getattr(value, '_err_re_command') and name in self.commands: del self.commands[name] def remove_command_filters_from(self, instance_to_inject): with self._gbl: for name, method in inspect.getmembers(instance_to_inject, inspect.ismethod): if getattr(method, '_err_command_filter', False): log.debug('Removing command filter: %s', name) self.command_filters.remove(method) def _admins_to_notify(self): """ Creates a list of administrators to notify """ admins_to_notify = self.bot_config.BOT_ADMINS_NOTIFICATIONS return admins_to_notify def warn_admins(self, warning: str) -> None: """ Send a warning to the administrators of the bot. :param warning: The markdown-formatted text of the message to send. """ for admin in self._admins_to_notify(): self.send(self.build_identifier(admin), warning) def callback_message(self, msg): """Processes for commands and dispatches the message to all the plugins.""" if self.process_message(msg): # Act only in the backend tells us that this message is OK to broadcast self._dispatch_to_plugins('callback_message', msg) def callback_mention(self, msg, people): log.debug("%s has/have been mentioned", ', '.join(str(p) for p in people)) self._dispatch_to_plugins('callback_mention', msg, people) def callback_presence(self, pres): self._dispatch_to_plugins('callback_presence', pres) def callback_room_joined(self, room): """ Triggered when the bot has joined a MUC. :param room: An instance of :class:`~errbot.backends.base.MUCRoom` representing the room that was joined. """ self._dispatch_to_plugins('callback_room_joined', room) def callback_room_left(self, room): """ Triggered when the bot has left a MUC. :param room: An instance of :class:`~errbot.backends.base.MUCRoom` representing the room that was left. """ self._dispatch_to_plugins('callback_room_left', room) def callback_room_topic(self, room): """ Triggered when the topic in a MUC changes. :param room: An instance of :class:`~errbot.backends.base.MUCRoom` representing the room for which the topic changed. """ self._dispatch_to_plugins('callback_room_topic', room) def callback_stream(self, stream): log.info('Initiated an incoming transfer %s.', stream) Tee(stream, self.plugin_manager.get_all_active_plugins()).start() def signal_connect_to_all_plugins(self): for bot in self.plugin_manager.get_all_active_plugins(): if hasattr(bot, 'callback_connect'): # noinspection PyBroadException try: log.debug('Trigger callback_connect on %s.', bot.__class__.__name__) bot.callback_connect() except Exception: log.exception(f'callback_connect failed for {bot}.') def connect_callback(self): log.info('Activate internal commands') if self._plugin_errors_during_startup: errors = f'Some plugins failed to start during bot startup:\n\n{self._plugin_errors_during_startup}' else: errors = '' errors += self.plugin_manager.activate_non_started_plugins() if errors: self.warn_admins(errors) log.info(errors) log.info('Notifying connection to all the plugins...') self.signal_connect_to_all_plugins() log.info('Plugin activation done.') def disconnect_callback(self): log.info('Disconnect callback, deactivating all the plugins.') self.plugin_manager.deactivate_all_plugins() def get_doc(self, command): """Get command documentation """ if not command.__doc__: return '(undocumented)' if self.prefix == '!': return command.__doc__ ununderscore_keys = (m.replace('_', ' ') for m in self.all_commands.keys()) pat = re.compile(fr'!({"|".join(ununderscore_keys)})') return re.sub(pat, self.prefix + '\1', command.__doc__) @staticmethod def get_plugin_class_from_method(meth): for cls in inspect.getmro(type(meth.__self__)): if meth.__name__ in cls.__dict__: return cls return None def get_command_classes(self): return (self.get_plugin_class_from_method(command) for command in self.all_commands.values()) def shutdown(self): self.close_storage() self.plugin_manager.shutdown() self.repo_manager.shutdown() def prefix_groupchat_reply(self, message: Message, identifier: Identifier): if message.body.startswith('#'): # Markdown heading, insert an extra newline to ensure the # markdown rendering doesn't break. message.body = "\n" + message.body errbot-6.1.1+ds/errbot/core_plugins/000077500000000000000000000000001355337103200174065ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/core_plugins/__init__.py000066400000000000000000000000701355337103200215140ustar00rootroot00000000000000from flask.app import Flask flask_app = Flask(__name__) errbot-6.1.1+ds/errbot/core_plugins/acls.plug000066400000000000000000000002071355337103200212200ustar00rootroot00000000000000[Core] Name = ACLs Module = acls Core = True [Documentation] Description = This is checking commands for ACLs. [Python] Version = 2+ errbot-6.1.1+ds/errbot/core_plugins/acls.py000066400000000000000000000105361355337103200207070ustar00rootroot00000000000000import fnmatch from errbot import BotPlugin, cmdfilter from errbot.backends.base import RoomOccupant BLOCK_COMMAND = (None, None, None) def get_acl_usr(msg): """Return the ACL attribute of the sender of the given message""" if hasattr(msg.frm, 'aclattr'): # if the identity requires a special field to be used for acl return msg.frm.aclattr return msg.frm.person # default def glob(text, patterns): """ Match text against the list of patterns according to unix glob rules. Return True if a match is found, False otherwise. """ if isinstance(patterns, str): patterns = (patterns,) if not isinstance(text, str): text = str(text) return any(fnmatch.fnmatchcase(text, str(pattern)) for pattern in patterns) def ciglob(text, patterns): """ Case-insensitive version of glob. Match text against the list of patterns according to unix glob rules. Return True if a match is found, False otherwise. """ if isinstance(patterns, str): patterns = (patterns,) return glob(text.lower(), [p.lower() for p in patterns]) class ACLS(BotPlugin): """ This plugin implements access controls for commands, allowing them to be restricted via various rules. """ def access_denied(self, msg, reason, dry_run): if not dry_run and not self.bot_config.HIDE_RESTRICTED_ACCESS: self._bot.send_simple_reply(msg, reason) return BLOCK_COMMAND @cmdfilter def acls(self, msg, cmd, args, dry_run): """ Check command against ACL rules as defined in the bot configuration. :param msg: The original chat message. :param cmd: The command name itself. :param args: Arguments passed to the command. :param dry_run: True when this is a dry-run. """ self.log.debug('Check %s for ACLs.', cmd) f = self._bot.all_commands[cmd] cmd_str = f'{f.__self__.name}:{cmd}' usr = get_acl_usr(msg) acl = self.bot_config.ACCESS_CONTROLS_DEFAULT.copy() for pattern, acls in self.bot_config.ACCESS_CONTROLS.items(): if ':' not in pattern: pattern = f'*:{pattern}' if ciglob(cmd_str, (pattern,)): acl.update(acls) break self.log.info('Matching ACL %s against username %s for command %s.', acl, usr, cmd_str) if 'allowusers' in acl and not glob(usr, acl['allowusers']): return self.access_denied(msg, "You're not allowed to access this command from this user", dry_run) if 'denyusers' in acl and glob(usr, acl['denyusers']): return self.access_denied(msg, "You're not allowed to access this command from this user", dry_run) if msg.is_group: if not isinstance(msg.frm, RoomOccupant): raise Exception(f'msg.frm is not a RoomOccupant. Class of frm: {msg.frm.__class__}') room = str(msg.frm.room) if 'allowmuc' in acl and acl['allowmuc'] is False: return self.access_denied(msg, "You're not allowed to access this command from a chatroom", dry_run) if 'allowrooms' in acl and not glob(room, acl['allowrooms']): return self.access_denied(msg, "You're not allowed to access this command from this room", dry_run) if 'denyrooms' in acl and glob(room, acl['denyrooms']): return self.access_denied(msg, "You're not allowed to access this command from this room", dry_run) elif 'allowprivate' in acl and acl['allowprivate'] is False: return self.access_denied( msg, "You're not allowed to access this command via private message to me", dry_run ) self.log.info('Check if %s is admin only command.', cmd) if f._err_command_admin_only: if not glob(get_acl_usr(msg), self.bot_config.BOT_ADMINS): return self.access_denied(msg, 'This command requires bot-admin privileges', dry_run) # For security reasons, admin-only commands are direct-message only UNLESS # specifically overridden by setting allowmuc to True for such commands. if msg.is_group and not acl.get('allowmuc', False): return self.access_denied(msg, 'This command may only be issued through a direct message', dry_run) return msg, cmd, args errbot-6.1.1+ds/errbot/core_plugins/backup.plug000066400000000000000000000002431355337103200215430ustar00rootroot00000000000000[Core] Name = Backup Module = backup Core = True [Documentation] Description = This core plugin manage import and export of data from Err. [Python] Version = 2+ errbot-6.1.1+ds/errbot/core_plugins/backup.py000066400000000000000000000037441355337103200212350ustar00rootroot00000000000000import os from errbot import BotPlugin, botcmd class Backup(BotPlugin): """ Backup related commands. """ @botcmd(admin_only=True) def backup(self, msg, args): """Backup everything. Makes a backup script called backup.py in the data bot directory. You can restore the backup from the command line with errbot --restore """ filename = os.path.join(self.bot_config.BOT_DATA_DIR, 'backup.py') with open(filename, 'w') as f: f.write('## This file is not executable on its own. use errbot -r FILE to restore your bot.\n\n') f.write('log.info("Restoring repo_manager.")\n') for key, value in self._bot.repo_manager.items(): f.write('bot.repo_manager["' + key + '"] = ' + repr(value) + '\n') f.write('log.info("Restoring plugin_manager.")\n') for key, value in self._bot.plugin_manager.items(): # don't mimic that in real plugins, this is core only. f.write('bot.plugin_manager["' + key + '"] = ' + repr(value) + '\n') f.write('log.info("Installing plugins.")\n') f.write('if "installed_repos" in bot.repo_manager:\n') f.write(' for repo in bot.repo_manager["installed_repos"]:\n') f.write(' log.error(bot.repo_manager.install_repo(repo))\n') f.write('log.info("Restoring plugins data.")\n') f.write('bot.plugin_manager.update_plugin_places(bot.repo_manager.get_all_repos_paths())\n') for plugin in self._bot.plugin_manager.plugins.values(): if plugin._store: f.write('pobj = bot.plugin_manager.plugins["' + plugin.name + '"]\n') f.write('pobj.init_storage()\n') for key, value in plugin.items(): f.write('pobj["' + key + '"] = ' + repr(value) + '\n') f.write('pobj.close_storage()\n') return f'The backup file has been written in "{filename}".' errbot-6.1.1+ds/errbot/core_plugins/chatRoom.plug000066400000000000000000000002271355337103200220540ustar00rootroot00000000000000[Core] Name = ChatRoom Module = chatRoom Core = True [Documentation] Description = This is a basic implementation of a chatroom [Python] Version = 2+errbot-6.1.1+ds/errbot/core_plugins/chatRoom.py000066400000000000000000000175461355337103200215510ustar00rootroot00000000000000import logging from errbot import BotPlugin, botcmd, SeparatorArgParser, ShlexArgParser from errbot.backends.base import RoomNotJoinedError log = logging.getLogger(__name__) class ChatRoom(BotPlugin): connected = False def callback_connect(self): self.log.info('Callback_connect') if not self.connected: self.connected = True for room in self.bot_config.CHATROOM_PRESENCE: self.log.debug('Try to join room %s', repr(room)) try: self._join_room(room) except Exception: # Ensure failure to join a room doesn't crash the plugin # as a whole. self.log.exception(f'Joining room {repr(room)} failed') def _join_room(self, room): username = self.bot_config.CHATROOM_FN password = None if isinstance(room, (tuple, list)): room, password = room # unpack self.log.info('Joining room %s with username %s and pass ***.', room, username) else: self.log.info('Joining room %s with username %s.', room, username) self.query_room(room).join(username=self.bot_config.CHATROOM_FN, password=password) def deactivate(self): self.connected = False super().deactivate() @botcmd(split_args_with=SeparatorArgParser()) def room_create(self, message, args): """ Create a chatroom. Usage: !room create Examples (XMPP): !room create example-room@chat.server.tld Examples (IRC): !room create #example-room """ if len(args) < 1: return "Please tell me which chatroom to create." room = self.query_room(args[0]) room.create() return f'Created the room {room}.' @botcmd(split_args_with=ShlexArgParser()) def room_join(self, message, args): """ Join (creating it first if needed) a chatroom. Usage: !room join [] Examples (XMPP): !room join example-room@chat.server.tld !room join example-room@chat.server.tld super-secret-password Examples (IRC): !room join #example-room !room join #example-room super-secret-password !room join #example-room "password with spaces" """ arglen = len(args) if arglen < 1: return "Please tell me which chatroom to join." args[0].strip() room_name, password = (args[0], None) if arglen == 1 else (args[0], args[1]) room = self.query_room(room_name) if room is None: return f'Cannot find room {room_name}.' room.join(username=self.bot_config.CHATROOM_FN, password=password) return f'Joined the room {room_name}.' @botcmd(split_args_with=SeparatorArgParser()) def room_leave(self, message, args): """ Leave a chatroom. Usage: !room leave Examples (XMPP): !room leave example-room@chat.server.tld Examples (IRC): !room leave #example-room """ if len(args) < 1: return 'Please tell me which chatroom to leave.' self.query_room(args[0]).leave() return f'Left the room {args[0]}.' @botcmd(split_args_with=SeparatorArgParser()) def room_destroy(self, message, args): """ Destroy a chatroom. Usage: !room destroy Examples (XMPP): !room destroy example-room@chat.server.tld Examples (IRC): !room destroy #example-room """ if len(args) < 1: return "Please tell me which chatroom to destroy." self.query_room(args[0]).destroy() return f'Destroyed the room {args[0]}.' @botcmd(split_args_with=SeparatorArgParser()) def room_invite(self, message, args): """ Invite one or more people into a chatroom. Usage: !room invite [, ..] Examples (XMPP): !room invite room@conference.server.tld bob@server.tld Examples (IRC): !room invite #example-room bob """ if len(args) < 2: return 'Please tell me which person(s) to invite into which room.' self.query_room(args[0]).invite(*args[1:]) return f'Invited {", ".join(args[1:])} into the room {args[0]}.' @botcmd def room_list(self, message, args): """ List chatrooms the bot has joined. Usage: !room list Examples: !room list """ rooms = [str(room) for room in self.rooms()] if len(rooms): rooms_str = '\n\t'.join(rooms) return f"I'm currently in these rooms:\n\t{rooms_str}" else: return "I'm not currently in any rooms." @botcmd(split_args_with=ShlexArgParser()) def room_occupants(self, message, args): """ List the occupants in a given chatroom. Usage: !room occupants [ ..] Examples (XMPP): !room occupants room@conference.server.tld Examples (IRC): !room occupants #example-room #another-example-room """ if len(args) < 1: yield "Please supply a room to list the occupants of." return for room in args: try: occupants = [o.person for o in self.query_room(room).occupants] occupants_str = "\n\t".join(occupants) yield f'Occupants in {room}:\n\t{occupants_str}.' except RoomNotJoinedError as e: yield f'Cannot list occupants in {room}: {e}.' @botcmd(split_args_with=ShlexArgParser()) def room_topic(self, message, args): """ Get or set the topic for a room. Usage: !room topic [] Examples (XMPP): !room topic example-room@chat.server.tld !room topic example-room@chat.server.tld "Err rocks!" Examples (IRC): !room topic #example-room !room topic #example-room "Err rocks!" """ arglen = len(args) if arglen < 1: return "Please tell me which chatroom you want to know the topic of." if arglen == 1: try: topic = self.query_room(args[0]).topic except RoomNotJoinedError as e: return f'Cannot get the topic for {args[0]}: {e}.' if topic is None: return f'No topic is set for {args[0]}.' else: return f'Topic for {args[0]}: {topic}.' else: try: self.query_room(args[0]).topic = args[1] except RoomNotJoinedError as e: return f'Cannot set the topic for {args[0]}: {e}.' return f"Topic for {args[0]} set." def callback_message(self, msg): try: if msg.is_direct: username = msg.frm.person if username in self.bot_config.CHATROOM_RELAY: self.log.debug('Message to relay from %s.', username) body = msg.body rooms = self.bot_config.CHATROOM_RELAY[username] for roomstr in rooms: self.send(self.query_room(roomstr), body) elif msg.is_group: fr = msg.frm chat_room = str(fr.room) if chat_room in self.bot_config.REVERSE_CHATROOM_RELAY: users_to_relay_to = self.bot_config.REVERSE_CHATROOM_RELAY[chat_room] self.log.debug('Message to relay to %s.', users_to_relay_to) body = f'[{fr.person}] {msg.body}' for user in users_to_relay_to: self.send(user, body) except Exception as e: self.log.exception(f'crashed in callback_message {e}') errbot-6.1.1+ds/errbot/core_plugins/cnf_filter.plug000066400000000000000000000002631355337103200224130ustar00rootroot00000000000000[Core] Name = CommandNotFoundFilter Module = cnf_filter Core = True [Documentation] Description = Allow overridable actions when a command is not present. [Python] Version = 2+ errbot-6.1.1+ds/errbot/core_plugins/cnf_filter.py000066400000000000000000000025531355337103200221000ustar00rootroot00000000000000from errbot import BotPlugin, cmdfilter class CommandNotFoundFilter(BotPlugin): @cmdfilter(catch_unprocessed=True) def cnf_filter(self, msg, cmd, args, dry_run, emptycmd=False): """ Check if command exists. If not, signal plugins. This plugin will be called twice: once as a command filter and then again as a "command not found" filter. See the emptycmd parameter. :param msg: Original chat message. :param cmd: Parsed command. :param args: Command arguments. :param dry_run: True when this is a dry-run. :param emptycmd: False when this command has been parsed and is valid. True if the command was not found. """ if not emptycmd: return msg, cmd, args if self.bot_config.SUPPRESS_CMD_NOT_FOUND: self.log.debug('Suppressing command not found feedback.') return command = msg.body.strip() for prefix in self.bot_config.BOT_ALT_PREFIXES + (self.bot_config.BOT_PREFIX,): if command.startswith(prefix): command = command.replace(prefix, '', 1) break command_args = command.split(' ', 1) command = command_args[0] if len(command_args) > 1: args = ' '.join(command_args[1:]) return self._bot.unknown_command(msg, command, args) errbot-6.1.1+ds/errbot/core_plugins/flows.plug000066400000000000000000000002421355337103200214270ustar00rootroot00000000000000[Core] Name = Flows Module = flows Core = True [Documentation] Description = Core Errbot commands to query and manage conversation flows. [Python] Version = 2+ errbot-6.1.1+ds/errbot/core_plugins/flows.py000066400000000000000000000136061355337103200211200ustar00rootroot00000000000000import io import json from errbot import BotPlugin, botcmd, arg_botcmd from errbot.flow import FlowNode, FlowRoot, Flow, FLOW_END from errbot.core_plugins.acls import glob, get_acl_usr class Flows(BotPlugin): """ Management commands related to flows / conversations. """ def recurse_node(self, response: io.StringIO, stack, f: FlowNode, flow: Flow = None): if f in stack: response.write(f'{"  " * (len(stack))}↺
') return if isinstance(f, FlowRoot): doc = f.description if flow else '' response.write('Flow [' + f.name + '] ' + doc + '
') if flow and flow.current_step == f: response.write(f'↪  Start (_{flow.requestor}_)
') else: cmd = 'END' if f is FLOW_END else self._bot.all_commands[f.command] requestor = f'(_{str(flow.requestor)}_)' if flow and flow.current_step == f else '' doc = cmd.__doc__ if flow and f is not FLOW_END else '' response.write(f'{"  " * len(stack)}' f'↪  **{f if f is not FLOW_END else "END"}** {doc if doc else ""} {requestor}
') for _, sf in f.children: self.recurse_node(response, stack + [f], sf, flow) @botcmd(syntax='') def flows_show(self, _, args): """ Shows the structure of a flow. """ if not args: return 'You need to specify a flow name.' with io.StringIO() as response: flow_node = self._bot.flow_executor.flow_roots.get(args, None) if flow_node is None: return f"Flow {args} doesn't exist." self.recurse_node(response, [], flow_node) return response.getvalue() # noinspection PyUnusedLocal @botcmd def flows_list(self, msg, args): """ Displays the list of setup flows. """ with io.StringIO() as response: for name, flow_node in self._bot.flow_executor.flow_roots.items(): response.write("- **" + name + "** " + flow_node.description + "\n") return response.getvalue() @botcmd(split_args_with=' ', syntax=' [initial_payload]') def flows_start(self, msg, args): """ Manually start a flow within the context of the calling user. You can prefeed the flow data with a json payload. Example: !flows start poll_setup {"title":"yeah!","options":["foo","bar","baz"]} """ if not args: return 'You need to specify a flow to manually start' context = {} flow_name = args[0] if len(args) > 1: json_payload = ' '.join(args[1:]) try: context = json.loads(json_payload) except Exception as e: return f'Cannot parse json {json_payload}: {e}.' self._bot.flow_executor.start_flow(flow_name, msg.frm, context) return f'Flow **{flow_name}** started ...' @botcmd() def flows_status(self, msg, args): """ Displays the list of started flows. """ with io.StringIO() as response: if not self._bot.flow_executor.in_flight: response.write('No Flow started.\n') else: if not [flow for flow in self._bot.flow_executor.in_flight if self.check_user(msg, flow)]: response.write(f'No Flow started for current user: {get_acl_usr(msg)}.\n') else: if args: for flow in self._bot.flow_executor.in_flight: if self.check_user(msg, flow): if flow.name == args: self.recurse_node(response, [], flow.root, flow) else: for flow in self._bot.flow_executor.in_flight: if self.check_user(msg, flow): next_steps = [f'\\*{str(step[1].command)}\\*' for step in flow._current_step.children if step[1].command] next_steps_str = '\n'.join(next_steps) text = f'\\>>> {str(flow.requestor)} is using flow \\*{flow.name}\\* on step ' \ f'\\*{flow.current_step}\\*\nNext Step(s): \n{next_steps_str}' response.write(text) return response.getvalue() @botcmd(syntax='[flow_name]') def flows_stop(self, msg, args): """ Stop flows you are in. optionally, stop a specific flow you are in. """ if args: flow = self._bot.flow_executor.stop_flow(args, msg.frm) if flow: yield flow.name + ' stopped.' return yield 'Flow not found.' return one_stopped = False for flow in self._bot.flow_executor.in_flight: if flow.requestor == msg.frm: flow = self._bot.flow_executor.stop_flow(flow.name, msg.frm) if flow: one_stopped = True yield flow.name + ' stopped.' if not one_stopped: yield 'No Flow found.' @arg_botcmd('flow_name', type=str) @arg_botcmd('user', type=str) def flows_kill(self, _, user, flow_name): """ Admin command to kill a specific flow.""" flow = self._bot.flow_executor.stop_flow(flow_name, self.build_identifier(user)) if flow: return flow.name + ' killed.' return 'Flow not found.' def check_user(self, msg, flow): """Checks to make sure that either the user started the flow, or is a bot admin """ if glob(get_acl_usr(msg), self.bot_config.BOT_ADMINS): return True elif glob(get_acl_usr(msg), flow.requestor.person): return True return False errbot-6.1.1+ds/errbot/core_plugins/health.plug000066400000000000000000000002421355337103200215420ustar00rootroot00000000000000[Core] Name = Health Module = health Core = True [Documentation] Description = Core plugin for bot lifecycle and health related commands. [Python] Version = 2+ errbot-6.1.1+ds/errbot/core_plugins/health.py000066400000000000000000000072371355337103200212360ustar00rootroot00000000000000import gc import os import signal from datetime import datetime from errbot import BotPlugin, botcmd, arg_botcmd from errbot.utils import format_timedelta, global_restart class Health(BotPlugin): @botcmd(template='status') def status(self, msg, args): """ If I am alive I should be able to respond to this one """ plugins_statuses = self.status_plugins(msg, args) loads = self.status_load(msg, args) gc = self.status_gc(msg, args) return {'plugins_statuses': plugins_statuses['plugins_statuses'], 'loads': loads['loads'], 'gc': gc['gc']} @botcmd(template='status_load') def status_load(self, _, args): """ shows the load status """ try: from posix import getloadavg loads = getloadavg() except Exception: loads = None return {'loads': loads} @botcmd(template='status_gc') def status_gc(self, _, args): """ shows the garbage collection details """ return {'gc': gc.get_count()} @botcmd(template='status_plugins') def status_plugins(self, _, args): """ shows the plugin status """ pm = self._bot.plugin_manager all_blacklisted = pm.get_blacklisted_plugin() all_loaded = pm.get_all_active_plugin_names() all_attempted = sorted(pm.plugin_infos.keys()) plugins_statuses = [] for name in all_attempted: if name in all_blacklisted: if name in all_loaded: plugins_statuses.append(('BA', name)) else: plugins_statuses.append(('BD', name)) elif name in all_loaded: plugins_statuses.append(('A', name)) elif pm.get_plugin_obj_by_name(name) is not None \ and pm.get_plugin_obj_by_name(name).get_configuration_template() is not None \ and pm.get_plugin_configuration(name) is None: plugins_statuses.append(('C', name)) else: plugins_statuses.append(('D', name)) return {'plugins_statuses': plugins_statuses} @botcmd def uptime(self, _, args): """ Return the uptime of the bot """ u = format_timedelta(datetime.now() - self._bot.startup_time) since = self._bot.startup_time.strftime('%A, %b %d at %H:%M') return f"I've been up for {u} (since {since})." # noinspection PyUnusedLocal @botcmd(admin_only=True) def restart(self, msg, args): """ Restart the bot. """ self.send(msg.frm, "Deactivating all the plugins...") self._bot.plugin_manager.deactivate_all_plugins() self.send(msg.frm, "Restarting") self._bot.shutdown() global_restart() return "I'm restarting..." # noinspection PyUnusedLocal @arg_botcmd('--confirm', dest="confirmed", action="store_true", help="confirm you want to shut down", admin_only=True) @arg_botcmd('--kill', dest="kill", action="store_true", help="kill the bot instantly, don't shut down gracefully", admin_only=True) def shutdown(self, msg, confirmed, kill): """ Shutdown the bot. Useful when the things are going crazy and you don't have access to the machine. """ if not confirmed: yield "Please provide `--confirm` to confirm you really want me to shut down." return if kill: yield "Killing myself right now!" os.kill(os.getpid(), signal.SIGKILL) else: yield "Roger that. I am shutting down." os.kill(os.getpid(), signal.SIGINT) errbot-6.1.1+ds/errbot/core_plugins/help.plug000066400000000000000000000002121355337103200212220ustar00rootroot00000000000000[Core] Name = Help Module = help Core = True [Documentation] Description = Core plugin of help related functions. [Python] Version = 2+ errbot-6.1.1+ds/errbot/core_plugins/help.py000066400000000000000000000153171355337103200207170ustar00rootroot00000000000000import textwrap import subprocess from dulwich import errors as dulwich_errors from errbot import BotPlugin, botcmd from errbot.utils import git_tag_list from errbot.version import VERSION class Help(BotPlugin): MSG_HELP_TAIL = 'Type help to get more info ' \ 'about that specific command.' MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.' def is_git_directory(self, path='.'): try: tags = git_tag_list(path) except dulwich_errors.NotGitRepository: tags = None except Exception as _: # we might want to handle other exceptions another way. For now leaving this general tags = None return tags.pop(-1) if tags is not None else None # noinspection PyUnusedLocal @botcmd(template='about') def about(self, msg, args): """Return information about this Errbot instance and version""" git_version = self.is_git_directory() if git_version: return dict(version=f"{git_version.decode('utf-8')} GIT CHECKOUT") else: return {'version': VERSION} # noinspection PyUnusedLocal @botcmd def apropos(self, msg, args): """ Returns a help string listing available options. Automatically assigned to the "help" command.""" if not args: return 'Usage: ' + self._bot.prefix + 'apropos search_term' description = 'Available commands:\n' cls_commands = {} for (name, command) in self._bot.all_commands.items(): cls = self._bot.get_plugin_class_from_method(command) cls = str.__module__ + '.' + cls.__name__ # makes the fuul qualified name commands = cls_commands.get(cls, []) if not self.bot_config.HIDE_RESTRICTED_COMMANDS or self._bot.check_command_access(msg, name)[0]: commands.append((name, command)) cls_commands[cls] = commands usage = '' for cls in sorted(cls_commands): commands = [] for (name, command) in cls_commands[cls]: if name == 'help': continue if command._err_command_hidden: continue doc = command.__doc__ if doc is not None and args.lower() not in doc.lower(): continue name_with_spaces = name.replace('_', ' ', 1) doc = (doc or '(undocumented)').strip().split('\n', 1)[0] commands.append('\t' + self._bot.prefix + name_with_spaces + ': ' + doc) usage += '\n'.join(commands) usage += '\n\n' return ''.join(filter(None, [description, usage])).strip() @botcmd def help(self, msg, args): """Returns a help string listing available options. Automatically assigned to the "help" command.""" def may_access_command(m, cmd): m, _, _ = self._bot._process_command_filters( msg=m, cmd=cmd, args=None, dry_run=True ) return m is not None def get_name(named): return named.__name__.lower() # Normalize args to lowercase for ease of use args = args.lower() if args else '' usage = '' description = '### All commands\n' cls_obj_commands = {} for (name, command) in self._bot.all_commands.items(): cls = self._bot.get_plugin_class_from_method(command) obj = command.__self__ _, commands = cls_obj_commands.get(cls, (None, [])) if not self.bot_config.HIDE_RESTRICTED_COMMANDS or may_access_command(msg, name): commands.append((name, command)) cls_obj_commands[cls] = (obj, commands) # show all if not args: for cls in sorted(cls_obj_commands.keys(), key=lambda c: cls_obj_commands[c][0].name): obj, commands = cls_obj_commands[cls] name = obj.name # shows class and description usage += f'\n**{name}**\n\n*{cls.__errdoc__.strip() or ""}*\n\n' for name, command in sorted(commands): if command._err_command_hidden: continue # show individual commands usage += self._cmd_help_line(name, command) usage += '\n\n' # end cls section elif args: for cls, (obj, cmds) in cls_obj_commands.items(): if obj.name.lower() == args: break else: cls, obj, cmds = None, None, None if cls is None: # Plugin not found. description = '' all_commands = dict(self._bot.all_commands) all_commands.update( {k.replace('_', ' '): v for k, v in all_commands.items()}) if args in all_commands: usage += self._cmd_help_line(args, all_commands[args], True) else: usage += self.MSG_HELP_UNDEFINED_COMMAND else: # filter out the commands related to this class description = f'\n**{obj.name}**\n\n*{cls.__errdoc__.strip() or ""}*\n\n' pairs = [] for (name, command) in cmds: if self.bot_config.HIDE_RESTRICTED_COMMANDS: if command._err_command_hidden: continue if not may_access_command(msg, name): continue pairs.append((name, command)) pairs = sorted(pairs) for (name, command) in pairs: usage += self._cmd_help_line(name, command) return ''.join(filter(None, [description, usage])) def _cmd_help_line(self, name, command, show_doc=False): """ Returns: str. a single line indicating the help representation of a command. """ cmd_name = name.replace('_', ' ') cmd_doc = textwrap.dedent(self._bot.get_doc(command)).strip() prefix = self._bot.prefix if getattr(command, '_err_command_prefix_required', True) else '' name = cmd_name patt = getattr(command, '_err_command_re_pattern', None) if patt: re_help_name = getattr(command, '_err_command_re_name_help', None) name = re_help_name if re_help_name else patt.pattern if not show_doc: cmd_doc = cmd_doc.split('\n')[0] if len(cmd_doc) > 80: cmd_doc = f'{cmd_doc[:77]}...' help_str = f'- **{prefix}{name}** - {cmd_doc}\n' return help_str errbot-6.1.1+ds/errbot/core_plugins/plugins.plug000066400000000000000000000002401355337103200217540ustar00rootroot00000000000000[Core] Name = Plugins Module = plugins Core = True [Documentation] Description = Commands to manage the plugins of the bot by chatting. [Python] Version = 2+ errbot-6.1.1+ds/errbot/core_plugins/plugins.py000066400000000000000000000335071355337103200214510ustar00rootroot00000000000000from ast import literal_eval from pprint import pformat import os import shutil import logging from errbot import BotPlugin, botcmd from errbot.plugin_manager import PluginConfigurationException, PluginActivationException from errbot.repo_manager import RepoException class Plugins(BotPlugin): @botcmd(admin_only=True) def repos_install(self, _, args): """ install a plugin repository from the given source or a known public repo (see !repos to find those). for example from a known repo : !install err-codebot for example a git url : git@github.com:gbin/plugin.git or an url towards a tar.gz archive : http://www.gootz.net/plugin-latest.tar.gz """ args = args.strip() if not args: yield 'Please specify a repository listed in "!repos" or ' \ 'give me the URL to a git repository that I should clone for you.' return try: yield f'Installing {args}...' local_path = self._bot.repo_manager.install_repo(args) errors = self._bot.plugin_manager.update_plugin_places(self._bot.repo_manager.get_all_repos_paths()) if errors: v = '\n'.join(errors.values()) yield f'Some plugins are generating errors:\n{v}.' # if the load of the plugin failed, uninstall cleanly teh repo for path in errors.keys(): if str(path).startswith(local_path): yield f'Removing {local_path} as it did not load correctly.' shutil.rmtree(local_path) else: yield f'A new plugin repository has been installed correctly from {args}. ' \ f'Refreshing the plugins commands...' loading_errors = self._bot.plugin_manager.activate_non_started_plugins() if loading_errors: yield loading_errors yield 'Plugins reloaded.' except RepoException as re: yield f'Error installing the repo: {re}' @botcmd(admin_only=True) def repos_uninstall(self, _, repo_name): """ uninstall a plugin repository by name. """ if not repo_name.strip(): yield "You should have a repo name as argument" return repos = self._bot.repo_manager.get_installed_plugin_repos() if repo_name not in repos: yield "This repo is not installed check with " + self._bot.prefix + "repos the list of installed ones" return plugin_path = os.path.join(self._bot.repo_manager.plugin_dir, repo_name) self._bot.plugin_manager.remove_plugins_from_path(plugin_path) self._bot.repo_manager.uninstall_repo(repo_name) yield f'Repo {repo_name} removed.' @botcmd(template='repos') def repos(self, _, args): """ list the current active plugin repositories """ installed_repos = self._bot.repo_manager.get_installed_plugin_repos() all_names = [name for name in installed_repos] repos = {'repos': []} for repo_name in all_names: installed = False if repo_name in installed_repos: installed = True from_index = self._bot.repo_manager.get_repo_from_index(repo_name) if from_index is not None: description = '\n'.join((f'{plug.name}: {plug.documentation}' for plug in from_index)) else: description = 'No description.' # installed, public, name, desc repos['repos'].append((installed, from_index is not None, repo_name, description)) return repos @botcmd(template='repos2') def repos_search(self, _, args): """ Searches the repo index. for example: !repos search jenkins """ if not args: # TODO(gbin): return all the repos. return {'error': "Please specify a keyword."} return {'repos': self._bot.repo_manager.search_repos(args)} @botcmd(split_args_with=' ', admin_only=True) def repos_update(self, _, args): """ update the bot and/or plugins use : !repos update all to update everything or : !repos update repo_name repo_name ... to update selectively some repos """ if 'all' in args: results = self._bot.repo_manager.update_all_repos() else: results = self._bot.repo_manager.update_repos(args) yield "Start updating ... " for d, success, feedback in results: if success: yield f'Update of {d} succeeded...\n\n{feedback}\n\n' else: yield f'Update of {d} failed...\n\n{feedback}' for plugin in self._bot.plugin_manager.getAllPlugins(): if plugin.path.startswith(d) and hasattr(plugin, 'is_activated') and plugin.is_activated: name = plugin.name yield f'/me is reloading plugin {name}' try: self._bot.plugin_manager.reload_plugin_by_name(plugin.name) yield f'Plugin {plugin.name} reloaded.' except PluginActivationException as pae: yield f'Error reactivating plugin {plugin.name}: {pae}' yield "Done." @botcmd(split_args_with=' ', admin_only=True) def plugin_config(self, _, args): """ configure or get the configuration / configuration template for a specific plugin ie. !plugin config ExampleBot could return a template if it is not configured: {'LOGIN': 'example@example.com', 'PASSWORD': 'password', 'DIRECTORY': '/toto'} Copy paste, adapt so can configure the plugin : !plugin config ExampleBot {'LOGIN': 'my@email.com', 'PASSWORD': 'myrealpassword', 'DIRECTORY': '/tmp'} It will then reload the plugin with this config. You can at any moment retrieve the current values: !plugin config ExampleBot should return : {'LOGIN': 'my@email.com', 'PASSWORD': 'myrealpassword', 'DIRECTORY': '/tmp'} """ plugin_name = args[0] if self._bot.plugin_manager.is_plugin_blacklisted(plugin_name): return f'Load this plugin first with {self._bot.prefix} load {plugin_name}.' obj = self._bot.plugin_manager.get_plugin_obj_by_name(plugin_name) if obj is None: return f'Unknown plugin or the plugin could not load {plugin_name}.' template_obj = obj.get_configuration_template() if template_obj is None: return 'This plugin is not configurable.' if len(args) == 1: response = f'Default configuration for this plugin (you can copy and paste this directly as a command):' \ f'\n\n```\n{self._bot.prefix}plugin config {plugin_name} {pformat(template_obj)}\n```' current_config = self._bot.plugin_manager.get_plugin_configuration(plugin_name) if current_config: response += f'\n\nCurrent configuration:\n\n```\n{self._bot.prefix}plugin config {plugin_name} ' \ f'{pformat(current_config)}\n```' return response # noinspection PyBroadException try: real_config_obj = literal_eval(' '.join(args[1:])) except Exception: self.log.exception('Invalid expression for the configuration of the plugin') return 'Syntax error in the given configuration' if type(real_config_obj) != type(template_obj): return 'It looks fishy, your config type is not the same as the template !' self._bot.plugin_manager.set_plugin_configuration(plugin_name, real_config_obj) try: self._bot.plugin_manager.deactivate_plugin(plugin_name) except PluginActivationException as pae: return f'Error deactivating {plugin_name}: {pae}.' try: self._bot.plugin_manager.activate_plugin(plugin_name) except PluginConfigurationException as ce: self.log.debug('Invalid configuration for the plugin, reverting the plugin to unconfigured.') self._bot.plugin_manager.set_plugin_configuration(plugin_name, None) return f'Incorrect plugin configuration: {ce}.' except PluginActivationException as pae: return f'Error activating plugin: {pae}.' return 'Plugin configuration done.' def formatted_plugin_list(self, active_only=True): """ Return a formatted, plain-text list of loaded plugins. When active_only=True, this will only return plugins which are actually active. Otherwise, it will also include inactive (blacklisted) plugins. """ if active_only: all_plugins = self._bot.plugin_manager.get_all_active_plugin_names() else: all_plugins = self._bot.plugin_manager.get_all_plugin_names() return "\n".join(("- " + plugin for plugin in all_plugins)) @botcmd(admin_only=True) def plugin_reload(self, _, args): """reload a plugin: reload the code of the plugin leaving the activation status intact.""" name = args.strip() if not name: yield ( f'Please tell me which of the following plugins to reload:\n' f'{self.formatted_plugin_list(active_only=False)}') return if name not in self._bot.plugin_manager.get_all_plugin_names(): yield (f'{name} isn\'t a valid plugin name. ' f'The current plugins are:\n{self.formatted_plugin_list(active_only=False)}') return if name not in self._bot.plugin_manager.get_all_active_plugin_names(): answer = f'Warning: plugin {name} is currently not activated. ' answer += f'Use `{self._bot.prefix}plugin activate {name}` to activate it.' yield answer try: self._bot.plugin_manager.reload_plugin_by_name(name) yield f'Plugin {name} reloaded.' except PluginActivationException as pae: yield f'Error activating plugin {name}: {pae}.' @botcmd(admin_only=True) def plugin_activate(self, _, args): """activate a plugin. [calls .activate() on the plugin]""" args = args.strip() if not args: return (f'Please tell me which of the following plugins to activate:\n' f'{self.formatted_plugin_list(active_only=False)}') if args not in self._bot.plugin_manager.get_all_plugin_names(): return (f"{args} isn't a valid plugin name. The current plugins are:\n" f"{self.formatted_plugin_list(active_only=False)}") if args in self._bot.plugin_manager.get_all_active_plugin_names(): return f'{args} is already activated.' try: self._bot.plugin_manager.activate_plugin(args) except PluginActivationException as pae: return f'Error activating plugin: {pae}' return f'Plugin {args} activated.' @botcmd(admin_only=True) def plugin_deactivate(self, _, args): """deactivate a plugin. [calls .deactivate on the plugin]""" args = args.strip() if not args: return (f'Please tell me which of the following plugins to deactivate:\n' f'{self.formatted_plugin_list(active_only=False)}') if args not in self._bot.plugin_manager.get_all_plugin_names(): return (f"{args} isn't a valid plugin name. The current plugins are:\n" f"{self.formatted_plugin_list(active_only=False)}") if args not in self._bot.plugin_manager.get_all_active_plugin_names(): return f'{args} is already deactivated.' try: self._bot.plugin_manager.deactivate_plugin(args) except PluginActivationException as pae: return f'Error deactivating {args}: {pae}' return f'Plugin {args} deactivated.' @botcmd(admin_only=True) def plugin_blacklist(self, _, args): """Blacklist a plugin so that it will not be loaded automatically during bot startup. If the plugin is currently activated, it will deactiveate it first.""" if args not in self._bot.plugin_manager.get_all_plugin_names(): return (f"{args} isn't a valid plugin name. The current plugins are:\n" f"{self.formatted_plugin_list(active_only=False)}") if args in self._bot.plugin_manager.get_all_active_plugin_names(): try: self._bot.plugin_manager.deactivate_plugin(args) except PluginActivationException as pae: return f'Error deactivating {args}: {pae}.' return self._bot.plugin_manager.blacklist_plugin(args) @botcmd(admin_only=True) def plugin_unblacklist(self, _, args): """Remove a plugin from the blacklist""" if args not in self._bot.plugin_manager.get_all_plugin_names(): return (f"{args} isn't a valid plugin name. The current plugins are:\n" f"{self.formatted_plugin_list(active_only=False)}") if args not in self._bot.plugin_manager.get_all_active_plugin_names(): try: self._bot.plugin_manager.activate_plugin(args) except PluginActivationException as pae: return f'Error activating plugin: {pae}' return self._bot.plugin_manager.unblacklist_plugin(args) @botcmd(admin_only=True, template='plugin_info') def plugin_info(self, _, args): """Gives you a more technical information about a specific plugin.""" pm = self._bot.plugin_manager if args not in pm.get_all_plugin_names(): return (f"{args} isn't a valid plugin name. The current plugins are:\n" f"{self.formatted_plugin_list(active_only=False)}") return {'plugin_info': pm.plugin_infos[args], 'plugin': pm.plugins[args], 'logging': logging} errbot-6.1.1+ds/errbot/core_plugins/templates/000077500000000000000000000000001355337103200214045ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/core_plugins/templates/about.md000066400000000000000000000011361355337103200230410ustar00rootroot00000000000000This is Errbot version {{version}} * Visit http://errbot.io/ for more information about errbot in general. * Visit http://errbot.io/en/latest/#user-guide for help with configuration, administration and plugin development. Errbot is built through the hard work and dedication of everyone who contributes code, documentation and bug reports at https://github.com/errbotio but a special thank you should be given to Guillaume Binet, Tali Petrover, Ben van Daele, Paul Labedan and others at Mondial Telecom, the birthplace of Errbot, without whom this project never would have grown into what it is today. errbot-6.1.1+ds/errbot/core_plugins/templates/plugin_info.md000066400000000000000000000017311355337103200242410ustar00rootroot00000000000000#### Plugin info from {{ plugin_info.location }} name: {{ plugin_info.name }} module: {{ plugin_info.module }} full_module_path: {{ plugin_info.location.parent / (plugin_info.module + '.py') }} core: {{ plugin_info.core }} {% if plugin_info.dependencies %} dependencies: {{ ', '.join(plugin_info.dependencies) }} {% endif %} class: {{ plugin.__module__ + "." + plugin.__class__.__name__ }} storage namespace: {{ plugin.namespace }} log destination: {{ plugin.log.name }} log level: {{ logging.getLevelName(plugin.log.level) }} {% if plugin.keys %} **storage content** Key | Value -------------------- | ----------------------- {% for key, value in plugin.items() %}{{ key.ljust(20) }} | `{{ value }}` {% endfor %} {% endif %} {% if plugin.config %} **config content** Key | Value -------------------- | ----------------------- {% for key, value in plugin.config.items() %}{{ key.ljust(20) }} | `{{ value }}` {% endfor %} {% endif %} errbot-6.1.1+ds/errbot/core_plugins/templates/repos.md000066400000000000000000000006061355337103200230600ustar00rootroot00000000000000{% macro status(installed, public) -%} {% if installed %}**I**{% else %} {% endif %}{% if public %} {% else %}**P**{% endif %}{%- endmacro %} Status | Name | Description ------- | ----------------------- | ------------ {% for installed, public, name, desc in repos %}{{ status(installed, public).ljust(7) }} | {{ ('**'+name+'**').ljust(22) }} | {{ desc }} {% endfor %} errbot-6.1.1+ds/errbot/core_plugins/templates/repos2.md000066400000000000000000000004031355337103200231350ustar00rootroot00000000000000{% if error %} {{error}} {% else %} Status | Name | Description ------- | ----------------------- | ------------ {% for repo in repos %} | {{('**'+repo.entry_name+'**').ljust(22)}} | {{repo.documentation}} {% endfor %} {% endif %} errbot-6.1.1+ds/errbot/core_plugins/templates/status.md000066400000000000000000000001641355337103200232520ustar00rootroot00000000000000## Yes I am alive... {% include 'status_plugins.md' %} {% include 'status_load.md' %} {% include 'status_gc.md' %} errbot-6.1.1+ds/errbot/core_plugins/templates/status_gc.md000066400000000000000000000000601355337103200237160ustar00rootroot00000000000000GC 0->{{ gc[0] }} 1->{{ gc[1] }} 2->{{ gc[2] }} errbot-6.1.1+ds/errbot/core_plugins/templates/status_load.md000066400000000000000000000000641355337103200242500ustar00rootroot00000000000000Load {{ loads[0] }}, {{ loads[1] }}, {{ loads[2] }} errbot-6.1.1+ds/errbot/core_plugins/templates/status_plugins.md000066400000000000000000000014151355337103200250130ustar00rootroot00000000000000{% macro status(name) -%} {% if name == 'A' -%} **A**{:color='green'} {%- elif name == 'D' -%} **D** {%- elif name == 'C' -%} **C**{:color='yellow'} {%- elif name == 'B' -%} **B**{:color='red'} {%- elif name == 'BA' -%} **B**{:color='red'},**A**{:color='green'} {%- elif name == 'BD' -%} **B**{:color='red'},**D** {%- endif %} {%- endmacro %} ### Plugins Status | Name ------- | ----------------------- {% for state, name in plugins_statuses %}{{ status(state).strip().ljust(7) }} | {{ name }} {% endfor %} {{ status('A').strip() }} = Activated, {{ status('D').strip() }} = Deactivated, {{ status('B').strip() }} = Blacklisted, {{ status('C').strip() }} = Needs to be configured errbot-6.1.1+ds/errbot/core_plugins/templates/webstatus.md000066400000000000000000000002101355337103200237400ustar00rootroot00000000000000Internal webserver URI mapping [URI Regexp -> endpoint]: {% for uri, endpoint in rules %} - {{ uri|e }} -> {{ endpoint }} {% endfor %} errbot-6.1.1+ds/errbot/core_plugins/test.md000066400000000000000000000024631355337103200207140ustar00rootroot00000000000000Normal Markdown: image: ![Err is talking](http://errbot.io/_static/errbot.png) link: This is [a link](http://www.errbot.io). **bold** _italic_ This is a list: - element one - element two - element three This is a ruler: - - - # This is an H1 Paragraph in h1 ## This is an H2 Paragraph in h2 ### This is an H3 Paragraph in h3 #### This is an H4 Paragraph in h4 ##### This is an H5 Paragraph in h5 ###### This is an H6 Paragraph in h6 Markdown extra (colors): Red paragraph {:color='red'} Inline `blue text`{:color='blue'} Inline `green text`{:color='green'} Inline *emphasis blue text*{:color='blue'} Inline `yellow on cyan`{:color='yellow' bgcolor='cyan'} Markdown extra (table): First Header | Second Header -------------- | ------------- Content Cell | **bold** Normal Content | Normal content too Normal Content | Normal content too Normal Content | Normal content too Content Cell | _italic_ Markdown extra (table multiline): First Header | Second Header ------------- | ------------- Content Cell | l1
l2 l3
l5 | l4 Special characters: Copyright: © Natural amp: & Less : < Markdown extra (code) with brackets: ```json { "employees":[ {"firstName":"John", "lastName":"Doe"}, {"firstName":"Anna", "lastName":"Smith"}, {"firstName":"Peter","lastName":"Jones"} ] } ``` errbot-6.1.1+ds/errbot/core_plugins/textcmds.plug000066400000000000000000000002711355337103200221320ustar00rootroot00000000000000[Core] Name = TextCmds Module = textcmds Core = True [Documentation] Description = Commands only available in text mode to manage the chatting context (inperson, inroom, asadmin ...). errbot-6.1.1+ds/errbot/core_plugins/textcmds.py000066400000000000000000000053501355337103200216160ustar00rootroot00000000000000from errbot import BotPlugin, botcmd INROOM, USER, MULTILINE = 'inroom', 'user', 'multiline' class TextModeCmds(BotPlugin): """ Internal to TextBackend. """ __errdoc__ = "Added commands for testing purposes" def activate(self): # This won't activate the plugin in anything else than text mode. if self.mode != 'text': return super().activate() # Some defaults if it was never used before'. if INROOM not in self: self[INROOM] = False if USER not in self: self[USER] = self.build_identifier(self.bot_config.BOT_ADMINS[0]) if MULTILINE not in self: self[MULTILINE] = False # Restore the values to their live state. self._bot._inroom = self[INROOM] self._bot.user = self[USER] self._bot._multiline = self[MULTILINE] def deactivate(self): # Save the live state. self[INROOM] = self._bot._inroom self[USER] = self._bot.user self[MULTILINE] = self._bot._multiline super().deactivate() @botcmd def inroom(self, msg, args): """ This puts you in a room with the bot. """ self._bot._inroom = True if args: room = args else: room = '#testroom' self._bot.query_room(room).join() return f'Joined Room {room}.' @botcmd def inperson(self, msg, _): """ This puts you in a 1-1 chat with the bot. """ self._bot._inroom = False return 'Now in one-on-one with the bot.' @botcmd def asuser(self, msg, args): """ This puts you in a room with the bot. You can specify a name otherwise it will default to 'luser'. """ if args: usr = args if usr[0] != '@': usr = '@' + usr self._bot.user = self.build_identifier(usr) else: self._bot.user = self.build_identifier('@luser') return f'You are now: {self._bot.user}.' @botcmd def asadmin(self, msg, _): """ This puts you in a 1-1 chat with the bot. """ self._bot.user = self.build_identifier(self.bot_config.BOT_ADMINS[0]) return f'You are now an admin: {self._bot.user}.' @botcmd def ml(self, msg, _): """ Switch back and forth between normal mode and multiline mode. Use this if you want to test commands spanning multiple lines. Note: in multiline, press enter twice to end and send the message. """ self._bot._multiline = not self._bot._multiline return 'Multiline mode, press enter twice to end messages' if self._bot._multiline else 'Normal one line mode.' errbot-6.1.1+ds/errbot/core_plugins/utils.plug000066400000000000000000000002011355337103200214300ustar00rootroot00000000000000[Core] Name = Utils Module = utils Core = True [Documentation] Description = Core Errbot utils commands. [Python] Version = 2+ errbot-6.1.1+ds/errbot/core_plugins/utils.py000066400000000000000000000044541355337103200211270ustar00rootroot00000000000000from os import path from errbot import BotPlugin, botcmd def tail(f, window=20): return ''.join(f.readlines()[-window:]) class Utils(BotPlugin): # noinspection PyUnusedLocal @botcmd def echo(self, _, args): """ A simple echo command. Useful for encoding tests etc ... """ return args @botcmd def whoami(self, msg, args): """ A simple command echoing the details of your identifier. Useful to debug identity problems. """ if args: frm = self.build_identifier(str(args).strip('"')) else: frm = msg.frm resp = "| key | value\n" resp += "| -------- | --------\n" resp += f"| person | `{frm.person}`\n" resp += f"| nick | `{frm.nick}`\n" resp += f"| fullname | `{frm.fullname}`\n" resp += f"| client | `{frm.client}`\n\n" # extra info if it is a MUC if hasattr(frm, 'room'): resp += f"\n`room` is {frm.room}\n" resp += f"\n\n- string representation is '{frm}'\n" resp += f"- class is '{frm.__class__.__name__}'\n" return resp # noinspection PyUnusedLocal @botcmd(historize=False) def history(self, msg, args): """display the command history""" answer = [] user_cmd_history = self._bot.cmd_history[msg.frm.person] length = len(user_cmd_history) for i in range(0, length): c = user_cmd_history[i] answer.append(f'{length - i:2d}:{self._bot.prefix}{c[0]} {c[1]}') return '\n'.join(answer) # noinspection PyUnusedLocal @botcmd(admin_only=True) def log_tail(self, msg, args): """ Display a tail of the log of n lines or 40 by default use : !log tail 10 """ n = 40 if args.isdigit(): n = int(args) if self.bot_config.BOT_LOG_FILE: with open(self.bot_config.BOT_LOG_FILE) as f: return '```\n' + tail(f, n) + '\n```' return 'No log is configured, please define BOT_LOG_FILE in config.py' @botcmd def render_test(self, _, args): """ Tests / showcases the markdown rendering on your current backend """ with open(path.join(path.dirname(path.realpath(__file__)), 'test.md')) as f: return f.read() errbot-6.1.1+ds/errbot/core_plugins/vcheck.plug000066400000000000000000000002621355337103200215420ustar00rootroot00000000000000[Core] Name = VersionChecker Module = vcheck Core = True [Documentation] Description = This is calling home to check if Errbot has a new version available [Python] Version = 2+errbot-6.1.1+ds/errbot/core_plugins/vcheck.py000066400000000000000000000042251355337103200212260ustar00rootroot00000000000000from urllib.error import HTTPError, URLError import threading import sys import requests from errbot import BotPlugin from errbot.utils import version2tuple from errbot.version import VERSION HOME = 'http://version.errbot.io/' installed_version = version2tuple(VERSION) PY_VERSION = '.'.join(str(e) for e in sys.version_info[:3]) class VersionChecker(BotPlugin): connected = False activated = False def activate(self): if self.mode not in ('null', 'test', 'Dummy', 'text'): # skip in all test confs. self.activated = True self.version_check() # once at startup anyway self.start_poller(3600 * 24, self.version_check) # once every 24H super().activate() else: self.log.info('Skip version checking under %s mode.', self.mode) def deactivate(self): self.activated = False super().deactivate() def _async_vcheck(self): # noinspection PyBroadException try: current_version_txt = requests.get(HOME, params={'errbot': VERSION, 'python': PY_VERSION}).text.strip() self.log.debug("Tested current Errbot version and it is " + current_version_txt) current_version = version2tuple(current_version_txt) if installed_version < current_version: self.log.debug('A new version %s has been found, notify the admins!', current_version) self.warn_admins(f'Version {current_version_txt} of Errbot is available. ' f'http://pypi.python.org/pypi/errbot/{current_version_txt}. ' f'To disable this check do: {self._bot.prefix}plugin blacklist VersionChecker') except (HTTPError, URLError): self.log.info('Could not establish connection to retrieve latest version.') def version_check(self): if not self.activated: self.log.debug('Version check disabled') return self.log.debug('Checking version in background.') threading.Thread(target=self._async_vcheck).start() def callback_connect(self): if not self.connected: self.connected = True errbot-6.1.1+ds/errbot/core_plugins/webserver.plug000066400000000000000000000002601355337103200223010ustar00rootroot00000000000000[Core] Name = Webserver Module = webserver Core = True [Documentation] Description = This is a plugin for enabling webhooks and web interface to Errbot. [Python] Version = 2+errbot-6.1.1+ds/errbot/core_plugins/webserver.py000066400000000000000000000154621355337103200217740ustar00rootroot00000000000000import sys import os from json import loads from random import randrange from threading import Thread from webtest import TestApp from errbot.core_plugins import flask_app from werkzeug.serving import ThreadedWSGIServer from errbot import botcmd, BotPlugin, webhook from urllib.request import unquote from OpenSSL import crypto TEST_REPORT = """*** Test Report URL : %s Detected your post as : %s Status code : %i """ def make_ssl_certificate(key_path, cert_path): """ Generate a self-signed certificate The generated key will be written out to key_path, with the corresponding certificate itself being written to cert_path. :param cert_path: path where to write the certificate. :param key_path: path where to write the key. """ cert = crypto.X509() cert.set_serial_number(randrange(1, sys.maxsize)) cert.gmtime_adj_notBefore(0) cert.gmtime_adj_notAfter(60 * 60 * 24 * 365) subject = cert.get_subject() subject.CN = '*' setattr(subject, 'O', 'Self-Signed Certificate for Errbot') # Pep8 annoyance workaround issuer = cert.get_issuer() issuer.CN = 'Self-proclaimed Authority' setattr(issuer, 'O', 'Self-Signed') # Pep8 annoyance workaround pkey = crypto.PKey() pkey.generate_key(crypto.TYPE_RSA, 4096) cert.set_pubkey(pkey) cert.sign(pkey, 'sha256') f = open(cert_path, 'w') f.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode('utf-8')) f.close() f = open(key_path, 'w') f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey).decode('utf-8')) f.close() class Webserver(BotPlugin): def __init__(self, *args, **kwargs): self.server = None self.server_thread = None self.ssl_context = None self.test_app = TestApp(flask_app) super().__init__(*args, **kwargs) def get_configuration_template(self): return {'HOST': '0.0.0.0', 'PORT': 3141, 'SSL': {'enabled': False, 'host': '0.0.0.0', 'port': 3142, 'certificate': '', 'key': ''}} def check_configuration(self, configuration): # it is a pain, just assume a default config if SSL is absent or set to None if configuration.get('SSL', None) is None: configuration['SSL'] = {'enabled': False, 'host': '0.0.0.0', 'port': 3142, 'certificate': '', 'key': ''} super().check_configuration(configuration) def activate(self): if not self.config: self.log.info('Webserver is not configured. Forbid activation') return if self.server_thread and self.server_thread.is_alive(): raise Exception('Invalid state, you should not have a webserver already running.') self.server_thread = Thread(target=self.run_server, name='Webserver Thread') self.server_thread.start() self.log.debug('Webserver started.') super().activate() def deactivate(self): if self.server is not None: self.log.info('Shutting down the internal webserver.') self.server.shutdown() self.log.info('Waiting for the webserver thread to quit.') self.server_thread.join() self.log.info('Webserver shut down correctly.') super().deactivate() def run_server(self): try: host = self.config['HOST'] port = self.config['PORT'] ssl = self.config['SSL'] self.log.info('Starting the webserver on %s:%i', host, port) ssl_context = (ssl['certificate'], ssl['key']) if ssl['enabled'] else None self.server = ThreadedWSGIServer(host, ssl['port'] if ssl_context else port, flask_app, ssl_context=ssl_context) self.server.serve_forever() self.log.debug('Webserver stopped') except KeyboardInterrupt: self.log.info('Keyboard interrupt, request a global shutdown.') self.server.shutdown() except Exception: self.log.exception('The webserver exploded.') @botcmd(template='webstatus') def webstatus(self, msg, args): """ Gives a quick status of what is mapped in the internal webserver """ return {'rules': (((rule.rule, rule.endpoint) for rule in flask_app.url_map._rules))} @webhook def echo(self, incoming_request): """ A simple test webhook """ self.log.debug("Your incoming request is :" + str(incoming_request)) return str(incoming_request) @botcmd(split_args_with=' ') def webhook_test(self, _, args): """ Test your webhooks from within err. The syntax is : !webhook test [relative_url] [post content] It triggers the notification and generate also a little test report. """ url = args[0] content = ' '.join(args[1:]) # try to guess the content-type of what has been passed try: # try if it is plain json loads(content) contenttype = 'application/json' except ValueError: # try if it is a form splitted = content.split('=') # noinspection PyBroadException try: payload = '='.join(splitted[1:]) loads(unquote(payload)) contenttype = 'application/x-www-form-urlencoded' except Exception as _: contenttype = 'text/plain' # dunno what it is self.log.debug('Detected your post as : %s.', contenttype) response = self.test_app.post(url, params=content, content_type=contenttype) return TEST_REPORT % (url, contenttype, response.status_code) @botcmd(admin_only=True) def generate_certificate(self, _, args): """ Generate a self-signed SSL certificate for the Webserver """ yield ('Generating a new private key and certificate. This could take a ' 'while if your system is slow or low on entropy') key_path = os.sep.join((self.bot_config.BOT_DATA_DIR, "webserver_key.pem")) cert_path = os.sep.join((self.bot_config.BOT_DATA_DIR, "webserver_certificate.pem")) make_ssl_certificate(key_path=key_path, cert_path=cert_path) yield f'Certificate successfully generated and saved in {self.bot_config.BOT_DATA_DIR}.' suggested_config = self.config suggested_config['SSL']['enabled'] = True suggested_config['SSL']['host'] = suggested_config['HOST'] suggested_config['SSL']['port'] = suggested_config['PORT'] + 1 suggested_config['SSL']['key'] = key_path suggested_config['SSL']['certificate'] = cert_path yield 'To enable SSL with this certificate, the following config is recommended:' yield f'{suggested_config!r}' errbot-6.1.1+ds/errbot/core_plugins/wsview.py000066400000000000000000000061741355337103200213140ustar00rootroot00000000000000from inspect import getmembers, ismethod from json import loads import logging from flask.app import Flask from flask.views import View from flask import request import errbot.core_plugins log = logging.getLogger(__name__) def strip_path(): # strip the trailing slashes on incoming requests request.environ['PATH_INFO'] = request.environ['PATH_INFO'].rstrip('/') def try_decode_json(req): data = req.data.decode() try: return loads(data) except Exception: return None def reset_app(): """Zap everything here, useful for unit tests """ errbot.core_plugins.flask_app = Flask(__name__) def route(obj): """Check for functions to route in obj and route them.""" flask_app = errbot.core_plugins.flask_app classname = obj.__class__.__name__ log.info("Checking %s for webhooks", classname) for name, func in getmembers(obj, ismethod): if getattr(func, '_err_webhook_uri_rule', False): log.info("Webhook routing %s", func.__name__) form_param = func._err_webhook_form_param uri_rule = func._err_webhook_uri_rule verbs = func._err_webhook_methods raw = func._err_webhook_raw callable_view = WebView.as_view(func.__name__ + '_' + '_'.join(verbs), func, form_param, raw) # Change existing rule. for rule in flask_app.url_map._rules: if rule.rule == uri_rule: flask_app.view_functions[rule.endpoint] = callable_view return # Add a new rule flask_app.add_url_rule(uri_rule, view_func=callable_view, methods=verbs, strict_slashes=False) class WebView(View): def __init__(self, func, form_param, raw): if form_param is not None and raw: raise Exception("Incompatible parameters: form_param cannot be set if raw is True") self.func = func self.raw = raw self.form_param = form_param self.method_filter = lambda obj: ismethod(obj) and self.func.__name__ == obj.__name__ def dispatch_request(self, *args, **kwargs): if self.raw: # override and gives the request directly response = self.func(request, **kwargs) elif self.form_param: content = request.form.get(self.form_param) if content is None: raise Exception('Received a request on a webhook with a form_param defined, ' 'but that key (%s) is missing from the request.', self.form_param) try: content = loads(content) except ValueError: log.debug('The form parameter is not JSON, return it as a string.') response = self.func(content, **kwargs) else: data = try_decode_json(request) if not data: if hasattr(request, 'forms'): data = dict(request.forms) # form encoded else: data = request.data.decode() response = self.func(data, **kwargs) return response if response else '' # assume None as an OK response (simplifies the client side) errbot-6.1.1+ds/errbot/flow.py000066400000000000000000000415151355337103200162440ustar00rootroot00000000000000import logging from threading import RLock from typing import Mapping, List, Tuple, Union, Callable, Any, Optional from multiprocessing.pool import ThreadPool from errbot import Message from errbot.backends.base import Identifier, Room, RoomOccupant log = logging.getLogger(__name__) Predicate = Callable[[Mapping[str, Any]], bool] EXECUTOR_THREADS = 5 # the maximum number of simultaneous flows in automatic mode at the same time. class FlowNode(object): """ This is a step in a Flow/conversation. It is linked to a specific botcmd and also a "predicate". The predicate is a function that tells the flow executor if the flow can enter the step without the user intervention (automatically). The predicates defaults to False. The predicate is a function that takes one parameter, the context of the conversation. """ def __init__(self, command: str = None, hints: bool = True): """ Creates a FlowNone, takes the command to which the Node is linked to. :param command: the command this Node is linked to. Can only be None if this Node is a Root. :param hints: hints the users for the next steps in chat. """ self.command = command self.children = [] # (predicate, node) self.hints = hints def connect(self, node_or_command: Union['FlowNode', str], predicate: Predicate = lambda _: False, hints: bool = True): """ Construct the flow graph by connecting this node to another node or a command. The predicate is a function that tells the flow executor if the flow can enter the step without the user intervention (automatically). :param node_or_command: the node or a string for a command you want to connect this Node to (this node or command will be the follow up of this one) :param predicate: function with one parameter, the context, to determine of the flow executor can continue automatically this flow with no user intervention. :param hints: hints the user on the next step possible. :return: the newly created node if you passed a command or the node you gave it to be easily chainable. """ node_to_connect_to = node_or_command if isinstance(node_or_command, FlowNode) else FlowNode(node_or_command, hints=hints) self.children.append((predicate, node_to_connect_to)) return node_to_connect_to def predicate_for_node(self, node: 'FlowNode'): """ gets the predicate function for the specified child node. :param node: the child node :return: the predicate that allows the automatic execution of that node. """ for predicate, possible_node in self.children: if node == possible_node: return predicate return None def __str__(self): return self.command class FlowRoot(FlowNode): """ This represent the entry point of a flow description. """ def __init__(self, name: str, description: str): """ :param name: The name of the conversation/flow. :param description: A human description of what this flow does. :param hints: Hints for the next steps when triggered. """ super().__init__() self.name = name self.description = description self.auto_triggers = set() self.room_flow = False def connect(self, node_or_command: Union['FlowNode', str], predicate: Predicate = lambda _: False, auto_trigger: bool = False, room_flow: bool = False): """ :see: FlowNode except fot auto_trigger :param predicate: :see: FlowNode :param node_or_command: :see: FlowNode :param auto_trigger: Flag this root as autotriggering: it will start a flow if this command is executed in the chat. :param room_flow: Bind the flow to the room instead of a single person """ resp = super().connect(node_or_command, predicate) if auto_trigger: self.auto_triggers.add(node_or_command) self.room_flow = room_flow return resp def __str__(self): return self.name class _FlowEnd(FlowNode): def __str__(self): return 'END' #: Flow marker indicating that the flow ends. FLOW_END = _FlowEnd() class InvalidState(Exception): """ Raised when the Flow Executor is asked to do something contrary to the contraints it has been given. """ pass class Flow(object): """ This is a live Flow. It keeps context of the conversation (requestor and context). Context is just a python dictionary representing the state of the conversation. """ def __init__(self, root: FlowRoot, requestor: Identifier, initial_context: Mapping[str, Any]): """ :param root: the root of this flow. :param requestor: the user requesting this flow. :param initial_context: any data we already have that could help executing this flow automatically. """ self._root = root self._current_step = self._root self.ctx = dict(initial_context) self.requestor = requestor def next_autosteps(self) -> List[FlowNode]: """ Get the next steps that can be automatically executed according to the set predicates. """ return [node for predicate, node in self._current_step.children if predicate(self.ctx)] def next_steps(self) -> List[FlowNode]: """ Get all the possible next steps after this one (predicates statisfied or not). """ return [node for predicate, node in self._current_step.children] def advance(self, next_step: FlowNode, enforce_predicate=True): """ Move on along the flow. :param next_step: Which node you want to move the flow forward to. :param enforce_predicate: Do you want to check if the predicate is verified for this step or not. Usually, if it is a manual step, the predicate is irrelevant because the user will give the missing information as parameters to the command. """ if enforce_predicate: predicate = self._current_step.predicate_for_node(next_step) if predicate is None: raise ValueError(f'There is no such children: {next_step}.') if not predicate(self.ctx): raise InvalidState('It is not possible to advance to this step because its predicate is false.') self._current_step = next_step @property def name(self) -> str: """ Helper property to get the name of the flow. """ return self._root.name @property def current_step(self) -> FlowNode: """ The current step this Flow is waiting on. """ return self._current_step @property def root(self) -> FlowRoot: """ The original flowroot of this flow. """ return self._root def check_identifier(self, identifier: Identifier): is_room = isinstance(self.requestor, Room) is_room = is_room and isinstance(identifier, RoomOccupant) is_room = is_room and self.requestor == identifier.room return is_room or self.requestor == identifier def __str__(self): return f'{self._root} ({self.requestor}) with params {dict(self.ctx)}' class BotFlow: """ Defines a Flow plugin ie. a plugin that will define new flows from its methods with the @botflow decorator. """ def __init__(self, bot, name=None): super().__init__() self._bot = bot self.is_activated = False self._name = name @property def name(self) -> str: """ Get the name of this flow as described in its .plug file. :return: The flow name. """ return self._name def activate(self) -> None: """ Override if you want to do something at initialization phase (don't forget to super(Gnagna, self).activate()) """ self._bot.inject_flows_from(self) self.is_activated = True def deactivate(self) -> None: """ Override if you want to do something at tear down phase (don't forget to super(Gnagna, self).deactivate()) """ self._bot.remove_flows_from(self) self.is_activated = False def get_command(self, command_name: str): """ Helper to get a specific command. """ self._bot.all_commands.get(command_name, None) class FlowExecutor(object): """ This is a instance that can monitor and execute flow instances. """ def __init__(self, bot): self._lock = RLock() self.flow_roots = {} self.in_flight = [] self._pool = ThreadPool(EXECUTOR_THREADS) self._bot = bot def add_flow(self, flow: FlowRoot): """ Register a flow with this executor. """ with self._lock: self.flow_roots[flow.name] = flow def trigger(self, cmd: str, requestor: Identifier, extra_context=None) -> Optional[Flow]: """ Trigger workflows that may have command cmd as a auto_trigger or an in flight flow waiting for command. This assume cmd has been correctly executed. :param requestor: the identifier of the person who started this flow :param cmd: the command that has just been executed. :param extra_context: extra context from the current conversation :returns: The flow it triggered or None if none were matching. """ flow, next_step = self.check_inflight_flow_triggered(cmd, requestor) if not flow: flow, next_step = self._check_if_new_flow_is_triggered(cmd, requestor) if not flow: return None flow.advance(next_step, enforce_predicate=False) if extra_context: flow.ctx = dict(extra_context) self._enqueue_flow(flow) return flow def check_inflight_already_running(self, user: Identifier) -> bool: """ Check if user is already running a flow. :param user: the user """ with self._lock: for flow in self.in_flight: if flow.requestor == user: return True return False def check_inflight_flow_triggered(self, cmd: str, user: Identifier) -> Tuple[Optional[Flow], Optional[FlowNode]]: """ Check if a command from a specific user was expected in one of the running flow. :param cmd: the command that has just been executed. :param user: the identifier of the person who started this flow :returns: The name of the flow it triggered or None if none were matching.""" log.debug("Test if the command %s is a trigger for an inflight flow ...", cmd) # TODO: What if 2 flows wait for the same command ? with self._lock: for flow in self.in_flight: if flow.check_identifier(user): log.debug("Requestor has a flow %s in flight", flow.name) for next_step in flow.next_steps(): if next_step.command == cmd: log.debug("Requestor has a flow in flight waiting for this command !") return flow, next_step log.debug("None matched.") return None, None def _check_if_new_flow_is_triggered(self, cmd: str, user: Identifier) -> Tuple[Optional[Flow], Optional[FlowNode]]: """ Trigger workflows that may have command cmd as a auto_trigger.. This assume cmd has been correctly executed. :param cmd: the command that has just been executed. :param user: the identifier of the person who started this flow :returns: The name of the flow it triggered or None if none were matching. """ log.debug("Test if the command %s is an auto-trigger for any flow ...", cmd) with self._lock: for name, flow_root in self.flow_roots.items(): if cmd in flow_root.auto_triggers and not self.check_inflight_already_running(user): log.debug("Flow %s has been auto-triggered by the command %s by user %s", name, cmd, user) return self._create_new_flow(flow_root, user, cmd) return None, None @staticmethod def _create_new_flow(flow_root, requestor: Identifier, initial_command) \ -> Tuple[Optional[Flow], Optional[FlowNode]]: """ Helper method to create a new FLow. """ empty_context = {} flow = Flow(flow_root, requestor, empty_context) for possible_next_step in flow.next_steps(): if possible_next_step.command == initial_command: # The predicate is good as we just executed manually the command. return flow, possible_next_step return None, None def start_flow(self, name: str, requestor: Identifier, initial_context: Mapping[str, Any]) -> Flow: """ Starts the execution of a Flow. """ if name not in self.flow_roots: raise ValueError(f'Flow {name} doesn\'t exist') if self.check_inflight_already_running(requestor): raise ValueError(f'User {str(requestor)} is already running a flow.') flow_root = self.flow_roots[name] identity = requestor if isinstance(requestor, RoomOccupant) and flow_root.room_flow: identity = requestor.room flow = Flow(self.flow_roots[name], identity, initial_context) self._enqueue_flow(flow) return flow def stop_flow(self, name: str, requestor: Identifier) -> Optional[Flow]: """ Stops a specific flow. It is a no op if the flow doesn't exist. Returns the stopped flow if found. """ with self._lock: for flow in self.in_flight: if flow.name == name and flow.check_identifier(requestor): log.debug(f'Removing flow {str(flow)}.') self.in_flight.remove(flow) return flow return None def _enqueue_flow(self, flow): with self._lock: if flow not in self.in_flight: self.in_flight.append(flow) self._pool.apply_async(self.execute, (flow,)) def execute(self, flow: Flow): """ This is where the flow execution happens from one of the thread of the pool. """ while True: autosteps = flow.next_autosteps() steps = flow.next_steps() if not steps: log.debug("Flow ended correctly.Nothing left to do.") with self._lock: self.in_flight.remove(flow) break if not autosteps and flow.current_step.hints: possible_next_steps = [f'You are in the flow **{flow.name}**, you can continue with:\n\n'] for step in steps: cmd = step.command cmd_fnc = self._bot.all_commands[cmd] reg_cmd = cmd_fnc._err_re_command syntax_args = cmd_fnc._err_command_syntax reg_prefixed = cmd_fnc._err_command_prefix_required if reg_cmd else True syntax = self._bot.prefix if reg_prefixed else '' if not reg_cmd: syntax += cmd.replace('_', ' ') if syntax_args: syntax += syntax_args possible_next_steps.append(f'- {syntax}') self._bot.send(flow.requestor, '\n'.join(possible_next_steps)) break log.debug('Steps triggered automatically %s.', ', '.join(str(node) for node in autosteps)) log.debug('All possible next steps: %s.', ', '.join(str(node) for node in steps)) for autostep in autosteps: log.debug("Proceeding automatically with step %s", autostep) if autostep == FLOW_END: log.debug('This flow ENDED.') with self._lock: self.in_flight.remove(flow) return try: msg = Message(frm=flow.requestor, flow=flow) result = self._bot.commands[autostep.command](msg, None) log.debug('Step result %s: %s', flow.requestor, result) except Exception as e: log.exception('%s errored at %s', flow, autostep) self._bot.send(flow.requestor, f'{flow} errored at {autostep} with "{e}"') flow.advance(autostep) # TODO: this is only true for a single step, make it forkable. log.debug('Flow execution suspended/ended normally.') errbot-6.1.1+ds/errbot/logs.py000066400000000000000000000037321355337103200162400ustar00rootroot00000000000000import inspect import logging import sys COLORS = {'DEBUG': 'cyan', 'INFO': 'green', 'WARNING': 'yellow', 'ERROR': 'red', 'CRITICAL': 'red', } NO_COLORS = {'DEBUG': '', 'INFO': '', 'WARNING': '', 'ERROR': '', 'CRITICAL': '', } def ispydevd(): for frame in inspect.stack(): if frame[1].endswith("pydevd.py"): return True return False root_logger = logging.getLogger() root_logger.setLevel(logging.INFO) pydev = ispydevd() stream = sys.stdout if pydev else sys.stderr isatty = pydev or stream.isatty() # force isatty if we are under pydev because it supports coloring anyway. console_hdlr = logging.StreamHandler(stream) def get_log_colors(theme_color=None): """Return a tuple containing the log format string and a log color dict""" if theme_color == 'light': text_color_theme = 'white' elif theme_color == 'dark': text_color_theme = 'black' else: # Anything else produces nocolor return '%(name)-25.25s%(reset)s %(message)s%(reset)s', NO_COLORS return f'%(name)-25.25s%(reset)s %({text_color_theme})s%(message)s%(reset)s', COLORS def format_logs(formatter=None, theme_color=None): """ You may either use the formatter parameter to provide your own custom formatter, or the theme_color parameter to use the built in color scheme formatter. """ if formatter: console_hdlr.setFormatter(formatter) # if isatty and not True: elif isatty: from colorlog import ColoredFormatter # noqa log_format, colors_dict = get_log_colors(theme_color) color_formatter = ColoredFormatter( "%(asctime)s %(log_color)s%(levelname)-8s%(reset)s " + log_format, datefmt="%H:%M:%S", reset=True, log_colors=colors_dict, ) console_hdlr.setFormatter(color_formatter) else: console_hdlr.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)-25s %(message)s")) root_logger.addHandler(console_hdlr) errbot-6.1.1+ds/errbot/plugin_info.py000066400000000000000000000071161355337103200176050ustar00rootroot00000000000000import sys import inspect from configparser import ConfigParser from dataclasses import dataclass from importlib._bootstrap import module_from_spec from importlib._bootstrap_external import spec_from_file_location from errbot.utils import version2tuple from pathlib import Path from typing import Tuple, List, Type from configparser import Error as ConfigParserError VersionType = Tuple[int, int, int] @dataclass class PluginInfo: name: str module: str doc: str core: bool python_version: VersionType errbot_minversion: VersionType errbot_maxversion: VersionType dependencies: List[str] location: Path = None @staticmethod def load(plugfile_path: Path) -> 'PluginInfo': with plugfile_path.open(encoding='utf-8') as plugfile: return PluginInfo.load_file(plugfile, plugfile_path) @staticmethod def load_file(plugfile, location: Path) -> 'PluginInfo': cp = ConfigParser() cp.read_file(plugfile) pi = PluginInfo.parse(cp) pi.location = location return pi @staticmethod def parse(config: ConfigParser) -> 'PluginInfo': """ Throws ConfigParserError with a meaningful message if the ConfigParser doesn't contain the minimal information required. """ name = config.get('Core', 'Name') module = config.get('Core', 'Module') core = config.get('Core', 'Core', fallback='false').lower() == 'true' doc = config.get('Documentation', 'Description', fallback=None) python_version = config.get('Python', 'Version', fallback=None) # Old format backward compatibility if python_version: if python_version in ('2+', '3'): python_version = (3, 0, 0) elif python_version == '2': python_version = (2, 0, 0) else: try: python_version = tuple(version2tuple(python_version)[0:3]) # We can ignore the alpha/beta part. except ValueError as ve: raise ConfigParserError(f'Invalid Python Version format: {python_version} ({ve})') min_version = config.get('Errbot', 'Min', fallback=None) max_version = config.get('Errbot', 'Max', fallback=None) try: if min_version: min_version = version2tuple(min_version) except ValueError as ve: raise ConfigParserError(f'Invalid Errbot min version format: {min_version} ({ve})') try: if max_version: max_version = version2tuple(max_version) except ValueError as ve: raise ConfigParserError(f'Invalid Errbot max version format: {max_version} ({ve})') depends_on = config.get('Core', 'DependsOn', fallback=None) deps = [name.strip() for name in depends_on.split(',')] if depends_on else [] return PluginInfo(name, module, doc, core, python_version, min_version, max_version, deps) def load_plugin_classes(self, base_module_name: str, baseclass: Type): # load the module module_name = base_module_name + '.' + self.module spec = spec_from_file_location(module_name, self.location.parent / (self.module + '.py')) modu1e = module_from_spec(spec) spec.loader.exec_module(modu1e) sys.modules[module_name] = modu1e # introspect the modules to find plugin classes def is_plugin(member): return inspect.isclass(member) and issubclass(member, baseclass) and member != baseclass plugin_classes = inspect.getmembers(modu1e, is_plugin) return plugin_classes errbot-6.1.1+ds/errbot/plugin_manager.py000066400000000000000000000511431355337103200202630ustar00rootroot00000000000000""" Logic related to plugin loading and lifecycle """ from copy import deepcopy from importlib import machinery import logging import os import subprocess import sys import traceback from pathlib import Path from typing import Tuple, Dict, Any, Type, Set, List, Optional, Callable from errbot.flow import BotFlow, Flow from errbot.repo_manager import check_dependencies from errbot.storage.base import StoragePluginBase from .botplugin import BotPlugin from .plugin_info import PluginInfo from .utils import version2tuple, collect_roots from .templating import remove_plugin_templates_path, add_plugin_templates_path from .version import VERSION from .core_plugins.wsview import route from .storage import StoreMixin PluginInstanceCallback = Callable[[str, Type[BotPlugin]], BotPlugin] log = logging.getLogger(__name__) CORE_PLUGINS = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'core_plugins') class PluginActivationException(Exception): pass class IncompatiblePluginException(PluginActivationException): pass class PluginConfigurationException(PluginActivationException): pass def _ensure_sys_path_contains(paths): """ Ensure that os.path contains paths :param paths: a list of base paths to walk from elements can be a string or a list/tuple of strings """ for entry in paths: if isinstance(entry, (list, tuple)): _ensure_sys_path_contains(entry) elif entry is not None and entry not in sys.path: sys.path.append(entry) def populate_doc(plugin_object: BotPlugin, plugin_info: PluginInfo) -> None: plugin_class = type(plugin_object) plugin_class.__errdoc__ = plugin_class.__doc__ if plugin_class.__doc__ else plugin_info.doc def install_packages(req_path: Path): """ Installs all the packages from the given requirements.txt Return an exc_info if it fails otherwise None. """ def is_docker(): with open('/proc/1/cgroup') as d: return 'docker' in d.read() log.info('Installing packages from "%s".', req_path) # use sys.executable explicitly instead of just 'pip' because depending on how the bot is deployed # 'pip' might not be available on PATH: for example when installing errbot on a virtualenv and # starting it with systemclt pointing directly to the executable: # [Service] # ExecStart=/home/errbot/.env/bin/errbot pip_cmdline = [sys.executable, '-m', 'pip'] # noinspection PyBroadException try: if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and (sys.base_prefix != sys.prefix)): # this is a virtualenv, so we can use it directly subprocess.check_call(pip_cmdline + ['install', '--requirement', str(req_path)]) elif is_docker(): # this is a docker container, so we can use it directly subprocess.check_call(pip_cmdline + ['install', '--requirement', str(req_path)]) else: # otherwise only install it as a user package subprocess.check_call(pip_cmdline + ['install', '--user', '--requirement', str(req_path)]) except Exception: log.exception('Failed to execute pip install for %s.', req_path) return sys.exc_info() def check_python_plug_section(plugin_info: PluginInfo) -> bool: """ Checks if we have the correct version to run this plugin. Returns true if the plugin is loadable """ version = plugin_info.python_version # if the plugin doesn't restric anything, assume it is ok and try to load it. if not version: return True sys_version = sys.version_info[:3] if version < (3, 0, 0): log.error('Plugin %s is made for python 2 only and Errbot is not compatible with Python 2 anymore.', plugin_info.name) log.error('Please contact the plugin developer or try to contribute to port the plugin.') return False if version >= sys_version: log.error('Plugin %s requires python >= %s and this Errbot instance runs %s.', plugin_info.name, '.'.join(str(v) for v in version), '.'.join(str(v) for v in sys_version)) log.error('Upgrade your python interpreter if you want to use this plugin.') return False return True def check_errbot_version(plugin_info: PluginInfo): """ Checks if a plugin version between min_version and max_version is ok for this errbot. Raises IncompatiblePluginException if not. """ name, min_version, max_version = plugin_info.name, plugin_info.errbot_minversion, plugin_info.errbot_maxversion current_version = version2tuple(VERSION) if min_version and min_version > current_version: raise IncompatiblePluginException(f'The plugin {name} asks for Errbot with a minimal version of ' f'{min_version} while Errbot is version {VERSION}.') if max_version and max_version < current_version: raise IncompatiblePluginException(f'The plugin {name} asks for Errbot with a maximum version of {max_version} ' f'while Errbot is version {VERSION}') # Storage names CONFIGS = 'configs' BL_PLUGINS = 'bl_plugins' class BotPluginManager(StoreMixin): def __init__(self, storage_plugin: StoragePluginBase, extra_plugin_dir: Optional[str], autoinstall_deps: bool, core_plugins: Tuple[str, ...], plugin_instance_callback: PluginInstanceCallback, plugins_callback_order: Tuple[Optional[str], ...]): """ Creates a Plugin manager :param storage_plugin: the plugin used to store to config for this manager :param extra_plugin_dir: an extra directory to search for plugins :param autoinstall_deps: if True, will install also the plugin deps from requirements.txt :param core_plugins: the list of core plugin that will be started :param plugin_instance_callback: the callback to instantiate a plugin (to inject the dependency on the bot) :param plugins_callback_order: the order on which the plugins will be callbacked """ super().__init__() self.autoinstall_deps: bool = autoinstall_deps self._extra_plugin_dir: str = extra_plugin_dir self._plugin_instance_callback: PluginInstanceCallback = plugin_instance_callback self.core_plugins: Tuple[str, ...] = core_plugins # Make sure there is a 'None' entry in the callback order, to include # any plugin not explicitly ordered. self.plugins_callback_order = plugins_callback_order if None not in self.plugins_callback_order: self.plugins_callback_order += (None,) self.plugin_infos: Dict[str, PluginInfo] = {} self.plugins: Dict[str, BotPlugin] = {} self.flow_infos: Dict[str, PluginInfo] = {} self.flows: Dict[str, Flow] = {} self.plugin_places = [] self.open_storage(storage_plugin, 'core') if CONFIGS not in self: self[CONFIGS] = {} def get_plugin_obj_by_name(self, name: str) -> BotPlugin: return self.plugins.get(name, None) def reload_plugin_by_name(self, name): """ Completely reload the given plugin, including reloading of the module's code :throws PluginActivationException: needs to be taken care of by the callers. """ plugin = self.plugins[name] if plugin.is_activated: self.deactivate_plugin(name) module_alias = plugin.__module__ module_old = __import__(module_alias) f = module_old.__file__ module_new = machinery.SourceFileLoader(module_alias, f).load_module(module_alias) class_name = type(plugin).__name__ new_class = getattr(module_new, class_name) plugin.__class__ = new_class self.activate_plugin(name) def _install_potential_package_dependencies(self, path: Path, feedback: Dict[Path, str]): req_path = path / 'requirements.txt' if req_path.exists(): log.info('Checking package dependencies from %s.', req_path) if self.autoinstall_deps: exc_info = install_packages(req_path) if exc_info is not None: typ, value, trace = exc_info feedback[path] = f'{typ}: {value}\n{"".join(traceback.format_tb(trace))}' else: msg, _ = check_dependencies(req_path) if msg and path not in feedback: # favor the first error. feedback[path] = msg def _load_plugins_generic(self, path: Path, extension: str, base_module_name, baseclass: Type, dest_dict: Dict[str, Any], dest_info_dict: Dict[str, Any], feedback: Dict[Path, str]): self._install_potential_package_dependencies(path, feedback) plugfiles = path.glob('**/*.' + extension) for plugfile in plugfiles: try: plugin_info = PluginInfo.load(plugfile) name = plugin_info.name if name in dest_info_dict: log.warning('Plugin %s already loaded.', name) continue # save the plugin_info for ref. dest_info_dict[name] = plugin_info # Skip the core plugins not listed in CORE_PLUGINS if CORE_PLUGINS is defined. if self.core_plugins and plugin_info.core and (plugin_info.name not in self.core_plugins): log.debug("%s plugin will not be loaded because it's not listed in CORE_PLUGINS", name) continue plugin_classes = plugin_info.load_plugin_classes(base_module_name, baseclass) if not plugin_classes: feedback[path] = f'Did not find any plugin in {path}.' continue if len(plugin_classes) > 1: # TODO: This is something we can support as "subplugins" or something similar. feedback[path] = 'Contains more than one plugin, only one will be loaded.' # instantiate the plugin object. _, clazz = plugin_classes[0] dest_dict[name] = self._plugin_instance_callback(name, clazz) except Exception: feedback[path] = traceback.format_exc() def _load_plugins(self) -> Dict[Path, str]: feedback = {} for path in self.plugin_places: self._load_plugins_generic(path, 'plug', 'errbot.plugins', BotPlugin, self.plugins, self.plugin_infos, feedback) self._load_plugins_generic(path, 'flow', 'errbot.flows', BotFlow, self.flows, self.flow_infos, feedback) return feedback def update_plugin_places(self, path_list) -> Dict[Path, str]: """ This updates where this manager is trying to find plugins and try to load newly found ones. :param path_list: the path list where to search for plugins. :return: the feedback for any specific path in case of error. """ repo_roots = (CORE_PLUGINS, self._extra_plugin_dir, path_list) all_roots = collect_roots(repo_roots) log.debug('New entries added to sys.path:') for entry in all_roots: if entry not in sys.path: log.debug(entry) sys.path.append(entry) # so plugins can relatively import their repos _ensure_sys_path_contains(repo_roots) self.plugin_places = [Path(root) for root in all_roots] return self._load_plugins() def get_all_active_plugins(self) -> List[BotPlugin]: """This returns the list of plugins in the callback ordered defined from the config.""" all_plugins = [] for name in self.plugins_callback_order: # None is a placeholder for any plugin not having a defined order if name is None: all_plugins += [ plugin for name, plugin in self.plugins.items() if name not in self.plugins_callback_order and plugin.is_activated ] else: plugin = self.plugins[name] if plugin.is_activated: all_plugins.append(plugin) return all_plugins def get_all_active_plugin_names(self): return [name for name, plugin in self.plugins.items() if plugin.is_activated] def get_all_plugin_names(self): return self.plugins.keys() def deactivate_all_plugins(self): for name in self.get_all_active_plugin_names(): self.deactivate_plugin(name) # plugin blacklisting management def get_blacklisted_plugin(self): return self.get(BL_PLUGINS, []) def is_plugin_blacklisted(self, name): return name in self.get_blacklisted_plugin() def blacklist_plugin(self, name): if self.is_plugin_blacklisted(name): logging.warning('Plugin %s is already blacklisted.', name) return f'Plugin {name} is already blacklisted.' self[BL_PLUGINS] = self.get_blacklisted_plugin() + [name] log.info('Plugin %s is now blacklisted.', name) return f'Plugin {name} is now blacklisted.' def unblacklist_plugin(self, name): if not self.is_plugin_blacklisted(name): logging.warning('Plugin %s is not blacklisted.', name) return f'Plugin {name} is not blacklisted.' plugin = self.get_blacklisted_plugin() plugin.remove(name) self[BL_PLUGINS] = plugin log.info('Plugin %s removed from blacklist.', name) return f'Plugin {name} removed from blacklist.' # configurations management def get_plugin_configuration(self, name): configs = self[CONFIGS] if name not in configs: return None return configs[name] def set_plugin_configuration(self, name, obj): # TODO: port to with statement configs = self[CONFIGS] configs[name] = obj self[CONFIGS] = configs def activate_non_started_plugins(self): """ Activates all plugins that are not activated, respecting its dependencies. :return: Empty string if no problem occured or a string explaining what went wrong. """ log.info('Activate bot plugins...') errors = '' for name, plugin in self.plugins.items(): try: if self.is_plugin_blacklisted(name): errors += f'Notice: {plugin.name} is blacklisted, ' \ f'use {self.bot.prefix}plugin unblacklist {name} to unblacklist it.\n' continue if not plugin.is_activated: log.info('Activate plugin: %s.', name) self.activate_plugin(name) except Exception as e: log.exception('Error loading %s.', name) errors += f'Error: {name} failed to activate: {e}.\n' log.debug('Activate flow plugins ...') for name, flow in self.flows.items(): try: if not flow.is_activated: log.info('Activate flow: %s', name) self.activate_flow(name) except Exception as e: log.exception(f'Error loading flow {name}.') errors += f'Error: flow {name} failed to start: {e}.\n' return errors def _activate_plugin(self, plugin: BotPlugin, plugin_info: PluginInfo): """ Activate a specific plugin with no check. """ if plugin.is_activated: raise Exception('Internal Error, invalid activated state.') name = plugin.name try: config = self.get_plugin_configuration(name) if plugin.get_configuration_template() is not None and config is not None: log.debug('Checking configuration for %s...', name) plugin.check_configuration(config) log.debug('Configuration for %s checked OK.', name) plugin.configure(config) # even if it is None we pass it on except Exception as ex: log.exception('Something is wrong with the configuration of the plugin %s', name) plugin.config = None raise PluginConfigurationException(str(ex)) try: add_plugin_templates_path(plugin_info) populate_doc(plugin, plugin_info) plugin.activate() route(plugin) plugin.callback_connect() except Exception: log.error('Plugin %s failed at activation stage, deactivating it...', name) self.deactivate_plugin(name) raise def activate_flow(self, name: str): if name not in self.flows: raise PluginActivationException(f'Could not find the flow named {name}.') flow = self.flows[name] if flow.is_activated: raise PluginActivationException(f'Flow {name} is already active.') flow.activate() def deactivate_flow(self, name: str): flow = self.flows[name] if not flow.is_activated: raise PluginActivationException(f'Flow {name} is already inactive.') flow.deactivate() def activate_plugin(self, name: str): """ Activate a plugin with its dependencies. """ try: if name not in self.plugins: raise PluginActivationException(f'Could not find the plugin named {name}.') plugin = self.plugins[name] if plugin.is_activated: raise PluginActivationException(f'Plugin {name} already activate.') plugin_info = self.plugin_infos[name] if not check_python_plug_section(plugin_info): return None check_errbot_version(plugin_info) dep_track = set() depends_on = self._activate_plugin_dependencies(name, dep_track) plugin.dependencies = depends_on self._activate_plugin(plugin, plugin_info) except PluginActivationException: raise except Exception as e: log.exception(f'Error loading {name}.') raise PluginActivationException(f'{name} failed to start : {e}.') def _activate_plugin_dependencies(self, name: str, dep_track: Set[str]) -> List[str]: plugin_info = self.plugin_infos[name] dep_track.add(name) depends_on = plugin_info.dependencies for dep_name in depends_on: if dep_name in dep_track: raise PluginActivationException(f'Circular dependency in the set of plugins ({", ".join(dep_track)})') if dep_name not in self.plugins: raise PluginActivationException(f'Unknown plugin dependency {dep_name}.') dep_plugin = self.plugins[dep_name] dep_plugin_info = self.plugin_infos[dep_name] if not dep_plugin.is_activated: log.debug('%s depends on %s and %s is not activated. Activating it ...', name, dep_name, dep_name) self._activate_plugin_dependencies(dep_name, dep_track) self._activate_plugin(dep_plugin, dep_plugin_info) return depends_on def deactivate_plugin(self, name: str): plugin = self.plugins[name] if not plugin.is_activated: log.warning('Plugin already deactivated, ignore.') return plugin_info = self.plugin_infos[name] plugin.deactivate() remove_plugin_templates_path(plugin_info) def remove_plugin(self, plugin: BotPlugin): """ Deactivate and remove a plugin completely. :param plugin: the plugin to remove :return: """ # First deactivate it if it was activated if plugin.is_activated: self.deactivate_plugin(plugin.name) del(self.plugins[plugin.name]) del(self.plugin_infos[plugin.name]) def remove_plugins_from_path(self, root): """ Remove all the plugins that are in the filetree pointed by root. """ old_plugin_infos = deepcopy(self.plugin_infos) for name, pi in old_plugin_infos.items(): if str(pi.location).startswith(root): self.remove_plugin(self.plugins[name]) def shutdown(self): log.info('Shutdown.') self.close_storage() log.info('Bye.') def __hash__(self): # Ensures this class (and subclasses) are hashable. # Presumably the use of mixins causes __hash__ to be # None otherwise. return int(id(self)) errbot-6.1.1+ds/errbot/plugin_wizard.py000066400000000000000000000113051355337103200201450ustar00rootroot00000000000000#!/usr/bin/env python import errno import jinja2 import os import re import sys from configparser import ConfigParser from errbot.version import VERSION def new_plugin_wizard(directory=None): """ Start the wizard to create a new plugin in the current working directory. """ if directory is None: print('This wizard will create a new plugin for you in the current directory.') directory = os.getcwd() else: print(f'This wizard will create a new plugin for you in "{directory}".') if os.path.exists(directory) and not os.path.isdir(directory): print(f'Error: The path "{directory}" exists but it isn\'t a directory') sys.exit(1) name = ask("What should the name of your new plugin be?", validation_regex=r'^[a-zA-Z][a-zA-Z0-9 _-]*$').strip() module_name = name.lower().replace(' ', '_') directory_name = name.lower().replace(' ', '-') class_name = ''.join([s.capitalize() for s in name.lower().split(' ')]) description = ask('What may I use as a short (one-line) description of your plugin?') python_version = '3' errbot_min_version = ask(f'Which minimum version of errbot will your plugin work with? ' f'Leave blank to support any version or input CURRENT to select ' f'the current version {VERSION}.').strip() if errbot_min_version.upper() == 'CURRENT': errbot_min_version = VERSION errbot_max_version = ask(f'Which maximum version of errbot will your plugin work with? ' f'Leave blank to support any version or input CURRENT to select ' f'the current version {VERSION}.').strip() if errbot_max_version.upper() == "CURRENT": errbot_max_version = VERSION plug = ConfigParser() plug['Core'] = {'Name': name, 'Module': module_name, } plug['Documentation'] = { 'Description': description, } plug['Python'] = { 'Version': python_version, } if errbot_max_version != '' or errbot_min_version != '': plug['Errbot'] = {} if errbot_min_version != '': plug['Errbot']['Min'] = errbot_min_version if errbot_max_version != '': plug['Errbot']['Max'] = errbot_max_version plugin_path = directory plugfile_path = os.path.join(plugin_path, module_name + '.plug') pyfile_path = os.path.join(plugin_path, module_name + '.py') try: os.makedirs(plugin_path, mode=0o700) except IOError as e: if e.errno != errno.EEXIST: raise if os.path.exists(plugfile_path) or os.path.exists(pyfile_path): path = os.path.join(directory, f'{module_name}.{{py,plug}}') ask(f'Warning: A plugin with this name was already found at {path}\n' f'If you continue, these will be overwritten.\n' f'Press Ctrl+C to abort now or type in "overwrite" to confirm overwriting of these files.', valid_responses=['overwrite'], ) with open(plugfile_path, 'w') as f: plug.write(f) with open(pyfile_path, 'w') as f: f.write(render_plugin(locals())) print(f'Success! You\'ll find your new plugin at \'{plugfile_path}\'') print('(Don\'t forget to include a LICENSE file if you are going to publish your plugin).') def ask(question, valid_responses=None, validation_regex=None): """ Ask the user for some input. If valid_responses is supplied, the user must respond with something present in this list. """ response = None print(question) while True: response = input('> ') if valid_responses is not None: assert isinstance(valid_responses, list) if response in valid_responses: break else: print(f'Bad input: Please answer one of: {", ".join(valid_responses)}') elif validation_regex is not None: m = re.search(validation_regex, response) if m is None: print(f'Bad input: Please respond with something matching this regex: {validation_regex}') else: break else: break return response def render_plugin(values): """ Render the Jinja template for the plugin with the given values. """ env = jinja2.Environment( loader=jinja2.FileSystemLoader(os.path.join(os.path.dirname(__file__), 'templates')), auto_reload=False, keep_trailing_newline=True, autoescape=True ) template = env.get_template('new_plugin.py.tmpl') return template.render(**values) if __name__ == '__main__': try: new_plugin_wizard() except KeyboardInterrupt: sys.exit(1) errbot-6.1.1+ds/errbot/rendering/000077500000000000000000000000001355337103200166725ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/rendering/__init__.py000066400000000000000000000055031355337103200210060ustar00rootroot00000000000000# vim: noai:ts=4:sw=4 import re from markdown import Markdown from markdown.extensions.extra import ExtraExtension # Attribute regexp looks for extendend syntax: {: ... } ATTR_RE = re.compile(r'{:([^}]*)}') MD_ESCAPE_RE = re.compile('|'.join(re.escape(c) for c in ('\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!'))) # Here are few helpers to simplify the conversion from markdown to various # backend formats. def ansi(): """This makes a converter from markdown to ansi (console) format. It can be called like this: from errbot.rendering import ansi md_converter = ansi() # you need to cache the converter ansi_txt = md_converter.convert(md_txt) """ from .ansiext import AnsiExtension md = Markdown(output_format='ansi', extensions=[ExtraExtension(), AnsiExtension()]) md.stripTopLevelTags = False return md def text(): """This makes a converter from markdown to text (unicode) format. It can be called like this: from errbot.rendering import text md_converter = text() # you need to cache the converter pure_text = md_converter.convert(md_txt) """ from .ansiext import AnsiExtension md = Markdown(output_format='text', extensions=[ExtraExtension(), AnsiExtension()]) md.stripTopLevelTags = False return md def imtext(): """This makes a converter from markdown to imtext (unicode) format. imtest is the format like gtalk, slack or skype with simple _ or * markup. It can be called like this: from errbot.rendering import imtext md_converter = imtext() # you need to cache the converter im_text = md_converter.convert(md_txt) """ from .ansiext import AnsiExtension md = Markdown(output_format='imtext', extensions=[ExtraExtension(), AnsiExtension()]) md.stripTopLevelTags = False return md class Mde2mdConverter(object): def convert(self, mde): while True: m = ATTR_RE.search(mde) if m is None: break left, right = m.span() mde = mde[:left] + mde[right:] return mde def md(): """This makes a converter from markdown-extra to markdown, stripping the attributes from extra. """ return Mde2mdConverter() def xhtml(): """This makes a converter from markdown to xhtml format. It can be called like this: from errbot.rendering import xhtml md_converter = xhtml() # you need to cache the converter html = md_converter.convert(md_txt) """ return Markdown(output_format='xhtml', extensions=[ExtraExtension()]) def md_escape(txt): """ Call this if you want to be sure your text won't be interpreted as markdown :param txt: bare text to escape. """ return MD_ESCAPE_RE.sub(lambda match: '\\' + match.group(0), txt) errbot-6.1.1+ds/errbot/rendering/ansiext.py000066400000000000000000000436111355337103200207240ustar00rootroot00000000000000from itertools import chain from collections import namedtuple from functools import partial import io import logging from markdown import Markdown from markdown.extensions import Extension from markdown.postprocessors import Postprocessor from markdown.inlinepatterns import SubstituteTagPattern from markdown.extensions.fenced_code import FencedBlockPreprocessor from ansi.colour import fg, bg, fx from html import unescape log = logging.getLogger(__name__) # chr that should not count as a space class NSC(object): def __init__(self, s): self.s = s def __str__(self): return self.s # The translation table for the special characters. CharacterTable = namedtuple('CharacterTable', ['fg_black', 'fg_red', 'fg_green', 'fg_yellow', 'fg_blue', 'fg_magenta', 'fg_cyan', 'fg_white', 'fg_default', 'bg_black', 'bg_red', 'bg_green', 'bg_yellow', 'bg_blue', 'bg_magenta', 'bg_cyan', 'bg_white', 'bg_default', 'fx_reset', 'fx_bold', 'fx_italic', 'fx_underline', 'fx_not_italic', 'fx_not_underline', 'fx_normal', 'fixed_width', 'end_fixed_width', 'inline_code', 'end_inline_code', ]) ANSI_CHRS = CharacterTable(fg_black=fg.black, fg_red=fg.red, fg_green=fg.green, fg_yellow=fg.yellow, fg_blue=fg.blue, fg_magenta=fg.magenta, fg_cyan=fg.cyan, fg_white=fg.white, fg_default=fg.default, bg_black=bg.black, bg_red=bg.red, bg_green=bg.green, bg_yellow=bg.yellow, bg_blue=bg.blue, bg_magenta=bg.magenta, bg_cyan=bg.cyan, bg_white=bg.white, bg_default=bg.default, fx_reset=fx.reset, fx_bold=fx.bold, fx_italic=fx.italic, fx_underline=fx.underline, fx_not_italic=fx.not_italic, fx_not_underline=fx.not_underline, fx_normal=fx.normal, fixed_width='', end_fixed_width='', inline_code='', end_inline_code='') # Pure Text doesn't have any graphical chrs. TEXT_CHRS = CharacterTable(fg_black='', fg_red='', fg_green='', fg_yellow='', fg_blue='', fg_magenta='', fg_cyan='', fg_white='', fg_default='', bg_black='', bg_red='', bg_green='', bg_yellow='', bg_blue='', bg_magenta='', bg_cyan='', bg_white='', bg_default='', fx_reset='', fx_bold='', fx_italic='', fx_underline='', fx_not_italic='', fx_not_underline='', fx_normal='', fixed_width='', end_fixed_width='', inline_code='', end_inline_code='') # IMText have some formatting available IMTEXT_CHRS = CharacterTable(fg_black='', fg_red='', fg_green='', fg_yellow='', fg_blue='', fg_magenta='', fg_cyan='', fg_white='', fg_default='', bg_black='', bg_red='', bg_green='', bg_yellow='', bg_blue='', bg_magenta='', bg_cyan='', bg_white='', bg_default='', fx_reset='', fx_bold=NSC('*'), fx_italic='', fx_underline=NSC('_'), fx_not_italic='', fx_not_underline=NSC('_'), fx_normal=NSC('*'), fixed_width='```\n', end_fixed_width='```\n', inline_code='`', end_inline_code='`') NEXT_ROW = "&NEXT_ROW;" class Table(object): def __init__(self, chr_table): self.headers = [] self.rows = [] self.in_headers = False self.ct = chr_table def next_row(self): if self.in_headers: self.headers.append([]) # is that exists ? else: self.rows.append([]) def add_col(self): if not self.rows: self.rows = [[]] else: self.rows[-1].append(('', 0)) def add_header(self): if not self.headers: self.headers = [[]] else: self.headers[-1].append(('', 0)) def begin_headers(self): self.in_headers = True def end_headers(self): self.in_headers = False def write(self, text): cells = self.headers if self.in_headers else self.rows text_cell, count = cells[-1][-1] if isinstance(text, str): text_cell += text count += len(text) else: text_cell += str(text) # This is a non space chr cells[-1][-1] = text_cell, count def __str__(self): nbcols = max(len(row) for row in chain(self.headers, self.rows)) maxes = [0, ] * nbcols for row in chain(self.headers, self.rows): for i, el in enumerate(row): txt, length = el # Account for multiline cells cnt = str(txt).count(NEXT_ROW) if cnt > 0: length -= cnt * len(NEXT_ROW) if maxes[i] < length: maxes[i] = length # add up margins maxes = [m + 2 for m in maxes] output = io.StringIO() if self.headers: output.write('┏' + '┳'.join('━' * m for m in maxes) + '┓') output.write('\n') first = True for row in self.headers: if not first: output.write('┣' + '╋'.join('━' * m for m in maxes) + '┫') output.write('\n') first = False for i, header in enumerate(row): text, ln = header output.write('┃ ' + text + ' ' * (maxes[i] - 2 - ln) + ' ') output.write('┃') output.write('\n') output.write('┡' + '╇'.join('━' * m for m in maxes) + '┩') output.write('\n') else: output.write('┌' + '┬'.join('─' * m for m in maxes) + '┐') output.write('\n') first = True for row in self.rows: max_row_height = 1 for i, item in enumerate(row): text, _ = item row_height = str(text).count(NEXT_ROW) + 1 if row_height > max_row_height: max_row_height = row_height if not first: output.write('├' + '┼'.join('─' * m for m in maxes) + '┤') output.write('\n') first = False for j in range(max_row_height): for i, item in enumerate(row): text, ln = item multi = text.split(NEXT_ROW) if len(multi) > j: text = multi[j] ln = len(text) else: ln = 1 text = ' ' output.write('│ ' + text + ' ' * (maxes[i] - 2 - ln) + ' ') output.write('│') output.write('\n') output.write('└' + '┴'.join('─' * m for m in maxes) + '┘') output.write('\n') return str(self.ct.fixed_width) + output.getvalue() + str(self.ct.end_fixed_width) class BorderlessTable(object): def __init__(self, chr_table): self.headers = [] self.rows = [] self.in_headers = False self.ct = chr_table def next_row(self): if self.in_headers: self.headers.append([]) # is that exists ? else: self.rows.append([]) def add_col(self): if not self.rows: self.rows = [[]] else: self.rows[-1].append(('', 0)) def add_header(self): if not self.headers: self.headers = [[]] else: self.headers[-1].append(('', 0)) def begin_headers(self): self.in_headers = True def end_headers(self): self.in_headers = False def write(self, text): cells = self.headers if self.in_headers else self.rows text_cell, count = cells[-1][-1] if isinstance(text, str): text_cell += text count += len(text) else: text_cell += str(text) # This is a non space chr cells[-1][-1] = text_cell, count def __str__(self): nbcols = max(len(row) for row in chain(self.headers, self.rows)) maxes = [0, ] * nbcols for row in chain(self.headers, self.rows): for i, el in enumerate(row): txt, length = el # Account for multiline cells cnt = str(txt).count(NEXT_ROW) if cnt > 0: length -= cnt * len(NEXT_ROW) if maxes[i] < length: maxes[i] = length # add up margins maxes = [m + 2 for m in maxes] output = io.StringIO() if self.headers: for row in self.headers: for i, header in enumerate(row): text, ln = header output.write(text + ' ' * (maxes[i] - 2 - ln) + ' ') output.write('\n') for row in self.rows: max_row_height = 1 for i, item in enumerate(row): text, _ = item row_height = str(text).count(NEXT_ROW) + 1 if row_height > max_row_height: max_row_height = row_height for j in range(max_row_height): for i, item in enumerate(row): text, ln = item multi = text.split(NEXT_ROW) if len(multi) > j: text = multi[j] ln = len(text) else: ln = 1 text = ' ' output.write(text + ' ' * (maxes[i] - 2 - ln) + ' ') output.write('\n') return str(self.ct.fixed_width) + output.getvalue() + str(self.ct.end_fixed_width) def recurse(write, chr_table, element, table=None, borders=True): post_element = [] if element.text: text = element.text else: text = '' items = element.items() for k, v in items: if k == 'color': color_attr = getattr(chr_table, 'fg_' + v, None) if color_attr is None: log.warning("there is no '%s' color in ansi.", v) continue write(color_attr) post_element.append(chr_table.fg_default) elif k == 'bgcolor': color_attr = getattr(chr_table, 'bg_' + v, None) if color_attr is None: log.warning("there is no '%s' bgcolor in ansi", v) continue write(color_attr) post_element.append(chr_table.bg_default) if element.tag == 'img': text = dict(items)['src'] elif element.tag == 'strong': write(chr_table.fx_bold) post_element.append(chr_table.fx_normal) elif element.tag == 'code': write(chr_table.inline_code) post_element.append(chr_table.end_inline_code) elif element.tag == 'em': write(chr_table.fx_underline) post_element.append(chr_table.fx_not_underline) elif element.tag == 'p': write(' ') post_element.append('\n') elif element.tag == 'br' and table: # Treat
differently in a table. write(NEXT_ROW) elif element.tag == 'a': post_element.append(' (' + element.get('href') + ')') elif element.tag == 'li': write('• ') post_element.append('\n') elif element.tag == 'hr': write('─' * 80) write('\n') elif element.tag == 'ul': # ignore the text part text = None elif element.tag == 'h1': write(chr_table.fx_bold) text = text.upper() post_element.append(chr_table.fx_normal) post_element.append('\n\n') elif element.tag == 'h2': write('\n') write(' ') write(chr_table.fx_bold) post_element.append(chr_table.fx_normal) post_element.append('\n\n') elif element.tag == 'h3': write('\n') write(' ') write(chr_table.fx_underline) post_element.append(chr_table.fx_not_underline) post_element.append('\n\n') elif element.tag in ('h4', 'h5', 'h6'): write('\n') write(' ') post_element.append('\n') elif element.tag == 'table': table = Table(chr_table) if borders else BorderlessTable(chr_table) orig_write = write write = table.write text = None elif element.tag == 'tbody': text = None elif element.tag == 'thead': table.begin_headers() text = None elif element.tag == 'tr': table.next_row() text = None elif element.tag == 'td': table.add_col() elif element.tag == 'th': table.add_header() if text: write(text) for e in element: recurse(write, chr_table, e, table, borders) if element.tag == 'table': write = orig_write write(str(table)) if element.tag == 'thead': table.end_headers() for restore in post_element: write(restore) if element.tail: tail = element.tail.rstrip('\n') if tail: write(tail) def translate(element, chr_table=ANSI_CHRS, borders=True): f = io.StringIO() def write(ansi_obj): return f.write(str(ansi_obj)) recurse(write, chr_table, element, borders=borders) result = f.getvalue().rstrip('\n') # remove the useless final \n return result + str(chr_table.fx_reset) # patch us in def enable_format(name, chr_table, borders=True): Markdown.output_formats[name] = partial(translate, chr_table=chr_table, borders=borders) for n, ct in (('ansi', ANSI_CHRS), ('text', TEXT_CHRS), ('imtext', IMTEXT_CHRS)): enable_format(n, ct) class AnsiPostprocessor(Postprocessor): """Markdown generates html entities, this reputs them back to their unicode equivalent""" def run(self, text): return unescape(text) # This is an adapted FencedBlockPreprocessor that doesn't insert
class AnsiPreprocessor(FencedBlockPreprocessor):
    def run(self, lines):
        """ Match and store Fenced Code Blocks in the HtmlStash. """
        text = "\n".join(lines)
        while 1:
            m = self.FENCED_BLOCK_RE.search(text)
            if m:
                code = self._escape(m.group('code'))

                placeholder = self.markdown.htmlStash.store(code)
                text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}'
            else:
                break
        return text.split('\n')

    def _escape(self, txt):
        """ basic html escaping """
        txt = txt.replace('&', '&')
        txt = txt.replace('<', '<')
        txt = txt.replace('>', '>')
        txt = txt.replace('"', '"')
        return txt


class AnsiExtension(Extension):
    """(kinda hackish) This is just a private extension to postprocess the html text to ansi text"""

    def extendMarkdown(self, md, md_globals):
        md.registerExtension(self)
        md.postprocessors.add(
            "unescape_html", AnsiPostprocessor(), ">unescape"
        )
        md.preprocessors.add(
            "ansi_fenced_codeblock", AnsiPreprocessor(md), " tags as is for proper table multiline cell processing
            "br", SubstituteTagPattern(r'
', "br"), " 0x82: '\u201a', # SINGLE LOW-9 QUOTATION MARK 0x83: '\u0192', # LATIN SMALL LETTER F WITH HOOK 0x84: '\u201e', # DOUBLE LOW-9 QUOTATION MARK 0x85: '\u2026', # HORIZONTAL ELLIPSIS 0x86: '\u2020', # DAGGER 0x87: '\u2021', # DOUBLE DAGGER 0x88: '\u02c6', # MODIFIER LETTER CIRCUMFLEX ACCENT 0x89: '\u2030', # PER MILLE SIGN 0x8a: '\u0160', # LATIN CAPITAL LETTER S WITH CARON 0x8b: '\u2039', # SINGLE LEFT-POINTING ANGLE QUOTATION MARK 0x8c: '\u0152', # LATIN CAPITAL LIGATURE OE 0x8d: '\x8d', # 0x8e: '\u017d', # LATIN CAPITAL LETTER Z WITH CARON 0x8f: '\x8f', # 0x90: '\x90', # 0x91: '\u2018', # LEFT SINGLE QUOTATION MARK 0x92: '\u2019', # RIGHT SINGLE QUOTATION MARK 0x93: '\u201c', # LEFT DOUBLE QUOTATION MARK 0x94: '\u201d', # RIGHT DOUBLE QUOTATION MARK 0x95: '\u2022', # BULLET 0x96: '\u2013', # EN DASH 0x97: '\u2014', # EM DASH 0x98: '\u02dc', # SMALL TILDE 0x99: '\u2122', # TRADE MARK SIGN 0x9a: '\u0161', # LATIN SMALL LETTER S WITH CARON 0x9b: '\u203a', # SINGLE RIGHT-POINTING ANGLE QUOTATION MARK 0x9c: '\u0153', # LATIN SMALL LIGATURE OE 0x9d: '\x9d', # 0x9e: '\u017e', # LATIN SMALL LETTER Z WITH CARON 0x9f: '\u0178', # LATIN CAPITAL LETTER Y WITH DIAERESIS } def _replace_charref(s): s = s.group(1) if s[0] == '#': # numeric charref if s[1] in 'xX': num = int(s[2:].rstrip(';'), 16) else: num = int(s[1:].rstrip(';')) if num in _invalid_charrefs: return _invalid_charrefs[num] if 0xD800 <= num <= 0xDFFF or num > 0x10FFFF: return '\uFFFD' if num in _invalid_codepoints: return '' return chr(num) else: # named charref if s in SAFE_ENTITIES: return SAFE_ENTITIES[s] # find the longest matching name (as defined by the standard) for x in range(len(s) - 1, 1, -1): if s[:x] in SAFE_ENTITIES: return SAFE_ENTITIES[s[:x]] + s[x:] else: return '&' + s _charref = re.compile(r'&(#[0-9]+;?' r'|#[xX][0-9a-fA-F]+;?' r'|[^\t\n\f <&#;]{1,32};?)') def unescape(s): if '&' not in s: return s return _charref.sub(_replace_charref, s) errbot-6.1.1+ds/errbot/repo_manager.py000066400000000000000000000252151355337103200177330ustar00rootroot00000000000000from typing import Tuple, Union, Sequence, List, Generator, Dict import json import re import logging import os import shutil import subprocess from collections import namedtuple from datetime import timedelta, datetime from os import path import tarfile from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import urlopen from urllib.parse import urlparse from errbot.storage import StoreMixin from errbot.storage.base import StoragePluginBase from .utils import git_clone from .utils import git_pull from .utils import ON_WINDOWS log = logging.getLogger(__name__) def human_name_for_git_url(url): # try to humanize the last part of the git url as much as we can s = url.split(':')[-1].split('/')[-2:] if s[-1].endswith('.git'): s[-1] = s[-1][:-4] return str('/'.join(s)) INSTALLED_REPOS = 'installed_repos' REPO_INDEXES_CHECK_INTERVAL = timedelta(hours=1) REPO_INDEX = 'repo_index' LAST_UPDATE = 'last_update' RepoEntry = namedtuple('RepoEntry', 'entry_name, name, python, repo, path, avatar_url, documentation') FIND_WORDS_RE = re.compile(r"(\w[\w']*\w|\w)") class RepoException(Exception): pass def makeEntry(repo_name: str, plugin_name: str, json_value): return RepoEntry(entry_name=repo_name, name=plugin_name, python=json_value['python'], repo=json_value['repo'], path=json_value['path'], avatar_url=json_value['avatar_url'], documentation=json_value['documentation']) def tokenizeJsonEntry(json_dict): """ Returns all the words in a repo entry. """ search = ' '.join((str(word) for word in json_dict.values())) return set(FIND_WORDS_RE.findall(search.lower())) def which(program): if ON_WINDOWS: program += '.exe' def is_exe(file_path): return os.path.isfile(file_path) and os.access(file_path, os.X_OK) fpath, fname = os.path.split(program) if fpath: if is_exe(program): return program else: for path in os.environ["PATH"].split(os.pathsep): exe_file = os.path.join(path, program) if is_exe(exe_file): return exe_file return None def check_dependencies(req_path: Path) -> Tuple[Union[str, None], Sequence[str]]: """ This methods returns a pair of (message, packages missing). Or None, [] if everything is OK. """ log.debug('check dependencies of %s', req_path) # noinspection PyBroadException try: from pkg_resources import get_distribution missing_pkg = [] if not req_path.is_file(): log.debug('%s has no requirements.txt file', req_path) return None, missing_pkg with req_path.open() as f: for line in f: stripped = line.strip() # skip empty lines. if not stripped: continue # noinspection PyBroadException try: get_distribution(stripped) except Exception: missing_pkg.append(stripped) if missing_pkg: return f'You need these dependencies for {req_path}: ' + ','.join(missing_pkg), missing_pkg return None, missing_pkg except Exception: log.exception('Problem checking for dependencies.') return 'You need to have setuptools installed for the dependency check of the plugins', [] class BotRepoManager(StoreMixin): """ Manages the repo list, git clones/updates or the repos. """ def __init__(self, storage_plugin: StoragePluginBase, plugin_dir: str, plugin_indexes: Tuple[str, ...]) -> None: """ Make a repo manager. :param storage_plugin: where the manager store its state. :param plugin_dir: where on disk it will git clone the repos. :param plugin_indexes: a list of URL / path to get the json repo index. """ super().__init__() self.plugin_indexes = plugin_indexes self.storage_plugin = storage_plugin self.plugin_dir = plugin_dir self.open_storage(storage_plugin, 'repomgr') def shutdown(self) -> None: self.close_storage() def check_for_index_update(self) -> None: if REPO_INDEX not in self: log.info('No repo index, creating it.') self.index_update() return if datetime.fromtimestamp(self[REPO_INDEX][LAST_UPDATE]) < datetime.now() - REPO_INDEXES_CHECK_INTERVAL: log.info('Index is too old, update it.') self.index_update() def index_update(self) -> None: index = {LAST_UPDATE: datetime.now().timestamp()} for source in reversed(self.plugin_indexes): try: if urlparse(source).scheme in ('http', 'https'): with urlopen(url=source, timeout=10) as request: # nosec log.debug('Update from remote source %s...', source) encoding = request.headers.get_content_charset() content = request.read().decode(encoding if encoding else 'utf-8') else: with open(source, encoding='utf-8', mode='r') as src_file: log.debug('Update from local source %s...', source) content = src_file.read() index.update(json.loads(content)) except (HTTPError, URLError, IOError): log.exception('Could not update from source %s, keep the index as it is.', source) break else: # nothing failed so ok, we can store the index. self[REPO_INDEX] = index log.debug('Stored %d repo entries.', len(index) - 1) def get_repo_from_index(self, repo_name: str) -> List[RepoEntry]: """ Retrieve the list of plugins for the repo_name from the index. :param repo_name: the name of the repo :return: a list of RepoEntry """ plugins = self[REPO_INDEX].get(repo_name, None) if plugins is None: return None result = [] for name, plugin in plugins.items(): result.append(makeEntry(repo_name, name, plugin)) return result def search_repos(self, query: str) -> Generator[RepoEntry, None, None]: """ A simple search feature, keywords are AND and case insensitive on all the fields. :param query: a string query :return: an iterator of RepoEntry """ # first see if we are up to date. self.check_for_index_update() if REPO_INDEX not in self: log.error('No index.') return query_work_set = set(FIND_WORDS_RE.findall(query.lower())) for repo_name, plugins in self[REPO_INDEX].items(): if repo_name == LAST_UPDATE: continue for plugin_name, plugin in plugins.items(): if query_work_set.intersection(tokenizeJsonEntry(plugin)): yield makeEntry(repo_name, plugin_name, plugin) def get_installed_plugin_repos(self) -> Dict[str, str]: return self.get(INSTALLED_REPOS, {}) def add_plugin_repo(self, name: str, url: str) -> None: with self.mutable(INSTALLED_REPOS, {}) as repos: repos[name] = url def set_plugin_repos(self, repos: Dict[str, str]) -> None: """ Used externally. """ self[INSTALLED_REPOS] = repos def get_all_repos_paths(self) -> List[str]: return [os.path.join(self.plugin_dir, d) for d in self.get(INSTALLED_REPOS, {}).keys()] def install_repo(self, repo: str) -> str: """ Install the repository from repo :param repo: The url, git url or path on disk of a repository. It can point to either a git repo or a .tar.gz of a plugin :returns: The path on disk where the repo has been installed on. :raises: :class:`~RepoException` if an error occured. """ self.check_for_index_update() human_name = None # try to find if we have something with that name in our index if repo in self[REPO_INDEX]: human_name = repo repo_url = next(iter(self[REPO_INDEX][repo].values()))['repo'] elif not repo.endswith('tar.gz'): # This is a repo url, make up a plugin definition for it human_name = human_name_for_git_url(repo) repo_url = repo else: repo_url = repo # TODO: Update download path of plugin. if repo_url.endswith('tar.gz'): fo = urlopen(repo_url) # nosec tar = tarfile.open(fileobj=fo, mode='r:gz') tar.extractall(path=self.plugin_dir) s = repo_url.split(':')[-1].split('/')[-1] human_name = s[:-len('.tar.gz')] else: human_name = human_name or human_name_for_git_url(repo_url) try: git_clone(repo_url, os.path.join(self.plugin_dir, human_name)) except Exception as exception: # dulwich errors all base on exceptions.Exception raise RepoException(f'Could not load this plugin: \n\n{repo_url}\n\n---\n\n{exception}') self.add_plugin_repo(human_name, repo_url) return os.path.join(self.plugin_dir, human_name) def update_repos(self, repos) -> Generator[Tuple[str, int, str], None, None]: """ This git pulls the specified repos on disk. Yields tuples like (name, success, reason) """ # protects for update outside of what we know is installed names = set(self.get_installed_plugin_repos().keys()).intersection(set(repos)) for d in (path.join(self.plugin_dir, name) for name in names): success = 1 try: git_pull(d) feedback = "Pulled remote" success = 0 except Exception as exception: feedback = f"Error pulling remote {exception}" pass dep_err, missing_pkgs = check_dependencies(Path(d) / 'requirements.txt') if dep_err: feedback += dep_err + '\n' yield d, success, feedback def update_all_repos(self) -> Generator[Tuple[str, int, str], None, None]: return self.update_repos(self.get_installed_plugin_repos().keys()) def uninstall_repo(self, name: str) -> None: repo_path = path.join(self.plugin_dir, name) # ignore errors because the DB can be desync'ed from the file tree. shutil.rmtree(repo_path, ignore_errors=True) repos = self.get_installed_plugin_repos() del(repos[name]) self.set_plugin_repos(repos) errbot-6.1.1+ds/errbot/storage/000077500000000000000000000000001355337103200163615ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/storage/__init__.py000066400000000000000000000044441355337103200205000ustar00rootroot00000000000000import types from collections import MutableMapping from contextlib import contextmanager import logging log = logging.getLogger(__name__) class StoreException(Exception): pass class StoreAlreadyOpenError(StoreException): pass class StoreNotOpenError(StoreException): pass class StoreMixin(MutableMapping): """ This class handle the basic needs of bot plugins and core like loading, unloading and creating a storage """ def __init__(self): self._store = None self.namespace = None def open_storage(self, storage_plugin, namespace): if hasattr(self, 'store') and self._store is not None: raise StoreAlreadyOpenError("Storage appears to be opened already") log.debug("Opening storage '%s'", namespace) self._store = storage_plugin.open(namespace) self.namespace = namespace def close_storage(self): if not hasattr(self, '_store') or self._store is None: raise StoreNotOpenError("Storage does not appear to have been opened yet") self._store.close() self._store = None log.debug("Closed storage '%s'", self.namespace) # those are the minimal things to behave like a dictionary with the UserDict.DictMixin def __getitem__(self, key): return self._store.get(key) @contextmanager def mutable(self, key, default=None): try: obj = self._store.get(key) except KeyError: obj = default yield obj # implements autosave for a plugin persistent entry # with self['foo'] as f: # f[4] = 2 # saves the entry ! self._store.set(key, obj) def __setitem__(self, key, item): return self._store.set(key, item) def __delitem__(self, key): return self._store.remove(key) def keys(self): return self._store.keys() def __len__(self): return self._store.len() def __iter__(self): for i in self._store.keys(): yield i def __contains__(self, x): try: self._store.get(x) return True except KeyError: return False # compatibility with with def __enter__(self): return self def __exit__(self, type, value, traceback): self.close_storage() errbot-6.1.1+ds/errbot/storage/base.py000066400000000000000000000040161355337103200176460ustar00rootroot00000000000000from abc import abstractmethod from typing import Any, Iterable class StorageBase(object): """ Contract to implemement a storage. """ @abstractmethod def set(self, key: str, value: Any) -> None: """ Atomically set the key to the given value. The caller of set will protect against set on non open. :param key: string as key :param value: pickalable python object """ pass @abstractmethod def get(self, key: str) -> Any: """ Get the value stored for key. Raises KeyError if the key doesn't exist. The caller of get will protect against get on non open. :param key: the key :return: the value """ pass @abstractmethod def remove(self, key: str) -> None: """ Remove key. Raises KeyError if the key doesn't exist. The caller of get will protect against get on non open. :param key: the key """ pass @abstractmethod def len(self) -> int: """ :return: the number of keys set. """ pass @abstractmethod def keys(self) -> Iterable[str]: """ :return: an iterator on all the entries """ pass @abstractmethod def close(self) -> None: """ Sync and close the storage. The caller of close will protect against close on non open and double close. """ pass class StoragePluginBase(object): """ Base to implement a storage plugin. This is a factory for the namespaces. """ def __init__(self, bot_config): self._storage_config = getattr(bot_config, 'STORAGE_CONFIG', {}) @abstractmethod def open(self, namespace: str) -> StorageBase: """ Open the storage with the given namespace (core, or plugin name) and config. The caller of open will protect against double opens. :param namespace: a namespace to isolate the plugin storages. :return: """ pass errbot-6.1.1+ds/errbot/storage/memory.plug000066400000000000000000000002101355337103200205530ustar00rootroot00000000000000[Core] Name = Memory Module = memory [Documentation] Description = This is the storage plugin for an in-memory store (non-persistent). errbot-6.1.1+ds/errbot/storage/memory.py000066400000000000000000000017411355337103200202460ustar00rootroot00000000000000from typing import Any from errbot.storage.base import StorageBase, StoragePluginBase ROOTS = {} # make a little bit of an emulated persistence. class MemoryStorage(StorageBase): def __init__(self, namespace): self.namespace = namespace self.root = ROOTS.get(namespace, {}) def get(self, key: str) -> Any: if key not in self.root: raise KeyError(f"{key} doesn't exist.") return self.root[key] def set(self, key: str, value: Any) -> None: self.root[key] = value def remove(self, key: str): if key not in self.root: raise KeyError(f"{key} doesn't exist.") del self.root[key] def len(self): return len(self.root) def keys(self): return self.root.keys() def close(self) -> None: ROOTS[self.namespace] = self.root class MemoryStoragePlugin(StoragePluginBase): def open(self, namespace: str) -> StorageBase: return MemoryStorage(namespace) errbot-6.1.1+ds/errbot/storage/shelf.plug000066400000000000000000000002111355337103200203450ustar00rootroot00000000000000[Core] Name = Shelf Module = shelf [Documentation] Description = This is the storage plugin for the traditional shelf store for errbot. errbot-6.1.1+ds/errbot/storage/shelf.py000066400000000000000000000035311355337103200200360ustar00rootroot00000000000000import logging from typing import Any import shelve import os import shutil from errbot.storage.base import StorageBase, StoragePluginBase log = logging.getLogger('errbot.storage.shelf') class ShelfStorage(StorageBase): def __init__(self, path): log.debug('Open shelf storage %s', path) self.shelf = shelve.DbfilenameShelf(path, protocol=2) def get(self, key: str) -> Any: return self.shelf[key] def remove(self, key: str): if key not in self.shelf: raise KeyError(f"{key} doesn't exist.") del self.shelf[key] def set(self, key: str, value: Any) -> None: self.shelf[key] = value def len(self): return len(self.shelf) def keys(self): return self.shelf.keys() def close(self) -> None: self.shelf.close() self.shelf = None class ShelfStoragePlugin(StoragePluginBase): def __init__(self, bot_config): super().__init__(bot_config) if 'basedir' not in self._storage_config: self._storage_config['basedir'] = bot_config.BOT_DATA_DIR def open(self, namespace: str) -> StorageBase: config = self._storage_config # Hack to port move old DBs to the new location. new_spot = os.path.join(config['basedir'], namespace + '.db') old_spot = os.path.join(config['basedir'], 'plugins', namespace + '.db') if os.path.isfile(old_spot): if os.path.isfile(new_spot): log.warning('You have an old v3 DB at %s and a duplicate new one at %s.', old_spot, new_spot) log.warning('You need to either remove the old one or move it in place of the new one manually.') else: log.info('Moving your old v3 DB from %s to %s.', old_spot, new_spot) shutil.move(old_spot, new_spot) return ShelfStorage(new_spot) errbot-6.1.1+ds/errbot/streaming.py000066400000000000000000000062211355337103200172610ustar00rootroot00000000000000 import os import io from itertools import starmap, repeat from threading import Thread from .backends.base import STREAM_WAITING_TO_START, STREAM_TRANSFER_IN_PROGRESS import logging CHUNK_SIZE = 4096 log = logging.getLogger(__name__) def repeatfunc(func, times=None, *args): # from the itertools receipes """Repeat calls to func with specified arguments. Example: repeatfunc(random.random) :param args: params to the function to call. :param times: number of times to repeat. :param func: the function to repeatedly call. """ if times is None: return starmap(func, repeat(args)) return starmap(func, repeat(args, times)) class Tee(object): """ Tee implements a multi reader / single writer """ def __init__(self, incoming_stream, clients): """ clients is a list of objects implementing callback_stream """ self.incoming_stream = incoming_stream self.clients = clients def start(self): """ starts the transfer asynchronously """ t = Thread(target=self.run) t.start() return t def run(self): """ streams to all the clients synchronously """ nb_clients = len(self.clients) pipes = [(io.open(r, 'rb'), io.open(w, 'wb')) for r, w in repeatfunc(os.pipe, nb_clients)] streams = [self.incoming_stream.clone(pipe[0]) for pipe in pipes] def streamer(index): try: self.clients[index].callback_stream(streams[index]) if streams[index].status == STREAM_WAITING_TO_START: streams[index].reject() plugin = self.clients[index].name logging.warning('%s did not accept nor reject the incoming file transfer', plugin) logging.warning('I reject it as a fallback.') except Exception as _: # internal error, mark the error. streams[index].error() else: if streams[index].status == STREAM_TRANSFER_IN_PROGRESS: # if the plugin didn't do it by itself, mark the transfer as a success. streams[index].success() # stop the stream if the callback_stream returns read, write = pipes[index] pipes[index] = (None, None) # signal the main thread to stop streaming read.close() write.close() threads = [Thread(target=streamer, args=(i,)) for i in range(nb_clients)] for thread in threads: thread.start() while True: if self.incoming_stream.closed: break chunk = self.incoming_stream.read(CHUNK_SIZE) log.debug("dispatch %d bytes", len(chunk)) if not chunk: break for (_, w) in pipes: if w: w.write(chunk) log.debug("EOF detected") for (r, w) in pipes: if w: w.close() # close should flush too # we want to be sure that if we join on the main thread, # everything is either fully transfered or errored for thread in threads: thread.join() errbot-6.1.1+ds/errbot/templates/000077500000000000000000000000001355337103200167135ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/templates/card.md000066400000000000000000000014741355337103200201540ustar00rootroot00000000000000{% if card.summary %} **{{card.summary}}** {% endif %} {% if not card.thumbnail %} {% if card.link %} ##[{{ card.title }}]({{ card.link }}) {% else %} ##{{ card.title }} {% endif %} {% else %} {% if card.link %} | | |-:|:- | ![{{ card.thumbnail }}]({{ card.thumbnail }}) | **[{{ card.title }}]({{ card.link }})** {% else %} | | |-:|:- | ![{{ card.thumbnail }}]({{ card.thumbnail }}) | **{{ card.title }}** {% endif %} {% endif %} {% if card.image %} ![{{ card.image }}]({{ card.image }}) {{ card.body }} {: color='{{card.text_color}}' bgcolor='{{card.color}}' } {% else %} {{ card.body }} {: color='{{card.text_color}}' bgcolor='{{card.color}}' } {% endif %} {% for key,_ in card.fields %}| {{ key }} {% endfor %} {% for _ in card.fields %}| - {% endfor %} {% for _,value in card.fields %}| {{ value }} {% endfor %} errbot-6.1.1+ds/errbot/templates/initdir/000077500000000000000000000000001355337103200203555ustar00rootroot00000000000000errbot-6.1.1+ds/errbot/templates/initdir/config.py.tmpl000066400000000000000000000012371355337103200231520ustar00rootroot00000000000000import logging # This is a minimal configuration to get you started with the Text mode. # If you want to connect Errbot to chat services, checkout # the options in the more complete config-template.py from here: # https://raw.githubusercontent.com/errbotio/errbot/master/errbot/config-template.py BACKEND = 'Text' # Errbot will start in text mode (console only mode) and will answer commands from there. BOT_DATA_DIR = r'{{ data_dir }}' BOT_EXTRA_PLUGIN_DIR = r'{{ extra_plugin_dir }}' BOT_LOG_FILE = r'{{ log_path }}' BOT_LOG_LEVEL = logging.DEBUG BOT_ADMINS = ('@CHANGE_ME', ) # !! Don't leave that to "@CHANGE_ME" if you connect your errbot to a chat system !! errbot-6.1.1+ds/errbot/templates/initdir/example.plug000066400000000000000000000002211355337103200226740ustar00rootroot00000000000000[Core] Name = Example Module = example [Documentation] Description = This is a simple plugin example to get you started. [Python] Version = 2+ errbot-6.1.1+ds/errbot/templates/initdir/example.py000066400000000000000000000012341355337103200223620ustar00rootroot00000000000000from errbot import BotPlugin, botcmd class Example(BotPlugin): """ This is a very basic plugin to try out your new installation and get you started. Feel free to tweak me to experiment with Errbot. You can find me in your init directory in the subdirectory plugins. """ @botcmd # flags a command def tryme(self, msg, args): # a command callable with !tryme """ Execute to check if Errbot responds to command. Feel free to tweak me to experiment with Errbot. You can find me in your init directory in the subdirectory plugins. """ return 'It *works* !' # This string format is markdown. errbot-6.1.1+ds/errbot/templates/new_plugin.py.tmpl000066400000000000000000000055321355337103200224140ustar00rootroot00000000000000from errbot import BotPlugin, botcmd, arg_botcmd, webhook class {{ class_name }}(BotPlugin): """ {{ description }} """ def activate(self): """ Triggers on plugin activation You should delete it if you're not using it to override any default behaviour """ super({{ class_name }}, self).activate() def deactivate(self): """ Triggers on plugin deactivation You should delete it if you're not using it to override any default behaviour """ super({{ class_name }}, self).deactivate() def get_configuration_template(self): """ Defines the configuration structure this plugin supports You should delete it if your plugin doesn't use any configuration like this """ return {'EXAMPLE_KEY_1': "Example value", 'EXAMPLE_KEY_2': ["Example", "Value"] } def check_configuration(self, configuration): """ Triggers when the configuration is checked, shortly before activation Raise a errbot.ValidationException in case of an error You should delete it if you're not using it to override any default behaviour """ super({{ class_name }}, self).check_configuration(configuration) def callback_connect(self): """ Triggers when bot is connected You should delete it if you're not using it to override any default behaviour """ pass def callback_message(self, message): """ Triggered for every received message that isn't coming from the bot itself You should delete it if you're not using it to override any default behaviour """ pass def callback_botmessage(self, message): """ Triggered for every message that comes from the bot itself You should delete it if you're not using it to override any default behaviour """ pass @webhook def example_webhook(self, incoming_request): """A webhook which simply returns 'Example'""" return "Example" # Passing split_args_with=None will cause arguments to be split on any kind # of whitespace, just like Python's split() does @botcmd(split_args_with=None) def example(self, message, args): """A command which simply returns 'Example'""" return "Example" @arg_botcmd('name', type=str) @arg_botcmd('--favorite-number', type=int, unpack_args=False) def hello(self, message, args): """ A command which says hello to someone. If you include --favorite-number, it will also tell you their favorite number. """ if args.favorite_number is None: return f'Hello {args.name}.' else: return f'Hello {args.name}, I hear your favorite number is {args.favorite_number}.' errbot-6.1.1+ds/errbot/templating.py000066400000000000000000000027161355337103200174410ustar00rootroot00000000000000import logging import os from errbot.plugin_info import PluginInfo from jinja2 import Environment, FileSystemLoader from pathlib import Path log = logging.getLogger(__name__) def make_templates_path(root: Path) -> Path: return root / 'templates' system_templates_path = str(make_templates_path(Path(__file__).parent)) template_path = [system_templates_path] env = Environment(loader=FileSystemLoader(template_path), trim_blocks=True, keep_trailing_newline=False, autoescape=True) def tenv(): return env def add_plugin_templates_path(plugin_info: PluginInfo): global env tmpl_path = make_templates_path(plugin_info.location.parent) if tmpl_path.exists(): log.debug('Templates directory found for this plugin [%s]', tmpl_path) template_path.append(str(tmpl_path)) # for webhooks # Ditch and recreate a new templating environment env = Environment(loader=FileSystemLoader(template_path), autoescape=True) return log.debug('No templates directory found for this plugin [Looking for %s]', tmpl_path) def remove_plugin_templates_path(plugin_info: PluginInfo): global env tmpl_path = str(make_templates_path(plugin_info.location.parent)) if tmpl_path in template_path: template_path.remove(tmpl_path) # Ditch and recreate a new templating environment env = Environment(loader=FileSystemLoader(template_path), autoescape=True) errbot-6.1.1+ds/errbot/utils.py000066400000000000000000000147201355337103200164330ustar00rootroot00000000000000from typing import List import fnmatch import inspect import logging import os import re import sys import time from platform import system from functools import wraps from dulwich import porcelain log = logging.getLogger(__name__) ON_WINDOWS = system() == 'Windows' PLUGINS_SUBDIR = 'plugins' # noinspection PyPep8Naming class deprecated(object): """ deprecated decorator. emits a warning on a call on an old method and call the new method anyway """ def __init__(self, new=None): self.new = new def __call__(self, old): @wraps(old) def wrapper(*args, **kwds): frame = inspect.getframeinfo(inspect.currentframe().f_back) msg = f'{frame.filename}: {frame.lineno}: ' if len(args): pref = type(args[0]).__name__ + '.' # TODO might break for individual methods else: pref = '' msg += f'call to the deprecated {pref}{old.__name__}' if self.new is not None: if type(self.new) is property: msg += f'... use the property {pref}{self.new.fget.__name__} instead' else: msg += f'... use {pref}{self.new.__name__} instead' msg += '.' logging.warning(msg) if self.new: if type(self.new) is property: return self.new.fget(*args, **kwds) return self.new(*args, **kwds) return old(*args, **kwds) wrapper.__name__ = old.__name__ wrapper.__doc__ = old.__doc__ wrapper.__dict__.update(old.__dict__) return wrapper def format_timedelta(timedelta): total_seconds = timedelta.seconds + (86400 * timedelta.days) hours, remainder = divmod(total_seconds, 3600) minutes, seconds = divmod(remainder, 60) if hours == 0 and minutes == 0: return f'{seconds:d} seconds' elif not hours: return f'{minutes:d} minutes' elif not minutes: return f'{hours:d} hours' return f'{hours:d} hours and {minutes:d} minutes' INVALID_VERSION_EXCEPTION = 'version %s in not in format "x.y.z" or "x.y.z-{beta,alpha,rc1,rc2...}" for example "1.2.2"' def version2tuple(version): vsplit = version.split('-') if len(vsplit) == 2: main, sub = vsplit if sub == 'alpha': sub_int = -1 elif sub == 'beta': sub_int = 0 elif sub.startswith('rc'): sub_int = int(sub[2:]) else: raise ValueError(INVALID_VERSION_EXCEPTION % version) elif len(vsplit) == 1: main = vsplit[0] sub_int = sys.maxsize else: raise ValueError(INVALID_VERSION_EXCEPTION % version) response = [int(el) for el in main.split('.')] response.append(sub_int) if len(response) != 4: raise ValueError(INVALID_VERSION_EXCEPTION % version) return tuple(response) REMOVE_EOL = re.compile(r'\n') REINSERT_EOLS = re.compile(r'

||
', re.I) ZAP_TAGS = re.compile(r'<[^>]+>') def rate_limited(min_interval): """ decorator to rate limit a function. :param min_interval: minimum interval allowed between 2 consecutive calls. :return: the decorated function """ def decorate(func): last_time_called = [0.0] def rate_limited_function(*args, **kargs): elapsed = time.time() - last_time_called[0] log.debug('Elapsed %f since last call', elapsed) left_to_wait = min_interval - elapsed if left_to_wait > 0: log.debug('Wait %f due to rate limiting...', left_to_wait) time.sleep(left_to_wait) ret = func(*args, **kargs) last_time_called[0] = time.time() return ret return rate_limited_function return decorate def split_string_after(str_, n): """Yield chunks of length `n` from the given string :param n: length of the chunks. :param str_: the given string. """ for start in range(0, max(len(str_), 1), n): yield str_[start:start + n] def find_roots(path, file_sig='*.plug'): """Collects all the paths from path recursively that contains files of type `file_sig`. :param path: a base path to walk from :param file_sig: the file pattern to look for :return: a set of paths """ roots = set() # you can have several .plug per directory. for root, dirnames, filenames in os.walk(path, followlinks=True): for filename in fnmatch.filter(filenames, file_sig): dir_to_add = os.path.dirname(os.path.join(root, filename)) relative = os.path.relpath(os.path.realpath(dir_to_add), os.path.realpath(path)) for subelement in relative.split(os.path.sep): # if one of the element is just a relative construct, it is ok to continue inspecting it. if subelement in ('.', '..'): continue # if it is an hidden directory or a python temp directory, just ignore it. if subelement.startswith('.') or subelement == '__pycache__': log.debug('Ignore %s.', dir_to_add) break else: roots.add(dir_to_add) return roots def collect_roots(base_paths, file_sig='*.plug'): """Collects all the paths from base_paths recursively that contains files of type `file_sig`. :param base_paths: a list of base paths to walk from elements can be a string or a list/tuple of strings :param file_sig: the file pattern to look for :return: a set of paths """ result = set() for path_or_list in base_paths: if isinstance(path_or_list, (list, tuple)): result |= collect_roots(base_paths=path_or_list, file_sig=file_sig) elif path_or_list is not None: result |= find_roots(path_or_list, file_sig) return result def global_restart(): """Restart the current process.""" python = sys.executable os.execl(python, python, *sys.argv) def git_clone(url: str, path: str) -> None: """ Clones a repository from git url to path """ if not os.path.exists(path): os.makedirs(path) porcelain.clone(url, path) def git_pull(repo_path: str) -> None: """ Does a git pull on a repository """ porcelain.pull(repo_path) def git_tag_list(repo_path: str) -> List[str]: """ Lists git tags on a cloned repo """ porcelain.tag_list(repo_path) errbot-6.1.1+ds/errbot/version.py000066400000000000000000000002121355337103200167470ustar00rootroot00000000000000# Just the current version of Errbot. # It is used for deployment on pypi AND for version checking at plugin load time. VERSION = '6.1.1' errbot-6.1.1+ds/gplv3-exceptions.txt000066400000000000000000000005351355337103200173760ustar00rootroot00000000000000As a special exception, the copyright holders of Errbot hereby grant permission for plug-ins, scripts or add-ons not bundled or distributed as part of Errbot itself and potentially licensed under a different license, to be used with Errbot, provided that you also meet the terms and conditions of the licenses of those plug-ins, scripts or add-ons. errbot-6.1.1+ds/pytest.ini000066400000000000000000000000371355337103200154510ustar00rootroot00000000000000[pytest] python_classes=PyTest errbot-6.1.1+ds/requirements.txt000066400000000000000000000000051355337103200166770ustar00rootroot00000000000000-e . errbot-6.1.1+ds/setup.cfg000066400000000000000000000002251355337103200152400ustar00rootroot00000000000000[flake8] max-line-length = 120 [pycodestyle] max-line-length = 120 ignore = E741,E722 exclude = errbot/config-template.py, tests/config-travisci.py errbot-6.1.1+ds/setup.py000077500000000000000000000113101355337103200151310ustar00rootroot00000000000000#!/usr/bin/env python # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import os import sys from setuptools import setup, find_packages py_version = sys.version_info[:2] if py_version < (3, 6): raise RuntimeError('Errbot requires Python 3.6 or later') VERSION_FILE = os.path.join('errbot', 'version.py') deps = ['webtest', 'setuptools', 'flask', 'requests', 'jinja2', 'pyOpenSSL', 'colorlog', 'markdown<3.0', # rendering stuff, 3.0+ deprecates 'safe()' 'ansi', 'Pygments>=2.0.2', 'pygments-markdown-lexer>=0.1.0.dev39', # sytax coloring to debug md 'dulwich' # python implementation of git ] src_root = os.curdir def read_version(): """ Read directly the errbot/version.py and gives the version without loading Errbot. :return: errbot.version.VERSION """ variables = {} with open(VERSION_FILE) as f: exec(compile(f.read(), 'version.py', 'exec'), variables) return variables['VERSION'] def read(fname, encoding='ascii'): return open(os.path.join(os.path.dirname(__file__), fname), 'r', encoding=encoding).read() if __name__ == "__main__": VERSION = read_version() args = set(sys.argv) changes = read('CHANGES.rst', 'utf8') if changes.find(VERSION) == -1: raise Exception('You forgot to put a release note in CHANGES.rst ?!') if args & {'bdist', 'bdist_dumb', 'bdist_rpm', 'bdist_wininst', 'bdist_msi'}: raise Exception("err doesn't support binary distributions") packages = find_packages(src_root, include=['errbot', 'errbot.*']) setup( name="errbot", version=VERSION, packages=packages, entry_points={ 'console_scripts': [ 'errbot = errbot.cli:main', ] }, install_requires=deps, tests_require=['nose', 'webtest', 'requests'], package_data={ 'errbot': ['backends/*.plug', 'backends/*.html', 'backends/styles/*.css', 'backends/images/*.svg', 'core_plugins/*.plug', 'core_plugins/*.md', 'core_plugins/templates/*.md', 'storage/*.plug', 'templates/initdir/example.py', 'templates/initdir/example.plug', 'templates/initdir/config.py.tmpl', 'templates/*.md', 'templates/new_plugin.py.tmpl', ], }, extras_require={ 'graphic': ['PySide', ], 'hipchat': ['hypchat', 'sleekxmpp', 'pyasn1', 'pyasn1-modules'], 'IRC': ['irc', ], 'slack': ['slackclient>=1.0.5,<2.0', ], 'telegram': ['python-telegram-bot', ], 'XMPP': ['sleekxmpp', 'pyasn1', 'pyasn1-modules'], ':python_version<"3.7"': ['dataclasses'], # backward compatibility for 3.3->3.6 for dataclasses ':sys_platform!="win32"': ['daemonize'], }, author="errbot.io", author_email="info@errbot.io", description="Errbot is a chatbot designed to be simple to extend with plugins written in Python.", long_description=''.join([read('README.rst'), '\n\n', changes]), license="GPL", keywords="xmpp irc slack hipchat gitter tox chatbot bot plugin chatops", url="http://errbot.io/", classifiers=[ "Development Status :: 5 - Production/Stable", "Topic :: Communications :: Chat", "Topic :: Communications :: Chat :: Internet Relay Chat", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", ], src_root=src_root, platforms='any', ) errbot-6.1.1+ds/tests/000077500000000000000000000000001355337103200145625ustar00rootroot00000000000000errbot-6.1.1+ds/tests/__init__.py000066400000000000000000000000751355337103200166750ustar00rootroot00000000000000import logging logging.getLogger('').setLevel(logging.INFO) errbot-6.1.1+ds/tests/assets/000077500000000000000000000000001355337103200160645ustar00rootroot00000000000000errbot-6.1.1+ds/tests/assets/repos/000077500000000000000000000000001355337103200172145ustar00rootroot00000000000000errbot-6.1.1+ds/tests/assets/repos/a.json000066400000000000000000000010611355337103200203250ustar00rootroot00000000000000{ "name1/err-reponame1": {"pluginname1": { "python": "2+", "repo": "https://github.com/name/err-reponame1", "path": "/plugin1.plug", "avatar_url": "https://avatars.githubusercontent.com/u/588833?v=3", "name": "PluginName1", "documentation": "docs1" }}, "name2/err-reponame2": {"pluginname2": { "python": "2+", "repo": "https://github.com/name/err-reponame2", "path": "/plugin2.plug", "avatar_url": "https://avatars.githubusercontent.com/u/588833?v=3", "name": "PluginName2", "documentation": "docs2" }} } errbot-6.1.1+ds/tests/assets/repos/b.json000066400000000000000000000010651355337103200203320ustar00rootroot00000000000000{ "name2/err-reponame2": {"pluginname2": { "python": "2+", "repo": "https://github.com/name/err-reponame2", "path": "/plugin2.plug", "avatar_url": "https://avatars.githubusercontent.com/u/588833?v=3", "name": "NewPluginName2", "documentation": "docs2" }}, "name3/err-reponame3": {"pluginname3": { "python": "2+", "repo": "https://github.com/name/err-reponame1", "path": "/plugin1.plug", "avatar_url": "https://avatars.githubusercontent.com/u/588833?v=3", "name": "PluginName1", "documentation": "docs1" }} } errbot-6.1.1+ds/tests/assets/repos/simple.json000066400000000000000000000010611355337103200213760ustar00rootroot00000000000000{ "name1/err-reponame1": {"pluginname1": { "python": "2+", "repo": "https://github.com/name/err-reponame1", "path": "/plugin1.plug", "avatar_url": "https://avatars.githubusercontent.com/u/588833?v=3", "name": "PluginName1", "documentation": "docs1" }}, "name2/err-reponame2": {"pluginname2": { "python": "2+", "repo": "https://github.com/name/err-reponame2", "path": "/plugin2.plug", "avatar_url": "https://avatars.githubusercontent.com/u/588833?v=3", "name": "PluginName2", "documentation": "docs2" }} } errbot-6.1.1+ds/tests/assets/requirements_already_there.txt000066400000000000000000000000061355337103200242340ustar00rootroot00000000000000errboterrbot-6.1.1+ds/tests/assets/requirements_never_there.txt000066400000000000000000000000261355337103200237340ustar00rootroot00000000000000impossible_requirementerrbot-6.1.1+ds/tests/assets/templates/000077500000000000000000000000001355337103200200625ustar00rootroot00000000000000errbot-6.1.1+ds/tests/assets/templates/args_as_md.md000066400000000000000000000001551355337103200225040ustar00rootroot00000000000000 {% for arg in args -%}{% set tag = loop.cycle('**', '_') -%} {{ tag }}{{ arg }}{{ tag }} {%- endfor %} errbot-6.1.1+ds/tests/backend_manager_test.py000066400000000000000000000007511355337103200212570ustar00rootroot00000000000000import logging import pytest from errbot.core import ErrBot from errbot.bootstrap import CORE_BACKENDS from errbot.backend_plugin_manager import BackendPluginManager logging.basicConfig(level=logging.DEBUG) backends_to_check = ['Text', 'Test', 'Null'] @pytest.mark.parametrize('backend_name', backends_to_check) def test_builtins(backend_name): bpm = BackendPluginManager({}, 'errbot.backends', backend_name, ErrBot, CORE_BACKENDS) assert bpm.plugin_info.name == backend_name errbot-6.1.1+ds/tests/backend_tests/000077500000000000000000000000001355337103200173735ustar00rootroot00000000000000errbot-6.1.1+ds/tests/backend_tests/slack_test.py000066400000000000000000000243321355337103200221050ustar00rootroot00000000000000import sys import unittest import logging import os from tempfile import mkdtemp from mock import MagicMock from errbot.bootstrap import bot_config_defaults log = logging.getLogger(__name__) try: from errbot.backends import slack class TestSlackBackend(slack.SlackBackend): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.test_msgs = [] self.sc = MagicMock() def callback_message(self, msg): self.test_msgs.append(msg) def username_to_userid(self, username, *args, **kwargs): """Have to mock because we don't have a slack server.""" return 'Utest' def channelname_to_channelid(self, channelname): return 'Ctest' def channelid_to_channelname(self, channelid): return 'meh' def get_im_channel(self, id_): return 'Cfoo' def find_user(self, user): m = MagicMock() m.name = user return m except SystemExit: log.exception("Can't import backends.slack for testing") @unittest.skipIf(not slack, "package slackclient not installed") class SlackTests(unittest.TestCase): def setUp(self): # make up a config. tempdir = mkdtemp() # reset the config every time sys.modules.pop('errbot.config-template', None) __import__('errbot.config-template') config = sys.modules['errbot.config-template'] bot_config_defaults(config) config.BOT_DATA_DIR = tempdir config.BOT_LOG_FILE = os.path.join(tempdir, 'log.txt') config.BOT_EXTRA_PLUGIN_DIR = [] config.BOT_LOG_LEVEL = logging.DEBUG config.BOT_IDENTITY = {'username': 'err@localhost', 'token': '___'} config.BOT_ASYNC = False config.BOT_PREFIX = '!' config.CHATROOM_FN = 'blah' self.slack = TestSlackBackend(config) def testBotMessageWithAttachments(self): attachment = { 'title': 'sometitle', 'id': 1, 'fallback': ' *Host:* host-01', 'color': 'daa038', 'fields': [{'title': 'Metric', 'value': '1', 'short': True}], 'title_link': 'https://xx.com' } bot_id = 'B04HMXXXX' bot_msg = { 'channel': 'C0XXXXY6P', 'icons': {'emoji': ':warning:', 'image_64': 'https://xx.com/26a0.png'}, 'ts': '1444416645.000641', 'type': 'message', 'text': '', 'bot_id': bot_id, 'username': 'riemann', 'subtype': 'bot_message', 'attachments': [attachment] } self.slack._dispatch_slack_message(bot_msg) msg = self.slack.test_msgs.pop() self.assertEqual(msg.extras['attachments'], [attachment]) def testSlackEventObjectAddedToExtras(self): bot_id = 'B04HMXXXX' bot_msg = { 'channel': 'C0XXXXY6P', 'icons': {'emoji': ':warning:', 'image_64': 'https://xx.com/26a0.png'}, 'ts': '1444416645.000641', 'type': 'message', 'text': '', 'bot_id': bot_id, 'username': 'riemann', 'subtype': 'bot_message', } self.slack._dispatch_slack_message(bot_msg) msg = self.slack.test_msgs.pop() self.assertEqual(msg.extras['slack_event'], bot_msg) def testPrepareMessageBody(self): test_body = """ hey, this is some code: ``` foobar ``` """ parts = self.slack.prepare_message_body(test_body, 10000) assert parts == [test_body] test_body = """this block is unclosed: ``` foobar """ parts = self.slack.prepare_message_body(test_body, 10000) assert parts == [test_body + "\n```\n"] test_body = """``` foobar """ parts = self.slack.prepare_message_body(test_body, 10000) assert parts == [test_body + "\n```\n"] test_body = """closed ``` foobar ``` not closed ```""" # ---------------------------------^ 21st char parts = self.slack.prepare_message_body(test_body, 21) assert len(parts) == 2 assert parts[0].count('```') == 2 assert parts[0].endswith('```') assert parts[1].count('```') == 2 assert parts[1].endswith('```\n') def test_extract_identifiers(self): extract_from = self.slack.extract_identifiers_from_string self.assertEqual( extract_from("<@U12345>"), (None, "U12345", None, None) ) self.assertEqual( extract_from("<@U12345|UName>"), ("UName", "U12345", None, None) ) self.assertEqual( extract_from("<@B12345>"), (None, "B12345", None, None) ) self.assertEqual( extract_from("<#C12345>"), (None, None, None, "C12345") ) self.assertEqual( extract_from("<#G12345>"), (None, None, None, "G12345") ) self.assertEqual( extract_from("<#D12345>"), (None, None, None, "D12345") ) self.assertEqual( extract_from("@person"), ("person", None, None, None) ) self.assertEqual( extract_from("#general/someuser"), ("someuser", None, "general", None) ) self.assertEqual( extract_from("#general"), (None, None, "general", None) ) with self.assertRaises(ValueError): extract_from("") with self.assertRaises(ValueError): extract_from("general") with self.assertRaises(ValueError): extract_from("<>") with self.assertRaises(ValueError): extract_from("") with self.assertRaises(ValueError): extract_from("<@I12345>") def test_build_identifier(self): build_from = self.slack.build_identifier def check_person(person, expected_uid, expected_cid): return person.userid == expected_uid and person.channelid == expected_cid assert build_from("<#C12345>").name == 'meh' assert check_person(build_from("<@U12345>"), "U12345", "Cfoo") assert check_person(build_from("@user"), "Utest", "Cfoo") assert build_from("#channel").name == 'meh' # the mock always return meh ;) self.assertEqual( build_from("#channel/user"), slack.SlackRoomOccupant(None, "Utest", "Cfoo", self.slack) ) def test_uri_sanitization(self): sanitize = self.slack.sanitize_uris self.assertEqual( sanitize( "The email is ."), "The email is test@example.org." ) self.assertEqual( sanitize( "Pretty URL Testing: with " "more text"), "Pretty URL Testing: example.org with more text" ) self.assertEqual( sanitize("URL "), "URL http://example.org" ) self.assertEqual( sanitize("Normal <text> that shouldn't be affected"), "Normal <text> that shouldn't be affected" ) self.assertEqual( sanitize( "Multiple uris , " " and " ", and " "."), "Multiple uris test@example.org, other@example.org and " "http://www.example.org, https://example.com and subdomain.example.org." ) def test_slack_markdown_link_preprocessor(self): convert = self.slack.md.convert self.assertEqual( "This is .", convert("This is [a link](http://example.com/).") ) self.assertEqual( "This is and .", convert("This is [a link](https://example.com/) and [an email address](mailto:me@comp.org).") ) self.assertEqual( "This is and a manual URL: https://example.com/.", convert("This is [a link](http://example.com/) and a manual URL: https://example.com/.") ) self.assertEqual( "", convert("[This is a link](http://example.com/)") ) self.assertEqual( "This is http://example.com/image.png.", convert("This is ![an image](http://example.com/image.png).") ) self.assertEqual( "This is [some text] then ", convert("This is [some text] then [a link](http://example.com)") ) def test_mention_processing(self): self.slack.sc.server.users.find = MagicMock(side_effect=self.slack.find_user) mentions = self.slack.process_mentions self.assertEqual( mentions( "<@U1><@U2><@U3>"), ( "@U1@U2@U3", [self.slack.build_identifier('<@U1>'), self.slack.build_identifier('<@U2>'), self.slack.build_identifier('<@U3>')]) ) self.assertEqual( mentions( "Is <@U12345>: here?"), ( "Is @U12345: here?", [self.slack.build_identifier('<@U12345>')]) ) self.assertEqual( mentions( "<@U12345> told me about @a and <@U56789> told me about @b"), ( "@U12345 told me about @a and @U56789 told me about @b", [self.slack.build_identifier('<@U12345>'), self.slack.build_identifier('<@U56789>')]) ) self.assertEqual( mentions( "!these!<@UABCDE>!mentions! will !still!<@UFGHIJ>!work!"), ( "!these!@UABCDE!mentions! will !still!@UFGHIJ!work!", [self.slack.build_identifier('<@UABCDE>'), self.slack.build_identifier('<@UFGHIJ>')]) ) errbot-6.1.1+ds/tests/base_backend_test.py000066400000000000000000000772561355337103200205750ustar00rootroot00000000000000# coding=utf-8 import sys import logging from pathlib import Path from tempfile import mkdtemp from os.path import sep import pytest import os # noqa import re # noqa from collections import OrderedDict from queue import Queue, Empty # noqa from errbot.core import ErrBot from errbot.backends.base import Message, Room, Identifier, ONLINE from errbot.backends.test import TestPerson, TestOccupant, TestRoom, ShallowConfig from errbot import botcmd, re_botcmd, arg_botcmd, templating # noqa from errbot.bootstrap import CORE_STORAGE, bot_config_defaults from errbot.plugin_manager import BotPluginManager from errbot.rendering import text from errbot.core_plugins.acls import ACLS from errbot.repo_manager import BotRepoManager from errbot.backend_plugin_manager import BackendPluginManager from errbot.storage.base import StoragePluginBase from errbot.utils import PLUGINS_SUBDIR LONG_TEXT_STRING = "This is a relatively long line of output, but I am repeated multiple times.\n" logging.basicConfig(level=logging.DEBUG) SIMPLE_JSON_PLUGINS_INDEX = """ {"errbotio/err-helloworld": {"HelloWorld": {"path": "/helloWorld.plug", "documentation": "let's say hello !", "avatar_url": "https://avatars.githubusercontent.com/u/15802630?v=3", "name": "HelloWorld", "python": "2+", "repo": "https://github.com/errbotio/err-helloworld" } } } """ class DummyBackend(ErrBot): def change_presence(self, status: str = ONLINE, message: str = '') -> None: pass def prefix_groupchat_reply(self, message: Message, identifier: Identifier): pass def query_room(self, room: str) -> Room: pass def __init__(self, extra_config=None): self.outgoing_message_queue = Queue() if extra_config is None: extra_config = {} # make up a config. tempdir = mkdtemp() # reset the config every time sys.modules.pop('errbot.config-template', None) __import__('errbot.config-template') config = ShallowConfig() config.__dict__.update(sys.modules['errbot.config-template'].__dict__) bot_config_defaults(config) # It injects itself as a plugin. Changed the name to be sure we distinguish it. self.name = 'DummyBackendRealName' config.BOT_DATA_DIR = tempdir config.BOT_LOG_FILE = tempdir + sep + 'log.txt' config.BOT_PLUGIN_INDEXES = tempdir + sep + 'repos.json' config.BOT_EXTRA_PLUGIN_DIR = [] config.BOT_LOG_LEVEL = logging.DEBUG config.BOT_IDENTITY = {'username': 'err@localhost'} config.BOT_ASYNC = False config.BOT_PREFIX = '!' config.CHATROOM_FN = 'blah' # Writeout the made up repos file with open(config.BOT_PLUGIN_INDEXES, "w") as index_file: index_file.write(SIMPLE_JSON_PLUGINS_INDEX) for key in extra_config: setattr(config, key, extra_config[key]) super().__init__(config) self.bot_identifier = self.build_identifier('err') self.md = text() # We just want simple text for testing purposes # setup a memory based storage spm = BackendPluginManager(config, 'errbot.storage', 'Memory', StoragePluginBase, CORE_STORAGE) storage_plugin = spm.load_plugin() # setup the plugin_manager just internally botplugins_dir = os.path.join(config.BOT_DATA_DIR, PLUGINS_SUBDIR) if not os.path.exists(botplugins_dir): os.makedirs(botplugins_dir, mode=0o755) # get it back from where we publish it. repo_index_paths = (os.path.join(os.path.dirname(__file__), '..', 'docs', '_extra', 'repos.json'),) repo_manager = BotRepoManager(storage_plugin, botplugins_dir, repo_index_paths) self.attach_storage_plugin(storage_plugin) self.attach_repo_manager(repo_manager) self.attach_plugin_manager(BotPluginManager(storage_plugin, config.BOT_EXTRA_PLUGIN_DIR, config.AUTOINSTALL_DEPS, getattr(config, 'CORE_PLUGINS', None), lambda name, clazz: clazz(self, name), getattr(config, 'PLUGINS_CALLBACK_ORDER', (None, )))) self.inject_commands_from(self) self.inject_command_filters_from(ACLS(self)) def build_identifier(self, text_representation): return TestPerson(text_representation) def build_reply(self, msg, text=None, private=False, threaded=False): reply = self.build_message(text) reply.frm = self.bot_identifier reply.to = msg.frm if threaded: reply.parent = msg return reply def send_message(self, msg): msg._body = self.md.convert(msg.body) self.outgoing_message_queue.put(msg) def pop_message(self, timeout=3, block=True): return self.outgoing_message_queue.get(timeout=timeout, block=block) @botcmd def command(self, msg, args): return "Regular command" @botcmd(admin_only=True) def admin_command(self, msg, args): return "Admin command" @re_botcmd(pattern=r'^regex command with prefix$', prefixed=True) def regex_command_with_prefix(self, msg, match): return "Regex command" @re_botcmd(pattern=r'^regex command without prefix$', prefixed=False) def regex_command_without_prefix(self, msg, match): return "Regex command" @re_botcmd(pattern=r'regex command with capture group: (?P.*)', prefixed=False) def regex_command_with_capture_group(self, msg, match): return match.group('capture') @re_botcmd(pattern=r'matched by two commands') def double_regex_command_one(self, msg, match): return "one" @re_botcmd(pattern=r'matched by two commands', flags=re.IGNORECASE) def double_regex_command_two(self, msg, match): return "two" @re_botcmd(pattern=r'match_here', matchall=True) def regex_command_with_matchall(self, msg, matches): return len(matches) @botcmd def return_args_as_str(self, msg, args): return "".join(args) @botcmd(template='args_as_md') def return_args_as_md(self, msg, args): return {'args': args} @botcmd def send_args_as_md(self, msg, args): self.send_templated(msg.frm, 'args_as_md', {'args': args}) @botcmd def raises_exception(self, msg, args): raise Exception("Kaboom!") @botcmd def yield_args_as_str(self, msg, args): for arg in args: yield arg @botcmd(template='args_as_md') def yield_args_as_md(self, msg, args): for arg in args: yield {'args': [arg]} @botcmd def yields_str_then_raises_exception(self, msg, args): yield 'foobar' raise Exception('Kaboom!') @botcmd def return_long_output(self, msg, args): return LONG_TEXT_STRING * 3 @botcmd def yield_long_output(self, msg, args): for i in range(2): yield LONG_TEXT_STRING * 3 ## # arg_botcmd test commands ## @arg_botcmd('--first-name', dest='first_name') @arg_botcmd('--last-name', dest='last_name') def yields_first_name_last_name(self, msg, first_name=None, last_name=None): yield "%s %s" % (first_name, last_name) @arg_botcmd('--first-name', dest='first_name') @arg_botcmd('--last-name', dest='last_name') def returns_first_name_last_name(self, msg, first_name=None, last_name=None): return "%s %s" % (first_name, last_name) @arg_botcmd('--first-name', dest='first_name') @arg_botcmd('--last-name', dest='last_name', unpack_args=False) def returns_first_name_last_name_without_unpacking(self, msg, args): return "%s %s" % (args.first_name, args.last_name) @arg_botcmd('value', type=str) @arg_botcmd('--count', dest='count', type=int) def returns_value_repeated_count_times(self, msg, value=None, count=None): # str * int gives a repeated string return value * count @property def mode(self): return "Dummy" @property def rooms(self): return [] @pytest.fixture def dummy_backend(): return DummyBackend() def test_buildreply(dummy_backend): m = dummy_backend.build_message('Content') m.frm = dummy_backend.build_identifier('user') m.to = dummy_backend.build_identifier('somewhere') resp = dummy_backend.build_reply(m, 'Response') assert str(resp.to) == 'user' assert str(resp.frm) == 'err' assert str(resp.body) == 'Response' assert resp.parent is None def test_buildreply_with_parent(dummy_backend): m = dummy_backend.build_message('Content') m.frm = dummy_backend.build_identifier('user') m.to = dummy_backend.build_identifier('somewhere') resp = dummy_backend.build_reply(m, 'Response', threaded=True) assert resp.parent is not None def test_bot_admins_unique_string(): dummy = DummyBackend(extra_config={'BOT_ADMINS': 'err@localhost'}) assert dummy.bot_config.BOT_ADMINS == ('err@localhost',) @pytest.fixture def dummy_execute_and_send(): dummy = DummyBackend() example_message = dummy.build_message('some_message') example_message.frm = dummy.build_identifier('noterr') example_message.to = dummy.build_identifier('err') assets_path = os.path.join(os.path.dirname(__file__), 'assets') templating.template_path.append(str(templating.make_templates_path(Path(assets_path)))) templating.env = templating.Environment(loader=templating.FileSystemLoader(templating.template_path)) return dummy, example_message def test_commands_can_return_string(dummy_execute_and_send): dummy, m = dummy_execute_and_send dummy._execute_and_send(cmd='return_args_as_str', args=['foo', 'bar'], match=None, msg=m, template_name=dummy.return_args_as_str._err_command_template) assert "foobar" == dummy.pop_message().body def test_commands_can_return_md(dummy_execute_and_send): dummy, m = dummy_execute_and_send dummy._execute_and_send(cmd='return_args_as_md', args=['foo', 'bar'], match=None, msg=m, template_name=dummy.return_args_as_md._err_command_template) response = dummy.pop_message() assert "foobar" == response.body def test_commands_can_send_templated(dummy_execute_and_send): dummy, m = dummy_execute_and_send dummy._execute_and_send(cmd='send_args_as_md', args=['foo', 'bar'], match=None, msg=m, template_name=dummy.return_args_as_md._err_command_template) response = dummy.pop_message() assert "foobar" == response.body def test_exception_is_caught_and_shows_error_message(dummy_execute_and_send): dummy, m = dummy_execute_and_send dummy._execute_and_send(cmd='raises_exception', args=[], match=None, msg=m, template_name=dummy.raises_exception._err_command_template) assert dummy.MSG_ERROR_OCCURRED in dummy.pop_message().body dummy._execute_and_send(cmd='yields_str_then_raises_exception', args=[], match=None, msg=m, template_name=dummy.yields_str_then_raises_exception._err_command_template) assert "foobar" == dummy.pop_message().body assert dummy.MSG_ERROR_OCCURRED in dummy.pop_message().body def test_commands_can_yield_strings(dummy_execute_and_send): dummy, m = dummy_execute_and_send dummy._execute_and_send(cmd='yield_args_as_str', args=['foo', 'bar'], match=None, msg=m, template_name=dummy.yield_args_as_str._err_command_template) assert "foo" == dummy.pop_message().body assert "bar" == dummy.pop_message().body def test_commands_can_yield_md(dummy_execute_and_send): dummy, m = dummy_execute_and_send dummy._execute_and_send(cmd='yield_args_as_md', args=['foo', 'bar'], match=None, msg=m, template_name=dummy.yield_args_as_md._err_command_template) assert "foo" == dummy.pop_message().body assert "bar" == dummy.pop_message().body def test_output_longer_than_max_msg_size_is_split_into_multiple_msgs_when_returned(dummy_execute_and_send): dummy, m = dummy_execute_and_send dummy.bot_config.MESSAGE_SIZE_LIMIT = len(LONG_TEXT_STRING) dummy._execute_and_send(cmd='return_long_output', args=['foo', 'bar'], match=None, msg=m, template_name=dummy.return_long_output._err_command_template) for i in range(3): # return_long_output outputs a string that's 3x longer than the size limit assert LONG_TEXT_STRING.strip() == dummy.pop_message().body with pytest.raises(Empty): dummy.pop_message(block=False) def test_output_longer_than_max_msg_size_is_split_into_multiple_msgs_when_yielded(dummy_execute_and_send): dummy, m = dummy_execute_and_send dummy.bot_config.MESSAGE_SIZE_LIMIT = len(LONG_TEXT_STRING) dummy._execute_and_send(cmd='yield_long_output', args=['foo', 'bar'], match=None, msg=m, template_name=dummy.yield_long_output._err_command_template) for i in range(6): # yields_long_output yields 2 strings that are 3x longer than the size limit assert LONG_TEXT_STRING.strip() == dummy.pop_message().body with pytest.raises(Empty): dummy.pop_message(block=False) def makemessage(dummy, message, from_=None, to=None): if not from_: from_ = dummy.build_identifier("noterr") if not to: to = dummy.build_identifier("noterr") m = dummy.build_message(message) m.frm = from_ m.to = to return m def test_inject_skips_methods_without_botcmd_decorator(dummy_backend): assert 'build_message' not in dummy_backend.commands def test_inject_and_remove_botcmd(dummy_backend): assert 'command' in dummy_backend.commands dummy_backend.remove_commands_from(dummy_backend) assert len(dummy_backend.commands) == 0 def test_inject_and_remove_re_botcmd(dummy_backend): assert 'regex_command_with_prefix' in dummy_backend.re_commands dummy_backend.remove_commands_from(dummy_backend) assert len(dummy_backend.re_commands) == 0 def test_callback_message(dummy_backend): dummy_backend.callback_message(makemessage(dummy_backend, "!return_args_as_str one two")) assert "one two" == dummy_backend.pop_message().body def test_callback_message_with_prefix_optional(): dummy = DummyBackend({'BOT_PREFIX_OPTIONAL_ON_CHAT': True}) m = makemessage(dummy, "return_args_as_str one two") dummy.callback_message(m) assert "one two" == dummy.pop_message().body # Groupchat should still require the prefix m.frm = TestOccupant("someone", "room") room = TestRoom("room", bot=dummy) m.to = room dummy.callback_message(m) with pytest.raises(Empty): dummy.pop_message(block=False) m = makemessage(dummy, "!return_args_as_str one two", from_=TestOccupant("someone", "room"), to=room) dummy.callback_message(m) assert "one two" == dummy.pop_message().body def test_callback_message_with_bot_alt_prefixes(): dummy = DummyBackend({'BOT_ALT_PREFIXES': ('Err',), 'BOT_ALT_PREFIX_SEPARATORS': (',', ';')}) dummy.callback_message(makemessage(dummy, "Err return_args_as_str one two")) assert "one two" == dummy.pop_message().body dummy.callback_message(makemessage(dummy, "Err, return_args_as_str one two")) assert "one two" == dummy.pop_message().body def test_callback_message_with_re_botcmd(dummy_backend): dummy_backend.callback_message(makemessage(dummy_backend, "!regex command with prefix")) assert "Regex command" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "regex command without prefix")) assert "Regex command" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "!regex command with capture group: Captured text")) assert "Captured text" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "regex command with capture group: Captured text")) assert "Captured text" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "This command also allows extra text in front - regex " "command with capture group: Captured text")) assert "Captured text" == dummy_backend.pop_message().body def test_callback_message_with_re_botcmd_and_alt_prefixes(): dummy_backend = DummyBackend({'BOT_ALT_PREFIXES': ('Err',), 'BOT_ALT_PREFIX_SEPARATORS': (',', ';')}) dummy_backend.callback_message(makemessage(dummy_backend, "!regex command with prefix")) assert "Regex command" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "Err regex command with prefix")) assert "Regex command" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "Err, regex command with prefix")) assert "Regex command" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "regex command without prefix")) assert "Regex command" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "!regex command with capture group: Captured text")) assert "Captured text" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "regex command with capture group: Captured text")) assert "Captured text" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "This command also allows extra text in front - " "regex command with capture group: Captured text")) assert "Captured text" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "Err, regex command with capture group: Captured text")) assert "Captured text" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "Err This command also allows extra text in front - " "regex command with capture group: Captured text")) assert "Captured text" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "!match_here")) assert "1" == dummy_backend.pop_message().body dummy_backend.callback_message(makemessage(dummy_backend, "!match_here match_here match_here")) assert "3" == dummy_backend.pop_message().body def test_regex_commands_can_overlap(dummy_backend): dummy_backend.callback_message(makemessage(dummy_backend, "!matched by two commands")) response = (dummy_backend.pop_message().body, dummy_backend.pop_message().body) assert response == ("one", "two") or response == ("two", "one") def test_regex_commands_allow_passing_re_flags(dummy_backend): dummy_backend.callback_message(makemessage(dummy_backend, "!MaTcHeD By TwO cOmMaNdS")) assert "two" == dummy_backend.pop_message().body with pytest.raises(Empty): dummy_backend.pop_message(timeout=1) def test_arg_botcmd_returns_first_name_last_name(dummy_backend): dummy_backend.callback_message( makemessage( dummy_backend, "!returns_first_name_last_name --first-name=Err --last-name=Bot" ) ) assert "Err Bot" def test_arg_botcmd_returns_with_escaping(dummy_backend): first_name = 'Err\\"' last_name = 'Bot' dummy_backend.callback_message( makemessage( dummy_backend, "!returns_first_name_last_name --first-name=%s --last-name=%s" % (first_name, last_name) ) ) assert 'Err" Bot' == dummy_backend.pop_message().body def test_arg_botcmd_returns_with_incorrect_escaping(dummy_backend): first_name = 'Err"' last_name = 'Bot' dummy_backend.callback_message( makemessage( dummy_backend, "!returns_first_name_last_name --first-name=%s --last-name=%s" % (first_name, last_name) ) ) assert 'I couldn\'t parse this command; No closing quotation' in dummy_backend.pop_message().body def test_arg_botcmd_yields_first_name_last_name(dummy_backend): dummy_backend.callback_message( makemessage( dummy_backend, "!yields_first_name_last_name --first-name=Err --last-name=Bot" ) ) assert "Err Bot" == dummy_backend.pop_message().body def test_arg_botcmd_returns_value_repeated_count_times(dummy_backend): dummy_backend.callback_message( makemessage(dummy_backend, "!returns_value_repeated_count_times Foo --count 5") ) assert "FooFooFooFooFoo" == dummy_backend.pop_message().body def test_arg_botcmd_doesnt_raise_systemerror(dummy_backend): dummy_backend.callback_message(makemessage(dummy_backend, "!returns_first_name_last_name --invalid-parameter")) def test_arg_botcdm_returns_errors_as_chat(dummy_backend): dummy_backend.callback_message(makemessage(dummy_backend, "!returns_first_name_last_name --invalid-parameter")) assert "I couldn't parse the arguments; unrecognized arguments: --invalid-parameter" \ in dummy_backend.pop_message().body def test_arg_botcmd_returns_help_message_as_chat(dummy_backend): dummy_backend.callback_message(makemessage(dummy_backend, "!returns_first_name_last_name --help")) assert "usage: returns_first_name_last_name [-h] [--last-name LAST_NAME]" in dummy_backend.pop_message().body def test_arg_botcmd_undoes_fancy_unicode_dash_conversion(dummy_backend): dummy_backend.callback_message( makemessage( dummy_backend, "!returns_first_name_last_name —first-name=Err —last-name=Bot" ) ) assert "Err Bot" == dummy_backend.pop_message().body def test_arg_botcmd_without_argument_unpacking(dummy_backend): dummy_backend.callback_message( makemessage(dummy_backend, "!returns_first_name_last_name_without_unpacking --first-name=Err --last-name=Bot") ) assert "Err Bot" == dummy_backend.pop_message().body def test_access_controls(dummy_backend): testroom = TestRoom("room", bot=dummy_backend) tests = [ # BOT_ADMINS scenarios dict( message=makemessage(dummy_backend, "!admin_command"), bot_admins=('noterr',), expected_response="Admin command", ), dict( message=makemessage(dummy_backend, "!admin_command"), bot_admins=(), expected_response="This command requires bot-admin privileges", ), dict( message=makemessage(dummy_backend, "!admin_command"), bot_admins=('*err',), expected_response="Admin command", ), # admin_only commands SHOULD be private-message only by default dict( message=makemessage(dummy_backend, "!admin_command", from_=TestOccupant('noterr', room=testroom), to=testroom), bot_admins=('noterr',), expected_response="This command may only be issued through a direct message", ), # But MAY be sent via groupchat IF 'allowmuc' is specifically set to True. dict( message=makemessage(dummy_backend, "!admin_command", from_=TestOccupant('noterr', room=testroom), to=testroom), bot_admins=('noterr',), acl={'admin_command': {'allowmuc': True}}, expected_response="Admin command", ), # ACCESS_CONTROLS scenarios WITHOUT wildcards (<4.0 format) dict( message=makemessage(dummy_backend, "!command"), expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!regex command with prefix"), expected_response="Regex command" ), dict( message=makemessage(dummy_backend, "!command"), acl_default={'allowmuc': False, 'allowprivate': False}, expected_response="You're not allowed to access this command via private message to me" ), dict( message=makemessage(dummy_backend, "regex command without prefix"), acl_default={'allowmuc': False, 'allowprivate': False}, expected_response="You're not allowed to access this command via private message to me" ), dict( message=makemessage(dummy_backend, "!command"), acl_default={'allowmuc': True, 'allowprivate': False}, expected_response="You're not allowed to access this command via private message to me" ), dict( message=makemessage(dummy_backend, "!command"), acl_default={'allowmuc': False, 'allowprivate': True}, expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'allowprivate': False}}, acl_default={'allowmuc': False, 'allowprivate': True}, expected_response="You're not allowed to access this command via private message to me" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'allowmuc': True}}, acl_default={'allowmuc': True, 'allowprivate': False}, expected_response="You're not allowed to access this command via private message to me" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'allowprivate': True}}, acl_default={'allowmuc': False, 'allowprivate': False}, expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command", from_=TestOccupant("someone", "room"), to=TestRoom("room", bot=dummy_backend)), acl={'command': {'allowrooms': ('room',)}}, expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command", from_=TestOccupant("someone", "room_1"), to=TestRoom("room1", bot=dummy_backend)), acl={'command': {'allowrooms': ('room_*',)}}, expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command", from_=TestOccupant("someone", "room"), to=TestRoom("room", bot=dummy_backend)), acl={'command': {'allowrooms': ('anotherroom@localhost',)}}, expected_response="You're not allowed to access this command from this room", ), dict( message=makemessage(dummy_backend, "!command", from_=TestOccupant("someone", "room"), to=TestRoom("room", bot=dummy_backend)), acl={'command': {'denyrooms': ('room',)}}, expected_response="You're not allowed to access this command from this room", ), dict( message=makemessage(dummy_backend, "!command", from_=TestOccupant("someone", "room"), to=TestRoom("room", bot=dummy_backend)), acl={'command': {'denyrooms': ('*',)}}, expected_response="You're not allowed to access this command from this room", ), dict( message=makemessage(dummy_backend, "!command", from_=TestOccupant("someone", "room"), to=TestRoom("room", bot=dummy_backend)), acl={'command': {'denyrooms': ('anotherroom',)}}, expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'allowusers': ('noterr',)}}, expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'allowusers': 'noterr'}}, # simple string instead of tuple expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'allowusers': ('err',)}}, expected_response="You're not allowed to access this command from this user", ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'allowusers': ('*err',)}}, expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'denyusers': ('err',)}}, expected_response="Regular command" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'denyusers': ('noterr',)}}, expected_response="You're not allowed to access this command from this user" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'denyusers': 'noterr'}}, # simple string instead of tuple expected_response="You're not allowed to access this command from this user" ), dict( message=makemessage(dummy_backend, "!command"), acl={'command': {'denyusers': ('*err',)}}, expected_response="You're not allowed to access this command from this user" ), # ACCESS_CONTROLS scenarios WITH wildcards (>=4.0 format) dict( message=makemessage(dummy_backend, "!command"), acl={'DummyBackendRealName:command': {'denyusers': ('noterr',)}}, expected_response="You're not allowed to access this command from this user" ), dict( message=makemessage(dummy_backend, "!command"), acl={'*:command': {'denyusers': ('noterr',)}}, expected_response="You're not allowed to access this command from this user" ), dict( message=makemessage(dummy_backend, "!command"), acl={'DummyBackendRealName:*': {'denyusers': ('noterr',)}}, expected_response="You're not allowed to access this command from this user" ), # Overlapping globs should use first match dict( message=makemessage(dummy_backend, "!command"), acl=OrderedDict([ ('DummyBackendRealName:*', {'denyusers': ('noterr',)}), ('DummyBackendRealName:command', {'denyusers': ()}) ]), expected_response="You're not allowed to access this command from this user" ), # ACCESS_CONTROLS with numeric username as in telegram dict( message=makemessage(dummy_backend, "!command", from_=dummy_backend.build_identifier(1234)), acl={'command': {'allowusers': (1234, )}}, expected_response="Regular command" ), ] for test in tests: dummy_backend.bot_config.ACCESS_CONTROLS_DEFAULT = test.get('acl_default', {}) dummy_backend.bot_config.ACCESS_CONTROLS = test.get('acl', {}) dummy_backend.bot_config.BOT_ADMINS = test.get('bot_admins', ()) logger = logging.getLogger(__name__) logger.info("** message: {}".format(test['message'].body)) logger.info("** bot_admins: {}".format(dummy_backend.bot_config.BOT_ADMINS)) logger.info("** acl: {!r}".format(dummy_backend.bot_config.ACCESS_CONTROLS)) logger.info("** acl_default: {!r}".format(dummy_backend.bot_config.ACCESS_CONTROLS_DEFAULT)) dummy_backend.callback_message(test['message']) assert test['expected_response'] == dummy_backend.pop_message().body errbot-6.1.1+ds/tests/borken_plugin/000077500000000000000000000000001355337103200174205ustar00rootroot00000000000000errbot-6.1.1+ds/tests/borken_plugin/broken.plug000066400000000000000000000002051355337103200215660ustar00rootroot00000000000000[Core] Name = Broken Module = broken [Documentation] Description = This is totally broken, don't use it. [Python] Version = 2+ errbot-6.1.1+ds/tests/borken_plugin/broken.py000066400000000000000000000003261355337103200212530ustar00rootroot00000000000000from errbot import BotPlugin, botcmd import borken # fails on purpose class Broken(BotPlugin): @botcmd def hello(self, msg, args): """ this command says hello """ return 'Hello World !' errbot-6.1.1+ds/tests/commandnotfound_plugin/000077500000000000000000000000001355337103200213335ustar00rootroot00000000000000errbot-6.1.1+ds/tests/commandnotfound_plugin/commandnotfound.plug000066400000000000000000000001011355337103200254070ustar00rootroot00000000000000[Core] Name = TestCommandNotFoundFilter Module = commandnotfound errbot-6.1.1+ds/tests/commandnotfound_plugin/commandnotfound.py000066400000000000000000000004711355337103200251020ustar00rootroot00000000000000from errbot import BotPlugin, cmdfilter class TestCommandNotFoundFilter(BotPlugin): @cmdfilter(catch_unprocessed=True) def command_not_found(self, msg, cmd, args, dry_run, emptycmd=False): if not emptycmd: return msg, cmd, args return "Command fell through: {}".format(msg) errbot-6.1.1+ds/tests/commands_test.py000066400000000000000000000337561355337103200200120ustar00rootroot00000000000000# coding=utf-8 import os import re import logging from os import path, mkdir from queue import Empty from shutil import rmtree from tempfile import mkdtemp from mock import MagicMock import pytest import tarfile extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'dummy_plugin') def test_root_help(testbot): assert 'All commands' in testbot.exec_command('!help') def test_help(testbot): assert '!about' in testbot.exec_command('!help Help') assert 'That command is not defined.' in testbot.exec_command('!help beurk') # Ensure that help reports on re_commands. assert 'runs foo' in testbot.exec_command('!help foo') # Part of Dummy assert 'runs re_foo' in testbot.exec_command('!help re_foo') # Part of Dummy assert 'runs re_foo' in testbot.exec_command('!help re foo') # Part of Dummy def test_about(testbot): assert 'Errbot version' in testbot.exec_command('!about') def test_uptime(testbot): assert 'I\'ve been up for' in testbot.exec_command('!uptime') def test_status(testbot): assert 'Yes I am alive' in testbot.exec_command('!status') def test_status_plugins(testbot): assert 'A = Activated, D = Deactivated' in testbot.exec_command('!status plugins') def test_status_load(testbot): assert 'Load ' in testbot.exec_command('!status load') def test_whoami(testbot): assert 'person' in testbot.exec_command('!whoami') assert 'gbin@localhost' in testbot.exec_command('!whoami') def test_echo(testbot): assert 'foo' in testbot.exec_command('!echo foo') def test_status_gc(testbot): assert 'GC 0->' in testbot.exec_command('!status gc') def test_config_cycle(testbot): testbot.push_message('!plugin config Webserver') m = testbot.pop_message() assert 'Default configuration for this plugin (you can copy and paste this directly as a command)' in m assert 'Current configuration' not in m testbot.assertCommand("!plugin config Webserver {'HOST': 'localhost', 'PORT': 3141, 'SSL': None}", 'Plugin configuration done.') assert 'Current configuration' in testbot.exec_command('!plugin config Webserver') assert 'localhost' in testbot.exec_command('!plugin config Webserver') def test_apropos(testbot): assert '!about: Return information about' in testbot.exec_command('!apropos about') def test_logtail(testbot): assert 'DEBUG' in testbot.exec_command('!log tail') def test_history(testbot): assert 'up' in testbot.exec_command('!uptime') assert 'uptime' in testbot.exec_command('!history') orig_sender = testbot.bot.sender # Pretend to be someone else. History should be empty testbot.bot.sender = testbot.bot.build_identifier('non_default_person') testbot.push_message('!history') with pytest.raises(Empty): testbot.pop_message(timeout=1) assert 'should be a separate history' in testbot.exec_command('!echo should be a separate history') assert 'should be a separate history' in testbot.exec_command('!history') testbot.bot.sender = orig_sender # Pretend to be the original person again. History should still contain uptime assert 'uptime' in testbot.exec_command('!history') def test_plugin_cycle(testbot): plugins = [ 'errbotio/err-helloworld', ] for plugin in plugins: testbot.assertCommand( '!repos install {0}'.format(plugin), 'Installing {0}...'.format(plugin) ), assert 'A new plugin repository has been installed correctly from errbotio/err-helloworld' in \ testbot.pop_message(timeout=60) assert 'Plugins reloaded' in testbot.pop_message() assert 'this command says hello' in testbot.exec_command('!help hello') assert 'Hello World !' in testbot.exec_command('!hello') testbot.push_message('!plugin reload HelloWorld') assert 'Plugin HelloWorld reloaded.' == testbot.pop_message() testbot.push_message('!hello') # should still respond assert 'Hello World !' == testbot.pop_message() testbot.push_message('!plugin blacklist HelloWorld') assert 'Plugin HelloWorld is now blacklisted.' == testbot.pop_message() testbot.push_message('!plugin deactivate HelloWorld') assert 'HelloWorld is already deactivated.' == testbot.pop_message() testbot.push_message('!hello') # should not respond assert 'Command "hello" not found' in testbot.pop_message() testbot.push_message('!plugin unblacklist HelloWorld') assert 'Plugin HelloWorld removed from blacklist.' == testbot.pop_message() testbot.push_message('!plugin activate HelloWorld') assert 'HelloWorld is already activated.' == testbot.pop_message() testbot.push_message('!hello') # should respond back assert 'Hello World !' == testbot.pop_message() testbot.push_message('!repos uninstall errbotio/err-helloworld') assert 'Repo errbotio/err-helloworld removed.' == testbot.pop_message() testbot.push_message('!hello') # should not respond assert 'Command "hello" not found' in testbot.pop_message() def test_broken_plugin(testbot): borken_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'borken_plugin') try: tempd = mkdtemp() tgz = os.path.join(tempd, "borken.tar.gz") with tarfile.open(tgz, "w:gz") as tar: tar.add(borken_plugin_dir, arcname='borken') assert 'Installing' in testbot.exec_command('!repos install file://' + tgz, timeout=120) assert 'import borken # fails' in testbot.pop_message() assert 'as it did not load correctly.' in testbot.pop_message() assert 'Plugins reloaded.' in testbot.pop_message() finally: rmtree(tempd) def test_backup(testbot): bot = testbot.bot # used while restoring bot.push_message('!repos install https://github.com/errbotio/err-helloworld.git') assert 'Installing' in testbot.pop_message() assert 'err-helloworld' in testbot.pop_message(timeout=60) assert 'reload' in testbot.pop_message() bot.push_message('!backup') msg = testbot.pop_message() assert 'has been written in' in msg filename = re.search(r'"(.*)"', msg).group(1) # At least the backup should mention the installed plugin assert 'errbotio/err-helloworld' in open(filename).read() # Now try to clean the bot and restore for p in testbot.bot.plugin_manager.get_all_active_plugins(): p.close_storage() assert 'Plugin HelloWorld deactivated.' in testbot.exec_command('!plugin deactivate HelloWorld') plugins_dir = path.join(testbot.bot_config.BOT_DATA_DIR, 'plugins') bot.repo_manager['installed_repos'] = {} bot.plugin_manager['configs'] = {} rmtree(plugins_dir) mkdir(plugins_dir) from errbot.bootstrap import restore_bot_from_backup log = logging.getLogger(__name__) # noqa restore_bot_from_backup(filename, bot=bot, log=log) assert 'Plugin HelloWorld activated.' in testbot.exec_command('!plugin activate HelloWorld') assert 'Hello World !' in testbot.exec_command('!hello') testbot.push_message('!repos uninstall errbotio/err-helloworld') def test_encoding_preservation(testbot): testbot.push_message('!echo へようこそ') assert 'へようこそ' == testbot.pop_message() def test_webserver_webhook_test(testbot): testbot.push_message("!plugin config Webserver {'HOST': 'localhost', 'PORT': 3141, 'SSL': None}") assert 'Plugin configuration done.' in testbot.pop_message() testbot.assertCommand("!webhook test /echo toto", 'Status code : 200') def test_activate_reload_and_deactivate(testbot): for command in ('activate', 'reload', 'deactivate'): testbot.push_message("!plugin {}".format(command)) m = testbot.pop_message() assert 'Please tell me which of the following plugins to' in m assert 'ChatRoom' in m testbot.push_message(f'!plugin {command} nosuchplugin') m = testbot.pop_message() assert "nosuchplugin isn't a valid plugin name. The current plugins are" in m assert 'ChatRoom' in m testbot.push_message('!plugin reload ChatRoom') assert 'Plugin ChatRoom reloaded.' == testbot.pop_message() testbot.push_message('!status plugins') assert 'A │ ChatRoom' in testbot.pop_message() testbot.push_message('!plugin deactivate ChatRoom') assert 'Plugin ChatRoom deactivated.' == testbot.pop_message() testbot.push_message("!status plugins") assert 'D │ ChatRoom' in testbot.pop_message() testbot.push_message('!plugin deactivate ChatRoom') assert 'ChatRoom is already deactivated.' in testbot.pop_message() testbot.push_message('!plugin activate ChatRoom') assert 'Plugin ChatRoom activated.' in testbot.pop_message() testbot.push_message('!status plugins') assert 'A │ ChatRoom' in testbot.pop_message() testbot.push_message('!plugin activate ChatRoom') assert 'ChatRoom is already activated.' == testbot.pop_message() testbot.push_message('!plugin deactivate ChatRoom') assert 'Plugin ChatRoom deactivated.' == testbot.pop_message() testbot.push_message('!plugin reload ChatRoom') assert 'Warning: plugin ChatRoom is currently not activated. Use !plugin activate ChatRoom to activate it.' == \ testbot.pop_message() assert 'Plugin ChatRoom reloaded.' == testbot.pop_message() testbot.push_message('!plugin blacklist ChatRoom') assert 'Plugin ChatRoom is now blacklisted.' == testbot.pop_message() testbot.push_message('!status plugins') assert 'B,D │ ChatRoom' in testbot.pop_message() # Needed else configuration for this plugin gets saved which screws up # other tests testbot.push_message('!plugin unblacklist ChatRoom') testbot.pop_message() def test_unblacklist_and_blacklist(testbot): testbot.push_message('!plugin unblacklist nosuchplugin') m = testbot.pop_message() assert "nosuchplugin isn't a valid plugin name. The current plugins are" in m assert 'ChatRoom' in m testbot.push_message('!plugin blacklist nosuchplugin') m = testbot.pop_message() assert "nosuchplugin isn't a valid plugin name. The current plugins are" in m assert 'ChatRoom' in m testbot.push_message('!plugin blacklist ChatRoom') assert 'Plugin ChatRoom is now blacklisted' in testbot.pop_message() testbot.push_message('!plugin blacklist ChatRoom') assert 'Plugin ChatRoom is already blacklisted.' == testbot.pop_message() testbot.push_message('!status plugins') assert 'B,D │ ChatRoom' in testbot.pop_message() testbot.push_message('!plugin unblacklist ChatRoom') assert 'Plugin ChatRoom removed from blacklist.' == testbot.pop_message() testbot.push_message('!plugin unblacklist ChatRoom') assert 'Plugin ChatRoom is not blacklisted.' == testbot.pop_message() testbot.push_message('!status plugins') assert 'A │ ChatRoom' in testbot.pop_message() def test_optional_prefix(testbot): testbot.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT = False assert 'Yes I am alive' in testbot.exec_command('!status') testbot.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT = True assert 'Yes I am alive' in testbot.exec_command('!status') assert 'Yes I am alive' in testbot.exec_command('status') def test_optional_prefix_re_cmd(testbot): testbot.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT = False assert 'bar' in testbot.exec_command('!plz dont match this') testbot.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT = True assert 'bar' in testbot.exec_command('!plz dont match this') assert 'bar' in testbot.exec_command('plz dont match this') def test_simple_match(testbot): assert 'bar' in testbot.exec_command('match this') def test_no_suggest_on_re_commands(testbot): testbot.push_message('!re_ba') # Don't suggest a regexp command. assert '!re bar' not in testbot.pop_message() def test_callback_no_command(testbot): extra_plugin_dir = os.path.join( os.path.dirname(os.path.realpath(__file__)), 'commandnotfound_plugin' ) cmd = '!this_is_not_a_real_command_at_all' expected_str = "Command fell through: {}".format(cmd) testbot.exec_command('!plugin deactivate CommandNotFoundFilter') testbot.bot.plugin_manager._extra_plugin_dir = extra_plugin_dir testbot.bot.plugin_manager.update_plugin_places([]) testbot.exec_command('!plugin activate TestCommandNotFoundFilter') assert expected_str == testbot.exec_command(cmd) def test_subcommands(testbot): # test single subcommand (method is run_subcommands()) cmd = '!run subcommands with these args' cmd_underscore = '!run_subcommands with these args' expected_args = 'with these args' assert expected_args == testbot.exec_command(cmd) assert expected_args == testbot.exec_command(cmd_underscore) # test multiple subcmomands (method is run_lots_of_subcommands()) cmd = '!run lots of subcommands with these args' cmd_underscore = '!run_lots_of_subcommands with these args' assert expected_args == testbot.exec_command(cmd) assert expected_args == testbot.exec_command(cmd_underscore) def test_command_not_found_with_space_in_bot_prefix(testbot): testbot.bot_config.BOT_PREFIX = '! ' assert 'Command "blah" not found.' in testbot.exec_command('! blah') assert 'Command "blah" / "blah toto" not found.' in testbot.exec_command('! blah toto') def test_mock_injection(testbot): helper_mock = MagicMock() helper_mock.return_value = 'foo' mock_dict = {'helper_method': helper_mock} testbot.inject_mocks('Dummy', mock_dict) assert 'foo' in testbot.exec_command('!baz') def test_multiline_command(testbot): testbot.assertCommand( ''' !bar title first line of body second line of body ''', '!bar title\nfirst line of body\nsecond line of body', dedent=True ) def test_plugin_info_command(testbot): output = testbot.exec_command('!plugin info Help') assert 'name: Help' in output assert 'module: help' in output assert 'help.py' in output assert 'log level: NOTSET' in output errbot-6.1.1+ds/tests/config-travisci.py000066400000000000000000000010371355337103200202240ustar00rootroot00000000000000# config for travisci # Don't use this for sensible defaults import logging BOT_DATA_DIR = '/tmp' BOT_EXTRA_PLUGIN_DIR = None AUTOINSTALL_DEPS = True BOT_LOG_FILE = '/tmp/err.log' BOT_LOG_LEVEL = logging.DEBUG BOT_LOG_SENTRY = False SENTRY_DSN = '' SENTRY_LOGLEVEL = BOT_LOG_LEVEL BOT_ASYNC = True BOT_IDENTITY = { 'username': 'err@localhost', 'password': 'changeme', } BOT_ADMINS = ('gbin@localhost',) CHATROOM_PRESENCE = () CHATROOM_FN = 'Err' BOT_PREFIX = '!' DIVERT_TO_PRIVATE = () CHATROOM_RELAY = {} REVERSE_CHATROOM_RELAY = {} errbot-6.1.1+ds/tests/config_plugin/000077500000000000000000000000001355337103200174055ustar00rootroot00000000000000errbot-6.1.1+ds/tests/config_plugin/config.plug000066400000000000000000000000741355337103200215440ustar00rootroot00000000000000[Core] Name = Config Module = config [Python] Version = 2+ errbot-6.1.1+ds/tests/config_plugin/config.py000066400000000000000000000002771355337103200212320ustar00rootroot00000000000000from errbot import BotPlugin class Config(BotPlugin): """ Just a plugin with a simple string config. """ def get_configuration_template(self): return {'One': 'one'} errbot-6.1.1+ds/tests/core_plugins_test.py000066400000000000000000000014571355337103200206730ustar00rootroot00000000000000import os extra_plugin_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'room_plugin') extra_config = {'CORE_PLUGINS': ('Help', 'Utils', 'CommandNotFoundFilter'), 'BOT_ALT_PREFIXES': ('!',), 'BOT_PREFIX': '$'} def test_help_is_still_here(testbot): assert 'All commands' in testbot.exec_command('!help') def test_backup_help_not_here(testbot): assert 'That command is not defined.' in testbot.exec_command('!help backup') def test_backup_should_not_be_there(testbot): assert 'Command "backup" not found.' in testbot.exec_command('!backup') def test_echo_still_here(testbot): assert 'toto' in testbot.exec_command('!echo toto') def test_bot_prefix_replaced(testbot): assert '$help - Returns a help string' in testbot.exec_command('$help') errbot-6.1.1+ds/tests/core_test.py000077500000000000000000000007761355337103200171400ustar00rootroot00000000000000"""Test _admins_to_notify wrapper functionality""" import pytest extra_config = {'BOT_ADMINS_NOTIFICATIONS': 'zoni@localdomain'} def test_admins_to_notify(testbot): """Test which admins will be notified""" notified_admins = testbot._bot._admins_to_notify() assert 'zoni@localdomain' in notified_admins def test_admins_not_notified(testbot): """Test which admins will not be notified""" notified_admins = testbot._bot._admins_to_notify() assert 'gbin@local' not in notified_admins errbot-6.1.1+ds/tests/dependencies_test.py000066400000000000000000000033321355337103200206220ustar00rootroot00000000000000import os extra_plugin_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'dependent_plugins') def test_if_all_loaded_by_default(testbot): plug_names = testbot.bot.plugin_manager.get_all_active_plugin_names() assert 'Single' in plug_names assert 'Parent1' in plug_names assert 'Parent2' in plug_names def test_single_dependency(testbot): pm = testbot.bot.plugin_manager for p in ('Single', 'Parent1', 'Parent2'): pm.deactivate_plugin(p) # everything should be gone plug_names = pm.get_all_active_plugin_names() assert 'Single' not in plug_names assert 'Parent1' not in plug_names assert 'Parent2' not in plug_names pm.activate_plugin('Single') # it should have activated the dependent plugin 'Parent1' only plug_names = pm.get_all_active_plugin_names() assert 'Single' in plug_names assert 'Parent1' in plug_names assert 'Parent2' not in plug_names def test_double_dependency(testbot): pm = testbot.bot.plugin_manager all = ('Double', 'Parent1', 'Parent2') for p in all: pm.deactivate_plugin(p) pm.activate_plugin('Double') plug_names = pm.get_all_active_plugin_names() for p in all: assert p in plug_names def test_dependency_retrieval(testbot): assert 'youpi' in testbot.exec_command('!depfunc') def test_direct_circular_dependency(testbot): plug_names = testbot.bot.plugin_manager.get_all_active_plugin_names() assert 'Circular1' not in plug_names def test_indirect_circular_dependency(testbot): plug_names = testbot.bot.plugin_manager.get_all_active_plugin_names() assert 'Circular2' not in plug_names assert 'Circular3' not in plug_names assert 'Circular4' not in plug_names errbot-6.1.1+ds/tests/dependent_plugins/000077500000000000000000000000001355337103200202715ustar00rootroot00000000000000errbot-6.1.1+ds/tests/dependent_plugins/circular1.plug000066400000000000000000000001001355337103200230360ustar00rootroot00000000000000[Core] Name = Circular1 Module = circular1 DependsOn = Circular1errbot-6.1.1+ds/tests/dependent_plugins/circular1.py000066400000000000000000000001041355337103200225230ustar00rootroot00000000000000from errbot import BotPlugin class Circular1(BotPlugin): pass errbot-6.1.1+ds/tests/dependent_plugins/circular2.plug000066400000000000000000000001001355337103200230370ustar00rootroot00000000000000[Core] Name = Circular2 Module = circular2 DependsOn = Circular3errbot-6.1.1+ds/tests/dependent_plugins/circular2.py000066400000000000000000000001041355337103200225240ustar00rootroot00000000000000from errbot import BotPlugin class Circular2(BotPlugin): pass errbot-6.1.1+ds/tests/dependent_plugins/circular3.plug000066400000000000000000000001001355337103200230400ustar00rootroot00000000000000[Core] Name = Circular3 Module = circular3 DependsOn = Circular4errbot-6.1.1+ds/tests/dependent_plugins/circular3.py000066400000000000000000000001041355337103200225250ustar00rootroot00000000000000from errbot import BotPlugin class Circular3(BotPlugin): pass errbot-6.1.1+ds/tests/dependent_plugins/circular4.plug000066400000000000000000000001001355337103200230410ustar00rootroot00000000000000[Core] Name = Circular4 Module = circular4 DependsOn = Circular2errbot-6.1.1+ds/tests/dependent_plugins/circular4.py000066400000000000000000000001041355337103200225260ustar00rootroot00000000000000from errbot import BotPlugin class Circular4(BotPlugin): pass errbot-6.1.1+ds/tests/dependent_plugins/double.plug000066400000000000000000000001011355337103200224240ustar00rootroot00000000000000[Core] Name = Double Module = double DependsOn = Parent1, Parent2errbot-6.1.1+ds/tests/dependent_plugins/double.py000066400000000000000000000001011355337103200221050ustar00rootroot00000000000000from errbot import BotPlugin class Double(BotPlugin): pass errbot-6.1.1+ds/tests/dependent_plugins/parent1.plug000066400000000000000000000000461355337103200225340ustar00rootroot00000000000000[Core] Name = Parent1 Module = parent1errbot-6.1.1+ds/tests/dependent_plugins/parent1.py000066400000000000000000000001571355337103200222200ustar00rootroot00000000000000from errbot import BotPlugin class Parent1(BotPlugin): def shared_function(self): return 'youpi' errbot-6.1.1+ds/tests/dependent_plugins/parent2.plug000066400000000000000000000000461355337103200225350ustar00rootroot00000000000000[Core] Name = Parent2 Module = parent2errbot-6.1.1+ds/tests/dependent_plugins/parent2.py000066400000000000000000000001021355337103200222070ustar00rootroot00000000000000from errbot import BotPlugin class Parent2(BotPlugin): pass errbot-6.1.1+ds/tests/dependent_plugins/single.plug000066400000000000000000000000701355337103200224400ustar00rootroot00000000000000[Core] Name = Single Module = single DependsOn = Parent1errbot-6.1.1+ds/tests/dependent_plugins/single.py000066400000000000000000000002531355337103200221240ustar00rootroot00000000000000from errbot import BotPlugin, botcmd class Single(BotPlugin): @botcmd def depfunc(self, msg, args): return self.get_plugin('Parent1').shared_function() errbot-6.1.1+ds/tests/dummy_plugin/000077500000000000000000000000001355337103200172735ustar00rootroot00000000000000errbot-6.1.1+ds/tests/dummy_plugin/dummy.plug000066400000000000000000000000721355337103200213160ustar00rootroot00000000000000[Core] Name = Dummy Module = dummy [Python] Version = 2+ errbot-6.1.1+ds/tests/dummy_plugin/dummy.py000066400000000000000000000020221355337103200207740ustar00rootroot00000000000000from __future__ import absolute_import from errbot import BotPlugin, botcmd, re_botcmd, botmatch class DummyTest(BotPlugin): """Just a test plugin to see if it is picked up. """ @botcmd def foo(self, msg, args): """This runs foo.""" return 'bar' @re_botcmd(pattern=r"plz dont match this") def re_foo(self, msg, match): """This runs re_foo.""" return 'bar' @botmatch(r"match this") def re_bar(self, msg, match): """This runs re_foo.""" return 'bar' @botcmd def run_subcommands(self, msg, args): """Tests a simple subcommand""" return args @botcmd def run_lots_of_subcommands(self, msg, args): """Tests multiple subcommands""" return args def helper_method(self, arg): return arg @botcmd def baz(self, msg, args): """Tests mock injection method""" return self.helper_method('baz') @botcmd def bar(self, msg, args): """This runs bar.""" return msg errbot-6.1.1+ds/tests/dyna_plugin/000077500000000000000000000000001355337103200170735ustar00rootroot00000000000000errbot-6.1.1+ds/tests/dyna_plugin/dyna.plug000066400000000000000000000000701355337103200207140ustar00rootroot00000000000000[Core] Name = Dyna Module = dyna [Python] Version = 2+ errbot-6.1.1+ds/tests/dyna_plugin/dyna.py000066400000000000000000000047471355337103200204140ustar00rootroot00000000000000from __future__ import absolute_import from errbot import BotPlugin, botcmd, Command, botmatch, arg_botcmd def say_foo(plugin, msg, args): return 'foo %s' % type(plugin) class Dyna(BotPlugin): """Just a test plugin to see if dynamic plugin API works. """ @botcmd def add_simple(self, _, _1): simple1 = Command(lambda plugin, msg, args: 'yep %s' % type(plugin), name='say_yep') simple2 = Command(say_foo) self.create_dynamic_plugin('simple with special#', (simple1, simple2), doc='documented') return 'added' @botcmd def remove_simple(self, msg, args): self.destroy_dynamic_plugin('simple with special#') return 'removed' @botcmd def add_arg(self, _, _1): cmd1_name = 'echo_to_me' cmd1 = Command(lambda plugin, msg, args: 'string to echo is %s' % args.positional_arg, cmd_type=arg_botcmd, cmd_args=('positional_arg',), cmd_kwargs={'unpack_args': False, 'name': cmd1_name}, name=cmd1_name) self.create_dynamic_plugin('arg', (cmd1,), doc='documented') return 'added' @botcmd def remove_arg(self, msg, args): self.destroy_dynamic_plugin('arg') return 'removed' @botcmd def add_re(self, _, _1): re1 = Command(lambda plugin, msg, match: 'fffound', name='ffound', cmd_type=botmatch, cmd_args=(r'^.*cheese.*$',)) self.create_dynamic_plugin('re', (re1, )) return 'added' @botcmd def remove_re(self, msg, args): self.destroy_dynamic_plugin('re') return 'removed' @botcmd def add_saw(self, _, _1): re1 = Command(lambda plugin, msg, args: '+'.join(args), name='splitme', cmd_type=botcmd, cmd_kwargs={'split_args_with': ','}) self.create_dynamic_plugin('saw', (re1, )) return 'added' @botcmd def remove_saw(self, msg, args): self.destroy_dynamic_plugin('saw') return 'removed' @botcmd def clash(self, msg, args): return 'original' @botcmd def add_clashing(self, _, _1): simple1 = Command(lambda plugin, msg, args: 'dynamic', name='clash') self.create_dynamic_plugin('clashing', (simple1, )) return 'added' @botcmd def remove_clashing(self, _, _1): self.destroy_dynamic_plugin('clashing') return 'removed' errbot-6.1.1+ds/tests/dynaplug_test.py000066400000000000000000000035141355337103200200210ustar00rootroot00000000000000from os import path extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'dyna_plugin') def test_simple(testbot): assert 'added' in testbot.exec_command('!add_simple') assert 'yep' in testbot.exec_command('!say_yep') assert 'foo' in testbot.exec_command('!say_foo') assert 'documented' in testbot.exec_command('!help') assert 'removed' in testbot.exec_command('!remove_simple') assert 'Command "say_foo" not found' in testbot.exec_command('!say_foo') def test_arg(testbot): assert 'added' in testbot.exec_command('!add_arg') assert 'string to echo is string_to_echo' in testbot.exec_command('!echo_to_me string_to_echo') assert 'removed' in testbot.exec_command('!remove_arg') assert ('Command "echo_to_me" / "echo_to_me string_to_echo" not found' in testbot.exec_command('!echo_to_me string_to_echo')) def test_re(testbot): assert 'added' in testbot.exec_command('!add_re') assert 'fffound' in testbot.exec_command('I said cheese') assert 'removed' in testbot.exec_command('!remove_re') def test_saw(testbot): assert 'added' in testbot.exec_command('!add_saw') assert 'foo+bar+baz' in testbot.exec_command('!splitme foo,bar,baz') assert 'removed' in testbot.exec_command('!remove_saw') def test_clashing(testbot): assert 'original' in testbot.exec_command('!clash') assert 'clashing.clash clashes with Dyna.clash so it has been renamed clashing-clash' in \ testbot.exec_command('!add_clashing') assert 'added' in testbot.pop_message() assert 'original' in testbot.exec_command('!clash') assert 'dynamic' in testbot.exec_command('!clashing-clash') assert 'removed' in testbot.exec_command('!remove_clashing') assert 'original' in testbot.exec_command('!clash') assert 'not found' in testbot.exec_command('!clashing-clash') errbot-6.1.1+ds/tests/fail_config_plugin/000077500000000000000000000000001355337103200204005ustar00rootroot00000000000000errbot-6.1.1+ds/tests/fail_config_plugin/failp.plug000066400000000000000000000000721355337103200223630ustar00rootroot00000000000000[Core] Name = Failp Module = failp [Python] Version = 2+ errbot-6.1.1+ds/tests/fail_config_plugin/failp.py000066400000000000000000000005161355337103200220470ustar00rootroot00000000000000from errbot import BotPlugin, ValidationException class FailP(BotPlugin): """ Just a plugin failing at config time. """ def get_configuration_template(self): return {'One': 1, 'Two': 2} def check_configuration(self, configuration): raise ValidationException('Message explaining why it failed.') errbot-6.1.1+ds/tests/flow_e2e_test.py000066400000000000000000000114361355337103200177020ustar00rootroot00000000000000from os import path from queue import Empty import pytest extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'flow_plugin') def test_list_flows(testbot): assert len(testbot.bot.flow_executor.flow_roots) == 4 testbot.bot.push_message('!flows list') result = testbot.pop_message() assert 'documentation of W1' in result assert 'documentation of W2' in result assert 'documentation of W3' in result assert 'documentation of W4' in result assert 'w1' in result assert 'w2' in result assert 'w3' in result assert 'w4' in result def test_no_autotrigger(testbot): assert 'a' in testbot.exec_command('!a') assert len(testbot.bot.flow_executor.in_flight) == 0 def test_autotrigger(testbot): assert 'c' in testbot.exec_command('!c') flow_message = testbot.pop_message() assert 'You are in the flow w2, you can continue with' in flow_message assert '!b' in flow_message assert len(testbot.bot.flow_executor.in_flight) == 1 assert testbot.bot.flow_executor.in_flight[0].name == 'w2' def test_no_duplicate_autotrigger(testbot): assert 'c' in testbot.exec_command('!c') flow_message = testbot.pop_message() assert 'You are in the flow w2, you can continue with' in flow_message assert 'c' in testbot.exec_command('!c') assert len(testbot.bot.flow_executor.in_flight) == 1 assert testbot.bot.flow_executor.in_flight[0].name == 'w2' def test_secondary_autotrigger(testbot): assert 'e' in testbot.exec_command('!e') second_message = testbot.pop_message() assert 'You are in the flow w2, you can continue with' in second_message assert '!d' in second_message assert len(testbot.bot.flow_executor.in_flight) == 1 assert testbot.bot.flow_executor.in_flight[0].name == 'w2' def test_manual_flow(testbot): assert 'Flow w1 started' in testbot.exec_command('!flows start w1') flow_message = testbot.pop_message() assert 'You are in the flow w1, you can continue with' in flow_message assert '!a' in flow_message assert 'a' in testbot.exec_command('!a') flow_message = testbot.pop_message() assert 'You are in the flow w1, you can continue with' in flow_message assert '!b' in flow_message assert '!c' in flow_message def test_manual_flow_with_or_without_hinting(testbot): assert 'Flow w4 started' in testbot.exec_command('!flows start w4') assert 'a' in testbot.exec_command('!a') assert 'b' in testbot.exec_command('!b') flow_message = testbot.pop_message() assert 'You are in the flow w4, you can continue with' in flow_message assert '!c' in flow_message def test_no_flyby_trigger_flow(testbot): testbot.bot.push_message('!flows start w1') # One message or the other can arrive first. flow_message = testbot.pop_message() assert 'Flow w1 started' in flow_message or 'You are in the flow w1' in flow_message flow_message = testbot.pop_message() assert 'Flow w1 started' in flow_message or 'You are in the flow w1' in flow_message assert 'a' in testbot.exec_command('!a') flow_message = testbot.pop_message() assert 'You are in the flow w1' in flow_message assert 'c' in testbot.exec_command('!c') # c is a trigger for w2 but it should not trigger now. flow_message = testbot.pop_message() assert 'You are in the flow w1' in flow_message assert len(testbot.bot.flow_executor.in_flight) == 1 def test_flow_only(testbot): assert 'a' in testbot.exec_command('!a') # non flow_only should respond. testbot.push_message('!d') with pytest.raises(Empty): testbot.pop_message(timeout=1) def test_flow_only_help(testbot): testbot.push_message('!help') msg = testbot.bot.pop_message() assert '!a' in msg # non flow_only should be in help by default assert '!d' not in msg # flow_only should not be in help by default def test_flows_stop(testbot): assert 'c' in testbot.exec_command('!c') flow_message = testbot.bot.pop_message() assert 'You are in the flow w2' in flow_message assert 'w2 stopped' in testbot.exec_command('!flows stop w2') assert len(testbot.bot.flow_executor.in_flight) == 0 def test_flows_kill(testbot): assert 'c' in testbot.exec_command('!c') flow_message = testbot.bot.pop_message() assert 'You are in the flow w2' in flow_message assert 'w2 killed' in testbot.exec_command('!flows kill gbin@localhost w2') def test_room_flow(testbot): assert 'Flow w3 started' in testbot.exec_command('!flows start w3') flow_message = testbot.pop_message() assert 'You are in the flow w3, you can continue with' in flow_message assert '!a' in flow_message assert 'a' in testbot.exec_command('!a') flow_message = testbot.pop_message() assert 'You are in the flow w3, you can continue with' in flow_message assert '!b' in flow_message errbot-6.1.1+ds/tests/flow_plugin/000077500000000000000000000000001355337103200171075ustar00rootroot00000000000000errbot-6.1.1+ds/tests/flow_plugin/flowtest.flow000066400000000000000000000001061355337103200216440ustar00rootroot00000000000000[Core] Name = FlowTest Module = flowtest_flows [Python] Version = 2+ errbot-6.1.1+ds/tests/flow_plugin/flowtest.plug000066400000000000000000000001001355337103200216360ustar00rootroot00000000000000[Core] Name = FlowTest Module = flowtest [Python] Version = 2+ errbot-6.1.1+ds/tests/flow_plugin/flowtest.png000066400000000000000000001327111355337103200214710ustar00rootroot00000000000000PNG  IHDRPX1sBITO pHYs+ IDATxwXGݽwvEi,X5jb`&Xc7Qch]lFAAD" ږ83VT:{|x}wfgG++ZB0 0jV4]\\|᧓>V*0P*IeRFK"@kjV64U&RUFm:n`}AYO%F]vj!F{6ݽb!dlf2.dL "!zu6+mOwmLs`ס€~!&k+fefvDr Ü=qL ?e0s_fzfcgsKspŰ#4Uġ>͛yGZZM8 ?|7mV VsG V-.(r!*?q~if5o0a7q1w޶' rdtF ګ=?\dhȈ/ ٠>77Ƙɣ,&l^j%3wڃv  5qDQaQ膿$zOzpќetݘq[o704caV}8 8*Gm,'Lg`(chF_*ởމgBྜྷzt`i |c`hc7nhӡu=)_~ņ~qusRԑR<XʛyhӜYXZY$){㸤?VmzTT BI$I|*I}I)捛i5wv˵ jiHPu" ţVpư!Z\<{H7 !R>|9okoC3 9OsJKKM*kdV^n݇2qSح[ԥOABF6SU)5* r8BcXþP*f,}SLx'q^܊pRyf޾^Y9VJG H{+xuRժ] YtC9m$1!aoq {#Fsܹ&MlR&B{KJJ=܎]:=#XU?̑艬m2}PV sT$I% T8G8}>bDuZ,S*-)%B,g%zFIp,/W 4R M e2ae2H㸇)|1>~T*?5`8Bc[TE%$$xyyk׮]v...$IҰ]8^^,PUi ޮuUz=>ưD>>>6lضmۆ "##\. j׮]֭R㽺!ٟPo*a 1]( iٲ̙3 v!Hڷo߾}{KK˚)a ."RRRN:U*J(77ŋ+VpqqQ*5]Y 1 tҎ=zdJhbqYY LLL{աc5a !BCC###h600h׮ݻw333y<.0hB*D"Z]EC*Ra !BS`VaGtttu뢢JKK|~׮]?3\>sLРAzY|BVǟϊMa2`r~$,wpqw~9(aueee-[СC%%%&&&#F4h BL&{90%﬑s7i.˲ѷhˍHG8yԂ?au Z'3B,r} 0k ai"pcڜH(-)>aǛ{(QMð#4)UVm޼uyy(DR|A^v CeB@xrKRUõ05\Yf9se3f7̬n E#c!@ ,Sf=y*KI71Z8.!!oy󦩩U:t j^B3 |]2ma.ۺ| 88::`1 Cp"eCBB֮]-j^B(- "[n6lPq\f};/y 3ð#4,{qƥ/[ɩnl B`bf[kCzEB+E,MLH ù1kX9wԩSzrJ:Kc#G, kr]o8tfKv֧wōa N?P!eoD:~!59HgWolO58KI|o?O3 !(-:Ν;I\bEvY?y3l<(@(h AZYJtTw3ɵ0Tj7|ؙ>-Zڕ/_`z@?I@މC\:{le*++3gΝ; XfM=9y:v"l3h5@E"CS&LH$5]) p]9amӱDOXK}ƍ;Ijs !FH%ae'.--;w;tO>b9q=J~~~Fh4eeU4M={SN,Je2Űz GڅGwp@XO{Բ鍀Vgbfƍ?>{S5 0OobԄH&Re 2}\'|J[\!AF&A4vuғi5Z=}ܜS^ggggJeDDnFcX]( .x{{ؼmwcw!ZKd禧edgf^ncedbYqIzZt **>6ɭ !Y_IQ0wcM-bMmg)x6qECIQZ /;W_&!)"7;=7; POr󲳲=z$уff9y~-|=χ㸇)|1>~T*}fʕ+aaa.\-**0aBϞ=---E"{I&͚5덣S.]4n8$$d޽G֭[ttב#G>@aVpS ccÇw" |ߦܒiiC BB Bbؿ/Au#J7$Io_/ݸ :4ahdܗ$I<܊$Fr[nB3 SSs &~^n.< ;{[n+˾>h8666>>>>>>..ɓ'EEEj:00o߾#{GN0G%/XhQFFsMð#"II&'O6mڥKVZդIN:uܹ`H/$IxC"/O+X*m~Z;w.'IXR=y$33SP(JRh ԣG;;;CCC}} (W^VVV2L:|>SNR4>>.:h+H$ڵ+pرIIIiii/^\j.Tdz%Sh4m۶" 04񼼼\]]}||<<<쌌 JADau~Ǐ_~B(HPj:''ѣG/^\nݘ1c9f]A^YYYqq/ZXX?((HwH+7y<^EcV n޼_EFF,hre7o>j(ww_,Zb"Hddd<~ڵkqao޼aÂ* !k֬#ߍ5(8B5k֜>}ӧ ôo^._|Y֭ȑ#}}}j6u\au9&TޕJ-BE+啢+s$]\\tþ<==5MǏgϞ\b~3ҾEC#F"a x^De(cҥ...+W_Z̙3! =|{O>=**J(o}~egiiYB%zX Gh&2B#FnΝ{/_:-ӛB@Z M#.mӾ|;h!ǂK?/ ѹқħR?3-#{ nPff̍ŋ˴wpn*ݨQI&Y[[Ϙ1?6lׁ0 7p/[!Gioocǎ\% ԗ"t/k*@"=yyzS/Y҃ĖM 'Z ibS-ئE{ i)g)U3,ǏqE__~\ɐ)w2tc1118py󊋋/^|ܹ'a?8BB(22r„ nر١Cj7 !$0\+[OLtpr8yY{AB"7/գ[L.(*bn)  P~>ezŠy<*HrP7vP)§Y<e<߹)j!À$ SӤRСC'N9cƌ<ðz r.b.5җT@y$RIeHeSBKCɪw}o߾۷;::〉K8B㸨SEtӝEQm:@3dݽsoq,4jO9ݩGpƦAH@eϞk(!X4LKxzA 9b6!t+e3"#f,+*JI +Z$ֱ3gNVVVttܹsn*h ð{!tӧ`x@$ C3/n}x5vvlѵWwS؊=㆏˗[})צ})C-4юmFQTyk@ήj;mi1u튞ZE?i zMZ7^dyxx]^{aVO>>}z\\\׮],XP#jܴse 鰈~[ԩ-(I~snՅddk1$4=}Ҙ_iZ\@Թ! 8 oRsLm덧9ǐ"QNT淆6id*jժUIII4aYf]zwɒ%5U7jX" ڊ'?mZY[._D  DQȽ-2V @BW,rto(dX;k~ 3 9ike,3sHg7 E@CGtYQ F w2BfȺy$|h K;x~EFF;vlѢE۶m}38B?Gz ++˗U?(QլUBزMs ^ !mzeN0ǡ,^Mn64&@̀mA !Y7ZZfJHI5S @zh\5 DB!EeBaW%8~MP㼌ׯGDD޽{8 a e8e%K5kVÃy<KYB"I$W"n$)*!qSWF_8@Q$K$(J<<7P.K8Cr{G~imVC+k@VMu?xucV6@(((?~.]*}` 2s`3f(3qL&#BPvV{4й?SZZڗ_~_ *-Bhcm3k?sތ(+s9 !Ru/6ԑ?|OeݦC8pG}}x 1B3 iӦd 6nܸ:K777ҥWNNR,..VTkB'd2EUOs΄Ç>aKFUY2B(44tҥ700ҺaA\8.22~+++[xq֭!r|NSSSÇO6?4hPSSw~۴i݂  \NMM石&MԿ~^5[z$޽{ Oׯ_hggw7'/CLLLwi^bũSƍ7gj#aPY?v%.][5ªСC.\yΝ;7U)i~|hݻ}\:vرcWL04vZ]N:5Ǔ e˖7n\|988APqq1B`B>433322JKK{Q3q paXmVBʕ+ʚ5k֠AD"QMW Zr/Xf֭[۵kנBB 5`hݺuhhL jXgō7͛^dUMrN:4} __߆3Qð_Lr޽{ϙ3Bhgg׽{wB3 S5>abo[6ð:>G褤+W-XɩkU>?rH>ȑzWTN7LL(au]ݛ9s˗[htRww׷Q*W^mrǏ h׮]aaH3!T*Baا:**ヒhѢʕ+^!aÆ3g!V'O|W°H$3f B%¬t au]5,,,|7ӵ@ƺuZZdIlll.]͛6mܸqڵŸ ]M6-***00㸗Dd-o߾}ԩaÆ5 @y<VUKڵ+,,ʈq8{!p:/3/eK:GϾriz֭k׮MJJ6lشi<==_!۷a.P?=zto~ے#Fyfhh8E>)O aa^F+ hx66234W\}M? "SJNN^zS f͚5zhݜWTQF 0 99R(VSh߷lbkkq7nFQTΝ.^أG=7!TTT066n80ϰB&aـ.vn&FBH'4yXr=d򄌌+HՆ3fǎ-]{cxf͚)Sr|BdԩWn۶;⮉i޲eK/kCV?TUa[$Nod#}nDySsC)s|f퇼v͛###3228r-Z#Q@ r9MXGxDwD#]͛7_~ƍa9UrBطk\A@WqܩSƎ;~Bz vy ֺtVwAD"qS}U*۶mݙW峭  0ٳ^NzlC;!SV3WWw4222矰؇d#F <"G]{FDDܻwǧ#={l+ *B;{fx7ceIi|d+ͽ{nll[_prr6mZΝ aƍu]vyyy&fsh@#4uUYMJLܒp':"apKӵ{-- BP痖feeeggHҦMN8uΟ8k Bرc9rСc:99^`I$B#JKKeL!`k̙T*ZVU*P`fffgg*BٱcӧO_ͫh ꍪn rP({4RD"XXX +qD"ф Ν;gϞaÆf4a@Dh LMr Tzb^C 䲮 .444x|>_$QUEcXd0|A;v_ aUBhoo?eʔ֭[7gy3:''gǎwWBxo լjm.eU**,߳ ;;jXXX+Z]',,,*8rP"L0ݻwֳf@ Bz7o3ɓ'5] {ZӧNlo=Z65}g@njwHoeeUrG>qرc-[VEOOTϞ=czn8KJJzs7R%Vnw?Bbbɓ]v577uw>|RRRqQqW5GkUp:J߇ٞ @p {ڎ4ŭ*aBQQQ}DHv+-[K,meR0$IB(U?-8iL&zɓWxD"[ٳgk֮+441 &1"I2EYq?_I&duQZZzሰmb׭Ms3+ =@BZwj٩[G3 3 @ZI )m5{dVR|c a_9{9~=XĥJO\8k=kC8.&&_~/^X#4B(Aÿ&^#1>iѬ{vuppև~L& 0|aÎ?~={3潓ށa%{J$X: !ddq6bQC@֡4M8lRutFڎ VթKc{؄0 nݺOdkicGw4027!$y+WkۼuhpBNmIħx~jA~WrGh!#IGzJMM]jV] {澟gkl05eٚKq\Rb+IS9 =LM9gp4CCéSZXX;v?b#Êr(y2%M<.: *ҵ!EEEEPz !,S%⊞Zr<:&IP$,*,[!CcX!?~y/_޴iӏ^eYHz/JQS<}ϺyzZߍ2ظ[c5X xO\eKJJeUD+΄߸z P ໸;ѾObrdTFS qDQ=Auê:th.]ݒQġߖ۽}@$hުg!!.5%9罪%8++X6a2YV8kŲl{WB4MS Gh g϶>vضm۪aN^H!c:jqSǔ)7٪RBIx;)A|$tݞ$A@IjZ7_'I$Ie!D$IeeJ Be/нL$GQEӴJ./ZW:$L+֕<B?b}(a:o!#3yqC|;jw3ӳ H$Iee'A (j4M?֝ZB Hr# IDATSŲrBG,[V|^r)BV½~~~g Yn{pppURԇkߤg]BBǿi㲳rtn?p2I&kt2~=ujsҒRƍq1Iغh7saVQeg 9(QFɳmazzR=Go\VBO@/ NG*$I0ݻׯ?\.ws,=}_|][AwIjӾqE%Km:F6WE_,,(uk-Z?idܵî=ұkL$ОOvT@ ?+~vT&:jHVagoܭCnNٓt"EQ]tiڴi}Zڎ㸌'-۶uVp@Ǭ_ݹSEaŅ|7*i݇ hV@V>QkFMRLM,Yץg0@@(4k2d [qI )S愸9?~~&,ARB[7j LRoY:t̐JJJ]wn:=I^nQ7ccun !Ԩ,-Qd=s3nG<#re$O#S r\.tE*P~n>Ar+.J:?L,0ܡ}iIH$=ǵiثOFszLӼ?Oshtoff|Gg#%; fݹ ~|ҵ;vpi ìm陉 Iٹ5wkaΜ9>>>'N3gN~~~B( @Zq\̍X66vm x8Zi. 425ndmkg261"IRV# Ʀ<>ϿI@_{ؽoW3 ӣ^f˲'r h|j\쭸fV֖,ˊĢ.:)e)),Ǒ$ٺ] xV֖.N6rM#kh-(ezzAz&f& CTA}:lު?-/*,iFהj'*XI?;e7{=颃Q62Dd(П (_"l2 H)PR:.ݛMuM>?x$w4>w=]PaCC$+4߆ ,--O8'REA[qR~FLa~ўoI$PJ 8[mBqoG;9x@(69 _.}(-)]*hh=ޒB{B1x !Dݲ5` , H=,Ց}475KB(i!~|WCtyUt_@H-Pw&9~CY%r۲9l=!xݧ:z0` MQԹsvsPmm~|}]]Ν;/_^VW_qHӄ=UhaLDz?Q_S-M-ښnBگ7 K_o۳جF%?uvKSKփ"2Y,ZA|s~wIqɥEeiYoikk4:&NĖₒ?lama7ĆϐZL-L֮o"?WW[OQT zdFu/611~Ks˃䌫QOOi-A644ܻw/88X1!EQ7n Iƍaaa7<ƘRi6iӦ'OVWW|ϽgaCsWvm1H1YSg@C:adiq/GϞ9v AoZjbnRUY#uBry<B(loAf~kx<.י>wڵK׳2"P$ illay<ޓ?bf?ps\ԘhryZZZQQ$YdaKoI64xi&UWT3\βw#uI6'.<mh&YdD,6qy +ǜ8t*&P  }672C*~dƖ &f&͍8)m}hPYYYPPP``?mmm#FoiiH𲲲aΝ;w>y򤙙\.#G\`3/\\/>#z)/+.wIebnᬭ 7D7de55pk[kw/W$2Ӳ}" f;;;=}<ތsp742lojn ~[8|t0ͦ(AJstuhkHKz* mذza>e-mCY,MӥE-mC=hkHK~آehW[S=_bd7r_|L钒(a<<<<==gJ-\&-/)v,HtMuMfzvsc3dzwsvs*RK'4E\IH-wo7-mܽuՕ5%(*#5SK[ֺ23=[! ](wH< ӕhEzx$QSAJfkKiScs@N-UhD" JLLA%''۷o|ѣG~~~AAAR0w={A+4ye˖۝]BH$B@ i˨,Lq%ЌAJNuo wXlC3 t@bMX$@!d C#HP}7!!!_[7,+,(Tc=I$ׯ9r$>>jȐ!j2妖"% VZt͢)ZqCA@ s$I0A(2qAҵA$ D!):3/8x*2XÇC(*66 Ў֭[$IfggKQF&nL999>>>ϟ?pL&C啖憆Ο?_SSYhn~k]'G 9󻩏(^{w#Ei禸U,J96L,߻w/55XXXXYYY[[)S)\/6y=M0 MdHHݻSRR<==i}\.711Y|9B(>> ÇsS\SSS]]X,坝=rJbbb\\܄ T*㏟\3Q'7ox/--֞#Ar B(Ɋfff]]]}$V\ݵ_}3fl5lذk׮555$&&ZYYYZZv=lP]]]XXXPPPUUU^^XYY)SfD"k,!yկCj4җWoBZH6⒟TCC]?Z;S.%_踹dQF)&>|s璓srrù\.k׮-Z"333sss>|XYYY]]]UU@QǓ4MD"HIQwuux;v~Cx/BCCi^L@ 0X,Vyu۶mwѣCCC9Ϊ6HLU\ ۶m+++uH$:tA`` ù{@ hooV4|A Urss322 [[[Fekkkjj\.0l6СC׮]CO:uȑ>>>>PbX'D!łJ;|>a?ߥX iHSp1@.G!aWDH 6z>_X#FbG*n޼9--M"XXX7nȑΊ:0BYld7+bMS ڽW*_|hVLUooo. a$ɀccc|w&%%WTT|wwYf9;;[ZZD"d-+jsO=G]]?l8FWUV߈USޮXVr9vYţ) `kUQ Y@"&vm՗SZb d2x}q1ij À"Z ,:٭wDr\R[[ۧ~~[[%KyyyYZZ|30W.\>:šˊl6,0$\{+4AC 100*))7o^w5rC ۲hUw܉Ȩ[pRdiCCCtt ڬt24M" 0`7?;$ffV揪k=VTP>%){Kx񴶆i#Ǵ9 TwGN)b3M :V4ԓѿjְZPԴk׮?S[[[Aaʲ746w,:0@%Ou=}^7nܐ#GM#mۖo(gꢢn߾rCBB/^aooooo?ͭF !2vweFxDºܿø1  [?.!A{R !êrNe)wSgq!44M)HA[{5u' hZ1/+BSNuqqC:@4-iQuF̚??;6jQFbFGG;99u=[ZZFsb8\1+--z7ݻWQQMB߿WC!!1 -^l[֍3 W^lki\@X,);Q5gȚ*~&-+>H[ve!R6y7FosSƇ=)QՁ-Z:Z55fvVA8S2ҲF⡳A)I&M 7 ֭[RWϻ'""ߊƔˑ uuuoưa܌Uݿ44ͤKB H`|>O|R 4֡meyټ,Ҧ^hdĩ|d $Rt"(EW2wR--AZ1 9>-pxHS %# WޠT)l֬Y~$)S#FPz$$H.^x…;wԌ=zɁ^^^bYAB P(ip,9!~ Pi~A*_t?}g&<@p#J7M p A"MP(!j45^D`JBBǯ\RXXjժ#GF)Z4!A4IY<,9TzNF*A~bsrOPg@Yv|i V2gο'6@y|@" ^+46@Ԝ!'hD[I=pf|R)Kc$A$7(J;篺D`J_S}}C;+L<s>/Ҧmh7f#{ra"aYq_"+#ᰨ1TRǥi-Ņij 9\OBB a}kΫMn" 1SC(hnl66!h.-%ŽvqփN2y%BcX\\ϝ;Gʕ+g͚e'#" Ks>0b>+W|~Ks  žCy"IOHM A@]S(04'ux|yZNg)B-M-\426"X$APLfGS4E6_:+4/cǎ߿Ν;~~~/~ Up8/Os# ;e%WWI%Z:ZC{q8$b Ft| ,(톊202~H.;G)G7Rݤ =v$w}ê.x2맡J$+4ZpGv6e/ixjbfla!aOvu36s8Rm3(g@P@8pBRr-~PU>\EAٖ l6 :3_~Q&/2 I-\~&%%eΝO\~+bmm PJ\.#byL&-Fl6J#M @@t.(PZ'˰Xc/auh~J)I\[**&!Ƽ '.]ڹsgttt@@˧O 6~ݣ:$Up0eQVwo)MCr/Y0d!o? BPSSҪ!@b_2>%1u꭬-|j 2?CZZڬY"""F~b|SlR" IDATG  Ftr3IL6ga@Su}ha7AS~V!$ORoO5:O@z 6Uyp8Y3g]fS,A2!r|k#OsEYezjpS?b&NxmCߨ*/HO2S WhLihhayyMk\G[YY;W$mS9sHpWW٫l U=H"}Ʀ199sƍnnnwNLL//\{͘1CMw>p`# fjab`(QdʴM46ZoooU5@qenm;{2?L&,JKLOhmI-\1%iի ,w/]G-Z/rϺ۷ooڴڵkƍ[jՄ z#ސsŊrL bE$BH& ->}PZZZN>%jj=R yOj )L&ۼy}._x}̘1---Bvxʕ?<66vڴiaz!^0LJJJ~~~mmmkkkWW#u@KKS XYYY555}WEEGG?{P+Re搜 $ǍgccA7bBǏoRRR͛z$<PuD9I8 b&??Ν;˗/711ZBcJ@dppppppo߾]RRLgǏ߸qcQQQDDĻ;de >+bbbRSSO>;Q )cŋf?{ٸqciiiDDG}43 2,22ܹs=Bc~wy'88iN)駟6nXYYofff}*a !TRRuFUG4X )Ydd>hO3>s޽7oniiy׬Y3]&?\1(ׯollO.]*u+Xcǎ-[HUVj;' NrE(Tm4M>|?733[zuXXl%wܹuV5k.]ۡbB,cccMMMU1%`&11q֭k׮7n\WWWggggg? ٔH$}<{˗/ԍS !pCwИd;v绸DFF^p)>iӦ? ijjڹsΝ;y<ʕ+/_4M1\~E!bٯ###WhL '4)PHQ*tSS֭[wءv aj?11bٲeeeeqqq )Yf_zX,󑺺۷+u^{ ;SE]~!99yVVVo8uT|ݫS$r쪪[ݻEQnZdɑ#Gaaa\.700_rɓqUw*++7o޼{nKKa !T^^nkk啟o``H$zW\nKKcpÇ7mڴw^;;?xٸpϟחT!\gs>|8''gȑsΝ;w0L (,))6l*+*..onnvvv* 6..ԩSgΜ111y뭷ϟ0 S$k BU3 *** -E@VO,u"k[P77>6>>ڵk.]3f/ðC˗<=='M/U WhxwCKc]JTI$1#;N1}66!!ڵk/^:tY͛aXAڵ tRmmmUG4 ^ݾUQ[ @644[]~ڈo%l]]6GFF*j̙3FG_`:J\Ђa'Ndgg hujm[ 9F@1{FFz:ܥs8Xn?|@CCck+}Tn9sɓGk3\QQѽ{JJJ݃w!TXXo>>b |V]Pu_|+!`b>>J3??_Qݻ/g̘k3Çlr]ccc>_YY)/_>o޼r+SZZ:qq huRPyy37e1S }~F.̫رsidIII666nnncƌGWWתUN8oO:U(~ϟwvv~EBs=ztȑx"nuj[7oʽuE`Dәw/??СO^G'%%%%%XZZ[[[?߯aX/ikkr劏ϪU 566]C y^_uSSӛo˳Z-PuթL$z?ihh(*bEQu~~~>>>b S4M|m Ü>}͛NNN˗/Wz h\7\I" <O (*4Ԕmmm uuueeeBE@@@hhh@@H$_0_vmG(fngffܹ$ɕ+WZ[[Yj!h{;L%%%Ii,D"@PUUu…ׯkii B::::::xf SC/={600flmmݶm[aa /0c AL B C3l\f|ſ P%bD")**JOOoooH$2 @^2Tgnذlݺuzzzϱ3gΜ;wdz\P$lS"gow(z|]wXX,Jb4%%ifffcc(溺,0G Outt9sO?566~c'[n[}[ HtrvM(3糞aaaTi444Z"tﺺƆFf[YY999YZZ<}0}a욚6maa;cH]]ݑ#G?pԨQϱ꯾p̙ ,jWhfGyXMӵu#j϶,Wn0k?WGUUUUVV*jvMMMccc}}}nn͛7b`+7a]vUWWvuu5555jՎSMMM~O?4jԨkzyy=Nm+Z`XM8yhCyiꄴ ?_im7M>>>+. >|X^^^QQޮbeeejjjmmm``qPii Yf_GGǥKvforU!3\~~a֬Y}s!tҥ]vJS \wdook vObEO7[?\"(tuu}}}=<<T NtBBBqq-[,Xx-..ׯWylClݺʕ+IzR4""bڴiHm .X,…;^JPG. `o@@RɅez+^>.{{{{{)SJKKsss ttt!'OFuObjj&2 {V4MgeeD"6o>mq.b 77LNAAMrssgΜrJ<:r/B& p{ښqE"Ш+a}zxaxZ'''gdd<|)00JUݪ+**LBQTllJb0LFFFvvvn$Iߞ={BBB{ggތpV;GILLɌDZ| ELh4}Νn=FkkkRRRjjjjjjAAAqqqKKMppp@@QW_ڵ>xw|w}gv=z#j"...++(cdd0䔔&77`E>U__͛7O8oC1)I҃[NCCO>Yh И*dgee$2n8///gggss^z뒒}O7n70(ԩSk֬dVZz5/ T{ΝJkkLJ899)wboܹs/;0i:22rڵ=Zt x<ž Иz)..sNLLLvvvNNNgggpp \\\r:77>~ŋ{=]]O SO5kTVVΟ?/RuPSGEݻw͛III%%%&M=zsѣG|WX+1f SC7nXbEii9s6mڄG/BcjƍQQQZZZaaaǏWס ;XYYZw?͞wp7L}億P8}'z{{o߾ݻwΟ?ժUxWhl @ݸqYYY)S{FUUUYYYssT*ef $Ir8MMM}}}kkkΊӗIHHضmٳgzEaUUUMMM-yD"A%؀yΝLD2y䰰0GGG<ߩOIM*knmnkocAqX Y)43CTʕ+[lINN=z;g^__޽겖<| Hk`oc?tH\(33322͛ G6mڰa\\\BS/EEEGz1 ڒ aPmMmQ~q{ L K/ .lܸqĉׯwwww6.w%ϣ{t>UɃ+46`EFFFGGgffVUU͘1#00@ѩM64V/\3@!! HQtLT'Y꿰B(##ԩS}Ǜ3g|Зݶ'OSˆW\=\  P+8;N2EKݓ6lP־gBtbbbZZqӮk׮_.Y8vXccc6]\\|w6551 py7׮ow|8XX":BaRSoρ05'yC/Y?v8owl-3x1`mo)=8i$%6 22lӦM6l R]Ŕʉ'|}}cEEGGϛ7OKKk >>>aaaҥKQQQ=Bmmm`R:PVVֶ_^>@ _affnog+!;|닊Ǝf͚_fYW <`baruc#gggeWhvvvFGG)\.߳gO]]PSSsرbLQԑ#G233׭[gccϊxxx2D(J[n>}X"0 #|;2wZN %YNqWVzѣ쳀>> d4tˆAxvNigpeVRp Ϗ&%%1 xP"ܿ׷!>>KNtRRڞګ?3iҤI&ݿ ߿t钏Oxxxpp`8(nݕ떑$Nadw3+> ׯ644^={J $EQwcN buw+@MMM|޽Eݾ}ƦK__?&&&""!bdP-P8s̙3g&''GGG߾};???400;v]\\ U=ᵵd]]|>2I>_e] :eF,BT7nݻÇnnncƌYhQ_wV K}zSCSG{60ԟĐ UB._x.3Ib%;ЪDP(LMM}W233ϟb}vKKfbbbiҚ͝<_____7xի7nΎ>~Ǹqゃ,,,rL:@d \:DcE²'#s2s7BhGt7D@! Hh˹K/),,?x[lll^~ 3FQ=ފۺq!a'47]#IJKK0M%hZZ[AEiE&FEe5u\FGO),),mnnahFSKd3F$ ebfRS[+^(.,z3Ѓ,B8;;OҚ4i/0uW^R00137-+jo x"MaG{GYqyNiMUEuUE5IvVƆKk)hhXZ[1 S\P"4֓,"IjkjK2I\U$;w>zΝ;ZZZ^^^CIPPÉURTn򼼼/_>rJ3EG#FxyyYZZkkk:^DQݛ񔜚=AW.oQpy͘P m-mE=mlhZSY%xn脑)R._jaiVVRШ3yꄴe%MM-G,^ t{RiW1̨#,xO?<2Q}}T, 1lW~~{656lcS㶶7\LV\\~_!x~}{%1 L`%29ͱp{w[4^ʲʎI&^܂-6.7#N=-+=sUwnFE^hd2m׸\4mm폪k[ۇ8-[ؠ矎@H<6W+X۷'&&( VVV))),K  :Tq ooo?HǛ8qĉbbbnݺucnnndd4 ϖ)+.3427S$cCZvmaE$~`ښ؛q6y3,xm]=;:r:xTPKKƼlAmMXxaīfO˥NN3M.?t*lᣇ3 }Ž;M2NCCP\PLSYZ݌}C!߾G!xu͍YbqAAArrٳg<<</^L~nIwPPSo/}g_@UKbMyqrۯWVTmxﳒEK_}O|'M(&.i_~ gX,-)-;FOptI;Eopwor!..U% #]0+3-k^;utܝ{+,34}=6sKS|BA .MÏ;rqqO*wNN|MŃXWt(+++KHH}vNNNiiƍ~~~p##3.!4DoA TW$'|mCHh"mixy| O?e1!QF8ta"Ԩxil6 B+ +9n8%;hnolg|clK KI6nK齻䩡8>>ԩS #F;v3@mV@uJ:<['Lɕȫs̚!Xr]=GW=v8e<"Nsf]!~r땗?yj7{*ʫ읇L1ߛ'W'6]UQb|̚?9BM#Xz͗=aͶIOVܨ{B@ ~:x*vuu;BUUU?Khht+++++9sTVV޽{ݻ999UUU۾}ݰah(@gh*a+j ]ܝu7\^cjn@$Iby"0/[X+.+J40ҷj{jZ} bSRg Q |0DDrjkkݻy;w@tt>mڴo0,ǖ+-Q麖-gM#xQPCQdOaeZW]kuĔ]vBŕ sx }M8u͐z#339Hڻ,ɓ'O,IRiiiQQѾ}p8RRR bX}˲CrCBG/~0&68[m׎>[GUkE;qf//94Ϫl۸].1[N}ʲf!zbERr˳M gj'"b~mGGGTTԐ!Cnj3i$zv<,$$ߔ:(ˏR?YYNPs9$IJ,wTJB=A>"IRپ=%m,˻wj!A=ycFԪ+b\{a&B~ዿqw}#3gΜ9s*,:tСPSSo߾ 6\h4*bbb,l6~+"Iٳgd)aBB?{Q#:M]u/ywLn)XW:>xU/9eų{I)EQi*˲$J?Q#I$JEQ$Gy;BC6HPQ{*ϧB,O QsNuۦv777>|x޽۶mS]$&&7nԨQ>ء uttDFF] !jG~s~ec&[ζ+siί65ugbb"nڮT*JyCbȲ򇦲,+($I$` }ю=m-. 8ޕ z==Uk6|uxTC'LCLh2\=sLYRRDɓ'kjj#""1h eVPPP@@Z-8֮u 7dZ#""199vN .?Y5ZMk6Sp'<3 kެ3&QJoJ|c 2 ! aBBes|4Y,8CgLm‘C>^&x&MsG0,3$';4!8rHyyyeeeeeeccc{{{GGG[[ Z666vV5$$d2<"',--up7.))d2 H6/A$4C#}QQQQQQcǎUliiohhhjjjnnhii=e:Z/#I/F zQDX]muU\ nM~ɲ\SSCU:aރ*JyčO>HpJn[$˥bc!/a>JE .NMll_)NlSd0\NWSY:/ sl oY\G;K)VvAz29] n4*0 lt0&hH9uB%@$SɔRA%u:2 W!VuÇQκWɲvDFB:;;:' i#}dk bX 6l ;@ݻkzJZ~=!ߏpF,'|ooo3G9q~Sղ,ۓ,ǎ4v=7MI$ ? !4'^ZZYڻ^ i>^i0eA`[j9Ϲ=tr,pU;:dojnn\rѣGǍ7|܌ w'zuk6x/ܼ20$1%W5>YFȯtt@YZ}j5 wfA >yj޼sEFwdJ;7I;Μ;b "\`*nH2=޳` ĹFhTߴ\{JuU6fv;3qK?ZrrF Q=ZTT4mڴ%KX֟7Ht=%a#=oJ}n?ag $8zOx hm4:x\c0zʥqhf;+>)]Rن׫a@g[ۂǚK554B^-jeI&etIB)K n5:e,a|ȁSΞvA~!Dձ,` 7{vb!ʍ._k+|7Sܹ QQJv;{}ܗXLdH"(ݔ PZݭ'@sG@ ' C 0LpZyO6ro`DZ) ؆F2> |qp;x zSP0$Qܱec=X9&`_jvҌ,ǑJMe&(m{wi]Ry@Kdd9XlYNşDtuaa?eHri1Ufd s*yXnwIsᦀ*S0-`Kg怄;fw\_b0 e0/Vwn^fq+0IUUb¼yCxtI $$ s7MO &yL?Rjꮯm8qDJ@J-#U5u`bpᮻ~fLpGB83*__P fulh0$,\XZ-r#,KR\3( n"o9(z,Ǐ V  8!0"huڞZV x@(%16%{ ^z h6.F_1 rzqsق$8q\r4e3,żn=""ҋ O+ `+**N:UPP#}hٍ~~Rm뎊kbB#U=vÜ9'e[C 4y: Ӻ0DN^Vi:k:ƯT,*?4(=P 6a…jO,zXhx/Cʕ222>q B@N'_=.̞ /x}x,3BE૯`Ѣs_PՖ`~hD%4/5'n[wvoX?qqq^]h>sɔ ju%佷ZxbS*g><8u@a!<X2eJo}ܖa 1:wN$펖3-{wojǢ_*4`^ObɓO>6 i.yv I$Լwo }}[y0<ض .&3[`Yv "`顔dktZf)yb}.77׻C)=~:cƌW_}gϊ Y|'Y?~DDKߨd`F5:\VL׹Sx䠭gC|B;v'òe`6{L6/ؼe'N(zkdy^'%'M?a>9cƌ'NX"33aŋ9s?Nlٲĉʓ4aB?VLhY}`B8z&M_vE5ۥ,˅O ||O1<} 'ɓ7|3/߿ʕ$̚5kرōLh,CI <(TVȑ`zL!_[݀x^*+!/.po !|zH!B^,[ .B?`~#PZz^<{L!mh(:ss7!>pBa~rCpr $$`<#Fuu7'tv-omU64B}VY`RgqhP_+V f3{/^b!&4B}傃W/ 2ޠ!p8<fv=Jf9Vՙ~m67æM3ôi"&4PJOU,߻smeE陳-Gd9N:br~ؾ?u@)Xz ;~ ~0xeXRQ^2jhaָ%;UkmElA(J45Aq1AT<0<<# &4o(,vh9)]#GL)47Ce%ldeOC~>xF!Lh(*/3wux?֞`m;]֛PU |, 110w.<_5B0h[= ߟQ-OCn'׻e_ `p!]/BA$i_(;U<`v¼'?;9Rp8fӧo/t:X`X nmPF^ 1&狱 4f$l&&2ry(--[`$ A`DNʺl耮.Ey0h nt_y<?..7,9 ۶uü*P  B{;= PV@s3ՠAx80aµju5.Bw7p=mq064EreHjlCWN'47 @S4W,](YO)u-{rW l_Urs!t1LhT*խSlxy9ϧUwrsK}D6mŋAnYZaB> +a223彽x{va٧ g}}JXƌkw` XVQfB> wD}${XKGseδ^S}δl{>6 .Bȷ`W^}k?vDg{#4]~m;'ʫ/Y9>mB 8nZZY !ð %Uflr,qӎ]{; FOW7q෿͛a9`G̜ CIIB\m!$**חmXl;Zz@LѷdjBJ YWÃNn7l0q"L,B'aPL)eaD9Wz8saHHZXa HM% `B#tP gW_Aw7PP=qqaB#t}Q`6#.!E !E!/„F!|&4B!0B!_ B"LhBaB#BO@R*"0,~\$Y !]8˲^>B!.ުl7n,)){N\:t믿޵k>|xƍ---(,B]1!CX֍7zA1gΜo6&&f׮]Nؾ}֭[Wj\؆FJզ=z\___YYӌt^*/B]Lh8}I$Gy[,;v5//iY_,0LiiĢ(n۶h4u]۷ox< IҞ={!F„F LhIOO7L%%%JZ;v|&)??BEHV2䂉!0h4Æ t8,:u@+,㩨P^H)9! qR۷BFhXݲe 555<n:yoB ˲Çgvw eddڵt1 q _-Zt~G@5B Lh!ܳgO^^PyɓwX,iiiY[[[?Ι3СCw5A! C-))ٻwnWfksGQR@Ѽ ]]]6mJOO@B> oOȿ)k׭[2 dddƍ76449g b! ˲#F _~РAf[:.77wΝ Ì1BInOX3f* BaB#F P{oHfY6###22$UUU|qy4BgITTTTWW7bĈ%Rz̙¨%)7n 2e`jB0э@eJ)0=C$BHaRAl@#|&4B!p!E!/K]8IENDB`errbot-6.1.1+ds/tests/flow_plugin/flowtest.py000066400000000000000000000007661355337103200213410ustar00rootroot00000000000000from __future__ import absolute_import from errbot import BotPlugin, botcmd class FlowTest(BotPlugin): """A plugin to test the flows see flowtest.png for the structure. """ @botcmd def a(self, msg, args): return 'a' @botcmd def b(self, msg, args): return 'b' @botcmd def c(self, msg, args): return 'c' @botcmd(flow_only=True) def d(self, msg, args): return 'd' @botcmd def e(self, msg, args): return 'e' errbot-6.1.1+ds/tests/flow_plugin/flowtest_flows.py000066400000000000000000000026171355337103200225500ustar00rootroot00000000000000from __future__ import absolute_import from errbot import botflow, FlowRoot, BotFlow class FlowDefinitions(BotFlow): """A plugin to test the flows see flowtest.png for the structure. """ @botflow def w1(self, flow: FlowRoot): "documentation of W1" a_node = flow.connect('a') # no autotrigger b_node = a_node.connect('b') c_node = a_node.connect('c') # crosses the autotrigger of w2 d_node = c_node.connect('d') assert a_node.hints assert b_node.hints assert c_node.hints assert d_node.hints @botflow def w2(self, flow: FlowRoot): """documentation of W2""" c_node = flow.connect('c', auto_trigger=True) b_node = c_node.connect('b') e_node = flow.connect('e', auto_trigger=True) # 2 autotriggers for the same workflow d_node = e_node.connect('d') @botflow def w3(self, flow: FlowRoot): """documentation of W3""" c_node = flow.connect('a', room_flow=True) b_node = c_node.connect('b') @botflow def w4(self, flow: FlowRoot): """documentation of W4""" a_node = flow.connect('a') b_node = a_node.connect('b') c_node = b_node.connect('c') c_node.connect('d') # set some hinting. flow.hints = False a_node.hints = False b_node.hints = True c_node.hint = False errbot-6.1.1+ds/tests/flow_test.py000066400000000000000000000025621355337103200171470ustar00rootroot00000000000000import logging import pytest from errbot.backends.test import TestPerson from errbot.flow import Flow, FlowRoot, InvalidState log = logging.getLogger(__name__) def test_node(): root = FlowRoot("test", "This is my flowroot") node = root.connect("a", lambda ctx: ctx['toto'] == 'titui') assert root.predicate_for_node(node)({'toto': 'titui'}) assert not root.predicate_for_node(node)({'toto': 'blah'}) def test_flow_predicate(): root = FlowRoot("test", "This is my flowroot") node = root.connect("a", lambda ctx: 'toto' in ctx and ctx['toto'] == 'titui') somebody = TestPerson('me') # Non-matching predicate flow = Flow(root, somebody, {}) assert node in flow.next_steps() assert node not in flow.next_autosteps() with pytest.raises(InvalidState): flow.advance(node) flow.advance(node, enforce_predicate=False) # This will bypass the restriction assert flow._current_step == node # Matching predicate flow = Flow(root, somebody, {'toto': 'titui'}) assert node in flow.next_steps() assert node in flow.next_autosteps() flow.advance(node) assert flow._current_step == node def test_autotrigger(): root = FlowRoot("test", "This is my flowroot") node = root.connect("a", lambda ctx: 'toto' in ctx and ctx['toto'] == 'titui', auto_trigger=True) assert node.command in root.auto_triggers errbot-6.1.1+ds/tests/i18n_plugin/000077500000000000000000000000001355337103200167175ustar00rootroot00000000000000errbot-6.1.1+ds/tests/i18n_plugin/i18n.plug000066400000000000000000000000701355337103200203640ustar00rootroot00000000000000[Core] Name = I18N Module = i18n [Python] Version = 2+ errbot-6.1.1+ds/tests/i18n_plugin/i18n.py000066400000000000000000000010001355337103200200370ustar00rootroot00000000000000# -*- coding=utf-8 -*- from errbot import BotPlugin, botcmd class I18nTest(BotPlugin): """A Just a test plugin to see if it is picked up. """ @botcmd def i18n_1(self, msg, args): return 'язы́к' @botcmd(name='ру́сский') def i18n_2(self, msg, args): return 'OK' @botcmd(name='prefix_ру́сский') def i18n_3(self, msg, args): return 'OK' @botcmd(name='ру́сский_suffix') def i18n_4(self, msg, args): return 'OK' errbot-6.1.1+ds/tests/i18n_test.py000066400000000000000000000012711355337103200167530ustar00rootroot00000000000000# -*- coding=utf-8 -*- from os import path # This is to test end2end i18n behavior. extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'i18n_plugin') def test_i18n_return(testbot): assert 'язы́к' in testbot.exec_command('!i18n 1') def test_i18n_simple_name(testbot): assert 'OK' in testbot.exec_command('!ру́сский') def test_i18n_prefix(testbot): assert 'OK' in testbot.exec_command('!prefix_ру́сский') assert 'OK' in testbot.exec_command('!prefix ру́сский') def test_i18n_suffix(testbot): assert 'OK' in testbot.exec_command('!ру́сский_suffix') assert 'OK' in testbot.exec_command('!ру́сский suffix') errbot-6.1.1+ds/tests/link_test.py000066400000000000000000000004041355337103200171260ustar00rootroot00000000000000# coding=utf-8 from os import path import pytest extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'test_link') def test_linked_plugin_here(testbot): testbot.push_message('!status plugins') assert 'Dummy' in testbot.pop_message() errbot-6.1.1+ds/tests/matchall_plugin/000077500000000000000000000000001355337103200177255ustar00rootroot00000000000000errbot-6.1.1+ds/tests/matchall_plugin/matchall.plug000066400000000000000000000000521355337103200224000ustar00rootroot00000000000000[Core] Name = MatchAll Module = matchall errbot-6.1.1+ds/tests/matchall_plugin/matchall.py000066400000000000000000000002201355337103200220560ustar00rootroot00000000000000from errbot import BotPlugin, botmatch class MatchAll(BotPlugin): @botmatch(r".*") def all(self, msg, match): return 'Works!' errbot-6.1.1+ds/tests/matchall_test.py000066400000000000000000000004741355337103200177650ustar00rootroot00000000000000# coding=utf-8 from os import path import pytest extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'matchall_plugin') def test_botmatch_correct(testbot): assert 'Works!' in testbot.exec_command('hi hi hi') def test_botmatch(testbot): assert 'Works!' in testbot.exec_command('123123') errbot-6.1.1+ds/tests/md_rendering_test.py000066400000000000000000000013161355337103200206310ustar00rootroot00000000000000# vim: ts=4:sw=4 import logging from errbot import rendering log = logging.getLogger(__name__) def test_ansi(): mdc = rendering.ansi() assert mdc.convert("*woot*") == "\x1b[4mwoot\x1b[24m\x1b[0m" def test_text(): mdc = rendering.text() assert mdc.convert("*woot*") == "woot" assert mdc.convert("# woot") == "WOOT" def test_mde2md(): mdc = rendering.md() assert mdc.convert("woot") == "woot" assert mdc.convert("woot{:stuff} really{:otherstuff}") == "woot really" def test_escaping(): mdc = rendering.text() original = '#not a title\n*not italic*\n`not code`\ntoto{not annotation}' escaped = rendering.md_escape(original) assert original == mdc.convert(escaped) errbot-6.1.1+ds/tests/mention_plugin/000077500000000000000000000000001355337103200176115ustar00rootroot00000000000000errbot-6.1.1+ds/tests/mention_plugin/mention.plug000066400000000000000000000001741355337103200221550ustar00rootroot00000000000000[Core] Name = Mention Module = mention [Documentation] Description = An Errbot mention test plugin. [Python] Version = 2+ errbot-6.1.1+ds/tests/mention_plugin/mention.py000066400000000000000000000005121355337103200216320ustar00rootroot00000000000000from errbot import BotPlugin class MentionTestPlugin(BotPlugin): def callback_mention(self, msg, people): if self.bot_identifier in people: self.send(msg.frm, "Somebody mentioned me!", msg) return self.send(msg.frm, "Somebody mentioned %s!" % ','.join(p.person for p in people), msg) errbot-6.1.1+ds/tests/mention_test.py000066400000000000000000000010161355337103200176420ustar00rootroot00000000000000from os import path extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'mention_plugin') def test_foreign_mention(testbot): assert 'Somebody mentioned toto!' in testbot.exec_command('I am telling you something @toto') def test_testbot_mention(testbot): assert 'Somebody mentioned me!' in testbot.exec_command('I am telling you something @Err') def test_multiple_mentions(testbot): assert 'Somebody mentioned toto,titi!' in testbot.exec_command('I am telling you something @toto and @titi') errbot-6.1.1+ds/tests/muc_test.py000066400000000000000000000072071355337103200167650ustar00rootroot00000000000000import os import errbot.backends.base from errbot.backends.test import TestOccupant import logging log = logging.getLogger(__name__) extra_plugin_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'room_plugin') def test_plugin_methods(testbot): p = testbot.bot.plugin_manager.get_plugin_obj_by_name('ChatRoom') assert p is not None assert hasattr(p, 'rooms') assert hasattr(p, 'query_room') def test_create_join_leave_destroy_lifecycle(testbot): # noqa rooms = testbot.bot.rooms() assert len(rooms) == 1 r1 = rooms[0] assert str(r1) == "testroom" assert issubclass(r1.__class__, errbot.backends.base.Room) r2 = testbot.bot.query_room('testroom2') assert not r2.exists r2.create() assert r2.exists rooms = testbot.bot.rooms() assert r2 not in rooms assert not r2.joined r2.destroy() rooms = testbot.bot.rooms() assert r2 not in rooms r2.join() assert r2.exists assert r2.joined rooms = testbot.bot.rooms() assert r2 in rooms r2 = testbot.bot.query_room('testroom2') assert r2.joined r2.leave() assert not r2.joined r2.destroy() rooms = testbot.bot.rooms() assert r2 not in rooms def test_occupants(testbot): # noqa room = testbot.bot.rooms()[0] assert len(room.occupants) == 1 assert TestOccupant('err', 'testroom') in room.occupants def test_topic(testbot): # noqa room = testbot.bot.rooms()[0] assert room.topic is None room.topic = "Errbot rocks!" assert room.topic == "Errbot rocks!" assert testbot.bot.rooms()[0].topic == "Errbot rocks!" def test_plugin_callbacks(testbot): # noqa p = testbot.bot.plugin_manager.get_plugin_obj_by_name('RoomTest') assert p is not None p.purge() log.debug("query and join") p.query_room('newroom').join() assert p.events.get(timeout=5) == "callback_room_joined newroom" p.query_room('newroom').topic = "Errbot rocks!" assert p.events.get(timeout=5) == "callback_room_topic Errbot rocks!" p.query_room('newroom').leave() assert p.events.get(timeout=5) == "callback_room_left newroom" def test_botcommands(testbot): # noqa rooms = testbot.bot.rooms() room = testbot.bot.query_room('testroom') assert len(rooms) == 1 assert rooms[0] == room assert room.joined assert "Left the room testroom" in testbot.exec_command("!room leave testroom") room = testbot.bot.query_room('testroom') assert not room.joined assert "I'm not currently in any rooms." in testbot.exec_command("!room list") assert "Destroyed the room testroom" in testbot.exec_command("!room destroy testroom") rooms = testbot.bot.rooms() room = testbot.bot.query_room('testroom') assert not room.exists assert room not in rooms assert "Created the room testroom" in testbot.exec_command("!room create testroom") rooms = testbot.bot.rooms() room = testbot.bot.query_room('testroom') assert room.exists assert room not in rooms assert not room.joined assert "Joined the room testroom" in testbot.exec_command("!room join testroom") rooms = testbot.bot.rooms() room = testbot.bot.query_room('testroom') assert room.exists assert room.joined assert room in rooms assert "testroom" in testbot.exec_command("!room list") assert "err" in testbot.exec_command("!room occupants testroom") assert "No topic is set for testroom" in testbot.exec_command("!room topic testroom") assert "Topic for testroom set." in testbot.exec_command("!room topic testroom 'Errbot rocks!'") assert "Topic for testroom: Errbot rocks!" in testbot.exec_command("!room topic testroom") errbot-6.1.1+ds/tests/multi_plugin/000077500000000000000000000000001355337103200172725ustar00rootroot00000000000000errbot-6.1.1+ds/tests/multi_plugin/mp.py000066400000000000000000000003241355337103200202570ustar00rootroot00000000000000from errbot import BotPlugin, botcmd class WhateverName(BotPlugin): """Test plugin to verify that now class names don't matter. """ @botcmd def myname(self, msg, args): return self.name errbot-6.1.1+ds/tests/multi_plugin/mp1.plug000066400000000000000000000000411355337103200206530ustar00rootroot00000000000000[Core] Name = Multi1 Module = mp errbot-6.1.1+ds/tests/multi_plugin/mp2.plug000066400000000000000000000000411355337103200206540ustar00rootroot00000000000000[Core] Name = Multi2 Module = mp errbot-6.1.1+ds/tests/multi_plugin_test.py000066400000000000000000000007571355337103200207140ustar00rootroot00000000000000from os import path extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'multi_plugin') # This tests the decorellation between plugin class names and real names # by making 2 instances of the same plugin collide on purpose. def test_first(testbot): r = testbot.exec_command('!myname') assert 'Multi1' == r or 'Multi2' == r def test_second(testbot): assert 'Multi2' == testbot.exec_command('!multi2-myname') or 'Multi1' == testbot.exec_command('!multi1-myname') errbot-6.1.1+ds/tests/ngircd/000077500000000000000000000000001355337103200160305ustar00rootroot00000000000000errbot-6.1.1+ds/tests/ngircd/README000066400000000000000000000001151355337103200167050ustar00rootroot00000000000000This is just a minimal setup to run an ngircd server locally to test errbot. errbot-6.1.1+ds/tests/ngircd/go.sh000077500000000000000000000000411355337103200167670ustar00rootroot00000000000000ngircd --config ./ngircd.conf -n errbot-6.1.1+ds/tests/ngircd/ngircd.conf000066400000000000000000000342421355337103200201520ustar00rootroot00000000000000# # This is a sample configuration file for the ngIRCd IRC daemon, which must # be customized to the local preferences and needs. # # Comments are started with "#" or ";". # # A lot of configuration options in this file start with a ";". You have # to remove the ";" in front of each variable to actually set a value! # The disabled variables are shown with example values for completeness only # and the daemon is using compiled-in default settings. # # Use "ngircd --configtest" (see manual page ngircd(8)) to validate that the # server interprets the configuration file as expected! # # Please see ngircd.conf(5) for a complete list of configuration options # and their descriptions. # [Global] # The [Global] section of this file is used to define the main # configuration of the server, like the server name and the ports # on which the server should be listening. # These settings depend on your personal preferences, so you should # make sure that they correspond to your installation and setup! # Server name in the IRC network, must contain at least one dot # (".") and be unique in the IRC network. Required! Name = irc.gootz.net # Information about the server and the administrator, used by the # ADMIN command. Not required by server but by RFC! ;AdminInfo1 = Description ;AdminInfo2 = Location ;AdminEMail = admin@irc.server # Text file which contains the ngIRCd help text. This file is required # to display help texts when using the "HELP " command. ;HelpFile = /usr/share/doc/ngircd/Commands.txt # Info text of the server. This will be shown by WHOIS and # LINKS requests for example. Info = Server Info Text # Comma separated list of IP addresses on which the server should # listen. Default values are: # "0.0.0.0" or (if compiled with IPv6 support) "::,0.0.0.0" # so the server listens on all IP addresses of the system by default. Listen = 127.0.0.1 # Text file with the "message of the day" (MOTD). This message will # be shown to all users connecting to the server: ;MotdFile = /etc/ngircd.motd # A simple Phrase (<256 chars) if you don't want to use a motd file. ;MotdPhrase = "Hello world!" # The name of the IRC network to which this server belongs. This name # is optional, should only contain ASCII characters, and can't contain # spaces. It is only used to inform clients. The default is empty, # so no network name is announced to clients. ;Network = aIRCnetwork # Global password for all users needed to connect to the server. # (Default: not set) ;Password = abc # This tells ngIRCd to write its current process ID to a file. # Note that the pidfile is written AFTER chroot and switching the # user ID, e.g. the directory the pidfile resides in must be # writable by the ngIRCd user and exist in the chroot directory. ;PidFile = /var/run/ngircd/ngircd.pid # Ports on which the server should listen. There may be more than # one port, separated with ",". (Default: 6667) ;Ports = 6667, 6668, 6669 # Group ID under which the ngIRCd should run; you can use the name # of the group or the numerical ID. ATTENTION: For this to work the # server must have been started with root privileges! ;ServerGID = 65534 # User ID under which the server should run; you can use the name # of the user or the numerical ID. ATTENTION: For this to work the # server must have been started with root privileges! In addition, # the configuration and MOTD files must be readable by this user, # otherwise RESTART and REHASH won't work! ;ServerUID = 65534 [Limits] # Define some limits and timeouts for this ngIRCd instance. Default # values should be safe, but it is wise to double-check :-) # The server tries every seconds to establish a link # to not yet (or no longer) connected servers. ;ConnectRetry = 60 # Number of seconds after which the whole daemon should shutdown when # no connections are left active after handling at least one client # (0: never, which is the default). # This can be useful for testing or when ngIRCd is started using # "socket activation" with systemd(8), for example. ;IdleTimeout = 0 # Maximum number of simultaneous in- and outbound connections the # server is allowed to accept (0: unlimited): ;MaxConnections = 0 # Maximum number of simultaneous connections from a single IP address # the server will accept (0: unlimited): ;MaxConnectionsIP = 5 # Maximum number of channels a user can be member of (0: no limit): ;MaxJoins = 10 # Maximum length of an user nickname (Default: 9, as in RFC 2812). # Please note that all servers in an IRC network MUST use the same # maximum nickname length! ;MaxNickLength = 9 # Maximum number of channels returned in response to a /list # command (0: unlimited): ;MaxListSize = 100 # After seconds of inactivity the server will send a # PING to the peer to test whether it is alive or not. ;PingTimeout = 120 # If a client fails to answer a PING with a PONG within # seconds, it will be disconnected by the server. ;PongTimeout = 20 [Options] # Optional features and configuration options to further tweak the # behavior of ngIRCd. If you want to get started quickly, you most # probably don't have to make changes here -- they are all optional. # List of allowed channel types (channel prefixes) for newly created # channels on the local server. By default, all supported channel # types are allowed. Set this variable to the empty string to disallow # creation of new channels by local clients at all. ;AllowedChannelTypes = #&+ # Are remote IRC operators allowed to control this server, e.g. # use commands like CONNECT, SQUIT, DIE, ...? ;AllowRemoteOper = no # A directory to chroot in when everything is initialized. It # doesn't need to be populated if ngIRCd is compiled as a static # binary. By default ngIRCd won't use the chroot() feature. # ATTENTION: For this to work the server must have been started # with root privileges! ;ChrootDir = /var/empty # Set this hostname for every client instead of the real one. # Use %x to add the hashed value of the original hostname. ;CloakHost = cloaked.host # Use this hostname for hostname cloaking on clients that have the # user mode "+x" set, instead of the name of the server. # Use %x to add the hashed value of the original hostname. ;CloakHostModeX = cloaked.user # The Salt for cloaked hostname hashing. When undefined a random # hash is generated after each server start. ;CloakHostSalt = abcdefghijklmnopqrstuvwxyz # Set every clients' user name to their nickname ;CloakUserToNick = yes # Try to connect to other IRC servers using IPv4 and IPv6, if possible. ;ConnectIPv6 = yes ;ConnectIPv4 = yes # Default user mode(s) to set on new local clients. Please note that # only modes can be set that the client could set using regular MODE # commands, you can't set "a" (away) for example! Default: none. ;DefaultUserModes = i # Do DNS lookups when a client connects to the server. ;DNS = yes # Do IDENT lookups if ngIRCd has been compiled with support for it. # Users identified using IDENT are registered without the "~" character # prepended to their user name. ;Ident = yes # Directory containing configuration snippets (*.conf), that should # be read in after parsing this configuration file. ;IncludeDir = /etc/conf.d # Enhance user privacy slightly (useful for IRC server on TOR or I2P) # by censoring some information like idle time, logon time, etc. ;MorePrivacy = no # Normally ngIRCd doesn't send any messages to a client until it is # registered. Enable this option to let the daemon send "NOTICE *" # messages to clients while connecting. ;NoticeBeforeRegistration = no # Should IRC Operators be allowed to use the MODE command even if # they are not(!) channel-operators? ;OperCanUseMode = no # Should IRC Operators get AutoOp (+o) in persistent (+P) channels? ;OperChanPAutoOp = yes # Mask IRC Operator mode requests as if they were coming from the # server? (This is a compatibility hack for ircd-irc2 servers) ;OperServerMode = no # Use PAM if ngIRCd has been compiled with support for it. # Users identified using PAM are registered without the "~" character # prepended to their user name. ;PAM = yes # When PAM is enabled, all clients are required to be authenticated # using PAM; connecting to the server without successful PAM # authentication isn't possible. # If this option is set, clients not sending a password are still # allowed to connect: they won't become "identified" and keep the "~" # character prepended to their supplied user name. # Please note: To make some use of this behavior, it most probably # isn't useful to enable "Ident", "PAM" and "PAMIsOptional" at the # same time, because you wouldn't be able to distinguish between # Ident'ified and PAM-authenticated users: both don't have a "~" # character prepended to their respective user names! ;PAMIsOptional = no # Let ngIRCd send an "authentication PING" when a new client connects, # and register this client only after receiving the corresponding # "PONG" reply. ;RequireAuthPing = no # Silently drop all incoming CTCP requests. ;ScrubCTCP = no # Syslog "facility" to which ngIRCd should send log messages. # Possible values are system dependent, but most probably auth, daemon, # user and local1 through local7 are possible values; see syslog(3). # Default is "local5" for historical reasons, you probably want to # change this to "daemon", for example. ;SyslogFacility = local1 # Password required for using the WEBIRC command used by some # Web-to-IRC gateways. If not set/empty, the WEBIRC command can't # be used. (Default: not set) ;WebircPassword = xyz ;[SSL] # SSL-related configuration options. Please note that this section # is only available when ngIRCd is compiled with support for SSL! # So don't forget to remove the ";" above if this is the case ... # SSL Server Key Certificate ;CertFile = /etc/ssl/server-cert.pem # Select cipher suites allowed for SSL/TLS connections. This defaults # to HIGH:!aNULL:@STRENGTH (OpenSSL) or SECURE128 (GnuTLS). # See 'man 1ssl ciphers' (OpenSSL) or 'man 3 gnutls_priority_init' # (GnuTLS) for details. # For OpenSSL: ;CipherList = HIGH:!aNULL:@STRENGTH:!SSLv3 # For GnuTLS: ;CipherList = SECURE128:-VERS-SSL3.0 # Diffie-Hellman parameters ;DHFile = /etc/ssl/dhparams.pem # SSL Server Key ;KeyFile = /etc/ssl/server-key.pem # password to decrypt SSLKeyFile (OpenSSL only) ;KeyFilePassword = secret # Additional Listen Ports that expect SSL/TLS encrypted connections ;Ports = 6697, 9999 [Operator] # [Operator] sections are used to define IRC Operators. There may be # more than one [Operator] block, one for each local operator. # ID of the operator (may be different of the nickname) ;Name = TheOper # Password of the IRC operator ;Password = ThePwd # Optional Mask from which /OPER will be accepted ;Mask = *!ident@somewhere.example.com [Operator] # More [Operator] sections, if you like ... [Server] # Other servers are configured in [Server] sections. If you # configure a port for the connection, then this ngircd tries to # connect to to the other server on the given port; if not it waits # for the other server to connect. # There may be more than one server block, one for each server. # # Server Groups: # The ngIRCd allows "server groups": You can assign an "ID" to every # server with which you want this ngIRCd to link. If a server of a # group won't answer, the ngIRCd tries to connect to the next server # in the given group. But the ngircd never tries to connect to two # servers with the same group ID. # IRC name of the remote server, must match the "Name" variable in # the [Global] section of the other server (when using ngIRCd). ;Name = irc2.example.net # Internet host name or IP address of the peer (only required when # this server should establish the connection). ;Host = connect-to-host.example.net # IP address to use as _source_ address for the connection. if # unspecified, ngircd will let the operating system pick an address. ;Bind = 10.0.0.1 # Port of the server to which the ngIRCd should connect. If you # assign no port the ngIRCd waits for incoming connections. ;Port = 6667 # Own password for the connection. This password has to be configured # as "PeerPassword" on the other server. ;MyPassword = MySecret # Foreign password for this connection. This password has to be # configured as "MyPassword" on the other server. ;PeerPassword = PeerSecret # Group of this server (optional) ;Group = 123 # Set the "Passive" option to "yes" if you don't want this ngIRCd to # connect to the configured peer (same as leaving the "Port" variable # empty). The advantage of this option is that you can actually # configure a port an use the IRC command CONNECT more easily to # manually connect this specific server later. ;Passive = no # Connect to the remote server using TLS/SSL (Default: false) ;SSLConnect = yes # Define a (case insensitive) list of masks matching nicknames that # should be treated as IRC services when introduced via this remote # server, separated by commas (","). # REGULAR SERVERS DON'T NEED this parameter, so leave it empty # (which is the default). # When you are connecting IRC services which mask as a IRC server # and which use "virtual users" to communicate with, for example # "NickServ" and "ChanServ", you should set this parameter to # something like "*Serv" or "NickServ,ChanServ,XyzServ". ;ServiceMask = *Serv,Global [Server] # More [Server] sections, if you like ... [Channel] # Pre-defined channels can be configured in [Channel] sections. # Such channels are created by the server when starting up and even # persist when there are no more members left. # Persistent channels are marked with the mode 'P', which can be set # and unset by IRC operators like other modes on the fly. # There may be more than one [Channel] block, one for each channel. # Name of the channel ;Name = #TheName # Topic for this channel ;Topic = a great topic # Initial channel modes ;Modes = tnk # initial channel password (mode k) ;Key = Secret # Key file, syntax for each line: "::". # Default: none. ;KeyFile = /etc/#chan.key # maximum users per channel (mode l) ;MaxUsers = 23 [Channel] # More [Channel] sections, if you like ... # -eof- errbot-6.1.1+ds/tests/persistence_test.py000066400000000000000000000007311355337103200205200ustar00rootroot00000000000000from errbot.storage import StoreMixin from errbot.storage.memory import MemoryStoragePlugin def test_simple_store_retreive(): sm = StoreMixin() sm.open_storage(MemoryStoragePlugin(None), 'ns') sm['toto'] = 'titui' assert sm['toto'] == 'titui' def test_mutable(): sm = StoreMixin() sm.open_storage(MemoryStoragePlugin(None), 'ns') sm['toto'] = [1, 3] with sm.mutable('toto') as titi: titi[1] = 5 assert sm['toto'] == [1, 5] errbot-6.1.1+ds/tests/plugin_config_fail_test.py000066400000000000000000000004411355337103200220100ustar00rootroot00000000000000from os import path extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'fail_config_plugin') def test_failed_config(testbot): assert 'Incorrect plugin configuration: Message explaining why it failed.' \ in testbot.exec_command('!plugin config Failp {}') errbot-6.1.1+ds/tests/plugin_config_test.py000066400000000000000000000055551355337103200210300ustar00rootroot00000000000000from os import path from errbot.botplugin import recurse_check_structure, ValidationException import pytest extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'config_plugin') def test_recurse_check_structure_valid(): sample = dict(string="Foobar", list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True, false=False) to_check = dict(string="Foobar", list=["Foo", "Bar", "Bas"], dict={'foo': "Bar"}, none=None, true=True, false=False) recurse_check_structure(sample, to_check) def test_recurse_check_structure_missingitem(): sample = dict(string="Foobar", list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True, false=False) to_check = dict(string="Foobar", list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True) with pytest.raises(ValidationException): recurse_check_structure(sample, to_check) def test_recurse_check_structure_extrasubitem(): sample = dict(string="Foobar", list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True, false=False) to_check = dict(string="Foobar", list=["Foo", "Bar", "Bas"], dict={'foo': "Bar", 'Bar': "Foo"}, none=None, true=True, false=False) with pytest.raises(ValidationException): recurse_check_structure(sample, to_check) def test_recurse_check_structure_missingsubitem(): sample = dict(string="Foobar", list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True, false=False) to_check = dict(string="Foobar", list=["Foo", "Bar", "Bas"], dict={}, none=None, true=True, false=False) with pytest.raises(ValidationException): recurse_check_structure(sample, to_check) def test_recurse_check_structure_wrongtype_1(): sample = dict(string="Foobar", list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True, false=False) to_check = dict(string=None, list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True, false=False) with pytest.raises(ValidationException): recurse_check_structure(sample, to_check) def test_recurse_check_structure_wrongtype_2(): sample = dict(string="Foobar", list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True, false=False) to_check = dict(string="Foobar", list={'foo': "Bar"}, dict={'foo': "Bar"}, none=None, true=True, false=False) with pytest.raises(ValidationException): recurse_check_structure(sample, to_check) def test_recurse_check_structure_wrongtype_3(): sample = dict(string="Foobar", list=["Foo", "Bar"], dict={'foo': "Bar"}, none=None, true=True, false=False) to_check = dict(string="Foobar", list=["Foo", "Bar"], dict=["Foo", "Bar"], none=None, true=True, false=False) with pytest.raises(ValidationException): recurse_check_structure(sample, to_check) def test_failed_config(testbot): assert 'Plugin configuration done.' in testbot.exec_command('!plugin config Config {"One": "two"}') errbot-6.1.1+ds/tests/plugin_info_test.py000066400000000000000000000026651355337103200205150ustar00rootroot00000000000000import sys import pytest from io import StringIO from pathlib import Path from errbot.plugin_info import PluginInfo plugfile_base = Path(__file__).absolute().parent / 'config_plugin' plugfile_path = plugfile_base / 'config.plug' def test_load_from_plugfile_path(): pi = PluginInfo.load(plugfile_path) assert pi.name == 'Config' assert pi.module == 'config' assert pi.doc is None assert pi.python_version == (3, 0, 0) assert pi.errbot_minversion is None assert pi.errbot_maxversion is None @pytest.mark.parametrize('test_input,expected', [ ('2', (2, 0, 0)), ('2+', (3, 0, 0)), ('3', (3, 0, 0)), ('1.2.3', (1, 2, 3)), ('1.2.3-beta', (1, 2, 3)), ]) def test_python_version_parse(test_input, expected): f = StringIO(""" [Core] Name = Config Module = config [Python] Version = %s """ % test_input) assert PluginInfo.load_file(f, None).python_version == expected def test_doc(): f = StringIO(""" [Core] Name = Config Module = config [Documentation] Description = something """) assert PluginInfo.load_file(f, None).doc == 'something' def test_errbot_version(): f = StringIO(""" [Core] Name = Config Module = config [Errbot] Min = 1.2.3 Max = 4.5.6-beta """) info = PluginInfo.load_file(f, None) assert info.errbot_minversion == (1, 2, 3, sys.maxsize) assert info.errbot_maxversion == (4, 5, 6, 0) errbot-6.1.1+ds/tests/plugin_management_test.py000066400000000000000000000101531355337103200216650ustar00rootroot00000000000000import os import pytest import tempfile from configparser import ConfigParser from pathlib import Path import errbot.repo_manager from errbot import plugin_manager from errbot.plugin_info import PluginInfo from errbot.plugin_manager import IncompatiblePluginException from errbot.utils import find_roots, collect_roots CORE_PLUGINS = plugin_manager.CORE_PLUGINS def touch(name): with open(name, 'a'): os.utime(name, None) assets = Path(__file__).parent / 'assets' def test_check_dependencies(): response, deps = errbot.repo_manager.check_dependencies(assets / 'requirements_never_there.txt') assert 'You need these dependencies for' in response assert 'impossible_requirement' in response assert ['impossible_requirement'] == deps def test_check_dependencies_no_requirements_file(): response, deps = errbot.repo_manager.check_dependencies(assets / 'requirements_non_existent.txt') assert response is None def test_check_dependencies_requirements_file_all_installed(): response, deps = errbot.repo_manager.check_dependencies(assets / 'requirements_already_there.txt') assert response is None def test_find_plugin_roots(): root = tempfile.mkdtemp() a = os.path.join(root, 'a') b = os.path.join(a, 'b') c = os.path.join(root, 'c') os.mkdir(a) os.mkdir(b) os.mkdir(c) touch(os.path.join(a, 'toto.plug')) touch(os.path.join(b, 'titi.plug')) touch(os.path.join(root, 'tutu.plug')) roots = find_roots(root) assert root in roots assert a in roots assert b in roots assert c not in roots def test_collect_roots(): toto = tempfile.mkdtemp() touch(os.path.join(toto, 'toto.plug')) touch(os.path.join(toto, 'titi.plug')) titi = tempfile.mkdtemp() touch(os.path.join(titi, 'tata.plug')) tutu = tempfile.mkdtemp() subtutu = os.path.join(tutu, 'subtutu') os.mkdir(subtutu) touch(os.path.join(subtutu, 'tutu.plug')) assert collect_roots((CORE_PLUGINS, None)) == {CORE_PLUGINS, } assert collect_roots((CORE_PLUGINS, toto)) == {CORE_PLUGINS, toto} assert collect_roots((CORE_PLUGINS, [toto, titi])) == {CORE_PLUGINS, toto, titi} assert collect_roots((CORE_PLUGINS, toto, titi, 'nothing')) == {CORE_PLUGINS, toto, titi} assert collect_roots((toto, tutu)) == {toto, subtutu} def test_ignore_dotted_directories(): root = tempfile.mkdtemp() a = os.path.join(root, '.invisible') os.mkdir(a) touch(os.path.join(a, 'toto.plug')) assert collect_roots((CORE_PLUGINS, root)) == {CORE_PLUGINS, } def dummy_config_parser() -> ConfigParser: cp = ConfigParser() cp.add_section('Core') cp.set('Core', 'Name', 'dummy') cp.set('Core', 'Module', 'dummy') cp.add_section('Errbot') return cp def test_errbot_version_check(): real_version = plugin_manager.VERSION too_high_min_1 = dummy_config_parser() too_high_min_1.set('Errbot', 'Min', '1.6.0') too_high_min_2 = dummy_config_parser() too_high_min_2.set('Errbot', 'Min', '1.6.0') too_high_min_2.set('Errbot', 'Max', '2.0.0') too_low_max_1 = dummy_config_parser() too_low_max_1.set('Errbot', 'Max', '1.0.1-beta') too_low_max_2 = dummy_config_parser() too_low_max_2.set('Errbot', 'Min', '0.9.0-rc2') too_low_max_2.set('Errbot', 'Max', '1.0.1-beta') ok1 = dummy_config_parser() # empty section ok2 = dummy_config_parser() ok2.set('Errbot', 'Min', '1.4.0') ok3 = dummy_config_parser() ok3.set('Errbot', 'Max', '1.5.2') ok4 = dummy_config_parser() ok4.set('Errbot', 'Min', '1.2.1') ok4.set('Errbot', 'Max', '1.6.1-rc1') try: plugin_manager.VERSION = '1.5.2' for config in (too_high_min_1, too_high_min_2, too_low_max_1, too_low_max_2): pi = PluginInfo.parse(config) with pytest.raises(IncompatiblePluginException): plugin_manager.check_errbot_version(pi) for config in (ok1, ok2, ok3, ok4): pi = PluginInfo.parse(config) plugin_manager.check_errbot_version(pi) finally: plugin_manager.VERSION = real_version errbot-6.1.1+ds/tests/poller_plugin/000077500000000000000000000000001355337103200174355ustar00rootroot00000000000000errbot-6.1.1+ds/tests/poller_plugin/poller_plugin.plug000066400000000000000000000001111355337103200231720ustar00rootroot00000000000000[Core] Name = PollerPlugin Module = poller_plugin [Python] Version = 2+ errbot-6.1.1+ds/tests/poller_plugin/poller_plugin.py000066400000000000000000000014601355337103200226630ustar00rootroot00000000000000from __future__ import absolute_import from errbot import BotPlugin, botcmd class PollerPlugin(BotPlugin): def delayed_hello(self, frm): self.send(frm, 'Hello world! was sent 5 seconds ago') @botcmd def hello(self, msg, args): """Say hello to the world.""" self.start_poller(0.1, self.delayed_hello, times=1, kwargs={'frm': msg.frm}) return "Hello, world!" def delayed_hello_loop(self, frm): self.send(frm, 'Hello world! was sent 5 seconds ago') self.stop_poller(self.delayed_hello_loop, args=(frm, )) @botcmd def hello_loop(self, msg, args): """Say hello to the world.""" self.start_poller(0.1, self.delayed_hello_loop, args=(msg.frm, )) return "Hello, world!" errbot-6.1.1+ds/tests/poller_test.py000066400000000000000000000015001355337103200174640ustar00rootroot00000000000000from os import path import time CURRENT_FILE_DIR = path.dirname(path.realpath(__file__)) extra_plugin_dir = path.join(CURRENT_FILE_DIR, 'poller_plugin') def test_delayed_hello(testbot): assert 'Hello, world!' in testbot.exec_command('!hello') time.sleep(1) delayed_msg = 'Hello world! was sent 5 seconds ago' assert delayed_msg in testbot.pop_message(timeout=1) # Assert that only one message has been enqueued assert testbot.bot.outgoing_message_queue.empty() def test_delayed_hello_loop(testbot): assert 'Hello, world!' in testbot.exec_command('!hello_loop') time.sleep(1) delayed_msg = 'Hello world! was sent 5 seconds ago' assert delayed_msg in testbot.pop_message(timeout=1) # Assert that only one message has been enqueued assert testbot.bot.outgoing_message_queue.empty() errbot-6.1.1+ds/tests/repo_manager_test.py000066400000000000000000000101331355337103200206300ustar00rootroot00000000000000import tempfile import shutil import os import pytest from errbot import repo_manager from errbot.storage.memory import MemoryStoragePlugin assets = os.path.join(os.path.dirname(__file__), 'assets') @pytest.fixture def plugdir_and_storage(request): plugins_dir = tempfile.mkdtemp() storage_plugin = MemoryStoragePlugin('repomgr') def on_finish(): shutil.rmtree(plugins_dir) request.addfinalizer(on_finish) return plugins_dir, storage_plugin def test_index_population(plugdir_and_storage): plugdir, storage = plugdir_and_storage manager = repo_manager.BotRepoManager(storage, plugdir, (os.path.join(assets, 'repos', 'simple.json'),)) manager.index_update() index_entry = manager[repo_manager.REPO_INDEX] assert repo_manager.LAST_UPDATE in index_entry assert 'pluginname1' in index_entry['name1/err-reponame1'] assert 'pluginname2' in index_entry['name2/err-reponame2'] def test_index_merge(plugdir_and_storage): plugdir, storage = plugdir_and_storage manager = repo_manager.BotRepoManager(storage, plugdir, (os.path.join(assets, 'repos', 'b.json'), os.path.join(assets, 'repos', 'a.json'),)) manager.index_update() index_entry = manager[repo_manager.REPO_INDEX] # First they should be all here assert 'pluginname1' in index_entry['name1/err-reponame1'] assert 'pluginname2' in index_entry['name2/err-reponame2'] assert 'pluginname3' in index_entry['name3/err-reponame3'] # then it must be the correct one of the overriden one assert index_entry['name2/err-reponame2']['pluginname2']['name'] == 'NewPluginName2' def test_reverse_merge(plugdir_and_storage): plugdir, storage = plugdir_and_storage manager = repo_manager.BotRepoManager(storage, plugdir, (os.path.join(assets, 'repos', 'a.json'), os.path.join(assets, 'repos', 'b.json'),)) manager.index_update() index_entry = manager[repo_manager.REPO_INDEX] assert not index_entry['name2/err-reponame2']['pluginname2']['name'] == 'NewPluginName2' def test_no_update_if_one_fails(plugdir_and_storage): plugdir, storage = plugdir_and_storage manager = repo_manager.BotRepoManager(storage, plugdir, (os.path.join(assets, 'repos', 'a.json'), os.path.join(assets, 'repos', 'doh.json'),)) manager.index_update() assert repo_manager.REPO_INDEX not in manager def test_tokenization(): e = { "python": "2+", "repo": "https://github.com/name/err-reponame1", "path": "/plugin1.plug", "avatar_url": "https://avatars.githubusercontent.com/u/588833?v=3", "name": "PluginName1", "documentation": "docs1" } words = { 'https', 'com', 'name', 'err', 'docs1', 'reponame1', 'plug', '2', 'plugin1', 'avatars', 'github', 'githubusercontent', 'u', 'v', '3', '588833', 'pluginname1' } assert repo_manager.tokenizeJsonEntry(e) == words def test_search(plugdir_and_storage): plugdir, storage = plugdir_and_storage manager = repo_manager.BotRepoManager(storage, plugdir, (os.path.join(assets, 'repos', 'simple.json'),)) a = [p for p in manager.search_repos('docs2')] assert len(a) == 1 assert a[0].name == 'pluginname2' a = [p for p in manager.search_repos('zorg')] assert len(a) == 0 a = [p for p in manager.search_repos('plug')] assert len(a) == 2 def test_git_url_name_guessing(): assert repo_manager.human_name_for_git_url('https://github.com/errbotio/err-imagebot.git') \ == 'errbotio/err-imagebot' errbot-6.1.1+ds/tests/requirements.txt000066400000000000000000000000051355337103200200410ustar00rootroot00000000000000mock errbot-6.1.1+ds/tests/room_plugin/000077500000000000000000000000001355337103200171145ustar00rootroot00000000000000errbot-6.1.1+ds/tests/room_plugin/roomtest.plug000066400000000000000000000001001355337103200216500ustar00rootroot00000000000000[Core] Name = RoomTest Module = roomtest [Python] Version = 2+ errbot-6.1.1+ds/tests/room_plugin/roomtest.py000066400000000000000000000011701355337103200213410ustar00rootroot00000000000000from errbot import BotPlugin from queue import Queue import logging log = logging.getLogger(__name__) class RoomTest(BotPlugin): def activate(self): super().activate() self.purge() def callback_room_joined(self, room): log.info("join") self.events.put("callback_room_joined {!s}".format(room)) def callback_room_left(self, room): self.events.put("callback_room_left {!s}".format(room)) def callback_room_topic(self, room): self.events.put("callback_room_topic {}".format(room.topic)) def purge(self): log.info("purge") self.events = Queue() errbot-6.1.1+ds/tests/simple_identifiers_test.py000066400000000000000000000012651355337103200220550ustar00rootroot00000000000000from errbot.backends.test import TestPerson, TestOccupant def test_identifier_eq(): a = TestPerson("foo") b = TestPerson("foo") assert a == b def test_identifier_ineq(): a = TestPerson("foo") b = TestPerson("bar") assert not a == b assert a != b def test_mucidentifier_eq(): a = TestOccupant("foo", "room") b = TestOccupant("foo", "room") assert a == b def test_mucidentifier_ineq1(): a = TestOccupant("foo", "room") b = TestOccupant("bar", "room") assert not a == b assert a != b def test_mucidentifier_ineq2(): a = TestOccupant("foo", "room1") b = TestOccupant("foo", "room2") assert not a == b assert a != b errbot-6.1.1+ds/tests/streaming_test.py000066400000000000000000000010241355337103200201610ustar00rootroot00000000000000from io import BytesIO from errbot.backends.test import TestPerson from errbot.streaming import Tee from errbot.backends.base import Stream class StreamingClient(object): def callback_stream(self, stream): self.response = stream.read() def test_streaming(): canary = b'this is my test' * 1000 source = Stream(TestPerson("gbin@gootz.net"), BytesIO(canary)) clients = [StreamingClient() for _ in range(50)] Tee(source, clients).run() for client in clients: assert client.response == canary errbot-6.1.1+ds/tests/syntax_plugin/000077500000000000000000000000001355337103200174665ustar00rootroot00000000000000errbot-6.1.1+ds/tests/syntax_plugin/syntax.plug000066400000000000000000000000711355337103200217030ustar00rootroot00000000000000[Core] Name = Dummy Module = test [Python] Version = 2+ errbot-6.1.1+ds/tests/syntax_plugin/test.py000066400000000000000000000012241355337103200210160ustar00rootroot00000000000000from __future__ import absolute_import from errbot import BotPlugin, botcmd, re_botcmd, arg_botcmd class Test(BotPlugin): """Just a test plugin to see if _err_botcmd_syntax is consistent on all types of botcmd """ @botcmd # no syntax def foo_nosyntax(self, msg, args): pass @botcmd(syntax='[optional] ') def foo(self, msg, args): pass @re_botcmd(pattern=r".*") def re_foo(self, msg, match): pass @arg_botcmd('value', type=str) @arg_botcmd('--repeat-count', dest='repeat', type=int, default=2) def arg_foo(self, msg, value=None, repeat=None): return value * repeat errbot-6.1.1+ds/tests/syntax_test.py000066400000000000000000000010661355337103200175240ustar00rootroot00000000000000from os import path extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'syntax_plugin') def test_nosyntax(testbot): assert testbot.bot.commands['foo_nosyntax']._err_command_syntax is None def test_syntax(testbot): assert testbot.bot.commands['foo']._err_command_syntax == '[optional] ' def test_re_syntax(testbot): assert testbot.bot.re_commands['re_foo']._err_command_syntax == '.*' def test_arg_syntax(testbot): assert testbot.bot.commands['arg_foo']._err_command_syntax == '[-h] [--repeat-count REPEAT] value' errbot-6.1.1+ds/tests/template_plugin/000077500000000000000000000000001355337103200177535ustar00rootroot00000000000000errbot-6.1.1+ds/tests/template_plugin/templates/000077500000000000000000000000001355337103200217515ustar00rootroot00000000000000errbot-6.1.1+ds/tests/template_plugin/templates/test.md000066400000000000000000000000141355337103200232450ustar00rootroot00000000000000{{variable}}errbot-6.1.1+ds/tests/template_plugin/test.py000066400000000000000000000011151355337103200213020ustar00rootroot00000000000000from __future__ import absolute_import from errbot import BotPlugin, botcmd, re_botcmd, arg_botcmd class Test(BotPlugin): @botcmd def test_template1(self, msg, args): self.send_templated(msg.frm, 'test', {'variable': 'ok'}) @botcmd(template='test') def test_template2(self, msg, args): return {'variable': 'ok'} @botcmd(template='test') def test_template3(self, msg, args): yield {'variable': 'ok'} @arg_botcmd('my_var', type=str, template='test') def test_template4(self, msg, my_var=None): return {'variable': my_var} errbot-6.1.1+ds/tests/template_plugin/tplug.plug000066400000000000000000000000421355337103200217730ustar00rootroot00000000000000[Core] Name = TPlug Module = test errbot-6.1.1+ds/tests/templates_test.py000066400000000000000000000012171355337103200201720ustar00rootroot00000000000000from os import path # This is to test end2end i18n behavior. extra_plugin_dir = path.join(path.dirname(path.realpath(__file__)), 'template_plugin') def test_templates_1(testbot): assert 'ok' in testbot.exec_command('!test template1') def test_templates_2(testbot): assert 'ok' in testbot.exec_command('!test template2') def test_templates_3(testbot): assert 'ok' in testbot.exec_command('!test template3') def test_templates_4(testbot): assert 'ok' in testbot.exec_command('!test template4 ok') def test_templates_5(testbot): assert 'the following arguments are required: my_var' in testbot.exec_command('!test template4') errbot-6.1.1+ds/tests/test_link/000077500000000000000000000000001355337103200165565ustar00rootroot00000000000000errbot-6.1.1+ds/tests/test_link/dummy_plugin000077700000000000000000000000001355337103200240542../dummy_pluginustar00rootroot00000000000000errbot-6.1.1+ds/tests/utils_test.py000066400000000000000000000061731355337103200173420ustar00rootroot00000000000000# coding=utf-8 from datetime import timedelta import pytest from errbot.backends.test import ShallowConfig from errbot.bootstrap import CORE_STORAGE, bot_config_defaults from errbot.backend_plugin_manager import BackendPluginManager from errbot.storage.base import StoragePluginBase from errbot.utils import * from errbot.storage import StoreMixin log = logging.getLogger(__name__) @pytest.mark.parametrize('v1,v2', [ ('2.0.0', '2.0.1'), ('2.0.0', '2.1.0'), ('2.0.0', '3.0.0'), ('2.0.0-alpha', '2.0.0-beta'), ('2.0.0-beta', '2.0.0-rc1'), ('2.0.0-rc1', '2.0.0-rc2'), ('2.0.0-rc2', '2.0.0-rc3'), ('2.0.0-rc2', '2.0.0'), ('2.0.0-beta', '2.0.1'), ]) def test_version_check(v1, v2): assert version2tuple(v1) < version2tuple(v2) @pytest.mark.parametrize('version', [ '1.2.3.4', '1.2', '1.2.-beta', '1.2.3-toto', '1.2.3-rc', ]) def test_version_check_negative(version): with pytest.raises(ValueError): version2tuple(version) def test_formattimedelta(): td = timedelta(0, 60 * 60 + 13 * 60) assert '1 hours and 13 minutes' == format_timedelta(td) def test_storage(): key = 'test' __import__('errbot.config-template') config = ShallowConfig() config.__dict__.update(sys.modules['errbot.config-template'].__dict__) bot_config_defaults(config) spm = BackendPluginManager(config, 'errbot.storage', 'Memory', StoragePluginBase, CORE_STORAGE) storage_plugin = spm.load_plugin() persistent_object = StoreMixin() persistent_object.open_storage(storage_plugin, 'test') persistent_object[key] = 'à value' assert persistent_object[key] == 'à value' assert key in persistent_object del persistent_object[key] assert key not in persistent_object assert len(persistent_object) == 0 def test_split_string_after_returns_original_string_when_chunksize_equals_string_size(): str_ = 'foobar2000' * 2 splitter = split_string_after(str_, len(str_)) split = [chunk for chunk in splitter] assert [str_] == split def test_split_string_after_returns_original_string_when_chunksize_equals_string_size_plus_one(): str_ = 'foobar2000' * 2 splitter = split_string_after(str_, len(str_) + 1) split = [chunk for chunk in splitter] assert [str_] == split def test_split_string_after_returns_two_chunks_when_chunksize_equals_string_size_minus_one(): str_ = 'foobar2000' * 2 splitter = split_string_after(str_, len(str_) - 1) split = [chunk for chunk in splitter] assert ['foobar2000foobar200', '0'] == split def test_split_string_after_returns_two_chunks_when_chunksize_equals_half_length_of_string(): str_ = 'foobar2000' * 2 splitter = split_string_after(str_, int(len(str_) / 2)) split = [chunk for chunk in splitter] assert ['foobar2000', 'foobar2000'] == split errbot-6.1.1+ds/tests/webhooks_plugin/000077500000000000000000000000001355337103200177615ustar00rootroot00000000000000errbot-6.1.1+ds/tests/webhooks_plugin/webtest.plug000066400000000000000000000001251355337103200223250ustar00rootroot00000000000000[Core] Name = Test hooks for webhooks testing Module = webtest [Python] Version = 2+errbot-6.1.1+ds/tests/webhooks_plugin/webtest.py000066400000000000000000000026251355337103200220150ustar00rootroot00000000000000import logging from errbot import BotPlugin from errbot.core_plugins.webserver import webhook from flask import abort, after_this_request log = logging.getLogger(__name__) class WebTest(BotPlugin): @webhook def webhook1(self, payload): log.debug(str(payload)) return str(payload) @webhook(r'/custom_webhook') def webhook2(self, payload): log.debug(str(payload)) return str(payload) @webhook(r'/form', form_param='form') def webhook3(self, payload): log.debug(str(payload)) return str(payload) @webhook(r'/custom_form', form_param='form') def webhook4(self, payload): log.debug(str(payload)) return str(payload) @webhook(r'/raw', raw=True) def webhook5(self, payload): log.debug(str(payload)) return str(type(payload)) @webhook def webhook6(self, payload): log.debug(str(payload)) @after_this_request def add_header(response): response.headers['X-Powered-By'] = 'Errbot' return response return str(payload) @webhook def webhook7(self, payload): abort(403, "Forbidden") webhook8 = webhook(r'/lambda')(lambda x, y: str(x) + str(y)) # Just to test https://github.com/errbotio/errbot/issues/1043 @webhook(raw=True) def raw2(self, payload): log.debug(str(payload)) return str(type(payload)) errbot-6.1.1+ds/tests/webhooks_test.py000066400000000000000000000135511355337103200200210ustar00rootroot00000000000000import json import logging import os import pytest import requests import socket from errbot.backends.test import FullStackTest, testbot from time import sleep log = logging.getLogger(__name__) PYTHONOBJECT = ['foo', {'bar': ('baz', None, 1.0, 2)}] JSONOBJECT = json.dumps(PYTHONOBJECT) # Webserver port is picked based on the process ID so that when tests # are run in parallel with pytest-xdist, each process runs the server # on a different port WEBSERVER_PORT = 5000 + (os.getpid() % 1000) WEBSERVER_SSL_PORT = WEBSERVER_PORT + 1000 def webserver_ready(host, port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.connect((host, port)) s.shutdown(socket.SHUT_RDWR) s.close() return True except Exception: return False extra_plugin_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'webhooks_plugin') def wait_for_server(port: int): failure_count = 10 while not webserver_ready('localhost', port): waiting_time = 1.0 / failure_count log.info('Webserver not ready yet, sleeping for %f second.', waiting_time) sleep(waiting_time) failure_count -= 1 if failure_count == 0: raise TimeoutError("Could not start the internal Webserver to test.") @pytest.fixture def webhook_testbot(request, testbot): testbot.push_message("!plugin config Webserver {'HOST': 'localhost', 'PORT': %s, 'SSL': None}" % WEBSERVER_PORT) log.info(testbot.pop_message()) wait_for_server(WEBSERVER_PORT) return testbot def test_not_configured_url_returns_404(webhook_testbot): assert requests.post( 'http://localhost:{}/randomness_blah'.format(WEBSERVER_PORT), "{'toto': 'titui'}" ).status_code == 404 def test_webserver_plugin_ok(webhook_testbot): assert "/echo" in webhook_testbot.exec_command("!webstatus") def test_trailing_no_slash_ok(webhook_testbot): assert requests.post( 'http://localhost:{}/echo'.format(WEBSERVER_PORT), JSONOBJECT ).text == repr(json.loads(JSONOBJECT)) def test_trailing_slash_also_ok(webhook_testbot): assert requests.post( 'http://localhost:{}/echo/'.format(WEBSERVER_PORT), JSONOBJECT ).text == repr(json.loads(JSONOBJECT)) def test_json_is_automatically_decoded(webhook_testbot): assert requests.post( 'http://localhost:{}/webhook1'.format(WEBSERVER_PORT), JSONOBJECT ).text == repr(json.loads(JSONOBJECT)) def test_json_on_custom_url_is_automatically_decoded(webhook_testbot): assert requests.post( 'http://localhost:{}/custom_webhook'.format(WEBSERVER_PORT), JSONOBJECT ).text == repr(json.loads(JSONOBJECT)) def test_post_form_on_webhook_without_form_param_is_automatically_decoded(webhook_testbot): assert requests.post( 'http://localhost:{}/webhook1'.format(WEBSERVER_PORT), data=JSONOBJECT ).text == repr(json.loads(JSONOBJECT)) def test_post_form_on_webhook_with_custom_url_and_without_form_param_is_automatically_decoded(webhook_testbot): assert requests.post( 'http://localhost:{}/custom_webhook'.format(WEBSERVER_PORT), data=JSONOBJECT ).text == repr(json.loads(JSONOBJECT)) def test_webhooks_with_form_parameter_decode_json_automatically(webhook_testbot): form = {'form': JSONOBJECT} assert requests.post( 'http://localhost:{}/form'.format(WEBSERVER_PORT), data=form ).text == repr(json.loads(JSONOBJECT)) def test_webhooks_with_form_parameter_on_custom_url_decode_json_automatically(webhook_testbot): form = {'form': JSONOBJECT} assert requests.post( 'http://localhost:{}/custom_form'.format(WEBSERVER_PORT), data=form ).text, repr(json.loads(JSONOBJECT)) def test_webhooks_with_raw_request(webhook_testbot): form = {'form': JSONOBJECT} assert 'LocalProxy' in requests.post('http://localhost:{}/raw'.format(WEBSERVER_PORT), data=form).text def test_webhooks_with_naked_decorator_raw_request(webhook_testbot): form = {'form': JSONOBJECT} assert 'LocalProxy' in requests.post('http://localhost:{}/raw2'.format(WEBSERVER_PORT), data=form).text def test_generate_certificate_creates_usable_cert(webhook_testbot): d = webhook_testbot.bot.bot_config.BOT_DATA_DIR key_path = os.sep.join((d, "webserver_key.pem")) cert_path = os.sep.join((d, "webserver_certificate.pem")) assert "Generating" in webhook_testbot.exec_command("!generate_certificate", timeout=1) # Generating a certificate could be slow on weak hardware, so keep a safe # timeout on the first pop_message() assert "successfully generated" in webhook_testbot.pop_message(timeout=60) assert "is recommended" in webhook_testbot.pop_message(timeout=1) assert key_path in webhook_testbot.pop_message(timeout=1) webserver_config = { 'HOST': 'localhost', 'PORT': WEBSERVER_PORT, 'SSL': { 'certificate': cert_path, 'key': key_path, 'host': 'localhost', 'port': WEBSERVER_SSL_PORT, 'enabled': True, } } webhook_testbot.push_message('!plugin config Webserver {!r}'.format(webserver_config)) assert 'Plugin configuration done.' in webhook_testbot.pop_message(timeout=2) wait_for_server(WEBSERVER_SSL_PORT) assert requests.post( 'https://localhost:{}/webhook1'.format(WEBSERVER_SSL_PORT), JSONOBJECT, verify=False ).text == repr(json.loads(JSONOBJECT)) def test_custom_headers_and_status_codes(webhook_testbot): assert requests.post( 'http://localhost:{}/webhook6'.format(WEBSERVER_PORT) ).headers['X-Powered-By'] == 'Errbot' assert requests.post( 'http://localhost:{}/webhook7'.format(WEBSERVER_PORT) ).status_code == 403 def test_lambda_webhook(webhook_testbot): assert requests.post( 'http://localhost:{}/lambda'.format(WEBSERVER_PORT) ).status_code == 200 errbot-6.1.1+ds/tools/000077500000000000000000000000001355337103200145605ustar00rootroot00000000000000errbot-6.1.1+ds/tools/README000066400000000000000000000005201355337103200154350ustar00rootroot00000000000000Those are support tools for the project. ./plugins-page-gem.py generates a github wiki compatible page with all the plugin found in github. This... takes a while with the API rate limit. It will write out a Home.md. It will also update a blacklist of false positive on the initial research to optimize subsequent ones. errbot-6.1.1+ds/tools/__init__.py000066400000000000000000000000001355337103200166570ustar00rootroot00000000000000errbot-6.1.1+ds/tools/blacklisted.txt000066400000000000000000000632711355337103200176130ustar00rootroot00000000000000errbotio/errbot YaroslavMolchan/lctv kongluoxing/TomBot TheArchives/Inter Offbeatmammal/jsErrLog isagalaev/django_errorlog davidnutter/Centos-Errata jojax/django-js-error-hook sorensen/django-client-errors greyside/errand-boy mridang/django-erroneous carljm/django-errorstack sigurdga/django-by-errors rroemhild/docker-err mkorenkov/errbit.py Loffe/errors-included Pylons/pyramid_errmail moqada/django-js-error-logging bblanchon/SublimeText-HighlightBuildErrors errplane/errplane-python zhaque/django-error-capture-middleware jasonwyatt/Flask-ErrorMail ccpgames/jsonschema-errorprinter alecthomas/SublimeLinter-contrib-errcheck gregor-b/errcalc espenak/django_errortemplates simone-f/errori-in-osm ershadul/server_errors orimanabu/rhn_errata shikajiro/error_responser devxoul/flask-errorhandler yeago/django-site-error dylanahsmith/errbit-reporter-python katmagic/c_error_codes armooo/on_error_resume_next wtanaka/google-app-engine-django-errors jordanperr/PH291-Dual-Axis-Error-Calculation repoze/repoze.errorlog saromanov/errtoans csquared/errorbucket Qalthos/errorcats WNRI/errantia liob/erroneous echi5ya/errorlogger gmlexx/ErrorsDigest redfern314/errorcontr01 tudelft3d/Error3Dcity mnickey/CSV_ErrorFinder epavlick/esl-errors nerdia/det_errors SMFOSS/HushError CodeVigilant/error_finder Enome/namespace_error Zojax/zojax.error wpp1983/hlsl-error nzoschke/errorbucket-django LuqueDaniel/Ninja-error-log Khan/error-monitor-db ufal/wiki-error-corpus jeremiah1066/error_as_a_service fr0zd/Error-Propagation-Framework seanhess/sublime-build-errors brightinteractive/django-debug-error-logging mindriot101/NGTS-error-contributions eugeni/bugzilla_error_crawler socialplanning/SupervisorErrorMiddleware michalwujas/Trac-Symfony-Errors-Plugin djcf/error-reloader-extension lsigithub/lsi_axxia_errata_public numerodix/django-http-errors cs294-python/errors QsBBQ/ERR AmandaSutherland/ERRbot aseba/ErrDesk ozymandium/err-prop TomNeyland/err-argparse magfest/magbot-err FlorianoManigrasso/ErrFun yunojuno/python-errordite 4teamwork/errbit-python yllar/plugin.video.err.ee wchnicholas/NGSTagErrCorrect manoharan-lab/flyvbjerg-std-err errbotio/errbot-plugins-tester andreskaasik/plugin.video.err ianzapolsky/art_err_day willyrv/msmc-phasing-err panx27/edl-err-ana yunojuno/django-errordite karrot/errantia falconmfm/errchecker slerpy/erratic kanzihuang/errorhandler ionrock/erroremail mfergie/errorless simone/erre hclivess/errorseek stefantkeller/errorvalues corpaul/errorreporter neonrsa/errorplshelp peterhogg/ErrorSolve venuslikestolearn/ErrorExtract borisbergman/ErrorGenerator lmc2179/ErrorDetection errorcode7/errorcode7 mikeleisz/errorAutobot fengwangjiang/errorEstimator Monosone/ErrantHealth oylbin/error-log-monitor jtbricker/ErrorInclusion gsnewmark/ErrorCorrectingCodingAlgorithm pablotrinidad/ErrorViewsDjango lccostajr/VB6_ErrorChecking nobrin/nph-error404 chencoyote/ErrorPacketAttack jmicahc/John-ErrorCorrectionStudy bd808/apache-errors oslovo/error-handlers Iwillforgetmyusername/Error-propagation dhskohjingyu/error_correction AndreaCrotti/logging_errors jbwhit/dipole_error dustinmcintosh/SQL-Errors jeffreywolf/expectedError fengwangjiang/error-estimators NotSqrt/pylint-errors emanuele/error_test jaxvy/Errandr_openshift mdales/alfred-errno majiang/error_example beetleman/http_errors ksang/error-extractor zopefoundation/grokcore.errorview HShaoY/python_error e-democracy/edem.errormesg pozorvlak/error_proxy svc-sandbox/fixing-errors itpir/agc-error caramethylate/Profanity-Error rekeeley/GCE_errors vmaffei/PCR_error novareto/uvc.errorlog zopefoundation/grokcore.error fsouza/compressor_error zopefoundation/zope.errorview tohzijie/corrrect_error bdauvergne/error-codecs fratczakz/error_handler zopefoundation/zope.error mr-georgebaker/PyError hiroaki-yamamoto/flask-error yonilev/error-detection thundercraker/Python-Errata Ionastasi/lab_errors akutuzov/error_annotation sepandhaghighi/error_detect massiecb/Vowel-Errors jrenaud90/UnitError riteshms/gujErrati groupserver/gs.errormesg fmelinscak/error-py EricSchles/error_form hackrole/error_celery zh-jn/light_errata RONNCC/Error-Correcting rec/tornado-error emilecaron/flask-errors delfick/delfick_error Photobucket/errbot_plugins Achifaifa/IndexError recognosco/flask-uwsgi-error hillerlab/IterativeErrorCorrection LagrangianPoint/Apache-Error-Log-Cleaner RobbieLePommie/Sublime_PHPLastError kbec/spyne-uuid-error Montegasppa/Flask-ReportableError JoeGuzi/Cache-Job-Error-Detection TristanTrim/DjangoItemImportError aptivate/extract_django_error TerminalTesting/js_error_test scott1028/error_report_site_study martin-ueding/test-fit-error mattupstate/pytest-flask-error cltl/WSD_error_analysis life1347/ryu_ofp_error_parser forouher/stats_error_detection ehovind/red-hat-errata-notifications jessamynsmith/django-error-python3 pallavi2209/Homophone_error_controller VorobeY1326/Lab2_Errors potar/collective.error.detector zopefoundation/zope.app.error rristow/Products.ConflictErrorLogger alepharchives/errdb-map-reduce andrewyoung1991/django-error-store enderlabs/django-error-capture-middleware collective/Products.TrackConflictErrors tomarrell/BankErrorChecking kahrkunne/Julia-Longina-Balbina-Error danquixote/PythonErrorDuty zhangkeplus/GrammaticalErrorDetect PinLiang/Check_System_Errorcode fnannizzi/homophone_error_correction yokotsushima/cloud-regime-error-metric andrewyoung1991/django-error-store MarissaMC/NLP_Similarity-Error-Correction r-rathi/error-control-coding cmac4603/nav_time_error Salmista-94/Errata_PyQt5 Bernhard10/WarnAsError MephistoMMM/physics_experiment_error_calculate christophertbrown/fix_assembly_errors mikeshultz/django-error-pages palchikov/Compiler-Error-Assistant huanghu/generateErrorForAC alasdairnicol/django-error-messages nilsbunger/python-submodule-import-error rfw/wsgi_php_errors JoeReis/python_time_series_error zopefoundation/Products.SiteErrorLog edeposit/edeposit.amqp_errors Matla/PythonErrorDisplay name3anad/error_detection_correction_simulation artizirk/7xx-error-generator tomyedwab/error-monitor-db sprockets/sprockets.mixins.json_error anqilu/Error-Correction-Code mscrim/LogErrorTest cosyman/trial-and-error-python peta-okechan/calc-error-detection mikesj-public/mm_with_sampling_error SlicerIGT/SlicerTrackingErrorInspector aflansburg/diag-error-check mikesj-public/mm_with_sampling_error ET-CS/TypeError-Example anilarya/logErrorUtility rhintz42/Learning-Python-fix-errors phodina/dzs_error_checking jdufresne/django-test-value-error jdufresne/django-test-field-error jwilksftw/BLS-Work-Error-Finder harukaeru/MyPythonErrorSet tjerwinchen/IDBServerErrorReporter treyhunner/simple-history-error-demo mychrisdangelo/DjangoErrorHelperLLDB singhbhai/cap-read-errors Beanfield/mako-lookup-error fusionbox/django-fusionbox-error_logging cjferba/DetectorErroresPT sergray/Flask-MailErrors LeAustinHan/PythonCheckBlockError TwentyDW/Error-Aggregation-Flask-App lusa-hust/error-log-parser okurz/leaky_bucket_error_count dropbox-dashbpard/error-detect-of-log schicks/xkcdErrorCode Hussein1147/TinyErrandsBackend raviparekh/webapp-error-handler zdenulo/epd-error-example biomembranes/TensorOrderingWithErrorAn tponthieux/ImportErrorWithAppDomain wrldwzrd89/lib-python2-error-logger louisdijkstra/error-model-aligner tsussi/cloud-regime-error-metric wrldwzrd89/lib-python3-error-logger saurabhska/Homophone-Error-detection-and-correction netdude78/web2py_error_watch MakeSchool-17/twitter-bot-python-NickErrant alancleary/error-tolerant-real-valued-frequent-itemset-miner elkeschaper/Gc3pie_error_prone_workflow diego04/Simulator-for-Error-Correction-Detection-Encoding-Hamming-s- riteshnaik/Error-detection-and-correction-dealing-with-homophone-confusion Tian-hao/errorcorrection MarcusFlodihn/python-error-server rroemhild/docker-errbot alimac/errbot-dev nangia/errdemo elopezga/ErrorRate lukasbentkamp/ErrorPro thmason24/errorState_KF avybornova/error_counter 1stvamp/juju-errbot JD-P/error-journal CelestialGoonSquad/Error_Eggplant sarahwalters/error-coding asmith97/LabError jmcvetta/ansible-role-errbot CD3/pyErrorProp srgzyq/error-log-monitor yszheda/error-log-moniter soniyasadalkar/BetterErrorMessages surhudm/savitzky_golay_with_errors ChoudhariApoorva/Better-error-messages-for-template-programs yusanenko-vadim/django-error-monitor Andrew-Klaas/errbot_practice sparkstudios/Sentence-err sparkstudios/Sentence-err FutureMind/drf-friendly-errors zer0dev/pygen-err- axxia/axxia_errata razortheory/cf-pretty-form-errors linkdd/errcorrect ErraticErrors/ErraticBot lukasbk/ErrorPro n1xf1/apache_errors Insoleet/pyinstaller-error liormizr/error_manager brianhwitte/error_checker pacificgilly1992/RUAOAutoError bitesofcode/pyramid_errbit L-joker/error-log-monitor JvSlooten88/pupil_prediction_error PabloPiaggi/HistogramWithErrorbars piruty-joy/square_error_app kazi008/Bit_error_checking 11mariom/ansible-only-errors ravindran11/Error-Django-MongoDB ministryofjustice/django-form-error-reporting ministryofjustice/django-form-error-reporting ravindran11/Error-Django-MongoDB 11mariom/ansible-only-errors Weirb/error-correcting-codes edm1/error-aware-demultiplexer StefanVissers/ProjectSyntaxError e-mc/Error-Correcting-Code doolingdavid/lung-cancer-nn-errors doolingdavid/colon-cancer-nn-errors doolingdavid/breast-cancer-nn-errors doolingdavid/colon-cancer-rf-errors doolingdavid/breast-cancer-rf-errors doolingdavid/lung-cancer-rf-errors juan5d/lab3-paracaidista-calculo-de-error EFXCIA/err-openstack TaqiOfficial/symfony-by-errors Upflask/Upflask-error-pages gbin/err-storage-tester mkramb/errorify HUAZHEYINy/ErrorTest fonfon/ImportError-demo jingxianwen/cloud-regime-error-metric iceterminal/saveErrorLog belzner/idle-errors-uap dimalik/prediction_error alpha-beta-soup/errorgeopy SoftwareEngineering2016Group2/Errand spacemanspiff2007/GetOpenhabErrors samueldg/errbot-hipchat-docker Cis112233/Sentence-err marcomang/To_Err pipi1226/python-imgFindErr rdhananjaya/co318_err_correction WASPSS/line_err_estimator hover2pi/errors haxsaw/errator Simplistix/errorhandler etheleon/errorTrap Cobord/ErrorCorrectingCodes coroner4817/ErrorTextClassification nausheenfatma/Evaluation-Metrics-for-Graded-Relevance-Judgments-NDCG-and-ERR piohhmy/error-rat maggiewang1117/scrapy_errata AtefBN/esdoc-errata pavelfilippi/trial-error PlasmaSheep/sphinx-error arunavsk/Profanity-Error aafrey/errbot-dockerfile dfabulich/disable-errorprone attakei/errbot-crrontab FiannaOBrien/grammatical-errors bdero/errbot-test Judice/msq_error sebs616/Error-medio uskysd/openpyxl-errorbar Kaniabi/docker-errbot e621Mobile/Error-Reporter FilipVdBergh/Erres_project egrepo7/errorfilled_loginreg webpigeon/docker-errbot andrewbaxter/scrapy-errbackdupefilter JenniferRondineau/PipelineERRBS lllucius/spacewalk-errata-loader jhonjairoroa87/celery-detailed-error-handling ctoth/jumpToError ec06cumt/log_error_sendmail sot/attitude_error_mon minorg/viroid_error_rate mgrace-greenphire/error-log-parser wangyuxi1990/cloud-regime-error-metric jwanglof/flask-errorhandler-sendgrid shun-y/defectingErrorTelop krapivchenkon/appengine-route-svc-error cdtx/django_error_handlers AtefBN/errata-esdoc-ws fmarchenko/django-issues-errors hambuergaer/satellite6_errata_install LandRegistry/lc-error-reporting LandRegistry/lc-error-reporting shiift/error_correcting_parser riyapal/Characterising-Parser-Errors mihaibivol/isbnlib-flask-error-demo Rajeev69/match_error_check vasilty/percent_image_error_django christian-rauch/lcm_state_error_viz osu-cass/sdg-python-error-logging yusanenko-vadim/django-1.4-error-monitor NinjaWolf64/Python-3.4.3-Errors olgaramz/Automatic-detection-of-errors-in-comparative-constructions kevindeasis/Simulator-for-Error-Correction-Detection-Encoding-Hamming-s- makelinux/errors_resolver pylola/haiku-errors ktdreyer/errata-tool yjmade/django-errorlog yjmade/django-celery-errorlog fourier-being/Least-Squared-Error-Based-FIR-Filters soramichi/image_error_injector SiddhantRaman/Least-Squared-Error-FIR-Filter winecat/errCode takumak/err_prop WilsonYangLiu/errBar j-bennet/cass-err-timeout jijunjun1112/ErrorCode mirai260/error404 Tortuginator/ErrorReporter TeflonTrout/ErrorNoPoemFound stephen-bailey/Module14_errorHandling patorres61/PRG105-errorExceptions ElegantCow/Auscope-ErrorFinder FilipVdBergh/ErresV2 pablo-dev/ErrorCheckerExample lorenmh/drf-error GridTech123/pyError abendebury/sphinx-error Gabriel-Desharnais/Module-Erreur gchure/stripplot_error aamirkhanq/strange-error arykalin/errbot-getinfo sidnarayanan/TransferErrors joesonghamnida/ParallaxError daylight/sort_error Edinburgh-iGEM2016/Error-Correction kchelliah/Speaker-Error kianxineki/bottle-errorsrest jdown3/error-profiles AlexBoliachiy/SegmentationError yasyasch/defectingErrorTelop riebecj/Combining_Error_Ellipses clelange/AutoDetectSoftError drewandersonnz/os-app-create-error eahlb/XSPEC-error-finder vlt/python_error_handling clarcharr/errors.charr.xyz sih4sing5hong5/django_migration_error agronick/django_middleware_error thompcinnamon/practical_percent_error stephcoronel/Flame-Radius-Error dschep/serverless-python-submodule-error jltchiu/Recogntion-Error-Classification infsolution/DeteccaoDeErro czcorpus/proxy-error-docs absent1706/gae-error-reporting-demo janschulz/pytest-error-for-skips pinetree408/mode-error-result-parser jakul/gordon-regex-error negatendo/404error.gallery preetiramaraj/mining_hardware_errata inas404/Beep-on-error SharleneL/SpellErrorDetection rajeevrz/match_error_test cdeaneGit/catch_xmllint_errors rajeevrz/match_error_check cyshen/addError_cifar10 ronnycorral/APILogErrorCheck RanaivosonHerimanitra/hdx_error_detection petertodd/gitpython-unicode-error jamchamb/3ds-error-lookup angelalali/error_detection_mbi ywkw1717/error_correction_code_word timwaters/gdal_rasterize_error davido/buck_error_prone_integration ksenyaoleniuk/Error_correction_project MickeyNan/error_detect_api gregpoulos/panlex-error-correction DEGoodmanWilson/conan-libgpg-error Tomvictor/django-error-pages alpha-beta-soup/errorgeopy-example-app aronhnt/Error-Checking-Function raj040492/csv-error-parser ES-DOC/esdoc-errata-client ES-DOC/esdoc-errata-ws afdallismen/Django-error-message-displayed-twice angelalali/error_detection_mbi_py3 neeljp/matMult_error_python_petsc DeltaFour/Python-3.4.3-Errors Tcing/TuniHack2016_Fatal_Error ruczhangxy/bayes_error_rate_vs_auc DoubleSlider/Predict_milling_surface_loacation_error SunyanGu/intelligent-error-correction-NJUPT-Longaotian-Code CodecoolKrakow20161/python-lightweight-erp-project-error_squad rounaksalim95/Cryptography-and-Error-Correcting-Codes-Testing onwodoh/Search-StackOverFlow-for-Compiler-Errors drewandersonnz/osops-http50x-errors-report psjyothiprasad/Mining-Special-Data---Error-Correction-and-Performance-Assessment lijenstina/Experiments-with-patch-D791-Displaying-Addon-errors-in-the-UI shyam114/django_error_assist obonaventure/MsgErreur jthois/python.Classification-Error-prediction-in-java-projects pranay414/Error404 brandoconnor/err-plugins abraverm/err-jenkinsci TafBaf/ERR_Player jirkle/ErrCorp WiscEvan/gitcollab_ERR yunojuno-archive/python-errordite The-bug-err/ProjectEuler The-bug-err/30DaysOfCode zxdswgnda/getErrImgPair PavelBlend/Blender_Stalker_Dm_Err yunojuno-archive/django-errordite The-bug-err/Django-Project alvinwan/errorsquared codedu-python/errorfiles anshulg8/ERROR lucastheis/errorbars julianlpc/errores jolly-roger/errors simonmelouah/errands presci/errands rotom/errata paurosello/errorges rinman24/errorAnalysis dudenzz/spellingErrors shanemgrey/django-error RQstudio/errbot-rabbitmq pgallen90/flask_error Veerachart/Fisheye_error IvanicsSz/runtime_error xjr7670/error_count Dudu197/error-crawler williamdparker/mean-error kanekomasahiro/error_detection hayleethegamer/Error_Temp CSCI-362-02-2016/Syntax-Error ennuuos/DraconicError niboshi/python-colorize-errors joaopereiramrf/check_elasticsearch_errors 5w4pn11/WordErrorRate HeyTricky/django-error-reporting sudarshang/jedi_import_error AtefBN/esdoc-errata-test-suite rigopoui/CRC-error-detection liormizr/error_handling_talk pmaigutyak/mp-error-handlers ywkw1717/error-correction-code-word dramccaffrey/WC_error_model autoterrorbot/FB_tERRORBOT LauJangit/Simulation-Of-Quantum-Communication-Error FrederikWR/course-01405_algebraic_error_correcting_codes-2017 darrenyaoyao/OhmyNN_for_ASR_error_on_dialog_system Alan-g-s/Cell-Microsatelite-Data-Processing-and-Analysis-for-Imputation-Errors aleju/gan-error-avoidance PhyrionX/ErReSuLoNaDorH andersx/errorlearn chrisjbryant/errant suetAndTie/ClarkeErrorGrid mhsiddiqui/django-error-report blakegall/ErraticBot seanpianka/Fatal-Error qedsoftware/django-force-error sovamakarosh/Prtg_Err errezeta/errez commit-live-admin/errors srishti88/errors salcedo/errorpages AAAwesomer/ErrorRecognition amaiellu/ErrorChecker mwerevu/ErrorComp dingusagar/Error404 AndriiMazur/ErrorDecoder tbec/ErrorControlCoding pandahuang/ErrorTypeRecognition Nadiah16/ErrorCountAP ErrorFeed/ErrorFeed-Python priyabagaria/error-analysis Foolings/Trail-Error ForumOrganisation/errache-bot antspy/cntkError sabrinadowla14/flaskError MorozYaroslavKN-B/Test-error ErrolLin/errol.pa luismiguelnarvaez/demoError weijlander/ScaleErrors twhunt/backwards_error manexagirrezabal/errima-bertsolaritzan hzhang-wx/light_errata FreakyBytes/errbot-docker johnjosephmorgan/error-analysis Nelestya/BeautifullError rasika-a/errand_mapper rasika-a/errand_mapper eduardogpg/tinyintError briantical/Personal-Errands klawal/Hour-Error JayHyeonwoo/error_work zencore-dobetter/zencore-errors ErrolX3/ErrolX3.github.io Bharathkumar-nb/Poco-Error-Detector jpulec/errbot-heroku-deploy im-shyam/django_error_assist A-Setiabudi/HoldError-ANN anuragrana/Error_Logging_Django ncrmro/polymorphic_django_grapene_error RedHatHackFest/spark-error-selector bbbbb34016/get_the_error_counts angelusmx/error_parser_QSA zeran4/mnist_trial_and_error drkutuzov/arrays-with-errorbars Axeleik/False_merge_errosion alepers/error-responsibility-routing-thesis gpanther/blobstore-error-poc vmdowney/oclc-error-report-tools keisks/error-repair-parsing imalic3/python-word-error-rate hoionazun/HTTPSecondErrorTest jwilk/python-syntax-errors kylehovey/ERRNO_Picture_Daemon finnd/korymbus_sample_case_error OkBuilds/BuckwWatchmanErrorReproduce krnkl/appengine-route-svc-error derekhendrickx/find-my-flac-errors zetayue/DiffusionErrorAnalyzer lucianoRM/error_logger_server JeffreyEnglish/DissolveWithError comjoueur/snake_con_error hcoura/forbes_scrapy_error piruty/square_error_app OscarJHernandez/linear_fit_w_errors wooyek/django-error-views comjoueur/snake_con_error devoidheiligenschein/million-song-errythang willgeorgejr/system-32-error claudiusalp/SpellErrorCorrection jwfrizzell/udacity_causes_of_error zarechnev/stepik_errors_and_exceptions petebrowne/django-adminactions-forbidden-error krisys/django-error-email-throttle martin056/HandleErrorsDemo MrSimonC/Galaxy-Error-Log-Content MrSimonC/Galaxy-Error-Log-Interface igor-pea/Time-Error-Correction aary/buck-shared-library-error-report evandrocoan/SublimeSelectAllSpellingErrors lowlevel86/multiple-variable-trial-and-error mzelinka/klein2013-cloud-error-metrics NinjaWolf064/Python-3.4.3-Errors robodair/yapsy_multiprocess_pool_import_error hainan89/EffectOfLocalizationErrorsOnDecisionMaking Hem-Bhatt/Bit-error-propagation-in-block-cipher-modes Bala9626/Hamming-De-hamming-and-error-detection ODYTRON/Challenge-Modules-Classes-Error-Handling-And-List-Comprehension deanishe/alfred-errnum failtale/error-parser frozenfoxx/rpi-errbot cobhuni/errors_fixer Dhvani35729/error_deep gokceuludogan/SpellErrorCorrector marcosvsmorais/nagios-interface-check-errors mathemage/bars-n-errors jemdiggity/test_clinkage_errors cafecco/k-error-linear-complexity andrewpgit/spacewalk-api-errata realraum/r3bot-errbot red-hat-storage/errata-tool alexkuz/SublimeLinter-inline-errors CeadeS/err dkota1992/errorHandling WeepingJarl012/ErrorVisualization soukron/errata2cv eln1x/apache_errors jinho785600/errand_server isayme/alfred-errno AllFromBCN/error_handling k2la/rss-error bermau/mdrd_and_error_example Gnoffel/errbot-gnu-terrypratchett Zetten/bazel-generate-workspace-error zdenulo/gcs_local_gae_error rarator/process_sap_error ahubler/Email-Error-sorting techinologic/python_error_catching Nerdenator/db_error_handling krzys-h/soviet-error God3err/god3err-hack-tools alvaro-serra/IRL_application_on_ErrP cprohoda/errorhandler TheManhattan/ErrorCode vieiramanoel/ErrorCalc ClaudeXin/error_detect danie1cohen/docker-errbot lijiawenl/code_errorlog paszabo/error_logger anatoliis/cerberus_error austinfrey/errbot-dockerfile maersk-digital/flask-errors santanu-tripathy/error-ellipse crossoveranx/ErrorRelatedPotential-Text-Prediction Alderney1/PythonErrorDisplay lddubeau/drf_pickling_error anuragrana/Error-Logging-In-Django ahmedshuhel/google-error.nvim theavey/practical_percent_error nielsrolf/django-error-logs Jgmora/tinyintError_package MKotlik/CLS-crawl-error-analytics NuanceCD/FlightErrorCodeTest b199712/NVMe_Error_Check markasselin/TrackingErrorAnalysis kanekomasahiro/grammatical-error-detection EliaGorokhovsky/Time-Error-Correction omeomi/PredictionError_analysis omeomi/PredictionError_experiment brettdh/serverless-wsgi-django-import-error monishnarendra/Error-Detection-using-CRC-Circuit_16bits Herant/Python_plays_gta5_MemoryError_fix danluu/fs-errors iamcco/react-error-codes.vim Arthur-Milchior/anki-LaTeX-Error gorff/Toric-Code-Correlated-Error-Decoder wkentaur/err-ne melkamar/pytest-setuppy-err norlag/CalculatriceErrAbs 0xffea/err-backend-matrix Rickym270/InterfaceErrReport kyeongsoo/multi-path_error_flow_control rick1611/err6fgb-repo MDCGP105-1718/portfolio-err0rzzz DivanshuTak/Erratum chris-bc/errata Entropikz/ErrorUnknown pviniciusm/errc2017 TRiBByX/ErrorDatabase agnieszkapiwowarczyk/tabularError stretchmaniac/error-propagation CTraverse/Transcription-Errors AriChow/error_propagation habibu/ujErretem Zandahlen/exercisesERROR Tubyhes/hotjar-errorify klawal/hour-error lhagaman/error_analysis kelseyflap/multithreading-error padster/Error-UNet alessandropeca/hardnessratio-errors KilerMaxi/trial_and_error zdenulo/dataflow_bigquery_error nicolas-r/katello-centos-errata-import cynikolai/VOC-Error-Analyzer BinWangGBLW/Dynamic-Error-Tool-QEMU harmansethi92/log_error_analysis braikoff/TrialAndError lajanugen/errata_analysis_eecs573 Numlet/INP_poisson_errors ludwingperezt/django-error-log-notificator maximedb/artificial_errors_generation dartrevan/DynamicErrorNetwork ajaanbaahu/grammar_error_classifier Arafat4341/VisualizingErrorUsingMatplotlib manuelabutuc/extract-keepright-errors eddielavr/find_the_error hoits/HTTPSecondErrorTest alstar95/extractErrorLog yabolu/error_log_show rimon107/ViolinErrorVoronoiChartPython shubhamrajIT/CRC-8-Error-Detction-Technique Adrien-Meilac/Quality-analysis-and-errors-statistics cashlink/error-boundary ktalik/munin-nginx-errors Kurara/unittest_errors modultechnology/nel_errors gwenzek/SimpleNextError crowdynews/stackdriver-error-reporting-python Geethu-b/ANLP-Grammatical-Error-Correction Wojtechnology/trial-and-error ekampf/sanic-sentry-error-handler j0r1/ErrUt wkentaur/graph-err zikiki/news_sentiment_ERR Ascrin96/errors sywangs/error ryanndelion/DW-all-day-err-day noetipo/errands PrisoftPeter/error Ping-linlin/error treyhyphen/errflux krm9c/ErrorDriven-Learning YureiTheOriginal/ErrorSquad-bomber brennan4661/Thesis_error bremersj/error-coding MOBLCIBI/Percent-Error gursimran81/Errbot-Plugin tpin3694/error_crawler ikerib/errolda-api jschaf/error-reporting swanhong/LizardError martinstroet/trapz_errors cdumay/cdumay-error hyugithub/bellman_error Samuelczhu/Error-Propagation ryanfleharty/erroneous_monk g104robo/error_ellipse RobinL/glue_errors LJC0915/errol.pa TheETKing/Error-Server robin-libert/projet_theorie_erreurs martintb/nbsphinx_image_error fbeutler/flask_error_handler I3lacx/trial_and_error cheersyouran/analyst-forecast-errors DavidWhois/erron_POI_Classifier olpossum/circular_error_probable romanhaa/barcodeErrorProducer anay97/python-errorcode-generator aneeshakella17/GenomicErrorCorrection quierati/rabbitmq-delay-errors Python3pkg/Flask-ReportableError dmmatson/log-error-finder Grover-c13/GithubErrorSubmission lamdba/error_s1 loopypanda/git-screen-error raviy8408/test_error_calculator tomguy04/SqlConnectionError TCLamnidis/Sex.DetERRmine masouduut94/Persian_text_error_detector not-sham/django_error_assist arianyambao/codec_error_fix evandrocoan/SelectAllSpellingErrors tianlinyang/grammatical-error-correction alcarney/erratum agalera/bottle-errorsrest bhaveshkumarraj/Error-Correcting-Output-Classifier jonashoffmeister/rf_orientation_error_compensation jonashoffmeister/rf_orientation_error_compensation Fischerfredl/flask-json-errorhandler yokilaarora/Grammatical-Error-Correction Archan2607/IT556_The-ERRORs_DAIICT 8hantanu/TunErr mapcon/err_anlys AIIX/err-mycroft owasptu/Error404_SC18 Cnogz/Python_ErrorHandling mikesuhan/ErrorCodeTallier errorport/errorPort_logo ManOfMiles/ErrorFaresEmail mikesuhan/ErrorCodeTallier pixrl/ErrorCorrectionCodeGui dmitos/errores pinglinvip/error rnatella/errordumper vivekgrover1/errbot niranjangs4/error jialutu/errorhandling RouzbehMajidi/errorcalc kpimaker/errorhandlerdecorator The-bug-err/ShortifyURL python-cafe/erros-python BigDaMa/error-generator muzlightbeer/php-errata SvenNederhoff/errbot-seen TheCodingLand/PdfErrors o1i/error_corrections MohamedAymanEzz/Measurement-Errors gaoyaoxin/EC_errata mrmiddy/Gconnect-Error yomgui1/delta_error kevfrem97/ValueError emtwo/compute-error emtwo/compute-error YuanYuLin/libgpg-error fliper-b3/errol-log line-mind/error_solver pawnesh/webpage-error-checker mbsa-tud/OpenErrorPro garc0062/prepositional_error_correction smangul1/error.correction.benchmarking bingqing0729/trackingErr_optimization_lstm WSU-RAS/adl_error_detection BewQr3/Discord-bot-error-handler mkhlv/PVS_network_errors uniquezhiyuan/RadrErrorDiagnose sarathchandare/Rest-Error-Validation-Python scroobius-pip/2dErrorCorrection ArslanKha/error_and_solution hmrodrigues/http-dynamic-error gdeest/repro_link_error talionet/StudentsErrorPatternAnalysis kevfrem97/FileNotFoundError TimothyShi-kika/grammatical-errors-generator zixuan75/error-3040-project-description bbondd/DataCommunicationErrorControl jankatins/pytest-error-for-skips mkramer45/UnicodeConversionError_Traxsource WilliamDowns/ModelErrorCalc Asday/django-custom-error-handler softdevteam/error_recovery_experiment errbot-6.1.1+ds/tools/extras.txt000066400000000000000000000056431355337103200166370ustar00rootroot00000000000000gamkedo-la/robokedo openstack/oslo.tools tormich/yobabot bigmlab/bigmbot elopio/snappy-m-o mPharma/bot-demo RQstudio/errbottest-tensorflow sde-melo/hipchat-errbot-japanese labs-holic/FutBot EdoPut/errbot-ebay arykalin/errbot-getinfo obihann/SantaBot PythonSanSebastian/pyperr jerrykan/recordwho brandverity/tcs_bot J4LP/pingbot unitycoders/uc_pyircbot ilkka/jutibot prsnnami/chatops zsoobhan/nibbler kaytwo/lockbot debojitkakoti/atraey lagenorhynque/muse-bot fookatchu/GoogleCloudVisionParser Team488/meeting-bot chiel1980/chatbot-plugins staircaseJapes/slack rdo-infra/rdobot xi/lunchbot nelsonam/meryl fookatchu/LinkParser lekum/ansiblebot jlu368/slackcitizenshiptester kgutwin/lyncbot rvm-xx/weather-bot kkc/gogobot MaxwellBo/SlackVCS lanthos/chillbot ZaneRL9/IpreoChatOps youske/errbot-backend-chatwork teran-mckinney/potatoclicker lazam/FunBot srayneau/eva ministry-of-love/contractor davvi/bender roman8422/waiter_bot Axylos/errfite Beit-Hatfutsot/jewbot tormich/errkeygen ValhallaGamePlays/odin danie1cohen/errbot-kodi 0ahab/vpn_hook robojase/errbot-chat-points t-kenji/errbot-moogle jpulec/errbot-deploy tormich/errmesos markusj/titlebot-ng RQstudio/botplugins Appleman1234/arisu qlixed/nerdobot pirxthepilot/ninjam-slack-bot matanby/st0ckbot aherok/errbot_plugins attakei/errbot-teratail-fetch austinhappel/sabot coala/corobo avengerpenguin/alfred Axylos/pyfite Bloodskate/chatops checalov/AliBot brandverity/tcs_bot chiel1980/chatbot-plugins chiel1980/docker-containers codebam/python-sparklebot darkorb/errbot-digicert drsm79/botify drsm79/mybot f3l/f3lbot fookatchu/GoogleCloudVisionParser fookatchu/LinkParser foxxyz/bookiebot ghoti/Rooster GreenelyAB/errbot-port hanks/Mathbot_for_hipchat harlowja/gerritbot2 helo9/err_forum hreeder/r2bot-eve-plugins ibelle/garakbot ilkka/jutibot J4LP/pingbot J4LP/rooster j4y-funabashi/irc_bot jasedit/errbot-heyyou jasedit/errbot-plotter jlu368/slackcitizenshiptester jspam/mensabot kaytwo/lockbot KenMercusLai/err-vocab kevgliss/analys kgutwin/lyncbot kkc/gogobot kongluoxing/TomBot lagenorhynque/muse-bot leitu/errbot-plugin linuxtechie/transmission-gtalk MarceloCorpucci/spencer-errbot markusj/titlebot-ng MattHodge/PSErrbot MaxwellBo/SlackVCS mayflower/errbot-alerrtmanagerr ministry-of-love/concord_track ministry-of-love/contractor ministry-of-love/diplomat namanbharadwaj/VirtualNaman Navisite/cloudbot nelsonam/meryl obihann/SantaBot oiKuiyuyou/AoikRocketChatErrbot phlax/errbot-pootle-build phlax/errbot-remotes pirxthepilot/errbot-ipam redwallhp/errbot-appeals redwallhp/errbot-timezones rhyshort/rhysbot-plugins RyanHartje/errbot-tyler spoof79/errbot spyn/dungarmatrix staircaseJapes/slack takuan-osho/bot-codes tazzledazzle/python-archive Team488/meeting-bot teran-mckinney/potatoclicker TheArchives/Inter thebmo/bmodjangotest theho/thehobot tomblench/ofhave unitycoders/uc_pyircbot vitorio/snarky-screening xi/lunchbot xsrender/Utility-Bots yalker24/urlcheck YaroslavMolchan/lctv ZaneRL9/IpreoChatOps zhangsm/hatter errbot-6.1.1+ds/tools/gen_home.py000077500000000000000000000015571355337103200167260ustar00rootroot00000000000000#!/usr/bin/env python3 from jinja2 import Template import json template = Template(open('plugins.md').read()) blacklisted = [repo.strip() for repo in open('blacklisted.txt', 'r').readlines()] PREFIX_LEN = len('https://github.com/') with open('repos.json', 'r') as p: repos = json.load(p) # Removes the weird forks of errbot itself and # blacklisted repos filtered_plugins = [] for repo, plugins in repos.items(): for name, plugin in plugins.items(): if plugin['path'].startswith('errbot/builtins'): continue if plugin['repo'][PREFIX_LEN:] in blacklisted: continue filtered_plugins.append(plugin) sorted_plugins = sorted(filtered_plugins, key=lambda plugin: -plugin['score']) with open('Home.md', 'w') as out: out.write(template.render(plugins=sorted_plugins)) errbot-6.1.1+ds/tools/plugin-gen.py000077500000000000000000000175051355337103200172120ustar00rootroot00000000000000#!/usr/bin/env python3 from datetime import datetime import requests import sys from requests.auth import HTTPBasicAuth from datetime import datetime import logging import time import configparser import json logging.basicConfig() log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) DEFAULT_AVATAR = 'https://upload.wikimedia.org/wikipedia/commons/5/5f/Err-logo.png' try: user, token = open('token', 'r').read().strip().split(':') # token is generated from the personal tokens in github. AUTH = HTTPBasicAuth(user, token) except FileNotFoundError: log.fatal("No token found, cannot access the GitHub API") except ValueError: log.fatal("Token file cannot be properly read, should be of the form username:token") except: log.exception("auth execption:") # sys.exit(-1) user_cache = {} try: with open('user_cache', 'r') as f: user_cache = eval(f.read()) except FileNotFoundError: # File doesn't exist, so we continue on log.info("No user cache existing, will be generating it for the " + "first time.") def add_blacklisted(repo): with open('blacklisted.txt', 'a') as f: f.write(repo) f.write('\n') plugins = {} def save_plugins(): with open('repos.json', 'w') as f: json.dump(plugins, f, indent=2, separators=(',', ': ')) BLACKLISTED = [] try: with open('blacklisted.txt', 'r') as f: BLACKLISTED = [line.strip() for line in f.readlines()] except FileNotFoundError: log.info("No blacklisted.txt found, no plugins will be blacklisted.") def get_avatar_url(repo): username = repo.split('/')[0] if username in user_cache: user = user_cache[username] else: user_res = requests.get('https://api.github.com/users/' + username, auth=AUTH) user = user_res.json() if 'avatar_url' in user: # don't pollute the presistent cache user_cache[username] = user with open('user_cache', 'w') as f: f.write(repr(user_cache)) rate_limit(user_res) return user['avatar_url'] if 'avatar_url' in user else DEFAULT_AVATAR def rate_limit(resp): """ Wait enough to be in the budget for this request. :param resp: the http response from github :return: """ if 'X-RateLimit-Remaining' not in resp.headers: log.info("No rate limit detected. Hum along...") return remain = int(resp.headers['X-RateLimit-Remaining']) limit = int(resp.headers['X-RateLimit-Limit']) log.info('Rate limiter: %s allowed out of %d', remain, limit) if remain > 1: # margin by one request return reset = int(resp.headers['X-RateLimit-Reset']) ts = datetime.fromtimestamp(reset) delay = (ts - datetime.now()).total_seconds() log.info("Hit rate limit. Have to wait for %d seconds", delay) if delay < 0: # time drift delay = 2 time.sleep(delay) def parse_date(gh_date: str)-> datetime: return datetime.strptime(gh_date, "%Y-%m-%dT%H:%M:%SZ") def check_repo(repo): repo_name = repo.get('full_name', None) if repo_name is None: log.error('No name in %s', repo) log.debug('Checking %s...', repo_name) code_resp = requests.get('https://api.github.com/search/code?q=extension:plug+repo:%s' % repo_name, auth=AUTH) if code_resp.status_code != 200: log.error('Error getting https://api.github.com/search/code?q=extension:plug+repo:%s', repo_name) log.error('code %d', code_resp.status_code) log.error('content %s', code_resp.text) return plug_items = code_resp.json()['items'] if not plug_items: log.debug('No plugin found in %s, blacklisting it.', repo_name) add_blacklisted(repo_name) return owner = repo['owner'] avatar_url = owner['avatar_url'] if 'avatar_url' in owner else DEFAULT_AVATAR days_old = (datetime.now() - parse_date(repo['updated_at'])).days score = repo['stargazers_count'] + repo['watchers_count'] * 2 + repo['forks_count'] - days_old / 25 for plug in plug_items: plugfile_resp = requests.get('https://raw.githubusercontent.com/%s/master/%s' % (repo_name, plug['path'])) log.debug('Found a plugin:') log.debug('Repo: %s', repo_name) log.debug('File: %s', plug['path']) parser = configparser.ConfigParser() try: parser.read_string(plugfile_resp.text) name = parser['Core']['Name'] log.debug('Name: %s', name) if 'Documentation' in parser and 'Description' in parser['Documentation']: doc = parser['Documentation']['Description'] log.debug('Documentation: %s', doc) else: doc = '' if 'Python' in parser: python = parser['Python']['Version'] log.debug('Python Version: %s', python) else: python = '2' plugin = { 'path': plug['path'], 'repo': repo['html_url'], 'documentation': doc, 'name': name, 'python': python, 'avatar_url': avatar_url, 'score': score, } repo_entry = plugins.get(repo_name, {}) repo_entry[name] = plugin plugins[repo_name] = repo_entry log.debug('Catalog added plugin %s.', plugin['name']) except: log.error('Invalid syntax in %s, skipping... ' % plug['path']) continue rate_limit(plugfile_resp) save_plugins() rate_limit(code_resp) def find_plugins(query): url = 'https://api.github.com/search/repositories?q=%s+in:name+language:python&sort=stars&order=desc' % query while True: repo_resp = requests.get(url, auth=AUTH) repo_json = repo_resp.json() if repo_json.get('message', None) == 'Bad credentials': log.error('Invalid credentials, check your token file, see README.') sys.exit(-1) log.debug("Repo reqs before ratelimit %s/%s" % ( repo_resp.headers['X-RateLimit-Remaining'], repo_resp.headers['X-RateLimit-Limit'])) if 'message' in repo_json and repo_json['message'].startswith('API rate limit exceeded for'): log.error('API rate limit hit anyway ... wait for 30s') time.sleep(30) continue items = repo_json['items'] for i, repo in enumerate(items): if repo['full_name'] in BLACKLISTED: log.debug('Skipping %s.', repo) continue check_repo(repo) if 'next' not in repo_resp.links: break url = repo_resp.links['next']['url'] log.debug('Next url: %s', url) rate_limit(repo_resp) def main(): find_plugins('err') # Those are found by global search only available on github UI: # https://github.com/search?l=&q=Documentation+extension%3Aplug&ref=advsearch&type=Code&utf8=%E2%9C%93 url = 'https://api.github.com/repos/%s' with open('extras.txt', 'r') as extras: for repo_name in extras: repo_name = repo_name.strip() repo_resp = requests.get(url % repo_name, auth=AUTH) repo = repo_resp.json() if repo.get('message', None) == 'Bad credentials': log.error('Invalid credentials, check your token file, see README.') sys.exit(-1) if 'message' in repo and repo['message'].startswith('API rate limit exceeded for'): log.error('API rate limit hit anyway ... wait for 30s') time.sleep(30) continue if 'message' in repo and repo['message'].startswith('Not Found'): log.error('%s not found.', repo_name) else: check_repo(repo) rate_limit(repo_resp) if __name__ == "__main__": main() errbot-6.1.1+ds/tools/plugins.md000066400000000000000000000006271355337103200165700ustar00rootroot00000000000000If you are looking for errbot documentation, it is there: [errbot.io](http://errbot.io/). ### All errbot plugins found from github {% for plugin in plugins %} ## {{loop.index}}\. [{{plugin.name}}]({{plugin.repo}}) {{plugin.documentation}} - Python {{plugin.python}} - Install: `!repos install {{plugin.repo}}` - Activity: {{plugin.score}} --- {% endfor %} errbot-6.1.1+ds/tools/user_cache000066400000000000000000007141731355337103200166210ustar00rootroot00000000000000{'adsabs': {'received_events_url': 'https://api.github.com/users/adsabs/received_events', 'bio': 'Smithsonian/NASA Astrophysics Data System', 'gists_url': 'https://api.github.com/users/adsabs/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/adsabs/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/adsabs/orgs', 'public_repos': 74, 'email': 'ads@cfa.harvard.edu', 'followers_url': 'https://api.github.com/users/adsabs/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/adsabs/repos', 'following': 0, 'blog': 'http://adsabs.harvard.edu', 'subscriptions_url': 'https://api.github.com/users/adsabs/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1004839?v=3', 'updated_at': '2015-10-22T10:09:49Z', 'created_at': '2011-08-25T18:34:13Z', 'name': 'SAO/NASA ADS', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'adsabs', 'following_url': 'https://api.github.com/users/adsabs/following{/other_user}', 'html_url': 'https://github.com/adsabs', 'url': 'https://api.github.com/users/adsabs', 'location': 'Cambridge, MA, USA', 'id': 1004839, 'events_url': 'https://api.github.com/users/adsabs/events{/privacy}'}, 'jonschoning': {'received_events_url': 'https://api.github.com/users/jonschoning/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/jonschoning/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jonschoning/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/jonschoning/orgs', 'public_repos': 20, 'email': 'jonschoning@gmail.com', 'followers_url': 'https://api.github.com/users/jonschoning/followers', 'gravatar_id': '', 'public_gists': 444, 'followers': 18, 'type': 'User', 'repos_url': 'https://api.github.com/users/jonschoning/repos', 'following': 11, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/jonschoning/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/137183?v=3', 'updated_at': '2015-10-19T00:05:54Z', 'created_at': '2009-10-09T03:40:25Z', 'name': 'Jon Schoning', 'hireable': None, 'company': 'Polaris Solutions LLC', 'site_admin': False, 'login': 'jonschoning', 'following_url': 'https://api.github.com/users/jonschoning/following{/other_user}', 'html_url': 'https://github.com/jonschoning', 'url': 'https://api.github.com/users/jonschoning', 'location': 'Chicago, IL', 'id': 137183, 'events_url': 'https://api.github.com/users/jonschoning/events{/privacy}'}, 'markusj': {'received_events_url': 'https://api.github.com/users/markusj/received_events', 'following_url': 'https://api.github.com/users/markusj/following{/other_user}', 'gists_url': 'https://api.github.com/users/markusj/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/markusj/starred{/owner}{/repo}', 'html_url': 'https://github.com/markusj', 'public_repos': 9, 'email': None, 'followers_url': 'https://api.github.com/users/markusj/followers', 'followers': 1, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/markusj/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/markusj/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1589968?v=3', 'updated_at': '2016-12-11T13:42:41Z', 'created_at': '2012-03-30T09:35:32Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'id': 1589968, 'organizations_url': 'https://api.github.com/users/markusj/orgs', 'url': 'https://api.github.com/users/markusj', 'location': None, 'login': 'markusj', 'events_url': 'https://api.github.com/users/markusj/events{/privacy}'}, 'samueldg': {'received_events_url': 'https://api.github.com/users/samueldg/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/samueldg/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/samueldg/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/samueldg/orgs', 'public_repos': 9, 'email': None, 'followers_url': 'https://api.github.com/users/samueldg/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 3, 'type': 'User', 'repos_url': 'https://api.github.com/users/samueldg/repos', 'following': 4, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/samueldg/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/4542383?v=3', 'updated_at': '2016-07-27T18:05:27Z', 'created_at': '2013-05-27T18:33:11Z', 'name': 'Samuel Dion-Girardeau', 'hireable': None, 'company': 'Nuance', 'site_admin': False, 'login': 'samueldg', 'following_url': 'https://api.github.com/users/samueldg/following{/other_user}', 'html_url': 'https://github.com/samueldg', 'url': 'https://api.github.com/users/samueldg', 'location': 'Montréal, QC', 'id': 4542383, 'events_url': 'https://api.github.com/users/samueldg/events{/privacy}'}, 'kevgliss': {'received_events_url': 'https://api.github.com/users/kevgliss/received_events', 'following_url': 'https://api.github.com/users/kevgliss/following{/other_user}', 'gists_url': 'https://api.github.com/users/kevgliss/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/kevgliss/starred{/owner}{/repo}', 'html_url': 'https://github.com/kevgliss', 'public_repos': 17, 'email': None, 'followers_url': 'https://api.github.com/users/kevgliss/followers', 'followers': 17, 'gravatar_id': '', 'public_gists': 2, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/kevgliss/repos', 'following': 1, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/kevgliss/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2262214?v=3', 'updated_at': '2016-12-14T05:15:53Z', 'created_at': '2012-09-01T19:32:44Z', 'name': None, 'hireable': None, 'company': 'Netflix', 'site_admin': False, 'id': 2262214, 'organizations_url': 'https://api.github.com/users/kevgliss/orgs', 'url': 'https://api.github.com/users/kevgliss', 'location': None, 'login': 'kevgliss', 'events_url': 'https://api.github.com/users/kevgliss/events{/privacy}'}, 'Kha': {'received_events_url': 'https://api.github.com/users/Kha/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Kha/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Kha/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Kha/orgs', 'public_repos': 40, 'email': None, 'followers_url': 'https://api.github.com/users/Kha/followers', 'gravatar_id': '', 'public_gists': 16, 'followers': 13, 'type': 'User', 'repos_url': 'https://api.github.com/users/Kha/repos', 'following': 8, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/Kha/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/109126?v=3', 'updated_at': '2015-08-25T14:51:46Z', 'created_at': '2009-07-27T15:51:03Z', 'name': 'Sebastian Ullrich', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'Kha', 'following_url': 'https://api.github.com/users/Kha/following{/other_user}', 'html_url': 'https://github.com/Kha', 'url': 'https://api.github.com/users/Kha', 'location': 'Germany', 'id': 109126, 'events_url': 'https://api.github.com/users/Kha/events{/privacy}'}, 'mattadair': {'received_events_url': 'https://api.github.com/users/mattadair/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/mattadair/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/mattadair/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/mattadair/orgs', 'public_repos': 3, 'email': None, 'followers_url': 'https://api.github.com/users/mattadair/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/mattadair/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/mattadair/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/11413917?v=3', 'updated_at': '2015-06-25T16:09:12Z', 'created_at': '2015-03-10T20:27:55Z', 'name': 'Matt Adair', 'hireable': None, 'company': 'Silverpop - IBM', 'site_admin': False, 'login': 'mattadair', 'following_url': 'https://api.github.com/users/mattadair/following{/other_user}', 'html_url': 'https://github.com/mattadair', 'url': 'https://api.github.com/users/mattadair', 'location': 'Atlanta', 'id': 11413917, 'events_url': 'https://api.github.com/users/mattadair/events{/privacy}'}, 'leitu': {'received_events_url': 'https://api.github.com/users/leitu/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/leitu/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/leitu/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/leitu/orgs', 'public_repos': 78, 'email': 'alex.lei.tu@gmail.com', 'followers_url': 'https://api.github.com/users/leitu/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 9, 'type': 'User', 'repos_url': 'https://api.github.com/users/leitu/repos', 'following': 15, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/leitu/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2867202?v=3', 'updated_at': '2016-01-28T03:32:28Z', 'created_at': '2012-11-23T00:53:21Z', 'name': 'Lei Tu', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'leitu', 'following_url': 'https://api.github.com/users/leitu/following{/other_user}', 'html_url': 'https://github.com/leitu', 'url': 'https://api.github.com/users/leitu', 'location': 'Shanghai', 'id': 2867202, 'events_url': 'https://api.github.com/users/leitu/events{/privacy}'}, 'chiel1980': {'received_events_url': 'https://api.github.com/users/chiel1980/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/chiel1980/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/chiel1980/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/chiel1980/orgs', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/chiel1980/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/chiel1980/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/chiel1980/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5485100?v=3', 'updated_at': '2015-12-17T12:30:55Z', 'created_at': '2013-09-18T09:11:07Z', 'name': 'Michiel van Es', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'chiel1980', 'following_url': 'https://api.github.com/users/chiel1980/following{/other_user}', 'html_url': 'https://github.com/chiel1980', 'url': 'https://api.github.com/users/chiel1980', 'location': None, 'id': 5485100, 'events_url': 'https://api.github.com/users/chiel1980/events{/privacy}'}, 'Adman': {'received_events_url': 'https://api.github.com/users/Adman/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Adman/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Adman/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Adman/orgs', 'public_repos': 26, 'email': 'ado@xlc-team.info', 'followers_url': 'https://api.github.com/users/Adman/followers', 'gravatar_id': '', 'public_gists': 3, 'followers': 22, 'type': 'User', 'repos_url': 'https://api.github.com/users/Adman/repos', 'following': 1, 'blog': 'http://xlc-team.info', 'subscriptions_url': 'https://api.github.com/users/Adman/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/865063?v=3', 'updated_at': '2016-02-19T15:36:39Z', 'created_at': '2011-06-21T18:57:51Z', 'name': 'Adrián Matejov', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'Adman', 'following_url': 'https://api.github.com/users/Adman/following{/other_user}', 'html_url': 'https://github.com/Adman', 'url': 'https://api.github.com/users/Adman', 'location': 'Slovakia', 'id': 865063, 'events_url': 'https://api.github.com/users/Adman/events{/privacy}'}, 'helo9': {'received_events_url': 'https://api.github.com/users/helo9/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/helo9/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/helo9/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/helo9/orgs', 'public_repos': 7, 'email': None, 'followers_url': 'https://api.github.com/users/helo9/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/helo9/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/helo9/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1108683?v=3', 'updated_at': '2016-02-09T22:35:49Z', 'created_at': '2011-10-06T21:05:50Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'helo9', 'following_url': 'https://api.github.com/users/helo9/following{/other_user}', 'html_url': 'https://github.com/helo9', 'url': 'https://api.github.com/users/helo9', 'location': None, 'id': 1108683, 'events_url': 'https://api.github.com/users/helo9/events{/privacy}'}, 'Kromey': {'received_events_url': 'https://api.github.com/users/Kromey/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Kromey/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Kromey/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Kromey/orgs', 'public_repos': 16, 'email': None, 'followers_url': 'https://api.github.com/users/Kromey/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 2, 'type': 'User', 'repos_url': 'https://api.github.com/users/Kromey/repos', 'following': 1, 'blog': 'https://kromey.us/', 'subscriptions_url': 'https://api.github.com/users/Kromey/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1141941?v=3', 'updated_at': '2015-10-21T05:55:34Z', 'created_at': '2011-10-20T22:28:54Z', 'name': 'Travis Veazey', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'Kromey', 'following_url': 'https://api.github.com/users/Kromey/following{/other_user}', 'html_url': 'https://github.com/Kromey', 'url': 'https://api.github.com/users/Kromey', 'location': 'Fairbanks, AK, USA', 'id': 1141941, 'events_url': 'https://api.github.com/users/Kromey/events{/privacy}'}, 'charlesrg': {'received_events_url': 'https://api.github.com/users/charlesrg/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/charlesrg/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/charlesrg/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/charlesrg/orgs', 'public_repos': 21, 'email': None, 'followers_url': 'https://api.github.com/users/charlesrg/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/charlesrg/repos', 'following': 1, 'blog': 'http://www.charlesgomes.com', 'subscriptions_url': 'https://api.github.com/users/charlesrg/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1641239?v=3', 'updated_at': '2015-10-16T20:00:07Z', 'created_at': '2012-04-13T18:32:43Z', 'name': 'Charles Gomes', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'charlesrg', 'following_url': 'https://api.github.com/users/charlesrg/following{/other_user}', 'html_url': 'https://github.com/charlesrg', 'url': 'https://api.github.com/users/charlesrg', 'location': None, 'id': 1641239, 'events_url': 'https://api.github.com/users/charlesrg/events{/privacy}'}, 'allyunion': {'received_events_url': 'https://api.github.com/users/allyunion/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/allyunion/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/allyunion/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/allyunion/orgs', 'public_repos': 15, 'email': None, 'followers_url': 'https://api.github.com/users/allyunion/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/allyunion/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/allyunion/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1470643?v=3', 'updated_at': '2015-08-13T16:38:40Z', 'created_at': '2012-02-24T19:04:00Z', 'name': 'Jason Lee', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'allyunion', 'following_url': 'https://api.github.com/users/allyunion/following{/other_user}', 'html_url': 'https://github.com/allyunion', 'url': 'https://api.github.com/users/allyunion', 'location': 'Los Angeles, CA', 'id': 1470643, 'events_url': 'https://api.github.com/users/allyunion/events{/privacy}'}, 'HaroBling': {'received_events_url': 'https://api.github.com/users/HaroBling/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/HaroBling/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/HaroBling/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/HaroBling/orgs', 'public_repos': 1, 'email': None, 'followers_url': 'https://api.github.com/users/HaroBling/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/HaroBling/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/HaroBling/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/645892?v=3', 'updated_at': '2016-07-29T03:55:21Z', 'created_at': '2011-03-01T23:29:37Z', 'name': 'HaroBling', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'HaroBling', 'following_url': 'https://api.github.com/users/HaroBling/following{/other_user}', 'html_url': 'https://github.com/HaroBling', 'url': 'https://api.github.com/users/HaroBling', 'location': None, 'id': 645892, 'events_url': 'https://api.github.com/users/HaroBling/events{/privacy}'}, 'darkorb': {'received_events_url': 'https://api.github.com/users/darkorb/received_events', 'following_url': 'https://api.github.com/users/darkorb/following{/other_user}', 'gists_url': 'https://api.github.com/users/darkorb/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/darkorb/starred{/owner}{/repo}', 'html_url': 'https://github.com/darkorb', 'public_repos': 5, 'email': None, 'followers_url': 'https://api.github.com/users/darkorb/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 1, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/darkorb/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/darkorb/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/3943313?v=3', 'updated_at': '2016-12-04T06:35:40Z', 'created_at': '2013-03-22T17:08:50Z', 'name': 'Alex Smith', 'hireable': None, 'company': None, 'site_admin': False, 'id': 3943313, 'organizations_url': 'https://api.github.com/users/darkorb/orgs', 'url': 'https://api.github.com/users/darkorb', 'location': 'New Zealand', 'login': 'darkorb', 'events_url': 'https://api.github.com/users/darkorb/events{/privacy}'}, 'rroemhild': {'received_events_url': 'https://api.github.com/users/rroemhild/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/rroemhild/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/rroemhild/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/rroemhild/orgs', 'public_repos': 20, 'email': None, 'followers_url': 'https://api.github.com/users/rroemhild/followers', 'gravatar_id': '', 'public_gists': 10, 'followers': 20, 'type': 'User', 'repos_url': 'https://api.github.com/users/rroemhild/repos', 'following': 16, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/rroemhild/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/78305?v=3', 'updated_at': '2016-07-11T09:09:14Z', 'created_at': '2009-04-27T12:03:58Z', 'name': 'Rafael Römhild', 'hireable': None, 'company': 'Scholz & Volkmer GmbH', 'site_admin': False, 'login': 'rroemhild', 'following_url': 'https://api.github.com/users/rroemhild/following{/other_user}', 'html_url': 'https://github.com/rroemhild', 'url': 'https://api.github.com/users/rroemhild', 'location': 'germany', 'id': 78305, 'events_url': 'https://api.github.com/users/rroemhild/events{/privacy}'}, 'Bloodskate': {'received_events_url': 'https://api.github.com/users/Bloodskate/received_events', 'following_url': 'https://api.github.com/users/Bloodskate/following{/other_user}', 'gists_url': 'https://api.github.com/users/Bloodskate/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Bloodskate/starred{/owner}{/repo}', 'html_url': 'https://github.com/Bloodskate', 'public_repos': 20, 'email': None, 'followers_url': 'https://api.github.com/users/Bloodskate/followers', 'followers': 6, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/Bloodskate/repos', 'following': 4, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/Bloodskate/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/11041007?v=3', 'updated_at': '2016-12-16T16:22:15Z', 'created_at': '2015-02-17T07:37:57Z', 'name': 'Prasanna Mishra', 'hireable': None, 'company': 'BrainAnts', 'site_admin': False, 'id': 11041007, 'organizations_url': 'https://api.github.com/users/Bloodskate/orgs', 'url': 'https://api.github.com/users/Bloodskate', 'location': 'Kathmandu , Nepal', 'login': 'Bloodskate', 'events_url': 'https://api.github.com/users/Bloodskate/events{/privacy}'}, 'fmnisme': {'received_events_url': 'https://api.github.com/users/fmnisme/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/fmnisme/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/fmnisme/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/fmnisme/orgs', 'public_repos': 16, 'email': 'fmnisme@gmail.com', 'followers_url': 'https://api.github.com/users/fmnisme/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/fmnisme/repos', 'following': 1, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/fmnisme/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5197501?v=3', 'updated_at': '2015-10-12T03:42:55Z', 'created_at': '2013-08-09T13:28:57Z', 'name': 'fmn', 'hireable': None, 'company': 'ND.', 'site_admin': False, 'login': 'fmnisme', 'following_url': 'https://api.github.com/users/fmnisme/following{/other_user}', 'html_url': 'https://github.com/fmnisme', 'url': 'https://api.github.com/users/fmnisme', 'location': 'China', 'id': 5197501, 'events_url': 'https://api.github.com/users/fmnisme/events{/privacy}'}, 'harlowja': {'received_events_url': 'https://api.github.com/users/harlowja/received_events', 'following_url': 'https://api.github.com/users/harlowja/following{/other_user}', 'gists_url': 'https://api.github.com/users/harlowja/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/harlowja/starred{/owner}{/repo}', 'html_url': 'https://github.com/harlowja', 'public_repos': 69, 'email': 'harlowja@gmail.com', 'followers_url': 'https://api.github.com/users/harlowja/followers', 'followers': 53, 'gravatar_id': '', 'public_gists': 245, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/harlowja/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/harlowja/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/970458?v=3', 'updated_at': '2016-12-05T21:43:58Z', 'created_at': '2011-08-10T03:02:54Z', 'name': 'Joshua Harlow', 'hireable': None, 'company': 'GoDaddy', 'site_admin': False, 'id': 970458, 'organizations_url': 'https://api.github.com/users/harlowja/orgs', 'url': 'https://api.github.com/users/harlowja', 'location': 'Sunnyvale', 'login': 'harlowja', 'events_url': 'https://api.github.com/users/harlowja/events{/privacy}'}, 'KeijiAurora': {'received_events_url': 'https://api.github.com/users/KeijiAurora/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/KeijiAurora/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/KeijiAurora/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/KeijiAurora/orgs', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/KeijiAurora/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/KeijiAurora/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/KeijiAurora/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/8508688?v=3', 'updated_at': '2014-09-10T06:24:42Z', 'created_at': '2014-08-21T01:33:41Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'KeijiAurora', 'following_url': 'https://api.github.com/users/KeijiAurora/following{/other_user}', 'html_url': 'https://github.com/KeijiAurora', 'url': 'https://api.github.com/users/KeijiAurora', 'location': None, 'id': 8508688, 'events_url': 'https://api.github.com/users/KeijiAurora/events{/privacy}'}, 'RyanHartje': {'received_events_url': 'https://api.github.com/users/RyanHartje/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/RyanHartje/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/RyanHartje/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/RyanHartje/orgs', 'public_repos': 19, 'email': None, 'followers_url': 'https://api.github.com/users/RyanHartje/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 7, 'type': 'User', 'repos_url': 'https://api.github.com/users/RyanHartje/repos', 'following': 15, 'blog': 'http://ryanhartje.com', 'subscriptions_url': 'https://api.github.com/users/RyanHartje/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/4894582?v=3', 'updated_at': '2016-06-24T13:33:20Z', 'created_at': '2013-06-30T18:22:54Z', 'name': 'Ryan Hartje', 'hireable': None, 'company': 'WP Engine', 'site_admin': False, 'login': 'RyanHartje', 'following_url': 'https://api.github.com/users/RyanHartje/following{/other_user}', 'html_url': 'https://github.com/RyanHartje', 'url': 'https://api.github.com/users/RyanHartje', 'location': 'Austin, TX', 'id': 4894582, 'events_url': 'https://api.github.com/users/RyanHartje/events{/privacy}'}, 'hanks': {'received_events_url': 'https://api.github.com/users/hanks/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/hanks/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/hanks/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/hanks/orgs', 'public_repos': 34, 'email': 'zhouhan315 at gmail dot com', 'followers_url': 'https://api.github.com/users/hanks/followers', 'gravatar_id': '', 'public_gists': 11, 'followers': 31, 'type': 'User', 'repos_url': 'https://api.github.com/users/hanks/repos', 'following': 8, 'blog': 'http://www.cnblogs.com/btchenguang/', 'subscriptions_url': 'https://api.github.com/users/hanks/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/759107?v=3', 'updated_at': '2015-09-29T14:23:27Z', 'created_at': '2011-04-29T14:33:57Z', 'name': 'hanks', 'hireable': True, 'company': 'China', 'site_admin': False, 'login': 'hanks', 'following_url': 'https://api.github.com/users/hanks/following{/other_user}', 'html_url': 'https://github.com/hanks', 'url': 'https://api.github.com/users/hanks', 'location': 'Japan', 'id': 759107, 'events_url': 'https://api.github.com/users/hanks/events{/privacy}'}, 'deko2369': {'received_events_url': 'https://api.github.com/users/deko2369/received_events', 'following_url': 'https://api.github.com/users/deko2369/following{/other_user}', 'gists_url': 'https://api.github.com/users/deko2369/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/deko2369/starred{/owner}{/repo}', 'html_url': 'https://github.com/deko2369', 'public_repos': 10, 'email': None, 'followers_url': 'https://api.github.com/users/deko2369/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 1, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/deko2369/repos', 'following': 1, 'blog': 'https://twitter.com/deko2369/', 'subscriptions_url': 'https://api.github.com/users/deko2369/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1182616?v=3', 'updated_at': '2016-11-21T08:52:35Z', 'created_at': '2011-11-09T05:28:35Z', 'name': 'deko2369', 'hireable': None, 'company': 'y', 'site_admin': False, 'id': 1182616, 'organizations_url': 'https://api.github.com/users/deko2369/orgs', 'url': 'https://api.github.com/users/deko2369', 'location': 'Kanagawa, Japan', 'login': 'deko2369', 'events_url': 'https://api.github.com/users/deko2369/events{/privacy}'}, 'jwm': {'received_events_url': 'https://api.github.com/users/jwm/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/jwm/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jwm/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/jwm/orgs', 'public_repos': 33, 'email': 'jwm@horde.net', 'followers_url': 'https://api.github.com/users/jwm/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 15, 'type': 'User', 'repos_url': 'https://api.github.com/users/jwm/repos', 'following': 10, 'blog': 'http://horde.net/~jwm/', 'subscriptions_url': 'https://api.github.com/users/jwm/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/37176?v=3', 'updated_at': '2015-08-19T19:30:01Z', 'created_at': '2008-11-29T01:24:25Z', 'name': 'John Morrissey', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'jwm', 'following_url': 'https://api.github.com/users/jwm/following{/other_user}', 'html_url': 'https://github.com/jwm', 'url': 'https://api.github.com/users/jwm', 'location': 'Cambridge, MA', 'id': 37176, 'events_url': 'https://api.github.com/users/jwm/events{/privacy}'}, 'thebmo': {'received_events_url': 'https://api.github.com/users/thebmo/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/thebmo/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/thebmo/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/thebmo/orgs', 'public_repos': 9, 'email': 'bmosier@gmail.com', 'followers_url': 'https://api.github.com/users/thebmo/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 5, 'type': 'User', 'repos_url': 'https://api.github.com/users/thebmo/repos', 'following': 4, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/thebmo/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/4762033?v=3', 'updated_at': '2015-08-28T14:00:50Z', 'created_at': '2013-06-21T16:39:15Z', 'name': 'Brian Mosier', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'thebmo', 'following_url': 'https://api.github.com/users/thebmo/following{/other_user}', 'html_url': 'https://github.com/thebmo', 'url': 'https://api.github.com/users/thebmo', 'location': 'Boston, MA', 'id': 4762033, 'events_url': 'https://api.github.com/users/thebmo/events{/privacy}'}, 'garmann': {'received_events_url': 'https://api.github.com/users/garmann/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/garmann/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/garmann/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/garmann/orgs', 'public_repos': 19, 'email': 'gregor.armann@googlemail.com', 'followers_url': 'https://api.github.com/users/garmann/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/garmann/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/garmann/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2914242?v=3', 'updated_at': '2016-07-25T09:45:06Z', 'created_at': '2012-11-28T18:24:03Z', 'name': 'Gregor Armann', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'garmann', 'following_url': 'https://api.github.com/users/garmann/following{/other_user}', 'html_url': 'https://github.com/garmann', 'url': 'https://api.github.com/users/garmann', 'location': 'Berlin', 'id': 2914242, 'events_url': 'https://api.github.com/users/garmann/events{/privacy}'}, 'apophys': {'received_events_url': 'https://api.github.com/users/apophys/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/apophys/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/apophys/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/apophys/orgs', 'public_repos': 12, 'email': 'admin@kubikmilan.sk', 'followers_url': 'https://api.github.com/users/apophys/followers', 'gravatar_id': '', 'public_gists': 4, 'followers': 9, 'type': 'User', 'repos_url': 'https://api.github.com/users/apophys/repos', 'following': 16, 'blog': 'https://blog.kubikmilan.sk', 'subscriptions_url': 'https://api.github.com/users/apophys/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/666120?v=3', 'updated_at': '2015-09-16T14:52:04Z', 'created_at': '2011-03-12T20:09:46Z', 'name': 'Milan Kubík', 'hireable': None, 'company': 'Red Hat', 'site_admin': False, 'login': 'apophys', 'following_url': 'https://api.github.com/users/apophys/following{/other_user}', 'html_url': 'https://github.com/apophys', 'url': 'https://api.github.com/users/apophys', 'location': 'Brno, Czech Republic', 'id': 666120, 'events_url': 'https://api.github.com/users/apophys/events{/privacy}'}, 'rossnz': {'received_events_url': 'https://api.github.com/users/rossnz/received_events', 'following_url': 'https://api.github.com/users/rossnz/following{/other_user}', 'gists_url': 'https://api.github.com/users/rossnz/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/rossnz/starred{/owner}{/repo}', 'html_url': 'https://github.com/rossnz', 'public_repos': 6, 'email': None, 'followers_url': 'https://api.github.com/users/rossnz/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 12, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/rossnz/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/rossnz/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1291611?v=3', 'updated_at': '2016-11-16T04:13:57Z', 'created_at': '2011-12-29T02:20:39Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'id': 1291611, 'organizations_url': 'https://api.github.com/users/rossnz/orgs', 'url': 'https://api.github.com/users/rossnz', 'location': None, 'login': 'rossnz', 'events_url': 'https://api.github.com/users/rossnz/events{/privacy}'}, 'Betriebsrat': {'received_events_url': 'https://api.github.com/users/Betriebsrat/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Betriebsrat/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Betriebsrat/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Betriebsrat/orgs', 'public_repos': 9, 'email': None, 'followers_url': 'https://api.github.com/users/Betriebsrat/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/Betriebsrat/repos', 'following': 4, 'blog': 'http://drugfactory.org', 'subscriptions_url': 'https://api.github.com/users/Betriebsrat/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/7068223?v=3', 'updated_at': '2015-10-14T20:06:31Z', 'created_at': '2014-03-26T09:44:08Z', 'name': 'Sebastian', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'Betriebsrat', 'following_url': 'https://api.github.com/users/Betriebsrat/following{/other_user}', 'html_url': 'https://github.com/Betriebsrat', 'url': 'https://api.github.com/users/Betriebsrat', 'location': None, 'id': 7068223, 'events_url': 'https://api.github.com/users/Betriebsrat/events{/privacy}'}, 'Navisite': {'received_events_url': 'https://api.github.com/users/Navisite/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Navisite/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Navisite/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Navisite/orgs', 'public_repos': 9, 'email': None, 'followers_url': 'https://api.github.com/users/Navisite/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/Navisite/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/Navisite/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1181318?v=3', 'updated_at': '2016-01-05T10:30:23Z', 'created_at': '2011-11-08T17:28:21Z', 'name': 'NaviSite - RND', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'Navisite', 'following_url': 'https://api.github.com/users/Navisite/following{/other_user}', 'html_url': 'https://github.com/Navisite', 'url': 'https://api.github.com/users/Navisite', 'location': 'Syracuse, NY', 'id': 1181318, 'events_url': 'https://api.github.com/users/Navisite/events{/privacy}'}, 'codebam': {'received_events_url': 'https://api.github.com/users/codebam/received_events', 'following_url': 'https://api.github.com/users/codebam/following{/other_user}', 'gists_url': 'https://api.github.com/users/codebam/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/codebam/starred{/owner}{/repo}', 'html_url': 'https://github.com/codebam', 'public_repos': 49, 'email': 'codebam@riseup.net', 'followers_url': 'https://api.github.com/users/codebam/followers', 'followers': 12, 'gravatar_id': '', 'public_gists': 5, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/codebam/repos', 'following': 56, 'blog': 'http://sean-behan.appspot.com', 'subscriptions_url': 'https://api.github.com/users/codebam/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6035884?v=3', 'updated_at': '2016-12-13T05:47:42Z', 'created_at': '2013-11-25T23:56:30Z', 'name': 'Sean Behan', 'hireable': True, 'company': None, 'site_admin': False, 'id': 6035884, 'organizations_url': 'https://api.github.com/users/codebam/orgs', 'url': 'https://api.github.com/users/codebam', 'location': 'Canada, Toronto', 'login': 'codebam', 'events_url': 'https://api.github.com/users/codebam/events{/privacy}'}, 'MattHodge': {'received_events_url': 'https://api.github.com/users/MattHodge/received_events', 'following_url': 'https://api.github.com/users/MattHodge/following{/other_user}', 'gists_url': 'https://api.github.com/users/MattHodge/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/MattHodge/starred{/owner}{/repo}', 'html_url': 'https://github.com/MattHodge', 'public_repos': 31, 'email': 'matthodge@gmail.com', 'followers_url': 'https://api.github.com/users/MattHodge/followers', 'followers': 51, 'gravatar_id': '', 'public_gists': 45, 'bio': 'Australian and ex-Texan living and working in The Netherlands.', 'type': 'User', 'repos_url': 'https://api.github.com/users/MattHodge/repos', 'following': 2, 'blog': 'http://www.hodgkins.io', 'subscriptions_url': 'https://api.github.com/users/MattHodge/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1966555?v=3', 'updated_at': '2016-12-16T16:11:19Z', 'created_at': '2012-07-13T00:43:23Z', 'name': 'Matthew Hodgkins', 'hireable': True, 'company': 'Coolblue', 'site_admin': False, 'id': 1966555, 'organizations_url': 'https://api.github.com/users/MattHodge/orgs', 'url': 'https://api.github.com/users/MattHodge', 'location': 'Rotterdam, The Netherlands', 'login': 'MattHodge', 'events_url': 'https://api.github.com/users/MattHodge/events{/privacy}'}, 'nelsonam': {'received_events_url': 'https://api.github.com/users/nelsonam/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/nelsonam/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/nelsonam/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/nelsonam/orgs', 'public_repos': 38, 'email': None, 'followers_url': 'https://api.github.com/users/nelsonam/followers', 'gravatar_id': '', 'public_gists': 8, 'followers': 33, 'type': 'User', 'repos_url': 'https://api.github.com/users/nelsonam/repos', 'following': 20, 'blog': 'http://themusegarden.wordpress.com', 'subscriptions_url': 'https://api.github.com/users/nelsonam/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/557945?v=3', 'updated_at': '2015-10-21T03:35:05Z', 'created_at': '2011-01-11T18:42:54Z', 'name': 'Allison Nelson', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'nelsonam', 'following_url': 'https://api.github.com/users/nelsonam/following{/other_user}', 'html_url': 'https://github.com/nelsonam', 'url': 'https://api.github.com/users/nelsonam', 'location': 'San Francisco', 'id': 557945, 'events_url': 'https://api.github.com/users/nelsonam/events{/privacy}'}, 'linuxtechie': {'received_events_url': 'https://api.github.com/users/linuxtechie/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/linuxtechie/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/linuxtechie/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/linuxtechie/orgs', 'public_repos': 13, 'email': 'myself@linuxtechie.com', 'followers_url': 'https://api.github.com/users/linuxtechie/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/linuxtechie/repos', 'following': 1, 'blog': 'www.linuxtechie.com', 'subscriptions_url': 'https://api.github.com/users/linuxtechie/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/206623?v=3', 'updated_at': '2015-05-27T06:31:48Z', 'created_at': '2010-02-19T10:01:55Z', 'name': 'Linuxtechie', 'hireable': True, 'company': 'www.linuxtechie.com', 'site_admin': False, 'login': 'linuxtechie', 'following_url': 'https://api.github.com/users/linuxtechie/following{/other_user}', 'html_url': 'https://github.com/linuxtechie', 'url': 'https://api.github.com/users/linuxtechie', 'location': 'Pune, India', 'id': 206623, 'events_url': 'https://api.github.com/users/linuxtechie/events{/privacy}'}, 'glenbot': {'received_events_url': 'https://api.github.com/users/glenbot/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/glenbot/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/glenbot/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/glenbot/orgs', 'public_repos': 39, 'email': None, 'followers_url': 'https://api.github.com/users/glenbot/followers', 'gravatar_id': '', 'public_gists': 11, 'followers': 31, 'type': 'User', 'repos_url': 'https://api.github.com/users/glenbot/repos', 'following': 20, 'blog': 'http://glenbot.com', 'subscriptions_url': 'https://api.github.com/users/glenbot/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/122543?v=3', 'updated_at': '2015-10-12T19:16:07Z', 'created_at': '2009-09-02T19:43:32Z', 'name': 'Glen Zangirolami', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'glenbot', 'following_url': 'https://api.github.com/users/glenbot/following{/other_user}', 'html_url': 'https://github.com/glenbot', 'url': 'https://api.github.com/users/glenbot', 'location': 'Houston, TX ', 'id': 122543, 'events_url': 'https://api.github.com/users/glenbot/events{/privacy}'}, 'ibelle': {'received_events_url': 'https://api.github.com/users/ibelle/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/ibelle/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ibelle/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/ibelle/orgs', 'public_repos': 12, 'email': 'isaiah@isaiahbelle.com', 'followers_url': 'https://api.github.com/users/ibelle/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 2, 'type': 'User', 'repos_url': 'https://api.github.com/users/ibelle/repos', 'following': 2, 'blog': 'http://www.isaiahbelle.com', 'subscriptions_url': 'https://api.github.com/users/ibelle/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1002411?v=3', 'updated_at': '2015-10-17T14:37:51Z', 'created_at': '2011-08-24T19:48:41Z', 'name': 'Isaiah Belle', 'hireable': None, 'company': 'Isaiah Belle Digital LLC', 'site_admin': False, 'login': 'ibelle', 'following_url': 'https://api.github.com/users/ibelle/following{/other_user}', 'html_url': 'https://github.com/ibelle', 'url': 'https://api.github.com/users/ibelle', 'location': 'United States', 'id': 1002411, 'events_url': 'https://api.github.com/users/ibelle/events{/privacy}'}, 'stevemcquaid': {'received_events_url': 'https://api.github.com/users/stevemcquaid/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/stevemcquaid/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/stevemcquaid/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/stevemcquaid/orgs', 'public_repos': 72, 'email': 'steve@stevemcquaid.com', 'followers_url': 'https://api.github.com/users/stevemcquaid/followers', 'gravatar_id': '', 'public_gists': 20, 'followers': 3, 'type': 'User', 'repos_url': 'https://api.github.com/users/stevemcquaid/repos', 'following': 11, 'blog': 'http://www.stevemcquaid.com', 'subscriptions_url': 'https://api.github.com/users/stevemcquaid/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/974046?v=3', 'updated_at': '2016-02-16T17:49:11Z', 'created_at': '2011-08-11T14:24:21Z', 'name': 'Stephen McQuaid', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'stevemcquaid', 'following_url': 'https://api.github.com/users/stevemcquaid/following{/other_user}', 'html_url': 'https://github.com/stevemcquaid', 'url': 'https://api.github.com/users/stevemcquaid', 'location': 'Sunnyvale, CA', 'id': 974046, 'events_url': 'https://api.github.com/users/stevemcquaid/events{/privacy}'}, 'freddii': {'received_events_url': 'https://api.github.com/users/freddii/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/freddii/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/freddii/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/freddii/orgs', 'public_repos': 10, 'email': None, 'followers_url': 'https://api.github.com/users/freddii/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/freddii/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/freddii/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/7213207?v=3', 'updated_at': '2016-05-19T23:11:34Z', 'created_at': '2014-04-07T18:34:29Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'freddii', 'following_url': 'https://api.github.com/users/freddii/following{/other_user}', 'html_url': 'https://github.com/freddii', 'url': 'https://api.github.com/users/freddii', 'location': None, 'id': 7213207, 'events_url': 'https://api.github.com/users/freddii/events{/privacy}'}, 'taoistmath': {'received_events_url': 'https://api.github.com/users/taoistmath/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/taoistmath/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/taoistmath/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/taoistmath/orgs', 'public_repos': 21, 'email': 'greg.fogelberg@gmail.com', 'followers_url': 'https://api.github.com/users/taoistmath/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 2, 'type': 'User', 'repos_url': 'https://api.github.com/users/taoistmath/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/taoistmath/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/588833?v=3', 'updated_at': '2015-12-06T16:46:47Z', 'created_at': '2011-01-28T18:39:13Z', 'name': 'Greg Fogelberg', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'taoistmath', 'following_url': 'https://api.github.com/users/taoistmath/following{/other_user}', 'html_url': 'https://github.com/taoistmath', 'url': 'https://api.github.com/users/taoistmath', 'location': None, 'id': 588833, 'events_url': 'https://api.github.com/users/taoistmath/events{/privacy}'}, 'gbin': {'received_events_url': 'https://api.github.com/users/gbin/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/gbin/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/gbin/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/gbin/orgs', 'public_repos': 89, 'email': 'gbin@gootz.net', 'followers_url': 'https://api.github.com/users/gbin/followers', 'gravatar_id': '', 'public_gists': 15, 'followers': 46, 'type': 'User', 'repos_url': 'https://api.github.com/users/gbin/repos', 'following': 35, 'blog': 'http://klaig.blogspot.com/', 'subscriptions_url': 'https://api.github.com/users/gbin/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/975564?v=3', 'updated_at': '2015-10-25T00:16:24Z', 'created_at': '2011-08-12T07:27:26Z', 'name': 'Guillaume Binet', 'hireable': True, 'company': 'Google', 'site_admin': False, 'login': 'gbin', 'following_url': 'https://api.github.com/users/gbin/following{/other_user}', 'html_url': 'https://github.com/gbin', 'url': 'https://api.github.com/users/gbin', 'location': 'San Francisco', 'id': 975564, 'events_url': 'https://api.github.com/users/gbin/events{/privacy}'}, 'benvd': {'received_events_url': 'https://api.github.com/users/benvd/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/benvd/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/benvd/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/benvd/orgs', 'public_repos': 15, 'email': None, 'followers_url': 'https://api.github.com/users/benvd/followers', 'gravatar_id': '', 'public_gists': 7, 'followers': 12, 'type': 'User', 'repos_url': 'https://api.github.com/users/benvd/repos', 'following': 1, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/benvd/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/332862?v=3', 'updated_at': '2015-10-07T12:44:33Z', 'created_at': '2010-07-15T10:25:52Z', 'name': 'Ben Van Daele', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'benvd', 'following_url': 'https://api.github.com/users/benvd/following{/other_user}', 'html_url': 'https://github.com/benvd', 'url': 'https://api.github.com/users/benvd', 'location': 'Belgium', 'id': 332862, 'events_url': 'https://api.github.com/users/benvd/events{/privacy}'}, 'alexanderfahlke': {'received_events_url': 'https://api.github.com/users/alexanderfahlke/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/alexanderfahlke/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/alexanderfahlke/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/alexanderfahlke/orgs', 'public_repos': 20, 'email': None, 'followers_url': 'https://api.github.com/users/alexanderfahlke/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 6, 'type': 'User', 'repos_url': 'https://api.github.com/users/alexanderfahlke/repos', 'following': 8, 'blog': 'http://hadooppowered.com', 'subscriptions_url': 'https://api.github.com/users/alexanderfahlke/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/190039?v=3', 'updated_at': '2016-02-14T14:45:40Z', 'created_at': '2010-01-26T11:23:08Z', 'name': 'Alexander Fahlke', 'hireable': None, 'company': 'GfK SE', 'site_admin': False, 'login': 'alexanderfahlke', 'following_url': 'https://api.github.com/users/alexanderfahlke/following{/other_user}', 'html_url': 'https://github.com/alexanderfahlke', 'url': 'https://api.github.com/users/alexanderfahlke', 'location': 'Germany', 'id': 190039, 'events_url': 'https://api.github.com/users/alexanderfahlke/events{/privacy}'}, 'jasedit': {'received_events_url': 'https://api.github.com/users/jasedit/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/jasedit/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jasedit/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/jasedit/orgs', 'public_repos': 22, 'email': 'jasedit@gmail.com', 'followers_url': 'https://api.github.com/users/jasedit/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 6, 'type': 'User', 'repos_url': 'https://api.github.com/users/jasedit/repos', 'following': 8, 'blog': 'http://www.catexia.com', 'subscriptions_url': 'https://api.github.com/users/jasedit/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/53046?v=3', 'updated_at': '2016-07-08T20:22:16Z', 'created_at': '2009-02-09T15:51:54Z', 'name': 'Jason Ziglar', 'hireable': None, 'company': 'Virginia Tech', 'site_admin': False, 'login': 'jasedit', 'following_url': 'https://api.github.com/users/jasedit/following{/other_user}', 'html_url': 'https://github.com/jasedit', 'url': 'https://api.github.com/users/jasedit', 'location': 'Christiansburg, VA', 'id': 53046, 'events_url': 'https://api.github.com/users/jasedit/events{/privacy}'}, 'keithslater': {'received_events_url': 'https://api.github.com/users/keithslater/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/keithslater/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/keithslater/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/keithslater/orgs', 'public_repos': 11, 'email': None, 'followers_url': 'https://api.github.com/users/keithslater/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/keithslater/repos', 'following': 0, 'blog': 'http://www.keithslater.com', 'subscriptions_url': 'https://api.github.com/users/keithslater/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/940991?v=3', 'updated_at': '2016-01-04T21:36:04Z', 'created_at': '2011-07-27T01:46:45Z', 'name': 'Keith Slater', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'keithslater', 'following_url': 'https://api.github.com/users/keithslater/following{/other_user}', 'html_url': 'https://github.com/keithslater', 'url': 'https://api.github.com/users/keithslater', 'location': 'St. Louis', 'id': 940991, 'events_url': 'https://api.github.com/users/keithslater/events{/privacy}'}, 'J4LP': {'received_events_url': 'https://api.github.com/users/J4LP/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/J4LP/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/J4LP/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/J4LP/orgs', 'public_repos': 20, 'email': None, 'followers_url': 'https://api.github.com/users/J4LP/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/J4LP/repos', 'following': 0, 'blog': 'http://j4lp.com', 'subscriptions_url': 'https://api.github.com/users/J4LP/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/4965488?v=3', 'updated_at': '2015-04-11T19:52:14Z', 'created_at': '2013-07-08T14:31:58Z', 'name': 'I Whip My Code Back and Forth', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'J4LP', 'following_url': 'https://api.github.com/users/J4LP/following{/other_user}', 'html_url': 'https://github.com/J4LP', 'url': 'https://api.github.com/users/J4LP', 'location': 'Egghelende, Sinq Laison', 'id': 4965488, 'events_url': 'https://api.github.com/users/J4LP/events{/privacy}'}, 'spyn': {'received_events_url': 'https://api.github.com/users/spyn/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/spyn/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/spyn/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/spyn/orgs', 'public_repos': 9, 'email': None, 'followers_url': 'https://api.github.com/users/spyn/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 10, 'type': 'User', 'repos_url': 'https://api.github.com/users/spyn/repos', 'following': 13, 'blog': 'http://www.mattie.id.au', 'subscriptions_url': 'https://api.github.com/users/spyn/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/703285?v=3', 'updated_at': '2015-09-26T15:15:53Z', 'created_at': '2011-04-01T08:31:54Z', 'name': 'Mattie', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'spyn', 'following_url': 'https://api.github.com/users/spyn/following{/other_user}', 'html_url': 'https://github.com/spyn', 'url': 'https://api.github.com/users/spyn', 'location': 'Perth, Western Australia', 'id': 703285, 'events_url': 'https://api.github.com/users/spyn/events{/privacy}'}, 'GoogleCloudPlatform': {'received_events_url': 'https://api.github.com/users/GoogleCloudPlatform/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/GoogleCloudPlatform/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/GoogleCloudPlatform/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/GoogleCloudPlatform/orgs', 'public_repos': 325, 'email': None, 'followers_url': 'https://api.github.com/users/GoogleCloudPlatform/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/GoogleCloudPlatform/repos', 'following': 0, 'blog': 'https://github.com/GoogleCloudPlatform/Template/wiki/Template', 'subscriptions_url': 'https://api.github.com/users/GoogleCloudPlatform/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2810941?v=3', 'updated_at': '2016-07-26T00:52:35Z', 'created_at': '2012-11-16T04:52:17Z', 'name': 'Google Cloud Platform', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'GoogleCloudPlatform', 'following_url': 'https://api.github.com/users/GoogleCloudPlatform/following{/other_user}', 'html_url': 'https://github.com/GoogleCloudPlatform', 'url': 'https://api.github.com/users/GoogleCloudPlatform', 'location': None, 'id': 2810941, 'events_url': 'https://api.github.com/users/GoogleCloudPlatform/events{/privacy}'}, 'ghoti': {'received_events_url': 'https://api.github.com/users/ghoti/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/ghoti/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ghoti/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/ghoti/orgs', 'public_repos': 10, 'email': 'ghoti2007@gmail.com', 'followers_url': 'https://api.github.com/users/ghoti/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/ghoti/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/ghoti/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/34587?v=3', 'updated_at': '2015-06-23T14:33:58Z', 'created_at': '2008-11-15T08:01:42Z', 'name': 'ghoti', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'ghoti', 'following_url': 'https://api.github.com/users/ghoti/following{/other_user}', 'html_url': 'https://github.com/ghoti', 'url': 'https://api.github.com/users/ghoti', 'location': 'Teh Interwebs!', 'id': 34587, 'events_url': 'https://api.github.com/users/ghoti/events{/privacy}'}, 'MarceloCorpucci': {'received_events_url': 'https://api.github.com/users/MarceloCorpucci/received_events', 'following_url': 'https://api.github.com/users/MarceloCorpucci/following{/other_user}', 'gists_url': 'https://api.github.com/users/MarceloCorpucci/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/MarceloCorpucci/starred{/owner}{/repo}', 'html_url': 'https://github.com/MarceloCorpucci', 'public_repos': 18, 'email': 'mcorpucci@gmail.com', 'followers_url': 'https://api.github.com/users/MarceloCorpucci/followers', 'followers': 2, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/MarceloCorpucci/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/MarceloCorpucci/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1916330?v=3', 'updated_at': '2016-12-15T12:40:25Z', 'created_at': '2012-07-03T01:19:36Z', 'name': 'Marcelo Corpucci', 'hireable': None, 'company': None, 'site_admin': False, 'id': 1916330, 'organizations_url': 'https://api.github.com/users/MarceloCorpucci/orgs', 'url': 'https://api.github.com/users/MarceloCorpucci', 'location': 'Buenos Aires, Argentina', 'login': 'MarceloCorpucci', 'events_url': 'https://api.github.com/users/MarceloCorpucci/events{/privacy}'}, 'jordant': {'received_events_url': 'https://api.github.com/users/jordant/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/jordant/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jordant/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/jordant/orgs', 'public_repos': 55, 'email': 'jordan.tardif@gmail.com', 'followers_url': 'https://api.github.com/users/jordant/followers', 'gravatar_id': '', 'public_gists': 9, 'followers': 6, 'type': 'User', 'repos_url': 'https://api.github.com/users/jordant/repos', 'following': 16, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/jordant/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1435863?v=3', 'updated_at': '2015-10-24T10:04:25Z', 'created_at': '2012-02-14T07:08:41Z', 'name': 'Jordan Tardif', 'hireable': True, 'company': 'DreamHost', 'site_admin': False, 'login': 'jordant', 'following_url': 'https://api.github.com/users/jordant/following{/other_user}', 'html_url': 'https://github.com/jordant', 'url': 'https://api.github.com/users/jordant', 'location': 'Long Beach', 'id': 1435863, 'events_url': 'https://api.github.com/users/jordant/events{/privacy}'}, 'statmuse': {'received_events_url': 'https://api.github.com/users/statmuse/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/statmuse/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/statmuse/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/statmuse/orgs', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/statmuse/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/statmuse/repos', 'following': 0, 'blog': 'https://www.statmuse.com/', 'subscriptions_url': 'https://api.github.com/users/statmuse/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/11478405?v=3', 'updated_at': '2016-07-14T09:57:04Z', 'created_at': '2015-03-14T18:50:17Z', 'name': 'StatMuse Inc.', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'statmuse', 'following_url': 'https://api.github.com/users/statmuse/following{/other_user}', 'html_url': 'https://github.com/statmuse', 'url': 'https://api.github.com/users/statmuse', 'location': 'San Francisco, CA', 'id': 11478405, 'events_url': 'https://api.github.com/users/statmuse/events{/privacy}'}, 'fictivekin': {'received_events_url': 'https://api.github.com/users/fictivekin/received_events', 'following_url': 'https://api.github.com/users/fictivekin/following{/other_user}', 'gists_url': 'https://api.github.com/users/fictivekin/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/fictivekin/starred{/owner}{/repo}', 'html_url': 'https://github.com/fictivekin', 'public_repos': 64, 'email': 'hello@fictivekin.com', 'followers_url': 'https://api.github.com/users/fictivekin/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': 'Fictive Kin is a digital engineering & design studio in Brooklyn. We make web & mobile products for select clients.', 'type': 'Organization', 'repos_url': 'https://api.github.com/users/fictivekin/repos', 'following': 0, 'blog': 'http://fictivekin.com/', 'subscriptions_url': 'https://api.github.com/users/fictivekin/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/358205?v=3', 'updated_at': '2016-11-26T10:42:53Z', 'created_at': '2010-08-09T04:38:12Z', 'name': 'Fictive Kin', 'hireable': None, 'company': None, 'site_admin': False, 'id': 358205, 'organizations_url': 'https://api.github.com/users/fictivekin/orgs', 'url': 'https://api.github.com/users/fictivekin', 'location': 'Brooklyn, NY', 'login': 'fictivekin', 'events_url': 'https://api.github.com/users/fictivekin/events{/privacy}'}, 'AoiKuiyuyou': {'received_events_url': 'https://api.github.com/users/AoiKuiyuyou/received_events', 'following_url': 'https://api.github.com/users/AoiKuiyuyou/following{/other_user}', 'gists_url': 'https://api.github.com/users/AoiKuiyuyou/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/AoiKuiyuyou/starred{/owner}{/repo}', 'html_url': 'https://github.com/AoiKuiyuyou', 'public_repos': 123, 'email': None, 'followers_url': 'https://api.github.com/users/AoiKuiyuyou/followers', 'followers': 16, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/AoiKuiyuyou/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/AoiKuiyuyou/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/9025382?v=3', 'updated_at': '2016-08-19T13:39:59Z', 'created_at': '2014-10-05T11:16:53Z', 'name': None, 'hireable': True, 'company': None, 'site_admin': False, 'id': 9025382, 'organizations_url': 'https://api.github.com/users/AoiKuiyuyou/orgs', 'url': 'https://api.github.com/users/AoiKuiyuyou', 'location': None, 'login': 'AoiKuiyuyou', 'events_url': 'https://api.github.com/users/AoiKuiyuyou/events{/privacy}'}, 'chmouel': {'received_events_url': 'https://api.github.com/users/chmouel/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/chmouel/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/chmouel/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/chmouel/orgs', 'public_repos': 97, 'email': 'chmouel@chmouel.com', 'followers_url': 'https://api.github.com/users/chmouel/followers', 'gravatar_id': '', 'public_gists': 87, 'followers': 76, 'type': 'User', 'repos_url': 'https://api.github.com/users/chmouel/repos', 'following': 15, 'blog': 'http://blog.chmouel.com', 'subscriptions_url': 'https://api.github.com/users/chmouel/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/98980?v=3', 'updated_at': '2015-10-24T00:12:28Z', 'created_at': '2009-06-25T20:43:26Z', 'name': 'Chmouel Boudjnah', 'hireable': True, 'company': 'Red Hat', 'site_admin': False, 'login': 'chmouel', 'following_url': 'https://api.github.com/users/chmouel/following{/other_user}', 'html_url': 'https://github.com/chmouel', 'url': 'https://api.github.com/users/chmouel', 'location': 'Paris, France', 'id': 98980, 'events_url': 'https://api.github.com/users/chmouel/events{/privacy}'}, 'drsm79': {'received_events_url': 'https://api.github.com/users/drsm79/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/drsm79/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/drsm79/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/drsm79/orgs', 'public_repos': 75, 'email': None, 'followers_url': 'https://api.github.com/users/drsm79/followers', 'gravatar_id': '', 'public_gists': 17, 'followers': 22, 'type': 'User', 'repos_url': 'https://api.github.com/users/drsm79/repos', 'following': 7, 'blog': 'http://twitter.com/drsm79', 'subscriptions_url': 'https://api.github.com/users/drsm79/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/104090?v=3', 'updated_at': '2015-09-30T17:21:09Z', 'created_at': '2009-07-12T08:56:29Z', 'name': 'Simon Metson', 'hireable': None, 'company': 'IBM Cloud Data Services', 'site_admin': False, 'login': 'drsm79', 'following_url': 'https://api.github.com/users/drsm79/following{/other_user}', 'html_url': 'https://github.com/drsm79', 'url': 'https://api.github.com/users/drsm79', 'location': 'Bristol', 'id': 104090, 'events_url': 'https://api.github.com/users/drsm79/events{/privacy}'}, 'nubity': {'received_events_url': 'https://api.github.com/users/nubity/received_events', 'bio': 'Nubity IT Cloud Monitor & Management', 'gists_url': 'https://api.github.com/users/nubity/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/nubity/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/nubity/orgs', 'public_repos': 2, 'email': 'signup@nubity.com', 'followers_url': 'https://api.github.com/users/nubity/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/nubity/repos', 'following': 0, 'blog': 'http://www.nubity.com/', 'subscriptions_url': 'https://api.github.com/users/nubity/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/13696620?v=3', 'updated_at': '2016-07-07T10:06:04Z', 'created_at': '2015-08-07T15:48:09Z', 'name': 'Nubity Inc', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'nubity', 'following_url': 'https://api.github.com/users/nubity/following{/other_user}', 'html_url': 'https://github.com/nubity', 'url': 'https://api.github.com/users/nubity', 'location': 'Simi Valley, California', 'id': 13696620, 'events_url': 'https://api.github.com/users/nubity/events{/privacy}'}, 'atinaxe': {'received_events_url': 'https://api.github.com/users/atinaxe/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/atinaxe/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/atinaxe/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/atinaxe/orgs', 'public_repos': 1, 'email': None, 'followers_url': 'https://api.github.com/users/atinaxe/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/atinaxe/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/atinaxe/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/13616125?v=3', 'updated_at': '2015-08-07T13:24:05Z', 'created_at': '2015-08-03T00:02:31Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'atinaxe', 'following_url': 'https://api.github.com/users/atinaxe/following{/other_user}', 'html_url': 'https://github.com/atinaxe', 'url': 'https://api.github.com/users/atinaxe', 'location': None, 'id': 13616125, 'events_url': 'https://api.github.com/users/atinaxe/events{/privacy}'}, 'errbotters': {'received_events_url': 'https://api.github.com/users/errbotters/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/errbotters/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/errbotters/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/errbotters/orgs', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/errbotters/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/errbotters/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/errbotters/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/19437736?v=3', 'updated_at': '2016-05-18T20:56:40Z', 'created_at': '2016-05-18T20:47:28Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'errbotters', 'following_url': 'https://api.github.com/users/errbotters/following{/other_user}', 'html_url': 'https://github.com/errbotters', 'url': 'https://api.github.com/users/errbotters', 'location': None, 'id': 19437736, 'events_url': 'https://api.github.com/users/errbotters/events{/privacy}'}, 'mrshu': {'received_events_url': 'https://api.github.com/users/mrshu/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/mrshu/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/mrshu/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/mrshu/orgs', 'public_repos': 177, 'email': None, 'followers_url': 'https://api.github.com/users/mrshu/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 41, 'type': 'User', 'repos_url': 'https://api.github.com/users/mrshu/repos', 'following': 3, 'blog': 'http://shu.io', 'subscriptions_url': 'https://api.github.com/users/mrshu/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/461491?v=3', 'updated_at': '2015-09-21T22:13:42Z', 'created_at': '2010-10-31T09:02:36Z', 'name': 'Marek Šuppa', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'mrshu', 'following_url': 'https://api.github.com/users/mrshu/following{/other_user}', 'html_url': 'https://github.com/mrshu', 'url': 'https://api.github.com/users/mrshu', 'location': 'Slovakia', 'id': 461491, 'events_url': 'https://api.github.com/users/mrshu/events{/privacy}'}, 'ilkka': {'received_events_url': 'https://api.github.com/users/ilkka/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/ilkka/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ilkka/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/ilkka/orgs', 'public_repos': 141, 'email': 'ilkka@ilkka.io', 'followers_url': 'https://api.github.com/users/ilkka/followers', 'gravatar_id': '', 'public_gists': 33, 'followers': 25, 'type': 'User', 'repos_url': 'https://api.github.com/users/ilkka/repos', 'following': 3, 'blog': 'http://ilkka.io', 'subscriptions_url': 'https://api.github.com/users/ilkka/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1549?v=3', 'updated_at': '2016-01-06T14:02:47Z', 'created_at': '2008-02-28T11:12:14Z', 'name': 'Ilkka Laukkanen', 'hireable': None, 'company': 'Futurice', 'site_admin': False, 'login': 'ilkka', 'following_url': 'https://api.github.com/users/ilkka/following{/other_user}', 'html_url': 'https://github.com/ilkka', 'url': 'https://api.github.com/users/ilkka', 'location': 'Finland', 'id': 1549, 'events_url': 'https://api.github.com/users/ilkka/events{/privacy}'}, 'jspam': {'received_events_url': 'https://api.github.com/users/jspam/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/jspam/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jspam/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/jspam/orgs', 'public_repos': 13, 'email': None, 'followers_url': 'https://api.github.com/users/jspam/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 5, 'type': 'User', 'repos_url': 'https://api.github.com/users/jspam/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/jspam/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/496373?v=3', 'updated_at': '2015-09-25T18:44:10Z', 'created_at': '2010-11-25T11:52:14Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'jspam', 'following_url': 'https://api.github.com/users/jspam/following{/other_user}', 'html_url': 'https://github.com/jspam', 'url': 'https://api.github.com/users/jspam', 'location': None, 'id': 496373, 'events_url': 'https://api.github.com/users/jspam/events{/privacy}'}, 'YaroslavMolchan': {'received_events_url': 'https://api.github.com/users/YaroslavMolchan/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/YaroslavMolchan/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/YaroslavMolchan/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/YaroslavMolchan/orgs', 'public_repos': 12, 'email': None, 'followers_url': 'https://api.github.com/users/YaroslavMolchan/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 3, 'type': 'User', 'repos_url': 'https://api.github.com/users/YaroslavMolchan/repos', 'following': 1, 'blog': 'http://molchan.me', 'subscriptions_url': 'https://api.github.com/users/YaroslavMolchan/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/9335727?v=3', 'updated_at': '2015-11-24T07:33:58Z', 'created_at': '2014-10-21T12:03:53Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'YaroslavMolchan', 'following_url': 'https://api.github.com/users/YaroslavMolchan/following{/other_user}', 'html_url': 'https://github.com/YaroslavMolchan', 'url': 'https://api.github.com/users/YaroslavMolchan', 'location': 'Ukraine', 'id': 9335727, 'events_url': 'https://api.github.com/users/YaroslavMolchan/events{/privacy}'}, 'redwallhp': {'received_events_url': 'https://api.github.com/users/redwallhp/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/redwallhp/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/redwallhp/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/redwallhp/orgs', 'public_repos': 19, 'email': 'mcredwallhp@gmail.com', 'followers_url': 'https://api.github.com/users/redwallhp/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 3, 'type': 'User', 'repos_url': 'https://api.github.com/users/redwallhp/repos', 'following': 2, 'blog': 'http://nerd.nu', 'subscriptions_url': 'https://api.github.com/users/redwallhp/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6827872?v=3', 'updated_at': '2016-07-12T07:42:20Z', 'created_at': '2014-03-01T23:08:22Z', 'name': 'redwall_hp', 'hireable': None, 'company': 'Nerd.nu', 'site_admin': False, 'login': 'redwallhp', 'following_url': 'https://api.github.com/users/redwallhp/following{/other_user}', 'html_url': 'https://github.com/redwallhp', 'url': 'https://api.github.com/users/redwallhp', 'location': None, 'id': 6827872, 'events_url': 'https://api.github.com/users/redwallhp/events{/privacy}'}, 'teran-mckinney': {'received_events_url': 'https://api.github.com/users/teran-mckinney/received_events', 'following_url': 'https://api.github.com/users/teran-mckinney/following{/other_user}', 'gists_url': 'https://api.github.com/users/teran-mckinney/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/teran-mckinney/starred{/owner}{/repo}', 'html_url': 'https://github.com/teran-mckinney', 'public_repos': 50, 'email': 'sega01@go-beyond.org', 'followers_url': 'https://api.github.com/users/teran-mckinney/followers', 'followers': 14, 'gravatar_id': '', 'public_gists': 3, 'bio': "Since birth, I've never died. Does that mean I'm immortal?", 'type': 'User', 'repos_url': 'https://api.github.com/users/teran-mckinney/repos', 'following': 18, 'blog': 'http://go-beyond.org/', 'subscriptions_url': 'https://api.github.com/users/teran-mckinney/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/4624114?v=3', 'updated_at': '2016-12-13T03:16:39Z', 'created_at': '2013-06-05T19:47:53Z', 'name': 'Teran McKinney', 'hireable': True, 'company': None, 'site_admin': False, 'id': 4624114, 'organizations_url': 'https://api.github.com/users/teran-mckinney/orgs', 'url': 'https://api.github.com/users/teran-mckinney', 'location': 'San Antonio, Texas', 'login': 'teran-mckinney', 'events_url': 'https://api.github.com/users/teran-mckinney/events{/privacy}'}, 'cweiske': {'received_events_url': 'https://api.github.com/users/cweiske/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/cweiske/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/cweiske/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/cweiske/orgs', 'public_repos': 73, 'email': 'cweiske@cweiske.de', 'followers_url': 'https://api.github.com/users/cweiske/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 73, 'type': 'User', 'repos_url': 'https://api.github.com/users/cweiske/repos', 'following': 8, 'blog': 'http://cweiske.de/', 'subscriptions_url': 'https://api.github.com/users/cweiske/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/59036?v=3', 'updated_at': '2016-07-04T19:52:34Z', 'created_at': '2009-03-01T08:06:34Z', 'name': 'Christian Weiske', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'cweiske', 'following_url': 'https://api.github.com/users/cweiske/following{/other_user}', 'html_url': 'https://github.com/cweiske', 'url': 'https://api.github.com/users/cweiske', 'location': 'Leipzig, Germany', 'id': 59036, 'events_url': 'https://api.github.com/users/cweiske/events{/privacy}'}, 'timfreund': {'received_events_url': 'https://api.github.com/users/timfreund/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/timfreund/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/timfreund/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/timfreund/orgs', 'public_repos': 34, 'email': None, 'followers_url': 'https://api.github.com/users/timfreund/followers', 'gravatar_id': '', 'public_gists': 4, 'followers': 18, 'type': 'User', 'repos_url': 'https://api.github.com/users/timfreund/repos', 'following': 22, 'blog': 'http://tim.freunds.net/blog', 'subscriptions_url': 'https://api.github.com/users/timfreund/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/96033?v=3', 'updated_at': '2015-08-11T00:56:38Z', 'created_at': '2009-06-16T18:46:19Z', 'name': 'Tim Freund', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'timfreund', 'following_url': 'https://api.github.com/users/timfreund/following{/other_user}', 'html_url': 'https://github.com/timfreund', 'url': 'https://api.github.com/users/timfreund', 'location': 'Lancaster, PA', 'id': 96033, 'events_url': 'https://api.github.com/users/timfreund/events{/privacy}'}, 'xnaveira': {'received_events_url': 'https://api.github.com/users/xnaveira/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/xnaveira/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/xnaveira/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/xnaveira/orgs', 'public_repos': 27, 'email': None, 'followers_url': 'https://api.github.com/users/xnaveira/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 3, 'type': 'User', 'repos_url': 'https://api.github.com/users/xnaveira/repos', 'following': 2, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/xnaveira/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2534411?v=3', 'updated_at': '2015-08-18T12:28:34Z', 'created_at': '2012-10-11T06:02:22Z', 'name': 'Xavier Naveira', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'xnaveira', 'following_url': 'https://api.github.com/users/xnaveira/following{/other_user}', 'html_url': 'https://github.com/xnaveira', 'url': 'https://api.github.com/users/xnaveira', 'location': 'Sweden', 'id': 2534411, 'events_url': 'https://api.github.com/users/xnaveira/events{/privacy}'}, 'fookatchu': {'received_events_url': 'https://api.github.com/users/fookatchu/received_events', 'following_url': 'https://api.github.com/users/fookatchu/following{/other_user}', 'gists_url': 'https://api.github.com/users/fookatchu/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/fookatchu/starred{/owner}{/repo}', 'html_url': 'https://github.com/fookatchu', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/fookatchu/followers', 'followers': 4, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/fookatchu/repos', 'following': 2, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/fookatchu/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/9697285?v=3', 'updated_at': '2016-11-29T18:03:01Z', 'created_at': '2014-11-12T14:52:17Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'id': 9697285, 'organizations_url': 'https://api.github.com/users/fookatchu/orgs', 'url': 'https://api.github.com/users/fookatchu', 'location': None, 'login': 'fookatchu', 'events_url': 'https://api.github.com/users/fookatchu/events{/privacy}'}, 'unitycoders': {'received_events_url': 'https://api.github.com/users/unitycoders/received_events', 'following_url': 'https://api.github.com/users/unitycoders/following{/other_user}', 'gists_url': 'https://api.github.com/users/unitycoders/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/unitycoders/starred{/owner}{/repo}', 'html_url': 'https://github.com/unitycoders', 'public_repos': 13, 'email': None, 'followers_url': 'https://api.github.com/users/unitycoders/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/unitycoders/repos', 'following': 0, 'blog': 'https://www.fossgalaxy.com', 'subscriptions_url': 'https://api.github.com/users/unitycoders/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/721349?v=3', 'updated_at': '2016-10-08T10:28:21Z', 'created_at': '2011-04-11T00:41:36Z', 'name': 'FOSS Galaxy', 'hireable': None, 'company': None, 'site_admin': False, 'id': 721349, 'organizations_url': 'https://api.github.com/users/unitycoders/orgs', 'url': 'https://api.github.com/users/unitycoders', 'location': 'United Kingdom', 'login': 'unitycoders', 'events_url': 'https://api.github.com/users/unitycoders/events{/privacy}'}, 'daenney': {'received_events_url': 'https://api.github.com/users/daenney/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/daenney/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/daenney/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/daenney/orgs', 'public_repos': 35, 'email': None, 'followers_url': 'https://api.github.com/users/daenney/followers', 'gravatar_id': '', 'public_gists': 22, 'followers': 45, 'type': 'User', 'repos_url': 'https://api.github.com/users/daenney/repos', 'following': 6, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/daenney/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/569574?v=3', 'updated_at': '2015-10-19T20:04:19Z', 'created_at': '2011-01-17T21:10:24Z', 'name': 'Daniele Sluijters', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'daenney', 'following_url': 'https://api.github.com/users/daenney/following{/other_user}', 'html_url': 'https://github.com/daenney', 'url': 'https://api.github.com/users/daenney', 'location': 'Stockholm', 'id': 569574, 'events_url': 'https://api.github.com/users/daenney/events{/privacy}'}, 'hgranillo': {'received_events_url': 'https://api.github.com/users/hgranillo/received_events', 'following_url': 'https://api.github.com/users/hgranillo/following{/other_user}', 'gists_url': 'https://api.github.com/users/hgranillo/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/hgranillo/starred{/owner}{/repo}', 'html_url': 'https://github.com/hgranillo', 'public_repos': 2, 'email': None, 'followers_url': 'https://api.github.com/users/hgranillo/followers', 'followers': 2, 'gravatar_id': '', 'public_gists': 1, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/hgranillo/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/hgranillo/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/16230136?v=3', 'updated_at': '2016-10-09T00:29:12Z', 'created_at': '2015-12-09T19:39:23Z', 'name': 'Horacio Granillo', 'hireable': None, 'company': 'Nubity', 'site_admin': False, 'id': 16230136, 'organizations_url': 'https://api.github.com/users/hgranillo/orgs', 'url': 'https://api.github.com/users/hgranillo', 'location': 'Argentina', 'login': 'hgranillo', 'events_url': 'https://api.github.com/users/hgranillo/events{/privacy}'}, 'carriercomm': {'received_events_url': 'https://api.github.com/users/carriercomm/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/carriercomm/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/carriercomm/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/carriercomm/orgs', 'public_repos': 3184, 'email': None, 'followers_url': 'https://api.github.com/users/carriercomm/followers', 'gravatar_id': '', 'public_gists': 12, 'followers': 1, 'type': 'User', 'repos_url': 'https://api.github.com/users/carriercomm/repos', 'following': 12, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/carriercomm/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1732196?v=3', 'updated_at': '2015-10-24T22:09:41Z', 'created_at': '2012-05-12T07:40:21Z', 'name': 'Kevin Hatfield', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'carriercomm', 'following_url': 'https://api.github.com/users/carriercomm/following{/other_user}', 'html_url': 'https://github.com/carriercomm', 'url': 'https://api.github.com/users/carriercomm', 'location': None, 'id': 1732196, 'events_url': 'https://api.github.com/users/carriercomm/events{/privacy}'}, 'dyerrington': {'received_events_url': 'https://api.github.com/users/dyerrington/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/dyerrington/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/dyerrington/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/dyerrington/orgs', 'public_repos': 12, 'email': None, 'followers_url': 'https://api.github.com/users/dyerrington/followers', 'gravatar_id': '', 'public_gists': 20, 'followers': 32, 'type': 'User', 'repos_url': 'https://api.github.com/users/dyerrington/repos', 'following': 2, 'blog': 'http://www.yerrington.net', 'subscriptions_url': 'https://api.github.com/users/dyerrington/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2257834?v=3', 'updated_at': '2015-10-24T01:35:39Z', 'created_at': '2012-09-01T00:26:17Z', 'name': 'David Yerrington', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'dyerrington', 'following_url': 'https://api.github.com/users/dyerrington/following{/other_user}', 'html_url': 'https://github.com/dyerrington', 'url': 'https://api.github.com/users/dyerrington', 'location': 'San Francisco, CA', 'id': 2257834, 'events_url': 'https://api.github.com/users/dyerrington/events{/privacy}'}, 'zoni': {'received_events_url': 'https://api.github.com/users/zoni/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/zoni/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/zoni/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/zoni/orgs', 'public_repos': 63, 'email': 'nick@groenen.me', 'followers_url': 'https://api.github.com/users/zoni/followers', 'gravatar_id': '', 'public_gists': 16, 'followers': 20, 'type': 'User', 'repos_url': 'https://api.github.com/users/zoni/repos', 'following': 21, 'blog': 'https://nick.groenen.me', 'subscriptions_url': 'https://api.github.com/users/zoni/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/145285?v=3', 'updated_at': '2015-10-17T23:40:13Z', 'created_at': '2009-10-27T14:05:15Z', 'name': 'Nick Groenen', 'hireable': True, 'company': 'Byte Internet', 'site_admin': False, 'login': 'zoni', 'following_url': 'https://api.github.com/users/zoni/following{/other_user}', 'html_url': 'https://github.com/zoni', 'url': 'https://api.github.com/users/zoni', 'location': 'The Netherlands', 'id': 145285, 'events_url': 'https://api.github.com/users/zoni/events{/privacy}'}, 'lagenorhynque': {'received_events_url': 'https://api.github.com/users/lagenorhynque/received_events', 'following_url': 'https://api.github.com/users/lagenorhynque/following{/other_user}', 'gists_url': 'https://api.github.com/users/lagenorhynque/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/lagenorhynque/starred{/owner}{/repo}', 'html_url': 'https://github.com/lagenorhynque', 'public_repos': 42, 'email': 'ignorantia.juris.non.excusa@gmail.com', 'followers_url': 'https://api.github.com/users/lagenorhynque/followers', 'followers': 5, 'gravatar_id': '', 'public_gists': 29, 'bio': 'lagénorhynque /laʒenɔʁɛ̃k/', 'type': 'User', 'repos_url': 'https://api.github.com/users/lagenorhynque/repos', 'following': 5, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/lagenorhynque/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6076694?v=3', 'updated_at': '2016-11-26T15:01:28Z', 'created_at': '2013-12-01T06:00:41Z', 'name': 'Kent OHASHI', 'hireable': None, 'company': None, 'site_admin': False, 'id': 6076694, 'organizations_url': 'https://api.github.com/users/lagenorhynque/orgs', 'url': 'https://api.github.com/users/lagenorhynque', 'location': 'Tokyo, Japan', 'login': 'lagenorhynque', 'events_url': 'https://api.github.com/users/lagenorhynque/events{/privacy}'}, 'jnhmcknight': {'received_events_url': 'https://api.github.com/users/jnhmcknight/received_events', 'following_url': 'https://api.github.com/users/jnhmcknight/following{/other_user}', 'gists_url': 'https://api.github.com/users/jnhmcknight/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jnhmcknight/starred{/owner}{/repo}', 'html_url': 'https://github.com/jnhmcknight', 'public_repos': 7, 'email': None, 'followers_url': 'https://api.github.com/users/jnhmcknight/followers', 'followers': 2, 'gravatar_id': '', 'public_gists': 1, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/jnhmcknight/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/jnhmcknight/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6341895?v=3', 'updated_at': '2016-12-16T19:35:23Z', 'created_at': '2014-01-07T17:54:02Z', 'name': 'Jared McKnight', 'hireable': None, 'company': None, 'site_admin': False, 'id': 6341895, 'organizations_url': 'https://api.github.com/users/jnhmcknight/orgs', 'url': 'https://api.github.com/users/jnhmcknight', 'location': None, 'login': 'jnhmcknight', 'events_url': 'https://api.github.com/users/jnhmcknight/events{/privacy}'}, 'arrrrr': {'received_events_url': 'https://api.github.com/users/arrrrr/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/arrrrr/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/arrrrr/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/arrrrr/orgs', 'public_repos': 7, 'email': None, 'followers_url': 'https://api.github.com/users/arrrrr/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/arrrrr/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/arrrrr/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2607219?v=3', 'updated_at': '2014-07-10T12:26:50Z', 'created_at': '2012-10-20T15:54:38Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'arrrrr', 'following_url': 'https://api.github.com/users/arrrrr/following{/other_user}', 'html_url': 'https://github.com/arrrrr', 'url': 'https://api.github.com/users/arrrrr', 'location': None, 'id': 2607219, 'events_url': 'https://api.github.com/users/arrrrr/events{/privacy}'}, 'austinhappel': {'received_events_url': 'https://api.github.com/users/austinhappel/received_events', 'following_url': 'https://api.github.com/users/austinhappel/following{/other_user}', 'gists_url': 'https://api.github.com/users/austinhappel/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/austinhappel/starred{/owner}{/repo}', 'html_url': 'https://github.com/austinhappel', 'public_repos': 28, 'email': None, 'followers_url': 'https://api.github.com/users/austinhappel/followers', 'followers': 15, 'gravatar_id': '', 'public_gists': 18, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/austinhappel/repos', 'following': 3, 'blog': 'http://austinhappel.com', 'subscriptions_url': 'https://api.github.com/users/austinhappel/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/636536?v=3', 'updated_at': '2016-11-28T23:18:23Z', 'created_at': '2011-02-24T18:51:18Z', 'name': 'Austin', 'hireable': None, 'company': None, 'site_admin': False, 'id': 636536, 'organizations_url': 'https://api.github.com/users/austinhappel/orgs', 'url': 'https://api.github.com/users/austinhappel', 'location': None, 'login': 'austinhappel', 'events_url': 'https://api.github.com/users/austinhappel/events{/privacy}'}, 'jvasallo': {'received_events_url': 'https://api.github.com/users/jvasallo/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/jvasallo/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jvasallo/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/jvasallo/orgs', 'public_repos': 44, 'email': 'joelvasallo@gmail.com', 'followers_url': 'https://api.github.com/users/jvasallo/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 7, 'type': 'User', 'repos_url': 'https://api.github.com/users/jvasallo/repos', 'following': 2, 'blog': 'http://www.joelvasallo.com', 'subscriptions_url': 'https://api.github.com/users/jvasallo/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1730603?v=3', 'updated_at': '2015-10-08T18:37:14Z', 'created_at': '2012-05-11T15:55:50Z', 'name': 'Joel Vasallo', 'hireable': True, 'company': 'Gogo', 'site_admin': False, 'login': 'jvasallo', 'following_url': 'https://api.github.com/users/jvasallo/following{/other_user}', 'html_url': 'https://github.com/jvasallo', 'url': 'https://api.github.com/users/jvasallo', 'location': 'Chicago, IL', 'id': 1730603, 'events_url': 'https://api.github.com/users/jvasallo/events{/privacy}'}, 'foamz': {'received_events_url': 'https://api.github.com/users/foamz/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/foamz/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/foamz/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/foamz/orgs', 'public_repos': 5, 'email': None, 'followers_url': 'https://api.github.com/users/foamz/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/foamz/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/foamz/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/722612?v=3', 'updated_at': '2014-12-17T23:08:17Z', 'created_at': '2011-04-11T14:26:33Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'foamz', 'following_url': 'https://api.github.com/users/foamz/following{/other_user}', 'html_url': 'https://github.com/foamz', 'url': 'https://api.github.com/users/foamz', 'location': None, 'id': 722612, 'events_url': 'https://api.github.com/users/foamz/events{/privacy}'}, 'obihann': {'received_events_url': 'https://api.github.com/users/obihann/received_events', 'following_url': 'https://api.github.com/users/obihann/following{/other_user}', 'gists_url': 'https://api.github.com/users/obihann/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/obihann/starred{/owner}{/repo}', 'html_url': 'https://github.com/obihann', 'public_repos': 66, 'email': 'jeffhann@gmail.com', 'followers_url': 'https://api.github.com/users/obihann/followers', 'followers': 25, 'gravatar_id': '', 'public_gists': 40, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/obihann/repos', 'following': 23, 'blog': 'jeffreyhann.ca', 'subscriptions_url': 'https://api.github.com/users/obihann/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1507841?v=3', 'updated_at': '2016-12-09T17:17:35Z', 'created_at': '2012-03-06T16:27:36Z', 'name': 'Jeffrey Hann', 'hireable': None, 'company': None, 'site_admin': False, 'id': 1507841, 'organizations_url': 'https://api.github.com/users/obihann/orgs', 'url': 'https://api.github.com/users/obihann', 'location': 'Halifax, Nova Scotia', 'login': 'obihann', 'events_url': 'https://api.github.com/users/obihann/events{/privacy}'}, 'brycied00d': {'received_events_url': 'https://api.github.com/users/brycied00d/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/brycied00d/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/brycied00d/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/brycied00d/orgs', 'public_repos': 71, 'email': None, 'followers_url': 'https://api.github.com/users/brycied00d/followers', 'gravatar_id': '', 'public_gists': 16, 'followers': 21, 'type': 'User', 'repos_url': 'https://api.github.com/users/brycied00d/repos', 'following': 24, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/brycied00d/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/435838?v=3', 'updated_at': '2015-10-18T01:28:38Z', 'created_at': '2010-10-11T20:16:25Z', 'name': 'Bryce Chidester', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'brycied00d', 'following_url': 'https://api.github.com/users/brycied00d/following{/other_user}', 'html_url': 'https://github.com/brycied00d', 'url': 'https://api.github.com/users/brycied00d', 'location': 'Spokane, WA', 'id': 435838, 'events_url': 'https://api.github.com/users/brycied00d/events{/privacy}'}, 'motord': {'received_events_url': 'https://api.github.com/users/motord/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/motord/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/motord/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/motord/orgs', 'public_repos': 36, 'email': None, 'followers_url': 'https://api.github.com/users/motord/followers', 'gravatar_id': '', 'public_gists': 4, 'followers': 15, 'type': 'User', 'repos_url': 'https://api.github.com/users/motord/repos', 'following': 92, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/motord/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/628702?v=3', 'updated_at': '2015-10-15T20:06:26Z', 'created_at': '2011-02-20T19:43:21Z', 'name': None, 'hireable': True, 'company': None, 'site_admin': False, 'login': 'motord', 'following_url': 'https://api.github.com/users/motord/following{/other_user}', 'html_url': 'https://github.com/motord', 'url': 'https://api.github.com/users/motord', 'location': None, 'id': 628702, 'events_url': 'https://api.github.com/users/motord/events{/privacy}'}, 'brandverity': {'received_events_url': 'https://api.github.com/users/brandverity/received_events', 'following_url': 'https://api.github.com/users/brandverity/following{/other_user}', 'gists_url': 'https://api.github.com/users/brandverity/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/brandverity/starred{/owner}{/repo}', 'html_url': 'https://github.com/brandverity', 'public_repos': 10, 'email': None, 'followers_url': 'https://api.github.com/users/brandverity/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/brandverity/repos', 'following': 0, 'blog': 'https://www.brandverity.com', 'subscriptions_url': 'https://api.github.com/users/brandverity/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/15149312?v=3', 'updated_at': '2016-03-07T17:45:59Z', 'created_at': '2015-10-15T22:25:13Z', 'name': 'BrandVerity', 'hireable': None, 'company': None, 'site_admin': False, 'id': 15149312, 'organizations_url': 'https://api.github.com/users/brandverity/orgs', 'url': 'https://api.github.com/users/brandverity', 'location': 'Seattle, WA', 'login': 'brandverity', 'events_url': 'https://api.github.com/users/brandverity/events{/privacy}'}, 'anitawoodruff': {'received_events_url': 'https://api.github.com/users/anitawoodruff/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/anitawoodruff/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/anitawoodruff/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/anitawoodruff/orgs', 'public_repos': 2, 'email': None, 'followers_url': 'https://api.github.com/users/anitawoodruff/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/anitawoodruff/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/anitawoodruff/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1918555?v=3', 'updated_at': '2015-10-22T13:38:58Z', 'created_at': '2012-07-03T14:56:09Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'anitawoodruff', 'following_url': 'https://api.github.com/users/anitawoodruff/following{/other_user}', 'html_url': 'https://github.com/anitawoodruff', 'url': 'https://api.github.com/users/anitawoodruff', 'location': None, 'id': 1918555, 'events_url': 'https://api.github.com/users/anitawoodruff/events{/privacy}'}, 'tazzledazzle': {'received_events_url': 'https://api.github.com/users/tazzledazzle/received_events', 'following_url': 'https://api.github.com/users/tazzledazzle/following{/other_user}', 'gists_url': 'https://api.github.com/users/tazzledazzle/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/tazzledazzle/starred{/owner}{/repo}', 'html_url': 'https://github.com/tazzledazzle', 'public_repos': 11, 'email': None, 'followers_url': 'https://api.github.com/users/tazzledazzle/followers', 'followers': 11, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/tazzledazzle/repos', 'following': 23, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/tazzledazzle/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/4683216?v=3', 'updated_at': '2016-12-09T05:47:50Z', 'created_at': '2013-06-12T21:34:18Z', 'name': 'Terence Schumacher', 'hireable': None, 'company': None, 'site_admin': False, 'id': 4683216, 'organizations_url': 'https://api.github.com/users/tazzledazzle/orgs', 'url': 'https://api.github.com/users/tazzledazzle', 'location': 'Seattle, WA', 'login': 'tazzledazzle', 'events_url': 'https://api.github.com/users/tazzledazzle/events{/privacy}'}, 'kgutwin': {'received_events_url': 'https://api.github.com/users/kgutwin/received_events', 'following_url': 'https://api.github.com/users/kgutwin/following{/other_user}', 'gists_url': 'https://api.github.com/users/kgutwin/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/kgutwin/starred{/owner}{/repo}', 'html_url': 'https://github.com/kgutwin', 'public_repos': 23, 'email': None, 'followers_url': 'https://api.github.com/users/kgutwin/followers', 'followers': 2, 'gravatar_id': '', 'public_gists': 4, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/kgutwin/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/kgutwin/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6315798?v=3', 'updated_at': '2016-11-28T22:27:33Z', 'created_at': '2014-01-04T04:08:18Z', 'name': 'Karl Gutwin', 'hireable': None, 'company': None, 'site_admin': False, 'id': 6315798, 'organizations_url': 'https://api.github.com/users/kgutwin/orgs', 'url': 'https://api.github.com/users/kgutwin', 'location': 'Boston, MA', 'login': 'kgutwin', 'events_url': 'https://api.github.com/users/kgutwin/events{/privacy}'}, 'cwjohnston': {'received_events_url': 'https://api.github.com/users/cwjohnston/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/cwjohnston/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/cwjohnston/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/cwjohnston/orgs', 'public_repos': 67, 'email': 'cameron@rootdown.net', 'followers_url': 'https://api.github.com/users/cwjohnston/followers', 'gravatar_id': '', 'public_gists': 86, 'followers': 29, 'type': 'User', 'repos_url': 'https://api.github.com/users/cwjohnston/repos', 'following': 5, 'blog': 'http://rootdown.net', 'subscriptions_url': 'https://api.github.com/users/cwjohnston/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/148017?v=3', 'updated_at': '2015-10-25T19:27:00Z', 'created_at': '2009-11-02T22:36:45Z', 'name': 'Cameron Johnston', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'cwjohnston', 'following_url': 'https://api.github.com/users/cwjohnston/following{/other_user}', 'html_url': 'https://github.com/cwjohnston', 'url': 'https://api.github.com/users/cwjohnston', 'location': 'Salt Lake City, UT', 'id': 148017, 'events_url': 'https://api.github.com/users/cwjohnston/events{/privacy}'}, 'Ecno92': {'received_events_url': 'https://api.github.com/users/Ecno92/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Ecno92/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Ecno92/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Ecno92/orgs', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/Ecno92/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 4, 'type': 'User', 'repos_url': 'https://api.github.com/users/Ecno92/repos', 'following': 6, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/Ecno92/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5213399?v=3', 'updated_at': '2015-10-11T08:18:40Z', 'created_at': '2013-08-12T12:08:13Z', 'name': 'Therry van Neerven', 'hireable': None, 'company': 'SendCloud', 'site_admin': False, 'login': 'Ecno92', 'following_url': 'https://api.github.com/users/Ecno92/following{/other_user}', 'html_url': 'https://github.com/Ecno92', 'url': 'https://api.github.com/users/Ecno92', 'location': 'Helenaveen, The Netherlands', 'id': 5213399, 'events_url': 'https://api.github.com/users/Ecno92/events{/privacy}'}, 'foxxyz': {'received_events_url': 'https://api.github.com/users/foxxyz/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/foxxyz/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/foxxyz/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/foxxyz/orgs', 'public_repos': 19, 'email': None, 'followers_url': 'https://api.github.com/users/foxxyz/followers', 'gravatar_id': '', 'public_gists': 8, 'followers': 6, 'type': 'User', 'repos_url': 'https://api.github.com/users/foxxyz/repos', 'following': 1, 'blog': 'http://ivo.la', 'subscriptions_url': 'https://api.github.com/users/foxxyz/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2602605?v=3', 'updated_at': '2015-10-22T06:22:12Z', 'created_at': '2012-10-19T21:35:02Z', 'name': None, 'hireable': True, 'company': None, 'site_admin': False, 'login': 'foxxyz', 'following_url': 'https://api.github.com/users/foxxyz/following{/other_user}', 'html_url': 'https://github.com/foxxyz', 'url': 'https://api.github.com/users/foxxyz', 'location': 'Los Angeles, CA', 'id': 2602605, 'events_url': 'https://api.github.com/users/foxxyz/events{/privacy}'}, 'Djiit': {'received_events_url': 'https://api.github.com/users/Djiit/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Djiit/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Djiit/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Djiit/orgs', 'public_repos': 19, 'email': 'julien.tanay@gmail.com', 'followers_url': 'https://api.github.com/users/Djiit/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 16, 'type': 'User', 'repos_url': 'https://api.github.com/users/Djiit/repos', 'following': 28, 'blog': 'http://julientanay.com', 'subscriptions_url': 'https://api.github.com/users/Djiit/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1169844?v=3', 'updated_at': '2016-02-27T01:03:40Z', 'created_at': '2011-11-03T13:50:16Z', 'name': 'Julien Tanay', 'hireable': None, 'company': 'Canal +', 'site_admin': False, 'login': 'Djiit', 'following_url': 'https://api.github.com/users/Djiit/following{/other_user}', 'html_url': 'https://github.com/Djiit', 'url': 'https://api.github.com/users/Djiit', 'location': 'Paris, France', 'id': 1169844, 'events_url': 'https://api.github.com/users/Djiit/events{/privacy}'}, 'hosom': {'received_events_url': 'https://api.github.com/users/hosom/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/hosom/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/hosom/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/hosom/orgs', 'public_repos': 28, 'email': '0xhosom@gmail.com', 'followers_url': 'https://api.github.com/users/hosom/followers', 'gravatar_id': '', 'public_gists': 3, 'followers': 21, 'type': 'User', 'repos_url': 'https://api.github.com/users/hosom/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/hosom/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5017324?v=3', 'updated_at': '2016-07-25T22:10:12Z', 'created_at': '2013-07-16T00:24:22Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'hosom', 'following_url': 'https://api.github.com/users/hosom/following{/other_user}', 'html_url': 'https://github.com/hosom', 'url': 'https://api.github.com/users/hosom', 'location': None, 'id': 5017324, 'events_url': 'https://api.github.com/users/hosom/events{/privacy}'}, 'sijis': {'received_events_url': 'https://api.github.com/users/sijis/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/sijis/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/sijis/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/sijis/orgs', 'public_repos': 44, 'email': None, 'followers_url': 'https://api.github.com/users/sijis/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 13, 'type': 'User', 'repos_url': 'https://api.github.com/users/sijis/repos', 'following': 17, 'blog': 'http://sijis.github.io', 'subscriptions_url': 'https://api.github.com/users/sijis/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/618177?v=3', 'updated_at': '2015-10-15T16:27:04Z', 'created_at': '2011-02-14T22:37:00Z', 'name': 'Sijis Aviles', 'hireable': None, 'company': 'Gogo', 'site_admin': False, 'login': 'sijis', 'following_url': 'https://api.github.com/users/sijis/following{/other_user}', 'html_url': 'https://github.com/sijis', 'url': 'https://api.github.com/users/sijis', 'location': 'Chicago, IL', 'id': 618177, 'events_url': 'https://api.github.com/users/sijis/events{/privacy}'}, 'f3l': {'received_events_url': 'https://api.github.com/users/f3l/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/f3l/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/f3l/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/f3l/orgs', 'public_repos': 17, 'email': None, 'followers_url': 'https://api.github.com/users/f3l/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/f3l/repos', 'following': 0, 'blog': 'http://f3l.de', 'subscriptions_url': 'https://api.github.com/users/f3l/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1502387?v=3', 'updated_at': '2015-01-07T12:16:23Z', 'created_at': '2012-03-05T10:53:18Z', 'name': 'F3L Team', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'f3l', 'following_url': 'https://api.github.com/users/f3l/following{/other_user}', 'html_url': 'https://github.com/f3l', 'url': 'https://api.github.com/users/f3l', 'location': 'Germany', 'id': 1502387, 'events_url': 'https://api.github.com/users/f3l/events{/privacy}'}, 'ministry-of-love': {'received_events_url': 'https://api.github.com/users/ministry-of-love/received_events', 'following_url': 'https://api.github.com/users/ministry-of-love/following{/other_user}', 'gists_url': 'https://api.github.com/users/ministry-of-love/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ministry-of-love/starred{/owner}{/repo}', 'html_url': 'https://github.com/ministry-of-love', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/ministry-of-love/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/ministry-of-love/repos', 'following': 0, 'blog': 'Jita 5-17', 'subscriptions_url': 'https://api.github.com/users/ministry-of-love/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/18013543?v=3', 'updated_at': '2016-06-21T00:33:43Z', 'created_at': '2016-03-22T18:06:18Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'id': 18013543, 'organizations_url': 'https://api.github.com/users/ministry-of-love/orgs', 'url': 'https://api.github.com/users/ministry-of-love', 'location': None, 'login': 'ministry-of-love', 'events_url': 'https://api.github.com/users/ministry-of-love/events{/privacy}'}, 'netquity': {'received_events_url': 'https://api.github.com/users/netquity/received_events', 'following_url': 'https://api.github.com/users/netquity/following{/other_user}', 'gists_url': 'https://api.github.com/users/netquity/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/netquity/starred{/owner}{/repo}', 'html_url': 'https://github.com/netquity', 'public_repos': 8, 'email': 'rschan@netquity.com', 'followers_url': 'https://api.github.com/users/netquity/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/netquity/repos', 'following': 0, 'blog': 'netquity.com', 'subscriptions_url': 'https://api.github.com/users/netquity/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1807942?v=3', 'updated_at': '2016-11-25T09:42:30Z', 'created_at': '2012-06-01T20:49:20Z', 'name': 'Netquity Corporation', 'hireable': None, 'company': None, 'site_admin': False, 'id': 1807942, 'organizations_url': 'https://api.github.com/users/netquity/orgs', 'url': 'https://api.github.com/users/netquity', 'location': 'Canada!', 'login': 'netquity', 'events_url': 'https://api.github.com/users/netquity/events{/privacy}'}, 'j4y-funabashi': {'received_events_url': 'https://api.github.com/users/j4y-funabashi/received_events', 'following_url': 'https://api.github.com/users/j4y-funabashi/following{/other_user}', 'gists_url': 'https://api.github.com/users/j4y-funabashi/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/j4y-funabashi/starred{/owner}{/repo}', 'html_url': 'https://github.com/j4y-funabashi', 'public_repos': 12, 'email': None, 'followers_url': 'https://api.github.com/users/j4y-funabashi/followers', 'followers': 2, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/j4y-funabashi/repos', 'following': 7, 'blog': 'http://j4y.co/', 'subscriptions_url': 'https://api.github.com/users/j4y-funabashi/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/12499625?v=3', 'updated_at': '2016-12-09T21:26:02Z', 'created_at': '2015-05-18T16:29:21Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'id': 12499625, 'organizations_url': 'https://api.github.com/users/j4y-funabashi/orgs', 'url': 'https://api.github.com/users/j4y-funabashi', 'location': None, 'login': 'j4y-funabashi', 'events_url': 'https://api.github.com/users/j4y-funabashi/events{/privacy}'}, 'ytjohn': {'received_events_url': 'https://api.github.com/users/ytjohn/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/ytjohn/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ytjohn/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/ytjohn/orgs', 'public_repos': 66, 'email': 'john@yourtech.us', 'followers_url': 'https://api.github.com/users/ytjohn/followers', 'gravatar_id': '', 'public_gists': 42, 'followers': 5, 'type': 'User', 'repos_url': 'https://api.github.com/users/ytjohn/repos', 'following': 1, 'blog': 'www.yourtech.us', 'subscriptions_url': 'https://api.github.com/users/ytjohn/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/163156?v=3', 'updated_at': '2016-04-29T02:00:14Z', 'created_at': '2009-12-06T00:58:16Z', 'name': 'John Hogenmiller', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'ytjohn', 'following_url': 'https://api.github.com/users/ytjohn/following{/other_user}', 'html_url': 'https://github.com/ytjohn', 'url': 'https://api.github.com/users/ytjohn', 'location': None, 'id': 163156, 'events_url': 'https://api.github.com/users/ytjohn/events{/privacy}'}, 'namanbharadwaj': {'received_events_url': 'https://api.github.com/users/namanbharadwaj/received_events', 'following_url': 'https://api.github.com/users/namanbharadwaj/following{/other_user}', 'gists_url': 'https://api.github.com/users/namanbharadwaj/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/namanbharadwaj/starred{/owner}{/repo}', 'html_url': 'https://github.com/namanbharadwaj', 'public_repos': 9, 'email': None, 'followers_url': 'https://api.github.com/users/namanbharadwaj/followers', 'followers': 6, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/namanbharadwaj/repos', 'following': 1, 'blog': 'http://www.namanbharadwaj.com/', 'subscriptions_url': 'https://api.github.com/users/namanbharadwaj/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1977419?v=3', 'updated_at': '2016-12-12T05:46:50Z', 'created_at': '2012-07-15T02:42:31Z', 'name': 'Naman Bharadwaj', 'hireable': None, 'company': 'Affirm', 'site_admin': False, 'id': 1977419, 'organizations_url': 'https://api.github.com/users/namanbharadwaj/orgs', 'url': 'https://api.github.com/users/namanbharadwaj', 'location': None, 'login': 'namanbharadwaj', 'events_url': 'https://api.github.com/users/namanbharadwaj/events{/privacy}'}, 'ZaneRL9': {'received_events_url': 'https://api.github.com/users/ZaneRL9/received_events', 'following_url': 'https://api.github.com/users/ZaneRL9/following{/other_user}', 'gists_url': 'https://api.github.com/users/ZaneRL9/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ZaneRL9/starred{/owner}{/repo}', 'html_url': 'https://github.com/ZaneRL9', 'public_repos': 8, 'email': None, 'followers_url': 'https://api.github.com/users/ZaneRL9/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/ZaneRL9/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/ZaneRL9/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/14057516?v=3', 'updated_at': '2016-12-06T15:30:27Z', 'created_at': '2015-08-31T15:33:21Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'id': 14057516, 'organizations_url': 'https://api.github.com/users/ZaneRL9/orgs', 'url': 'https://api.github.com/users/ZaneRL9', 'location': None, 'login': 'ZaneRL9', 'events_url': 'https://api.github.com/users/ZaneRL9/events{/privacy}'}, 'tomblench': {'received_events_url': 'https://api.github.com/users/tomblench/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/tomblench/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/tomblench/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/tomblench/orgs', 'public_repos': 7, 'email': None, 'followers_url': 'https://api.github.com/users/tomblench/followers', 'gravatar_id': '', 'public_gists': 7, 'followers': 5, 'type': 'User', 'repos_url': 'https://api.github.com/users/tomblench/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/tomblench/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6302842?v=3', 'updated_at': '2015-05-04T08:56:54Z', 'created_at': '2014-01-02T11:44:56Z', 'name': 'Tom Blench', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'tomblench', 'following_url': 'https://api.github.com/users/tomblench/following{/other_user}', 'html_url': 'https://github.com/tomblench', 'url': 'https://api.github.com/users/tomblench', 'location': None, 'id': 6302842, 'events_url': 'https://api.github.com/users/tomblench/events{/privacy}'}, 'br0ziliy': {'received_events_url': 'https://api.github.com/users/br0ziliy/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/br0ziliy/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/br0ziliy/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/br0ziliy/orgs', 'public_repos': 30, 'email': None, 'followers_url': 'https://api.github.com/users/br0ziliy/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 2, 'type': 'User', 'repos_url': 'https://api.github.com/users/br0ziliy/repos', 'following': 3, 'blog': 'https://vkaigoro.fedorapeople.org/', 'subscriptions_url': 'https://api.github.com/users/br0ziliy/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1941223?v=3', 'updated_at': '2016-02-27T02:39:27Z', 'created_at': '2012-07-09T07:47:15Z', 'name': 'Vasyl "vk" Kaigorodov', 'hireable': None, 'company': 'Red Hat Inc.', 'site_admin': False, 'login': 'br0ziliy', 'following_url': 'https://api.github.com/users/br0ziliy/following{/other_user}', 'html_url': 'https://github.com/br0ziliy', 'url': 'https://api.github.com/users/br0ziliy', 'location': None, 'id': 1941223, 'events_url': 'https://api.github.com/users/br0ziliy/events{/privacy}'}, 'arkahu': {'received_events_url': 'https://api.github.com/users/arkahu/received_events', 'following_url': 'https://api.github.com/users/arkahu/following{/other_user}', 'gists_url': 'https://api.github.com/users/arkahu/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/arkahu/starred{/owner}{/repo}', 'html_url': 'https://github.com/arkahu', 'public_repos': 6, 'email': None, 'followers_url': 'https://api.github.com/users/arkahu/followers', 'followers': 1, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/arkahu/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/arkahu/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/12159040?v=3', 'updated_at': '2016-11-15T20:37:07Z', 'created_at': '2015-04-28T19:29:57Z', 'name': 'Arttu Huttunen', 'hireable': None, 'company': None, 'site_admin': False, 'id': 12159040, 'organizations_url': 'https://api.github.com/users/arkahu/orgs', 'url': 'https://api.github.com/users/arkahu', 'location': 'Finland', 'login': 'arkahu', 'events_url': 'https://api.github.com/users/arkahu/events{/privacy}'}, 'KarlMW': {'received_events_url': 'https://api.github.com/users/KarlMW/received_events', 'following_url': 'https://api.github.com/users/KarlMW/following{/other_user}', 'gists_url': 'https://api.github.com/users/KarlMW/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/KarlMW/starred{/owner}{/repo}', 'html_url': 'https://github.com/KarlMW', 'public_repos': 2, 'email': None, 'followers_url': 'https://api.github.com/users/KarlMW/followers', 'followers': 1, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/KarlMW/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/KarlMW/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/735464?v=3', 'updated_at': '2016-12-15T01:00:06Z', 'created_at': '2011-04-17T23:08:42Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'id': 735464, 'organizations_url': 'https://api.github.com/users/KarlMW/orgs', 'url': 'https://api.github.com/users/KarlMW', 'location': None, 'login': 'KarlMW', 'events_url': 'https://api.github.com/users/KarlMW/events{/privacy}'}, 'kkc': {'received_events_url': 'https://api.github.com/users/kkc/received_events', 'following_url': 'https://api.github.com/users/kkc/following{/other_user}', 'gists_url': 'https://api.github.com/users/kkc/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/kkc/starred{/owner}{/repo}', 'html_url': 'https://github.com/kkc', 'public_repos': 42, 'email': 'kakashi1000@gmail.com', 'followers_url': 'https://api.github.com/users/kkc/followers', 'followers': 11, 'gravatar_id': '', 'public_gists': 35, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/kkc/repos', 'following': 56, 'blog': 'http://kkc.github.io', 'subscriptions_url': 'https://api.github.com/users/kkc/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/129365?v=3', 'updated_at': '2016-12-09T02:46:22Z', 'created_at': '2009-09-21T03:20:33Z', 'name': 'Kakashi Liu', 'hireable': None, 'company': None, 'site_admin': False, 'id': 129365, 'organizations_url': 'https://api.github.com/users/kkc/orgs', 'url': 'https://api.github.com/users/kkc', 'location': 'Taiwan', 'login': 'kkc', 'events_url': 'https://api.github.com/users/kkc/events{/privacy}'}, 'estherbester': {'received_events_url': 'https://api.github.com/users/estherbester/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/estherbester/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/estherbester/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/estherbester/orgs', 'public_repos': 39, 'email': None, 'followers_url': 'https://api.github.com/users/estherbester/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 27, 'type': 'User', 'repos_url': 'https://api.github.com/users/estherbester/repos', 'following': 2, 'blog': 'http://esthernam.com', 'subscriptions_url': 'https://api.github.com/users/estherbester/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/94963?v=3', 'updated_at': '2015-09-16T00:31:13Z', 'created_at': '2009-06-13T07:18:51Z', 'name': 'Esther N', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'estherbester', 'following_url': 'https://api.github.com/users/estherbester/following{/other_user}', 'html_url': 'https://github.com/estherbester', 'url': 'https://api.github.com/users/estherbester', 'location': 'California', 'id': 94963, 'events_url': 'https://api.github.com/users/estherbester/events{/privacy}'}, 'atalyad': {'received_events_url': 'https://api.github.com/users/atalyad/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/atalyad/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/atalyad/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/atalyad/orgs', 'public_repos': 6, 'email': 'atalyad@gmail.com', 'followers_url': 'https://api.github.com/users/atalyad/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 7, 'type': 'User', 'repos_url': 'https://api.github.com/users/atalyad/repos', 'following': 6, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/atalyad/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1914801?v=3', 'updated_at': '2015-08-15T07:18:19Z', 'created_at': '2012-07-02T16:03:37Z', 'name': 'tali petrover', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'atalyad', 'following_url': 'https://api.github.com/users/atalyad/following{/other_user}', 'html_url': 'https://github.com/atalyad', 'url': 'https://api.github.com/users/atalyad', 'location': None, 'id': 1914801, 'events_url': 'https://api.github.com/users/atalyad/events{/privacy}'}, 'jmlynch': {'received_events_url': 'https://api.github.com/users/jmlynch/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/jmlynch/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jmlynch/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/jmlynch/orgs', 'public_repos': 2, 'email': None, 'followers_url': 'https://api.github.com/users/jmlynch/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/jmlynch/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/jmlynch/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/9432628?v=3', 'updated_at': '2016-01-22T14:34:32Z', 'created_at': '2014-10-28T13:17:52Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'jmlynch', 'following_url': 'https://api.github.com/users/jmlynch/following{/other_user}', 'html_url': 'https://github.com/jmlynch', 'url': 'https://api.github.com/users/jmlynch', 'location': None, 'id': 9432628, 'events_url': 'https://api.github.com/users/jmlynch/events{/privacy}'}, 'TehMillhouse': {'received_events_url': 'https://api.github.com/users/TehMillhouse/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/TehMillhouse/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/TehMillhouse/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/TehMillhouse/orgs', 'public_repos': 23, 'email': 'max@trollbu.de', 'followers_url': 'https://api.github.com/users/TehMillhouse/followers', 'gravatar_id': '', 'public_gists': 4, 'followers': 22, 'type': 'User', 'repos_url': 'https://api.github.com/users/TehMillhouse/repos', 'following': 12, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/TehMillhouse/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/796437?v=3', 'updated_at': '2015-10-06T20:43:00Z', 'created_at': '2011-05-18T18:12:02Z', 'name': 'Max Wagner', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'TehMillhouse', 'following_url': 'https://api.github.com/users/TehMillhouse/following{/other_user}', 'html_url': 'https://github.com/TehMillhouse', 'url': 'https://api.github.com/users/TehMillhouse', 'location': 'Karlsruhe, Baden-Württemberg, Germany', 'id': 796437, 'events_url': 'https://api.github.com/users/TehMillhouse/events{/privacy}'}, 'qgerome': {'received_events_url': 'https://api.github.com/users/qgerome/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/qgerome/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/qgerome/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/qgerome/orgs', 'public_repos': 45, 'email': 'geromequentin@gmail.com', 'followers_url': 'https://api.github.com/users/qgerome/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 4, 'type': 'User', 'repos_url': 'https://api.github.com/users/qgerome/repos', 'following': 4, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/qgerome/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1607549?v=3', 'updated_at': '2015-10-13T20:48:45Z', 'created_at': '2012-04-03T09:01:39Z', 'name': 'Quentin Gérôme', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'qgerome', 'following_url': 'https://api.github.com/users/qgerome/following{/other_user}', 'html_url': 'https://github.com/qgerome', 'url': 'https://api.github.com/users/qgerome', 'location': 'Brussels, Belgium', 'id': 1607549, 'events_url': 'https://api.github.com/users/qgerome/events{/privacy}'}, 'rhyshort': {'received_events_url': 'https://api.github.com/users/rhyshort/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/rhyshort/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/rhyshort/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/rhyshort/orgs', 'public_repos': 6, 'email': None, 'followers_url': 'https://api.github.com/users/rhyshort/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 4, 'type': 'User', 'repos_url': 'https://api.github.com/users/rhyshort/repos', 'following': 4, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/rhyshort/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1616727?v=3', 'updated_at': '2015-10-05T09:24:59Z', 'created_at': '2012-04-05T20:32:28Z', 'name': 'Rhys Short', 'hireable': None, 'company': 'IBM', 'site_admin': False, 'login': 'rhyshort', 'following_url': 'https://api.github.com/users/rhyshort/following{/other_user}', 'html_url': 'https://github.com/rhyshort', 'url': 'https://api.github.com/users/rhyshort', 'location': 'Bristol', 'id': 1616727, 'events_url': 'https://api.github.com/users/rhyshort/events{/privacy}'}, 'pirxthepilot': {'received_events_url': 'https://api.github.com/users/pirxthepilot/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/pirxthepilot/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/pirxthepilot/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/pirxthepilot/orgs', 'public_repos': 16, 'email': None, 'followers_url': 'https://api.github.com/users/pirxthepilot/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 2, 'type': 'User', 'repos_url': 'https://api.github.com/users/pirxthepilot/repos', 'following': 5, 'blog': 'http://modulogeek.com', 'subscriptions_url': 'https://api.github.com/users/pirxthepilot/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/7690118?v=3', 'updated_at': '2016-07-25T21:40:18Z', 'created_at': '2014-05-24T18:26:54Z', 'name': 'Joon Guillen', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'pirxthepilot', 'following_url': 'https://api.github.com/users/pirxthepilot/following{/other_user}', 'html_url': 'https://github.com/pirxthepilot', 'url': 'https://api.github.com/users/pirxthepilot', 'location': None, 'id': 7690118, 'events_url': 'https://api.github.com/users/pirxthepilot/events{/privacy}'}, 'hreeder': {'received_events_url': 'https://api.github.com/users/hreeder/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/hreeder/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/hreeder/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/hreeder/orgs', 'public_repos': 43, 'email': 'harry@harryreeder.co.uk', 'followers_url': 'https://api.github.com/users/hreeder/followers', 'gravatar_id': '', 'public_gists': 8, 'followers': 7, 'type': 'User', 'repos_url': 'https://api.github.com/users/hreeder/repos', 'following': 5, 'blog': 'www.harryreeder.co.uk', 'subscriptions_url': 'https://api.github.com/users/hreeder/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/601246?v=3', 'updated_at': '2015-10-24T10:47:04Z', 'created_at': '2011-02-04T22:47:05Z', 'name': 'Harry Reeder', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'hreeder', 'following_url': 'https://api.github.com/users/hreeder/following{/other_user}', 'html_url': 'https://github.com/hreeder', 'url': 'https://api.github.com/users/hreeder', 'location': 'Edinburgh, GB', 'id': 601246, 'events_url': 'https://api.github.com/users/hreeder/events{/privacy}'}, 'err-taoistmath': {'received_events_url': 'https://api.github.com/users/err-taoistmath/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/err-taoistmath/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/err-taoistmath/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/err-taoistmath/orgs', 'public_repos': 6, 'email': None, 'followers_url': 'https://api.github.com/users/err-taoistmath/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/err-taoistmath/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/err-taoistmath/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/16807078?v=3', 'updated_at': '2016-02-05T20:55:07Z', 'created_at': '2016-01-20T21:37:34Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'err-taoistmath', 'following_url': 'https://api.github.com/users/err-taoistmath/following{/other_user}', 'html_url': 'https://github.com/err-taoistmath', 'url': 'https://api.github.com/users/err-taoistmath', 'location': None, 'id': 16807078, 'events_url': 'https://api.github.com/users/err-taoistmath/events{/privacy}'}, 'staircaseJapes': {'received_events_url': 'https://api.github.com/users/staircaseJapes/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/staircaseJapes/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/staircaseJapes/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/staircaseJapes/orgs', 'public_repos': 5, 'email': None, 'followers_url': 'https://api.github.com/users/staircaseJapes/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/staircaseJapes/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/staircaseJapes/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/8643880?v=3', 'updated_at': '2015-08-20T12:19:07Z', 'created_at': '2014-09-03T15:35:53Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'staircaseJapes', 'following_url': 'https://api.github.com/users/staircaseJapes/following{/other_user}', 'html_url': 'https://github.com/staircaseJapes', 'url': 'https://api.github.com/users/staircaseJapes', 'location': None, 'id': 8643880, 'events_url': 'https://api.github.com/users/staircaseJapes/events{/privacy}'}, 'Scaatis': {'received_events_url': 'https://api.github.com/users/Scaatis/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Scaatis/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Scaatis/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Scaatis/orgs', 'public_repos': 21, 'email': 'fsfuenf@gmail.com', 'followers_url': 'https://api.github.com/users/Scaatis/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 7, 'type': 'User', 'repos_url': 'https://api.github.com/users/Scaatis/repos', 'following': 2, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/Scaatis/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/208336?v=3', 'updated_at': '2015-10-23T16:39:52Z', 'created_at': '2010-02-22T12:15:00Z', 'name': 'Felix Schneider', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'Scaatis', 'following_url': 'https://api.github.com/users/Scaatis/following{/other_user}', 'html_url': 'https://github.com/Scaatis', 'url': 'https://api.github.com/users/Scaatis', 'location': 'Germany', 'id': 208336, 'events_url': 'https://api.github.com/users/Scaatis/events{/privacy}'}, 'TomNeyland': {'received_events_url': 'https://api.github.com/users/TomNeyland/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/TomNeyland/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/TomNeyland/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/TomNeyland/orgs', 'public_repos': 52, 'email': None, 'followers_url': 'https://api.github.com/users/TomNeyland/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 17, 'type': 'User', 'repos_url': 'https://api.github.com/users/TomNeyland/repos', 'following': 29, 'blog': 'https://twitter.com/TomNeyland', 'subscriptions_url': 'https://api.github.com/users/TomNeyland/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/4574112?v=3', 'updated_at': '2015-10-21T09:53:13Z', 'created_at': '2013-05-30T20:24:36Z', 'name': 'Tom Neyland', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'TomNeyland', 'following_url': 'https://api.github.com/users/TomNeyland/following{/other_user}', 'html_url': 'https://github.com/TomNeyland', 'url': 'https://api.github.com/users/TomNeyland', 'location': None, 'id': 4574112, 'events_url': 'https://api.github.com/users/TomNeyland/events{/privacy}'}, 'youske': {'received_events_url': 'https://api.github.com/users/youske/received_events', 'following_url': 'https://api.github.com/users/youske/following{/other_user}', 'gists_url': 'https://api.github.com/users/youske/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/youske/starred{/owner}{/repo}', 'html_url': 'https://github.com/youske', 'public_repos': 82, 'email': 'youske@gmail.com', 'followers_url': 'https://api.github.com/users/youske/followers', 'followers': 4, 'gravatar_id': '', 'public_gists': 3, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/youske/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/youske/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/22307?v=3', 'updated_at': '2016-11-30T13:14:19Z', 'created_at': '2008-08-28T06:50:03Z', 'name': 'youske', 'hireable': None, 'company': None, 'site_admin': False, 'id': 22307, 'organizations_url': 'https://api.github.com/users/youske/orgs', 'url': 'https://api.github.com/users/youske', 'location': None, 'login': 'youske', 'events_url': 'https://api.github.com/users/youske/events{/privacy}'}, 'Team488': {'received_events_url': 'https://api.github.com/users/Team488/received_events', 'following_url': 'https://api.github.com/users/Team488/following{/other_user}', 'gists_url': 'https://api.github.com/users/Team488/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Team488/starred{/owner}{/repo}', 'html_url': 'https://github.com/Team488', 'public_repos': 12, 'email': None, 'followers_url': 'https://api.github.com/users/Team488/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/Team488/repos', 'following': 0, 'blog': 'http://www.teamxbot.org/', 'subscriptions_url': 'https://api.github.com/users/Team488/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/3441816?v=3', 'updated_at': '2016-11-23T05:48:09Z', 'created_at': '2013-01-31T19:50:13Z', 'name': 'Team488 XBOT', 'hireable': None, 'company': None, 'site_admin': False, 'id': 3441816, 'organizations_url': 'https://api.github.com/users/Team488/orgs', 'url': 'https://api.github.com/users/Team488', 'location': 'Seattle, WA', 'login': 'Team488', 'events_url': 'https://api.github.com/users/Team488/events{/privacy}'}, 'sparunakian': {'received_events_url': 'https://api.github.com/users/sparunakian/received_events', 'following_url': 'https://api.github.com/users/sparunakian/following{/other_user}', 'gists_url': 'https://api.github.com/users/sparunakian/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/sparunakian/starred{/owner}{/repo}', 'html_url': 'https://github.com/sparunakian', 'public_repos': 3, 'email': None, 'followers_url': 'https://api.github.com/users/sparunakian/followers', 'followers': 2, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/sparunakian/repos', 'following': 3, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/sparunakian/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/3016217?v=3', 'updated_at': '2016-11-14T21:52:19Z', 'created_at': '2012-12-11T12:20:51Z', 'name': 'Stéphane Parunakian', 'hireable': None, 'company': None, 'site_admin': False, 'id': 3016217, 'organizations_url': 'https://api.github.com/users/sparunakian/orgs', 'url': 'https://api.github.com/users/sparunakian', 'location': None, 'login': 'sparunakian', 'events_url': 'https://api.github.com/users/sparunakian/events{/privacy}'}, 'raffraffraff': {'received_events_url': 'https://api.github.com/users/raffraffraff/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/raffraffraff/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/raffraffraff/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/raffraffraff/orgs', 'public_repos': 2, 'email': None, 'followers_url': 'https://api.github.com/users/raffraffraff/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/raffraffraff/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/raffraffraff/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/3386372?v=3', 'updated_at': '2015-09-29T10:53:32Z', 'created_at': '2013-01-25T23:58:23Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'raffraffraff', 'following_url': 'https://api.github.com/users/raffraffraff/following{/other_user}', 'html_url': 'https://github.com/raffraffraff', 'url': 'https://api.github.com/users/raffraffraff', 'location': None, 'id': 3386372, 'events_url': 'https://api.github.com/users/raffraffraff/events{/privacy}'}, 'mayflower': {'received_events_url': 'https://api.github.com/users/mayflower/received_events', 'following_url': 'https://api.github.com/users/mayflower/following{/other_user}', 'gists_url': 'https://api.github.com/users/mayflower/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/mayflower/starred{/owner}{/repo}', 'html_url': 'https://github.com/mayflower', 'public_repos': 166, 'email': None, 'followers_url': 'https://api.github.com/users/mayflower/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/mayflower/repos', 'following': 0, 'blog': 'https://mayflower.de', 'subscriptions_url': 'https://api.github.com/users/mayflower/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/536878?v=3', 'updated_at': '2016-12-01T12:19:30Z', 'created_at': '2010-12-26T12:18:56Z', 'name': 'Mayflower GmbH', 'hireable': None, 'company': None, 'site_admin': False, 'id': 536878, 'organizations_url': 'https://api.github.com/users/mayflower/orgs', 'url': 'https://api.github.com/users/mayflower', 'location': 'Germany', 'login': 'mayflower', 'events_url': 'https://api.github.com/users/mayflower/events{/privacy}'}, 'Ax3Effect': {'received_events_url': 'https://api.github.com/users/Ax3Effect/received_events', 'following_url': 'https://api.github.com/users/Ax3Effect/following{/other_user}', 'gists_url': 'https://api.github.com/users/Ax3Effect/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Ax3Effect/starred{/owner}{/repo}', 'html_url': 'https://github.com/Ax3Effect', 'public_repos': 7, 'email': None, 'followers_url': 'https://api.github.com/users/Ax3Effect/followers', 'followers': 4, 'gravatar_id': '', 'public_gists': 2, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/Ax3Effect/repos', 'following': 2, 'blog': 'http://ax3.co.uk', 'subscriptions_url': 'https://api.github.com/users/Ax3Effect/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/8061802?v=3', 'updated_at': '2016-11-26T18:06:23Z', 'created_at': '2014-07-03T19:37:21Z', 'name': 'Nazar Kravtsov', 'hireable': None, 'company': None, 'site_admin': False, 'id': 8061802, 'organizations_url': 'https://api.github.com/users/Ax3Effect/orgs', 'url': 'https://api.github.com/users/Ax3Effect', 'location': 'Russia/UK', 'login': 'Ax3Effect', 'events_url': 'https://api.github.com/users/Ax3Effect/events{/privacy}'}, 'kdknowlton': {'received_events_url': 'https://api.github.com/users/kdknowlton/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/kdknowlton/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/kdknowlton/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/kdknowlton/orgs', 'public_repos': 6, 'email': 'brunner314@gmail.com', 'followers_url': 'https://api.github.com/users/kdknowlton/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/kdknowlton/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/kdknowlton/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5031256?v=3', 'updated_at': '2015-05-06T13:48:58Z', 'created_at': '2013-07-17T13:22:01Z', 'name': 'Kevin Davis-Knowlton', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'kdknowlton', 'following_url': 'https://api.github.com/users/kdknowlton/following{/other_user}', 'html_url': 'https://github.com/kdknowlton', 'url': 'https://api.github.com/users/kdknowlton', 'location': 'United States', 'id': 5031256, 'events_url': 'https://api.github.com/users/kdknowlton/events{/privacy}'}, 'tobika': {'received_events_url': 'https://api.github.com/users/tobika/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/tobika/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/tobika/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/tobika/orgs', 'public_repos': 3, 'email': None, 'followers_url': 'https://api.github.com/users/tobika/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 2, 'type': 'User', 'repos_url': 'https://api.github.com/users/tobika/repos', 'following': 1, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/tobika/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5700305?v=3', 'updated_at': '2015-10-01T07:23:51Z', 'created_at': '2013-10-16T12:47:24Z', 'name': 'Tobias Kausch', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'tobika', 'following_url': 'https://api.github.com/users/tobika/following{/other_user}', 'html_url': 'https://github.com/tobika', 'url': 'https://api.github.com/users/tobika', 'location': None, 'id': 5700305, 'events_url': 'https://api.github.com/users/tobika/events{/privacy}'}, 'MaxwellBo': {'received_events_url': 'https://api.github.com/users/MaxwellBo/received_events', 'following_url': 'https://api.github.com/users/MaxwellBo/following{/other_user}', 'gists_url': 'https://api.github.com/users/MaxwellBo/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/MaxwellBo/starred{/owner}{/repo}', 'html_url': 'https://github.com/MaxwellBo', 'public_repos': 16, 'email': 'max@maxbo.me', 'followers_url': 'https://api.github.com/users/MaxwellBo/followers', 'followers': 5, 'gravatar_id': '', 'public_gists': 3, 'bio': 'Might be a chef', 'type': 'User', 'repos_url': 'https://api.github.com/users/MaxwellBo/repos', 'following': 7, 'blog': 'http://maxbo.me/', 'subscriptions_url': 'https://api.github.com/users/MaxwellBo/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5368490?v=3', 'updated_at': '2016-12-16T05:35:48Z', 'created_at': '2013-09-02T23:44:27Z', 'name': 'Max Bo', 'hireable': True, 'company': 'University of Queensland', 'site_admin': False, 'id': 5368490, 'organizations_url': 'https://api.github.com/users/MaxwellBo/orgs', 'url': 'https://api.github.com/users/MaxwellBo', 'location': 'Brisbane, Australia', 'login': 'MaxwellBo', 'events_url': 'https://api.github.com/users/MaxwellBo/events{/privacy}'}, 'attakei': {'received_events_url': 'https://api.github.com/users/attakei/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/attakei/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/attakei/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/attakei/orgs', 'public_repos': 72, 'email': None, 'followers_url': 'https://api.github.com/users/attakei/followers', 'gravatar_id': '', 'public_gists': 6, 'followers': 1, 'type': 'User', 'repos_url': 'https://api.github.com/users/attakei/repos', 'following': 12, 'blog': 'http://attakei.net/', 'subscriptions_url': 'https://api.github.com/users/attakei/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/668834?v=3', 'updated_at': '2016-07-28T11:17:25Z', 'created_at': '2011-03-14T14:29:30Z', 'name': 'attakei', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'attakei', 'following_url': 'https://api.github.com/users/attakei/following{/other_user}', 'html_url': 'https://github.com/attakei', 'url': 'https://api.github.com/users/attakei', 'location': 'Tokyo, Japan', 'id': 668834, 'events_url': 'https://api.github.com/users/attakei/events{/privacy}'}, 'yalker24': {'received_events_url': 'https://api.github.com/users/yalker24/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/yalker24/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/yalker24/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/yalker24/orgs', 'public_repos': 5, 'email': None, 'followers_url': 'https://api.github.com/users/yalker24/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/yalker24/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/yalker24/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/3600837?v=3', 'updated_at': '2015-07-11T14:57:35Z', 'created_at': '2013-02-15T08:38:20Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'yalker24', 'following_url': 'https://api.github.com/users/yalker24/following{/other_user}', 'html_url': 'https://github.com/yalker24', 'url': 'https://api.github.com/users/yalker24', 'location': None, 'id': 3600837, 'events_url': 'https://api.github.com/users/yalker24/events{/privacy}'}, 'kaytwo': {'received_events_url': 'https://api.github.com/users/kaytwo/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/kaytwo/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/kaytwo/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/kaytwo/orgs', 'public_repos': 15, 'email': 'ckanich@uic.edu', 'followers_url': 'https://api.github.com/users/kaytwo/followers', 'gravatar_id': '', 'public_gists': 3, 'followers': 5, 'type': 'User', 'repos_url': 'https://api.github.com/users/kaytwo/repos', 'following': 3, 'blog': 'https://www.cs.uic.edu/~ckanich/', 'subscriptions_url': 'https://api.github.com/users/kaytwo/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/587187?v=3', 'updated_at': '2016-01-06T17:21:32Z', 'created_at': '2011-01-27T20:59:49Z', 'name': 'Chris Kanich', 'hireable': None, 'company': 'University of Illinois at Chicago', 'site_admin': False, 'login': 'kaytwo', 'following_url': 'https://api.github.com/users/kaytwo/following{/other_user}', 'html_url': 'https://github.com/kaytwo', 'url': 'https://api.github.com/users/kaytwo', 'location': 'Chicago, Illinois', 'id': 587187, 'events_url': 'https://api.github.com/users/kaytwo/events{/privacy}'}, 'guymatz': {'received_events_url': 'https://api.github.com/users/guymatz/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/guymatz/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/guymatz/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/guymatz/orgs', 'public_repos': 52, 'email': 'gmatz@matz.org', 'followers_url': 'https://api.github.com/users/guymatz/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 3, 'type': 'User', 'repos_url': 'https://api.github.com/users/guymatz/repos', 'following': 2, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/guymatz/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1688920?v=3', 'updated_at': '2015-11-18T19:08:30Z', 'created_at': '2012-04-28T22:08:12Z', 'name': 'Guy Matz', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'guymatz', 'following_url': 'https://api.github.com/users/guymatz/following{/other_user}', 'html_url': 'https://github.com/guymatz', 'url': 'https://api.github.com/users/guymatz', 'location': 'Brooklyn, NY', 'id': 1688920, 'events_url': 'https://api.github.com/users/guymatz/events{/privacy}'}, 'xi': {'received_events_url': 'https://api.github.com/users/xi/received_events', 'following_url': 'https://api.github.com/users/xi/following{/other_user}', 'gists_url': 'https://api.github.com/users/xi/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/xi/starred{/owner}{/repo}', 'html_url': 'https://github.com/xi', 'public_repos': 78, 'email': 'tobias.bengfort@posteo.de', 'followers_url': 'https://api.github.com/users/xi/followers', 'followers': 13, 'gravatar_id': '', 'public_gists': 4, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/xi/repos', 'following': 3, 'blog': 'http://tobib.spline.de', 'subscriptions_url': 'https://api.github.com/users/xi/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/202576?v=3', 'updated_at': '2016-12-14T07:56:53Z', 'created_at': '2010-02-12T16:58:36Z', 'name': 'Tobias Bengfort', 'hireable': None, 'company': None, 'site_admin': False, 'id': 202576, 'organizations_url': 'https://api.github.com/users/xi/orgs', 'url': 'https://api.github.com/users/xi', 'location': None, 'login': 'xi', 'events_url': 'https://api.github.com/users/xi/events{/privacy}'}, 'vitorio': {'received_events_url': 'https://api.github.com/users/vitorio/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/vitorio/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/vitorio/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/vitorio/orgs', 'public_repos': 20, 'email': None, 'followers_url': 'https://api.github.com/users/vitorio/followers', 'gravatar_id': '', 'public_gists': 9, 'followers': 2, 'type': 'User', 'repos_url': 'https://api.github.com/users/vitorio/repos', 'following': 0, 'blog': 'http://vitor.io/', 'subscriptions_url': 'https://api.github.com/users/vitorio/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/336712?v=3', 'updated_at': '2015-11-23T00:12:00Z', 'created_at': '2010-07-19T09:10:29Z', 'name': 'Vitorio', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'vitorio', 'following_url': 'https://api.github.com/users/vitorio/following{/other_user}', 'html_url': 'https://github.com/vitorio', 'url': 'https://api.github.com/users/vitorio', 'location': 'Austin, TX', 'id': 336712, 'events_url': 'https://api.github.com/users/vitorio/events{/privacy}'}, 'zhangsm': {'received_events_url': 'https://api.github.com/users/zhangsm/received_events', 'following_url': 'https://api.github.com/users/zhangsm/following{/other_user}', 'gists_url': 'https://api.github.com/users/zhangsm/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/zhangsm/starred{/owner}{/repo}', 'html_url': 'https://github.com/zhangsm', 'public_repos': 28, 'email': 'shuimu625@gmail.com', 'followers_url': 'https://api.github.com/users/zhangsm/followers', 'followers': 6, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/zhangsm/repos', 'following': 6, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/zhangsm/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6757399?v=3', 'updated_at': '2016-12-16T08:00:10Z', 'created_at': '2014-02-22T16:46:27Z', 'name': 'zhangsm', 'hireable': None, 'company': None, 'site_admin': False, 'id': 6757399, 'organizations_url': 'https://api.github.com/users/zhangsm/orgs', 'url': 'https://api.github.com/users/zhangsm', 'location': '广东广州', 'login': 'zhangsm', 'events_url': 'https://api.github.com/users/zhangsm/events{/privacy}'}, 'xsrender': {'received_events_url': 'https://api.github.com/users/xsrender/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/xsrender/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/xsrender/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/xsrender/orgs', 'public_repos': 12, 'email': None, 'followers_url': 'https://api.github.com/users/xsrender/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/xsrender/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/xsrender/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5503861?v=3', 'updated_at': '2015-08-24T03:18:51Z', 'created_at': '2013-09-20T18:39:44Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'xsrender', 'following_url': 'https://api.github.com/users/xsrender/following{/other_user}', 'html_url': 'https://github.com/xsrender', 'url': 'https://api.github.com/users/xsrender', 'location': None, 'id': 5503861, 'events_url': 'https://api.github.com/users/xsrender/events{/privacy}'}, 'errbotio': {'received_events_url': 'https://api.github.com/users/errbotio/received_events', 'bio': 'Home for the Errbot project', 'gists_url': 'https://api.github.com/users/errbotio/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/errbotio/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/errbotio/orgs', 'public_repos': 30, 'email': 'info@errbot.io', 'followers_url': 'https://api.github.com/users/errbotio/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/errbotio/repos', 'following': 0, 'blog': 'http://errbot.io', 'subscriptions_url': 'https://api.github.com/users/errbotio/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/15802630?v=3', 'updated_at': '2015-11-11T23:07:02Z', 'created_at': '2015-11-11T15:52:11Z', 'name': 'errbot.io', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'errbotio', 'following_url': 'https://api.github.com/users/errbotio/following{/other_user}', 'html_url': 'https://github.com/errbotio', 'url': 'https://api.github.com/users/errbotio', 'location': None, 'id': 15802630, 'events_url': 'https://api.github.com/users/errbotio/events{/privacy}'}, 'alimac': {'received_events_url': 'https://api.github.com/users/alimac/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/alimac/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/alimac/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/alimac/orgs', 'public_repos': 38, 'email': None, 'followers_url': 'https://api.github.com/users/alimac/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 7, 'type': 'User', 'repos_url': 'https://api.github.com/users/alimac/repos', 'following': 5, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/alimac/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1930627?v=3', 'updated_at': '2015-12-22T20:23:31Z', 'created_at': '2012-07-06T03:40:04Z', 'name': 'Alina Mackenzie', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'alimac', 'following_url': 'https://api.github.com/users/alimac/following{/other_user}', 'html_url': 'https://github.com/alimac', 'url': 'https://api.github.com/users/alimac', 'location': None, 'id': 1930627, 'events_url': 'https://api.github.com/users/alimac/events{/privacy}'}, 'FrankZwiers': {'received_events_url': 'https://api.github.com/users/FrankZwiers/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/FrankZwiers/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/FrankZwiers/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/FrankZwiers/orgs', 'public_repos': 5, 'email': None, 'followers_url': 'https://api.github.com/users/FrankZwiers/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 4, 'type': 'User', 'repos_url': 'https://api.github.com/users/FrankZwiers/repos', 'following': 3, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/FrankZwiers/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1741077?v=3', 'updated_at': '2015-10-26T08:10:56Z', 'created_at': '2012-05-15T06:45:39Z', 'name': 'Frank Zwiers', 'hireable': None, 'company': 'a&m impact internetdiensten', 'site_admin': False, 'login': 'FrankZwiers', 'following_url': 'https://api.github.com/users/FrankZwiers/following{/other_user}', 'html_url': 'https://github.com/FrankZwiers', 'url': 'https://api.github.com/users/FrankZwiers', 'location': 'Lochem', 'id': 1741077, 'events_url': 'https://api.github.com/users/FrankZwiers/events{/privacy}'}, 'aherok': {'received_events_url': 'https://api.github.com/users/aherok/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/aherok/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/aherok/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/aherok/orgs', 'public_repos': 6, 'email': None, 'followers_url': 'https://api.github.com/users/aherok/followers', 'gravatar_id': '', 'public_gists': 6, 'followers': 4, 'type': 'User', 'repos_url': 'https://api.github.com/users/aherok/repos', 'following': 1, 'blog': 'andrzej.herok.pl', 'subscriptions_url': 'https://api.github.com/users/aherok/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/82140?v=3', 'updated_at': '2016-06-07T18:30:35Z', 'created_at': '2009-05-07T18:12:36Z', 'name': 'Andrzej Herok', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'aherok', 'following_url': 'https://api.github.com/users/aherok/following{/other_user}', 'html_url': 'https://github.com/aherok', 'url': 'https://api.github.com/users/aherok', 'location': 'Poland', 'id': 82140, 'events_url': 'https://api.github.com/users/aherok/events{/privacy}'}, 'joshuatobin': {'received_events_url': 'https://api.github.com/users/joshuatobin/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/joshuatobin/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/joshuatobin/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/joshuatobin/orgs', 'public_repos': 35, 'email': None, 'followers_url': 'https://api.github.com/users/joshuatobin/followers', 'gravatar_id': '', 'public_gists': 7, 'followers': 6, 'type': 'User', 'repos_url': 'https://api.github.com/users/joshuatobin/repos', 'following': 5, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/joshuatobin/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/665033?v=3', 'updated_at': '2015-10-14T15:52:43Z', 'created_at': '2011-03-11T23:46:35Z', 'name': 'Joshua Tobin', 'hireable': None, 'company': 'Heroku', 'site_admin': False, 'login': 'joshuatobin', 'following_url': 'https://api.github.com/users/joshuatobin/following{/other_user}', 'html_url': 'https://github.com/joshuatobin', 'url': 'https://api.github.com/users/joshuatobin', 'location': 'Saratoga Springs, NY', 'id': 665033, 'events_url': 'https://api.github.com/users/joshuatobin/events{/privacy}'}, 'oversize': {'received_events_url': 'https://api.github.com/users/oversize/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/oversize/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/oversize/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/oversize/orgs', 'public_repos': 9, 'email': 'manuel@schmidtman.de', 'followers_url': 'https://api.github.com/users/oversize/followers', 'gravatar_id': '', 'public_gists': 4, 'followers': 17, 'type': 'User', 'repos_url': 'https://api.github.com/users/oversize/repos', 'following': 63, 'blog': 'http://www.schmidtman.de', 'subscriptions_url': 'https://api.github.com/users/oversize/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/163734?v=3', 'updated_at': '2015-10-21T21:04:13Z', 'created_at': '2009-12-07T13:00:44Z', 'name': 'Manuel Schmidt', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'oversize', 'following_url': 'https://api.github.com/users/oversize/following{/other_user}', 'html_url': 'https://github.com/oversize', 'url': 'https://api.github.com/users/oversize', 'location': 'Wiesbaden', 'id': 163734, 'events_url': 'https://api.github.com/users/oversize/events{/privacy}'}, 'mpoussevin': {'received_events_url': 'https://api.github.com/users/mpoussevin/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/mpoussevin/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/mpoussevin/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/mpoussevin/orgs', 'public_repos': 8, 'email': None, 'followers_url': 'https://api.github.com/users/mpoussevin/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 4, 'type': 'User', 'repos_url': 'https://api.github.com/users/mpoussevin/repos', 'following': 2, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/mpoussevin/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1560150?v=3', 'updated_at': '2016-07-12T11:41:08Z', 'created_at': '2012-03-21T08:31:54Z', 'name': 'Mickaël Poussevin', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'mpoussevin', 'following_url': 'https://api.github.com/users/mpoussevin/following{/other_user}', 'html_url': 'https://github.com/mpoussevin', 'url': 'https://api.github.com/users/mpoussevin', 'location': None, 'id': 1560150, 'events_url': 'https://api.github.com/users/mpoussevin/events{/privacy}'}, 'pythiannunez': {'received_events_url': 'https://api.github.com/users/pythiannunez/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/pythiannunez/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/pythiannunez/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/pythiannunez/orgs', 'public_repos': 1, 'email': None, 'followers_url': 'https://api.github.com/users/pythiannunez/followers', 'gravatar_id': '', 'public_gists': 1, 'followers': 3, 'type': 'User', 'repos_url': 'https://api.github.com/users/pythiannunez/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/pythiannunez/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5192538?v=3', 'updated_at': '2015-10-12T11:31:33Z', 'created_at': '2013-08-08T19:33:04Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'pythiannunez', 'following_url': 'https://api.github.com/users/pythiannunez/following{/other_user}', 'html_url': 'https://github.com/pythiannunez', 'url': 'https://api.github.com/users/pythiannunez', 'location': None, 'id': 5192538, 'events_url': 'https://api.github.com/users/pythiannunez/events{/privacy}'}, 'takuan-osho': {'received_events_url': 'https://api.github.com/users/takuan-osho/received_events', 'following_url': 'https://api.github.com/users/takuan-osho/following{/other_user}', 'gists_url': 'https://api.github.com/users/takuan-osho/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/takuan-osho/starred{/owner}{/repo}', 'html_url': 'https://github.com/takuan-osho', 'public_repos': 72, 'email': 'shimizu.taku@gmail.com', 'followers_url': 'https://api.github.com/users/takuan-osho/followers', 'followers': 57, 'gravatar_id': '', 'public_gists': 36, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/takuan-osho/repos', 'following': 400, 'blog': 'https://twitter.com/takuan_osho', 'subscriptions_url': 'https://api.github.com/users/takuan-osho/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1075965?v=3', 'updated_at': '2016-12-13T08:25:41Z', 'created_at': '2011-09-24T07:48:53Z', 'name': 'SHIMIZU Taku', 'hireable': None, 'company': None, 'site_admin': False, 'id': 1075965, 'organizations_url': 'https://api.github.com/users/takuan-osho/orgs', 'url': 'https://api.github.com/users/takuan-osho', 'location': 'Japan', 'login': 'takuan-osho', 'events_url': 'https://api.github.com/users/takuan-osho/events{/privacy}'}, 'theho': {'received_events_url': 'https://api.github.com/users/theho/received_events', 'following_url': 'https://api.github.com/users/theho/following{/other_user}', 'gists_url': 'https://api.github.com/users/theho/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/theho/starred{/owner}{/repo}', 'html_url': 'https://github.com/theho', 'public_repos': 54, 'email': None, 'followers_url': 'https://api.github.com/users/theho/followers', 'followers': 6, 'gravatar_id': '', 'public_gists': 13, 'bio': 'Developer: Never Stop, Never Stopping!', 'type': 'User', 'repos_url': 'https://api.github.com/users/theho/repos', 'following': 2, 'blog': 'http://www.reallylimited.com', 'subscriptions_url': 'https://api.github.com/users/theho/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1423278?v=3', 'updated_at': '2016-12-09T10:06:02Z', 'created_at': '2012-02-09T15:03:50Z', 'name': 'James Ho', 'hireable': True, 'company': 'Really Limited', 'site_admin': False, 'id': 1423278, 'organizations_url': 'https://api.github.com/users/theho/orgs', 'url': 'https://api.github.com/users/theho', 'location': 'London', 'login': 'theho', 'events_url': 'https://api.github.com/users/theho/events{/privacy}'}, 'ersiko': {'received_events_url': 'https://api.github.com/users/ersiko/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/ersiko/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ersiko/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/ersiko/orgs', 'public_repos': 10, 'email': None, 'followers_url': 'https://api.github.com/users/ersiko/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 1, 'type': 'User', 'repos_url': 'https://api.github.com/users/ersiko/repos', 'following': 0, 'blog': 'https://www.tomas.cat/blog', 'subscriptions_url': 'https://api.github.com/users/ersiko/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/3366936?v=3', 'updated_at': '2015-10-13T15:00:14Z', 'created_at': '2013-01-24T08:41:22Z', 'name': 'Tomàs Núñez', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'ersiko', 'following_url': 'https://api.github.com/users/ersiko/following{/other_user}', 'html_url': 'https://github.com/ersiko', 'url': 'https://api.github.com/users/ersiko', 'location': None, 'id': 3366936, 'events_url': 'https://api.github.com/users/ersiko/events{/privacy}'}, 'KenMercusLai': {'received_events_url': 'https://api.github.com/users/KenMercusLai/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/KenMercusLai/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/KenMercusLai/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/KenMercusLai/orgs', 'public_repos': 54, 'email': 'k@kenmlai.me', 'followers_url': 'https://api.github.com/users/KenMercusLai/followers', 'gravatar_id': '', 'public_gists': 8, 'followers': 4, 'type': 'User', 'repos_url': 'https://api.github.com/users/KenMercusLai/repos', 'following': 0, 'blog': 'kenmlai.me', 'subscriptions_url': 'https://api.github.com/users/KenMercusLai/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5419014?v=3', 'updated_at': '2015-11-24T04:49:06Z', 'created_at': '2013-09-09T14:44:55Z', 'name': 'Ken M. Lai', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'KenMercusLai', 'following_url': 'https://api.github.com/users/KenMercusLai/following{/other_user}', 'html_url': 'https://github.com/KenMercusLai', 'url': 'https://api.github.com/users/KenMercusLai', 'location': None, 'id': 5419014, 'events_url': 'https://api.github.com/users/KenMercusLai/events{/privacy}'}, 'superawesome': {'received_events_url': 'https://api.github.com/users/superawesome/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/superawesome/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/superawesome/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/superawesome/orgs', 'public_repos': 32, 'email': None, 'followers_url': 'https://api.github.com/users/superawesome/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 2, 'type': 'User', 'repos_url': 'https://api.github.com/users/superawesome/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/superawesome/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1141442?v=3', 'updated_at': '2015-12-29T23:01:57Z', 'created_at': '2011-10-20T18:24:19Z', 'name': 'Jake Maul', 'hireable': None, 'company': 'Mozilla', 'site_admin': False, 'login': 'superawesome', 'following_url': 'https://api.github.com/users/superawesome/following{/other_user}', 'html_url': 'https://github.com/superawesome', 'url': 'https://api.github.com/users/superawesome', 'location': 'USA', 'id': 1141442, 'events_url': 'https://api.github.com/users/superawesome/events{/privacy}'}, 'WeiBanjo': {'received_events_url': 'https://api.github.com/users/WeiBanjo/received_events', 'following_url': 'https://api.github.com/users/WeiBanjo/following{/other_user}', 'gists_url': 'https://api.github.com/users/WeiBanjo/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/WeiBanjo/starred{/owner}{/repo}', 'html_url': 'https://github.com/WeiBanjo', 'public_repos': 30, 'email': None, 'followers_url': 'https://api.github.com/users/WeiBanjo/followers', 'followers': 3, 'gravatar_id': '', 'public_gists': 2, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/WeiBanjo/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/WeiBanjo/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/3101113?v=3', 'updated_at': '2016-11-18T07:42:19Z', 'created_at': '2012-12-21T21:12:12Z', 'name': 'Wei Wu', 'hireable': None, 'company': None, 'site_admin': False, 'id': 3101113, 'organizations_url': 'https://api.github.com/users/WeiBanjo/orgs', 'url': 'https://api.github.com/users/WeiBanjo', 'location': None, 'login': 'WeiBanjo', 'events_url': 'https://api.github.com/users/WeiBanjo/events{/privacy}'}, 'krismolendyke': {'received_events_url': 'https://api.github.com/users/krismolendyke/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/krismolendyke/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/krismolendyke/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/krismolendyke/orgs', 'public_repos': 41, 'email': None, 'followers_url': 'https://api.github.com/users/krismolendyke/followers', 'gravatar_id': '', 'public_gists': 2, 'followers': 21, 'type': 'User', 'repos_url': 'https://api.github.com/users/krismolendyke/repos', 'following': 3, 'blog': 'http://k20e.com', 'subscriptions_url': 'https://api.github.com/users/krismolendyke/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/125818?v=3', 'updated_at': '2015-10-23T20:17:05Z', 'created_at': '2009-09-11T17:27:38Z', 'name': 'Kris Molendyke', 'hireable': None, 'company': 'Monetate', 'site_admin': False, 'login': 'krismolendyke', 'following_url': 'https://api.github.com/users/krismolendyke/following{/other_user}', 'html_url': 'https://github.com/krismolendyke', 'url': 'https://api.github.com/users/krismolendyke', 'location': 'Philadelphia, PA', 'id': 125818, 'events_url': 'https://api.github.com/users/krismolendyke/events{/privacy}'}, 'TheArchives': {'received_events_url': 'https://api.github.com/users/TheArchives/received_events', 'bio': 'The original multiworld', 'gists_url': 'https://api.github.com/users/TheArchives/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/TheArchives/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/TheArchives/orgs', 'public_repos': 13, 'email': None, 'followers_url': 'https://api.github.com/users/TheArchives/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/TheArchives/repos', 'following': 0, 'blog': 'https://archivesmc.com', 'subscriptions_url': 'https://api.github.com/users/TheArchives/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1098410?v=3', 'updated_at': '2015-04-15T13:54:20Z', 'created_at': '2011-10-03T10:07:20Z', 'name': 'The Archives', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'TheArchives', 'following_url': 'https://api.github.com/users/TheArchives/following{/other_user}', 'html_url': 'https://github.com/TheArchives', 'url': 'https://api.github.com/users/TheArchives', 'location': None, 'id': 1098410, 'events_url': 'https://api.github.com/users/TheArchives/events{/privacy}'}, 'dvl': {'received_events_url': 'https://api.github.com/users/dvl/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/dvl/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/dvl/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/dvl/orgs', 'public_repos': 27, 'email': 'contato@xdvl.info', 'followers_url': 'https://api.github.com/users/dvl/followers', 'gravatar_id': '', 'public_gists': 24, 'followers': 19, 'type': 'User', 'repos_url': 'https://api.github.com/users/dvl/repos', 'following': 51, 'blog': 'http://dvl.rocks', 'subscriptions_url': 'https://api.github.com/users/dvl/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/308337?v=3', 'updated_at': '2015-10-30T03:51:50Z', 'created_at': '2010-06-18T05:44:17Z', 'name': 'André Luiz', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'dvl', 'following_url': 'https://api.github.com/users/dvl/following{/other_user}', 'html_url': 'https://github.com/dvl', 'url': 'https://api.github.com/users/dvl', 'location': 'Rio de Janeiro, Brazil', 'id': 308337, 'events_url': 'https://api.github.com/users/dvl/events{/privacy}'}, 'jeffx': {'received_events_url': 'https://api.github.com/users/jeffx/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/jeffx/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jeffx/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/jeffx/orgs', 'public_repos': 6, 'email': None, 'followers_url': 'https://api.github.com/users/jeffx/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/jeffx/repos', 'following': 6, 'blog': 'http://www.jeffx.com', 'subscriptions_url': 'https://api.github.com/users/jeffx/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/13067885?v=3', 'updated_at': '2016-01-07T21:26:44Z', 'created_at': '2015-06-26T16:56:38Z', 'name': 'Jeff Tillotson', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'jeffx', 'following_url': 'https://api.github.com/users/jeffx/following{/other_user}', 'html_url': 'https://github.com/jeffx', 'url': 'https://api.github.com/users/jeffx', 'location': 'Atlanta, GA', 'id': 13067885, 'events_url': 'https://api.github.com/users/jeffx/events{/privacy}'}, 'tlee911': {'received_events_url': 'https://api.github.com/users/tlee911/received_events', 'following_url': 'https://api.github.com/users/tlee911/following{/other_user}', 'gists_url': 'https://api.github.com/users/tlee911/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/tlee911/starred{/owner}{/repo}', 'html_url': 'https://github.com/tlee911', 'public_repos': 2, 'email': None, 'followers_url': 'https://api.github.com/users/tlee911/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/tlee911/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/tlee911/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6746746?v=3', 'updated_at': '2016-11-25T04:20:51Z', 'created_at': '2014-02-21T10:14:39Z', 'name': 'Thomas Lee', 'hireable': None, 'company': None, 'site_admin': False, 'id': 6746746, 'organizations_url': 'https://api.github.com/users/tlee911/orgs', 'url': 'https://api.github.com/users/tlee911', 'location': None, 'login': 'tlee911', 'events_url': 'https://api.github.com/users/tlee911/events{/privacy}'}, 'log0ymxm': {'received_events_url': 'https://api.github.com/users/log0ymxm/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/log0ymxm/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/log0ymxm/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/log0ymxm/orgs', 'public_repos': 93, 'email': 'paul@onfrst.com', 'followers_url': 'https://api.github.com/users/log0ymxm/followers', 'gravatar_id': '', 'public_gists': 47, 'followers': 37, 'type': 'User', 'repos_url': 'https://api.github.com/users/log0ymxm/repos', 'following': 65, 'blog': 'http://paul.engl.is/h', 'subscriptions_url': 'https://api.github.com/users/log0ymxm/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/35297?v=3', 'updated_at': '2015-10-24T10:49:07Z', 'created_at': '2008-11-18T22:02:58Z', 'name': 'Paul English', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'log0ymxm', 'following_url': 'https://api.github.com/users/log0ymxm/following{/other_user}', 'html_url': 'https://github.com/log0ymxm', 'url': 'https://api.github.com/users/log0ymxm', 'location': 'Salt Lake City, UT', 'id': 35297, 'events_url': 'https://api.github.com/users/log0ymxm/events{/privacy}'}, 'phlax': {'received_events_url': 'https://api.github.com/users/phlax/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/phlax/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/phlax/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/phlax/orgs', 'public_repos': 64, 'email': None, 'followers_url': 'https://api.github.com/users/phlax/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 18, 'type': 'User', 'repos_url': 'https://api.github.com/users/phlax/repos', 'following': 11, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/phlax/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/454682?v=3', 'updated_at': '2016-07-24T09:59:46Z', 'created_at': '2010-10-26T13:33:00Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'phlax', 'following_url': 'https://api.github.com/users/phlax/following{/other_user}', 'html_url': 'https://github.com/phlax', 'url': 'https://api.github.com/users/phlax', 'location': None, 'id': 454682, 'events_url': 'https://api.github.com/users/phlax/events{/privacy}'}, 'GreenelyAB': {'received_events_url': 'https://api.github.com/users/GreenelyAB/received_events', 'following_url': 'https://api.github.com/users/GreenelyAB/following{/other_user}', 'gists_url': 'https://api.github.com/users/GreenelyAB/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/GreenelyAB/starred{/owner}{/repo}', 'html_url': 'https://github.com/GreenelyAB', 'public_repos': 7, 'email': None, 'followers_url': 'https://api.github.com/users/GreenelyAB/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/GreenelyAB/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/GreenelyAB/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/15154706?v=3', 'updated_at': '2016-12-16T10:23:10Z', 'created_at': '2015-10-16T08:27:01Z', 'name': 'Greenely AB', 'hireable': None, 'company': None, 'site_admin': False, 'id': 15154706, 'organizations_url': 'https://api.github.com/users/GreenelyAB/orgs', 'url': 'https://api.github.com/users/GreenelyAB', 'location': None, 'login': 'GreenelyAB', 'events_url': 'https://api.github.com/users/GreenelyAB/events{/privacy}'}, 'SShrike': {'received_events_url': 'https://api.github.com/users/SShrike/received_events', 'bio': "Just another organism that pushes keys, clicks buttons, and sometimes spouts opinions you aren't inclined to agree with.", 'gists_url': 'https://api.github.com/users/SShrike/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/SShrike/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/SShrike/orgs', 'public_repos': 35, 'email': 'severen@shrike.me', 'followers_url': 'https://api.github.com/users/SShrike/followers', 'gravatar_id': '', 'public_gists': 4, 'followers': 14, 'type': 'User', 'repos_url': 'https://api.github.com/users/SShrike/repos', 'following': 28, 'blog': 'https://shrike.me/', 'subscriptions_url': 'https://api.github.com/users/SShrike/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/4061736?v=3', 'updated_at': '2016-07-26T06:51:48Z', 'created_at': '2013-04-04T18:10:17Z', 'name': 'Severen Redwood', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'SShrike', 'following_url': 'https://api.github.com/users/SShrike/following{/other_user}', 'html_url': 'https://github.com/SShrike', 'url': 'https://api.github.com/users/SShrike', 'location': 'Whangarei, New Zealand', 'id': 4061736, 'events_url': 'https://api.github.com/users/SShrike/events{/privacy}'}, 'AbigailBuccaneer': {'received_events_url': 'https://api.github.com/users/AbigailBuccaneer/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/AbigailBuccaneer/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/AbigailBuccaneer/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/AbigailBuccaneer/orgs', 'public_repos': 13, 'email': None, 'followers_url': 'https://api.github.com/users/AbigailBuccaneer/followers', 'gravatar_id': '', 'public_gists': 9, 'followers': 14, 'type': 'User', 'repos_url': 'https://api.github.com/users/AbigailBuccaneer/repos', 'following': 1, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/AbigailBuccaneer/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/908758?v=3', 'updated_at': '2016-01-06T14:37:51Z', 'created_at': '2011-07-11T20:58:09Z', 'name': 'Abigail', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'AbigailBuccaneer', 'following_url': 'https://api.github.com/users/AbigailBuccaneer/following{/other_user}', 'html_url': 'https://github.com/AbigailBuccaneer', 'url': 'https://api.github.com/users/AbigailBuccaneer', 'location': None, 'id': 908758, 'events_url': 'https://api.github.com/users/AbigailBuccaneer/events{/privacy}'}, 'jlu368': {'received_events_url': 'https://api.github.com/users/jlu368/received_events', 'following_url': 'https://api.github.com/users/jlu368/following{/other_user}', 'gists_url': 'https://api.github.com/users/jlu368/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jlu368/starred{/owner}{/repo}', 'html_url': 'https://github.com/jlu368', 'public_repos': 6, 'email': None, 'followers_url': 'https://api.github.com/users/jlu368/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/jlu368/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/jlu368/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5341863?v=3', 'updated_at': '2016-12-05T02:57:06Z', 'created_at': '2013-08-29T20:09:41Z', 'name': 'Jeff Lu', 'hireable': None, 'company': None, 'site_admin': False, 'id': 5341863, 'organizations_url': 'https://api.github.com/users/jlu368/orgs', 'url': 'https://api.github.com/users/jlu368', 'location': None, 'login': 'jlu368', 'events_url': 'https://api.github.com/users/jlu368/events{/privacy}'}, 'EFXCIA': {'received_events_url': 'https://api.github.com/users/EFXCIA/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/EFXCIA/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/EFXCIA/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/EFXCIA/orgs', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/EFXCIA/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/EFXCIA/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/EFXCIA/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/16101656?v=3', 'updated_at': '2015-12-01T14:24:53Z', 'created_at': '2015-12-01T13:57:25Z', 'name': 'EFXCIA', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'EFXCIA', 'following_url': 'https://api.github.com/users/EFXCIA/following{/other_user}', 'html_url': 'https://github.com/EFXCIA', 'url': 'https://api.github.com/users/EFXCIA', 'location': None, 'id': 16101656, 'events_url': 'https://api.github.com/users/EFXCIA/events{/privacy}'}, 'JonathanOBrien': {'received_events_url': 'https://api.github.com/users/JonathanOBrien/received_events', 'following_url': 'https://api.github.com/users/JonathanOBrien/following{/other_user}', 'gists_url': 'https://api.github.com/users/JonathanOBrien/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/JonathanOBrien/starred{/owner}{/repo}', 'html_url': 'https://github.com/JonathanOBrien', 'public_repos': 9, 'email': None, 'followers_url': 'https://api.github.com/users/JonathanOBrien/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/JonathanOBrien/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/JonathanOBrien/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5503861?v=3', 'updated_at': '2016-10-22T01:51:08Z', 'created_at': '2013-09-20T18:39:44Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'id': 5503861, 'organizations_url': 'https://api.github.com/users/JonathanOBrien/orgs', 'url': 'https://api.github.com/users/JonathanOBrien', 'location': None, 'login': 'JonathanOBrien', 'events_url': 'https://api.github.com/users/JonathanOBrien/events{/privacy}'}, 'ricardokirkner': {'received_events_url': 'https://api.github.com/users/ricardokirkner/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/ricardokirkner/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ricardokirkner/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/ricardokirkner/orgs', 'public_repos': 32, 'email': None, 'followers_url': 'https://api.github.com/users/ricardokirkner/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 5, 'type': 'User', 'repos_url': 'https://api.github.com/users/ricardokirkner/repos', 'following': 0, 'blog': 'http://ricardokirkner.github.com', 'subscriptions_url': 'https://api.github.com/users/ricardokirkner/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/478971?v=3', 'updated_at': '2016-01-02T16:47:43Z', 'created_at': '2010-11-12T15:14:11Z', 'name': 'Ricardo Kirkner', 'hireable': True, 'company': None, 'site_admin': False, 'login': 'ricardokirkner', 'following_url': 'https://api.github.com/users/ricardokirkner/following{/other_user}', 'html_url': 'https://github.com/ricardokirkner', 'url': 'https://api.github.com/users/ricardokirkner', 'location': None, 'id': 478971, 'events_url': 'https://api.github.com/users/ricardokirkner/events{/privacy}'}, 'panholt': {'received_events_url': 'https://api.github.com/users/panholt/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/panholt/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/panholt/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/panholt/orgs', 'public_repos': 2, 'email': None, 'followers_url': 'https://api.github.com/users/panholt/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/panholt/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/panholt/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/12188174?v=3', 'updated_at': '2016-07-23T20:14:30Z', 'created_at': '2015-04-30T15:50:12Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'panholt', 'following_url': 'https://api.github.com/users/panholt/following{/other_user}', 'html_url': 'https://github.com/panholt', 'url': 'https://api.github.com/users/panholt', 'location': None, 'id': 12188174, 'events_url': 'https://api.github.com/users/panholt/events{/privacy}'}, 'fernand0': {'received_events_url': 'https://api.github.com/users/fernand0/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/fernand0/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/fernand0/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/fernand0/orgs', 'public_repos': 25, 'email': None, 'followers_url': 'https://api.github.com/users/fernand0/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 24, 'type': 'User', 'repos_url': 'https://api.github.com/users/fernand0/repos', 'following': 19, 'blog': 'http://elmundoesimperfecto.com/', 'subscriptions_url': 'https://api.github.com/users/fernand0/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2467?v=3', 'updated_at': '2015-10-04T10:57:42Z', 'created_at': '2008-03-06T22:18:37Z', 'name': 'Fernando Tricas García', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'fernand0', 'following_url': 'https://api.github.com/users/fernand0/following{/other_user}', 'html_url': 'https://github.com/fernand0', 'url': 'https://api.github.com/users/fernand0', 'location': 'Huesca-Zaragoza, Spain', 'id': 2467, 'events_url': 'https://api.github.com/users/fernand0/events{/privacy}'}, 'redhat-infosec': {'received_events_url': 'https://api.github.com/users/redhat-infosec/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/redhat-infosec/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/redhat-infosec/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/redhat-infosec/orgs', 'public_repos': 3, 'email': None, 'followers_url': 'https://api.github.com/users/redhat-infosec/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/redhat-infosec/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/redhat-infosec/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/15036903?v=3', 'updated_at': '2015-10-08T17:09:21Z', 'created_at': '2015-10-08T17:09:21Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'redhat-infosec', 'following_url': 'https://api.github.com/users/redhat-infosec/following{/other_user}', 'html_url': 'https://github.com/redhat-infosec', 'url': 'https://api.github.com/users/redhat-infosec', 'location': None, 'id': 15036903, 'events_url': 'https://api.github.com/users/redhat-infosec/events{/privacy}'}, 'spoof79': {'received_events_url': 'https://api.github.com/users/spoof79/received_events', 'following_url': 'https://api.github.com/users/spoof79/following{/other_user}', 'gists_url': 'https://api.github.com/users/spoof79/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/spoof79/starred{/owner}{/repo}', 'html_url': 'https://github.com/spoof79', 'public_repos': 4, 'email': None, 'followers_url': 'https://api.github.com/users/spoof79/followers', 'followers': 1, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/spoof79/repos', 'following': 0, 'blog': 'www.muppet.se', 'subscriptions_url': 'https://api.github.com/users/spoof79/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/6874714?v=3', 'updated_at': '2016-05-10T17:58:17Z', 'created_at': '2014-03-06T16:15:33Z', 'name': 'Mico', 'hireable': None, 'company': 'Projectplace international', 'site_admin': False, 'id': 6874714, 'organizations_url': 'https://api.github.com/users/spoof79/orgs', 'url': 'https://api.github.com/users/spoof79', 'location': 'Sweden', 'login': 'spoof79', 'events_url': 'https://api.github.com/users/spoof79/events{/privacy}'}, 'avengerpenguin': {'received_events_url': 'https://api.github.com/users/avengerpenguin/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/avengerpenguin/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/avengerpenguin/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/avengerpenguin/orgs', 'public_repos': 68, 'email': None, 'followers_url': 'https://api.github.com/users/avengerpenguin/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 15, 'type': 'User', 'repos_url': 'https://api.github.com/users/avengerpenguin/repos', 'following': 38, 'blog': 'http://avengerpenguin.com', 'subscriptions_url': 'https://api.github.com/users/avengerpenguin/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1759611?v=3', 'updated_at': '2015-10-22T21:39:05Z', 'created_at': '2012-05-21T13:56:09Z', 'name': 'Ross Fenning', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'avengerpenguin', 'following_url': 'https://api.github.com/users/avengerpenguin/following{/other_user}', 'html_url': 'https://github.com/avengerpenguin', 'url': 'https://api.github.com/users/avengerpenguin', 'location': None, 'id': 1759611, 'events_url': 'https://api.github.com/users/avengerpenguin/events{/privacy}'}, 'phaistos-networks': {'received_events_url': 'https://api.github.com/users/phaistos-networks/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/phaistos-networks/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/phaistos-networks/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/phaistos-networks/orgs', 'public_repos': 6, 'email': None, 'followers_url': 'https://api.github.com/users/phaistos-networks/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'Organization', 'repos_url': 'https://api.github.com/users/phaistos-networks/repos', 'following': 0, 'blog': 'http://www.phaistosnetworks.gr', 'subscriptions_url': 'https://api.github.com/users/phaistos-networks/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2130489?v=3', 'updated_at': '2016-07-24T09:47:26Z', 'created_at': '2012-08-10T15:12:03Z', 'name': 'Phaistos Networks', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'phaistos-networks', 'following_url': 'https://api.github.com/users/phaistos-networks/following{/other_user}', 'html_url': 'https://github.com/phaistos-networks', 'url': 'https://api.github.com/users/phaistos-networks', 'location': 'Crete, Greece', 'id': 2130489, 'events_url': 'https://api.github.com/users/phaistos-networks/events{/privacy}'}, 'RobSpectre': {'received_events_url': 'https://api.github.com/users/RobSpectre/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/RobSpectre/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/RobSpectre/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/RobSpectre/orgs', 'public_repos': 53, 'email': 'rob@brooklynhacker.com', 'followers_url': 'https://api.github.com/users/RobSpectre/followers', 'gravatar_id': '', 'public_gists': 98, 'followers': 242, 'type': 'User', 'repos_url': 'https://api.github.com/users/RobSpectre/repos', 'following': 35, 'blog': 'http://www.brooklynhacker.com', 'subscriptions_url': 'https://api.github.com/users/RobSpectre/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/240731?v=3', 'updated_at': '2015-10-10T10:31:21Z', 'created_at': '2010-04-09T22:41:11Z', 'name': 'Rob Spectre', 'hireable': None, 'company': "Don't recruit me, bro.", 'site_admin': False, 'login': 'RobSpectre', 'following_url': 'https://api.github.com/users/RobSpectre/following{/other_user}', 'html_url': 'https://github.com/RobSpectre', 'url': 'https://api.github.com/users/RobSpectre', 'location': 'Brooklyn, NY', 'id': 240731, 'events_url': 'https://api.github.com/users/RobSpectre/events{/privacy}'}, 'kongluoxing': {'received_events_url': 'https://api.github.com/users/kongluoxing/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/kongluoxing/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/kongluoxing/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/kongluoxing/orgs', 'public_repos': 27, 'email': 'kong.luoxing@gmail.com', 'followers_url': 'https://api.github.com/users/kongluoxing/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 7, 'type': 'User', 'repos_url': 'https://api.github.com/users/kongluoxing/repos', 'following': 4, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/kongluoxing/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/2104457?v=3', 'updated_at': '2015-10-21T15:10:21Z', 'created_at': '2012-08-06T16:23:30Z', 'name': 'Kong Luoxing', 'hireable': True, 'company': 'Alibaba', 'site_admin': False, 'login': 'kongluoxing', 'following_url': 'https://api.github.com/users/kongluoxing/following{/other_user}', 'html_url': 'https://github.com/kongluoxing', 'url': 'https://api.github.com/users/kongluoxing', 'location': 'Fuzhou', 'id': 2104457, 'events_url': 'https://api.github.com/users/kongluoxing/events{/privacy}'}, 'Kaytwo': {'received_events_url': 'https://api.github.com/users/kaytwo/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/kaytwo/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/kaytwo/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/kaytwo/orgs', 'public_repos': 15, 'email': 'ckanich@uic.edu', 'followers_url': 'https://api.github.com/users/kaytwo/followers', 'gravatar_id': '', 'public_gists': 3, 'followers': 5, 'type': 'User', 'repos_url': 'https://api.github.com/users/kaytwo/repos', 'following': 3, 'blog': 'https://www.cs.uic.edu/~ckanich/', 'subscriptions_url': 'https://api.github.com/users/kaytwo/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/587187?v=3', 'updated_at': '2016-01-06T17:21:32Z', 'created_at': '2011-01-27T20:59:49Z', 'name': 'Chris Kanich', 'hireable': None, 'company': 'University of Illinois at Chicago', 'site_admin': False, 'login': 'kaytwo', 'following_url': 'https://api.github.com/users/kaytwo/following{/other_user}', 'html_url': 'https://github.com/kaytwo', 'url': 'https://api.github.com/users/kaytwo', 'location': 'Chicago, Illinois', 'id': 587187, 'events_url': 'https://api.github.com/users/kaytwo/events{/privacy}'}, 'marksull': {'received_events_url': 'https://api.github.com/users/marksull/received_events', 'following_url': 'https://api.github.com/users/marksull/following{/other_user}', 'gists_url': 'https://api.github.com/users/marksull/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/marksull/starred{/owner}{/repo}', 'html_url': 'https://github.com/marksull', 'public_repos': 5, 'email': None, 'followers_url': 'https://api.github.com/users/marksull/followers', 'followers': 0, 'gravatar_id': '', 'public_gists': 0, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/marksull/repos', 'following': 1, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/marksull/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/1705633?v=3', 'updated_at': '2016-09-05T03:38:10Z', 'created_at': '2012-05-04T11:48:48Z', 'name': 'Mark Sullivan', 'hireable': None, 'company': None, 'site_admin': False, 'id': 1705633, 'organizations_url': 'https://api.github.com/users/marksull/orgs', 'url': 'https://api.github.com/users/marksull', 'location': 'Brisbane, Australia', 'login': 'marksull', 'events_url': 'https://api.github.com/users/marksull/events{/privacy}'}, 'Andrew-Klaas': {'received_events_url': 'https://api.github.com/users/Andrew-Klaas/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/Andrew-Klaas/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Andrew-Klaas/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/Andrew-Klaas/orgs', 'public_repos': 10, 'email': 'aklaas2@gmail.com', 'followers_url': 'https://api.github.com/users/Andrew-Klaas/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/Andrew-Klaas/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/Andrew-Klaas/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/7883996?v=3', 'updated_at': '2015-12-23T14:21:22Z', 'created_at': '2014-06-13T21:15:54Z', 'name': 'Andrew Klaas', 'hireable': None, 'company': None, 'site_admin': False, 'login': 'Andrew-Klaas', 'following_url': 'https://api.github.com/users/Andrew-Klaas/following{/other_user}', 'html_url': 'https://github.com/Andrew-Klaas', 'url': 'https://api.github.com/users/Andrew-Klaas', 'location': None, 'id': 7883996, 'events_url': 'https://api.github.com/users/Andrew-Klaas/events{/privacy}'}, 'rooshoes': {'received_events_url': 'https://api.github.com/users/rooshoes/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/rooshoes/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/rooshoes/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/rooshoes/orgs', 'public_repos': 3, 'email': None, 'followers_url': 'https://api.github.com/users/rooshoes/followers', 'gravatar_id': '', 'public_gists': 0, 'followers': 0, 'type': 'User', 'repos_url': 'https://api.github.com/users/rooshoes/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/rooshoes/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/18250497?v=3', 'updated_at': '2016-06-26T03:14:42Z', 'created_at': '2016-04-03T20:55:26Z', 'name': None, 'hireable': None, 'company': None, 'site_admin': False, 'login': 'rooshoes', 'following_url': 'https://api.github.com/users/rooshoes/following{/other_user}', 'html_url': 'https://github.com/rooshoes', 'url': 'https://api.github.com/users/rooshoes', 'location': None, 'id': 18250497, 'events_url': 'https://api.github.com/users/rooshoes/events{/privacy}'}, 'sarlalian': {'received_events_url': 'https://api.github.com/users/sarlalian/received_events', 'bio': None, 'gists_url': 'https://api.github.com/users/sarlalian/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/sarlalian/starred{/owner}{/repo}', 'organizations_url': 'https://api.github.com/users/sarlalian/orgs', 'public_repos': 23, 'email': None, 'followers_url': 'https://api.github.com/users/sarlalian/followers', 'gravatar_id': '', 'public_gists': 5, 'followers': 10, 'type': 'User', 'repos_url': 'https://api.github.com/users/sarlalian/repos', 'following': 88, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/sarlalian/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/392649?v=3', 'updated_at': '2015-10-10T05:19:11Z', 'created_at': '2010-09-08T21:59:24Z', 'name': 'Will Fife', 'hireable': None, 'company': 'Laika, Inc.', 'site_admin': False, 'login': 'sarlalian', 'following_url': 'https://api.github.com/users/sarlalian/following{/other_user}', 'html_url': 'https://github.com/sarlalian', 'url': 'https://api.github.com/users/sarlalian', 'location': 'Hillsboro, OR', 'id': 392649, 'events_url': 'https://api.github.com/users/sarlalian/events{/privacy}'}, 'Axylos': {'received_events_url': 'https://api.github.com/users/Axylos/received_events', 'following_url': 'https://api.github.com/users/Axylos/following{/other_user}', 'gists_url': 'https://api.github.com/users/Axylos/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Axylos/starred{/owner}{/repo}', 'html_url': 'https://github.com/Axylos', 'public_repos': 45, 'email': 'robertdraketalley@gmail.com', 'followers_url': 'https://api.github.com/users/Axylos/followers', 'followers': 12, 'gravatar_id': '', 'public_gists': 2, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/Axylos/repos', 'following': 2, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/Axylos/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/5732879?v=3', 'updated_at': '2016-10-28T01:58:11Z', 'created_at': '2013-10-20T22:13:14Z', 'name': 'Drake Talley', 'hireable': True, 'company': None, 'site_admin': False, 'id': 5732879, 'organizations_url': 'https://api.github.com/users/Axylos/orgs', 'url': 'https://api.github.com/users/Axylos', 'location': 'United States', 'login': 'Axylos', 'events_url': 'https://api.github.com/users/Axylos/events{/privacy}'}, 'vrutkovs': {'received_events_url': 'https://api.github.com/users/vrutkovs/received_events', 'following_url': 'https://api.github.com/users/vrutkovs/following{/other_user}', 'gists_url': 'https://api.github.com/users/vrutkovs/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/vrutkovs/starred{/owner}{/repo}', 'html_url': 'https://github.com/vrutkovs', 'public_repos': 131, 'email': 'vrutkovs@redhat.com', 'followers_url': 'https://api.github.com/users/vrutkovs/followers', 'followers': 19, 'gravatar_id': '', 'public_gists': 28, 'bio': None, 'type': 'User', 'repos_url': 'https://api.github.com/users/vrutkovs/repos', 'following': 0, 'blog': None, 'subscriptions_url': 'https://api.github.com/users/vrutkovs/subscriptions', 'avatar_url': 'https://avatars.githubusercontent.com/u/114501?v=3', 'updated_at': '2016-12-15T10:08:27Z', 'created_at': '2009-08-12T09:23:08Z', 'name': 'Vadim Rutkovsky', 'hireable': None, 'company': 'Red Hat', 'site_admin': False, 'id': 114501, 'organizations_url': 'https://api.github.com/users/vrutkovs/orgs', 'url': 'https://api.github.com/users/vrutkovs', 'location': 'Brno, CZ', 'login': 'vrutkovs', 'events_url': 'https://api.github.com/users/vrutkovs/events{/privacy}'}, 'Vaelor': {'login': 'Vaelor', 'id': 6680834, 'avatar_url': 'https://avatars1.githubusercontent.com/u/6680834?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/Vaelor', 'html_url': 'https://github.com/Vaelor', 'followers_url': 'https://api.github.com/users/Vaelor/followers', 'following_url': 'https://api.github.com/users/Vaelor/following{/other_user}', 'gists_url': 'https://api.github.com/users/Vaelor/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/Vaelor/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/Vaelor/subscriptions', 'organizations_url': 'https://api.github.com/users/Vaelor/orgs', 'repos_url': 'https://api.github.com/users/Vaelor/repos', 'events_url': 'https://api.github.com/users/Vaelor/events{/privacy}', 'received_events_url': 'https://api.github.com/users/Vaelor/received_events', 'type': 'User', 'site_admin': False, 'name': 'Christian', 'company': None, 'blog': None, 'location': 'Germany', 'email': None, 'hireable': None, 'bio': None, 'public_repos': 8, 'public_gists': 1, 'followers': 0, 'following': 0, 'created_at': '2014-02-14T09:33:22Z', 'updated_at': '2017-02-19T23:06:20Z'}, 'meetmangukiya': {'login': 'meetmangukiya', 'id': 7620533, 'avatar_url': 'https://avatars3.githubusercontent.com/u/7620533?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/meetmangukiya', 'html_url': 'https://github.com/meetmangukiya', 'followers_url': 'https://api.github.com/users/meetmangukiya/followers', 'following_url': 'https://api.github.com/users/meetmangukiya/following{/other_user}', 'gists_url': 'https://api.github.com/users/meetmangukiya/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/meetmangukiya/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/meetmangukiya/subscriptions', 'organizations_url': 'https://api.github.com/users/meetmangukiya/orgs', 'repos_url': 'https://api.github.com/users/meetmangukiya/repos', 'events_url': 'https://api.github.com/users/meetmangukiya/events{/privacy}', 'received_events_url': 'https://api.github.com/users/meetmangukiya/received_events', 'type': 'User', 'site_admin': False, 'name': 'Meet Mangukiya', 'company': None, 'blog': None, 'location': 'Mumbai,Maharashtra,India', 'email': 'meetmangukiya98@gmail.com', 'hireable': None, 'bio': None, 'public_repos': 50, 'public_gists': 3, 'followers': 10, 'following': 28, 'created_at': '2014-05-18T12:55:44Z', 'updated_at': '2017-03-12T10:35:51Z'}, 'membrive': {'login': 'membrive', 'id': 1939897, 'avatar_url': 'https://avatars3.githubusercontent.com/u/1939897?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/membrive', 'html_url': 'https://github.com/membrive', 'followers_url': 'https://api.github.com/users/membrive/followers', 'following_url': 'https://api.github.com/users/membrive/following{/other_user}', 'gists_url': 'https://api.github.com/users/membrive/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/membrive/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/membrive/subscriptions', 'organizations_url': 'https://api.github.com/users/membrive/orgs', 'repos_url': 'https://api.github.com/users/membrive/repos', 'events_url': 'https://api.github.com/users/membrive/events{/privacy}', 'received_events_url': 'https://api.github.com/users/membrive/received_events', 'type': 'User', 'site_admin': False, 'name': 'Fernando Membrive', 'company': None, 'blog': 'http://membrive.net', 'location': None, 'email': None, 'hireable': None, 'bio': None, 'public_repos': 7, 'public_gists': 1, 'followers': 6, 'following': 7, 'created_at': '2012-07-08T23:46:57Z', 'updated_at': '2017-02-17T07:52:11Z'}, 'jntn': {'login': 'jntn', 'id': 63196, 'avatar_url': 'https://avatars0.githubusercontent.com/u/63196?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/jntn', 'html_url': 'https://github.com/jntn', 'followers_url': 'https://api.github.com/users/jntn/followers', 'following_url': 'https://api.github.com/users/jntn/following{/other_user}', 'gists_url': 'https://api.github.com/users/jntn/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/jntn/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/jntn/subscriptions', 'organizations_url': 'https://api.github.com/users/jntn/orgs', 'repos_url': 'https://api.github.com/users/jntn/repos', 'events_url': 'https://api.github.com/users/jntn/events{/privacy}', 'received_events_url': 'https://api.github.com/users/jntn/received_events', 'type': 'User', 'site_admin': False, 'name': 'Jonatan', 'company': None, 'blog': 'http://jntn.se', 'location': 'Göteborg/Sweden', 'email': None, 'hireable': None, 'bio': None, 'public_repos': 19, 'public_gists': 2, 'followers': 9, 'following': 5, 'created_at': '2009-03-13T19:15:42Z', 'updated_at': '2017-03-02T09:08:45Z'}, 'shubhamchaudhary': {'login': 'shubhamchaudhary', 'id': 3508878, 'avatar_url': 'https://avatars2.githubusercontent.com/u/3508878?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/shubhamchaudhary', 'html_url': 'https://github.com/shubhamchaudhary', 'followers_url': 'https://api.github.com/users/shubhamchaudhary/followers', 'following_url': 'https://api.github.com/users/shubhamchaudhary/following{/other_user}', 'gists_url': 'https://api.github.com/users/shubhamchaudhary/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/shubhamchaudhary/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/shubhamchaudhary/subscriptions', 'organizations_url': 'https://api.github.com/users/shubhamchaudhary/orgs', 'repos_url': 'https://api.github.com/users/shubhamchaudhary/repos', 'events_url': 'https://api.github.com/users/shubhamchaudhary/events{/privacy}', 'received_events_url': 'https://api.github.com/users/shubhamchaudhary/received_events', 'type': 'User', 'site_admin': False, 'name': 'Shubham Chaudhary', 'company': 'Zomato', 'blog': 'http://shubham.chaudhary.xyz', 'location': 'India (UTC +5:30)', 'email': 'shubham@chaudhary.xyz', 'hireable': True, 'bio': 'Dev @Zomato', 'public_repos': 54, 'public_gists': 11, 'followers': 81, 'following': 54, 'created_at': '2013-02-08T07:03:49Z', 'updated_at': '2017-02-13T21:36:21Z'}, 'absolution': {'login': 'absolution', 'id': 907539, 'avatar_url': 'https://avatars3.githubusercontent.com/u/907539?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/absolution', 'html_url': 'https://github.com/absolution', 'followers_url': 'https://api.github.com/users/absolution/followers', 'following_url': 'https://api.github.com/users/absolution/following{/other_user}', 'gists_url': 'https://api.github.com/users/absolution/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/absolution/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/absolution/subscriptions', 'organizations_url': 'https://api.github.com/users/absolution/orgs', 'repos_url': 'https://api.github.com/users/absolution/repos', 'events_url': 'https://api.github.com/users/absolution/events{/privacy}', 'received_events_url': 'https://api.github.com/users/absolution/received_events', 'type': 'User', 'site_admin': False, 'name': None, 'company': None, 'blog': None, 'location': None, 'email': None, 'hireable': None, 'bio': None, 'public_repos': 9, 'public_gists': 14, 'followers': 6, 'following': 1, 'created_at': '2011-07-11T11:03:41Z', 'updated_at': '2017-03-05T10:00:45Z'}, 'donK23': {'login': 'donK23', 'id': 5880301, 'avatar_url': 'https://avatars3.githubusercontent.com/u/5880301?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/donK23', 'html_url': 'https://github.com/donK23', 'followers_url': 'https://api.github.com/users/donK23/followers', 'following_url': 'https://api.github.com/users/donK23/following{/other_user}', 'gists_url': 'https://api.github.com/users/donK23/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/donK23/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/donK23/subscriptions', 'organizations_url': 'https://api.github.com/users/donK23/orgs', 'repos_url': 'https://api.github.com/users/donK23/repos', 'events_url': 'https://api.github.com/users/donK23/events{/privacy}', 'received_events_url': 'https://api.github.com/users/donK23/received_events', 'type': 'User', 'site_admin': False, 'name': None, 'company': 'daten sensorium', 'blog': 'http://shed01.blogspot.co.at/', 'location': 'Austria', 'email': None, 'hireable': True, 'bio': None, 'public_repos': 8, 'public_gists': 0, 'followers': 4, 'following': 9, 'created_at': '2013-11-07T14:54:01Z', 'updated_at': '2017-03-11T10:11:42Z'}, 'FundacaoLemann': {'login': 'FundacaoLemann', 'id': 11183597, 'avatar_url': 'https://avatars2.githubusercontent.com/u/11183597?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/FundacaoLemann', 'html_url': 'https://github.com/FundacaoLemann', 'followers_url': 'https://api.github.com/users/FundacaoLemann/followers', 'following_url': 'https://api.github.com/users/FundacaoLemann/following{/other_user}', 'gists_url': 'https://api.github.com/users/FundacaoLemann/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/FundacaoLemann/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/FundacaoLemann/subscriptions', 'organizations_url': 'https://api.github.com/users/FundacaoLemann/orgs', 'repos_url': 'https://api.github.com/users/FundacaoLemann/repos', 'events_url': 'https://api.github.com/users/FundacaoLemann/events{/privacy}', 'received_events_url': 'https://api.github.com/users/FundacaoLemann/received_events', 'type': 'Organization', 'site_admin': False, 'name': 'Fundação Lemann', 'company': None, 'blog': 'http://www.fundacaolemann.org.br', 'location': 'Brazil', 'email': None, 'hireable': None, 'bio': None, 'public_repos': 9, 'public_gists': 0, 'followers': 0, 'following': 0, 'created_at': '2015-02-24T21:50:33Z', 'updated_at': '2017-01-19T11:37:14Z'}, 'zeyger': {'login': 'zeyger', 'id': 14151867, 'avatar_url': 'https://avatars3.githubusercontent.com/u/14151867?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/zeyger', 'html_url': 'https://github.com/zeyger', 'followers_url': 'https://api.github.com/users/zeyger/followers', 'following_url': 'https://api.github.com/users/zeyger/following{/other_user}', 'gists_url': 'https://api.github.com/users/zeyger/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/zeyger/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/zeyger/subscriptions', 'organizations_url': 'https://api.github.com/users/zeyger/orgs', 'repos_url': 'https://api.github.com/users/zeyger/repos', 'events_url': 'https://api.github.com/users/zeyger/events{/privacy}', 'received_events_url': 'https://api.github.com/users/zeyger/received_events', 'type': 'User', 'site_admin': False, 'name': None, 'company': None, 'blog': None, 'location': None, 'email': None, 'hireable': None, 'bio': None, 'public_repos': 4, 'public_gists': 0, 'followers': 0, 'following': 0, 'created_at': '2015-09-06T18:11:12Z', 'updated_at': '2017-02-21T06:01:36Z'}, 'tormich': {'login': 'tormich', 'id': 4050486, 'avatar_url': 'https://avatars3.githubusercontent.com/u/4050486?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/tormich', 'html_url': 'https://github.com/tormich', 'followers_url': 'https://api.github.com/users/tormich/followers', 'following_url': 'https://api.github.com/users/tormich/following{/other_user}', 'gists_url': 'https://api.github.com/users/tormich/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/tormich/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/tormich/subscriptions', 'organizations_url': 'https://api.github.com/users/tormich/orgs', 'repos_url': 'https://api.github.com/users/tormich/repos', 'events_url': 'https://api.github.com/users/tormich/events{/privacy}', 'received_events_url': 'https://api.github.com/users/tormich/received_events', 'type': 'User', 'site_admin': False, 'name': 'Artem', 'company': 'mPharma', 'blog': 'http://www.tormich.name', 'location': 'Israel', 'email': None, 'hireable': None, 'bio': None, 'public_repos': 12, 'public_gists': 0, 'followers': 0, 'following': 2, 'created_at': '2013-04-03T16:49:44Z', 'updated_at': '2017-03-03T12:27:29Z'}, 'ShashanKK123': {'login': 'ShashanKK123', 'id': 14154688, 'avatar_url': 'https://avatars3.githubusercontent.com/u/14154688?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/ShashanKK123', 'html_url': 'https://github.com/ShashanKK123', 'followers_url': 'https://api.github.com/users/ShashanKK123/followers', 'following_url': 'https://api.github.com/users/ShashanKK123/following{/other_user}', 'gists_url': 'https://api.github.com/users/ShashanKK123/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/ShashanKK123/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/ShashanKK123/subscriptions', 'organizations_url': 'https://api.github.com/users/ShashanKK123/orgs', 'repos_url': 'https://api.github.com/users/ShashanKK123/repos', 'events_url': 'https://api.github.com/users/ShashanKK123/events{/privacy}', 'received_events_url': 'https://api.github.com/users/ShashanKK123/received_events', 'type': 'User', 'site_admin': False, 'name': 'H Shashank Koppar', 'company': 'Rakuten', 'blog': 'https://about.me/koppar', 'location': 'Japan', 'email': 'koppar.shashank@gmail.com', 'hireable': None, 'bio': 'DevOps Engineer at Rakuten', 'public_repos': 5, 'public_gists': 0, 'followers': 0, 'following': 0, 'created_at': '2015-09-07T01:08:26Z', 'updated_at': '2017-03-04T05:32:07Z'}, 'tamarintech': {'login': 'tamarintech', 'id': 3681218, 'avatar_url': 'https://avatars1.githubusercontent.com/u/3681218?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/tamarintech', 'html_url': 'https://github.com/tamarintech', 'followers_url': 'https://api.github.com/users/tamarintech/followers', 'following_url': 'https://api.github.com/users/tamarintech/following{/other_user}', 'gists_url': 'https://api.github.com/users/tamarintech/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/tamarintech/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/tamarintech/subscriptions', 'organizations_url': 'https://api.github.com/users/tamarintech/orgs', 'repos_url': 'https://api.github.com/users/tamarintech/repos', 'events_url': 'https://api.github.com/users/tamarintech/events{/privacy}', 'received_events_url': 'https://api.github.com/users/tamarintech/received_events', 'type': 'User', 'site_admin': False, 'name': 'tamarin', 'company': 'TamarinTech', 'blog': 'https://tamarintech.com', 'location': None, 'email': None, 'hireable': None, 'bio': None, 'public_repos': 20, 'public_gists': 1, 'followers': 3, 'following': 8, 'created_at': '2013-02-23T20:36:42Z', 'updated_at': '2016-07-09T06:24:27Z'}, 'projecthabile': {'login': 'projecthabile', 'id': 25874736, 'avatar_url': 'https://avatars1.githubusercontent.com/u/25874736?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/projecthabile', 'html_url': 'https://github.com/projecthabile', 'followers_url': 'https://api.github.com/users/projecthabile/followers', 'following_url': 'https://api.github.com/users/projecthabile/following{/other_user}', 'gists_url': 'https://api.github.com/users/projecthabile/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/projecthabile/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/projecthabile/subscriptions', 'organizations_url': 'https://api.github.com/users/projecthabile/orgs', 'repos_url': 'https://api.github.com/users/projecthabile/repos', 'events_url': 'https://api.github.com/users/projecthabile/events{/privacy}', 'received_events_url': 'https://api.github.com/users/projecthabile/received_events', 'type': 'Organization', 'site_admin': False, 'name': None, 'company': None, 'blog': None, 'location': None, 'email': None, 'hireable': None, 'bio': None, 'public_repos': 1, 'public_gists': 0, 'followers': 0, 'following': 0, 'created_at': '2017-02-19T03:48:17Z', 'updated_at': '2017-02-19T03:48:17Z'}, 'RQstudio': {'login': 'RQstudio', 'id': 15797558, 'avatar_url': 'https://avatars0.githubusercontent.com/u/15797558?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/RQstudio', 'html_url': 'https://github.com/RQstudio', 'followers_url': 'https://api.github.com/users/RQstudio/followers', 'following_url': 'https://api.github.com/users/RQstudio/following{/other_user}', 'gists_url': 'https://api.github.com/users/RQstudio/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/RQstudio/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/RQstudio/subscriptions', 'organizations_url': 'https://api.github.com/users/RQstudio/orgs', 'repos_url': 'https://api.github.com/users/RQstudio/repos', 'events_url': 'https://api.github.com/users/RQstudio/events{/privacy}', 'received_events_url': 'https://api.github.com/users/RQstudio/received_events', 'type': 'User', 'site_admin': False, 'name': None, 'company': None, 'blog': None, 'location': None, 'email': None, 'hireable': None, 'bio': None, 'public_repos': 3, 'public_gists': 0, 'followers': 0, 'following': 0, 'created_at': '2015-11-11T09:17:43Z', 'updated_at': '2017-02-03T03:25:57Z'}, 'alexsavio': {'login': 'alexsavio', 'id': 2472076, 'avatar_url': 'https://avatars3.githubusercontent.com/u/2472076?v=3', 'gravatar_id': '', 'url': 'https://api.github.com/users/alexsavio', 'html_url': 'https://github.com/alexsavio', 'followers_url': 'https://api.github.com/users/alexsavio/followers', 'following_url': 'https://api.github.com/users/alexsavio/following{/other_user}', 'gists_url': 'https://api.github.com/users/alexsavio/gists{/gist_id}', 'starred_url': 'https://api.github.com/users/alexsavio/starred{/owner}{/repo}', 'subscriptions_url': 'https://api.github.com/users/alexsavio/subscriptions', 'organizations_url': 'https://api.github.com/users/alexsavio/orgs', 'repos_url': 'https://api.github.com/users/alexsavio/repos', 'events_url': 'https://api.github.com/users/alexsavio/events{/privacy}', 'received_events_url': 'https://api.github.com/users/alexsavio/received_events', 'type': 'User', 'site_admin': False, 'name': 'Alexandre M. S.', 'company': 'Klinikum rechts der Isar, Technische Universitaet Muenchen', 'blog': 'http://alexsavio.github.io/', 'location': 'Munich, Germany', 'email': None, 'hireable': True, 'bio': 'PhD in ML applied to brain MRI. Brain PET-MRI processing for research in a hospital. Software Carpentry instructor. EuroPython Society and ACPySS board member.', 'public_repos': 81, 'public_gists': 26, 'followers': 26, 'following': 19, 'created_at': '2012-10-02T13:45:29Z', 'updated_at': '2017-03-09T16:08:25Z'}}errbot-6.1.1+ds/tox.ini000066400000000000000000000005501355337103200147330ustar00rootroot00000000000000[tox] envlist = py36,py37,codestyle,pypi-lint skip_missing_interpreters = True [testenv] deps = mock pytest slackclient>=1.0.5,<2.0 commands = py.test [testenv:codestyle] deps = pycodestyle commands = pycodestyle errbot tests --ignore=E252 [testenv:pypi-lint] deps = docutils commands = python setup.py check --restructured --strict --metadata

㋾5c&^:m< ({NdFux|Aٰ ŀiSH:S{5aj`a*>-˖bMmEG`n2Xy1Z.Ửlx9\rnnOHA޶=(!DZÆ!&)M/?®6+4:׼k੧? k1H_y@Vx. )v3T(\p{@c΃VHHBVKoEu ѢFn#ll)XC+BT3BԊC?^ÿ6~&0 |~E2i?W^ưG  # Y?.M^V" P<|\gfnDc(cѼ![?sg ? v h7}ƢP4CgAV'lNB.?TP*M58\AމS(*çD+oش+|u13O?8uFvJl޲/[N¯# .4IM{S_GDt<C t+PWZkd*4y?@\BXdKWzp8\ٜpTWVS -PT<LMКeb&Z>3TD\R agԉPRDy[9c@ZߖDlƏ{-sqM6_&  @"@!HA[H+HKǴ9s|S|RU&2BQ ͹:j  B6(-&(s.= eLP?꯵ϕyP(++GbBLu!67uxo}3ͤwދ7k?8|X{&V[u q~M @ %$h1GM丄ip'g !؄Q{D(N)TE vP.V *ˆU5@YU*ZBCt(5Wʭz.Գ|!F; q=s+pLlT oHLO>ہdiQ?9y @W#"ڌr<$@" DRKűSPp"ljݍ8lPTBY+UP j.~Nժ44",, 9%"D.xsg~cbkgQYYony*A8͵%_<]2   <7'H`xUٓTj^] Ղ$D@}32XUUDR ?Z~M)5ШըxG_okrm->[T"_ GHHk$OVJKQtrMܛ^ @!@!u#!! DkeUeeۄW!=b(ʲ*̈́"bjeXvm5p\p8!ҁ0R"$%YAkQw%C{1W]>{  (drL$ {6C}tPx:]F+=kR:}K, A߾:ݦ`4_^đ?qO˰K7݁~y @'@!#$nG!d5n7v9:mB }etT$Ǝ{¹ )Kf#?ȌؾoNu te9NFln4⺡jM9hu:$2CZ+DR!thbaCXc|yPypX& 6(dTs$} 8 q]kJP8{P-x jTVUf.T yUN"[DjX,0G[ < >B> nȇ$@$@݇lk Rh/ٍ", ʨd(zBR(k׻z5[6SF6LlN -LzVOȺ/OAԢVyb|u'|8 O )1̙Æ4ˎHHH.3q@7" ׄ*TR 87rKo2/B22 Y[Ng#G BCCGpzTnܱշ/8+b!^ ky\)lú_/G l@\>|(Dtt"#[2\RR F MY *MŠaQK   (ds$Ѕ !P4AYP#ULj]Y ] ;}<~)f}G4pbC՝ŗކGtt$~q.='m !޳%%eE-?"3qЋeFd!c#[]5WVɽ^Kʐu&??<ڤPcMNЫg+^@, @'@!'#J@iLUhBr/yA׵IVUUa{`Mz'n5 qq=)P*RDnTϗWdr+X„Бc~)fk ^n+ӿ_o$'ĉL.SX)jZрm %娨0hQ.**f!օC$oWaqR6WIHH(.$sg|=pJ(="7vjtm!.c;P18 }[[UɃ#DG){z|h52ٓ#ʞ~v| fqV˝}0jd:^WExQ+=UOҋy:E"I=b%ŭH >LznWsQkR),? 6]&dZe&TWUK٢kQq ./7.ֽ 6"DRGY\   87 s3b  # B5%,Q\UPJ"R˩jy?NEK~mַwm}4Yf^?~#DI ,̝J{!\h4^By8{<2Zh)"xO#' Y8vZzu¸4?zL%C.n23 GNn>"?ٹһ+ *ʛx[&N gZ%!"qᔢ\$ϲZmXmäL lxV~`8YAUk̓HHH(d[ϊ5IVBMD3BoCЍ ϽjɄ%Ͻ1|X_q^ ׷]Ȗ6Nm³9EiW%27uL' Eee\V;`2Uj2\aZ꤀}szZRf1DQQ)*]*޵X#!B<2(35_v `HJ/lHHڋl{e$@F@3V > C tch n|cnp<|l?R{};f$klm=%u"-8Qu^We.L%Oբ * 91t9{ -20 tP1+hpOTP)rϨR!f"N_qf=*)7 ϬGFuL̴+֝#|罏}Zo\nhLvj;blY*<:Urh{**LX~3^5}NDS'>dHHHLB@ @[#gvS6NKRCB6ՙsӧf e-P)rOTШG^~!^Z_#_Glv IDAT4&_?~6M紶O#   (d5 @PMУѨ w3;(gIr}߷ܜGԷNòi؎VǻGR>"hUՈHn$dr_ZV<>"$NQQ+HHHڑl;e$@C@WÉj;\N URtYZ-xDd} u)M0"WއK4N'Gt4?| 0LK<ĚOWށ\c1aH?ڸèq1 !nD7HHH:lGf_$@~% .b_צO? V>xĊ%“gO_e.jX{RPBB|l6:VjRz_Px[?߅3grq=rz`}L$@$@$(d;} h B)ghOi.N2R“W׍"أIm~۲Ne><.[MH-q :њ-mCm·Ft"$Dn\s wPU]{bܘ+l   @! @p9_@l c2` ] HTI:N5hܗb]!Ŵ-xVZ5\hiH1+Dp}Y9r/!#gE!r֫Js>p6ڔXm^ ,$@AM DBy p ʪ9:QjaKnN+fBd)^]e9IʡP]]nݮ2>HHHsϑ@Đ JZN^. (:}9GDRb.;2W֎Qx.z(Vv8NHHH n6. t5jFdFfV6j,6XᢾB1]m*V&s "#BHL@VV -ns])z Uo! t3l9\nUU@Hx lW{e Js:TVVyiK$@$@$@HŁ8+Hgr*CUA,v`Q.M)Ji.DhhyK$@$@$@HB6g6 \+rҔ׶/1KeFȏS&}&GR*37j7HHHH  Pd gUu d-%?1(h Hp#+00Ҍˢ=oV \KXHHH ` P0 PnL@gGF 3?ߞNQuP LR0!LhM -v:2?ٲ-   h7]G tc#QaJP rWjN(R٨]Zs.ھbY[klHHHڏle$@D@UҮٜ |W); نF0.,$.2#BckWj\gʁ.& UUհ險{$@$@$@$<(dgh) yY!<[hrbRʭ2V3 ѺMY4Ae؂'0ljN#   m-)#jr/Y\V5Ě"VWƖbJJ~Q EIY^ -a$@$@$@ @! HB$ps:.h@Z;ħ/*/zYQS.55%Q @pl.8 t>!dJiL|c:Rm%RN    &@lp'hF!mx+*Up4~#Cd,A$@$@$@N ~ VPBaOonE.\ Ua`1&aH+$Pe6TU|_DEgJ"0nJHHHZIBXH bFK8NzX=!N}, ,+ͨkA l٧t9R)U",,C@шFhhyO _$  2H h*TF${?Ba1&eLSBل*TX`V\ =JRUZ#idda1RhRu4h"X˃HHHl 'hը@HDK_UMD1v*Xa2WFzUVزOp5Ɔ#,4T W!L(U(YQK*Rv2ѕB!    1 4H#HoJ(U.pEתDo$)\]TRvGBtj5[F¤WUJaTz^5*īG69aplHHHlN #7)v)4j^RiH(IH8DuuL(ZU~ JUUeX }X7WxUT^Ы*D(=VJ$@$@$@$E (TN.E$PD~~1ӷ0gr U(תZ:UC8Zv"R5L56XlV@UIDF`Ue&#TY[zGzbrHHHH@Hp\0()¥a WJUjbI*H$,^*}*__* ms$@$@$@$g -3P6G$H#~:"6WUB5:&F&UeVUxS>I@lEoNĐ 0tӖxvrl:\ mBn kUyHHHHHGhD+ic eػܿ*[VzszKXC>H5:d6&ro[} @s(d#>IJg@ਥ_؈<i^'>wjӆh9H^_xMlT7HHHHHH9͑:HOp_e MKN!\/>>!:Q)ql8 yk6 Zy/ 9Vn ũX]5iIHHHH+ :mw>l9VC;qL$cE1Nalr>.AZF\#kL#Cʐd!3wٶ @7' кb}=3{~~eHFA[l;X1S<z©¤hFl XlhZM[|*8 ` ]k^9e|H){^~{](Ƶ58jeZOyc#]÷GNk$0iX3d A`wX$ ^|r^VSXxxnz^sr5/y酱7t, H@$  <&% z(6>=< G3G㤼ǟL]=Ö~O vz( H@$  H_uxj/ 7!ǽAg7Vjmn(bmە"<=I~rR۶D?(^lm~!6.Zk$/ǎʝogI3Lsk+J@$  HP"kB`"yG>"gqRMזU?@UXKrF?JKAjm1l;q%SRR<K2?Hmg{Dn~1\:D,Xcqg4- H@$  rm-冪!d!RfJ[ƬlJ iXyOƮg5=z8WɈsV[9E548arǖl:*WYpLthK\ "&ҳ%  H@$ _U@;L*c:?H:b(bn׳/}{/F3{I,ߜeJnޢZ 6ޝp`Hgüy%X]$  H@?v'A6Վy9K0?gK.5-f]ENfGHf҉ MWq=m$  H@~E%"psn!vN`u´KU9n`ܟCvJ"/O!yG7:l(fBrbLN^5 H@$  Hw%\v \X'cU +%7oovq>ӹ1%}_1&n_+Vc1wκb+w1vQO_x_$  H@$K-0eۓ8~XϘ>\H*'};̓3ِ#qhr)˶2DMfߺF,#iSd_>scF$_K_9yM H@$  Hk~G^G+P$  H@$0Z<%  H@$  H7%y)Z$  H@X@O$  H@$ h H@$  H@`%<j^$  H@|P"뛗%  H@$  H`y H@$  H@M@o^$  H@$ P";%  H@$  H7%y)Z$  H@X@O$  H@$ h H@$  H@`%<j^$  H@|P"뛗%  H@$  H`y H@9& IDAT$  H@M@o^$  H@$ P";%  H@$  H7%y)Z$  H@X@Oߤ0mǔ;~6f{u窛I@$  H`X (ӯru@W$  H@Jd $  H@l&`+ew^N"#ya= X=N}YLY̖"F?qrЌϻim5zv/  n(VgVt T#/K'ӽ^9iuRD8Ʀsb7smztmU+bS99\dyۛuѹ$  H@١5_O\okE1}r7Fs3iAIغ]loK0uR8hքVƱLbXƄN&1uf4_XY ,s_e٤dƒ,UTnaJ,88u_#gs,C2ufryjۇ5n)[L 90ոGi[{5) H@$ (Cq֞>y;w5GN1wӽf-R&t+3F'3.ݵq'q=+Xl9?sI˵f[XM ~1-rG>U$  H@0 8nccēsWv-&o^:|i# )Oʼn?#\>֒l/2f~%ߵ,"e!q[(=\JT._1pD{>h:y'^%`<ϧfA%t'ɶRlRUt˝7mXMa{طY*I:s׳K@$  H` (S8ŬXG/Zy˗1D ͘Isx7=Nrۋucc[s^ ךp8vyYI˘$ΌKsGa*&$3`iʵ5my?3,Q#W tEя<sT_Q75r629t,Sl&gH*XJ@$  H` <==;vtStGly ʌ ^X @+t]4uQGc9uJ7.s6Л-6Kx@Go݊q:sGm%yf U8/oaJ WZDG` ,f uY_$  H@xr=]#$  H@$ !(Cpe H@$  H@Y@p}]$  H@P";'M]$  H@$0%  H@$  A%Cpe H@$  H@Y@p}]$  H@P";'M]$  H@$0%  H@$  A%Cpeߤ0mǔ;Q"U7Ҥ/rfvRiob%Z%  H@$0x^q[s9]s_.V@yoynϥtU$  H@F@찙j t8NWS H@$  @WPVy~fffXķ h=-g/gYtvKw)"s ϧӥb6.:ngEki[S{qo Vc vmf;9\5 1VS^ѷxJm]~J$  HP"_SL6fQ@<9GPaX^:|i# )Oʼn?#\>֒l/j!O~ Od>1nzEIy[3DL],145ᰕqb+6=1IL{͗8Zuuk*&$3`iʵ5m뮿y?3s! ٖ2n{ve*n Mѧ 9O'2t4{u#)=~۝-H$  H@VA3ulXy;w5փGN1wӽf-R&t+3F'3uo~gFők) [om0H]W], oۮuTHO˴hs1N K.ԉ$  H@ٺ5'2ٰnO5،ь-͗0t՗:&#:xyrN*iw{ J{ž54g L;J>zS߲+ H@$0X0Ni[yjx?_WvEO+X.5TXbXkp%s;Gop*kEo`X-)>,ß{\))۳_+ sm)KHɽLulZoZS$  H@?~s#ٲ?#֞ÉK#f<%ֳ\ǁdO;']-8^űln ]<hbilm==o+m潼OX`,Neʱzӌb$  H@$0POzL] H@$  H@5$  H@$ !%DvHM:+ H@$  H@Jd;  H@$  H@CJ@쐚.uV$  H@w@$  H@!5]$  H@$  ($  H@$  )%CjY H@$  H@xZYo7 =?f:3Xט̥]Sz ݋ʣX0򁻽_xrh8$gvLTZ$  H@$ 6|WvZ]Cn.Kz~of"0/1fOX$>IW$  H@$ 'O@7lDAy.]P8)<ױ餼d5SÖ.]$  H@4=#@^NA \?ͅD|D˥{MזU?@UXKrFn羔KAjm1l;q%Sz$ݪwpl<[r3XNͺ[D%}@~r@Ar:Gl432ؙHDP zX\\$  H@$ ^WZ d!RfYٔ68YYC uj3x2v=Kño,/JF|Ufʙ, ;D{6ǬMKyiz뢽^_wp =<r3[YÞn,]2wbZݓ$  H@P"/鱒@J٭X ;#\ھ8_p'݂=N})7Kb),;WRwUj1Gt00k}rzn|=w"xqtf͝EtVifwhk/Ip+fn91]k5FDw4]L$  H@S@ɣLQϹ9K^#{A "h.)(u6C9ͳ̛sNI$)9a묭#g+Iu;h졘0SFk-X6: KzyT$  H@$ເYTah.;Pr.\j[#>7FHWJnps+\=ӹ1F`up1YҺ(_Y9 p@ hmOf,Wa,N:Ϙ>\Ha;̝n0#3)5l˩3;g/~͟9[&39vsŷǚuQ$  H@K੟Qs_' H@$  H@LZLH@$  H@@Jd%R$  H@$  &%i6 H@$  H@W@lD $  H@$ $Dv0͆" H@$  H@ (H$  H@$0P_$  H@$  H_%)@$  H@4$  H@$  +D_"H@$  H@`P";fC}$  H@$ ~K H@$  H@LJdl/$  H@$Я~  H@$  H@Ie hŧ%g_ogq>*_j#(i_u߃UܿWsm0[oW;/| EwOw{@/ے)X?{a#K{4;SϟǏkO? Rz?v?gVMz_o_}OV?~q[߃o۸a5w?r6N{PϭqϷ?E{`2Nmփ2V{]R"뛗%  H@$  H`O$  H@$ h H@$  H@`%<j^$  H@|P"뛗%  H@$  H`y H@$  H@M@o^$  H@$ P";%  H@$  H7%y)Z$  H@X@O$  H@$ h H@$  H@`%<j^$  H@|P"0n L"˜_J:{sn;3u#yjokM}z<:uEog$-gg<+8gkN;8sH󞣒xz:?;/w9JIr叜u\{)]S[9{q;Vt( H@$  D]r Z2jhG t[Bb~k.4]g`0@hS?MwًIߘ˩[4# [\9utLya)o?뚈w])*H;x䨻Hze+Zx s|gfM1Ξval_j_S%4=9nG9h㊲GCZ$  H@a3Ճlm9*.j2}٩l"ț~;\vgAѯ37ܥDgKqlvBRNQV}·}%3qӇ/`D0oNK@$  H@<}ݖW_fPҖX6G9%qXoJa~Ԓx5^{\/byƳ㪸pW2c;kxjtV;NG0kS2KbG>qG$  H@@?ZHhcwsw'u?5ZŬqfNizҼ}F6h2OeuL0|)װ(gdm,3{4y1 <-;4:̼ەrf?q<DzܭN%  H@$0_G5pr!;EeN$oI^ȋͤG.d=t:v|yÙ4| s]sy؉0 e*N=,DR5+麲CܗNeSz H@$  H%h)89¢|mk$&{W̥Ug U5|g:+fulYG*ݙwHw8u}| U߁L:S p˙8} ۗۙe-448p2f[~ [\yMsGssGı>l9\yo?0Nw)X37XKμqݖ$  H@~Vd ?@Pf.d@[,3's"mwc%H2KcLY]I1UsXĚq2Uv|py3)bnt* H@$  <>~GϏ9$ H@$  H@eZe~*- H@$  H@Y@cWs$  H@$2?$  H@$ ,D19 H@$  H@eJdJK@$  H@cP"՜$  H@$  2%O%  H@$  H1 (}jN$  H@~_dvݾHrO蝔g,gۥ^݋Xqγbzv_QWL~p H@$  7ۀ5^ I;` E|/䐃#d!I~Z zS6T$  H@١<{ASxnG:=6~a%18 dLU:{Iʾ-$_U* H@$ !/DvO@ (pr4NJn !Q,LNf6eъKHB~dw^QqǬ#9ؖOYe?e48JH$$UIG9s+L՜4~E1}'Ry9|wG`Kd`a->/id$ܙ)8p2kS2K&t`6ɕG9 8c^ȅ'1W?;琽:$  H@pЊp buyo5ĶX^xnA'W[_0[_jBXb"ݫfKű]hV/8_r%Ѽ4gXee?+xWYȶ9g1+?'kB;n)[ڒϠ9057ϲ3ҒY㲘[+ Y 6- H@$  tP"Cg=Xxai2nc:%s+ykfb6GؑYIY}PR17Q[]E m{wx&2!~\%ub0Ɲ GQGEj# Z9/YKGjbBu8p=E%  H@$ S@[{Я@PxS'35:ɓP1*kMQI+m}SP\0ce czRW%  H@$ A,DvOΐZHa;̝n0#3)=xHc(o_1a/cv$cϗ#=Å)m[F+ H@$ 'M@+Oڌ8fC|[} )̛{;94 H@$  H]X@f4s/kϹ,lm:S4;5j]$  H@D@[D$  H@${( H@$  H@$JdD$  H@$Y% H@$  H@D@ uC$  H@P"띓$  H@$  H`($1hpE3ܶvj_s8 <rG{zn4 L{{ ,q+9ϻV6Zܽ K@$  H@ sd4OQ1lleۑ e|5$Y7Oס H@$  <Jdܹ}4#MѮc#y1SY354"{Ϛxo{|F~ ]3$=":/ltE4l@W)H$  H@F@[QRK^]C1! NJ1BRw8q6$q$-~ov;.f{rz6-gu[W'kYz}V ?Kښmp~{m?wJ]͓CivYFƘ˭bٶ93w6uF$  H@Jd}&ԕø)X쓽Gy]+β0mG3w 4my$3\]Ngd K1_ H@$  H (E|èw l]gEw*"KI;armm( ,KB柉+fUlxy>=0>l9Fci>ˇ[aw:qz+$TI$l̰s%FmeOκ$eȌn/rԝ'\ù}U$ H@$  t:MN9D-^W-06QQ݂%f,A4S.c^Hx[krr㛤|BXl<׽ƻ)F*qbv}wFH+f>7"gx"<_;Ʀ{IĮDvقK@$  H@ (Ul-Œejz{lGoq[WJ_ voxf-IL!?-ų~Ök(z0ւXRWD2ws ]q\v\sDwMU;Oۧ'Ym֧ۚ3 H@$  H(}4Ox-NjAHϺ_xd*4+cqp%f"?{\;ŷugk]uNvSa#բJۛc6VAXYmcG2޶b߶VgGiu+I϶m7n\^*;k{XmRN$  H@$ 36P{GwM?6u^]Av8WPkek n%k9Ƕ߮_JÔCjZ ɹb>?BcVryNo 5s]+cvn5IjR"kz"?[V2%߾YE` 73Y[;Mt$ H@$ G Oԣ*$  H@$  H@E@[ $  H@$ G%DQI H@$  H@x,Jd $  H@$ G%DQI H@$  H@x,Jd $  H@$ G%DQI H@$  H@x,Jd $  H@$ G%DQI H@$  H@x,Jd mQNZ\fv#˱auL{Գ{Q+4uƛ3yV\ރ#i"yOsc H@$  *? I;m-4,Ce3,:MZcqE=yoO5J@$  H` (8>{$#lVba:A3V SfЏ/Sקfo}}VG̯Ц$  H@PP";gml8Z=r4NJn !Q,LNf6eъKHB~dw^QqǬ#9ؖOYe?e48JH$$UIG9s+L՜4~E1}'Ry9|wG`Kd`a->/id$ܙ)8p2kS2K&t07ɕG9 8c^ȅ'1W纟N`rھl%B Zm7O H@$  ZկAuyo5Ķsw*yjƌfʙ, ]i)/Mw'i(&Ǔr{Ӛߞ&4qx u&te*.ҽikaƺ$Z^ѵf|-W"ͻI u[Vn/w,d!RWC-cV~6 Nք]ٸlY^ Ss,;#-5. `cJ_ nʨW8xlO$  H@a;ܚL #Mq`hf/8^I[T]38=Y;Whޏy**@`h+؛@D3L j{uέ$&cvں*߃\یbFx: ։w*hpJUGEb? Z9/YKGjbBu8p{ݚΞ; H@$  om-£=ёLĚstac"'3#)t7љsX"[DOguzN+A 4\$)ҿ!Q[DΊ֎$u߳ yɗFrvl{Il>M H@$  CICv<˩y"5`뵑&J_J7;n|Ke5+M3[\|F-\18s꥓[o~:TQe0ײڒz ?}K[)y \"V $  H@BZB5o&&྽V]@+jC3v ,?e+S/ߠgc?714ئH*!ĠV Ɗ$ZZZՊ_ lv` ->Շo4MZU D4#&;3<,ȇ<4s5)'*s0M.07u䟢KɮxȂ_rvE_6E)ap]zcHԨ$  H@ƔVdt5=ۈ+H66i}dfaVVd{6,~ +!J vBd'kǙDH\,aW})f+-8Qo='zZt2_ =k:3y3}K\p{s~'m^,*g`gD$  H@ZOw'? / #{k2ŗ2X rtG6:+2t[$  H@I@[lM4I?_ýk.sKg?d$  H@&O 58+U9dڽhDŽkh$  H@xZՄ$  H@$  j$  H@$ @9 H@$  H@9#g$  H@$ . IDAT H9(}jB$  H@FN@Y& H@$  H@x dl73/rDүtPk}y[jp;fv'Ɠ|ԁo>mk|r#RpSܠ:3ys;~[vzqvU$  H@Q}rH ;GAuTmasL[^Lm vĂXxuӖb#ciŇb^Nv~6[uy[S$  H@ d''`03ýIP10zw2LxZMӖQL~c7Yt-  h೹pbT$  H@&O:j)nu6MٵRgpk%FG ݓ{ 37Fזykk awY1ɱ[c7|g \'G$  H@uN<- EJ.Qw휶aۻKU)e7f~8P~{yGQqt8 VC6,Ea8}~륉l2fj51'$  H@hk8NZ /#.WWȖOMV(tށX%/h[֨e,ҔHW6j@%/m\Bo(+̑ D(sy\ >Bz+ H@$ P ;")kzDI"5\*&ھ. K~/N,NS,Fӭ꯺ԙ8{3iULkW9Џ2Y[ ymX+ldޯc_f.?2c:} H@$  H`Z$TdQUI}F9S潅g$N3<볜=Bbcscp<{3%  H@$ gg&TCivߘ_z4AfΝo1X~a6ML;nt?ճ|^TĽyh Cg&9QPg8sk *E{=Z$  H@xπtؚ en,La xkZ-{WّlIÙ5m ''ˏɥ$B8KO_JJ@$  H@?ni- H@$  H@s:#;D$  H@$ 4 H@$  H@s dܔC$  H@$0t& H@$  H@cN@옛uH$  H@P ;$  H@$  H` (sSI@$  H@` dQ$  H@$  9cnJPdE3/o_H/1]1Ǽ-5CO3I>>&gH\:6|]8R$  H@$0Ag[%>Ϣ&wxHke9_%}~.? uI,HZeȏ~OIY?'KR-F9( H@$ *@vso` {>c||c_̄'%|:r45h'7~?g(*%  H@$0>Ȏy{mW9u^l4-| ~[O|>HǶ3K=XI]b֐ oWmWh5|O i9q>&ԛ"s4[((,ko=woYK6EX1*1L OI#/%7wRn=-Y1kҬq\J9>Zדa,M V)4 Pzu_ܮ~g??+b?g"‹ \$  H@ztFBW88NqoNܡ<36p<j] 2EV!^-ec~ <*l(ˏdy|(kCi_?ƷZXe8+ .k|A>*Yr.{59*lɅC,wzG|[Ι(9•>\,)|a,5*3ڰBuI,sewYi%|d[V^XRESkj5[W$  H@SM%.y<%*|5j -:ŭa;W=Y'+:Wt-SY}d;>峴%)I)܉6aY+M$  H` (>![ӱ.4߫s0v*;ˮtl&Ξ-ѽƐ`үuԵ98کk!(LL§yQq@ޭ~$#BcSu74ی]Lf2`폩Vg,PuJ0G4! BFp Xv:Mf8][Vc;og4vP{R$  H@U@['cܾ! %|n0sd1@ޅ: OQLdWYL!)gw^I H@$  EcqVSƚBm%& r3ݥ琬Lj,L dL&zeŽ::=JqsYX~1fv۲mV='zZ]MBx|ͤ4%p=zG]zq"+7qO !i;(Y޵2GuLJ@$  H` ?n듺3 / #K|_/c{Ks}lt$Vdtm=~2H@$  H@G@+g&HO m~z^5N}M9N4 S$  H@J@{P=kfqVrJ~~E/?&\<>J@$  H`xԉՀ$  H@$  ꒀ$  H@$ QP ;j@$  H@FR@Hj. H@$  H@uN$  H@$  H`$Ȏꒀ$  H@$ QP ;㵁~ϼȁ~IA=m0ٝO~SRI\KuOqD;Ͻ>)AuZ;'tC$  H@ }H(u ;GuTmasL[^Lm ĂXxuӖbqkxGU- H@$ .@vo`( ;s cww)~_̈́'%ߴn>m|iZvUa94&$  H@$0QxHQKu!Go"ͮ>[[+)ذk{$[p=O[p8sotm~=16ϐv۹e:vW<"QA~ U]$  H@@}@)n|9s%vQ҇%E7b~[A>l{`*%fߌ}3so >*`{`sU||wv%h3e ۧΫߐ}e2#퓨$  H@ hkrNʦ /#ῺE|jZ xz<#'cL%K,/_[֨e,ҔHW6j@v/m\Bo(+̑ }wL4W$  H@$ P ;b) _#O}W1}͸wIXb)pMeqR?bg5nPӝXOr7g2;o}ׇEY -{rܽ-ڞb;GI$  H@H ()ɉ\g70w8qK/ces.p|& Yא99fě1Ydd5<֩!v~zp@.*[h+%>ԣSl}jG.%  H@$ gP L|*UROxϓ8r[8~ßG {5;~*'j.Y}D|2DQ\K>$  H@xvn"EǕ1hL͜;c ĺF9oT̴s&Kc\=EuL;=U!1R,^چ|f2JG]J@$  H@P ; ,e}.Hz^XRXAFՒgq%`Ɇ4Y_Sf )Q$  H@'Ͽ;NnJ@$  H@C  H@$  H@J@츚.uV$  H@3  H@$  H@J@츚.uV$  H@3  H@$  H@J@츚.uV$  H@3  H@$  H@J@츚.uV$  H@30xE e8.9R j1oKxO3$Ggw:oYV|+$  H@&LjO'`O㳨]ZY{rIK?]$$2knp`G2j H@$  Wuc}X8#`W6y5K7e&HǶ3K=XI]b֐ oWmWh5|O i9q>&ԛ"s4[((,ko=woYK6EX1*1L OI#/%7wRn=-Y1kҬo\J9>Zדa,M V)4 Pzu_ܮ~g??+btg3ʊbz$  H@ztFB 88NqoNܡ<36p<jw 2EV!^-ec~ <*l(ˏdy|(kCi_?ƷCjl-,ȲR@5s>m{Yp,E{νi ¡UPK;C- EJ.Q><^YNkT&g~aK" X ke{k;KvQ\F[GLȋ훮$  H@$ "'TS,QiMQX>9)lՀPw1/m\[]˸ªX \$|q'+'`>>]|Lu[?wz 5OVFuZv1vn}giyK SYyc}b 2bzji5X`LWW}yo̻ZvAV:$s'o`eLnz6& H@$ ,@vOPnOǺdk0&~bzة,NS,Fӭ꯺8]{|?a b]o}cH0cWݺKzԵq&o&Ӽ {+pj M ݸ po3vi6=}lLmu:cqSkzDY?-.G8,;Ko& v;;3e:$  H@$п]tC70Gff|Ű~e%Y c/dUabq|%^~v۩)GD&]O/e|`G6:+2N%K@$  H@cZ@+czz&b\ߥO^tԘX:{ADԘ%  H@$ >n|fge*O>hDŽ멽n6a H@$  ꔀ$  H@$ QQU$  H@$0 dGCUuJ@$  H@ (5ZU, H@$  H@!@v4TU$  H@$  QU$  H@$0P}! d6`Xa֞@t.|YٝK)9|xrT)M$&"~2=֠:=6~cx p\!:ñLI@$  H@(@_f3N{Ud|؜ wL&LbA ,sdӖ{u~^}0]zyu@H@$  H` (@=ܡ0ԽIP10zwdP}龎𤵄6ͧ-7XDh~0=<٤ꖀ$  H@ <#6tG-MϼMٵR"OΦZ+)ذy|l=õ~t`p&pվ awY1ɱ>+~? IDAT1}gTy H@$  A@wn|9s%vQ҇%E7-ik1?mkTQvo>S噹GwtGw=9PhO>l>d~;K;Β]T3i0p:zy8_L;@~S{Ե$  H@$4Z4j*e0u,Sj.3C?;p2T^beM^9%`ZB,Mdekb5,b9рV*>J~z쮛m 6s\_1ۗ1zSv' H@$ gP x*%0%F>h";ϟWXAy~.qgmߌ{%ݏFy=rSYEOYv[-_u3qtg2'L\(+e~O.w$pܵ8<ߛ,RV>$  H@$Zt*#}b;YEaZFs,z] ֑ZTY,[CaNG٥!v^:}o 4WpƎ>H@$  H@*@YU~hfNGc>$TdQUI}jF9S潅g$N3<=|ވZ6ъ?!S{TK H@$  Hww 诨Am:RxXe.l[g]$  H@P ;q~#ħYTwrؓK]}ՍXQC?/. vU$  H@P ;~70{+ |^q ǒCX|n]} OZKhKMA#X8<-N H@$   d_i|ރ0늳:pG*[LBXΧX9[Έtl;#܃Ե!(f yYpm_|nBCH|AUǨO;̡L+1tTɞ:EAa9o<ﵷٞ,%آIwɄ5QKϷ`^ftI =Pk]O73|[ul^@[ nW~G[RslbL}dX>Oړ$  H@_z?N!ipܡ|/w(e=8 ZgWw_"+|1Kj}GRhw}2,څP׏;!5dY)J n9rж,ܢ8sU. VAY.i't79C(\Œ"ʛzz֨LjÖ %EΕeA.vw?mװcP_b ܸR$  H@$0!";!}xn*Lte,QiMQXhqҔHW6j@6⭮!J%OKk_;qαz6%gIT?5spQCʨ]T.?w߷R峴%)I)܉anu> z/Jr`S4 H@$ %@vbSu$צaL&/^OeqR?bg5nP.CK]SGh͇@3~3 EG{JBM~8±-465Pw.LýU4m&3z huUワkCd= $,u&0q:aƒ<.DoWXZI9qƕc'H$  H@\@['`( !|n(s ĺ6sx:R*3Eqk(̉ ;[ZȺFuT}ک)GD&]OQit4p·Q,&v) M~_JvCDt|ZpElu35Z$  H@P ;fft,mi!F\\2{n '0]zz ´0JdWPnZMJ:ZܫӓǛ8sqZ,a&hGK1[[-눎Fn?s"1߯Ŧ2_ =y3% o9\Qޮ.Dc%ef6t=xcڕ$  H@@vѫxu8~2ŗ2X ttG6:+2W)$  H@Њ؟ CF[>wzE_Scc OÕ$  H@Þ&4AYŪR?~kˏ D$  H@5m-5ZU, H@$  H@!ţ:%  H@$  H`Ȏ*$  H@$ P ;S$  H@FM@Ѫb H@$  H@ :%  H@$  H`Ȏx73/rDүtPk}y[jp;fv'Ɠ|ԁo>mk|r#RpSܠ:3ys;y 3vy%yktЫ$  H@$٧{ ;GvTmasL[^Lm ֙ĂXxuӖb#͠>?;S3KX23١oG\J@$  Lp0}CYؙ^$LPD {"3*LxZ_Ӗ믮p^f~:皁W,2'jht&$  H@$0xLQKu!Go"ͮ>[[+)ذk{$[ ign⍮-ѯ'frfbc;Ln&UI=!,uu$S*}6Z$  H@@ dea sJpK(o[C;跆}RUJF;N;gA}T|CZ#?,8Kv@g|;ףtL= = R$  H@x*m-~*6zL` 2bji5X!M8e*/Yb~2&L\ߒF-cUTDVBݵ{hZ\}CYehNd/mvMÀ3䞈`{ft\HQ)8S0zP$  H@$ ( .0%>h}+H$TdQUI}F9S潅g$N3<㳜c|#0Zճ싣n+7$  H@S (}j:WNy~9jj^k-7P,}=0ybKU7r|b:;-Qlh爵Wir*|cVߩP$  H@`:#;FP`* i`k72}VQ.midF\ gG!' gT$:hCϐ8w/ዏ6$Bؓ==J@$  H` ?(Q%  H@$  H@cM@[ڌ?$  H@$0Ay( H@$  H@cM@XG$  H@P ;(%  H@$  H` (k3H@$  H@ dQ$  H@$  5cmF H@$  H@T@'TuRIGYM9AЭSs ﵷٞ,%آIwɄ;j)` ,ژ5iVozkkf.rj0qoK5@ip+>r` Ø! \ALxW^%  H@$ N!ipܡ|/w(e=8 ZgWFǯd{kjWK٘_%5 >|)?t;}2,څP׏;!5dY)J n9rж,ܢ=^rδ ۅr*(%ޱsJpK(,kOG+i̯6lP]Rı>\]dloamg.r5x uMv}a"B$  H@,g&|+h*LtD7ߒF-cŕg*KS"_@b^,ڸ滖qq=U$ܹiI`kz;N9VVO}|U,>);\?dAj\ѵLec|};<DǺĴe0,Sj.ԙ>ؿb[ߘw /사tֹ-fIJ9N4`'#:?j5\Oy_I3r~$  H@$ Y}~Wu$צaL&/^SYEOYv[-_uf3q&3ĺƐ`үuԵ98کk!(LL§yQU/пw_( 7Ǯ@ݍ0 6cW~Ӵ3Xfcj;TLώ^|5=p `d#DNF;)$b=7eL.%  H@$0x"BP:3umuUbgPv6½_ Yϱ;V>=tg`{Ԕ#"ή';o(׏ɟ)tvmPyF+k\2EspΕL ?Nvma{ՠٽ4Ǫ7&fb*K`~.L'nz H@$  ȎI]6ML;nrdչ]D0ya4Pځ~K8 xbuL4^pN15>Qit4p·Q,&vлYGA)슇,HSu_X[_9όG,J[tW$  H@O@~1[ xkZ{L,I#t궞Cc70m++̿2VSҟ$+&emc\K ݑ ۲mV='zZ]MBx|ͤ4%p=z]tُkX?0 5, H@$ N?ƎA:av?}qK~s;_:x]#?][ϯT H@$  H`l hEvlA-O;k颯1t !K@$  HÞ^)o48+U9ddaעۗT%  H@$0*Z<*T$  H@FK@[GKVJ@$  H@(VU* H@$  H@%@vdU$  H@$  QaU$  H@$0Z dGKVJ@$  H@(Rg ̋\rͼx^ϬDnϓMC .ļNq{h^.5NtV¿;RB)#7qxs1:2>Mba-r^#+Ȳ->Ԋ 9X|oPk}y[j>cq$  H@P p}uPõ>v`\>%^NnUMJ?$5Wr? !J+evu`OλX&#c`V$  H@jfFOG=^!dM]*?K{❔@rx6D^&8pVo0KRWe05, H@$0 dGEI@y.q SX44~_]B#zFc)~uzc]!6=JvnY-ky?|g[-ۡ-o]Rڹ_q0Xw=>wXko ,$k9ߦ#$F~6_Mv&F\)ʇUD.kW{|TUg] v ݾǫ#W&4c5 b[>f IDATY:ovٵMz@[Kl'@k%>6׺GEwg[pczER==&.5π;퟾e-ʉ,,էۮħݰףy=1xlٶp8sotmyvw=agqsƧW4ez )HL_:Sd['vWߥY^VN_xܺ|&OV~> % H@P ;,r:6p ;QLj)kQaGY~4/%rʳ)/OWi{Zd5-Xְ?(Z<:j"#C(Ϲ=`|t3jw6U \ FY5|TYB>{ S>%-fˡ<=};`5y++q#kz$s8 SD&Nm" JqV3n}q\9C9U?UYTJgA뷤ev~L[[ ۡt o09mUIb~[A>l{`*%fonn໮?R(,$xe5w<%u0;p?:r)'CػW]Ε_oؘsSy7WqVo꨺c.\'i:7|\#;;oz_"+|翇'j{,|~߶y#n9+Yr._9gZ?ݟerIÊk79C(\Œ"G\t' H@FR@Hju^fqdkNW@(< Sҳ|'閩9z{o@j$gØUqs?Ov+:sј߲b xi;ǎXX QX ǹςITJs]޵%~dsW}A|1xno{ZùW[ +,L-?z鬚REnN>G]A,v]vk3[?p,LC1-`q# e0g{Ǭx *TX8OrqU/Sn݁ oa=#Lbʌ9X;ywgoBˎc\ԗ]K #8 w]sӷ>ga;SxkZ܂ߒeaT4%fzXqoc M1OtGDŽgcFqÉ:AMB~cBkQ1"eWUqo@S٢)@Pj0SOh`1 b2C'k [ULbXN~{՛i73QwYӴ(^qZKځ=~+ٛܠXJ'uRW=%PZB2u8ޮA$ >Aq$*u\,۝ic8=U' ~&3 bGM~q'4`Eiqw?^GH17z`;(竱$p)m;jbzZq_V{OӺFXocqڥ҉kxy9;_w\;2O]oAzФ_NuLMhƂ/L gw8LR^|nAnXKj%VFR˒GFe$ӟhtyѯ2P۹X\I{,{}b@z% C LCO/6K9} `~$~1hӛ@7-wPa.f,`nj8WgO!ϣo>g3o7<Fb-TD,4?mo_Ehw~25dP|ZnυGr)" " " //ȼ`|'T[1 ǶY~g]V-EFp ?G_e(89Ed9)g=i^ʤ;CW?i@B.4cLfaxAw0FFzmz#f֢8@Sf[ ܑg#ϝ#qdYGõ=gq!׫.sr.s*Io@1&l7 2vOD_Sc7+U I8\{n7G;?}C%qlJ#a,aPx6ZEZr7fE42qHi*bgpTUzq=r6y"" " " /,-~iWK#iZ~@Lԕ^Ώ_dx1V/ߏbq_9z㜙(Z ΢Vz:_3 YCV|Y3h$~S񥻪MDzWwcmè-)w dJ2_x3j ~>~ިe^~!e=Vq'm6mzϙ߷r/۾'/##T;=-vTiwGvHwʹRm[lA^v30+bCꑷ" " " "2H 2 FCPM`jՎ 8١j<82Sgb.) >kXj'7,7,4\L}?p=/w=ϒjd" d"VM QK#õQ.XzS Y(^*qza{V4"79(O WρO!0<;X '%Nj^1Mm XM1IJʹguSH!Zm=k^W?O^ Z PNz&a U5{79[|= _ҩ-Rc\G!l]"ߓ]\GW N<}nfk/e:z^{.5d4% c<^cͺXJ [Y TzbݣL!UZ kȾf+.rԽ&wE@D@D@D@u^o3WCd<-5fNVLl>ʕ5'bejIZF®oYl+gK6ciE>a|??$.;&y1A7Zc01}~@ ہ5LJBm<4KvQTz_R'C2#{9Bv1KQ߷쁁7;h  d NОqZ'ۭyŦf_:KXl`IR.'x94g4k*9zz \ ,J x~F2܍IXLCzsnDJ T.oʦ17ϊ%b=}v/۾{ƽo,YLr-hL{z f\'BV?Oq*'vl vȭeWN43V%ņˤ'o#/G" " " " w_KEs%s3|?mi=/Rߑ@jO:ڨVӈ-(0g>J(f߱gcCؼ //)D@D@D@Deٗi"0Hف2yhHL3c6C,s]f* ~Rϓ,G) 9 "=fV{a{t;I zYSO0e7$YC̕'֕D@D@D@&,-c,= % 3j83" " " " " "0$c,= % NL| d'KE@D@D@D@D@D`B H ;S:#" " " " " _@ى?OC;7':(v)y˿V[My6R,GY@K=D/fj0w?׵2,7ҹ-wVZ3c~?(;D:o~堾LV#-عQie%i!>e^.%*gg ð {k6huLgٙ j\&eŧsj+V|Ez/r^GN=Mߐ~eE:e)ˋq$38Ǵ\寣rpKOm+f2+Nq/]Ր~Pt0?1r-y5zeJ$?}&W  d2?+eZypTr>]>dfBXk9xgeǪåDg}k`lzhk2-."j !=ilh?#{1m<=>Eo&meꟵ="¢;WK.cKgBHfO럣qoijp}[Fḥ߾{W Mci:3؞pXŧs|ϾiIg;S2!" " " T@NЁ}vݚQ/^Q̄Otn$\BimSC0O>nqBXTK3ݴ?)CJicl%'y9)ל'ױx-i`z unh7#{1U[ ~a˗&8=L`ܓܰ<^UwS۱9gcj#ࣀ@:*YW93j)iSLaYVWqZ!9Ⱦ#z8j|(aۦ<8^8T}LJ$%糘؅rmwdڋNVVOgx~4Ï Vh_4$s6CKS-5?3jpjìj3;TjD@D@D@D@tYZ,,r /pF;KD6xNƒady>=)̕)>ƕ:L<5@>7_*m%n%a+LBQR[Glx<* Zi3O7:ETQSS|C khlu%&AuCUΟGM}]vZiW{ =ydp-pŎYIۓfb)%ڴp"?-'H/daNh*Ci,( 3janvy>'>q%5Í8؍0QXZXZHVsӱ[}40#n= FvN9WZq?%wokOx/ uz Rx3-$C&K4tc-$" " " "R z&HMR@%")u#.!ej4(%aW|e/{oux+=v#[I4_<ɜ3$Si'NbޗPTwY8ŕD,gjp1#n+)UyON %_rA?X!p뗔9PIZL 8y:v;Sr"~L?+(] LЬg{Q9ydhcU1IyInOǔx2m$Gt阍 c{Z,ELK=+\'m: *Q1w. v&Ota\X@n! }20?m+ 3'5ޤrr# D@D@D@&{"tD "0zܰCbk#1,},-7$xv-v.㻛(Ϯ1R . K_"`$n2gG=U~ CE - K_Ɖ @vx$}G'" " " " " "0T@١"^D@D@D@D@D@D@iP d{;7':wG]`ohp&ҿ4¾&7TN<ފaWVfø:rTZE/7\y>=[K#֩@RێbZ?|x/" " " "0dFvBS\cB@op=#k2Lfl;pZW7Vtb7Qq]f-O _py{ܰ6s"cގM`^|?8vWZ f\W|_JSW#9,Fo"{ڰ\ wCkYyw ;-`%W^G3Y7^ֈkr]8:ayܵU{hs-f-L)`=˪.D9?y\^vjGX6c_HMp-vSMjP--gNMbK\bw׌>_p H +&8 IDATGP[9zɛm?g DFg=˖'IaJ".,j [ϑ:@p??߮dGs7a.~f6g;Q;jIBp2`Qrܹ쏊i#S_#S98N\NZӌ>&nP{?ku's`."]Oik1=5X!0?w!}c3TJ?cˑ>f|!a*}ɮY._I=*ʁ|O\.c>^Alz# )bvcD(޼qU>_@$Xe=o+3Q[ZfR30R>Sg}H(BL Y!{RPWzCJі6so+1 L˦%Sh<ߪԎڞɬɲ (lKgw}^>^-}ٵ@{HlcBE[c3QkHU3Iq+s] 4=uSk_\BȾBZHEz`hAS0S]7zZi2_;A^ XeT[S3݆P/ qmPIYOĤ) WTq?լ(zmb>itr\iˁkc 8$N &_|w19z$LgBw5aQf1Ufү3saNr.SM؇c9.` QHC` XZŔ8Kȕ* KG呇^7^әG1saSf9Ώ OmU "&oTqd}s5_g3/"~(Zk)Kʾ jd~>ZC|! c:\6e%CЩ/VUe_2-0pdol&v~-m}`q>aK靇jf߭oGp-SV0%`t4C;>v8s2Sˡ*Y01S<ڋ)}?{U1s<MJs\ߣ g=b>RNvwGX^" " " " ">"$DJ VɛYS!)uKbj5(k/4q3IYp,ɤ4;B6 X9щ60|`u럔v$m&H|nmr^QuH2"%K6]5M(RaX_b%$TH]V v` }){ڞ]!shF"☡ֶ͚Ί_ͮ4'~r5('N}FR6:OÚa@`L(bRCfȢ0y WsvpŸoSg]MW2G!pD\^ `Q/OȮtwMN- E`" 8JŞEQ,D@D@D@D@D@^}FIlzZ|#uϦ )UD@D@D@D@D H MėD@?W D◤Lxdi+& 3/ۈI{E@D@D@D@D@D@HE@D@D@D@D@De@e1iȾlȾl#&W\@W0z\ܜ[pPj[M6o''E; ?Qϋ,r:$F<,k`zRU^o~]Q9B^_?B-" " " " "|T+NxSMA 3ɱ`i!eJ=xl夯]\1駚=\,8nzsݝvĻo.ҢoA| 8[=ìɾ{[uq5@BA/`ߥ{K d&\ZNֆފNUy?F_j6rzUkm{*QMG;{2X\̋:3v91ok1zǥ D@D@D@D@DS@YO [@m%oXƿ_.>,m|ֳl}4J4+lY5Wb=#HSf 1ђݣ2CTmFQ4|FyXw6ٿPӈ> *mDkɲyC䯆s9q0K7Ţ}˿A;z֥Wbʑ㇩v'K ش@?7ry 5r&3M+Vjb{?(+;LYN\v_Fo&NΝj*,+ 0˕t$g a qg a)_Z] ׌wWm%7>78 c{F"^#:QR/9XCJ@!$.[%wE@D@D@D@D`$#;A` FhM;?)>,fIt@rJiփC7~Vve`/ gTK3ݴ?)K$ԦxoN|w !p}XY*5uZˉװ5~ -ՇcxDܗ`$-}fyǸCvG'u](jZd"Ӄ7C`4z aSmPגe`}6%~|ܓ+S&F!>N Vh2%x4@jW)9E@D@D@D@D`$YZ<7yZn?@> ZъrufM*j3WK sLBUՑg/I(߉q66k;]~mX_HFq5 {z*z6d (l;{m$$Ak8~ƛ GoBo/c-m<kc^,çԤ$$p5ʤBkL4#!#MHMUowPjy#" " " " O&~"$<o9ފӔWV{7e=?3tSD@D@D@DoLEc 8tYj8XT| b{[/wOpu> " " " "$rjI"vo$2si+Qi/wl0-ɧ秃^ s ^x<YZxnKD@D@D@D@D@D9 H Z@$s@9K" " " " " " ' I.$ s)uԑjQjR6x+COqM#-pc=%Ru7SyF썝љ{Pl0\[޻gY߀mvxz>= \}^?;77'oGPQCG1'rrkپd:lO;f,YbxKx dEM6̜n&tMUIQ_ZH+ScV)BHYP^R'$d" " " " / ۽29c1uTq:e[ScX7pOlVKE%yRյg%kcjUM쫷-ڰV/ ׫/S;9W ش|1q4%ԽT4/>?}Țʭ[!,pQLzJ`y.˵5QZOusj2\wkiTԈ9ڹؤ$m"SrJ*(EVAREwMC䯀l~i {|i,+*G 8idOz4;Aa$L.E;CaV"[̆P"Yqu59Rvoә 5-|!v<]m=etǤQz0Ytdvjs s}aZFJj bZD@D@D@D@@@'{e*3Y7뜳u-XfQ/Z<~J(g~3 7wYl_ś "l-9f}Iނ)zqU5ϳk `4yVp%((\2?r2Kf8^ǿ6[,cp-1zOP,k^΁6z_cLJv;Uyևo"ĤIB]鵁^N}n7-|9E r!" " " " O {"$ N$ƲXxJ~3.8:.CNZ4v$8=Gt/d暜Ao>L(-()F,}'|5[8՝#E\m'ZuV s 1btǴAuɍƼK asv,Lwd:!|YaƸsg Oc RqMwhx9uK ! p#ޣ,K}Y̒bZ:`L{c4:58+ y͊ebL}seo˪â(^Cw'PQ3ڣLRn+?R&mۥK T)M36dkZycd>SϙJx ?r`vXO VmMum1L1'jk_\XHK.ƬHF4n)ME< R9P"9<$pV@#!Bp/Uhw|nSFmO|s^ tV*UGLxp`|J,%fu97j;g™1d_7&oT-=jq>K^" " " " ",$}lCYCIP-uܰڱubQ';C( qd\RL|"װPOnYnXi -h?Kk,ג ZUKwP>{<-<cG- G`qj(O5fm7ˢ{Yٳ"Y8*8쿢h>5\o|5>B`xJsiўu< Vыv3Vi}`Zˁ#`rܲ>毋U!l]"Ӥr߾'m3Nz&a U5{hݍ" " " " ">!૔x*`'LJ52MG$_;Υc`&i_TCWMg-rɳJ SYv&o&iw~  "5c4g3Ϥ$-HJS*KsdK%%;^}>$3{NJ@[ IDATP^Kp/dgPl~>}+f'ǩر%#֗]9zX]Hv<6O;{ث܏`J`%M?u92)Y13H}6KSpG=ȅ<nS(G\9_uzC+l+M9/j`vh"y/" " " " OA@fd!GYF;hg 15B?[W\@W ְ'{ϵ#w{kr * K'JD@D@D@D@D@D` ȌDYLP d'JD@D@D@D@D@D` H ;QGV%" " " " " T@ :-Nԑ~@vtKD@D@D@D@D@&ud_" " " " " "0A$+* D٧ޯ^*7$0/z8hG}>ok7`{m<@enfiڊo .7[fy66=z?'l5|}iX|''+Y_UdG留ko|I ~neWC|exs;vG"?"#]MZRoћkțKWi<% d\Z72|8=HIU\bVU|Qr )&̬DRX۷ !2VZpX/; =RY$d{4{Q ,MK')}(~Y2Z* ?7}JfaM]1Ͽ~ qʂ"o$ٞTVB/r:З]LGusjgfV/\ue}D}ȌK'tAV:=Fף\8)+໪V֊/i8nV-q-&t勂Jw<lMJ|8y&" " " " ]@>/_r?IwV8{ղk_EH-F_8+6]\1fr!Ǘ4wDĄ_%P x~PgrI-2`(97l0tfox$J;Wk͜+fado*s=Z|c+GKv F/ߝnp1Idt6N+ dx.x g^.|0Bc'k+Hſc¼T ?agY'(3W0כ9+_>ŗU797&/ʍc _ѪFNcqNlTK_u_5= 8'Q=3W2>xn/veKi,gj6jS-}U9̏+_j4S^D@D@D@D H \_3cY%&}QzPY=1w2wb Ka{z4YYسo93F-8nqͧeT摐USXK躭laLnN}vnY.je䈾OU!`ySX_89THzs/o׉Tsޖ4lf9|Wٳw'J݇riA6#^CBN=e1eR#O8gocy0œSGhBЦM-jz[YsJ,Lb~ޗGzCeRi8 /'" " " "$}^/qLJ#;B#gfaZx<cL>Fœb !ȷ KBvs,Zt5kޡQr[ٟ~b[a1U.$7Pf/O5GURQ , G3.Xevr'cdWDs<ѹܺ4떜ȧv/Ҧ %Եx|3iיF˙]Suٖ*89V^mvr1=j'iw\\QiWP g>sbF=U/-׳6ٴfe'ϼr-" " " "\$}F{qܩJUIc㈶Lv S fVR 3qƿq6^dqzӗX //B7G(X{eoߐ}5<Æ>&;8f2ZIasf]7 mEŁ k!1{1ǐv$G;7ncw{`9tlH¬LRLm66N$SwdWء,K/c[Ə'd%OMJI>;]e6asB0ZrYnXys׷RS[#q|L EցÞ(P?c|ˊ9\v,ze qΘ܁Y!͙1;UEU`l^:w8˸{XUx v^,7\?ۥ]kCv6RkX׭fVi˹C?Oe,kY=%W,1UcŗToj |7*PVU^_FڱNmyÞ(M߸́?ؘAm g./_ROE}-;7fnPW[Q ]2Cٴd2+p(yvSkdȦ7~aؙ`&x]ϗhc񘎓׿hDi6cߝw:T|IfؑGc$+ORk3s_ OHTK7f}'D@D@D@D@^H}!e6gA:?fGvVTrcDujD` JF_(Uǎ'MĂV8r1Fo- ~ `4&3d_ ۈ/m#(`yPVZk7{hc:ykcL;eWG/{u#-2"GbȾb"p- k*" " " " " " H"u4GD@D@D@D@D@D$}d+& +!Ⱥ" " " " " " $XHsD@D@D@D@D@D@\ H"GbȾb"p- k*" " " " " " H"u4GD@D@D@D@D@D$}dhc#YiDEF`q4 >Pȣ=k+?Ws<…k]XFc2]˂ohT+>Ϛş]*?%2nԧTWG <…NcKx\dnʻy8nȠ燮ƩuVZYs^(" " " "0$ b&induR:EGZMkeczt_ǐZIefv~&.N\ĵ۷ڐvVPꚮ&m/&m8߷G?$b 0k$u'{/I"zC" T1W 屚S{aC .S.R•/si^1ry̅|nY#lU ꓔ<ɘ,wp/ϼCM9^TV[emluౘo)(cdJ?asrՕ4v#vS_1?9U31թts `3h7`[x9V<鶌{J2x*Īu%M&uoBͽXg>ԶQ3w!)viuCj݈-,(Y:wmTVsWN6.]=4YY4jTlm˫MMD#4o4p^=@t^@?q/=-5_-Ŷıje:~VX`B;y|,LIIdd?{КY!sm;jz:vw!Tֹ(ڑlG^]l4`K _&dNeZw#NxdcW@pr(\*͘43ut4hC_<|Nʮd& [)Tz.fрvy2X@fF%t>q۲1Τ[9OX2mڅ|4kX4=Y7(/JeYD@D@D@DuDuݗ>b]Tf'qw&_^~B`W< b1ΓqUb K,;7&] ܩϗ0@O}NlN" {MfPU8}?r1049:m[FMd:poe)ćA)ՙ VRskV"Ҫ &>/|Wrw6cI~ӝ:n߰(ʎ\1NC /,M?͟tsCy䟤2\,wD'" " " " " ": ȈԛLId@'K" " " " " ": H":"" " " " " S@@)NȾN)Dv t(" " " " " $SoJ," " " " " "0$,!$ ԛLId@'K" " " " " ": H":"" " " " " S@@)NȾN{bl<+h,fAԧJys5/@c uaqU dQШV}5?NWNt 9qF~%ڂNЀg] Ec?*-̀_>[û)YÐE@D@D@D@tlpw5I?p'):NZoZ>'.hZu 4y ?މK=b-z2/Bѱ%]I\V˱;p$!]4yO`v7U9AKdk&Y<o&مXͩxDp!^iDgR[vh/Z]{4Ic_=BF6)R%:4?%ұ~l[Bٛ^V:P|71~^NL{O 6n+lJ:Ņ4"~ZÚ}mY?jK5| M#ϠSf!bIنV;fYbz{mx곱 fkSOMIK_}ttx'$c( d4{:gtqYu_: VzQ|qj#y!" " " "0e)/~Lj8E ͽXgiN3w!)viuC[:ݝ,,(~+3m+u~C7Ѩɛmb{9٘ں0[wyRbgۦ|59LCnIJt>٘FxsdҪIPCj|ll&o>gf(|>Fe&^C-9}f0ќ^&9kJmS 1bTDV=Lq _zQ Nmj(1N6狲TJ5iXƛD>z " " " " SU@O՞ָĄTTiT՜:wGM4ziu-˯kFeNe /:& 4OOIsiq}1zcTm|WF9I`v?Mh=:+ oukj8վ7Y:}VO}v3B7JhFI `novլֶN5qǴ}DIDK?Ve iIHnfnl8#[Hu," " " S\@)~L>i|` $lɦR-PA\FlScG$jS˭ZZ3 !\Ҿo_JsI܂2zTx kg-7Y&zVxs W򠭚)e1w''#Vh .s ynۘʮy ,hp~ tԖ`H6z+`9;PsCLLc )m|Eo8e=݃7Zh0dcZu+4vvq\I_lԟQ ڒZ9#GV+kD@D@D@D@L-"RG_NA~9)Q ÒįdvQľc$o< '˓(K 4BȺ0wϗlm EB|>Vgdsas4 V ={h.\ _,sdR=mW>\mj}Ҝ_>FmHeNw^fzXl#J" " " H"Gö+A 2 SCLxlZY;wdk'םL*$8U619-SU--޽TcG%Z2wuǜP12s]h^g%ɆrC(8JQ'?egR6l[STYmO@: 3{b(y ӸUKuV5?`]e /c{ջnDm !#|fgq<! 8C=&:fc݁h{A65r`{.$(1hkJ}7QYGz:($IM53YMi7&13mZ{~:r 9\6OX5Io+lȩWJ8]~-2cYǹ?֛W)Ş<"X7ss]BJ?嫠A [k7EoWvsal+G}.<<uKKs!Rϣp.$pRY%SUTe̡sJWpcuqwW&~L&ۡymP;'I0\Go8H{㏄;1S>:*9<,uLnrնiB"#S.H)hd]Q!eEkqdj8(!0{mU~))B.ʛo0[C#dۦ+O%Uٔ4|L I/[;}iM[ܫj?'+ur;l5O'~U9/ݷ Ԏ=[i9wYH^ludt[sؓRyo;E'#'6趂`%bصvlcvZy-n؆}%>驾PGlM|^6+轼맣 _e7<3rԏwOL gj&+;*Lr 7`% YJb(V-F_q:c 5tύbhSgkb\[CKyi+[ڿC{  )Jتq #Ոsy_İ1Ŀ}mß] QߋaӀ6aVP>UHIc,^}>.TXOAu0 VL&jEΛ,w\}&|L3㣝,z>3uc1 bSU l^t5W?Ύmu6WI$n[N4CY,_6AZ;.Ɂ'ƭ#~y񏐵" " " $"/,mdB|j @g^3ѴmXڧf- ':Gѯ+sdn>g"4+o'Qۨ0fZ}LcI, bEٝ7\uo(N{Ҡ8e5@;4}z^F|ySrC fV\Fc6:FTb.ʺ|H GU:$4VsuŞć9mHrt1zgxjx˥ ɒorb7p8CF0v:%*vr4&<:% `؈sy 9ĥЅ,Yc?(pp}>okS &/>:AF*6f+f=$ Q_5ƩOum7ltGKK-7YC\|4nqRD@D@\#;;}!? sاTj?2@g -=өq}tοB t3QZtd~çjTWrK X*);X^~FM뉵g}lqӡqRRՋA}UHsB18|Z[(wG뎵.A7ՏȺ鮥 f#''ϵO|#G@bBh*󛺏jNsN/zw|yCOZ~]_8x-!l+u]0+d\H{9{NVE]QGims\wkdmyT7~_P[5@{dn ry4 ljۜvN3Y>_]'j7`U>,isc_қB JfHf=O_?SUG{Ov|>hCc(~! Z\&iܸ!ȬVki =E4itwx !C{n>G؇l溝Oiↆ^nbT,IY4v>ͽI~f(4VV> m}}G )(#ODŽ] aK6jPυ 2.OfڟX@fFΓ~kh:W[MEBҪY:ܟPVϺ٪ D2p-͡(C?ߥM=lcBoFAQW/mjVŅ*ȃ|VqgxLٕ_Vf^۳j`?3$P٩*牚: GoT--m\墋Dnk7bw ˩OkKb_\=y_H,)̖ڮsU:'1{}AR9OWΒs8P4ts2A-˜ˑG9^ytm %$s9۝eg1#ӱ<2O9|O U e=x/#&^`~b2WgNO=Otcυ'Q *}Z5?BWIDATG%}?X!D7H#D@D@D5װS%$xULzJ*Y8$VƊ{䤭$ NR¶zUcv|Id_("0.7p)KSSYQHm:#;?f>DezE@.=)qO0E@D@D@D@D@DuDuICD@D@D@D@D@$S%Lx]$}]zR)" h SD@D@D@D@D@^^b6OIENDB`errbot-6.1.1+ds/docs/imgs/slack.png000066400000000000000000002706041355337103200171230ustar00rootroot00000000000000PNG  IHDRdu IDATx |T՝Ͻ3 ya2<4bl <bª-El -.h VUwZ.кVKDÈ!2!I O;-ιsz{98H@$  H@$  H@GxV'%  H@$  H@$ W@4$  H@$  H@8ҎIU$  H@$  H@$@ H@$  H@$  'Aix&XNq(^^jM8؉`qL˶H l;e%ö,N ˜<8Ciٶ|XXGO$  H@$  H@@+ZHKn4,7 Xql'fX89+ea&p@"A2f$ lqDu1wvmǴqot`Z+]* H@$  H@$pVN&e1bLJeq,E"na&eX6q839exC-sL~Z4X13n3,ǔ}g۷j߾kL-I@$  H@$  hU 2S.&Z8TL0 LƎ&;qLLȌ7 )nphןNq's4佻_dRq'v`t:$  H@$  H@IhUa2:hf ylmS.͊hYr[n Iq< lkSV +=7_'M6%bnc1YnN-SVHFN$  H@$  H@ 9Y.4O1+vP=v:sφ`.3dQo\8dw>˞=ǢNSit&cwjI@$  H@$  \VMuaif}xE3a{)}S~KKl1A,s ά4⎃c[t?j{HHЙXbj&ucs8v+%w=R^x!,J +"<)cﲰ ̺ps%k͇}i"1v%lwSѓхs.Vxg OMA<Έofūm;g #ukL~o9mcO]>:u?/J;UM\<,Sfxe~tkTZv k?I`]&.rKN^j_>#xhǑ J.]sH.K@$  H@$ LU4]ܻ3Ӥi6Iw.T|Ld $f933!4UY e'<~lOu&0k]L 62YiflzvF30ϣ(7o7<}ǿ7| R%CʃG56ܯL(o-/oydF?aR@tuoQCݖdɛ q!nSo ^ņa;X}>Vy٩I ?CW(X{u8$د~\zAG<Ĩ.kד9xJ:1@Z%Ĩ,+cw@EH@$  H@$ LU41+gfvt70f/a؞tbN=ӄ소4:kfshXix3;Pcuadn~ɞj?? e]Yߧ0+Jh  pgVSK2z AzBnm__v 5vu; ؙɌs̵ǣ%!l~};+H Qsu"MU*o*>Qmo,D2r8 >TlOZt8lt{:B͞JRߓs/Lˌp9dfVg |jS)o/w7IGƇc>:y=0 3bK9p[vgבPNcۆ 2 IʔfGB H@$  H@$ *XfLȲS:N+ܬDz&U0CӜcx̔O<vt?!Q!هbCT'kb pfB|9=1 8f lf|['d<`1ԙ`>٘9'vƩz%A NvGʹA B;]y28PדkGz+쏶P>ܛA Kzkzuӯ!,H'Xacp&]f4t}ڭa _#ɁЁ$  H@$  H@ *fhN,\(/osQ^tux fc {p^;(tA|?vz&*>8Fk(nC/:!-3jΤgM:D6N)w`C, ̃G$  H@$  |~ZH[1[o&;#`gpv?^}ht:1MQ7g1b1NXb^Y^ @ 'S;^npͬvܻv8ay7 } Y_hLcC+;O.Bk/a~OFW R@A_ߡt:5 9._sgža/,͠s~cX5!6_AN%CV6r_#}‡ qN?9MY>5wJe$  H@$  Hs(`9fDNrL' ׊߻=BFd9qK'쏚Nj"B<;MZNfgݫ /"x7H&wtgXq<e;KN~O:]$ H@$  H@$pdVe90x%HNϤ]F6N6u.:N ެU$v^26^< ++LG1~%H,O>=Ϥvn<؎C]u5Nx6I +GNs"i-ryIՠ$  H@$  H@TZH<8qD88zLZXzvGj6uu12;|>VǓכMIǎC奾&L"R'#DLݴj-m _DNi۾`«8=;?Ugu& H@$  H@$p *f)p7LıVЮ}>XvrK͎gt61WӮC{[ 8ž}kVvgpkYԆɶm<ٝDJXx=٩$  H@$  H@N@if&ǡ> UB:IVqHX l'ߩś'QsHDn7V3' bax=8b@MKa̜XV6;TSa H@$  H@$ZH3ff۶TQ78'뼾d9ī+p 35 eLHOsۋ%3+N#UV WaWWС} Y-'c{qlkOT㗀$  H@$  H@8% %x,+=gPQۏ?- gOk6<vҼa6$/ZG}8Q'=dXrilTN4=%PjT$  H@$  Hm *f#Xx&= T_f{o[G|ǩO,RGrzdX s}}=œOD$  H@$  HEVe9n 7Kq[JA8 |8i^w#/1" ;cv]gz7 ̰jѺz'ژz;HCX41˯?FN/!sXIn`r^'tz Iw+ H@$  H@>cVL͘&a& %Sxi3m^^ˋ?-vڑ.H=ve_CS.w#F$'cw :=8E, HfÝe3}hGjOF A6ffR4r#z;7`\:2&/f^xA&2׎dRvo]Po0y+=j.P֊f6FM'|d]^C0a\ԋX2fMmXhҸR7Ǒ2L'V34bݩf7;W>4\:j]NwNjs``jf)Vw/e݋ndY\>gu!^WB޹K@$  H@$ *Fe6Lt,l6Nz'K;Q@ :*ю '=]x:3 25_s H?G,M߯O=>'8`[Eʬ׹{VQ6;({w|\n.,kD3G,)wNeB@Lt2?ǯ?;5 w3L/(c޴GD2A+xlz1_I駱~ nbRQ% g1Fd=Ip̭ zaMr?7<9{䗽]RX-H9︙\ͺ(K i~ńbT舟+`OLAF|𪥔7,){a\Ac(lڗX+dD^2,- 2?!Y%<|Y_?%  H@$  H@QZFm45M Jępl⎍@S*ٟx Ҽ~3gv ]62fdag#=#ix=>pp8&x7;) s f3aw)?|~}mtիc&Ƀ2jnn[ZO`/5:Y-rG> }=Qs )&ky)Чk1CXܷEǶ/3ڇعA^^M6ZY|,0iJ oMt[:DKdfA4 NE-+7Q`WrVCd}iV((8V 197UP< v.U!C,Y1ȼkheGIJ@$  H@$ hU @XDI-M̤a#.ݵ05L89qHP\új;q3 <& d^N̍h8XծUq״)1XU O.xk(ML 򏷫,OkK))\uKZxZgr_0i)mDC:=S<1f1eƿ[rEʇyrgxs?K!OA~~5[wWuB~mDqf>`0u-fDД!wDjp|ɣ!:Ց$  H@$  H@M,[aa[Xi&! l^/55Գzl,ƟfDbY>l+c^,+;bL)7u,wJi+F>7qRVQ0W|L%3UmB+m)@lG/Ĥ(sc 񅃖T;z+)5 VxZd)g!I)j.u57L{ !h;Ov[d:0bsE _{?+nbJY /8Ǧ'o;]̭c4*dTΜ4 _` ͟yqgu, H@$  H@(`9eG$  H@$  H@h  H@$  H@$fHk3Z$  H@$  H@hiӵ$  H@$  H@mF@65P H@$  H@$ (=]+ H@$  H@$fHk3Z$  H@$  H@hiӵ$  H@$  H@mF@65P H@$  H@$ (=]+ H@$  H@$fHk3Z$  H@$  H@h@Eʘ;2.s[i__edIzx*uU,zCOkG˷X~8o\ȥNf/ȻW0mKZ}fZ:r9̻ǃ-⧶߻ѹp4F~Gkݜ$  H@$ 6*жi=YkXX<~= VȬS]&#:K]ʍkRSѲwʗ)XΜ^wj s^[=PoKvZlY㷷,7RssA`w'se!vl|1ջqߩa֕=nˍWɛ6<̃۫(ȴqHσɔS:$  H@$ S p|N |n`\&=G|gB&>.{e̛"ΣlCXYɹ#E bU})x?E&CzBW+<79sߪdkYh|חolV'+^k M:?2MG)z$LP.SeF77qY':MXo<6tflʧ=\ܳ!fc[y2܉ I^se/¥3-rPl~|/!R91,ENq!e }d¨z*0~`?Hb*K)43Y-ʈy, -]Ay*׭'{ܺ=^IYg.TRʔaydzQ|)dڳlY9s:^Sjә7݃?+- {wfWvI| xt[&!"}{sA:|ރYΤ{9]NT[Ɣ[)GFݹ1Stl͐oG 0z\B5oYz .҇{/=zr]x7x:A`7\o4w 0 -flWeuO>a]1]8llş8W8݃gr|.?0E%orQPˊe!3=it;,/C6f]ߟ_ܓ}3S2Z*?j֣ѝ.FlF<9imG>\^!~qݛi3zdV}Ģ z^^Yu] pհ3ɎT;dk;>v-wwXI"U{0ŽU]MAfT$  H@$  |iߢIrjχ(Q30t!KYn8 |\cJrVC\5h4/L;OJ륽v0}z"slJKA>͜?PɈ'7& 6ݎޙ|[-}T_]ÍubP TW|Q=ӽHP3s}1>êjOؙϗc#E^.7# H@$  H@8uXL&Z:C})H@$  H@*&vY7|9[7WenSq3ov9W)֍70iN6Vղ='bҚMg]>+!a*vӷq:1Q--guEnbsf7w 7tܖWհt=7v=Lq!*|iwKЯ8e#5mGQ0^W?g0iYeztGj@$  H@$  Hd 43d5.b҄|~p\>'@0Q#,3YQQ.iQrV?~sf>E?L̾,0Gz%a0֎[3ؓq gv▫Ϥ(+0włt҃}Ϥx&}Û/{.\7v.&[)G3n)5/|S2/Ĥybӭ8<'q,=ؾ1~t( pȁKcef3⟋޸7~%\5e ֞^ ksז;uXYjƧ{+wnm6F]-df`sϯ\FmANV;XGBɆcܳ?x|\t~~5s7h}y*/w/]7Wu;ӛ}TϖZ8#q|?^uG7^:wϯo<ߎ/o߽=fjuN\s0([pHp,w@xCGXSe/Fΐ7u#p۲':[)&E{;~g?w5%|ui;O0%H|׬㧷w៲F>/ $S蟘"x"YkL׮4η7@ K@$  H@LvKP"\^٥FIx}@w@U3o/N)A-D:l} y3&5| Oeq>\W>lg O-|нW;mɲkv eMlҍ{Qs=ݜ$  H@$ \@S K&< "fg(3~}m'PrƯ$ՏHU9g +f62fļu$  H@$  4/ͻT$  H@$  H@  H@$  H@$  Icנ%  H@$  H@W@S} H@$  H@$ 6)@Z|$  H@$  H@ (vb/ H@$  H@$&Hk]$  H@$  H@8^ҎWL%  H@$  H@ڤ@Uf\:A6ENG3s`.t;ZjcRn0{'؍.$  H@$  H@lv W$  H@$  H@iҭJ@$  H@$  |vϮSaJ_1w/xp71}ƕ{s#<0w)kL^äIпs c{*$8X$  H@$  HPFyNb0#zKJ1K^c3EVW2|̷8@pćXhaVw3-p%\1f7H;=~t$  H@$  HEeK?c`3q~BO jޛ5sĞ> ~P"V_Clw`gqy0RU_%  H@$  H@Neg 012#?`ft}\)gP@UWܬɿ#)7yC3A4xmw$  H@$  H@8H;eOLSF%  H@$  H@g @Zzr5|20׳d0e+ݜ3q#h<K6@)uu( H@$  H@$ph4*]wD^lݩ㮡?H`%,:OOd,ᥒz~ EY?Ō sʞ0*/m^Y%$  H@$  H@2̓cJYwh/X7΃~cӿIQ/<’Jrͽ>O=13Lʗ?ÜȝL6wO H@$  H@$ C,qCU$  H@$  H@8D@i$  H@$  H@P 9I@$  H@$  HW H@$  H@$  4'@Zs**$  H@$  H@! $  H@$  H@hN@TT& H@$  H@$ CH;D_%  H@$  H@$МiͩL$  H@$  H@(vJ@$  H@$  H9ҚSQ$  H@$  H@hȦv\nyT_˙;f0cgo uB$  H@$  H4h4Y%[d=-d]bJ o$2$  H@$  H@8ݟRy x5˭vX{GX'%  H@$  H@N6to^$ݗ>Pn 9j.4K]Ƶ IDAT3WSmrVQI>dF[j{KSfnL{>9tyAt$  H@$  H@I6HRie__:A-3JIŇRsOw`o2f=ˌV9%a `r*wfLHKm/ʺ3]@ <}^ݩ$  H@$  H@@@H 2 i=A1 ~vK7df6RY֔^^%+seC ]G$  H@$  H@@E*ݠXVnÎ{|'Īu5ӵFʖRN#n.АMf abl}[;$  H@$  H@8LmҢ왜Iwɴ8 GYr#dTհ&ZcW"ao $  H@$  H@i(жit"ذ&(&nZƺ%l)BhKYΪ8+]:2 me3"{BTI`i%  H@$  H@t -RYfj8\ȸ](=~x C./rY5L6_VIbs36d^3+%}÷jCLњ_; H@$  H@$p: X8%  H@$  H@$i 錴OZ}I@$  H@$   Ow/ H@$  H@$) ()A H@$  H@$ [@%  H@$  H@>%>%hu# H@$  H@$pz (vz??ݽ$  H@$  H@$@ڧn$  H@$  H@NoN移$  H@$  H@HՍ$  H@$  H@-@t$  H@$  H@@``.aSVc}ٛ-<0kg^Ͱ1*^C.zs[l(ʦ1\)c˸>j]c,GmR$  H@$  H@?` '8 CfyMp׼S2-Sz2d/GwN8X0o!^AA\H@$  H@$ _ |t-(k3ۿ=Sҕ,O{e󠟱h뜴<&`PCpX$  H@$  H@h+mxj=𦗘9~dr*ۙ1t4Jl]t7HN6v殪<{|u,nkYi,|)ܹͮk1r"~hAsdHnf;2Nz;^v;}r>b߱*N =2FY=ڛK@$  H@$ D6v!cMmS$  H@$  H]@czbOS\,8q'RCXTA.+s>!B4yt΂!)hChhE/*3E, AV!MeЗmjhU:$  H@$  H@Pyӗ3/ QvGӟ W~cGrÝ&(ϷSqS5HSi#Бp!H",^ll#9YYFrl9ǒDk n<3 h 3=]h.[vCC d0o_/kZ?k8^n(ǮS'UگkF 3$3F?!WN*6չkq@@@X$k=JUM/zx]O!u/JkhV2HMVԮg{XKHީ?1:yUu=DŶ#    H-ش13_e5T]YI oؘ}񎂑;'ByC:{fljJS鹇T}UNݭ}qE    fH-Ԏ TmcGMiԧ G-3׵˱|]>m.=TSȞLM  Ɇ[/>:6ԯLw`˦z5թ>뭢c>5?]սsq   F ,T2/7e19<:[1l[u> S9J|3萶Ӌ;re+/SmŎq`W~Q'Z/]ٝ:UwDqJ@7Evu׉;}tɛcW(+NvkԺ9Ү%K9OZ/~s;$'    *XygN]ɮ͠?+3URbMIi{t,P$R9ګKAy_U^] v8"WM=SI)4.O=MNC?*D ɐLF 7y f@@@VO%QuFδeTƆ9\s^W 3og/zBlcmŅ7CkrO.eZ    VN%zTћڙU>ʘJp(%3ַ*zq*m?޸r;gSހp˹`f̐3))oi9F@@@V4I I*:KUU{ gt[A v*[ *.xJ㪽H@C=-=v\ of^Щށ~uU0ѣyOpZ]pZϨÔgoDՋ:L> }SU3W'UV_2*    JX[;d2ӎjCuk[>+;_鱶zJ.Ψ%X;9mO)#O~<[EmDI\9GTPJE5Ψ׺ OUqQr6چ /Įd19Y**Kլݲ    )    J`kJ    "HiS    +MDJ    "HiS    +MDJ    "HiS    +MDJ    "HiS    +MDJ    "HiS    +MD"XifV"]PcCj 5-@@@@u(@"mMz벮46׸G}v馼 p@@@X$ռ e7_h q@@@֩4I#=U^U2zܚij9n֦{ui<]9)srNi t kT[[27kKI w :j_u'׭j_ugc>*,96xAϪo*%7s\[+%Im4@@@@` HnщO'rͨ܍ыz(/|?Vyo8Hrldj^n3TP{KsKg L]);D=?`oŝ[z#ͫ۷/wJmoTxkxأ[=R;:\*k ddt՗M; [OȦTz㮾캫Um^?6͔S}fDr̔v.ws%"kDT2.Y@@@@U!i#}-Μ*_z']AutWf:ťXIHYIUؕ`WʎC*rx3K*t{<$Ͼmژh(1e \ZgC(LՆwKގVE9]9?._jut<-M\\97rLJ6{p1"    B]eF ͺ:W_ N_fH ?wlꪤW~ջc)3$9C֖ɋSVl}2d)Tȑ!u_B~nH&yYm>Sinu*-Ws9uMԩЏ7.7'ՊMNC?*%'F@@@X%>GYW;ӖSfxrY{]-t̼>&'_6H)f^\|3,r睊ï!Gr'eCj+֭3_,.x3A@@@G`oTGO~ZYEoê Z4PKwy5kgse|޾m{Aۏ7n\<@OrHUlJq)բ9%Yr;=nSހp9 6P@@@Xvi2TRu^Ϋ$CI1@sDcVSaUfOdB jx5<POj׵]ή8וl?fC6d:;ЯצLeS@]=TD093Kj oUbv߮LxD=)_i5vh=S^n0e@@@@-v>$ՆOW׬|>!WvJcm4\rQQ]KjvrڞRF9LxK?ՉNr*5өiy*ԩ#iPCrd)n:+5uCr\R CٓR.qF տd:ܴ,    ܿw     ڹ!   i+d"     !   i+d"     !   i+d"     !   i+d"     !   i+d"     3wZ    <0Ȋnuu̯ԿM5͐ ~UIA=wk SfHr풓6v &d:4MS@#C604޿U߰ɐo?t6DN B>7]*yNW& .w8n r    ?O%QuFδeTƴ5)5gus&w9N}Blc+_ IDATPT;X|U_{sZ?7R-h@@@֋کD*>zS;ԇUJ Hp(%sfYܑ*zq*m?޸KYnFW]UmKb#   GD TW*PRQvy\3:ح᠆TuX T<%qjx$;k8zc~+ T@:^^0fMukS}qe ǘ<[ aNM f55+;_鱶zJ.Ψ%X;9mO)#VO~<[EmDI\9GԴavgwTVe(-]R])7Y;=Ok{Qg- @@@XG߿yU@@@@V[;W\S@@@@e 4    zH    (@"mi@@@@`H[=sEO@@@@QD24     z択"   ,eħi@@@@#@"m=E@@@XFiˈO    G_ Ms5<苲~]}F!U}:ѫJKQ$ƥ;BM@qcT./|*4H    *Xlj4ޑ+4>SePƱW403n%i2P4FT@@@@+iRM)&`sTpA97SCOk_p-Պ27sGo\We9Uy_jI=UY4яݪjklVzJ_}Ws9 XY:c>mVaV SYDv\Se@@@@-W=|+*-?"GHPyA_UJ8cr]oAgm w.9t_uȟU᳑NT>t(|Z'jNjcʿ(z]=N#UJ(9)U3F/ыqQ_d:^שêILUVXH!Ðr@@@V mz])!GI? L` ʮ6$J2q6BBv))&d %F Yo͒&w5u UlQ7_QfKAutWf:n61mEk\zD(ݵuC@@@V8'̈^]edԈ9{W{;Y5gd)oG;#حm(ǮE;~|ejÌn 6)N\qE4fK!IkidLMq."   +KDbGbʮ}vs]{O[oǮNu6n6Z4TJFZtF~CB+>fQgu#ZH-F.!   B ,4ٴ13_e5T]8ac gdw; F 2f{J4$sl3C%8]^Maf @@@@u#@"mzlj;58jJ>eQ >gĿwq|g$~l!.%%= ~<7|6x&jF5OQQG<%៤)G?qM4OIL1{toӵ9]{ss~7nq=QQXs7&S#!ŕ󲖢UP2MXx "2eıjLӈo141~w?/`YtF\S-dׁͮDAX8g!#   </xh:*"   'ii+    C H{h:*"   'ii+    C H{h:*"   'ii+    C H{h:*"   'ii+    C H{h:*"   'ii+    C H{h:*"   'ii+    C H{h:*"   'uH`6egoFƼʱI+JboʝpnMQ>)s)ApzN'ѡ5Y3}%_S iUyO+)ץ|%ϼσ>mHdKqw@@@@B`H[ 1͐"''HNg4Wl*)3Rf*6@SzL}lWoTtHY8=!.W}ظG C=@@@@`H{L3pTT6ygBAU5oJ 9=Rf'U[CsnV[HUASNW5]9)&'?b/9cřM)%Qvml3"7@@@@-v>|Z-1CGP}Y.ٯw۔keT )G7 4|.+ݬZl,6kgbjWvoշZF.    0!@"mI ]ou鲜nl!$ڛ\u?MtғrRk<{6Fm3IOj|eo黩kߨɫs_ʻSW~u +`E z|㦒ڶe3!j"   K+[;PJg0~-_rzEo|+SE"i9o~-_IчӷhzoJ7#f/.wNv"a]SEm0c54k_)n6+:    2W/*/i}wo^Ԟm#    fͳaIJs5W\߄i)3ݙvmqO[W5^_SVۛjYDC@@@]`o.=UY44{Zm[@Y[DwiHҨOmU=mtGD iIʉRj?!b]VaajD_'it3دkkmg~^}c*hj9m* k(VϞҭ*V[1mվv ϹӔ&[|N]l[{rkT!   +VDZ1%{MTMe̜WvYsR73LڦcYpD*)#pjoܾ[:Q+c}r_շeןIOeʪxKEzИʮL_Tg3Y~RmQ ,u ]7},8ƯS{~]}ѸGj{G:f$"ԕ js*]Hi>@@@@/S4:ܫs׏UR[$kۧsy^S@PE6m=Ĺ2kkdܥn#ۀW!g'g]wZFS^3%Vy@V6Pjj *'{kνorvX^1!:rREajJ٦i,ᙪb=wҭ91n22a9U#@@@@` H(tW/n;5[uL%2ۤVOåtg$i 3IfHV-GZWu\B{11!后y׉tjޑו7}p@@@@ iR[;g9jVqTg+J23M;~߯zIUlWu~Q7Qj.:Uqr՜dj$WC *5@@@@8xP^BTdhj=Z@crfg)=ٮQ;Y6\ת)srk[ w[7o(n5V]<+zsl4ѐF»dC Ys!   X6$E^m+3_yj.68ѣb^y^ v9ɒgeL u\}DVfn /(.ycF:Ij(yOdw?7*zJKP ԯ ߛZ'~a/2_h    xW@?O-+gN9Ye_*2tYڕg*~{ơ<    @LvdY‹ v$Gk(1S#j=gܳ_!4    @lWjٕYu^uTnOS(zKr<ڢA@@@XTv.*'@@@@֪[;2.@@@@E C@@@X$2.@@@@E C@@@X$2.@@@@E C@@@X$2.@@@@E C@@@X$2.@@@@E C@@@X$2.@@@@E C@@@X$2.@@@֧ IDAT@E C@@@X$2.@@@@ExtQ`~5X֫ʾ25JU;D{    H ە]y^cSŶ(x]QI&ĩw0%$01u_ݫ3cJZ[ۣݺN=]}UieS5뀒s4   ,q uұ3jF2J;\J#:%*ݪodq9=UZy@D ^џl4pꮌo4?Qޣ*RxWe#jtYM_+`Jg*|U)6I5R :V`yb s>#4*~MA'D%8\[1B(   ^6dRgȭmy?Fu'ue(V+_~STZ!G+_p| iֵsz+ϨKڞ&khԏ+#ڞlP~N}QłwU{K*~RA:Uݭ"Q%3@?CJRF+a73" 9IEXD@@@5$L>=}ށJKc[9U`n08~gvn;cT2R@@@@`踮Kp   (GWTo3OUQ@.T(Us{)ɼzQyIթnPUukw9]*;,ۍc!   HYgt1AF6vk~8W>lmJLwBuжߪ$NszX(@@@@\`o.v'\Veݪj[m[@YwiH‰;J/g$^H7NjWNRy5=d̡*>UkA#ubrϫor,SM ?Md E t kVXcڪ}s)9M6+"OڮWcIpM6` A@@@5!@"-4~=&t]fΫz;O9pmNz,8cljq 8}xDn-ȱѾsz[⧲qeU=hUu~eW}/|A,?HK麆ҏXlꮛ>X)|N]=zh#S3uJM?Nv/ٔ쐵03mQ2Y1@@@V[;gpZjϩ_?ViK \oR$qy^S@PE6m=ˬs󮫻S1l^K/wiN%Z,fJVy@V6Pjj *'{νorvX^1r1}H>*e ç\OgܹJ$PJtͺ=ګ;H     2HYܝY &VMvmRڃh$"aۜ6 w.:ReҮm*I14"\:截1cj8Kݾ&i20d3f%hOc|+f ifnL27embBzXvRݮWaDnZN@@@@!@"͚ɷvJݡ uM̐]ѩ~_iZgԧk?T(tO2Ʒ-&1:QnZ;RUT7<|5MEbYƫ^]+?_M u炦@@@@`AiQ[;g9jVqTg+J23M;~߯zIUlWu~Q7Qj.:Uqr-sZ&N^yC+z?V6 'ѮޯW%m7ZF@@@bH2uTSȞLL wSSebYt9j^wXrpjֶUyڌ V-,˒C^5W_yW͈V.SU\jWSp? T'.m"    6XtHv2ک%zTZ5+.3Uy9Yr窙.>̐4 i5T_P#-3RqP|!~Fo4Uk,Rf?Oԥ!kR @@@XVG߿Y{h?T9jgO|=ejWG,    1G̫\\:\5ؘN5˞SntDF@@@[;U+꼊Ωn-ݞQyS    l\ja#    vid    K-@"m    &Hid    K-@"m    &Hid    K-@"m    &Hid    K-@"m    &Hid    K-@"m    &Hid    K-@"m    &Hid    K-@"m    &Hid    K-@"m᱁ܬMsQp~m>nז}^UmծF9ʢ]VYfrAmܯq w]V~^e݊=+@@ٻE@GUԸ3/xHwLL*=édϕ#V2d5nU&~-+'ȼ` (.9bFw^yjB=j|[\Uj å@@@@A H!S(;3^LHtQVŧOsM{+_$)(ro :ea[W/\d~U NM.q6-"   ]λ d.gfe@5%V :pk9 X?K jk*rTQߣAЖm:??;ڮhziia]3+D4=ЮEPq xsjvouBKb]Kυ FTeǗ$DjR,@@@@  gN%ŘA]vZGm*oC8գgAC-+kn]vwa-rȶ4fGYN4%we7*W+ha^9PNݧ(I& )Qku+ %i<Ȯ3ñX":O[]8MYN4lb5@@@@U"@jg i*0h gb΢?( wUv4WTۘ+K ٝ.-S Lf3SȄqf2mN9 i?&Rul׺JF/gV˄S >Rr:UЌsG}!ʋUV n5ٛ6 % 7cp-mfj2dFl    )-YGlGVbpܺ#A4vʙABO9ߐ$W1K^i*hE.255/kD3K_d/ YYlsU 'R p]r/~?%zYVYkI\9x \!   t-i7Ew?8 Y@~g5yGWm._Ło6gjRǫ\ ]RRFfȫr,SΌ) YC 4'yh. 3Ufl,e&VI9Zǜ֭r4ibA@@@@GT@Z gpFHK~ep~"Eڲ#44+жL|eO)0WrzyAL2ʫxK4l)wB e'}f} gV¹j⁺JZE̠?-6u8+Œ\!   t,H;P'r8==3ڹf̹B=:y)<7%#áșfvMa:z4jfk+ Lgwi NB/EEvٗ"Ew%%RL)o,p-M^x5~v/x ib\#   /HsS,bciV[Vzyl/3v=rˏ-)XgYqYdAT!=)g:T8kkmo뷣͍GT=sPS!Wl@24jv]ҳ"Cv=%?Sр'R ?Vmn|UlNOv˒`R;|A@@@]ݸqƣ;|F     8     #.@ _@    `=gzA@@@x= @@@@L/    G|>     `@@@@ / G@@@x03    <d    F1n~N 6${N;j5x|0+z {TA;.1mX&(ܕ&K$} jA(o,ߧݶJR}    Vڛ:ve :s,s=zPq{ϒvtGխ31x@@@XҬu5lr\Z!i[9n[>>jso;+r/mS Xj4vn'4NU@@@@<橝@K`$pv+c)E;4_HA7V] ՔYi;;#iާ=(.n=x!M_<ָZM]3=9sZE48ޯXc<4*Yoa.M]xAOȚx&Uic_lNyH4nW]dJ5껾 Z fHݶ8 @@@@$@ -b~=&͍qHf[_&֥h)R}C^9_W}^g}PmNUW]Cl~7_髆gdӓ*l8ʬ@..臫_xAP`<%ۣ,2ڒ璊#z}u׷]/J@]/=otu,2ɦuN 5ծ~_UU䈷'    H\omהVmKk](O+jr{tPYѴD+0TjR#g}T]֣ùM{rnUYN~VVޕIѸݵS*nܭ,_wƃ*)YuL[Yd.r)wUC'2Fe}F->Ks<լsv+ϚWvz2?O>WIPa[ʏG[ @@@XH!^Muś:si{G*ic IDATKvYهVOåW     f1$sca%:VUW]R.kا]/kʓeWh%=+5ӣSN5T%I5oNm\9eȟC|Wܧ*[HlL b>)    RΔ,񛦂^BvJ \cjvb(Hi 3::}5WG.XYA@@@@`ƍ7VXaX){;HM_9 |ݭiUR K|{ݶCy@@@@vd73qI޳_ht&pܵuvL^]nk    +N$v4}vi+nG{ B    @ZvD!@@@@]@@@@ @@@@wi/#   %@ --& !   <_G@@@HK@ZZLB@@@x=    (    H{@@@@ -ii1Q@@@@q ?    @Zb    .@ q0@@@@D!@@@@]@ `    i HK)PxlͿm:?x|roTSI5trJF4wyHfS?{qhI4mh[     #5 n}ҺUfd 6^#: a!8+Ca(Z鎅 Cj::OI7zF@@@Q@ڽq)'/^/!jMBwenVi]dTߪI *4ۯ->V?+oC+h @@@yfP]UQdnҮ!͆cΏ`FCsݦTܯk jJyhuDlREmg{X> 6cԞx<7t*oSdl\I ×OO(PrDy{h*     vzBsK}NBp0E!6%s_F^<2W$Q4E[utئT_*mԵjvz]nejX<:UtA?\ZxB7N*SmnkR     ~bڭߩeاpi2.#%WMkQIER_L}iך lOQк pKe=?HTܸWYV˦|ه4R4?(8u_ϏPۭu^(    (Hd, ج8UȌ 9Ta3:򦞟 6PّܭiU]3]rKUVYYٜ6 ^-T°05gZiIOOZ7˱Q^u?7McrA]aq @@@xD,HΙpz+j8J2o=ȌxN }>ߣqB뒑* }%=1rd9u"ks4ai}[Rm* [sҝy   i+e%bdG%ej|!d. -sSy{W]ѰO^ֈU54'ˮKz~Ipki?ڍ]o vǔp,vF}@@@@> ~Avw2R靐sG@@@H[@ZTD@@@x=Ϋ@@@@ 6@@@@gi3w@@@@MEA@@@@Y1n~N 6${N;j5x\]WM%b y4ټM?|ŗdxɃ[| \RMm)*h    kjxF 2L^uTKOOUQjاƓJ?g    Hڰri]unm lP*q>6?ʼnڔxAWoܧ&;պ/}f@@@@xS;oAhsm3Hi5VR@vih1n)RDwwFҼO{TQMR{BsBxX;JSJmꚑ͙Ӫ(ڧ~5UZGx7TZMWRS֗5)Mh`Ǿ؜6iWfoi?ܮVktaѡ̍~CN6oÈ@@@@` HK2_dϴIs#jiY>?wFI#-u)a6}_PWU߰WYuT۴SUgUr.P넬#'F_͗w 2+:m3:˯ :^ԥ5h&o>Ŧ乤޻@_F/곁m׋::$Pom*o22m?RdvHGn8I%@@@VKVf~vBӪ-s)cKie~VVn2+~Mŭf]B8JwGc}#se*ܪhr+#˩5qk3;2Tܸ[YV6U=URDU"sys\Rޫ Od.VZ} yY5V5!9c?l._}2- @@@XҬ }W6~D_i{v%E 6)`&p)śIns$3$##{Umhv_^Txmpi|Ac4 2 ٌ%s7tOC6LȌ)gLe$we-2kau IG&@@@XҬYxkdZ d/hW0dZ#> Q߸W@PupDޫm:T.Y;UIfR[Sj$WC*.՞J+T:>rˑB}.@@@@/@ Ʀ^BBajvt(}|'.TuڼWCV +#M6\jؠHCOt -CPW W}ͧ?vK}_3ؕ9;-yH~ #ӞI/'    v ܫvR:$!WVyF4xzkrU[-TiQYI>kyݯ|MS_gUg ]9%p6E;sJ5' Ivzkr2|"   ܋nܸq^*R͊=Ou, ԯ r,gNbǐ^P]C#   ?)r dؕ㒼gLPᰩk#옒d7@@@@ 3m*hWAǪnkWۮ Zٞ_*>|b@@@@`H\fPC@@@XvueV    ,@ mAi@@@@`u H[ʬ@@@@Y@2     :וY!   ,e9@@@@)@ mu+B@@@Xfi Js    S@\Wf    Җ@@@@Vչ @@@@`-3(!   Nis]    2 H[fPC@@@XV2+@@@@e2X4?;>Z0ַ &UKqZ*J|/S׮=wZ NHWGCO*sIow?:mzSD&mj96|=VF]    +W@=M{>i*3R7k_a3lr:$6dXx=TY\mb =蕠?@@@ioK9y~ i'/7aY΁o( j[5~M(_yّ0#@@@@A3AMtTE/ؤ]C ZS]Ս7&UM /_e: jJyhuDlREmg{X> 6cԞ\iUQ%5U=V[e5WRCw%ϴ+A.(   +L@^5 /; ~4|X:~}{\w>a^ַP}QL]>iZ諮]֡ GbMg*j~wr|i<y }S OmVpp6S @@@@.@j^^_W})@nUN->Ks?p]FKLȣ; Tlvɰ+o T]֣ùʎxJō{Uel/ϗ}xJS);+E)Xe~sed90LGP[zM_ls    J vWX_YoYqyQA+*|F[Raf*4*.tB%*+߬Kfs$3{aB\R jԜi&=v>iݬރz7ߵnmq/X}L6;J[(    ,HΙpz+j8J2o=ȌxN }>ߣqB뒑* }%=1rd9uЖF֒*Y_llxP!+m49xSin    +E3VJƑQɎ#:W8?éҽj?jl}A|na:v*>՟[jx:A   veh mB _J PEnSKGF/3 _ 9 eK ;n3GA 7|i;N/0znba@@@@-@ m,!=ߨzi7|;"f+qPϷ()y[䪶ڭ7Z.ӣB9|gϮ][5ѣ_U۵_)]Cm@@@@' ƍ7~bTG@@@@` pFڪ_b&    ҖC6@@@@VUL@@@@`9-"m    zi~     rH[E@@@@XV3A@@@@     %f    !@ m9i@@@@` <4Si}iGmk^5lЎ4d6m,@i_RdKZ_tX%kW/ip.    C:ve :s,!kwL)`{F[4b9Y'@@@@ f-aҺ IqUV|oSN]i d: W"   KswսKV*[Nߦԣ1K]fܵ;eڂ 24   X,ķv.]]=߶Uܰ_4jK%} Աe^UC^y bfztj©s$3歿ܩT5^yC+ǡ SX1¾1̧\1c     L>*)p+3P$8uzf@rV*:lޫa+ݑ&[.kTlPK'dsU\sDusזp6#~!OoV++8vZ5y*ߪuv3#:Bo2;!t _@@@@)ZWWœztHvC*hv䪶ڭ7Z.ӣB9|Vڢyݯ|MS_gUg ]9%p6E;sJ5' Ivzkr23ùYU~~[K?mTnPn{`    ƍ7I?Z){;H8T?<~Pn2tʱhn9sZ;zP@@@@ IyO î gLPᰩk#옒d7@@@@ 3m*hWAǪnkWۮ Zٞ_*>qr@@@@`yH\^OZC@@@Xv҅eZ    +@ my=i @@@@` H[ ˴@@@@W@z    * Ji!   ,5@@@@U*@ m.,B@@@X^iIk    T@*]X    Җד@@@@VUL @@@@`y-'!   Rita    H[^OZC@@@XV2-@@@@ vZ_6LHzDcj6bV%nrJF4wp' Z_yI)zS h4դgXA[S [    @?O2ܻIV{ 6^#: a!ꤾ4dRHylA Z /@@@@` r҈-O^o+t@Zfu n^EK5 BuVgsneq1@@@@ }R;ӷfP]UQdnҮ!͆cM͏`FCsݦTܯk WAMS< 4ܺG["u6]óѽr UyƱA[jOh<:sZE{7zIMUESVk26d8ojk   +B@^5 /; ~4|X:~}{\Deoa^ַP}c•#ڦj=^wYZ'o3γ~5]W;9>4MFӻP[w M4%TtIPKEJ!   Eޫ늣geV2S4WAedʴi<*L}iך lOQк pKe=?HTܸWYVΦ|ه4R4?(8u_b^FBO*z &zRՙ{R5}F   i,ǒGރz7޽_ãz'8r}_\Rﵭ:nI|"   +L@ Zҏ) =ۯs$փڮ|GP_ /`I0mާ=*.@oW!GS7 -1)wQ UkR'|A@@@Vg$#ӣGu=qSy{G5؀/">uzY3nU6}? tp(.&IM( rno   ,ea\FjWd@a+;! yPǀWsa)<3qs$/E˲+cޫamZ]GV=Bscz{)9 mi@@@9. !u7FVʣi7|;v:%=%O{z˒5v덖m*Pu[>7r]rdr0i@@@X6ݸqƲFC    RR;W2-@@@@     J҅eZ    +@ my=i @@@@` H[ ˴@@@@W@z    * Ji!   ,5@@@@U*@ m.,B@@@X^iIk    T1n~N 6${N;j5x\]Kdv\ 1&icO"sW^Ú /ykH;%ά2&   <?T6QCn SW}]'RySe.kO4D`~]zmʳ%O   iZ69].ːέM-|_e5ΕZl$64^܈6}/[IS`߽>    [\rۥLPVvi5VR@vioն7V] ՔY);;#iާ=(n=x!M_<%)6uc3UQOjl:ƃN)oةR+hXԵH!nRE;I4{MSvEL6] yox[V ?@@@@`% HK:_dϴIs#jiY>?wFI#-u)a6}_PWU߰WYuT۴SUgUr.P넬#'F_͗w 2+:m3:˯ :^ԥ5h&o>Ŧ乤޻@_F/곁m׋::$Pom*o22m*2sڼ=pZ+ڤ-Uqu1@oO@@@@`ڹd%g'ڮ)=22ֺ1PuŮ#Tj%UgFƿT]֣ùM{rnUYN~VVޕk[dLwfvS*nܭYc6U=URDU"sys\$~uUC'2Fe}F->Ks[;{\sAMyvB@@@V4k!B땍/.WzDaIQ".M o4\sƃffۜ6 ɪ=bdW.;e){5rڥ1_PX MFblƒ㧡H+dFtsjܕ50V}[ /s:9Pm^ޱ    V@Nɰ;6qG~u_єׯ`TȴXoާ=*.H콪ݦCu/սã#*S6mx_2nhʛDΡ[.׺'BE@@@@`H"Kfa%:VUW]R.kا]/kʓeWh%=+5ӣSN5T%I5oNm\9eȟCgrRMQQӥxC"   e6{TRVSC u~3mFO ukr&ٙ1u5*swvaEmv?hOצ۴O_&v     #VUUd^)ᐫ`<#]5v덖m*PuByݯ|MS_gUg ]9hnfrgNddگp pCi=κo"vwWYE +` @@@X"7nXrSJѬ#߁TGNA+ǢiUR K|{ݶCy@@@@vd73qI޳_ht&pܵuvL^]_yC     ڙԲcUmelO/_yLp8ڲA    v.+'!   VR;W2/@@@@e 4    Z֕e^    *@ mY9i @@@@` H[+˼@@@@U@ڲr    j ZWq܈J_{u\e|    p <p/:[I OkKUto]('Wh}>i ! jJӜw4u&\9פ_+e1lPh(~!Wf@tO@@@@ <恴uv{NP7 |ʹ}:꒼R]Z@f!C9[B#s   *@ ͒sB?s3 uӸwX Sׯ)yT涥9\,+_%kp<B    p/$ٲ_T'S#iwawl{H?紾J ݩŽ+/ic%MPM/ةAlmҮn5Vn|N/UbA5ox]Z$vS%n}A595^QW6mc<ƻjGꝶc!7=e$SWo&6wuJ+Nʯ":4sZخrܤvqL^B:M^mW]yp}N5Ru-CEr4-qq~\!   J Y6>qBӋ Х3t%΍qHf[_&֥`BZrZV5'(HԸ1ۯCugu'/뫦?*ֱGuil뜊G@s˰yai~]Ycy4^iHJk=^嫺M5rKN덶)9ԟ.볺ĴSC][Y0dd$,3    bŖXjM++z{i: m?C934MjG$  H@$ hGڂ56(ߏ7~[>UKC}yyoyك楦{Or"v=βPNK|&88_XW'~֙cVgHG7]~ΙRȥc[Ãı>zз9~dG>tr/-Xnm'C]I2/|a5@54?7&>f_rߓ2rt \4 IDAT4uFvr^ʋtK~S!{p[ x ?;U^ ,7fɫ$%Oh:Lu_lnk6aB<ֹhiT}PI:'%I6;6B 1>8-g͎z7,'fTv%w "V8;Jl;  U߂sڙsR P^dVDEn|$l"# 䬟^m.fǏx~x3'w2$g,1zڌ]72zΛh Ixԩis$  H@$  _oܼy OIvkwp7bkڿM^y-]3;W;' H@$  H@hG V>r~LD~$  H@$  SH[ V*`2=)wWXsa BI@$  H@$pJݫI@$  H@$  =r^maҦO!leo_n~E='|5K?0;P-Dwwr?l]?D| Gӡ=BEsm 9^yrMu-[N p"]!nQS츻>cP=ֺ7pr0sgKMN,eL0ҥ$  H@$  H@Xg*$/olfiÙS_$+9~kFE|XMȴl*= 4tp/D‡͹]+Ä^ٵief>!:ۺD0Jə ^7xO;#a"WD,97ϟwMpNޢ&stfܦ%XMImtS$  H@$  l `ld\ŏfz<db-ĝo}"t='Qs#zz)0/_-=\gGko?qK귻oM .}.xvK4İlnn/Tݓ$  H@$  H@G@k쥦~ 9N_rl O}_:FuS8.~HZGD.3ά/d[ؿCF44tWMz^ OĖQDv0#іd'i2T[;@7l.}<hkwrɮol{8;Be% $  H@$  H@߸y  }}d_ch3Slz 5/WJHig H@$  H@$*\7pd9 ~@ptG ];)^d?K  H@$  H@nv~ov?z w.=uB$  H@$  H@A@a$  H@$  H@ s͗@H@$  H@$  l%6*)F H@$  H@$ 5P"m͗@H@$  H@$  l%6*)F H@$  H@$ 5P"m͗@H@$  H@$  l%6*)F H@$  H@$ 5P"m͗@H@$  H@$  lG>s? U1V`'yۨZi+.i7i(e>z0u08Iryݑ$  H@$  H@EOB{Zo=D4 e Dta.5DڃT$  H@$  7Wwp"=3 lܢa7\I$  H@$  v-ž[v-lBa9o:g ՃXO+j?% sQZrvb\eHٓф_t3s+;Fx&O88JQ}hO"}=W 4R^!\;ܴ[xנc>1oVK;0Ȭ-V<gxg|>K R>V^q{~5zW} P KtNt'ӽ=o?st$  H@$  H@X/J-S䓞 x))~slJ Mx;~'uL좠j/@7Kf# r\y(9#]J[j>Gh =t ;IIv2c8N2>w{Ea>קiM gd%3yx`~H}R8^dzgޭ`#$  H@$  H@]@^A+e<ũoyu+-1]zjy#2iK?ݙas2 2pÐLڒ) H@$  H@Pi*-ݍ1X%1ױbFl?[lcMqxp_222[0pf= |d-s/R =v# H@$  H@T@UOJE'BgFF{.pK&bR'Óa&Gh?϶%:Sx7d.5c' 4Tnk6هfAȘ#Xj^NԷ3<" CLEYp,Ň^kt[Fmmb|to [W$  H@$  H` (+⠸"5Z?V?"K@OR{ɦz' \[nu;${ʋ[d+<<_I8\筥)y{)rh> ;FXl_N>ϖyF^pI#[>vR[`$eZyG$  H@$  l oܼyUQJ@$  H@$  H`#m5$  H@$  H@P"m-B$  H@$  H@X;%^#K@$  H@$  l %6b)T H@$  H@$ P"m5$  H@$  H@P"m-B$  H@$  H@X;%^#K@$  H@$  l %6b)T H@$  H@$ P"m5$  H@$  H@P"m-B$  H@$  H@X;G>s?EU1V`'yۨZisNz'7iFzp=' H@$  H@$p|"10 1{3vHin"TnLE- H@$  H@'͇oJ<"hh 6]Ou& H@$  H@W@; N]{ ^;Ce6lNQHQ9V(iax#_=xV,qicu2>xa&蘴0 ^?Guɶ/p/EEf~ ˎ1" Wmq(͋Ǹb)!ax&n>KPX;0_95mT^ 0Z4=?ZcD6N_%  H@$  H@,D4<\ϯInN4cn>@GZr9u>HyrGˁ%3=zכd7|烿ocqrkߠ,->%QS4>'FpT\w?^M8=l%L&^ƏǼ]bZ},!auP9/^gҋ&' i;&^}>:7IfIn:GS,0 gsv'Ê' _IAaM$  H@$  iKOQSOz2⥤ ̱!wd-ֺwvZN*vj#)EA^\nze>""3Vm -u(.J˟af$/RS`0tux8NR)sNHS[!% 3)q?qW2r`qvEU}v;$  H@$  H`茴,a3p'LG+w:7I;naL>0vEc Y΄l}ӌ`L̰yP lٴ3p0Y_"f i%+6N+-be \%A pxp_22Pƙs1z?{ H@$  H@$i|MwQYw`ў ܸ/8]:m`v{>v N Q$y(y@aN0m+HbnOD숓g6Ϥ3l6}>B1uC$  H@$  J;WAqE8{p6V-*@횓ܯ~]6v$ 0r$M Ȼ(܌&9 5=h+t*+""p+t[lO]YkwLKrQVmFξO]";ݹ!NQKJbȪ:Hr9ݤ͇^Y^Rجi@ H@$  H@$7n޼ySP |>9cAFzu8+$ H@$  H@6J;%t NFM&h:?`;`NKqI@$  H@$ *iy}t=G [V f~jv{;$  H@$  H@ؠJmЅS$  H@$  H@+h$  H@$  H@T@ p [$  H@$  H`uH[]o& H@$  H@$AH۠ %  H@$  H@VW@h$  H@$  H@T@ p [$  H@$  H`uH[]o& H@$  H@$ADZ lGJW0DW LjZMTmjh> Ji Jsvp~\6`pd7ܜk4|a2'|#$  H@$  H@k#'0Ӛ` EfaX6wc4 ҭ"a9X)cZzɯNƗ&k H@$  H@677X/"6p:N`mw'q$´9qؖjdžx/%NC"vrp>$  H@$  |ڑHuKl-og*9g Z!sP=YQi/QcҚ?gwjJ~Η&d~-l.833;J@$  H@$qH[bL/hy IDAT_{'h"!D[oS \<ڙwDC/qvMͥOA8;f $]wdOs1NzLxJÍ'uk?g bɬTaz[9ȻD\tw߶SW>ê'Xgc>Ox܋f@$D|lfW᳇ie|ƧWPRZ}h };Wm"Ch[s[JYӜӢa&bX֦Gru'F'{sR~k| xg|>eiiVDE6 ]֜Ng˩Jn&p¯?#d<ȝs 3t) H@$  H@ (55')^J"0iF n>:NIE.2Sm$%(ڋ+ML9|njfÌ c=$/rCjjHuLf+fCDc)9MJ'!4F( `+{^2z~>a*krwQ^KZi6c.'y9O}JlϋgΎ51(,}!Bk[hn[{2%6w} lM1;9ՐOʜ{Laܵ\l6m'_qˇGuh%i[C$  H@$  H@Z@^>+?d<ũo9Y20 4%ӱ$mT3e Lo[4G} pFZ>Ed&fK#Ī&nbf>se6gA2`#-<|(̎UN)- `KHb$6<-/ ?0*e3<UίYYď Ćs6DW;~{6%.bC;f8VjKIE}5K_c;d5D:718F}4}@-ޭ[B]-gp?&Z$  H@$ u!D*/CR.=:5  0sCU!x$a9?WSd͜ak-~F5W\f-+'xLC&h8sbI)u'l|*4pX%n&BL\?A'.zη3`\I&d+ͩdjd/*8_Փ!Xꂳ {, QsS$HsS}4嫁1 |/m:F7;9U!=E4wiȤ!&flFW H@$  H@6in.R<[^o,./)-tGӰGJ0K<>d2fvcY8+sdBǡ|^C;l!Ǚ:.YxYfZAv4yNꋰ9fD8]6oGktoBFj:,z|]A3L3[x&/͉mA#la$xɼPcǘ=Kmq|]'7xԹhŝ|xѐ ˴=^; /n&{)/~@$h'7뗇E$  H@$  H@[7o޼CTt-0=@eKC|U$  H@$  HЎGaá[A$  H@$  H@7%mCFPHKxA憚$  H@$  H@@@`$  H@$  H@ЎFP$  H@$  H`(A!H@$  H@$  %)B H@$  H@$ u D:X  H@$  H@$H[k%  H@$  H@ցi`$  H@$  H@P"m"$  H@$  H@X|"-x6z| cD&N*Qy5VEq*a$  H@$  H@C"͇dk8 0X4$  H@$  H@.Dq~{Q{ H@$  H@$ u.ȗv.^k/kg,-Vcpj3ߧB`$vcE}tԿDa6JkZ^u[(_0pts^mҺfavYvx -%:Gcsl4k1[:\V*aܼemm{CjaJ5I}gpitym8Gd-l&8zm=p ue3c@}O`&U(mJko>~u- H@$  H@#/DL/hy_{'hO*-&>@T.~§=)Ovbَ=m"lcݻO38{ J?vIk1z'fL;5=o2dW~ů/!;V^`"[$>3cG݌D 48ipS u'y ޿+o(لĜML?u:lC4?Ɂ 0|t%M 3bz(ɱcq+T1 >F7uc"|.or?@%  H@$  H@%DR'=HRR\Ri& ݦ}tV]dHJvQPW޹՛AFI>mc3;4&ez0CG(ΰd'c~>:had9HMϦ~/P7cRqFXL>gpJJ}˟n/ҳvQ[-½- %6_4$9.cl6K7ӥΆ#d8IIu Y6=ȷ.Cmۅ7v'wg"|/^Dx{?R 9#7v%NM$  H@$  <J` f[ WJ%LG+wxߞ 10E#Qȕ`;cF6e^}40O>B`DsKpx7 -lǗ kKvQ ?sB7] 0g~|d'!C-fb EI?aEsܔc%w<"ٶ?m'))vw[9}CnH@$  H@$aH[JJE'BgFF{.psL*MY&Br$?m$%Oh:Lu4mb|2i%/5?nƃaC\o(|fY3G:{#xg%S)pXY]PNvs:TNNd'{.h/I(s}Ig9zCL_:FL;=^l3dz(^2}~"v9nRM#}y1L#$  H@$  H@ sY ZYk[UĽxwW,|8w&$ */ gϾB =Iv^ʖ:OltR9u 6j0yw'o7%ct4EAc {({3g4CAljaYGxx | 0Scޯ.&Qqh@3ٽlۿ05XOe=˅}\9;) лH- H@$  H@n'7o޼mٳ6޶U[2ٶ1iN㹼yqk$  H@$  H!Pi_mVYev |Ltwx%_^$  H@$ GR@uщN: ̜Q]hp+gUAJ@$  H@$HXfMR$  H@$  H~#~^$  H@$  HP"XfMR$  H@$  H~H_A$  H@$  H@x$H{$Y$  H@$  H@_%WP%  H@$  H@ %e$%  H@$  H@W@T$kF|t/Q}-Q $  H@$  <`G<b ?ltsPz)C+e>6`xI{~!#aFu3yFgBFΒרOvڢVŻn>~޽1Ygu_evNa\Exmw.bO`蝭gwf>_1twم^ƏǼ]bZ})Y5G634<ŵ~s;xg4fl3}4j!#Tda1&"ҝ%Z?ciX츓_p?hHŽh ->3j3Ct[_hO"}=4f.<0iG<$  H@$  H@T@3_*ۤئQEY\IOsS/<\,PB/ A gew=X&3T;TT| s\:~y#͝L&(<,.8Booٙg'B k~{eY0H+؅yx8NR)sw=E ݜn 9BeXtu{;4Eqa.+1D洑ׅ_ ҫ?z 2]eQQ̎1Ȯii j5HҢT"3FR&RlDi62> ta& H@$  H@溋hS"s»N`@&&E ;v̄ӹ YKT!Nj2ť Bcf~:G`چЍQ߇:F9wRR2nQ/!B% F 75g$>甜d?ljC?_oM3!,̏wy,A{A~ :|ohXks\?LLG+w:7I;/k]H@$  H@$HK\"jyHw[dZnLvzu1_p'Y);ũώHeu ۧank7 ,r5ž~;G;&vb撒W+5[c"3n;Դv}Ba"++,kS  mw~$  H@$  H`cs:d--S׏>}C]$M a?ɉ!d%Jvxp_H,5 i%=Lƺ:;(UY%&#tmd8P 0Ãıg ^=IsS#su,lm 6“e{y 4馬"g~]KG>luRd0K^%y(y@abcOwXs hEK˦[N9O6%18<ޘ9?8 oǴz102>F`z g^.Yiv} 懌]}ХN'LMVm Y :GwQYw`ў 0аӋ30/譝U$  H@$H[bR2sI[ l?>^.|Ҳ}4)<~~vB]% u^ QP 0 2/vjsOEN'|Rc%΂$`ǵxⰖnVyw+}%6Ӌuo9H̹h)GrnbQggkz_N IDATh\wg?۟ |c ${p8ϱrq$FxwH6dY↋dhϖ[8pǞ07ZDZEd[Y7%  H@$  H@X߸yu“2kwp7ioe\3<[E ՊJH@$  H@$ #A(5܉}e:0 $jk, H@$  H@hGڪ0kOdz Sj`Vs446梅v% H@$  H@J@׸$  H@$  H@J@j$  H@$  H@Z (VW$  H@$  H`C (KJ@$  H@$  ik%q%  H@$  H@6ij$  H@$  H@Z (VW$  H@$  H`C (KJ@$  H@$  ik%q%  H@$  H@6ij$  H@$  H@Z |s^/NO)??k8$  H@$  H@VA/qw|͛7D f7m{XZ$  H@$  H@K ۿɴG:sdDJI'$  H@$  H@x3}i_vQIH@$  H@$  <Vuם>t"N8]$  H@$  HZJ=4C H@$  H@$  D@T$  H@$  H@ik$  H@$  H@@@.$  H@$  H@~%5 %  H@$  H@iQ]H@$  H@$  <J=kJ@$  H@$  <%$  H@$  H@xH{X3$  H@$  H@xJ=Du! H@$  H@$ (f( H@$  H@$H{B$  H@$  H?5)#HĜv7&q~57"N /To^|HȣNGT3VŮip` 60`ppBB-pIu LcåƼ0ާST!gWKѓO=MlKƷKϳYO^_k'=o}HF5~ s ڞk'xF?]e`d5[iѠ E@@@Z aJ?o? D /Io6tGҭP܅1}/}79cvH\ḯ]՟I7Šq>q*q_ЍG""w%"   pH,mș攭[ 63=i}Dk耇D9L}Cz`я ڬV}/=@ 0C ,g|6Yw T;\vs`(=n;{~"   p}l6噫=,ьm/m=-Mym   ]!B    }NDZR    $ҺB    }NDZR    l iUiNiIס` 4Wc:A˝&FI @@@ p^^z|t@@@@^|>j3 [;&     $Ғ}%?)/U :>;O^՟x   $/@"-i+SHO[ 2_ɍGNK8@@@R e@@@@~; @@@@ ihQ@@@@ H©_/۳_Ъ;Q?^_nD[ZyD߮Pܕ{ULnThY OЌzg ^7Tvnl;zJgzw9yGV>^ZR;U}~3SO.   F``i/#P)0pď]gx;NV$ȖF{b՞,+C^G#KEڝ^Oc^Y&zTG"=cV\zPWn{xZͫly]QSy Z|94    [D}p(hHh *Zzv\Zs@%W4_Dʞ2COm/)#9z|9:]Q%۞M(aJ*̰Sb+dHfTVKݐ5 C)"2ѣ~sTM TD55{6i;e| Dr$*nnmpE_nW8Roh[4fWd8, S隚tqh`)"oó# }4Eܣ~ WuX=KS|U)lH2#R,-URi'p3/R|ݼ=+隚uoOfG    @GHuD-:aJـOƌuZ0(9ʝ>iϝ]zrt+5zyti.|ISYInްmǡ U?!3bIZ3$vVQ3 1#V)\W%zeqc?K+O)ZД _dP@XX +lT7|prJ@֗TO 6 MJ8r5JΕ|`(ݫ) 6 WOhLXFulV4z]K qx>JϺ]-^)FucNU_ғn&J/7&%rt;3:g*b)(]e_h+SVjS;_ŋtz}<ݟ6}#fhEmcbs@@@. Ea^Mx~U;tq%ZvƕKtOxIp[$kY,9u{TVe~CrL mjFW>D4MQVnpEd+) ~pgyGCiݎ_/W5n89ҚAsNbsghjcJiqWm8UR֬>[{jnLF|4&ѬNS?,̕!'3KOD>`e7Rمo_ã5$ @AC_k `r2ћTRZrCJoLݟ|}є8. @@@ @">͒-{픶º~N45/wʗR9lFNiPr `Dʸƃ}3KOhh7|Is \Q m^VuJV\zdSQ\;gL/nE\=۴BhJpmWiYteqW# hJE寨wDs,QW$T"P(ՍP*V*{ɑk8QB˯#xe^j M 3n(   @ օ(UI5EC9*ݲ2)#t3TEr9+]_у&T6Gdv9Q>XSvVV Q8ΝgUTGȴ G$S!+g'LWm 2bB {kvG6 {މ#AU٫qSE7$2phϴ}e {l%wNj}3Tl%~;}MyjYK+t+ȣA&˷}m22̈"K't\Ŷ29\PмNjGuʧs`DsK9e5-N=9e]Ney|:}\ {&Oψx.]pԮǫZ@.#  T=s]ݯ/&LOOd6!1XH|     /@"-^    "iuٞ˗)̖\qWwȖ䍸pOE@@@dHKb    O3zߜc@@@@*iNLDG? $[r(TEgnMw@اӇ?>;'"Q@@@HY3Ғ&3ɺ`4w$4XmھgHo)Ј"   .ͨ    HIg     HK݌    PDZ?t    ͨ    xkg?_&C9?J&}gx^fƀd?u; 0T}=pз> vQ;S=X/VaXn׿J?G@^e`dl5jF`x_nG   Q aJ> UťX^7ߝxo6GҭP{zTk}]CJ?f$Uqm>*}uA? B~MJtw}!k]G<$}gD{ ?=Mlχܣ:"NӬc^==hg|; ~:N   @w$)tG?z~tyr(TQL*Wݏ{ H4    ;H%=o6 x^4t5 vaO|utr6[n{'%   ) O|*euy>5&    YmVdEZVMT$ 得D}:|$ YK;/PEI"   t.$_٫YZPԘ{n3`䞃Lujԯ]4SP*?AKǮ銼 3y5bP@'4HZ d?bhR]~b͒gVm*|ÖRWr ץR]RE4W=;}{\vvi}gZάglt U\qEJi*n boue({>r8GvxBVS(*z~٢ao2e:TUXg}9.Գ60)m%wNԦ*{s v^]3_ tnF/ӄ{@@@zڹחzk&ޢO>ԈJ.8:]t U+S>;.2   @|z"3?]n"    @LD    IHK)VfWv~1`|z(:m)Q@@@ 8#-3Һ    =C3z< @@@@> >0 @@@@Hu1-    iNbԧMSb8 ^>*~QgNi=+    tTDZ rJS:MHu&@@@:(@"pTC@@@_$|3Z@@@@ H @@@@5ߌ@@@@;XVuzU$Sv]_%> fӃѨKPNpO? 7 n -}Yן[`۾E?W||zۯN% IDATkt}ix7"2 K   Oi)ix5aU,V޺(k4%^RIZqK3%_ - ӐϨy MiW5;L'QCM:SUEeV    tTm.jD,JE/wQFcS~,ҠBfv{481*S @@@8#-u3j    Cip2    @$R7    @? '!#   .5ٕ'w+o}L6 ]YvzڻHu#   0b|Y}]^q#   4 |>=զ[;&     $x@@@@HBDZHA@@@@DZ@ԧMSb8 V[;*^ޮvnX!   L)hw 0_=ؾ)FDOB!  tHDZب    u7j"af^(t$#QM(Β^Q7xq*i \k ʙ2AEiAV{ z ]qM5zg#ԯktD ;- ./)xD+T.W;LbTA@@@Hu!q^u҂DGԯwdsQ#fDfW~Pyz1oE%wGOpš 3uЦ;ouβ͗LEz|I *t|@@@&@"f$+i*ZZѳ=-t%d0$S( Q}YGN!Ӵ+w =it UTڽ$ܹz6qY.Waa%Ƽ5+ ~_N4-a 6XS/f&:_C7.۽*RМk)꿤s侤 @Oۻ+8#]uNUݩS4}gzsC?@@@."r).v[K{]5. ^Ҧ*䞟-Ǡ|pɳxb#×_ONH6+5^7#wn ~.^ܢsUe;@W8?֯hŶ`r8\+YTN2涁쭭k?kl*PW=e P|%@@@bi]l=TXGE) 1P嚥 DI#Wm.kϥ¦K "W`m.`,+;fql-[q vaW0dIгvӱ&^5_]@@@HsOmjـv7SI/NQ j^:vMWHtͫNvf@@@zYmv6r@@@@b$x@@@@HBDZH"6䶎?G)Й'lL   ] iɞD@@@3^    vId    ]/@"-i㨂7 ']+|u vnL!   dŢ껪 X77櫛3jLZgh@@@$:nGM@@@@~$@"M6CE@@@Q@@@@ HGP@@@@:.@"vD@@@GX}*m~"[L!%|ϠͦQ}CiM?:=p77$i<'ei 䀘c :l4W_iG_+: 3EOWgn?"c   QDZ6o?49QHjdaHS/)Q$F۸Ez|ؘ52]Vr'p6:=@@@:ODZ [S@@@@pFZߙKF    Ѕ$Һ    }GDZߙKF    Ѕ$Һ    }GDZߙKF    Ѕ$Һ    }GDZߙKF    Ѕ$Һ    }GDZߙKF    Ѕ$Һ    }GDZߙKF    Ѕ$Һ    =_&kDk{>H$TD@@@V~?x݁D_Knݒy    пo݁oT.E`P%և癡!   Vާ'>    @#    @$Ғ    @ ֯#   $+@"-Y)!   kiz<    @$Ғ    @ ֯#   $+@"-Y)!   kiY;_R~S*X*ՏEF;=L起TWnQ§?׺Kqz    i!_9_.K#7C#cUޏ̓!)LV(RޏuG"o'@@@@ ?CM0`]FCZ99#5)ANd}Tw!   w i @nJZL+]*e*JTsi:mUEZⅫul솺yOkt>N'g>ՕH ںtjcٟi Dj}(X[/׵7Cs&fB潫!];fkq>[+urM?R}58Mz:]6LVZfUK Ika+>xyX]^+ڂ⪢2?f5[Y0sU8j[   ܋@NhTmPgkdMT4*}"C5k],3:_:ch҆I-Z3julmJQZ'mkө1Qo9\okMi+I5`HC~#'~-s2eB-cT̑qG*rf֜o^W! ?gkfZXU͉oTw-9oȅ/kŢJp{ iswlD*6Q{Kk7E.Q+3ekC`ol*pYVʔ|W+7*)v,.Wdol   ,пvʩɏﴮw ]5S\iC~{ ^}?׶ gu2a_ո [CQӎֱFz`cU6$u/Q^,rܪ4M{M5&/h?hE S+p/ܬe }tmdw=Ӑd1lWUY' 49Lon}UCMF_3T y]{6&chhc]K,8fTcedYV!95tp\ɕ7QƢ_DA\|E@@@Q'$Ҕ3?anxOV.aMkΥůlr*=!B5Qbo.bʌHSBFrқo=]MIX)NISV#*Uc>a­}VƸV)fնٝTЌ[5>LqIX+5ZkRWkjnӱ\ZNm1mil8g:9|ƍgroOS]"   *@"-Nkm % ɔi&n=E+KK=/tZj#XBN׻!Uұ3WU]Rļk7duGE[EEc.3mٿฏ2k(@@@@si:wl)jتywN5}piCa|'z ƭ+`q4-;j*={Ǝ0LTY~ٚ/Yhω߫tռ/aImՖRmybĵ#z;En7oۯ=s~_6ѻ+r@@@HI_'҂ӧjC:.;>QmXMҲ:SU`F緭O4vZl!3'ݶ\U+6,]>SiZW:;Wiڻ&)ZgmLӨqub4scݶJ[/PUVM损V}%>4][H\U>nzi}uj=9pU`Hkv*[rڥڲ2U]ԎcNX׆ RF!ÔfWi:[;[:   (ЯvZ|]?՚3vG1zsl l\jZWj74Ӑhݮ-q^дعh隶ak?ƹ?>l,KHW2_˵mLSg4nOTVތl5տqS-"Sv4r?0.gZOC^_9ohNJn+Kj%ʌisNW7׼k mǻp&M6Q.`8H(x[KRH\F@@@P___nZ Zfou`rF@@@@/fA"   tNa$    @_`kg_aƇ    )HF    ui}}    @HF    ui}}    @HF    ui}}    @HF    u}}ɍ/j߱ %S=>-)GrATrXW;wkhhV_Ձi ډ2jp t|)] [a-!Ia4sr4F@@@zh-mR3-CNc%V{Ƿso3ԩip:i赣-{G<-/h*8M    'iv-׶Jٰ[+Ac5maF7&ѬE_yN|@"/p\kڵt J9 :$935r拚E\NZ)ĥ(    iw+ǼIItekҵ4kS{JkCi'f`!~㞎)L)|U+<;kF>bK ;jXR[*+`cmX a%2v]ZB:XֽQSDMsU8#jzM\sG=EuymeE.Emٻ_;V>#{mLGfMә}w'    'u"-Z{AzHCd9_pZMK4d':r7zSefsf'VUgOlXAk1DF=UՔySD.(40k>k>e-]7^on@u:m]=K'6ibÚr=vH%q;ήt/ 6d?Ȯh??jC!kaByFeHvȫzwpy=i }FsK-eT< @@@+:fFBȮ4{;]'CsѢ :U9]on.}F^ds EO^{Je~C'=lWԱ˦MoHF5nc5p)UyC2yLFgk{SmlkFdjiJTUٱ/e2S+-MϬiF*J9lM#L?mZCC|]ɴ`ΔVe|p4d;A9@@@@gD\5g# yKݽ,-쯖Zm6DWo^4U{NJT#d}S62s ?DƥS ٬U|+i晸 xծFvq yU[l?iu&"3\}[4f搮I   t ;5t;{YMU3ڎQ/tLҗ4s܏5o[Zqv?ֺ󦆭(jWk_V˔ж_['[[yLrK&P4v- cJyN+VL76N+ )- wkm4=bZS[NrJ/k٧2fZ:-P@@@E<&1DF}gko$=$g0][3h7Ӷԡd8&AIDAT۵0>񔮌!4K2F+_&`;;#coLY ^ndHHD!)m\ c9.hƗRO5sp GbI|+gO/]˵(11e蛶zr^tD(   J=5a[mձi~|fk    @>#-U{._ 죞SfI4i#-Sκ/DaSp@B?մQ$yz   !6p:Vw\Ǭ 6 ڲVڸslۂ6-4ٝ&    @L<    $!$(    4@@@@ E@@@@ 3    @$Ғ@    $x@@@@HBDZHA@@@@'":?),<@g!z-p.uUk|[UԸ],;3?f֖*5M@@@2~Hkp5+7W;k9=]"K+q&Uz^Ԏ՞麺nv̖2|C@@@z^Ǯ1\m9l홁ŽTyFݒKQPǙ$B@@@/4I%zsS+ektf|MTl֙1Xk/)pC-89Mؿ↘jفJ5x|U%ZTҵH vy QuC@@@@ NDZ îKӚ_4'$[U隹}eeNm ]{}6ǒunq_Tai,}E+feu+9U9']XJ=.,sLԛ{=oWZSjnެ=czN;J#KTzh?jWS[*xHk\>[]˵!9f\5g|[Iʦ   PiMԜ Tzzv aW5fh9ri52ō+}M& y ^oth²k*=DmwȔ*vPze_L~x۝a   iq`_H0d7܈]2(?m[?UY(Cc~w 3h8@@@@TDZ!Q C6I1_h,#CJ̐aԤV0S5u}2ӝr؜\VJܑtrlmjV!r%,E@@@@%@"5hkH*Mײl4#22La6Ʃ{7QF f(cs'|Jĝ?ǾPY4raK%f5cm/,[9iqu    ].Z#)'C\`ke$#5if}BWCm)|Qk׊[*+myIAepL֏U O@@@@N fQFjTY~UTtjج=L@H_^uRR7eDTsF!{2RCm-V0*E-w$چba;odsj7jv    p8#"A6Kc[2FMœ4Ɉȩ2m^3%{k%DWT嚰VΐIowo5#B֏*=@@@@gu>(@@@@[;2*@@@@N ɠC@@@$2*@@@@N ɠC@@@$2*@@@@N ɠC@@@$2*@@@@N ɠC@@@$2*@@@@NXWPIENDB`errbot-6.1.1+ds/docs/imgs/text.png000066400000000000000000005154131355337103200170120ustar00rootroot00000000000000PNG  IHDR^ IDATx x7$wBQAl"rD-"K.( "J^M(Ⱦ"KBtIyI6i%/<}23|ΙD (@ P(@ P(PTh](@ P(@ P0pP(@ P(@ P 8s=:N7@2Źј5 :{VrKeQL,ngC~LwRiD P(@ P(pg uPLe)C?ũRAX8|?< \p߈%|V%¤KQܤ5X(neDM[K|J(@ P(@ P@-'efn7x;tzg8i ;+On( .;eե(@ P(@ PNӜ/7CF.P}rBFͫ81wxwN5bu:l P(@ P(@'}&A6} zK< .m{*P(@ P(@ g.V=VEDQ?zr.ʦzd߄{o$H;)=N9Yؽ<EB\Zv:[^ӭ}?Z>` (@ PM07uQ*W5T*gMsq w7zeӕ|gǰ+s~IzotqA'}=PE7 y?R e(@ P(@ PpA1 c`5}f<~>ZٯY(@ P(@ PAFŴ esGDYo)@ P(@ P(P==!zf(@ P(@ P-vRLhqnq4&nMij10[#ɜ(@ P(@ PUR֪R(@ P(@ n>Y۟kO P(@ Pjjes)@ P(@ P@䠘_1hV 8[{ׇl^˷9[_Ы8op4q,\`S}|5|w|F ODfUF_.5~6BJnBD̪Pqg!n^Oԯ XQ5Z_-EDwz)VŦrKOj~Kk-ھD5qך?TߒD5(YW ޿W[t\y~mwCCDZUTDۯ(]((tQ[*RSSpumŠomau\'nGxQf|1f9Ln3NT*k7cFr'ޏLou)>rA*$ufxk$-}p "{a' -^j6C/#C.R/ _Ńu<L3uK:;%F,tCaھ<.z#_=Y_!EVBiBnY1-JwNn-`9`۠CǖbԾݰY#CYw ]rS+D:ݽ&OD _lҙ/_nk[U9>t*oj<8dVVfU._bobӌ/I㏕kbq 8c_Aݧ>~##BЪ7)vL3ۗsq0t(vg6S:e*R.? /hȽz r'.h*3 _j%1p81P# ؑW(dA tC+|TH=rBwlrry!k!_ɐ}nXގhP_.*W@~t@Az͞R@| ,>`tP͝đ2y??v+^FE?w^.**LJ}|.~K^xh>9^]O/#xj~oW8I9_ JR۱/_{q=I &BSP,Lݴ3-gÚhm򅄽q. ZIc@j>^~>(HL@üEg7~S1 P 7EɳGbW1Q4&xu 7aT6v(_FEgzpA>au8Q4нE\/>*Q~ Ŀp:\3~'U`˅\+wΟ.ۢ]"tP!X>r%B{`t<,#N: #b,m#>G͌X^cOcu |N1㷛(7$"K.Bӎ!J /J7-4*ֈޝB-v~7,:`a*."v,5$Vb>_oX:HD7K+ -qnc4/ld5eGԿΡϢZ⭭tkn.YBϫtF < /SB\Ϙ7i׆Um_tQtQՙns2ty@VN,(~]op M~>>q ۶] $|lIL%X߿ |zk6W"v揸ZX=>_6JmVҥnxgLS$| z\'0t5raSST>>zӇ❧d Fao6Uoe!7>1qʹr,b+>Ʈh޶T~F>5g XOSp~mKi[/VϏʕX *1HtWS|WP\Y <:Oמ7K@p~? }R%lN]~a {e "R:\ݽe!ÿ;JA_pc'QwA^q%-1`h'|l :_𗦢 h/֣(4_+*t((]{PY m8%CS Fbɕv1!TxaHDnk78^Ybkp?R[q:W[}AYG.ְ1YyU63ԥw>V8m_3EGC@x iGac0\ =^V+} j/%(x lXB}EU-_os#[W 0<%Ʊ((>D>Jak@KtFcN Ծ.9YlS2 !8>?nw?ݯ`%_j/CXAV3Du̓z-]_4En'pJP|u KH/)sOݛ߬/9 EoCpKϯ ;c-piߏa1O.Jz~(8>,k9{aȼ?KތQ11aX22$ؿ D+XSsдcj+Iy) ǃдةoCCDFH޿;.dAl97i%; 38r7փ,#H^QJfj WMeSBѱV 德?cӯйcŋ'# u1:/W!lo'ֺO*D`7d/̗E`˯˗}/o=]R{x&8 N|cHmѽ&,3_ǾO_ƾTm,'_͇]$UR*?q~&rՁp&aN}}Ѽ o*~~2r )z5C!0Rv %NE*VuokV{E(>cn~xq-F?×Ek*w -'AqSM܋DX20$- ݅n)>-M?jx ֎Dڼ5i[>0lEm[8-C#8>̲e#~nÙp{<\%Q1; ){n RnI ~{6OƓe,Vp~+:>?(WmaRX~-o:oçv=^ᚪj燆m㚖yLrok_lx])s7:38 !ͼ78{7BG.&۾[[Mdx2^5|Eh|?a䝃 *_Ƈx3;R.m!+1dQIG.U1N]r*c:d\01] u+ZȺMKp 4U\ pry,i~3ުdQ'rlIEukoq N?52r_GA[WۊW`e#~\H*HPnFiSgъWzfۯ>6ɫuAbƖB,,S? (Uqs2N}8fC{ g!(vV%8\ENC\ [hbՎϟDde#{aQھmpg/ݵ뵏g$l]xpǂZxVq `SFڥ+(x C{cIC¥Jo߾rSv`Z$.nsp]GI&X2oBYp~bBpʅfU=?RʾCˑt~'*>1g8+Tmӯp bԋt?Zut.!^gZ&~/}~ 39),Ið1~9jeL367cUw?wÏ(44괔[6vR_[ѭSʾP3֕bɺSL_m_m`,4[vi2oR{DzC7oPdU,[2[Ծ;Yv\C.=Kg!SX ]ױMC셺7T4.æWl -8{ׁ^,ÕU6?Y_B- /ʏva}nL>rڶ,.U?JXnuomݿ:t:rqvf :?Xk+ ${ɤ S'CV7&dOTNɻ3cC#wWm=?ݨ m?)CowW"yrїA"_d*0!j? .~^|~/)ۊ3]tgluL﬏?T#a Z .- (YsS&(r댐$4hT, OMPnAQrbV̓~]%HNh|19 ?9+|ԯ <$@uNG^gAlةQ+O-Ep rk A\>2OEGELuTWHѦ_H:S%ㄕ/<\c߾hlQ8g\ѩCɥ.:\M.+:}!.$׆ No jsBs57s>P8 f@bm?t;|m] :Qe?c*xxz˫.H^pW";=B<=] W9_Dg:xaYDEQ\ŭƸ^5:-gN#РSx}RWoQ÷hFyj9q3Zy Np{m /XՋ_j{)n^oнxtH,6ij@>A%ۻWt ť +Iۧ(Ͼ)'>i,ۧow/Ɠ| NUu@jC񍻡*MKnmcW%\ՅɮN@]y HȬ̥*wdѥ`{ ǟ3|he|A~~%Cv.gQJ|y~(_.:?*r1-_rGF^\1Lʃ!˗q,[Wt 6\ A'|oܛ~=MakRɌuE7"d.n،3p gǔ/t5I&P>{>QQ`0H9|$ަ&@m4<}\vΚ׆aTizv>m/`$aǬc[HYo[UXJп%۰Y>xa0r. <VVr|n?OBƘ(iZ(/7nYFXlJY^xm'_բ@ڋROtOٌ;K8qOdd߈I9y_@t+8~,Npl\uKP]WФ1 8؏/fn Øh-\<燢CA6$X?a}A IgS<ҸnNJ&L*_u8o/+?^Xj98ryalCj/zHvG~vs?ߩ'VyJն?4>dwuOê5Tተ`d>U|%c} \DSCZçVm9wF& 2o†G M ?7c&&kjEU,$?A䆐a|k] >{W]cqTcԬ2w^l?Wq[;Ue 1yp Ae[4bKL=GCC͘8YMOU'M1ZW5siN.@Y,|u"8q5ӿ*Xwɯ+-.ƯX1cE姗j+_7c^< O9Of#%혭Ty?SOzxݢNUŪKR(@ P(@ 8@ a[Q(@ P(@ ԨjQ(@ P(@ 8(@ P(@ P@ pPFY(@ P(@ P#pPZ1P(@ P(@ ԨjQ(@ P(@ 8(@ P(@ P@ pPFY(@ P(@ P#pPZ1P(@ P(@ ԨjQ(@ P(@ 8(@ P(@ P@ pPFY(@ P(@ P#pPZ1P(@ P(@ ԨjQ(@ P(@ 8(@ P(@ P@ pPFY(@ P(@ P#pPZ1P(@ P(@ ԨjQ(@ P(@ 8(@ P(@ P@ pPFY(@ P(@ P# yt-qq3mu6R30MpƅD,%>7ÆYh*>6m Ga/ YquUGb~ehɬ~5]v0[qBy~.S(@ PnSZuY?aOq*<ť^u…L8!?PplTtg{ gFeVj_nVBۗOR~ס*NAD²U) ye-thnUrY-/[{/|}~ixGVn-{3&VN%yH:3%_EI5e(@ P(@ PG|UAJ;F'cGr(]%/J7/GsȼDH!7-9ޑ#9Y? 6L!Z2y??v+^Fζr(@ P(@ ])fq\diمRxFQMVӝC1`hg[Bz̘5&WbYt5eKgJ_\ röm1pdC9 Z !:f g9 ?Nj~DB^QgSo JW!7>lFwAcsGȕG(.;Lxbլ9rW (@ P(@ PN Bz2"Q(bQ~Q:Ig5 eFԁ?Π>o$\i"XN]Ož& DqXm{F0ܫ8^Ybkp?R[q2N#1lY̅.3\ Qy@pu(uƿޮ?`ҠH89ƤQGy(]֣Pf:Ɨ?2K[bNEKbI:`X CL -ѽG#Ŏ*t>$-zːpF="?}!] r^ɳjiiYvkZO.ۇ-(@ P(@ T$PS\$~ Raʼn._(tI퍆G$: Ӌ$߶RwBr>}CPAX<+Hװw!~W*O_\u v%;oŇS``e.-C3&BѱV ʫ?cӯйc|Z?- p`N?uCs}8d=@b&# u1:/W!lo'শ4SzdW!Cjo<2v @^,$2 )/'cZq < '@+?l;D᡻0-Ŗ #H?W+qߡxnՌtB P(@ P@e*1(V٢kryw?KL}8<k4 j@xrm:TVAI>'9RPi3`S~YKđ7ČTUr*c:d\0JJ^joX08Sz:rLzh5u(,ܫ_ڄ͘>ǺLŠ~4[q|0j7`L= Aꢄa g)CSOE1$uh_\8d鐕 ͺzM:rޮ`e2FW P(@ P, CrqA~ޙ4ŧK@lA^j/[(]O}6RmG˅ȯ`ĭXAWxk,8L֥"ʐIypnX5|@Y[ 0& ³J~z:؋\ ۯǞñ DV:t:rqvf :?X2Z`#gQ ^q gb,v9uN܇O:GɆBNUpq+5  m m< 2iV'(@ P(@ Pq. Qǯ)F80`Gֿ}Ѫـq;θS'p K]t>{]%}YWt8B\$H 5A8ގ]y'p(3D?mQG-%Q?o=hT9$rEDcw ?ھ4_LC6AIQUT6Hڅ߀N*h]w7wxG'"5(.CwU!sP(y 3h+0p%?:Wq#$Px rk A\>ߘ[B`nhSG Ѿ{{'_9J(W P(@ P=x8,U(>dަ&(uBO\T=]vΚ׆aTizv>-.f"hL>& r>msw#E/f< Ha:"EckVd 5` L5vb6G'mP=MGШ(|f|ٍo>ۏTt{"  mZL¿}oO>/gP~| 1Wbcn飧`Zx?4ybWql%-gތvGzy}nݑ:\-L sA|H4kZ)@ P(@ Pr)ϣ4y[{;U(wQ^,+81q=VH޼0RVI*9ǧƅwYۭznN:o_Wkݿ>POe J!<߬1|Ykfe$5vU_j<a:}J/AvGI &lxD8(@ P(@ PÌuojqnq4&nM2C^l]ObXDlK;tUN˱l3<<$^+ϡ^Ku uŴ+fDrX?5lÑ4NP(@ P(@ <ѨY(@ P(@ Pw@l"FM P(@ P(`o[Q(@ P(@ 8 (@ P(@ P8(foQG P(@ P(s&b(@ P(@ P࠘EY(@ P(@ P pPᛈR(@ P(@ [bey(@ P(@ P/A1o"H P(@ P(`o[Q(@ P(@ 8 (@ P(@ P8(foQG P(@ P(s&b(@ P(@ P࠘EY(@ P(@ P pPᛈR(@ P(@ [bey(@ P(@ P/A1o"H P(@ P(`o[Q(@ P(@ 8 (@ P(@ P8(foQG P(@ P(fbaK\ 1G]VA30MpƅD,%>7ÆYh*>6m Ga/ YquUGb~ehɬ~5]v0[JoBof]BRL P(@ P 8I S\Eah['\S ǦIE ZOw}pfѡxQl&h%/}y]/EG|.DK,,\W"+K YNƎV*BrP͡I{&!ixDl1_?c*o4w|9XTbkI}J,>|ӥad 5/$vZe{`Z')@ P(@ P*`eTI+x<B!?ɥ[tQʗ\BxdމF"$萛hȁA,ğNw{ pߋ30c'PnWܹA`Z Gƙ9xaH|3ųw-53(@ P(@+H9Yhv4+\D +0RF( oIfequ@ = =ZZَeƁBQt%vaʌD|cR:&-z $!x4XFٝX)^uF.qj7* =փ |4񌢚;b6nN?2϶:(1k Ekh)M.[)ɻ^y/ ױvJv#)C)_<}cX7C= \uCt@tr^J3P 7rfc}x~;cVC[ y@ٺ.\aֿ<;MǗ_=TQ,^|aci 8&P(@ Pj@%qGƸ|x9F!p6S-\Q~Q:Ig5 eԁ?Π>o$\i"XM]Ož& DqXm{v0ܫ8^Ybkp?R[q2J#1lYuĘxg|λz5@a~6-vE Ω7&:ýDz47Xv*_/IWd@ CLsV%/f ;{S#N7EagS<zʅ\*t>$-zːpF=M0d!ҷGp#d@4h[ Z?4WOпd]!\iE8#^W1p, hl5Ә8M P(@ Pm_)\$~ EX~,(eߋ[Ohp N¡0u(Hmѽ&,%:}2 hfy88iWFaCxhKnU6h{8Ns@zKv0>>s<\^)Z^g~MXcc-:}WƦ_sҥ/Z䝈7e_F_4Y䊆a g)CSOe˰^CΉ1b4ݷq?J?l(*e8zY`ZL(@ P(@ ȠX.~8C;#trR-{"K)RE*@x곑j<ڜ_.D~a[; ?:}]vNy&RnB 7UAj4AG[_ l4 Ϛ(*I~a^d~>Len&Z0 (iѪTg A*z!;Yv\C.=g+*O?8oųcrp(@ P(@ PED1|N??oT5Gֿ}Ѫـq;θS'p K]t>{]%}YWt8B\$H 5A8ގP IDATʆZy'P)Ϣ*(3Ģ?mQG-%Q?_6L̓~]%HNh|19 3 t >A@.|ʧJQQXz }F@xjx7\:xZQVewU!sP(y 3h+dQw&7 0I7G/L# :QK+(@ P(@ P qQs}ɂ1M L>Q$l4<](sӞ. ;gMk0}xH= JŽY34& }g9IǶZ3_I$0grKˢ1n5KUׄ~\m&L\; vǣ˓6VO#hT>X3 r>R7GsE* @ ʖw0. H< "AiAXcutE}L=2[_|831 ׎ p6}LBtIiZY,-thZ-eoE#v)}~fb:$mp{?e{Qä [0 ||-bqe ~]aqt3 b2(@ P(@ P/PnPRI+x<B!?y%K0!/J7+Gsȼ,ؔ!7-9ʥs)A,ğNw{杲.h*bwJs1N P(@ P 8ټr.a)i(0*oW|okDBNtH;k|yPWtУU\EX6kH.4-|L0eF"z1 ]B\Qpn)FOu/|Clc~B{`t<,#N: #b~_[mH>xFQMVӝC1`hg[Bz̘5"8K&Kέ) !:f g9 ?Nj~DB^lΡ8o>+ GW15vxiz_<i@5LOÇ%\/<7b¨`M0d!ҷG_6pD )ЮmpcX6Qaw'>;ER(@ P-`bE``_)(eߋ[Ohp N¡f|ۢ{K-Lށ Q]ǾO_ƾTm,'(5|-}~@bZA2uUmכXY|8 [J%2:kT]-kqlAP36:;.m~ɧ5Ӣ&Dʧ+^74ڇN*>2R~ـ]sCrrvn] U)=2+A!|7vOTBˉkgZw~;JOS^OƼ)q*\/*H޿;.dAl996_IX(@ P(P*1(.pry,ivԞV %?'YO{Kx3}8a~hE+_y4:gf JłP(UQ~J%bBq /Ze/ **,R"B+ Z [YCB ;If̄$3̝,C'NONg>s=ϽSW[yu#*Dw>?_=k16U@@@@ *(&*[cY1Q7D'~U7UWz$hPus~a%: ~U.2B[2"KzZ&Z;f޳rk귯iBKApwf6BnRìݡ5;Cnjn%(V%)6;uUїwP̏r=Z>jl~& )T,JrijQpdosdءV""DaQJ(k?/Z    @ xƧA*J C萊[I4C[~$J_^M?.W]Ck?i–c?*R=:UsyD*QFD= W_ #   c(KdDtdx.L2?Ocg0$N"fi3R+ `5{MIZ~5oy//hfk0koVhWncL`S]E9*-PQi2ߑNȈU;̞H@@@@ p|(R^is5/X[,-q&G%rڛϭM۫TꜨ(]9{kɜfmͯSߡxTٻh7D{5^{ooQ.j=?zq UɞW5tt|ɡœ|; (,= egӒ?ӣwҨ ||[LG=$U=;^щR}s|$o3>Gnߵ-{ޞ:M|jp]lD;K׭{EO2Fc_EM9   ?nZ %tpBzP%TYGܘ7+d*&F-NI#ǫwߝu`rj=2*E]ߤoWꛢFhYmݷww7TtxNݢ? 骼&hcC63e;Fy]amݟ7-ՁŃ5p^θ Za#/*5=:;]{Ǖ 1r(/[F>^~)(5>~9c4+7*st2Hkt.o_k WKy<>^f3po}S;%Usk~{l@@@@jًt`Dp >O U¬zf[lQWu{Bܞ/KӎޮTƭշQƳ!XwPRbmAߟqfӕ_ꡉ].o:S-V-tky{{IG||;1{ 6tmX:*ն6{_Ӎj{^n[Misete|Sϗ}X_~XFK$,C3С/`ωZnPl򼯿sΟS_CΓǴT8:4=>m=(@@@@B")v!©4i4-9*"e-PVSF*R:V(VEX󷫈֧!~L_8-!QXaEv>zs/;obRԥJrxb#ڼȡSg4TVfwܫ큯+5cA[n2AJ19C4#r\'YkG4,|:4IX]R)0?    P_DFҢe:}߯.|Б 3'+~H,p㌲v/~l& Qv |Lo c_ֈ|b^G:VDk}c=   4(7$Íva78Ƽyڌyʹ)F19X+IMq3/^n] V*(ֱ-hƛ{Tu_=Xbjҫ#>D*=Mݬs`?61MQ3,@@@[wA @@@@@flL IDAT!Ƶ4D    /b_@@@@ )KC`     )/YE@@@Xb4    /b_@@@@ )KC`     )/YE@@@Xb4    /b_@@@@ )KC`     )/YE@@@Xb4    /b_@@@@ )KC`     )/YE@@@Xb4    /b_@@@@ )KC`     )/YE@@@XXDIz?=]ѣl[sT PJfjkެܢە9,qSi`ϛt6 V& _TLOW잺sa~,XC|߽)n.61?}.£F>xڅyhf垽q   ms2FO4d%'U0T oIJ$ޢ*sq-sPm>51쥲IL 'ew4Et@@@+~L,"6WRgrTʽ.7+w=[W9QQrג9/kb9ۚ_CU Qwтoh [ԴdO=.-CUU >|r0'_Byl, K졡iCd4jB+1QwIUώ)Dgtbk濰Tߜ.kyp:ѮwdުNӲ.r⯲4jW}PW]iO\s; ΎV}gkv򁜧%)ʱvo@e ,~mirbWMԳ J %''Iȷ4h-pjۆecߦӎ4PTG@@@sOHLWPʫׯ~o{K['d4JiZ}zQm|Am<y%)gB>]Uc9ՒlBƘT-Na+{ħϪW{sC癰jC߆%SRm{k;<E?ة-%4n;zH_YMW6٨A79|>W eDQAҪ8_9t: MoR_ E?_{+,(C>=jJٲVYR ~)ү<Ϸ   H7 pbkpcalq(+)Z#bp+wdZ"RU(0d؏'=b%$Jb+ӮGO{e|gMlQJTIqQlUVr99Tx옆쮓{=Uz`f,hß]&_=z\5͚gLPڻyG߾4uަsS5Cmd~/P@@@$)VӟǎC5W Y7"&rWnS#_>|ͣ2);OX'`JWEVLyZ7zf*3Ze*V*)h9gS,喝573lo^E!ӟX&?_oPnqV>L?-p癇Vb }9++r    &PEivd StL35kv7&Jy }(4JtbZ}S]ueGB8U>VYBZWKY.#TE-C,^WЮ5ڞ{nz4m_cMNv^߾ 1W,aj߭, IIV{#w U{UEXu^8;LkVE_A3?[$k0oPezC~U۳l׶ƅmdG@@@+Ѐg|̑ C4C[~$J_^ߥ   W-iF0U~8ߢ~qFY+y) S/qڌyʹ)F19X+IMq3/^n>}syz'[ӵ}zG[Tyy2S*l#5!~=A6OӘ'c7(ǨޮU#{vӊ(o\,Sο?ղ^և{ ¬]Y_Ԕ`/S@@@%17j2__U-r 7+wo ULU{+1&̐dWQNr TZ02m@Lwi2cuzvzH}ܣ~ՒPCt]4_{[=CEiWܟuS    sNQj^XZ*LJ3f5v|Y{qڴzNUΉؕwyYkN ٚ_CU QwтohӉ2kޢ]&{4~1@ui=jtm s(gNŢnJ0NK6LޱK/Ծm1uTxBtF'k K򑼖'9Ϩc~>Nz{4-[-HMWMp]i\9JUTT {cztO7^V?Y}e 1eU\gR3`;[$ƸIG{ (kLJzib}>+@@@Q Uk qЭֵG^Tj=ztw+bpQx_x%|RBRtWQkP/}F5s2+7*st2Hkt.o_k WKWPdW^qkSR_DFҢe:}߯.|Б 3'+~H,p㌲v/~l& Qv |]3H/HUNGZj 3\l=nqN]Zwiػ&GYz؟ƽMdSroҢYIS!D@@@joN7څT~8ߤ8Ƽy)[6#!ern`kLV#sƊ7~RSa዗O]X^!o=k`{=`4OV5X9mJ?PԢ$M+tkRv<{ˤy[(RI*@Z?D @@@ -*IvjA*[ QkZ4$CsYz:B-_RFmyG3ܣYXbjҫ#>D*=Mݬs`?61MQ3, Ā   p#p`<@@@@.! x    )@R,0ׅ@@@@(@Ȑt    $s] @@@@$K    )@R,0ׅ@@@@(@Ȑt    $s] @@@@$K    )@R,0ׅ@@@@(@Ȑt    $s] @@@@$K    )@R,0ׅ@@@@(@Ȑt    $s] @@@@$K    )@R,0ׅ@@@@(@Ȑt    $s] @@@@IN~z]+4G <*ʩ!+׬YE+sX>]>}syz'[ӵ}zG[Tyy2S*l#5!~=A6OӘ'c7(ǨޮU#{vӊ(EQWޡGn΀Ur/㵑 #iW۴h&%Ajߔ751zX6sQn9iȎ*mMVI/xM\J&1U۪{t:fls   CI"2Q\󞧅W_ެr-JnOԗ+U>iGuoWM6 ڼk 3sDRMvӱ[Uuo1oo/<"[>WJ8{|; 9g@X:*ն6{_Ӎj{^n[Mi!}f6]d6y/T43r}@vyUӪ{/t(6K~6scSx-j-_>;#[WT|<,>e޾պz8cMx֒7" ڭ)cKKO ݤs23eʣ0/5nslaM5jtŧ;%ߵ%@@@h?X\O;^e G%{AH79;geWnS#_>|ͣ2qKT h(+TBVvz/s?5(Rni;Ps3<j]>顋aZZ |k1W+jg1jwqd6LJ k|>6?քI>;+<3jXrv'~>YxEzw͹6.@@@ TdQ.̔laifD2SV}TW]2NCչϭiU4U1/AԿ}jf($&%_륁gCkv=*"KP:?K/KRm&垽Ϊ;y`qdOw58L,~fa HtxxPICQZED¢pQXB(ZS*D(Er_Ctzcz(=!lCZC5H}{Je>   @ch3>Ӷ& цC*m5n*~z3Q={MjGi-Uv4]sD2v4K̷SlQ5ʲKaSIZ<'d4b+~DYFiѲBپZWn>҆?$US TqFY?SLEV(;>ﵚoi{zz3+]HWjT}$G5ouz>M?.W]Ck?i–udEhx;59Qm>1uk]E?=[̉ }ҟږ<Fk@@@@[%#$Íva̕qfï5>k1/%ppj1%N2X97H5&9c?)vˍocZSb;ڳ{=`@` sb圲+M+Eff"gS#6Y#ЭI ۵I/RliğK'ë%j@@@@F(p΃o$M]R?HWez~a@O2_[-ck_Ӥ }tVg9:g׉Oji'0ul;׿s`IPZ4uN;ρUԢ4F @@@@Ηoyj-@@@@h\? -    HK~@@@@VX. !   Kd@@@@ `H    HK~@@@@VX. !   Kd@@@@ `H    HK~@@@@VX. !   Kd@@@@ `H    HK~@@@@VX. !   Kd@@@@ `H    HK~@@@@VX. !   Kd@@@@ `ј[#We~qEQ],B!MIEUf{yH%GXKf,Mtgud%,8u<^PgTԵ:3JW* fZuN k8AO$+4iw/+sHNNVcI^Ů- k|=b k[g Xuj+k㋚lxV w    E"#JyQIZ~5oy//hvfk0koVhWncL`S]E9*-PQi2ߑNȈU;fKJsc6mj~,@@@9DHyռcmTę'f2koV޿{\g?6mރSիs,v]%s^֚rV5N}TRe^Цe=xEML֫hcǁ2T%{^:' s(gNƢnJ0NK6LޱK/Ծm1uTxBtF'k K򑼖'9Ϩc~>Nz{4-[-)*Vívէ~ NT/OsZ֣|ӰU`;[+HӔΪiN?"U6WDY;>Kt *    'K[gIPݕR9TZ9Yye.ڛKXl ZV NшQ);-}gXI}Wqi58C3[;O[!{4lOl|e;FhbJYFQCql I ~.=Ńվ)ojc=:1L/Œ{o=QM1u+|EݵQيӍç)ޯc.UQ@@@HI"2Q\C#0+^Y{[.Uoݞ/g{Ҵ+զqk}TGlԦXng#tzhbWTfuƘT-Na+{ħϪW{kC癰NJ:|?tcƷDׯۖhӸu_!}f6]d6y/Tlc(x_*ןEI'2Uʡ_Pm}zmωVRE^sn>S>{f>V[@@@pH]j?R~15MAڱ0MKy(kH[ [jaLqaȰ;Nz&j?sZXBi-ֱŠuiq-JIS*ɽ=j^"g# OPYurJԌm5s{+Tkg2ÚuZg>@Bw;/5]-   ?zHRH[O?2k~# KF$>+V|8E:<(әs0aɊ=e*V*NoS%r㧆Y-;cjngB\ BZߧ?=t6L~X <^1F|cXajQ%LBNU+gh;j;->v}[}{m]Ǧ!   +PE9L͚Re%:2#!dFkws[,!-izty,_wݢ!YB/U+hm=7U=/1K&'+߾n>}Apwf6BnRn_;fgM-Ū$9fRڔZ}y5Xl9W`]uQ%\:PICQZED¢pQ?[Mx]3nd;?SbE@@@yTք!pt[}ܡm?t[%/]/|${MjGi-Uv4]+qvӧ)~Xf`2)ϯW] k7L/OHhDw i#^W6ׅ>DFҢe:}߯.|Б 3'+~H,p㌲v/~l& Qv |߬Z}0sZ>O__6Ώ4uʬxTEXRGj;H%9Ho|+g5dpgMP9rkyG+u?{Ȧ2ߤE>!Nr   prFtdx.Fgt˜`885^ g3[0ud oA3VAS@$>$y`eV 55q(@ P(@ ^@w*_k(@ P(@ PlT౺FaS(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@`RR(@ P(@ XXI1 9 P(@ P(@0H9]aa.U0 gcd3"$UGR28uoa(@ P(@ ؤ]Ѩ)1y"\4,q6f3?ѹ;.Gquh?-Ҷ'u*hqkv 1Y yXۯ<-P!%E!n#U5H<{lг880D~jDp/zTTG{w`ЂH/-ǯj?`.( 5bĬRp;]QbCTSf}zw3 -!(@ P0 IDAT(@ ض٧QeGo jq fʥKڗ9MHŤHHCFrT6됈_+1np2{eJq?Cs׹>^i뿝BCIiȸ{'N#ŒSD`_ؾj+jgM7)@ P(@ P%PL1 <~gdpJ@~bFhyR׏+uy>UFCشtFdB³.M?xL~FMחrX:&ώBO#ƨ aovRFTjaSA>qc+7|³W7GCѭy58 1°bV\Hd[-ŕ፦^PĝsF7XBoW˳AܩpmąK\z|w&Ngir|0ϣ ΍<(F_NN=jY$ >+r6#  X1^}lY&4!D'iK 6^Oyl?[.:~ f |l9l (@ P(@ P8 5ju}6j rKx"U_L{ D c'#i/{ wwZq=Q[{o0Eu630OO|o :a#I HjGv@i=b/;8 l2Wfэo>Bdp ^~ Yxe#7a=УK| C{BD0P,*~$|5qRRTG3v ~w㛿ݡ=Lj~QTFS~&ۣKI/)@ P(@ P0L1d tD]uw U^tϥ.)\Q tƩqOh{Un΍U8>inhK袁8ܳ3qba"ÑS`z4v Q7*]:= /(|?0FiPMmo<*'6{_6I} + ;3fnǁ4ޅVv@0[$];;ޝ)9dK\'*p~EO*/* [zS~ڱUpiڷ Wq` J%Oit+lSkE 6컙5RqHhxlz~O؛>~ ' C`]yk=p(@ P(`)R$,eEㄧ~i֍Ǧ Σ 4Ħ ;{i+פ!MVUnU_u^7Lm epg"*=|j$O2D+)\0{+ϼ9 V =1=O̽Ҟ n~yZf[ E{VHtpt@KrU CH5d㟐)? |wu?\2 2I1IODpsԞf8?ّ''y9SuERKG P(@ PXA-⋉Xqb*\]I4(~RIr/hRgƟy4A^ ?YBc Ozc)(9Ȅ#ܔW*V nMZ,sg`,fPv#s7] "zaqx=2Q1vS^1]= 2-Yp|8P=v&#x\<ڌŮ,hQa1V,;;ѹsj! cl(ص ɽMhw| ot zՃ PǭQK% i:jOZC6S.LPِߜ&.^8 l檻$X|(@ P(@ Q!|z.z -9ȅwEbDzV6!VΏҜ2}sϹ5p/L& jx׆ۣ52 9vk9O. JR\;K*)o 9fZf(@ P(@ ؠ@ ȽK+ahƛsk`i<} >= բsd6 Dԙ-:(e@Ԙ̽fI}I )P{kj-((@ P(@ P([7(@ P(@ P-"CۚFK P(@ P(PQLU,ۥ(@ P(@ PZ&Ŭvj(@ P(@ P@E 0)VQl(@ P(@ Pjکa`(@ P(@ P%XEɲ] P(@ P(@`RjQ(@ P(@ Tb%v)@ P(@ PVI1F P(@ P(PQLU,ۥ(@ P(@ PZ&Ŭvj(@ P(@ P@E 0)VQl(@ P(@ Pjکa`(@ P(@ P%XEɲ] P(@ P(@`RjQ(@ P(@ Tb%v)@ P(@ PVI1F P(@ P(PQLU,ۥ(@ P(@ PZ&Ŭvj(@ P(@ P@E 0)VQl(@ P(@ Pj bmf`WXt?;0KU( BýllS3(U_\G1l^C4ֱNǎ|CPAwUFya[ '}UDF◹wn׺A T2?,S[9pء;+ʇ#{(@ P(@ Pbe)Y@ahu6:UP9@תh0 ԑѢLu*hqk'VcOY7&em ))*D[fXeL/>f\>AVLc^`J4# eS|%۹IDOȚ,޺GWba]T3b?9(@ P(@ PV'P,)f, z!]/.̂ݥ v4!U_\׬nr]@-&FFB2ӠΖDD\p+Sb8U|*9Q'܁hX y8b50!V` P(@ Pu ؙ@pF d'fʥ:/U߾~\y+7E25n¦7"g`ti wu/.cr[6m ~1yv}:|}}5FM}O| =!tddPu@ܵt>>z _lkPtk^ B̹0Xr{2Yn~aKq~xqylͅ3!ۻrl;dxiz˗`#pqCvT$28a}Ю;E "؈a}c;>hϦs~x rc[8C"`vέdU P(@ PJ'`~R NW|v/KK&U_L{ D c'#HpUU<,77►:^^OcQ',0|JZ)k0$xsF_OB8 l2kguK10g_T EST+{K0CH >Ehtc?Gwz+":}?aӏ A.Q.48684g޹_.AmALǣ Xn;v[[pl ''9Zz I@-T b-KCpQ$zKG`䥿0~o,4P!"dC}6~\$U$BF g:§(@ P(@ X@)b<3cݝ*/RM\D8~*7G*7uh^rfĉo6sGBO7D%du{.wNG  ѧ7yhԆ焕mjp~ <^Wy</)T2xQ6OڏݙQwe'4t'LӠF&y[ f*Aҵ A/š#H.u*ߊ}}5-2ՈM*N\p.Oe!n)@ P(@ PYR$ŬyNxzgr\43<]_ +YgƟyd~Ć"'p2 p+rcy\%IErNLe ڞdaUP=L:X2DU\8F6-Dhѵq:Vg'|㡷M| {/;ݻQI_UॽGDKS*[f>_Hr9L/R,xq+7kqP(@ P(`.|S 0 U2֮Xx }4{b5VuS#:lm >^ g3[0ud oA3VAS@$>$y`eV 55q(@ P(@ ^@{-C` P(@ P(@ ؖcumM (@ P(@ P(&*JR(@ P(@ XbV;5 (@ P(@ P(YK P(@ P(`LY00 P(@ P(@`Rd.(@ P(@ P 0)fS(@ P(@ P*JIe(@ P(@ PV+N (@ P(@ P(&*JR(@ P(@ XbV;5 (@ P(@ P(YK P(@ P(`LY00 P(@ P(@`Rd.(@ P(@ P 0)fS(@ P(@ P*JIe(@ P(@ PV+N (@ P(@ P(&*JR(@ P(@ XbV;5 (@ P(@ P(YK P(@ P(`I163+, aߥ*p6F6)zNR{/U._Wv{Cg/!XJmcG! Y;*0-S'U^ ̽fn]MEP=' :Tj 'v"KW7r\Thl(@ P(@2N9ɃraC7`W}~~Q ~,FzO㟃cւ&tI՗*5"1$h Pg'&#(PamTBHDǦHj|RUaU#yÀ2Z z'FBF @ VMo)u๬?&IFA\F'`ʀXx0(K)@ P(@ PvOʎހ"[ rnr]@F1jd$!#9 *ug($ƧIAĕ78r%R!<SY^ xf IDAT=XD,(@ P(@ P)ft")*Ox/1Q./Fǩ{&?/l@j? n`.&r{]^)E~pp6-]g`ti wu/.cr[6m ~1yv}:|}}5FM}fhTM: s~rxLǷ1sB7=oOcwz(&̽9znͫY9KBRaKE:+S㏯,EBPB|#3(i|as L(|a'KKs#X"~ʭ1hnOtz p8]_3rחG3yQKۅ!8+b}Sgd8 ?cɠI8w̽\1dK` TYeV(@ P(@ OJqj3mA˕ONwpqh?jbW' @"Y1NF^|ѷz`xmgx?a\lSuͻwd}7쀔0z(_v q@d 6߯1)|*D אU'O,lY9^[2=68%@F7Loc/͹ Z-Ն#dJ"U{"[H,Ȕ4"h qV!V"yɩZz ɣj99EY~vR#Z!!V4't~sDFc:xzu/ahofW}SG O8:Z*-_G=Q)(@ P(@ P(ER,gTYuaxUr' Sg%km\pE'g.靑{stnf8h {3gĶ7K@dÑSȪݤ/W#CEnED"tge^}z@b?cɕKT9~;$IM=*߅K2xQ6oُLuo;uNhXIz|Uo 8;0ZSSkߐINw'$6+>m_Oz w5bnCm_ҙ]L842jA*]z/}ꘀc_ W]s4x5 k!9 1ٺa/CۉQ?QtQ?!S078"~lY{oeH'1_uF#ojGaR̿@.(@ P(@x(I8V⿑Is2&N+'[A-⋉Xw:-ޕJNʕT.w&qfGdS?YRL!' b%u̟z/uǙss'Mhb=c5ÄJ#޹5s%c|bX|P$C08_<@Ԙ5~3km4sHk|3"TO{6 ^m;YB.@y8n @ፎ`Azpo"5jM7c8v^//b6fD](@ P(@ PV'Pp*|:z%~tKGxqQw际\9ۖɊkJfc`!\*];op ox^d!.bՀhacntn1L/Vٷ~Q1kV_H);X^0#b֦pYzWCx'VgѸX )]eGpQ cCe&[* ~WPˮF+&#N{"AųŘް{p ]/+A$Gd>c8O…AMs%=Ͻ(@ P(@ P6qn3C _GpXGhmXޢbٻڴv»"c[ U6!VΏ[\u555555555555`%k\>L$$_nȤeJ F_ زN ߥ+J{Hz1T P(@ P@'U7 Ub&._|0/֮Xx }4{bT>b؊yx Bc xe0c4uTNbH )P{kj-((@ P(@ P(.Az.|(@ P(@ Pv5{ (@ P(@ P@++Q(@ P(@ زb<{(@ P(@ PLL(@ P(@ PlYI1[=N P(@ P(P&&J(@ P(@ P,-c(@ P(@ P(bebc% P(@ P(@[`R̖gS(@ P(@ II2(@ P(@ P- 0)f˳)@ P(@ P$XX(@ P(@ Pc(@ P(@ Pe`RLlD P(@ P(`L1v P(@ P(@2 0)V&6V(@ P(@ Pe&ly;(@ P(@ P@++Q(@ P(@ زb<{(@ P(@ PLL(@ P(@ PlYI1[=N P(@ P(P&&J(@ P(@ P,-c(@ P(@ P(bebc% P(@ P(@[`R̖gS(@ P(@ II2(@ P(@ P- 0)f˳)@ P(@ P$XX(@ P(@ Pc(@ P(@ Pe`RLlD P(@ P(`L1v P(@ P(@2 0)V&6V(@ P(@ Pe&ly;(@ P(@ P@++Q(@ P(@ زb<{(@ P(@ PLL(@ P(@ PlYI1[=N P(@ P(P&&J(@ P(@ P,`gI 50aw4ԖLm)P{]}5N냩+Q(@ P(@ ضEbZ uAL3`Z`↩p] @vsaT<_lj1T^k 1p/f/ǦbܳH|tQ#1RXi|2l-³F @ 9(@ P(@ Pc#`XqffoC'??P(@ PTJEnrGr{(J(I1%J'@r,{sN8t$fwx`醣I:oLLҠ}骗Lf_ʇ}c_[?>PoH P(@hOȏsbgHh,ҞS 1q.1JyȄ[bi'}EIŮY m3[^ L~^ "8d"c)a}55555555555555`5`7ڷHjOۈ.P!EFvz/(@ P(@ P@#yvŬ]&7 [GHkܤ(@ P(@ PxbQ9bEiKSO[i (@ P(@ P"̹C~E϶)@ P(@ P(,~5Pa;>*P{]g(@ P(@ PE/|l8P P(@ P(@6=} (@ P(@ P,LEu(@ P(@ PlZʒbv v"KW޵7;Xt Cgs({[kq7XEut kFBegg"g"B֏POhgOD4@9K+~ '''rNU;Wo ǧM?孟-&X}~Rx˨,l}$Ս$ fE^I1eS|%۹/g"t˧hZS#J: afE,۷Hen$d~__?|`jz)**PT D)J*Si>=T}+@͉ID224Pldk'ůO2~ X"iW ƵC7܃1_&'cGvחܻ  Fdl4!g>b"3r Z˙yd> x>u}1:$95@o^Pğsr*LGr@ 5r(5d!\V,ي I_moPtn GY6b/otZT</[VVᵆDy f.Yv{!(!W[g PɠQEkh}|ү_Qc2DwxB<΍ ҵkxaQ?Ԯ-v#>KȶM X1[+0kI-Ybl܁2C@p`׀3p`"Qe +#KwvF4d:> 㺠iO1_ o#v#^~_w^</5RԀ̽> Fͫ'1\zsWrjhy' O5 y87/nMM]&wAhiKNoO V3j04Tk19XA)c#ZDjj>VAlIj1)Y@6 D*d˟H:O;鷮"ױY&[' G5nDT߿޸,X.9?F@^)E~pp6-]ſ?> ??+F+O-}r}Q$[Eϵ^.亵D.`+*Tԫ2j _[~rpkMJv'q ⧹IGhYDe]\pU.}A8kS_%ޚt~&z}@> K߉fAMD~S(U 7o,*+e W_{>OGo*,ǡhueQA&O"UјT TP}G` )0CGC}IsG ?rV/WaF' l-0W:6#iA T+K{C9WT ?k)*˄§hj迲x>AthJQŷ;? wQ4ޗϋ*q |!5 IDATL8i**---([Ϙ.n)Ec]2(O2{|Ku*oN*俦}|DֿQ?V6#6b?!bt#(v-S_5eBxusAOrO0}0RȄ]mb~'/!αGdJk10վHo}ץߣ]Ģ]kEhٵPtK*pYcyO #x|-~D>y&fwJCvlˢȣN-+ @.=^sFY_O~|WHOaF?%?x3-Ӻz !W/;O>ۉKEuʢՈUb}k׀ ~BU?_7+t3?1#proL ghG~FD63Bx")>L$Ǐ|N?qV4}دi}F̭xhbmþ)P#gf ()O/6I;} V:)t\Zq?8` G&]tgpv~pH u\v!1RC*xTvٿ˹+c܎iP#g ѭ\xib͕5|Hy17:%/>:q2c"235i P)`: 'w<ί&lͲpHҭ4ZijUv#%1'mNܼ 2qo]o 8~B7'-[Gp{r~;2)TvX[X3hLdic>r 뻛Q?> lUXWu?skg3M8xQ/Q![qԫݪIMz2Tʰ;-NH0:H_Fn~b ZNi.⶝H݂H;;ngBl; ;YN[F?1݀͂#fĤ~돹p;O"QKfnݛ!?'&ƿ;oJɽq0 8ux˧2 Uzx7gVApH8=GZ R?NMﶬ^_oɫDȊ>>|RbX/~D-~PGr_בt7%E}E[Q6!,#Xe(5_ -j|~ :+FOXEqX[N9۩ /)?_]RL}W芦sq]$7 a&R/ڛ,(Wh<;7½~U5.5 7㬺54${yR =1=J/3pEЏ3:<\@J6 0qWc5{+H @&=WGя2W3F$z+/'G{͚<ieXؠ@vzvj,(^*-S{OڎH?{积ōkȉU"5pEr,euTI$j[@{)xdk dWE&RR뫑y;X?Fs b%6tyd 餘Y +{أlx_i}q{%2g78(QwƗUm89 3]@UжF4R3 |׷vO\Y?{Ef$! J "W䊈("(DAD@EQAPr  *  UHD =gw%}7 >;3y93u?:qP~٤oM2/XcIe=ݨ_ Ӟ: Yvu~xi\͡bg:._R*w+kzpmǩYYe眏%aQ\Q'EՌMo~Q#1h:~,] 9wQ= Ge=WMk#ܥW_/Yz5c?z |u\qTڏr;FjEAًqdr}vcڂb*ޛȱw57V GК#آ: V=?֧o~;݅`ufwY/F'vݟP[W}-ijILBf6^g%ǭL! #;.UuX__ixg?o*GltKz~'b[]_@O_Q d}r_؆oϬks%lbk#e2u]%_롐Yd Gή _swl؍Lx9i9ƥ_G oTnl—uk&>4n장 ]^Eo}![b8D[v3FDv-;Y NNdBʩ$7hOh)b\V"#͂oX}z[b =+W Ot&:2-Gou"zB 0w}40'^3Wɰm36w5FP.OZyj' cvd.CpfT1hWfs4ܒ{h]9tɿqɀJ¹4|kYBNj\} :\!k!.9EalPnU?R*5YgVzRwpt$&%IG܆^C֞&F8 2ԆN`=7Q/Cχ l~7KZ` r9zܩ~_};i7Q '>NX6XVkt+/s^H.v6~:*ҹ탏O]~y1~*` 9@H}A|~ ?*~??1{uЌ5qns A}et5L͋OmXz6l06mcmg4wŚ53KZn@aOi{>H`BZhDΉƅ3r(r4Zm9醾~rug?*.xP| n5ѸM%'^Kv['^~hr_qcd|-邷J:fZX8ou?pH>QK^/^vxM9;KEU(āFigr}V7V~XHe;Yg?b[LԚM]3C mD;]svt_]=AuvO`Us}wAk+Lrde_¼eBFGLd̒}Q%r-'S!`$6-gmw f1^KdXߧ%>z3jJ<şedv-^7!:]<R8Vn>I r'S8/b#;a֎[ͺhcayeN 5& h*w;&riaf=#@ZÆNj_ 9Ƀc0:NҶl~c}"Vt~3=K?޿s=V"W13aХH:l3OY9}3`ʐM?I9=FͱT)4_&zT՗I}'Y"Y91c >cP&a&9I/x-`LggVv-,p +^Fc<֪t@_N_C91w_fs SߣKC *Vά\^1l&2⭥J:ˮY_ä8\BW^b᪗8N ٢[ih>Y \.\<_+/NW)cQ-[y}5JZˊM#RAB=BrNXv_iF}BMpTUU}J"ǑrW~#U9_g`s#3E%+GQ}Vn穳~E_%ӗ+2н9ENoNqy-_mv6GbF d$ԣn}BH扅G޽AӥWݨuu_&oX/Bem_U>~%W맲WX(2 Voĩ4 !X=:+^`g1U C-sᇡ&BU/̓k҈593s Z˭]ǽÈ9ږl./ejǧM&T[׿"&/SETBaiyWU *=?T4}I*8Tb!~%'˖lJeK@[|MZPbB@! B@! @%B@! B@! nzQ B@! B@!P,+.,B@! B@!p3(נWI^5*ufXpRϢ^N.EvUq+-Ŵow߶50}- y $Li*m8Ak>|aS4_tWί@>}wX:2쵞2b,r+>ʇF* ! B@!  "{GUNӧ;A pE˵ QOXZ3AܚMöA1v̬Oy\|`k{_)Vp 3H޻?ԧ#|b>ϱJpg3L I` hV H ahⴤ"kpfxj;L=#g@D gRdƔJzjmvEHFr2[;ijWj(fϡqIgx4^ȇb-GL4pZ<ߝ&-lgv̅{tC0-g گx׃4n.{~)зC 0jE/s!sC뿴7,֠l '|MIX CGC= *?Es*k5'!<ڮ6|1#֟L?PߜS{:0`FGP^~~)'MyDžn$6ȩxlENȆB@! B!4p[o+c>t{A]Z|nP1X՝T XKryJ,Y{ vG2>FUu'ꉹ핗[=#k|cfV|zkXvJh}zɪ]D3P l`ɏP~£ sC-G6V:p 5{>X5ZRwqה}ꞍQj~*v? TLHRqSxWtV1wW_W^b pWuU1NqbLEN3EbO:*B+BV>M*Tˊ9%Ƕ>@56Y}1hJFѻj]T@~u=WWVSaW&'߾KXXE5;N=V iY[Ubja]sNJyY;9.Tz1Ox{VN/}zdzrU;/7 嗣MՈWm\蕫ջw()c`3{2U~NTK_r *JMtV y lBwUWt#>U_JձgCUsSm}"'?¬Zw UD9VSz[_TP*jafշ[M{fѫsܫo|VωP!n| G).f%N4  Dрh@4 15`)oЍkpj0vDެ~u-'OXgNq:rOZS7+t,i$l]˶st6F~_~s̉}u;^ze$a;VeZ` Ho9r??MO!8`Wd,q~9x:F7H1֦fC1vϭ$af[pEd}qzP`'h c"!1[xTq{Za߅)@_6UCA H:Q3 I&qpVĒIۦٱ1.wTKx&?8AUv+:TjVm/Oa!/Á3]ȭB]teջ!-Fsi.%&r(|[_&;CV}; eӘlNWn\ג[%r;=&Ȅ95̑\ g [Z m59*.Tj|.Y B@!  ؂bH_OG?rpjmLVVͤIpTR&Bq++߻:G o%K8r훈=-Ii Zk;T϶Pjߚ /7v!9?s,%;yU]98s IDATEIoS/Wi܊N& tG4b_͗]s}[s5z߱X:n/TI I=*S:m*U^I >0uxIoWe[ڏr;FjIIp,bIJ*5j\VCe7>j! B@! n"?Ʊ&rlfbͤ:E8)C#` $=R A?ҝB:Dgzw)ԾuUNϟ #lԛ=#=s:׺K1[g*Sd(|2/wHJYX֐`F &ec`\]kK; "S,W 9;ZW+y"*,I-i k\Ӛf7) 9TGY0lPg&=f*ێlͿtl*:C-j;&N/!?{z~͘/qds[{Ϡo%yn8O=g-{V$)o\; b72qq /2d1EMi >v%.? 9 ښzܩ~_};i7Dt'w<5pD5֗89I/^Wo;^>d}=mS1o 5/FO02?й{቗_5A*y*L&yQC7ZiφF^:4}~_4?\ѤsuwL[ j6's)[5AýeR$>97Ԙ<>/xLң8ʥQ\ iq\\fw%|,_}N}; ss&..^<``&ibA-+HܺfD;tB:ME2Hܳ"BSR= mIm[#ƅB@! BV xͭXiB@! B@! 'EB@! B@!  r]. B@! B@! h@! B@! B#PA1Xf klULZ-|RϢ^Fv V_A"&v#O?j-k:xp_y1@kn?16D1W5- h5olTo5}&! B@! n.0'nb̿gr0Ajxvn એ;:3~[Ȧ%ޞt\ؿq.U~ 8w/eĂy;M贋1rBN%پ.}ĠB@! B@TLLoY=g͠4i`럤?cf.$oEi9k~Żq3?t;sGguN0dT+?x צ/){;} zXRނoͯvN~Ӧ` c. я1DN)fMsbg_u?%^$,w,iBY[lj>Rߋڬ =Og/aۥL[ҽM'_KW ˂47q[,1~f髼w!B@! 8_{KmzgUՔz1EGu=f Vug/UR~tRK{^+izNR_>B5QǨND=1ңp T}zoܲ}[Z=Ac P/T|5YhV ^}B=CSxTW|/HnTJ.]|~rF+VU>׹Oݳ1J O_sVbBbp£⥳VcU5{⭫)/(<)TJ:S+O+"",/P4]R|.+{簟ӗhdVǠ)}HuG嬨tQY"Vjw_]ZMU_O|.`c`8[-?gnUU嬨 u=νj8in+e-dP}UH_Ʒr_ßQY1\-T^FYVW UU7ճzjG:MR-} k-z(5K9[㝯Ւ q,o蕫ջwW9< $a5~'SŬ@ lA[g]OvC9~8KZ 7*1ZyզfC1_ )Cas+7^)ܙ)~IXIvf oa $êHHWq J?˄+S.&lpc>p89wHL.o&tB>; XÎM@<&5x!.W|T^8t>Oj^2HtxLڅ|̱;U w&MKCs+:D,u[Lcs9s]L:׷ղ^~ui&/2%*xPh}W9)Y zd5#5;}S[a2ד?oE+M{q1 ^s7\r`I%Yo jtE[Zғlpق)5l-(n|/ ! B@! N?Ʊ&rlfbͤ:E8W0zfFb $=R A?ҝB:Dgzw)Ծu ?AFب7{F,{,jK}Tf̦!1Pd(|74 U")NNyd)gvpD3TZ bA>5r jye"O$^I0%-catmŔ) 9TGY0x33mGw_tz1䀖c<ߒ۟ƹ+heY%䗣' ̈є4kE{޳ ϼ4syb ^`djs;r&үeG;Y0j8_5aNLϵE% ftg]?鋲ߝ/B@! B@@vn?/.| ]f¯CYixwB*zW&wK wpkҝqfwȮe\jxaUxݞA1qٜcFœ067*T xeоbplZCuG(uAGbU|'=qz }X{S9_BoÞ /Cmx$̟ VDw pcp!hWCOOӀͩ9Νշќ&} ő7A% ;aੁ[%jFѭփwe}'i.~y1~*` 9@s#/;Jͧ /s;]P1wO]&IWԼݡ4 F 4 a厇oBrgip-؅jV}#F=~&#z$(5;^}*.i$B@! B| Kc/_f\֜thv _oOY?k˜|jLCT&wLJ(.zGv 8..y 3;"GNg`Nm-buθFf'zTK!az>EX;j>g9K0u"< fAl ̀KO5& Qm;X>'7NJ.dl/GKe$[b5K<'f,Y5*uq.&'mCDG0h!jwd[Abםq֤B@! B%PpPL]ixZp~v  Uŭ4&Ӿwm[Ciؿif eA1+շOѼvqLAXq_ j~̝Bp>Zh_qkwdB@! B@!{RGUNӧ;A pE˵ 0m/Mew?H S$n{h1WP/hҽ0t6 4D/d=w`76B;.eT[cEvzF΀/~FĥȌ)t0.d2~4jIWVC|t7{]S|7lWLw?CSY? G>LoY=g4pZ<ߝ&-lgv̅{tC0-g گx׃4n.{~)зÌa4Պ^BiH C2wҹw `U?Zml IDAT]:cP)Sa=>!VUW疿KdU};'?C̓_NJiSXv0 G/19Z\>k|v8!B@! B@UkoP}jܾrլk Cj]q_Ci`UwR5`M/gȑO*udgN*fWIG9ʀsUՉ'W^znT񍕛?[Z=Aci(}*ꁯ&vT@*akU/W'?z@yh *U)_aj[,`__S1!I1~MQ_YI +_}]{i*=\UŔV:EmE39Rӊi?)m QX4]R|.+{簟ӗhdVǠ)}HuG嬨tQY"Vjw_]ZMU_O|.`c`8[-?gnUU嬨 u=νj8in+e-dP}UH_Ʒr_Ji|v<>w]JXDрh@4  *lOtuļ6}cgHݺ'Pq:ryEK [ײmP<4 w@s̉7ʩWd$a5~'Sl lA[g]OvY9~8KZ 7*1ZyզfC1_ )Cas+7^q334I )ҎPx-lL;AdX 27B~P'`sw|ׄMa8{N7@xP''2UG5j$fAL }ܙ+%%M+رqgc=`Ju obY''VhhN?%#/C@ryRN;˦wCZU\ʅ&u+Gr$i4}EXb4/e"~v!~~BťbD̦Kʡϙ>v|Y6B@! 7H_OG?rpjmLVVͤI ;V0Gvy{Wg>(-9d G9vй}DZ%7Gu=7S!JAf v_r|p'x~dhUkn )Ye Jvk>HL.o&tB>;9m X@<ӓς+ىy]*:pw5w/u$:^o&Br~>XJw\󪄻ދzަ^NwN饡M"Lq鎺-& ip+TE&^~ui& dVD}ɻkTjٳ*ӻ+蔳tQ*{ ɍ>0uxI؟3sI-\MzYa1۴Zr}j)-B@! B@JS8D SٸR9h!at$GJ1G]VlQ;Ŝϰ:=. QorX.XUK{3Vf̦\>[KO}jh~ADR2R0)#FcM_+Y\*|Q@t`ف"Z^ Wd1Lz0bʔ F|y,sGbm6Ur4n婆w.T}eBzp|' &ݹ^gƍ`y׉ZvX. W!Et{>XUfs5 sؠ>>P)+%A=Fj '~֙oDWEΟzmx5airO| ={3 ṓ0*Xo^506á]=>ANb6^9wWΧFs-GpnCnF[W}s 7 (~7]U*w4΄yZgVNA3$j2;tH(i|S*{97Ԙ<>/xLңʥQ\ iq\\fw1Et1J'iZ6>ٝqy~IGvSrL K.Fԉ`[\$L ^7f/ d;X>'7NJ.ם|?R}T_z/K'CXgيωKy.}2|d{?<5QIz*/`LvraŋÈ<=-*h3Зsj7]嗣ťi̖铩b0*ˇ7?kɈ*,VfjL~q 2׮}3J<2şedy̤rde_¼eBFGLd̒tTFqaB@! B@u*TCjJ\z [}ҩڐsP8Dрh@4  DG@ݘarZxbj|éH "14<7hޡԻ7JTl]P2B@! B@!(ew^ۉwWX3qtP^_w1kEUqJIZG~J:4.ݬ8(CO,$-{~$: l3§ _%տ]cІ؞̞s^, ! B@! -J@˚w6_-B@! B@!p+'o^6 ! B@! B' A[\|! B@! B@܊$(v+Y! B@! 8r y5kXcbZתo(߹h&Z.U QtC5}|-(S_y1@kn?16D1W6ڧk?}ezj[])ƄB@! .(נ+s&݃="xTW>G6 ŸO3zavy %%J'5ՂJM¤2HKd Q4)nhj2>Lr7\\ =zУlJ4l3zj޽'FSX5ntcgL=HX 2"# EMDF,""ʰ) G ED!la !{'NYaW_ֽU  ׻~E@PE@PE@P'PzPL3n` [=s zqOE$\}Ʒ"{챺B@X\qNP<+WƿElEh^W8 w;]pT("("(uKxRAǺN"Isgo>m">}h$oaiN̥`jv|ߓo̠"iYH,Be4jO/k'=mCq_Lzv&ѷYϱj!hGsXIb~zXw|~,]xkmBo:z' ?ZTĶ Cwr۟ؽ'nO@t}=ۿnH{B?U:pKs,f= úðм.{p:ߋƁrkpwoL>#B?$=UdƒK^n#;̦$?;|WO[hB7k H݃qg~lj grp9u6F{Re5hjEq"sM뿢ч{ząc W1eE7Co氧ͣ$[fM~rqR9ƷWjGPE@PE@PEh1+oyOJls<ٟHiU \?J7f 3ʀU$:It!]0Wo)wF1c3|$jl5&V|93z#sEe5G~6A7Q8Lpi2ytvW 6i|X/G|(i :S4^L,$vNpil<^' 4hwܾ>A+>8,X_G%yVL g 3 v[f'&bW v.:Fa4ݮ7M' S~(D#;f )e;#wm_*MLG^n%#'Bu/I)OJ;KhdM }Č+qX]fE=miXIH0R ha-i2ߓnn3ϏRvCs>kg{S8TўkER>LV`Gן[qphjes!`F$ ,@Hgh +A@~Kh֭u}v Gw¡cήÞbJ=0դNT)kɲc;HܒQR[N}0>.cM!Ps|'e[IOi؍_y(wߦ9pЌ-?@hsyRẁƴi+8] uKWΈi4{xdLZ4KY|&-I`UnMu維ǣf$?M_n%' 4<_ 9Wv~-uBPE@PE@PEA|Қ~]DIoHOR|V+De%HE2JB$SQЙ ͞y_=lBِ՛Qchm$g۷7 plYG˽,]3k 96,gr |95S ̬V%,Ptb4<ʳV4*E}ʗ1txߊDvI>T,q^AOos2~O8$bÒjfaO_.?0Yv/DXuO׉wQPBLžXJW1yο* :O\Fg}T X~}+) -%Ȩqk7xRN*"("("8?(8O wSh%웻ݫ s$_GJXzw.It;5W ROYHKsŬYub>CFA:_{{KJm“:B;N2gOi|r=QNJ"("("(NEQ $'>˺nX5Wtw>LlF@U]DR3=#`iqdbQ}}ub>K۹8ju˨9Q} NHbۍ7oHnn7 h"~z }t{^eeH_{A/(x?ӿ} c{aGE'Rjb>1hSM[8HRm VS]$0p s ^YƗs*~(%׿\*vG2v6H>]gV%n;~3Q.~EPתk+olRaW.RE@PE@PE!PEy [~^y/H=+A޽+aͯוj*JJJJJJJJJO9P*d=w# ףmMεz;gNoh4T=zJk7J7X*"("("p̅eBԙ,5֜r[jXZ3ۏt3?.凩In^Mݸbi t|2EG+Ƽ2\-f mc6VUʖ"("("( I@+\rsCv^uZPE@PE@PE@PE$Oޘz("("("( M@nWWE@PE@PE@PE$b7渫^+"("("("pCԠ\UmS@C߲Ĺ͝ lۋ&>ao61'".ҿ_rui<0kESX tڇ: IDATb43o>ݸ=A O[G7>4Oվ"("("ܨ*5(fl{gx/Oj5 T6 a ll9X$\E1ؼo|'"=V~4S.[>5iB#eײZ<={ҳ|iυ+xkf"$,CZ3oN]Er~qY^%} ԯWՏ٫VV8Cy=9Uf/=.q6UuE@PE@PE@PC4AFкc}n]?Nv5Ėt@PV}=V\H+0+Πc [~)5@s!{ի W:³:Bn+j֞(pZ=M[Y]ۨg@~Xm4\;]>*}Q"("("\(TZc]'س7 u[6 q>4 Am4v'R{5;I7fFv4 L$|eٷ?' ' Q0||Y?i+b7ճD>NcՌCX03ľ8VYč;VCֱu"ՎO8umНg&9v-do_n^g>gP@N0t4 $-Y¡,g0l*4 90s4 ,S&|FNHzFOëȌ%*k~v9vE@PE@PE@~#1/"LĶ)>:R_%a}hp?c XK}ݮәJsr%lĿx|ifrc>>3ۋ'L̖^cbc >ޯ7r;WT ]sg]|s m%aD &.Ggw`&jI{$ǭ_Ʊ3EHdNo&˖#u@&zwd຾j8RuY˜g@Px0s`端/e^{R0j!Fx0i`b:nNzS$ay0ᎎBd !:BiPHR3Bl}w_dyV諉>>{ . -ԏ^7_t򤴻F֔*чHرJ}|եkˣ?}jH˙eGq} եq2`b+w_hw^خe_'G"5}N_&?ɈeLf|u_Wȇ5)s\˚$I4,erߨ~—c~ %Xy_.wNLWMhH:f,yjE|G^.*vJJJJJJJJJJצ'ӺmR^>dO)%08 :hFNh^f&l0%x@x^~پ kffRc s~~ك┶4,j$`]q}X4hGIgO[)hv;9ϳ}΁=)_w hϵNc )l&+_#ϖN. BVH X(V,<ŗа/T)}l19:@XCǜ]=-e =Lz`IR$ev%إ ,`|]I;B TOLS ˶f\#PCPMs[~*70󤜃+Bi9"VpDȯq+҈77!xuWjFگ8ڵ(B*-i>|! yGMX[E@ _e>?]JS*"("("&xt&_b{zdƓ8 }w$'YiX`* :Av2 w絃[H:z;j l摴7;"P±g!.JZ`(JwC2ΐ5/PW۰)+m j@ z3YE3JqYl?9b ki8yg5!^_P5?]>[1(Z־5c3Kp``4ps2~O8$bÒjfaO_.?0Yv/DXuO׉wQPř};R>e"ŋK/4-}3_njNq{eUAtޘQ5@蕛]V"("("\OA1IƱZb_F+Id^GpLo+`$=Rj׃pMҿ.)~3{+$hGۦrRٳXQK2Sɗ@8S@ A2ܒE#zu\bZ<)dd6-9ZH9wH7?Uc'w]Op2"RN G6%ÜExtERT)`lwu[2 z`[>yWn~vuD("("("p(R8e>ɉO粮 Vݝk(F@U]DR3=#`iqdb<˲NGsTC|u`;BZn55G06j@`/>!թ_2R̠@RwCF=ImmAMd!oOnO۫a<4șO'Ac,