├── .github ├── dependabot.yml └── workflows │ └── python-ci.yml ├── .gitignore ├── .isort.cfg ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── artwork ├── Bot.svg └── Bots.svg ├── doc ├── Makefile ├── _static │ ├── custom.css │ ├── delta-chat.svg │ └── favicon.ico ├── _templates │ ├── globaltoc.html │ └── sidebarintro.html ├── api.rst ├── changelog.rst ├── conf.py ├── index.rst ├── install.rst ├── links.rst ├── make.bat └── plugins.rst ├── examples ├── admin.py ├── deltachat_api.py ├── dynamic.py ├── filter_priority.py ├── hooks.py ├── impersonating.py ├── quote_reply.py ├── send_file.py └── simplebot_echo │ ├── CHANGELOG.rst │ ├── LICENSE │ ├── README.rst │ ├── setup.cfg │ ├── setup.py │ ├── simplebot_echo.py │ └── tox.ini ├── requirements ├── requirements-dev.txt ├── requirements-test.txt └── requirements.txt ├── scripts └── create_service.py ├── setup.py ├── src └── simplebot │ ├── __init__.py │ ├── __main__.py │ ├── avatars │ ├── adaptive-alt.png │ ├── adaptive-default.png │ ├── blue-alt.png │ ├── blue.png │ ├── green-alt.png │ ├── green.png │ ├── purple-alt.png │ ├── purple.png │ ├── red-alt.png │ ├── red.png │ ├── simplebot.png │ ├── yellow-alt.png │ └── yellow.png │ ├── bot.py │ ├── builtin │ ├── __init__.py │ ├── admin.py │ ├── cmdline.py │ ├── db.py │ ├── log.py │ └── settings.py │ ├── commands.py │ ├── filters.py │ ├── hookspec.py │ ├── main.py │ ├── parser.py │ ├── plugins.py │ ├── pytestplugin.py │ ├── templates │ ├── __init__.py │ └── help.j2 │ └── utils.py └── tests ├── test_cmdline.py ├── test_commands.py ├── test_deltabot.py ├── test_filters.py ├── test_parser.py └── test_plugins.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/requirements/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/python-ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: 7 | - 'v*.*.*' 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ['3.9', '3.12'] 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install '.[dev]' 27 | - name: Check code with isort 28 | run: | 29 | isort --check . 30 | - name: Check code with black 31 | run: | 32 | black --check . 33 | - name: Lint with flake8 34 | run: | 35 | # stop the build if there are Python syntax errors or undefined names 36 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | 42 | deploy: 43 | needs: test 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-python@v5 48 | with: 49 | python-version: '3.x' 50 | - id: check-tag 51 | run: | 52 | if [[ "${{ github.event.ref }}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 53 | echo ::set-output name=match::true 54 | fi 55 | - name: Create PyPI release 56 | uses: casperdcl/deploy-pypi@v2 57 | with: 58 | password: ${{ secrets.PYPI_TOKEN }} 59 | build: true 60 | # only upload if a tag is pushed (otherwise just build & check) 61 | upload: ${{ github.event_name == 'push' && steps.check-tag.outputs.match == 'true' }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pytest_cache 2 | .eggs 3 | *.pyc 4 | __pycache__ 5 | muacrypt.egg-info 6 | doc/_build 7 | .cache 8 | *.swp 9 | .tox 10 | build 11 | dist 12 | *.egg-info 13 | *~ 14 | .#* 15 | *# 16 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | profile=black 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v4.1.1] 4 | 5 | - fix bug in default account detection when there is only one account in ~/.simplebot/accounts 6 | 7 | ## [v4.1.0] 8 | 9 | - allow classic email 10 | - fix bug on registering function command with custom description 11 | 12 | ## [v4.0.0] 13 | 14 | - adapted to deltachat API breaking changes introduced in 1.94.0 15 | - improve detection of group title changes 16 | 17 | ## [v3.3.0] 18 | 19 | - added `hidden` argument to the command and filter declarations hooks, to hide them from the bot's help message 20 | 21 | ## [v3.2.0] 22 | 23 | - added support for importing/exporting backups and keys 24 | 25 | ## [v3.1.0] 26 | 27 | - updated to make use of new deltachat API 28 | 29 | ## [v3.0.0] 30 | 31 | - added support for message processing via webxdc interfaces, requests must have the form: `{payload: {simplebot: {text: "/help"}}}`, only "text" and "html" messages supported for now 32 | - breaking change: message IDs queue table modified to support special incoming webxdc messages 33 | - adapt simplebot's pytest plugin to deltachat's new pytest plugin API 34 | 35 | ## [v2.4.0] 36 | 37 | - fixed to be compatible with `deltachat>=1.66.0` 38 | - commands, filters and plugins are now sorted alphabetically in the help. 39 | - allow to set custom configuration values (ex. custom servers and ports) in `init` subcommand to support servers with not standard configurations. 40 | - added `Dockerfile` to repo to help setting up the bot (thanks @lerdem) 41 | 42 | ## [v2.3.0] 43 | 44 | - close "bytefile" (passed to `Replies.add`) after reading the content. 45 | - use a custom event thread to prevent dead `EventThread`. 46 | - honor `--stdlog` value in log file. 47 | - if filter returns `True`, stop message processing without exceptions. 48 | 49 | ## [v2.2.1] 50 | 51 | - fixed bug while processing member added/removed events from self. 52 | 53 | ## [v2.2.0] 54 | 55 | - show shield badge in commands/filters for bot administrators. 56 | - make commands case insensitive, now `/Help` is equivalent to `/help`. 57 | 58 | ## [v2.1.1] 59 | 60 | - mark messages as read before processing them. 61 | 62 | ## [v2.1.0] 63 | 64 | - mark messages as read so MDN work, if enabled. 65 | 66 | ## [v2.0.0] 67 | 68 | - ignore messages from other bots using the new Delta Chat API. Added `deltabot_incoming_bot_message` hook to process messages from bots. 69 | - allow to get account configuration values with `set_config` command. 70 | - allow to register administrators-only filters. 71 | - send bot's help as HTML message. 72 | - disable "move to DeltaChat folder" (mvbox_move setting) by default. 73 | - log less info if not in "debug" mode. 74 | - help command now also includes filters descriptions. 75 | - **breaking change:** plugins must register their "user preferences" with `DeltaBot.add_preference()` then the setting will be available to users with `/set` command. 76 | - **breaking change:** improved command and filter registration. 77 | - **breaking change:** changed configuration folder to `~/.simplebot` 78 | 79 | ## [v1.1.1] 80 | 81 | - fix bug in `simplebot.utils.get_default_account()` (#72) 82 | 83 | ## [v1.1.0] 84 | 85 | - Improved pytestplugin to allow simulating incoming messages with encryption errors (#68) 86 | 87 | ## [v1.0.1] 88 | 89 | - **From upstream:** major rewrite of deltabot to use new deltachat core python bindings 90 | which are pluginized themselves. 91 | - Changed SimpleBot logo (thanks Dann) and added default avatar 92 | generation based on account color. 93 | - Added `@simplebot.command` and `@simplebot.filter` decorators to 94 | simplify commands and filters creation. 95 | - Added new hooks `deltabot_ban`, `deltabot_unban`, 96 | `deltabot_title_changed` and `deltabot_image_changed` 97 | - Added options to influence filter execution order. 98 | - Added support for commands that are available only to bot administrators. 99 | - Improved command line, added account manager, administrator tools, 100 | options to set avatar, display name, status and other low level 101 | settings for non-standard servers. 102 | - Added default status message. 103 | - Improved code readability with type hints. 104 | 105 | ## v0.10.0 106 | 107 | - initial release 108 | 109 | [v4.1.1]: https://github.com/simplebot-org/simplebot/compare/v4.1.0...v4.1.1 110 | [v4.1.0]: https://github.com/simplebot-org/simplebot/compare/v4.0.0...v4.1.0 111 | [v4.0.0]: https://github.com/simplebot-org/simplebot/compare/v3.3.0...v4.0.0 112 | [v3.3.0]: https://github.com/simplebot-org/simplebot/compare/v3.2.0...v3.3.0 113 | [v3.2.0]: https://github.com/simplebot-org/simplebot/compare/v3.1.0...v3.2.0 114 | [v3.1.0]: https://github.com/simplebot-org/simplebot/compare/v3.0.0...v3.1.0 115 | [v3.0.0]: https://github.com/simplebot-org/simplebot/compare/v2.4.0...v3.0.0 116 | [v2.4.0]: https://github.com/simplebot-org/simplebot/compare/v2.3.0...v2.4.0 117 | [v2.3.0]: https://github.com/simplebot-org/simplebot/compare/v2.2.1...v2.3.0 118 | [v2.2.1]: https://github.com/simplebot-org/simplebot/compare/v2.2.0...v2.2.1 119 | [v2.2.0]: https://github.com/simplebot-org/simplebot/compare/v2.1.1...v2.2.0 120 | [v2.1.1]: https://github.com/simplebot-org/simplebot/compare/v2.1.0...v2.1.1 121 | [v2.1.0]: https://github.com/simplebot-org/simplebot/compare/v2.0.0...v2.1.0 122 | [v2.0.0]: https://github.com/simplebot-org/simplebot/compare/v1.1.1...v2.0.0 123 | [v1.1.1]: https://github.com/simplebot-org/simplebot/compare/v1.1.0...v1.1.1 124 | [v1.1.0]: https://github.com/simplebot-org/simplebot/compare/v1.0.1...v1.1.0 125 | [v1.0.1]: https://github.com/simplebot-org/simplebot/compare/v0.10.0...v1.0.1 126 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | RUN mkdir /app 3 | WORKDIR /app 4 | RUN apt-get update && apt-get install -y git && pip install --upgrade pip 5 | COPY . /app 6 | RUN pip install . 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include *.md 3 | include src/simplebot/avatars/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **⚠️ IMPORTANT:** This project is no longer actively maintained you should use instead: https://github.com/deltachat-bot/deltabot-cli-py 2 | 3 |

4 |

SimpleBot

5 | 6 | [![Latest Release](https://img.shields.io/pypi/v/simplebot.svg)](https://pypi.org/project/simplebot) 7 | [![Supported Versions](https://img.shields.io/pypi/pyversions/simplebot.svg)](https://pypi.org/project/simplebot) 8 | [![Downloads](https://pepy.tech/badge/simplebot)](https://pepy.tech/project/simplebot) 9 | [![License](https://img.shields.io/pypi/l/simplebot.svg)](https://pypi.org/project/simplebot) 10 | [![CI](https://github.com/simplebot-org/simplebot/actions/workflows/python-ci.yml/badge.svg)](https://github.com/simplebot-org/simplebot/actions/workflows/python-ci.yml) 11 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 12 | [![Contributors](https://img.shields.io/github/contributors/simplebot-org/simplebot.svg)](https://github.com/simplebot-org/simplebot/graphs/contributors) 13 | 14 | > An extensible Delta Chat bot. 15 | 16 | ## Install 17 | 18 | To install the latest stable version of SimpleBot run the following command (preferably in a [virtual environment](https://packaging.python.org/tutorials/installing-packages/#creating-and-using-virtual-environments)): 19 | 20 | ```sh 21 | pip install simplebot 22 | ``` 23 | 24 | To test unreleased version: 25 | 26 | ```sh 27 | pip install git+https://github.com/simplebot-org/simplebot 28 | ``` 29 | 30 | > **⚠️ NOTE:** If Delta Chat Python bindings package is not available for your platform you will need to compile and install the bindings manually, check [deltachat documentation](https://github.com/deltachat/deltachat-core-rust/blob/master/python/README.rst) for more info. 31 | 32 | ### Build with docker 33 | ```bash 34 | # building image 35 | docker build -t simplebot . 36 | # running container with simplebot 37 | # "/home/bot_volume" absolute path for storing bot data on host system 38 | docker run -it -v /home/bot_volume:/root/.simplebot simplebot bash 39 | ``` 40 | In container bash you can do same bot running as in [quick start section](#quick-start-running-a-botplugins) 41 | 42 | ## Quick Start: Running a bot+plugins 43 | 44 | (Replace variables `$ADDR` and `$PASSWORD` with the email and password for the account the bot will use) 45 | 46 | 1. Add an account to the bot: 47 | 48 | ```sh 49 | simplebot init "$ADDR" "$PASSWORD" 50 | ``` 51 | 52 | 2. Install some plugins: 53 | 54 | ```sh 55 | pip install simplebot-echo 56 | ``` 57 | 58 | 3. Start the bot: 59 | 60 | ```sh 61 | simplebot serve 62 | ``` 63 | 64 | ## Plugins 65 | 66 | SimpleBot is a base bot that relies on plugins to add functionality. 67 | 68 | Everyone can publish their own plugins, search in PyPI to discover cool [SimpleBot plugins](https://pypi.org/search/?q=simplebot&o=&c=Environment+%3A%3A+Plugins) 69 | 70 | > **⚠️ NOTE:** Plugins installed as Python packages (for example with `pip`) are global to all accounts you register in the bot, to separate plugins per account you need to run each account in its own virtual environment. 71 | 72 | ## Creating per account plugins 73 | 74 | If you know how to code in Python, you can quickly create plugins and install them to tweak your bot. 75 | 76 | Lets create an "echo bot", create a file named `echo.py` and write inside: 77 | 78 | ```python 79 | import simplebot 80 | 81 | @simplebot.filter 82 | def echo(message, replies): 83 | """Echoes back received message.""" 84 | replies.add(text=message.text) 85 | ``` 86 | 87 | That is it! you have created a plugin that will transform simplebot in an "echo bot" that will echo back any text message you send to it. Now tell simplebot to register your plugin: 88 | 89 | ```sh 90 | simplebot plugin --add ./echo.py 91 | ``` 92 | 93 | Now you can start the bot and write to it from Delta Chat app to see your new bot in action. 94 | 95 | Check the `examples` folder to see some examples about how to create plugins. 96 | 97 | ## Note for users 98 | 99 | SimpleBot uses [Autocrypt](https://autocrypt.org/) end-to-end encryption 100 | but note that the operator of the bot service can look into 101 | messages that are sent to it. 102 | 103 | 104 | ## Credits 105 | 106 | SimpleBot logo was created by Cuban designer "Dann". 107 | -------------------------------------------------------------------------------- /artwork/Bot.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | VERSION = $(shell python -c "import conf ; print(conf.version)") 5 | DOCZIP = devpi-$(VERSION).doc.zip 6 | # You can set these variables from the command line. 7 | SPHINXOPTS = 8 | SPHINXBUILD = sphinx-build 9 | PAPER = 10 | BUILDDIR = _build 11 | RSYNCOPTS = -e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" 12 | 13 | export HOME=/tmp/home 14 | export TESTHOME=$(HOME) 15 | 16 | # Internal variables. 17 | PAPEROPT_a4 = -D latex_paper_size=a4 18 | PAPEROPT_letter = -D latex_paper_size=letter 19 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 20 | # the i18n builder cannot share the environment and doctrees with the others 21 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 22 | 23 | # This variable is not auto generated as the order is important. 24 | USER_MAN_CHAPTERS = commands\ 25 | user\ 26 | indices\ 27 | packages\ 28 | # userman/index.rst\ 29 | # userman/devpi_misc.rst\ 30 | # userman/devpi_concepts.rst\ 31 | 32 | 33 | #export DEVPI_CLIENTDIR=$(CURDIR)/.tmp_devpi_user_man/client 34 | #export DEVPI_SERVERDIR=$(CURDIR)/.tmp_devpi_user_man/server 35 | 36 | chapter = commands 37 | 38 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp \ 39 | epub latex latexpdf text man changes linkcheck doctest gettext install \ 40 | quickstart-releaseprocess quickstart-pypimirror quickstart-server regen \ 41 | prepare-quickstart\ 42 | regen.server-fresh regen.server-restart regen.server-clean\ 43 | regen.uman-all regen.uman 44 | 45 | help: 46 | @echo "Please use \`make ' where is one of" 47 | @echo " html to make standalone HTML files" 48 | @echo " dirhtml to make HTML files named index.html in directories" 49 | @echo " singlehtml to make a single large HTML file" 50 | @echo " pickle to make pickle files" 51 | @echo " json to make JSON files" 52 | @echo " htmlhelp to make HTML files and a HTML help project" 53 | @echo " qthelp to make HTML files and a qthelp project" 54 | @echo " devhelp to make HTML files and a Devhelp project" 55 | @echo " epub to make an epub" 56 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 57 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 58 | @echo " text to make text files" 59 | @echo " man to make manual pages" 60 | @echo " texinfo to make Texinfo files" 61 | @echo " info to make Texinfo files and run them through makeinfo" 62 | @echo " gettext to make PO message catalogs" 63 | @echo " changes to make an overview of all changed/added/deprecated items" 64 | @echo " linkcheck to check all external links for integrity" 65 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 66 | @echo 67 | @echo "User Manual Regen Targets" 68 | @echo " regen.uman regenerates page. of the user manual chapeter e.g. regen.uman chapter=..." 69 | @echo " regen.uman-all regenerates the user manual" 70 | @echo " regen.uman-clean stop temp server and clean up directory" 71 | @echo " Chapter List: $(USER_MAN_CHAPTERS)" 72 | 73 | clean: 74 | -rm -rf $(BUILDDIR)/* 75 | 76 | version: 77 | @echo "version $(VERSION)" 78 | 79 | doczip: html 80 | python doczip.py $(DOCZIP) _build/html 81 | 82 | install: html 83 | rsync -avz $(RSYNCOPTS) _build/html/ delta@py.delta.chat:build/master 84 | 85 | 86 | html: 87 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 88 | @echo 89 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 90 | 91 | dirhtml: 92 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 93 | @echo 94 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 95 | 96 | singlehtml: 97 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 98 | @echo 99 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 100 | 101 | pickle: 102 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 103 | @echo 104 | @echo "Build finished; now you can process the pickle files." 105 | 106 | json: 107 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 108 | @echo 109 | @echo "Build finished; now you can process the JSON files." 110 | 111 | htmlhelp: 112 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 113 | @echo 114 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 115 | ".hhp project file in $(BUILDDIR)/htmlhelp." 116 | 117 | qthelp: 118 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 119 | @echo 120 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 121 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 122 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/devpi.qhcp" 123 | @echo "To view the help file:" 124 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/devpi.qhc" 125 | 126 | devhelp: 127 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 128 | @echo 129 | @echo "Build finished." 130 | @echo "To view the help file:" 131 | @echo "# mkdir -p $$HOME/.local/share/devhelp/devpi" 132 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/devpi" 133 | @echo "# devhelp" 134 | 135 | epub: 136 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 137 | @echo 138 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 139 | 140 | latex: 141 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 142 | @echo 143 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 144 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 145 | "(use \`make latexpdf' here to do that automatically)." 146 | 147 | latexpdf: 148 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 149 | @echo "Running LaTeX files through pdflatex..." 150 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 151 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 152 | 153 | text: 154 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 155 | @echo 156 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 157 | 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | texinfo: 164 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 165 | @echo 166 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 167 | @echo "Run \`make' in that directory to run these through makeinfo" \ 168 | "(use \`make info' here to do that automatically)." 169 | 170 | info: 171 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 172 | @echo "Running Texinfo files through makeinfo..." 173 | make -C $(BUILDDIR)/texinfo info 174 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 175 | 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | changes: 182 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 183 | @echo 184 | @echo "The overview file is in $(BUILDDIR)/changes." 185 | 186 | linkcheck: 187 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 188 | @echo 189 | @echo "Link check complete; look for any errors in the above output " \ 190 | "or in $(BUILDDIR)/linkcheck/output.txt." 191 | 192 | doctest: 193 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 194 | @echo "Testing of doctests in the sources finished, look at the " \ 195 | "results in $(BUILDDIR)/doctest/output.txt." 196 | 197 | 198 | -------------------------------------------------------------------------------- /doc/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* customizations to Alabaster theme . */ 2 | 3 | div.document { 4 | width: 1480px; 5 | } 6 | 7 | div.body { 8 | max-width: 1280px; 9 | } 10 | 11 | div.globaltoc { 12 | font-size: 1.4em; 13 | } 14 | 15 | img.logo { 16 | height: 120px; 17 | } 18 | 19 | div.footer { 20 | display: none; 21 | } 22 | -------------------------------------------------------------------------------- /doc/_static/delta-chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 26 | 30 | 34 | 35 | 37 | 41 | 45 | 46 | 48 | 52 | 56 | 57 | 59 | 63 | 67 | 68 | 70 | 74 | 78 | 79 | 89 | 98 | 99 | 123 | 125 | 126 | 128 | image/svg+xml 129 | 131 | 132 | 133 | 134 | 135 | 139 | 145 | 151 | 154 | 157 | 161 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /doc/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/doc/_static/favicon.ico -------------------------------------------------------------------------------- /doc/_templates/globaltoc.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 9 | external links: 10 | 14 | 15 | #deltachat [freenode] 16 | 17 |
18 | -------------------------------------------------------------------------------- /doc/_templates/sidebarintro.html: -------------------------------------------------------------------------------- 1 |

deltachat {{release}}

2 | -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | 2 | API reference 3 | ======================== 4 | 5 | DeltaBot 6 | -------------------------------- 7 | 8 | .. autoclass:: deltabot.bot.DeltaBot 9 | :members: 10 | 11 | .. autoclass:: deltabot.commands.IncomingCommand 12 | :members: 13 | 14 | .. autoclass:: deltabot.filters.Filters 15 | :members: 16 | 17 | .. autoclass:: deltabot.bot.Replies 18 | :members: 19 | 20 | .. autoclass:: deltabot.plugins.Plugins 21 | :members: 22 | 23 | .. autoclass:: deltabot.hookspec 24 | :members: 25 | 26 | Account (from deltachat package) 27 | -------------------------------- 28 | 29 | .. autoclass:: deltachat.account.Account 30 | :members: 31 | 32 | 33 | Message (from deltachat package) 34 | -------------------------------- 35 | 36 | .. autoclass:: deltachat.message.Message 37 | :members: 38 | 39 | Chat (from deltachat package) 40 | -------------------------------- 41 | 42 | .. autoclass:: deltachat.chat.Chat 43 | :members: 44 | 45 | Contact (from deltachat package) 46 | -------------------------------- 47 | 48 | .. autoclass:: deltachat.contact.Contact 49 | :members: 50 | -------------------------------------------------------------------------------- /doc/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog for deltachat-core's Python bindings 2 | ============================================== 3 | 4 | .. include:: ../CHANGELOG.rst 5 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # devpi documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jun 3 16:11:22 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | import sys 16 | 17 | # The version info for the project you're documenting, acts as replacement for 18 | # |version| and |release|, also used in various other places throughout the 19 | # built documents. 20 | from deltabot import __version__ as release 21 | 22 | version = ".".join(release.split(".")[:2]) 23 | 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | # sys.path.insert(0, os.path.abspath('.')) 28 | 29 | # -- General configuration ----------------------------------------------------- 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be extensions 35 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 36 | extensions = [ 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.autosummary", 39 | #'sphinx.ext.intersphinx', 40 | "sphinx.ext.todo", 41 | "sphinx.ext.viewcode", 42 | "breathe", 43 | #'sphinx.ext.githubpages', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ["_templates"] 48 | 49 | # The suffix of source filenames. 50 | source_suffix = ".rst" 51 | 52 | # The encoding of source files. 53 | # source_encoding = 'utf-8-sig' 54 | 55 | # The master toctree document. 56 | master_doc = "index" 57 | 58 | # General information about the project. 59 | project = "simplebot" 60 | copyright = "2020, The SimpleBot Contributors" 61 | 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # language = None 66 | 67 | # There are two options for replacing |today|: either, you set today to some 68 | # non-false value, then it is used: 69 | # today = '' 70 | # Else, today_fmt is used as the format for a strftime call. 71 | # today_fmt = '%B %d, %Y' 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | exclude_patterns = ["sketch", "_build", "attic"] 76 | 77 | # The reST default role (used for this markup: `text`) to use for all documents. 78 | # default_role = None 79 | 80 | # If true, '()' will be appended to :func: etc. cross-reference text. 81 | # add_function_parentheses = True 82 | 83 | # If true, the current module name will be prepended to all description 84 | # unit titles (such as .. function::). 85 | # add_module_names = True 86 | 87 | # If true, sectionauthor and moduleauthor directives will be shown in the 88 | # output. They are ignored by default. 89 | # show_authors = False 90 | 91 | # The name of the Pygments (syntax highlighting) style to use. 92 | pygments_style = "sphinx" 93 | 94 | # A list of ignored prefixes for module index sorting. 95 | # modindex_common_prefix = [] 96 | 97 | 98 | # -- Options for HTML output --------------------------------------------------- 99 | 100 | sys.path.append(os.path.abspath("_themes")) 101 | html_theme_path = ["_themes"] 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | # html_theme = 'flask' 106 | html_theme = "alabaster" 107 | 108 | # Theme options are theme-specific and customize the look and feel of a theme 109 | # further. For a list of options available for each theme, see the 110 | # documentation. 111 | html_theme_options = { 112 | "logo": "_static/delta-chat.svg", 113 | "font_size": "1.1em", 114 | "caption_font_size": "0.9em", 115 | "code_font_size": "1.1em", 116 | } 117 | 118 | # Add any paths that contain custom themes here, relative to this directory. 119 | # html_theme_path = ["_themes"] 120 | 121 | # The name for this set of Sphinx documents. If None, it defaults to 122 | # " v documentation". 123 | # html_title = None 124 | 125 | # A shorter title for the navigation bar. Default is the same as html_title. 126 | # html_short_title = None 127 | 128 | # The name of an image file (relative to this directory) to place at the top 129 | # of the sidebar. 130 | html_logo = "_static/delta-chat.svg" 131 | 132 | # The name of an image file (within the static path) to use as favicon of the 133 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 134 | # pixels large. 135 | html_favicon = "_static/favicon.ico" 136 | 137 | # Add any paths that contain custom static files (such as style sheets) here, 138 | # relative to this directory. They are copied after the builtin static files, 139 | # so a file named "default.css" will overwrite the builtin "default.css". 140 | html_static_path = ["_static"] 141 | 142 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 143 | # using the given strftime format. 144 | # html_last_updated_fmt = '%b %d, %Y' 145 | 146 | # If true, SmartyPants will be used to convert quotes and dashes to 147 | # typographically correct entities. 148 | # html_use_smartypants = True 149 | 150 | # Custom sidebar templates, maps document names to template names. 151 | # 152 | html_sidebars = { 153 | "index": ["sidebarintro.html", "globaltoc.html", "searchbox.html"], 154 | "**": ["sidebarintro.html", "globaltoc.html", "relations.html", "searchbox.html"], 155 | } 156 | 157 | 158 | # Additional templates that should be rendered to pages, maps page names to 159 | # template names. 160 | # html_additional_pages = {} 161 | 162 | # If false, no module index is generated. 163 | # html_domain_indices = True 164 | 165 | # If false, no index is generated. 166 | # html_use_index = True 167 | 168 | # If true, the index is split into individual pages for each letter. 169 | # html_split_index = False 170 | 171 | # If true, links to the reST sources are added to the pages. 172 | html_show_sourcelink = False 173 | 174 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 175 | html_show_sphinx = False 176 | 177 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 178 | # html_show_copyright = True 179 | 180 | # If true, an OpenSearch description file will be output, and all pages will 181 | # contain a tag referring to it. The value of this option must be the 182 | # base URL from which the finished HTML is served. 183 | html_use_opensearch = "https://doc.devpi.net" 184 | 185 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 186 | # html_file_suffix = None 187 | 188 | # Output file base name for HTML help builder. 189 | htmlhelp_basename = "simplebot" 190 | 191 | # -- Options for LaTeX output -------------------------------------------------- 192 | 193 | latex_elements = { 194 | # The paper size ('letterpaper' or 'a4paper'). 195 | #'papersize': 'letterpaper', 196 | # The font size ('10pt', '11pt' or '12pt'). 197 | "pointsize": "12pt", 198 | # Additional stuff for the LaTeX preamble. 199 | #'preamble': '', 200 | } 201 | 202 | # Grouping the document tree into LaTeX files. List of tuples 203 | # (source start file, target name, title, author, documentclass [howto/manual]). 204 | latex_documents = [ 205 | ( 206 | "index", 207 | "devpi.tex", 208 | "simplebot documentation", 209 | "The SimpleBot Contributors", 210 | "manual", 211 | ), 212 | ] 213 | 214 | # The name of an image file (relative to this directory) to place at the top of 215 | # the title page. 216 | # latex_logo = None 217 | 218 | # For "manual" documents, if this is true, then toplevel headings are parts, 219 | # not chapters. 220 | # latex_use_parts = False 221 | 222 | # If true, show page references after internal links. 223 | # latex_show_pagerefs = False 224 | 225 | # If true, show URL addresses after external links. 226 | # latex_show_urls = False 227 | 228 | # Documents to append as an appendix to all manuals. 229 | # latex_appendices = [] 230 | 231 | # If false, no module index is generated. 232 | # latex_domain_indices = True 233 | 234 | 235 | # -- Options for manual page output -------------------------------------------- 236 | 237 | # One entry per manual page. List of tuples 238 | # (source start file, name, description, authors, manual section). 239 | man_pages = [ 240 | ( 241 | "index", 242 | "simplebot", 243 | "simplebot documentation", 244 | ["The SimpleBot Contributors"], 245 | 1, 246 | ) 247 | ] 248 | 249 | # If true, show URL addresses after external links. 250 | # man_show_urls = False 251 | 252 | 253 | # -- Options for Texinfo output ------------------------------------------------ 254 | 255 | # Grouping the document tree into Texinfo files. List of tuples 256 | # (source start file, target name, title, author, 257 | # dir menu entry, description, category) 258 | texinfo_documents = [ 259 | ( 260 | "index", 261 | "devpi", 262 | "devpi Documentation", 263 | "holger krekel", 264 | "devpi", 265 | "One line description of project.", 266 | "Miscellaneous", 267 | ), 268 | ] 269 | 270 | # Documents to append as an appendix to all manuals. 271 | # texinfo_appendices = [] 272 | 273 | # If false, no module index is generated. 274 | # texinfo_domain_indices = True 275 | 276 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 277 | # texinfo_show_urls = 'footnote' 278 | 279 | 280 | # Example configuration for intersphinx: refer to the Python standard library. 281 | intersphinx_mapping = {"http://docs.python.org/": None} 282 | 283 | # autodoc options 284 | autodoc_member_order = "bysource" 285 | 286 | 287 | # always document __init__ functions 288 | def skip(app, what, name, obj, skip, options): 289 | return skip 290 | 291 | 292 | def setup(app): 293 | app.connect("autodoc-skip-member", skip) 294 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | deltabot: chat bots for deltachat 2 | ================================= 3 | 4 | The ``deltabot`` Python package implements several facilities: 5 | 6 | - a command line interface to initialize, configure and run bots 7 | 8 | - an extension API to let your Chat bot add new commands, 9 | configuration and interact with incoming messages 10 | 11 | deltabot uses the deltachat python package which 12 | contains bindings with the deltachat core Rust-library. 13 | 14 | 15 | getting started 16 | --------------- 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | 21 | install 22 | 23 | .. toctree:: 24 | :hidden: 25 | 26 | links 27 | changelog 28 | api 29 | plugins 30 | 31 | .. 32 | Indices and tables 33 | ================== 34 | 35 | * :ref:`genindex` 36 | * :ref:`modindex` 37 | * :ref:`search` 38 | 39 | -------------------------------------------------------------------------------- /doc/install.rst: -------------------------------------------------------------------------------- 1 | 2 | .. include:: ../README.rst 3 | -------------------------------------------------------------------------------- /doc/links.rst: -------------------------------------------------------------------------------- 1 | 2 | links 3 | ================================ 4 | 5 | .. _`deltachat`: https://delta.chat 6 | .. _`deltachat-core repo`: https://github.com/deltachat 7 | .. _pip: http://pypi.org/project/pip/ 8 | .. _virtualenv: http://pypi.org/project/virtualenv/ 9 | .. _merlinux: http://merlinux.eu 10 | .. _pypi: http://pypi.org/ 11 | .. _`issue-tracker`: https://github.com/deltachat/deltachat-core 12 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\devpi.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\devpi.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /doc/plugins.rst: -------------------------------------------------------------------------------- 1 | 2 | Implementing Plugin Hooks 3 | ========================== 4 | 5 | The Delta Chat Python bindings use `pluggy `_ 6 | for managing global and per-account plugin registration, and performing 7 | hook calls. There are two kinds of plugins: 8 | 9 | - Global plugins that are active for all accounts; they can implement 10 | hooks at account-creation and account-shutdown time. 11 | 12 | - Account plugins that are only active during the lifetime of a 13 | single Account instance. 14 | 15 | 16 | Registering a plugin 17 | -------------------- 18 | 19 | .. autofunction:: deltachat.register_global_plugin 20 | :noindex: 21 | 22 | .. automethod:: deltachat.account.Account.add_account_plugin 23 | :noindex: 24 | 25 | 26 | Per-Account Hook specifications 27 | ------------------------------- 28 | 29 | .. autoclass:: deltachat.hookspec.PerAccount 30 | :members: 31 | 32 | 33 | Global Hook specifications 34 | -------------------------- 35 | 36 | .. autoclass:: deltachat.hookspec.Global 37 | :members: 38 | 39 | -------------------------------------------------------------------------------- /examples/admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates how to add administrator commands, normal users 3 | will not see or be able to use this commands, only bot administrators can. 4 | To add a bot administrator you have to run in the command line: 5 | 6 | $ simplebot admin --add me@example.com 7 | """ 8 | 9 | import simplebot 10 | 11 | 12 | @simplebot.command(admin=True) 13 | def kick(message): 14 | """Kick from group the user that sent the quoted message. 15 | 16 | You must use the command in a group, swipe to reply a message and send: 17 | /kick 18 | as reply to the message, the bot will remove the sender of that message 19 | from the group. This can be useful in big groups. 20 | """ 21 | message.chat.remove_contact(message.quote.get_sender_contact()) 22 | 23 | 24 | @simplebot.command(admin=True) 25 | def status(bot, payload, replies): 26 | """Set bot status message. 27 | 28 | Example: /status I am way too cool. 29 | """ 30 | bot.account.set_config("selfstatus", payload) 31 | replies.add(text="Status updated.") 32 | 33 | 34 | class TestAdmin: 35 | def test_kick(self, mocker): 36 | admin = "admin@example.org" 37 | mocker.bot.add_admin(admin) 38 | 39 | addr = "user1@example.org" 40 | quote = mocker.make_incoming_message(addr=addr, group="mockgroup") 41 | assert addr in [c.addr for c in quote.chat.get_contacts()] 42 | 43 | replies = mocker.get_replies( 44 | text="/kick", addr=admin, quote=quote, group=quote.chat 45 | ) 46 | assert not replies 47 | assert addr not in [c.addr for c in quote.chat.get_contacts()] 48 | 49 | def test_status(self, mocker): 50 | admin = "admin@example.org" 51 | mocker.bot.add_admin(admin) 52 | 53 | status = "My test\nstatus." 54 | assert mocker.bot.account.get_config("selfstatus") != status 55 | 56 | mocker.get_one_reply(text="/status " + status, addr=admin) 57 | assert mocker.bot.account.get_config("selfstatus") == status 58 | -------------------------------------------------------------------------------- /examples/deltachat_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates how to use objects from deltachat API. 3 | For more info check deltachat package's documentation. 4 | """ 5 | 6 | import simplebot 7 | 8 | 9 | @simplebot.filter 10 | def message_info(message, replies): 11 | """Send info about the received message.""" 12 | lines = [] 13 | 14 | sender = message.get_sender_contact() 15 | lines.append("Contact:") 16 | lines.append("Name: {}".format(sender.name)) 17 | lines.append("Address: {}".format(sender.addr)) 18 | lines.append("Verified: {}".format(sender.is_verified())) 19 | lines.append("Status: {!r}".format(sender.status)) 20 | 21 | lines.append("") 22 | 23 | lines.append("Message:") 24 | lines.append("Encrypted: {}".format(message.is_encrypted())) 25 | if message.is_text(): 26 | t = "text" 27 | elif message.is_image(): 28 | t = "image" 29 | elif message.is_gif(): 30 | t = "gif" 31 | elif message.is_audio(): 32 | t = "audio" 33 | elif message.is_video(): 34 | t = "video" 35 | elif message.is_file(): 36 | t = "file" 37 | else: 38 | t = "unknown" 39 | lines.append("Type: {}".format(t)) 40 | if message.filename: 41 | lines.append("Attachment: {}".formmat(message.filename)) 42 | 43 | lines.append("") 44 | 45 | chat = message.chat 46 | lines.append("Chat:") 47 | lines.append("Name: {}".format(chat.get_name())) 48 | lines.append("ID: {}".format(chat.id)) 49 | lines.append("Type: {}".format("group" if chat.is_multiuser() else "private")) 50 | if chat.is_multiuser(): 51 | lines.append("Member Count: {}".format(len(chat.get_contacts()))) 52 | 53 | replies.add(text="\n".join(lines)) 54 | 55 | 56 | class TestDeltaChatApi: 57 | def test_message_info(self, mocker): 58 | addr = "addr@example.org" 59 | msg = mocker.get_one_reply(text="deltachat_api", addr=addr, filters=__name__) 60 | assert addr in msg.text 61 | -------------------------------------------------------------------------------- /examples/dynamic.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates how to add commands and filters dynamically. 3 | """ 4 | 5 | import os 6 | 7 | import simplebot 8 | 9 | 10 | @simplebot.hookimpl 11 | def deltabot_init(bot): 12 | if os.environ.get("LANG", "").startswith("es_"): # Spanish locale 13 | name = "/mi_eco" 14 | description = "Repite el texto enviado" 15 | else: 16 | name = "/my_echo" 17 | description = "Echoes back the given text" 18 | admin_only = os.environ.get("BOT_ADMIN_ONLY") == "1" 19 | bot.commands.register( 20 | func=echo_command, 21 | name=name, 22 | help=description, 23 | admin=admin_only, 24 | ) 25 | 26 | if os.environ.get("BOT_ENABLE_FILTER"): 27 | bot.filters.register(func=echo_filter, help=description, admin=admin_only) 28 | 29 | 30 | def echo_command(payload, replies): 31 | replies.add(text=payload) 32 | 33 | 34 | def echo_filter(message, replies): 35 | replies.add(text=message.text) 36 | 37 | 38 | class TestSendFile: 39 | def test_cmd(self, mocker): 40 | msgs = mocker.get_replies("/my_echo hello") 41 | if len(msgs) == 1: 42 | msg = msgs[0] 43 | else: 44 | msg = mocker.get_one_reply("/mi_eco hello") 45 | assert msg.text == "hello" 46 | 47 | def test_filter(self, mocker): 48 | if not os.environ.get("BOT_ENABLE_FILTER"): 49 | mocker.bot.filters.register(func=echo_filter, help="test") 50 | 51 | msg = mocker.get_one_reply("hello", filters=__name__) 52 | assert msg.text == "hello" 53 | -------------------------------------------------------------------------------- /examples/filter_priority.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates how to control filter execution order. 3 | """ 4 | 5 | import simplebot 6 | 7 | blacklist = ["user1@example.com", "user2@example.org"] 8 | 9 | 10 | @simplebot.filter(tryfirst=True) 11 | def validate(message): 12 | """Check that the sender is not in the blacklist.""" 13 | if message.get_sender_contact().addr in blacklist: 14 | # this will prevent other filters to process the message 15 | # NOTE: this doesn't apply to commands! 16 | return True 17 | 18 | 19 | @simplebot.filter 20 | def reply_salute(message, replies): 21 | """Reply to some common salutes.""" 22 | if message.text in ("hi", "hello", "howdy"): 23 | replies.add(text=message.text, quote=message) 24 | 25 | 26 | @simplebot.filter(trylast=True) 27 | def send_help(replies): 28 | """Send some hints to the sender if the message was not replied by a previous filter.""" 29 | if not replies.has_replies(): 30 | replies.add(text='I don\'t understand, send "hi"') 31 | 32 | 33 | class TestFilterPriority: 34 | def test_validate(self, mocker): 35 | import pytest 36 | 37 | mocker.get_one_reply(text="test validate", filters=__name__) 38 | 39 | msgs = mocker.get_replies( 40 | text="test validate", addr=blacklist[1], filters=__name__ 41 | ) 42 | assert not msgs 43 | 44 | def test_reply_salute(self, mocker): 45 | text = "hello" 46 | msg = mocker.get_one_reply(text=text, filters=__name__) 47 | assert msg.text == text 48 | 49 | def test_send_help(self, mocker): 50 | resp = "I don't understand" 51 | 52 | msg = mocker.get_one_reply(text="test send_help", filters=__name__) 53 | assert resp in msg.text 54 | 55 | msg = mocker.get_one_reply(text="hello", filters=__name__) 56 | assert resp not in msg.text 57 | -------------------------------------------------------------------------------- /examples/hooks.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates how to use some of simplebot's hooks at the 3 | package level and also in a class that is registered as a plugin. 4 | To see all available hooks check ``simplebot.hookspec`` 5 | """ 6 | 7 | import simplebot 8 | 9 | 10 | @simplebot.hookimpl 11 | def deltabot_init(bot): 12 | bot.plugins.add_module("grouplogging", GroupLoggingPlugin()) 13 | 14 | 15 | class GroupLoggingPlugin: 16 | @simplebot.hookimpl 17 | def deltabot_incoming_message(self, message): 18 | message.chat.send_text( 19 | "bot: incoming_message sys={} body={!r}".format( 20 | message.is_system_message(), message.text 21 | ) 22 | ) 23 | 24 | @simplebot.hookimpl 25 | def deltabot_member_added(self, chat, contact, actor, message, replies): 26 | replies.add("bot: member_added {}".format(contact.addr)) 27 | 28 | @simplebot.hookimpl 29 | def deltabot_member_removed(self, chat, contact, actor, message, replies): 30 | replies.add("bot: member_removed {}".format(contact.addr)) 31 | 32 | 33 | class TestGroupLoggingPlugin: 34 | def test_events(self, bot_tester, acfactory, lp): 35 | lp.sec("creating test accounts") 36 | ac3 = acfactory.get_one_online_account() 37 | 38 | lp.sec("create a group chat with only bot and the sender account") 39 | chat = bot_tester.send_account.create_group_chat("test") 40 | chat.add_contact(bot_tester.bot_contact) 41 | 42 | lp.sec("send a text and wait for reply") 43 | chat.send_text("some") 44 | reply = bot_tester.get_next_incoming() 45 | assert "some" in reply.text 46 | assert "sys=False" in reply.text 47 | 48 | lp.sec("add ac3 account to group chat") 49 | chat.add_contact(ac3) 50 | reply = bot_tester.get_next_incoming() 51 | assert "member_added" in reply.text 52 | assert ac3.get_config("addr") in reply.text 53 | 54 | lp.sec("remove ac3 account from group chat") 55 | chat.remove_contact(ac3) 56 | reply = bot_tester.get_next_incoming() 57 | assert "member_removed" in reply.text 58 | assert ac3.get_config("addr") in reply.text 59 | -------------------------------------------------------------------------------- /examples/impersonating.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates: 3 | 4 | * how to send a message impersonating another username. 5 | * how to define preferences that can be customized by each user. 6 | """ 7 | 8 | import simplebot 9 | 10 | 11 | @simplebot.hookimpl 12 | def deltabot_init(bot): 13 | bot.add_preference("name", "the name to impersonate") 14 | 15 | 16 | @simplebot.filter 17 | def filter_messages(bot, message, replies): 18 | """Send me any message in private and I will reply with the same message but impersonating another username.""" 19 | if not message.chat.is_multiuser() and message.text: 20 | sender = message.get_sender_contact() 21 | name = bot.get("name", scope=sender.addr) or sender.name 22 | replies.add(text=message.text, sender=name) 23 | 24 | 25 | class TestImpersonating: 26 | def test_impersonating(self, mocker): 27 | msg = mocker.get_one_reply(text="hello", filters=__name__) 28 | assert msg.override_sender_name 29 | -------------------------------------------------------------------------------- /examples/quote_reply.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates how to quote-reply a message. 3 | """ 4 | 5 | import re 6 | 7 | import simplebot 8 | 9 | 10 | @simplebot.command 11 | def mycalc(payload, message, replies): 12 | """caculcates result of arithmetic integer expression. 13 | 14 | send "/mycalc 23+20" to the bot to get the result "43" back 15 | """ 16 | # don't directly use eval() as it could execute arbitrary code 17 | parts = re.split(r"[\+\-\*\/]", payload) 18 | try: 19 | for part in parts: 20 | int(part.strip()) 21 | except ValueError: 22 | reply = "ExpressionError: {!r} not an int in {!r}".format(part, payload) 23 | else: 24 | # now it's safe to use eval 25 | reply = "result of {!r}: {}".format(payload, eval(payload)) 26 | 27 | replies.add(text=reply, quote=message) 28 | 29 | 30 | class TestQuoteReply: 31 | def test_mock_calc(self, mocker): 32 | reply_msg = mocker.get_one_reply("/mycalc 1+1") 33 | assert reply_msg.text.endswith("2") 34 | 35 | def test_mock_calc_fail(self, mocker): 36 | reply_msg = mocker.get_one_reply("/mycalc 1w+1") 37 | assert "ExpressionError" in reply_msg.text 38 | 39 | def test_bot_mycalc(self, bot_tester): 40 | msg_reply = bot_tester.get_one_reply("/mycalc 10*13+2") 41 | assert msg_reply.text.endswith("132") 42 | -------------------------------------------------------------------------------- /examples/send_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates how to reply with a file. 3 | This plugin uses a 3rd party lib so you will need to install it first: 4 | 5 | $ pip install xkcd 6 | """ 7 | 8 | import io 9 | from urllib.request import urlopen 10 | 11 | import xkcd 12 | 13 | import simplebot 14 | 15 | 16 | @simplebot.command(name="/xkcd") 17 | def cmd_xkcd(replies): 18 | """Send ramdom XKCD comic.""" 19 | comic = xkcd.getRandomComic() 20 | image = io.BytesIO(urlopen(comic.imageLink).read()) 21 | text = "#{} - {}\n\n{}".format(comic.number, comic.title, comic.altText) 22 | # we could omit bytefile and only send filename with a path to a file 23 | replies.add(text=text, filename=comic.imageName, bytefile=image) 24 | 25 | 26 | class TestSendFile: 27 | def test_cmd_xkcd(self, mocker): 28 | msg = mocker.get_one_reply("/xkcd") 29 | assert msg.text.startswith("#") 30 | assert msg.filename 31 | assert msg.is_image() 32 | -------------------------------------------------------------------------------- /examples/simplebot_echo/CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.1.0 5 | ----- 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /examples/simplebot_echo/LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /examples/simplebot_echo/README.rst: -------------------------------------------------------------------------------- 1 | "Echo" SimpleBot example plugin 2 | =============================== 3 | 4 | This serves as an example for how to write and package plugins 5 | with standard Python packaging machinery. 6 | 7 | The echo plugin registers a single `/echo` command that end-users 8 | can send to the bot. 9 | 10 | -------------------------------------------------------------------------------- /examples/simplebot_echo/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = simplebot_echo 3 | version = attr: simplebot_echo.version 4 | description = Example simplebot echo plugin for http://delta.chat/ 5 | long_description = file: README.rst, CHANGELOG.rst, LICENSE.rst 6 | keywords = simplebot, plugin, echo 7 | license = MPL 8 | classifiers = 9 | Development Status :: 3 - Alpha 10 | Environment :: Plugins 11 | Programming Language :: Python :: 3 12 | License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0) 13 | Operating System :: OS Independent 14 | Topic :: Utilities 15 | 16 | [options] 17 | zip_safe = False 18 | include_package_data = True 19 | packages = find: 20 | install_requires = 21 | simplebot 22 | 23 | [options.entry_points] 24 | simplebot.plugins = 25 | simplebot_echo = simplebot_echo 26 | 27 | [options.package_data] 28 | * = *.txt, *.rst 29 | -------------------------------------------------------------------------------- /examples/simplebot_echo/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /examples/simplebot_echo/simplebot_echo.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example illustrates how to register functions as commands/filters. 3 | """ 4 | 5 | import simplebot 6 | 7 | version = "0.1.0" 8 | 9 | 10 | @simplebot.command 11 | def echo(payload, replies): 12 | """Echoes back text. Example: /echo hello world""" 13 | replies.add(text=payload or "echo") 14 | 15 | 16 | @simplebot.filter 17 | def echo_filter(message, replies): 18 | """Echoes back received message.""" 19 | replies.add(text=message.text) 20 | 21 | 22 | class TestEcho: 23 | def test_echo(self, mocker): 24 | msg = mocker.get_one_reply("/echo") 25 | assert msg.text == "echo" 26 | 27 | msg = mocker.get_one_reply("/echo hello world") 28 | assert msg.text == "hello world" 29 | 30 | def test_echo_filter(self, mocker): 31 | text = "testing echo filter" 32 | msg = mocker.get_one_reply(text, filters=__name__) 33 | assert msg.text == text 34 | 35 | text = "testing echo filter in group" 36 | msg = mocker.get_one_reply(text, group="mockgroup", filters=__name__) 37 | assert msg.text == text 38 | -------------------------------------------------------------------------------- /examples/simplebot_echo/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35,lint 3 | 4 | [testenv] 5 | deps = 6 | pytest 7 | pdbpp 8 | commands = 9 | pytest {posargs} 10 | 11 | 12 | [testenv:lint] 13 | usedevelop = True 14 | basepython = python3 15 | deps = 16 | flake8 17 | restructuredtext_lint 18 | pygments 19 | commands = 20 | rst-lint README.rst CHANGELOG.rst 21 | flake8 --ignore=E127 --ignore=E741 --max-line-length 100 . 22 | 23 | [testenv:check-manifest] 24 | skip_install = True 25 | basepython = python3 26 | deps = check-manifest 27 | commands = check-manifest 28 | 29 | 30 | [pytest] 31 | addopts = -v 32 | filterwarnings = 33 | ignore::DeprecationWarning 34 | python_files = *.py 35 | -------------------------------------------------------------------------------- /requirements/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | -r requirements-test.txt 3 | black==24.4.2 4 | flake8==7.0.0 5 | isort==5.13.2 6 | -------------------------------------------------------------------------------- /requirements/requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest==8.2.1 2 | # needed by examples: 3 | xkcd==2.4.2 4 | wikiquote==0.1.17 5 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | deltachat==1.142.7 2 | py==1.11.0 3 | Pillow==11.1.0 4 | Jinja2==3.1.5 5 | -------------------------------------------------------------------------------- /scripts/create_service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Script to help creating systemd services for SimpleBot bots. 3 | Example usage: 4 | create_service.py --name "bot1" --user "exampleUser" --cmd "simplebot -a bot1@example.com serve" 5 | """ 6 | 7 | import argparse 8 | import subprocess 9 | 10 | cfg = """[Unit] 11 | Description={name} service 12 | After=network.target 13 | StartLimitIntervalSec=0 14 | 15 | [Service] 16 | Type=simple 17 | Restart=always 18 | RestartSec=5 19 | ## Restart every 24h: 20 | #WatchdogSec=86400 21 | #WatchdogSignal=SIGKILL 22 | User={user} 23 | ExecStart={cmd} 24 | 25 | [Install] 26 | WantedBy=multi-user.target 27 | """ 28 | 29 | 30 | if __name__ == "__main__": 31 | p = argparse.ArgumentParser(description="Simplebot service creator") 32 | p.add_argument("-n", "--name", help="Service name", required=True) 33 | p.add_argument("-u", "--user", help="User that will run the service", required=True) 34 | p.add_argument("-c", "--cmd", help="Command that will start the bot", required=True) 35 | args = p.parse_args() 36 | 37 | cfg = cfg.format(name=args.name, user=args.user, cmd=args.cmd) 38 | 39 | path = f"/etc/systemd/system/{args.name}.service" 40 | print(f"\nSERVICE PATH: {path}\nSERVICE CONFIGURATION:\n{cfg}") 41 | input("[Press enter to processed...]") 42 | with open(path, "w") as fd: 43 | fd.write(cfg) 44 | 45 | cmd = ["systemctl", "enable", args.name] 46 | subprocess.run(cmd) 47 | cmd[1] = "start" 48 | subprocess.run(cmd) 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup SimpleBot installation.""" 2 | 3 | import os 4 | 5 | import setuptools 6 | 7 | 8 | def load_requirements(path: str) -> list: 9 | """Load requirements from the given relative path.""" 10 | with open(path, encoding="utf-8") as file: 11 | requirements = [] 12 | for line in file.read().split("\n"): 13 | if line.startswith("-r"): 14 | dirname = os.path.dirname(path) 15 | filename = line.split(maxsplit=1)[1] 16 | requirements.extend(load_requirements(os.path.join(dirname, filename))) 17 | elif line and not line.startswith("#"): 18 | requirements.append(line.replace("==", ">=")) 19 | return requirements 20 | 21 | 22 | if __name__ == "__main__": 23 | with open("README.md", encoding="utf-8") as f: 24 | long_desc = f.read() 25 | 26 | setuptools.setup( 27 | name="simplebot", 28 | setup_requires=["setuptools_scm"], 29 | use_scm_version={ 30 | "root": ".", 31 | "relative_to": __file__, 32 | "tag_regex": r"^(?Pv)?(?P[^\+]+)(?P.*)?$", 33 | "git_describe_command": "git describe --dirty --tags --long --match v*.*.*", 34 | }, 35 | description="SimpleBot: Extensible bot for Delta Chat", 36 | long_description=long_desc, 37 | long_description_content_type="text/markdown", 38 | author="The SimpleBot Contributors", 39 | author_email="adbenitez@nauta.cu, holger@merlinux.eu", 40 | url="https://github.com/simplebot-org/simplebot", 41 | package_dir={"": "src"}, 42 | packages=setuptools.find_packages("src"), 43 | keywords="deltachat bot email", 44 | classifiers=[ 45 | "Development Status :: 4 - Beta", 46 | "Intended Audience :: Developers", 47 | "Intended Audience :: System Administrators", 48 | "Intended Audience :: End Users/Desktop", 49 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 50 | "Operating System :: POSIX", 51 | "Topic :: Utilities", 52 | "Programming Language :: Python :: 3", 53 | ], 54 | entry_points=""" 55 | [console_scripts] 56 | simplebot=simplebot.main:main 57 | [pytest11] 58 | simplebot.pytestplugin=simplebot.pytestplugin 59 | """, 60 | python_requires=">=3.9", 61 | install_requires=load_requirements("requirements/requirements.txt"), 62 | extras_require={ 63 | "test": load_requirements("requirements/requirements-test.txt"), 64 | "dev": load_requirements("requirements/requirements-dev.txt"), 65 | }, 66 | include_package_data=True, 67 | zip_safe=False, 68 | ) 69 | -------------------------------------------------------------------------------- /src/simplebot/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import DeltaBot # noqa 2 | from .commands import command_decorator as command # noqa 3 | from .filters import filter_decorator as filter # noqa 4 | from .hookspec import deltabot_hookimpl as hookimpl # noqa 5 | -------------------------------------------------------------------------------- /src/simplebot/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .main import main 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /src/simplebot/avatars/adaptive-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/adaptive-alt.png -------------------------------------------------------------------------------- /src/simplebot/avatars/adaptive-default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/adaptive-default.png -------------------------------------------------------------------------------- /src/simplebot/avatars/blue-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/blue-alt.png -------------------------------------------------------------------------------- /src/simplebot/avatars/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/blue.png -------------------------------------------------------------------------------- /src/simplebot/avatars/green-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/green-alt.png -------------------------------------------------------------------------------- /src/simplebot/avatars/green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/green.png -------------------------------------------------------------------------------- /src/simplebot/avatars/purple-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/purple-alt.png -------------------------------------------------------------------------------- /src/simplebot/avatars/purple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/purple.png -------------------------------------------------------------------------------- /src/simplebot/avatars/red-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/red-alt.png -------------------------------------------------------------------------------- /src/simplebot/avatars/red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/red.png -------------------------------------------------------------------------------- /src/simplebot/avatars/simplebot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/simplebot.png -------------------------------------------------------------------------------- /src/simplebot/avatars/yellow-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/yellow-alt.png -------------------------------------------------------------------------------- /src/simplebot/avatars/yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/avatars/yellow.png -------------------------------------------------------------------------------- /src/simplebot/builtin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplebot-org/simplebot/ff677ea4a736212128a32829048e6f21fcbc5b18/src/simplebot/builtin/__init__.py -------------------------------------------------------------------------------- /src/simplebot/builtin/admin.py: -------------------------------------------------------------------------------- 1 | from ..commands import command_decorator 2 | from ..hookspec import deltabot_hookimpl 3 | 4 | 5 | @deltabot_hookimpl 6 | def deltabot_init_parser(parser) -> None: 7 | parser.add_subcommand(ban) 8 | parser.add_subcommand(unban) 9 | parser.add_subcommand(list_banned) 10 | parser.add_subcommand(AdminCmd) 11 | 12 | 13 | class ban: 14 | """ban the given address.""" 15 | 16 | def add_arguments(self, parser) -> None: 17 | parser.add_argument("addr", help="email address to ban") 18 | 19 | def run(self, bot, args, out) -> None: 20 | ban_addr(bot, args.addr) 21 | out.line(f"Banned: {args.addr}") 22 | 23 | 24 | class unban: 25 | """unban the given address.""" 26 | 27 | def add_arguments(self, parser) -> None: 28 | parser.add_argument("addr", help="email address to unban") 29 | 30 | def run(self, bot, args, out) -> None: 31 | unban_addr(bot, args.addr) 32 | out.line(f"Unbanned: {args.addr}") 33 | 34 | 35 | class list_banned: 36 | """list banned addresses.""" 37 | 38 | def run(self, bot, args, out) -> None: 39 | out.line(get_banned_list(bot)) 40 | 41 | 42 | class AdminCmd: 43 | """administrator tools.""" 44 | 45 | name = "admin" 46 | db_key = "administrators" 47 | 48 | def add_arguments(self, parser) -> None: 49 | parser.add_argument( 50 | "-a", 51 | "--add", 52 | help="grant administrator rights to an address.", 53 | metavar="ADDR", 54 | ) 55 | parser.add_argument( 56 | "-d", 57 | "--del", 58 | help="revoke administrator rights to an address.", 59 | metavar="ADDR", 60 | dest="_del", 61 | ) 62 | parser.add_argument( 63 | "-l", "--list", help="list administrators.", action="store_true" 64 | ) 65 | 66 | def run(self, bot, args, out) -> None: 67 | if args.add: 68 | self._add(bot, args.add) 69 | elif args._del: 70 | self._del(bot, args._del) 71 | else: 72 | self._list(bot, out) 73 | 74 | def _add(self, bot, addr) -> None: 75 | add_admin(bot, addr) 76 | 77 | def _del(self, bot, addr) -> None: 78 | del_admin(bot, addr) 79 | 80 | def _list(self, bot, out) -> None: 81 | admins = bot.get(self.db_key, default="(Empty list)") 82 | out.line(f"Administrators:\n{admins}") 83 | 84 | 85 | @command_decorator(name="/ban", admin=True) 86 | def cmd_ban(command, replies) -> None: 87 | """Ban the given address or list banned addresses if no address is given. 88 | 89 | Examples: 90 | /ban foo@example.com 91 | /ban 92 | """ 93 | if "@" in command.payload: 94 | ban_addr(command.bot, command.payload) 95 | replies.add(text=f"Banned: {command.payload}") 96 | else: 97 | replies.add(text=get_banned_list(command.bot)) 98 | 99 | 100 | @command_decorator(name="/unban", admin=True) 101 | def cmd_unban(command, replies) -> None: 102 | """Unban the given address. 103 | 104 | Examples: 105 | /unban foo@example.com 106 | """ 107 | unban_addr(command.bot, command.payload) 108 | replies.add(text=f"Unbanned: {command.payload}") 109 | 110 | 111 | def ban_addr(bot, addr: str) -> None: 112 | contact = bot.get_contact(addr) 113 | contact.block() 114 | bot.plugins.hook.deltabot_ban(bot=bot, contact=contact) 115 | 116 | 117 | def unban_addr(bot, addr: str) -> None: 118 | contact = bot.get_contact(addr) 119 | contact.unblock() 120 | bot.plugins.hook.deltabot_unban(bot=bot, contact=contact) 121 | 122 | 123 | def get_banned_list(bot) -> str: 124 | addrs = [] 125 | for contact in bot.account.get_blocked_contacts(): 126 | addrs.append(contact.addr) 127 | banned = "\n".join(addrs) or "(Empty list)" 128 | return f"Banned addresses:\n{banned}" 129 | 130 | 131 | def get_admins(bot) -> list: 132 | return bot.get(AdminCmd.db_key, default="").split("\n") 133 | 134 | 135 | def add_admin(bot, addr) -> None: 136 | existing = set() 137 | for a in bot.get(AdminCmd.db_key, default="").split("\n"): 138 | existing.add(a) 139 | assert "," not in addr 140 | existing.add(addr) 141 | bot.set(AdminCmd.db_key, "\n".join(existing)) 142 | 143 | 144 | def del_admin(bot, addr) -> None: 145 | existing = set() 146 | for a in bot.get(AdminCmd.db_key, default="").split("\n"): 147 | existing.add(a) 148 | existing.remove(addr) 149 | bot.set(AdminCmd.db_key, "\n".join(existing)) 150 | 151 | 152 | class TestCommandAdmin: 153 | def test_mock_cmd_ban(self, mocker): 154 | reply_msg = mocker.get_one_reply("/ban foo@example.com") 155 | assert reply_msg.text.lower().startswith("banned:") 156 | reply_msg = mocker.get_one_reply("/ban") 157 | assert not reply_msg.text.lower().startswith("banned:") 158 | 159 | def test_mock_cmd_unban(self, mocker): 160 | reply_msg = mocker.get_one_reply("/unban foo@example.com") 161 | assert reply_msg.text.lower().startswith("unbanned:") 162 | -------------------------------------------------------------------------------- /src/simplebot/builtin/cmdline.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | 5 | from deltachat.tracker import ImexFailed 6 | 7 | from ..hookspec import deltabot_hookimpl 8 | from ..utils import ( 9 | abspath, 10 | get_account_path, 11 | get_accounts, 12 | get_builtin_avatars, 13 | get_default_account, 14 | set_builtin_avatar, 15 | set_default_account, 16 | ) 17 | 18 | 19 | @deltabot_hookimpl 20 | def deltabot_init_parser(parser) -> None: 21 | parser.add_subcommand(Init) 22 | parser.add_subcommand(ImportCmd) 23 | parser.add_subcommand(ExportCmd) 24 | parser.add_subcommand(Info) 25 | parser.add_subcommand(Serve) 26 | parser.add_subcommand(PluginCmd) 27 | parser.add_subcommand(set_avatar) 28 | parser.add_subcommand(set_name) 29 | parser.add_subcommand(set_status) 30 | parser.add_subcommand(set_config) 31 | 32 | parser.add_generic_option( 33 | "-d", 34 | "--default-account", 35 | action=DefaultAccountAction, 36 | help="set default account.", 37 | ) 38 | parser.add_generic_option( 39 | "-l", 40 | "--list-accounts", 41 | action=ListAccountsAction, 42 | help="list configured accounts.", 43 | ) 44 | parser.add_generic_option( 45 | "--avatars", action=ListAvatarsAction, help="show available builtin avatars." 46 | ) 47 | path = lambda p: ( 48 | get_account_path(p) 49 | if os.path.exists(get_account_path(p)) 50 | else os.path.abspath(os.path.expanduser(p)) 51 | ) 52 | default_account = os.environ.get("SIMPLEBOT_ACCOUNT") 53 | parser.add_generic_option( 54 | "-a", 55 | "--account", 56 | action="store", 57 | metavar="ADDR_OR_PATH", 58 | dest="basedir", 59 | type=path, 60 | default=default_account, 61 | help="address of the configured account to use or directory for" 62 | " storing all account state (can be set via SIMPLEBOT_ACCOUNT" 63 | " environment variable).", 64 | ) 65 | parser.add_generic_option( 66 | "--show-ffi", action="store_true", help="show low level ffi events." 67 | ) 68 | 69 | 70 | @deltabot_hookimpl 71 | def deltabot_init(bot, args) -> None: 72 | if args.show_ffi: 73 | from deltachat.events import FFIEventLogger # type: ignore 74 | 75 | log = FFIEventLogger(bot.account) 76 | bot.account.add_account_plugin(log) 77 | 78 | 79 | class ListAvatarsAction(argparse.Action): 80 | def __init__(self, *args, **kwargs) -> None: 81 | kwargs["nargs"] = 0 82 | super().__init__(*args, **kwargs) 83 | 84 | def __call__(self, parser, *args, **kwargs) -> None: 85 | for name in sorted(get_builtin_avatars()): 86 | parser.out.line(name) 87 | sys.exit(0) 88 | 89 | 90 | class ListAccountsAction(argparse.Action): 91 | def __init__(self, *args, **kwargs) -> None: 92 | kwargs["nargs"] = 0 93 | super().__init__(*args, **kwargs) 94 | 95 | def __call__(self, parser, *args, **kwargs) -> None: 96 | def_addr = get_default_account() 97 | for addr, path in get_accounts(): 98 | if def_addr == addr: 99 | parser.out.line(f"(default) {addr}: {path}") 100 | else: 101 | parser.out.line(f"{addr}: {path}") 102 | sys.exit(0) 103 | 104 | 105 | class DefaultAccountAction(argparse.Action): 106 | def __init__(self, *args, **kwargs) -> None: 107 | kwargs["metavar"] = "ADDR" 108 | super().__init__(*args, **kwargs) 109 | 110 | def __call__(self, parser, namespace, values, option_string=None) -> None: 111 | addr = values[0] 112 | if not os.path.exists(get_account_path(addr)): 113 | parser.out.fail( 114 | f'Unknown account "{addr}", add it first with "simplebot init"' 115 | ) 116 | 117 | set_default_account(addr) 118 | sys.exit(0) 119 | 120 | 121 | class Init: 122 | """initialize account with emailaddr and password. 123 | 124 | This will set and verify smtp/imap connectivity using the provided credentials. 125 | """ 126 | 127 | def add_arguments(self, parser) -> None: 128 | parser.add_argument("emailaddr", type=str) 129 | parser.add_argument("password", type=str) 130 | parser.add_argument( 131 | "-c", 132 | "--config", 133 | help="set low-level account settings before configuring the account, this is useful if you use an email server with custom configurations that Delta Chat cannot guess, like not standard ports etc., common used keys: (IMAP: mail_server, mail_port, mail_security), (SMTP: send_server, send_port, send_security)", 134 | action="append", 135 | default=[], 136 | nargs=2, 137 | metavar=("KEY", "VALUE"), 138 | ) 139 | 140 | def run(self, bot, args, out) -> None: 141 | if "@" not in args.emailaddr: 142 | out.fail(f"invalid email address: {args.emailaddr!r}") 143 | success = bot.perform_configure_address( 144 | args.emailaddr, args.password, **dict(args.config) 145 | ) 146 | if not success: 147 | out.fail(f"failed to configure with: {args.emailaddr}") 148 | 149 | 150 | class ImportCmd: 151 | """import keys or full backup.""" 152 | 153 | name = "import" 154 | 155 | def add_arguments(self, parser) -> None: 156 | parser.add_argument( 157 | "path", 158 | help="path to a backup file or path to a directory containing keys to import.", 159 | type=abspath, 160 | ) 161 | 162 | def run(self, bot, args, out) -> None: 163 | if os.path.isdir(args.path): 164 | try: 165 | bot.account.import_self_keys(args.path) 166 | out.line("Keys imported successfully.") 167 | except ImexFailed: 168 | out.fail(f"no valid keys found in {args.path!r}") 169 | elif os.path.isfile(args.path): 170 | if bot.account.is_configured(): 171 | out.fail("can't import backup into an already configured account") 172 | else: 173 | try: 174 | bot.account.import_all(args.path) 175 | print("Backup imported successfully") 176 | except ImexFailed: 177 | out.fail(f"invalid backup file {args.path!r}") 178 | else: 179 | out.fail(f"file doesn't exists {args.path!r}") 180 | 181 | 182 | class ExportCmd: 183 | """export full backup or keys.""" 184 | 185 | name = "export" 186 | 187 | def add_arguments(self, parser) -> None: 188 | parser.add_argument( 189 | "--keys-only", 190 | "-k", 191 | action="store_true", 192 | help="export only the public and private keys", 193 | ) 194 | parser.add_argument( 195 | "folder", 196 | help="path to the directory where the files should be saved, if not given, current working directory is used.", 197 | nargs="?", 198 | default=os.curdir, 199 | type=abspath, 200 | ) 201 | 202 | def run(self, bot, args, out) -> None: 203 | try: 204 | if args.keys_only: 205 | paths = bot.account.export_self_keys(args.folder) 206 | else: 207 | paths = [bot.account.export_all(args.folder)] 208 | out.line("Exported files:") 209 | for path in paths: 210 | out.line(path) 211 | except ImexFailed: 212 | out.fail(f"failed to export to {args.folder!r}") 213 | 214 | 215 | class Info: 216 | """show information about configured account.""" 217 | 218 | def run(self, bot, out) -> None: 219 | if not bot.is_configured(): 220 | out.fail("account not configured, use 'simplebot init'") 221 | 222 | for key, val in bot.account.get_info().items(): 223 | out.line(f"{key:30s}: {val}") 224 | 225 | 226 | class Serve: 227 | """serve and react to incoming messages""" 228 | 229 | def run(self, bot, out) -> None: 230 | if not bot.is_configured(): 231 | out.fail(f"account not configured: {bot.account.db_path}") 232 | 233 | bot.start() 234 | bot.account.wait_shutdown() 235 | 236 | 237 | class PluginCmd: 238 | """per account plugins management.""" 239 | 240 | name = "plugin" 241 | db_key = "module-plugins" 242 | 243 | def add_arguments(self, parser) -> None: 244 | parser.add_argument( 245 | "-l", "--list", help="list bot plugins.", action="store_true" 246 | ) 247 | parser.add_argument( 248 | "-a", 249 | "--add", 250 | help="add python module(s) paths to be loaded as bot plugin(s). Note that the filesystem paths to the python modules need to be available when the bot starts up. You can edit the modules after adding them.", 251 | metavar="PYMODULE", 252 | type=str, 253 | nargs="+", 254 | ) 255 | parser.add_argument( 256 | "-d", 257 | "--del", 258 | help="delete python module(s) plugin path from bot plugins.", 259 | metavar="PYMODULE", 260 | dest="_del", 261 | type=str, 262 | nargs="+", 263 | ) 264 | 265 | def run(self, bot, args, out) -> None: 266 | if args.add: 267 | self._add(bot, args.add, out) 268 | elif args._del: 269 | self._del(bot, args._del, out) 270 | else: 271 | for name, plugin in bot.plugins.items(): 272 | out.line(f"{name:25s}: {plugin}") 273 | 274 | def _add(self, bot, pymodules, out) -> None: 275 | existing = list( 276 | x for x in bot.get(self.db_key, default="").split("\n") if x.strip() 277 | ) 278 | for pymodule in pymodules: 279 | assert "," not in pymodule 280 | if not os.path.exists(pymodule): 281 | out.fail(f"{pymodule} does not exist") 282 | path = os.path.abspath(pymodule) 283 | existing.append(path) 284 | 285 | bot.set(self.db_key, "\n".join(existing)) 286 | out.line("new python module plugin list:") 287 | for mod in existing: 288 | out.line(mod) 289 | 290 | def _del(self, bot, pymodules, out) -> None: 291 | existing = list( 292 | x for x in bot.get(self.db_key, default="").split("\n") if x.strip() 293 | ) 294 | remaining = [] 295 | for pymodule in pymodules: 296 | for p in existing: 297 | if not p.endswith(pymodule): 298 | remaining.append(p) 299 | 300 | bot.set(self.db_key, "\n".join(remaining)) 301 | out.line(f"removed {len(existing) - len(remaining)} module(s)") 302 | 303 | 304 | class set_avatar: 305 | """set account's avatar.""" 306 | 307 | def add_arguments(self, parser) -> None: 308 | parser.add_argument( 309 | "avatar", help="path to the avatar image or builtin avatar name." 310 | ) 311 | 312 | def run(self, bot, args, out) -> None: 313 | if not set_builtin_avatar(bot, args.avatar): 314 | bot.account.set_avatar(args.avatar) 315 | out.line("Avatar updated.") 316 | 317 | 318 | class set_name: 319 | """set account's display name.""" 320 | 321 | def add_arguments(self, parser) -> None: 322 | parser.add_argument("name", type=str, help="the new display name") 323 | 324 | def run(self, bot, args) -> None: 325 | bot.account.set_config("displayname", args.name) 326 | 327 | 328 | class set_status: 329 | """set account's status/signature.""" 330 | 331 | def add_arguments(self, parser) -> None: 332 | parser.add_argument("text", type=str, help="the new status") 333 | 334 | def run(self, bot, args) -> None: 335 | bot.account.set_config("selfstatus", args.text) 336 | 337 | 338 | class set_config: 339 | """set low level account configuration or get current value if no new value is given.""" 340 | 341 | def add_arguments(self, parser) -> None: 342 | parser.add_argument("key", type=str, help="configuration key") 343 | parser.add_argument( 344 | "value", type=str, help="configuration new value", nargs="?" 345 | ) 346 | 347 | def run(self, bot, args, out) -> None: 348 | if args.value is not None: 349 | bot.account.set_config(args.key, args.value) 350 | out.line(f"{args.key}={bot.account.get_config(args.key)}") 351 | -------------------------------------------------------------------------------- /src/simplebot/builtin/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | 4 | from ..hookspec import deltabot_hookimpl 5 | 6 | 7 | @deltabot_hookimpl(tryfirst=True) 8 | def deltabot_init(bot) -> None: 9 | db_path = os.path.join(os.path.dirname(bot.account.db_path), "bot.db") 10 | bot.plugins.add_module("db", DBManager(db_path)) 11 | # delete all preferences on init to avoid preferences from deleted plugins 12 | for name, _ in bot.get_preferences(): 13 | bot.delete_preference(name) 14 | 15 | 16 | class DBManager: 17 | def __init__(self, db_path: str) -> None: 18 | self.db = sqlite3.connect( 19 | db_path, check_same_thread=False, isolation_level=None 20 | ) 21 | self.db.row_factory = sqlite3.Row 22 | with self.db: 23 | self.db.execute( 24 | "CREATE TABLE IF NOT EXISTS config" 25 | " (keyname TEXT PRIMARY KEY,value TEXT)" 26 | ) 27 | self.db.execute( 28 | "DROP TABLE IF EXISTS msgs" # migration from version <= 2.4.0 29 | ) 30 | self.db.execute("CREATE TABLE IF NOT EXISTS msgs (msg TEXT PRIMARY KEY)") 31 | 32 | def put_msg(self, msg: str) -> None: 33 | with self.db: 34 | self.db.execute("INSERT INTO msgs VALUES (?)", (msg,)) 35 | 36 | def pop_msg(self, msg: str) -> None: 37 | with self.db: 38 | self.db.execute("DELETE FROM msgs WHERE msg=?", (msg,)) 39 | 40 | def get_msgs(self) -> list: 41 | return [r[0] for r in self.db.execute("SELECT * FROM msgs").fetchall()] 42 | 43 | @deltabot_hookimpl 44 | def deltabot_store_setting(self, key: str, value: str) -> None: 45 | with self.db: 46 | if value is not None: 47 | self.db.execute("REPLACE INTO config VALUES (?,?)", (key, value)) 48 | else: 49 | self.db.execute("DELETE FROM config WHERE keyname=?", (key,)) 50 | 51 | @deltabot_hookimpl 52 | def deltabot_get_setting(self, key: str) -> None: 53 | row = self.db.execute("SELECT * FROM config WHERE keyname=?", (key,)).fetchone() 54 | return row and row["value"] 55 | 56 | @deltabot_hookimpl 57 | def deltabot_list_settings(self) -> list: 58 | rows = self.db.execute("SELECT * FROM config") 59 | return [(row["keyname"], row["value"]) for row in rows] 60 | 61 | @deltabot_hookimpl 62 | def deltabot_shutdown(self, bot) -> None: # noqa 63 | self.db.close() 64 | 65 | 66 | class TestDB: 67 | def test_settings_twice(self, mock_bot): 68 | mock_bot.set("hello", "world") 69 | assert mock_bot.get("hello") == "world" 70 | mock_bot.set("hello", "world") 71 | assert mock_bot.get("hello") == "world" 72 | 73 | def test_settings_scoped(self, mock_bot): 74 | mock_bot.set("hello", "world") 75 | mock_bot.set("hello", "xxx", scope="other") 76 | assert mock_bot.get("hello") == "world" 77 | assert mock_bot.get("hello", scope="other") == "xxx" 78 | 79 | l = mock_bot.list_settings() 80 | assert len(l) == 2 81 | assert l[0][0] == "global/hello" 82 | assert l[0][1] == "world" 83 | assert l[1][0] == "other/hello" 84 | assert l[1][1] == "xxx" 85 | -------------------------------------------------------------------------------- /src/simplebot/builtin/log.py: -------------------------------------------------------------------------------- 1 | import logging.handlers 2 | import os 3 | 4 | from ..hookspec import deltabot_hookimpl 5 | 6 | 7 | @deltabot_hookimpl 8 | def deltabot_init_parser(parser) -> None: 9 | parser.add_generic_option( 10 | "--stdlog", 11 | choices=["info", "debug", "err", "warn"], 12 | default="info", 13 | help="stdout logging level.", 14 | inipath="log:stdlog", 15 | ) 16 | 17 | 18 | @deltabot_hookimpl 19 | def deltabot_get_logger(args) -> logging.Logger: 20 | loglevel = getattr(logging, args.stdlog.upper()) 21 | return make_logger(args.basedir, loglevel) 22 | 23 | 24 | def make_logger(logdir, stdout_loglevel) -> logging.Logger: 25 | logger = logging.Logger("simplebot") 26 | logger.parent = None 27 | formatter = logging.Formatter( 28 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 29 | ) 30 | 31 | chandler = logging.StreamHandler() 32 | chandler.setLevel(stdout_loglevel) 33 | chandler.setFormatter(formatter) 34 | logger.addHandler(chandler) 35 | 36 | log_path = os.path.join(logdir, "bot.log") 37 | fhandler = logging.handlers.RotatingFileHandler( 38 | log_path, backupCount=3, maxBytes=2000000 39 | ) 40 | fhandler.setLevel(stdout_loglevel) 41 | fhandler.setFormatter(formatter) 42 | logger.addHandler(fhandler) 43 | 44 | return logger 45 | 46 | 47 | def test_logger_loglevel(capfd, tmpdir): 48 | logger = make_logger(tmpdir.strpath, stdout_loglevel=logging.INFO) 49 | logger.info("hello") 50 | logger.debug("world") 51 | err = capfd.readouterr()[1] 52 | assert "hello" in err 53 | assert "world" not in err 54 | -------------------------------------------------------------------------------- /src/simplebot/builtin/settings.py: -------------------------------------------------------------------------------- 1 | from ..commands import command_decorator 2 | from ..hookspec import deltabot_hookimpl 3 | 4 | 5 | @deltabot_hookimpl 6 | def deltabot_init_parser(parser) -> None: 7 | parser.add_subcommand(DB) 8 | 9 | 10 | def slash_scoped_key(key: str) -> tuple: 11 | i = key.find("/") 12 | if i == -1: 13 | raise ValueError("key {!r} does not contain a '/' scope delimiter") 14 | return (key[:i], key[i + 1 :]) 15 | 16 | 17 | class DB: 18 | """low level settings.""" 19 | 20 | def add_arguments(self, parser) -> None: 21 | parser.add_argument( 22 | "-l", "--list", help="list all key,values.", metavar="SCOPE", nargs="?" 23 | ) 24 | parser.add_argument( 25 | "-g", 26 | "--get", 27 | help="get a low level setting.", 28 | metavar="KEY", 29 | type=slash_scoped_key, 30 | ) 31 | parser.add_argument( 32 | "-s", 33 | "--set", 34 | help="set a low level setting.", 35 | metavar=("KEY", "VALUE"), 36 | nargs=2, 37 | ) 38 | parser.add_argument( 39 | "-d", 40 | "--del", 41 | help="delete a low level setting.", 42 | metavar="KEY", 43 | type=slash_scoped_key, 44 | dest="_del", 45 | ) 46 | 47 | def run(self, bot, args, out) -> None: 48 | if args.get: 49 | self._get(bot, *args.get, out) 50 | elif args._del: 51 | self._del(bot, *args._del, out) 52 | elif args.set: 53 | self._set(bot, *args.set) 54 | else: 55 | self._list(bot, args.list, out) 56 | 57 | def _get(self, bot, scope, key, out) -> None: 58 | res = bot.get(key, scope=scope) 59 | if res is None: 60 | out.fail(f"key {scope}/{key} does not exist") 61 | else: 62 | out.line(res) 63 | 64 | def _set(self, bot, key, value) -> None: 65 | scope, key = slash_scoped_key(key) 66 | bot.set(key, value, scope=scope) 67 | 68 | def _list(self, bot, scope, out) -> None: 69 | for key, res in bot.list_settings(scope): 70 | if "\n" in res: 71 | out.line(f"{key}:") 72 | for line in res.split("\n"): 73 | out.line(" " + line) 74 | else: 75 | out.line(f"{key}: {res}") 76 | 77 | def _del(self, bot, scope, key, out) -> None: 78 | res = bot.get(key, scope=scope) 79 | if res is None: 80 | out.fail(f"key {scope}/{key} does not exist") 81 | else: 82 | bot.delete(key, scope=scope) 83 | out.line(f"key {scope}/{key} deleted") 84 | 85 | 86 | @command_decorator(name="/set") 87 | def cmd_set(bot, payload, message, replies) -> None: 88 | """show all available settings or set a value for a setting. 89 | 90 | Examples: 91 | 92 | # show all settings 93 | /set 94 | 95 | # unset one setting named "mysetting" 96 | /set_mysetting 97 | 98 | # set one setting named "mysetting" 99 | /set_mysetting value 100 | """ 101 | addr = message.get_sender_contact().addr 102 | if not payload: 103 | text = dump_settings(bot, scope=addr) 104 | else: 105 | args = payload.split(maxsplit=1) 106 | name = args[0] 107 | if bot.get_preference_description(name) is None: 108 | text = "Invalid setting. Available settings:" 109 | for name, desc in bot.get_preferences(): 110 | text += f"\n\n/set_{name}: {desc}" 111 | else: 112 | value = None if len(args) == 1 else args[1] 113 | old = bot.set(name, value and value.strip(), scope=addr) 114 | text = f"old: {name}={old!r}\nnew: {name}={value!r}" 115 | replies.add(text=text) 116 | 117 | 118 | def dump_settings(bot, scope) -> str: 119 | lines = [] 120 | for name, desc in bot.get_preferences(): 121 | value = bot.get(name, scope=scope) 122 | lines.append(f"/set_{name} = {value}\nDescription: {desc}\n") 123 | if not lines: 124 | return "no settings" 125 | return "\n".join(lines) 126 | 127 | 128 | class TestCommandSettings: 129 | def test_mock_get_set_empty_settings(self, mocker): 130 | reply_msg = mocker.get_one_reply("/set") 131 | assert reply_msg.text.startswith("no settings") 132 | 133 | def test_mock_set_works(self, mocker): 134 | reply_msg = mocker.get_one_reply("/set hello world") 135 | assert "old" in reply_msg.text 136 | msg_reply = mocker.get_one_reply("/set") 137 | assert msg_reply.text == "hello=world" 138 | 139 | def test_get_set_functional(self, bot_tester): 140 | msg_reply = bot_tester.send_command("/set hello world") 141 | msg_reply = bot_tester.send_command("/set hello2 world2") 142 | msg_reply = bot_tester.send_command("/set") 143 | assert "hello=world" in msg_reply.text 144 | assert "hello2=world2" in msg_reply.text 145 | msg_reply = bot_tester.send_command("/set hello") 146 | assert "hello=None" in msg_reply.text 147 | -------------------------------------------------------------------------------- /src/simplebot/commands.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | from collections import OrderedDict 4 | from typing import Callable, Dict, Generator, Optional, Set 5 | 6 | from .hookspec import deltabot_hookimpl 7 | 8 | CMD_PREFIX = "/" 9 | _cmds: Set[tuple] = set() 10 | 11 | 12 | class NotFound(LookupError): 13 | """Command was not found.""" 14 | 15 | 16 | class Commands: 17 | def __init__(self, bot) -> None: 18 | self.logger = bot.logger 19 | self._cmd_defs: Dict[str, CommandDef] = OrderedDict() 20 | bot.plugins.add_module("commands", self) 21 | 22 | def register( 23 | self, 24 | func: Callable, 25 | name: str = None, 26 | help: str = None, # noqa 27 | admin: bool = False, 28 | hidden: bool = False, 29 | ) -> None: 30 | """register a command function that acts on each incoming non-system message. 31 | 32 | :param func: function can accept 'bot', 'command'(:class:`simplebot.commands.IncomingCommand`), 'message', 'payload' and 'replies'(:class:`simplebot.bot.Replies`) arguments. 33 | :param name: name of the command, example "/test", if not provided it is autogenerated from function name. 34 | :param help: command help, it will be extracted from the function docstring if not provided. 35 | :param admin: if True the command will be available for bot administrators only. 36 | """ 37 | name = name or CMD_PREFIX + func.__name__ 38 | if help is None: 39 | help = func.__doc__ 40 | short, long, args = parse_command_docstring( 41 | func, help, args=["command", "replies", "bot", "payload", "args", "message"] 42 | ) 43 | for cand_name in iter_underscore_subparts(name.lower()): 44 | if cand_name in self._cmd_defs: 45 | raise ValueError( 46 | f"command {name!r} fails to register, conflicts with: {cand_name!r}" 47 | ) 48 | for reg_name in self._cmd_defs: 49 | if reg_name.startswith(name.lower() + "_"): 50 | raise ValueError( 51 | f"command {name!r} fails to register, conflicts with: {reg_name!r}" 52 | ) 53 | 54 | cmd_def = CommandDef( 55 | name, 56 | short=short, 57 | long=long, 58 | func=func, 59 | args=args, 60 | admin=admin, 61 | hidden=hidden, 62 | ) 63 | self._cmd_defs[name.lower()] = cmd_def 64 | self.logger.debug(f"registered new command {name!r}") 65 | 66 | def unregister(self, name: str) -> Callable: 67 | """unregister a command function by name.""" 68 | return self._cmd_defs.pop(name.lower()) 69 | 70 | def dict(self) -> dict: 71 | return self._cmd_defs.copy() 72 | 73 | @deltabot_hookimpl 74 | def deltabot_incoming_message(self, bot, message, replies) -> Optional[bool]: 75 | if not message.text.startswith(CMD_PREFIX): 76 | return None 77 | args = message.text.split() 78 | payload = message.text.split(maxsplit=1)[1] if len(args) > 1 else "" 79 | orig_cmd_name = args.pop(0) 80 | 81 | if "@" in orig_cmd_name: 82 | suffix = "@" + bot.self_contact.addr 83 | if orig_cmd_name.endswith(suffix): 84 | orig_cmd_name = orig_cmd_name[: -len(suffix)] 85 | else: 86 | return True 87 | 88 | parts = orig_cmd_name.split("_") 89 | while parts: 90 | cmd_def = self._cmd_defs.get("_".join(parts).lower()) 91 | if cmd_def is not None: 92 | break 93 | newarg = parts.pop() 94 | args.insert(0, newarg) 95 | payload = (newarg + " " + payload).rstrip() 96 | 97 | if not cmd_def or ( 98 | cmd_def.admin and not bot.is_admin(message.get_sender_contact().addr) 99 | ): 100 | reply = f"unknown command {orig_cmd_name!r}" 101 | self.logger.warn(reply) 102 | if not message.chat.is_multiuser(): 103 | replies.add(text=reply) 104 | return True 105 | 106 | cmd = IncomingCommand( 107 | bot=bot, cmd_def=cmd_def, message=message, args=args, payload=payload 108 | ) 109 | bot.logger.debug(f"processing command {cmd}") 110 | try: 111 | res = cmd.cmd_def( 112 | command=cmd, 113 | replies=replies, 114 | bot=bot, 115 | payload=cmd.payload, 116 | args=cmd.args, 117 | message=cmd.message, 118 | ) 119 | except Exception as ex: 120 | self.logger.exception(ex) 121 | else: 122 | assert res is None, res 123 | return True 124 | 125 | 126 | class CommandDef: 127 | """Definition of a '/COMMAND' with args.""" 128 | 129 | def __init__( 130 | self, 131 | cmd: str, 132 | short: str, 133 | long: str, 134 | func: Callable, 135 | args: list, 136 | admin=False, 137 | hidden=False, 138 | ) -> None: 139 | if cmd[0] != CMD_PREFIX: 140 | raise ValueError(f"cmd {cmd!r} must start with {CMD_PREFIX!r}") 141 | self.cmd = cmd 142 | self.long = long 143 | self.short = short 144 | self.func = func 145 | self.args = args 146 | self.admin = admin 147 | self.hidden = hidden 148 | 149 | def __eq__(self, c) -> bool: 150 | return c.__dict__ == self.__dict__ 151 | 152 | def __call__(self, **kwargs): 153 | for key in list(kwargs.keys()): 154 | if key not in self.args: 155 | del kwargs[key] 156 | return self.func(**kwargs) 157 | 158 | 159 | class IncomingCommand: 160 | """incoming command request.""" 161 | 162 | def __init__(self, bot, cmd_def, args, payload, message) -> None: 163 | self.bot = bot 164 | self.cmd_def = cmd_def 165 | self.args = args 166 | self.payload = payload 167 | self.message = message 168 | 169 | def __repr__(self) -> str: 170 | return f"" 171 | 172 | 173 | def parse_command_docstring(func: Callable, description: str, args: list) -> tuple: 174 | if not description: 175 | raise ValueError(f"{func!r} needs to have a docstring") 176 | funcargs = set(inspect.getargs(func.__code__).args) 177 | if isinstance(func, types.MethodType): 178 | funcargs.discard("self") 179 | for arg in funcargs: 180 | if arg not in args: 181 | raise ValueError( 182 | f"{func!r} requests an invalid argument: {arg!r}, valid arguments: {args!r}" 183 | ) 184 | 185 | lines = description.strip().split("\n", maxsplit=1) 186 | return lines.pop(0), "".join(lines).strip(), funcargs 187 | 188 | 189 | def iter_underscore_subparts(name) -> Generator[str, None, None]: 190 | parts = name.split("_") 191 | while parts: 192 | yield "_".join(parts) 193 | parts.pop() 194 | 195 | 196 | def command_decorator(func: Callable = None, **kwargs) -> Callable: 197 | """Register decorated function as bot command. 198 | 199 | Check documentation of method `simplebot.commands.Commands.register` to 200 | see all parameters the decorated function can accept. 201 | """ 202 | 203 | def _decorator(func) -> Callable: 204 | kwargs["func"] = func 205 | _cmds.add(tuple(sorted(kwargs.items()))) 206 | return func 207 | 208 | if func is None: 209 | return _decorator 210 | return _decorator(func) 211 | -------------------------------------------------------------------------------- /src/simplebot/filters.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from typing import Callable, Dict, Set 3 | 4 | from .commands import parse_command_docstring 5 | from .hookspec import deltabot_hookimpl 6 | 7 | _filters: Set[tuple] = set() 8 | 9 | 10 | class Filters: 11 | def __init__(self, bot) -> None: 12 | self.logger = bot.logger 13 | self._filter_defs: Dict[str, FilterDef] = OrderedDict() 14 | bot.plugins.add_module("filters", self) 15 | 16 | def register( 17 | self, 18 | func: Callable, 19 | name: str = None, 20 | help: str = None, # noqa 21 | tryfirst: bool = False, 22 | trylast: bool = False, 23 | admin: bool = False, 24 | hidden: bool = False, 25 | ) -> None: 26 | """register a filter function that acts on each incoming non-system message. 27 | :param func: function can accept 'bot', 'message' and 'replies' arguments. 28 | :param name: name of the filter, if not provided it is autogenerated from function name. 29 | :param help: filter's description, it will be extracted from the function docstring if not provided. 30 | :param tryfirst: Set to True if the filter should be executed as 31 | soon as possible. 32 | :param trylast: Set to True if the filter should be executed as 33 | late as possible. 34 | :param admin: if True the filter will activate for bot administrators only. 35 | """ 36 | name = name or f"{func.__module__}.{func.__name__}" 37 | if help is None: 38 | help = func.__doc__ 39 | short, long, args = parse_command_docstring( 40 | func, help, args=["message", "replies", "bot"] 41 | ) 42 | prio = 0 - tryfirst + trylast 43 | filter_def = FilterDef( 44 | name, 45 | short=short, 46 | long=long, 47 | func=func, 48 | args=args, 49 | priority=prio, 50 | admin=admin, 51 | hidden=hidden, 52 | ) 53 | if name in self._filter_defs: 54 | raise ValueError(f"filter {name!r} already registered") 55 | self._filter_defs[name] = filter_def 56 | self.logger.debug(f"registered new filter {name!r}") 57 | 58 | def unregister(self, name: str) -> Callable: 59 | """unregister a filter function.""" 60 | return self._filter_defs.pop(name) 61 | 62 | def dict(self) -> dict: 63 | return self._filter_defs.copy() 64 | 65 | @deltabot_hookimpl(trylast=True) 66 | def deltabot_incoming_message(self, bot, message, replies) -> None: 67 | is_admin = bot.is_admin(message.get_sender_contact().addr) 68 | for name, filter_def in sorted( 69 | self._filter_defs.items(), key=lambda e: e[1].priority 70 | ): 71 | if filter_def.admin and not is_admin: 72 | continue 73 | self.logger.debug(f"calling filter {name!r} on message id={message.id}") 74 | if filter_def(message=message, replies=replies, bot=bot): 75 | return 76 | 77 | 78 | class FilterDef: 79 | """Definition of a Filter that acts on incoming messages.""" 80 | 81 | def __init__(self, name, short, long, func, args, priority, admin, hidden) -> None: 82 | self.name = name 83 | self.short = short 84 | self.long = long 85 | self.func = func 86 | self.args = args 87 | self.priority = priority 88 | self.admin = admin 89 | self.hidden = hidden 90 | 91 | def __eq__(self, c) -> bool: 92 | return c.__dict__ == self.__dict__ 93 | 94 | def __call__(self, **kwargs): 95 | for key in list(kwargs.keys()): 96 | if key not in self.args: 97 | del kwargs[key] 98 | return self.func(**kwargs) 99 | 100 | 101 | def filter_decorator(func: Callable = None, **kwargs) -> Callable: 102 | """Register decorated function as bot filter. 103 | 104 | Check documentation of method `simplebot.filters.Filters.register` to 105 | see all parameters the decorated function can accept. 106 | """ 107 | 108 | def _decorator(func) -> Callable: 109 | kwargs["func"] = func 110 | _filters.add(tuple(sorted(kwargs.items()))) 111 | return func 112 | 113 | if func is None: 114 | return _decorator 115 | return _decorator(func) 116 | -------------------------------------------------------------------------------- /src/simplebot/hookspec.py: -------------------------------------------------------------------------------- 1 | import pluggy # type: ignore 2 | 3 | SPEC_NAME = "deltabot" 4 | deltabot_hookspec = pluggy.HookspecMarker(SPEC_NAME) 5 | deltabot_hookimpl = pluggy.HookimplMarker(SPEC_NAME) 6 | 7 | 8 | class DeltaBotSpecs: 9 | """per DeltaBot instance hook specifications.""" 10 | 11 | @deltabot_hookspec 12 | def deltabot_init_parser(self, parser): 13 | """initialize the deltabot main parser with new options and subcommands. 14 | 15 | :param parser: a :class:`simplebot.parser.MyArgumentParser` instance where you can 16 | call `add_subcommand(name, func)` to get a sub parser where you 17 | can then add arguments. 18 | """ 19 | 20 | @deltabot_hookspec(firstresult=True) 21 | def deltabot_get_logger(self, args): 22 | """get a logger based on the parsed command line args. 23 | 24 | The returned logger needs to offer info/debug/warn/error methods. 25 | """ 26 | 27 | @deltabot_hookspec(historic=True) 28 | def deltabot_init(self, bot, args): 29 | """init a bot -- called before the bot starts serving requests. 30 | 31 | Note that plugins that register after DeltaBot initizialition 32 | will see their hookimpl get called during plugin registration. 33 | This allows "late" plugins to still register commands and filters. 34 | """ 35 | 36 | @deltabot_hookspec() 37 | def deltabot_start(self, bot): 38 | """called when a bot starts listening to incoming messages and performing 39 | outstanding processing of fresh messages. 40 | """ 41 | 42 | @deltabot_hookspec 43 | def deltabot_shutdown(self, bot): 44 | """shutdown all resources of the bot.""" 45 | 46 | @deltabot_hookspec(firstresult=True) 47 | def deltabot_incoming_message(self, message, bot, replies): 48 | """process an incoming fresh non-automated message. 49 | 50 | :param message: The incoming message. 51 | :param bot: The bot that triggered the event. 52 | :param replies: call replies.add() to schedule a reply. 53 | """ 54 | 55 | @deltabot_hookspec(firstresult=True) 56 | def deltabot_incoming_bot_message(self, message, bot, replies): 57 | """process an incoming fresh automated message. 58 | 59 | :param message: The incoming message. 60 | :param bot: The bot that triggered the event. 61 | :param replies: call replies.add() to schedule a reply. 62 | """ 63 | 64 | @deltabot_hookspec(firstresult=True) 65 | def deltabot_member_added(self, chat, contact, actor, message, replies, bot): 66 | """When a member has been added by an actor. 67 | 68 | :param chat: Chat where contact was added. 69 | :param contact: Contact that was added. 70 | :param actor: Who added the contact (None if it was our self-addr) 71 | :param message: The original system message that reports the addition. 72 | :param replies: Can be used to register replies without directly trying to send out. 73 | :param bot: The bot that triggered the event. 74 | """ 75 | 76 | @deltabot_hookspec(firstresult=True) 77 | def deltabot_member_removed(self, chat, contact, actor, message, replies, bot): 78 | """When a member has been removed by an actor. 79 | 80 | When a member left a chat, the contact and the actor will be the 81 | same Contact object. 82 | 83 | :param chat: Chat where contact was removed. 84 | :param contact: Contact that was removed. 85 | :param actor: Who removed the contact (None if it was our self-addr) 86 | :param message: The original system message that reports the removal. 87 | :param replies: Can be used to register replies without directly trying to send out. 88 | :param bot: The bot that triggered the event. 89 | """ 90 | 91 | @deltabot_hookspec 92 | def deltabot_title_changed(self, chat, old, actor, message, replies, bot): 93 | """When the group title has been modified by an actor. 94 | 95 | :param chat: Chat where title was changed. 96 | :param old: The title that has been changed. 97 | :param actor: Contact that changed the title (None if it was our self-addr) 98 | :param message: The original system message that reports the change. 99 | :param replies: Can be used to register replies without directly trying to send out. 100 | :param bot: The bot that triggered the event. 101 | """ 102 | 103 | @deltabot_hookspec 104 | def deltabot_image_changed(self, chat, deleted, actor, message, replies, bot): 105 | """When the group image has been modified by an actor. 106 | 107 | :param chat: Chat where the image was changed. 108 | :param deleted: True if the image was deleted instead of replaced. 109 | :param actor: Contact that changed the image (None if it was our self-addr) 110 | :param message: The original system message that reports the change. 111 | :param replies: Can be used to register replies without directly trying to send out. 112 | :param bot: The bot that triggered the event. 113 | """ 114 | 115 | @deltabot_hookspec(firstresult=True) 116 | def deltabot_store_setting(self, key, value): 117 | """store a named bot setting persistently.""" 118 | 119 | @deltabot_hookspec(firstresult=True) 120 | def deltabot_get_setting(self, key): 121 | """get a named persistent bot setting.""" 122 | 123 | @deltabot_hookspec(firstresult=True) 124 | def deltabot_list_settings(self): 125 | """get a list of persistent (key, value) tuples.""" 126 | 127 | @deltabot_hookspec 128 | def deltabot_ban(self, bot, contact): 129 | """When a contact have been banned.""" 130 | 131 | @deltabot_hookspec 132 | def deltabot_unban(self, bot, contact): 133 | """When a contact have been unbanned.""" 134 | -------------------------------------------------------------------------------- /src/simplebot/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Optional 4 | 5 | from deltachat import Account 6 | 7 | from .bot import DeltaBot 8 | from .parser import MyArgumentParser, get_base_parser 9 | from .plugins import get_global_plugin_manager 10 | 11 | 12 | def main(argv=None) -> None: 13 | """delta.chat bot management command line interface.""" 14 | pm = get_global_plugin_manager() 15 | if argv is None: 16 | argv = sys.argv 17 | try: 18 | parser = get_base_parser(plugin_manager=pm, argv=argv) 19 | args = parser.main_parse_argv(argv) 20 | except MyArgumentParser.ArgumentError as ex: 21 | print(str(ex), file=sys.stderr) 22 | sys.exit(1) 23 | bot = make_bot_from_args(args, plugin_manager=pm) 24 | parser.main_run(bot=bot, args=args) 25 | 26 | 27 | def make_bot_from_args(args, plugin_manager, account=None) -> Optional[DeltaBot]: 28 | if not args.basedir: 29 | return None 30 | if not os.path.exists(args.basedir): 31 | os.makedirs(args.basedir) 32 | 33 | logger = plugin_manager.hook.deltabot_get_logger(args=args) 34 | if account is None: 35 | db_path = os.path.join(args.basedir, "account.db") 36 | account = Account(db_path) 37 | 38 | return DeltaBot(account, logger, plugin_manager=plugin_manager, args=args) 39 | -------------------------------------------------------------------------------- /src/simplebot/parser.py: -------------------------------------------------------------------------------- 1 | # PYTHON_ARGCOMPLETE_OK 2 | 3 | import argparse 4 | import inspect 5 | import os 6 | from typing import Any 7 | 8 | import py 9 | 10 | from .utils import get_account_path, get_default_account 11 | 12 | main_description = """ 13 | The simplebot command line offers sub commands for initialization, configuration 14 | and web-serving of Delta Chat Bots. New sub commands may be added via plugins. 15 | """ 16 | 17 | 18 | class MyArgumentParser(argparse.ArgumentParser): 19 | class ArgumentError(Exception): 20 | """an error from the argparse subsystem.""" 21 | 22 | def __init__(self, *args, **kwargs) -> None: 23 | super().__init__(*args, **kwargs) 24 | self.basedir: str 25 | self.subparsers: Any 26 | self.generic_options: Any 27 | self.plugin_manager: Any 28 | self.out: Any 29 | 30 | def error(self, message) -> None: # noqa 31 | """raise errors instead of printing and raising SystemExit""" 32 | raise self.ArgumentError(message) 33 | 34 | def add_generic_option(self, *flags, **kwargs) -> None: 35 | """add a generic argument option.""" 36 | if not hasattr(self, "subparsers"): 37 | raise ValueError("can not add generic option to sub command") 38 | if not (flags and flags[0].startswith("-")): 39 | raise ValueError("can not generically add positional args") 40 | inipath = kwargs.pop("inipath", None) 41 | action = self.generic_options.add_argument(*flags, **kwargs) 42 | action.inipath = inipath # noqa 43 | 44 | def add_subcommand(self, cls) -> None: 45 | """Add a subcommand to simplebot.""" 46 | if not hasattr(self, "subparsers"): 47 | raise ValueError("can not add sub command to subcommand") 48 | doc, description = parse_docstring(cls.__doc__) 49 | name = getattr(cls, "name", None) 50 | if name is None: 51 | name = cls.__name__.lower() 52 | subparser = self.subparsers.add_parser( 53 | name=name, description=description, help=doc 54 | ) 55 | subparser.Action = argparse.Action 56 | 57 | inst = cls() 58 | meth = getattr(inst, "add_arguments", None) 59 | if meth is not None: 60 | meth(parser=subparser) 61 | subparser.set_defaults(subcommand_instance=inst) 62 | 63 | def _merge_ini(self) -> None: 64 | if not self.basedir: 65 | return 66 | p = os.path.join(self.basedir, "bot.ini") 67 | if os.path.exists(p): 68 | cfg = py.iniconfig.IniConfig(p) 69 | for action in self._actions: 70 | if getattr(action, "inipath", None): 71 | section, key = action.inipath.split(":") # noqa 72 | default: Any = cfg.get(section, key) 73 | if default: 74 | action.default = default 75 | 76 | def main_parse_argv(self, argv) -> argparse.Namespace: 77 | try_argcomplete(self) 78 | self._merge_ini() 79 | try: 80 | args = self.parse_args(argv[1:]) 81 | args.basedir = self.basedir 82 | return args 83 | except self.ArgumentError as e: 84 | if not argv[1:]: 85 | return self.parse_args(["-h"]) 86 | self.print_usage() 87 | self.exit(2, f"{self.prog}: error: {e.args[0]}\n") 88 | return None # unreachable 89 | 90 | def main_run(self, bot, args) -> None: 91 | try: 92 | if args.command is None: 93 | self.out.line(self.format_usage()) 94 | if self.description: 95 | self.out.line(self.description.strip()) 96 | self.out.line() 97 | for name, p in self.subparsers.choices.items(): 98 | desc = p.description.split("\n")[0].strip() 99 | self.out.line(f"{name:20s} {desc}") 100 | self.out.line() 101 | self.out.ok_finish("please specify a subcommand", red=True) 102 | 103 | funcargs = set(inspect.getargs(args.subcommand_instance.run.__code__).args) 104 | if not bot and "bot" in funcargs: 105 | msg = f'No default account is set so "--account" argument is required to use "{args.command}" subcommand.' 106 | self.out.fail(msg) 107 | kwargs = dict(bot=bot, args=args, out=self.out) 108 | for key in list(kwargs.keys()): 109 | if key not in funcargs: 110 | del kwargs[key] 111 | res = args.subcommand_instance.run(**kwargs) 112 | except ValueError as ex: 113 | res = str(ex) 114 | if res: 115 | self.out.fail(str(res)) 116 | 117 | 118 | class CmdlineOutput: 119 | def __init__(self) -> None: 120 | self.tw = py.io.TerminalWriter() 121 | 122 | def line(self, message="", **kwargs) -> None: 123 | self.tw.line(message, **kwargs) 124 | 125 | def fail(self, message) -> None: 126 | self.tw.line(f"FAIL: {message}", red=True) 127 | raise SystemExit(1) 128 | 129 | def ok_finish(self, message, **kwargs) -> None: 130 | self.line(message, **kwargs) 131 | raise SystemExit(0) 132 | 133 | 134 | def try_argcomplete(parser) -> None: 135 | if os.environ.get("_ARGCOMPLETE"): 136 | try: 137 | import argcomplete 138 | except ImportError: 139 | pass 140 | else: 141 | argcomplete.autocomplete(parser) 142 | 143 | 144 | def get_base_parser(plugin_manager, argv) -> MyArgumentParser: 145 | parser = MyArgumentParser(prog="simplebot", description=main_description) 146 | parser.plugin_manager = plugin_manager 147 | parser.subparsers = parser.add_subparsers(dest="command") 148 | parser.generic_options = parser.add_argument_group("generic options") 149 | parser.out = CmdlineOutput() 150 | plugin_manager.hook.deltabot_init_parser(parser=parser) 151 | 152 | # preliminary get the basedir 153 | args = parser.parse_known_args(argv[1:])[0] 154 | if not args.basedir: 155 | if args.command == "init": 156 | args.basedir = get_account_path(args.emailaddr) 157 | else: 158 | addr = get_default_account() 159 | args.basedir = addr and get_account_path(addr) 160 | if args.basedir and not os.path.exists(args.basedir): 161 | args.basedir = None 162 | parser.basedir = args.basedir 163 | 164 | return parser 165 | 166 | 167 | def parse_docstring(txt) -> tuple: 168 | description = txt 169 | i = txt.find(".") 170 | if i == -1: 171 | doc = txt 172 | else: 173 | doc = txt[: i + 1] 174 | return doc, description 175 | -------------------------------------------------------------------------------- /src/simplebot/plugins.py: -------------------------------------------------------------------------------- 1 | import pluggy # type: ignore 2 | 3 | from .hookspec import SPEC_NAME, DeltaBotSpecs 4 | 5 | 6 | class Plugins: 7 | def __init__(self, logger, plugin_manager) -> None: 8 | assert plugin_manager 9 | self._pm = plugin_manager 10 | self.logger = logger 11 | self.hook = self._pm.hook 12 | 13 | def add_module(self, name, module) -> None: 14 | """add a named simplebot plugin python module.""" 15 | self.logger.debug(f"registering plugin {name!r}") 16 | self._pm.register(plugin=module, name=name) 17 | self._pm.check_pending() 18 | 19 | def remove(self, name) -> None: 20 | """remove a named simplebot plugin.""" 21 | self.logger.debug(f"removing plugin {name!r}") 22 | self._pm.unregister(name=name) 23 | 24 | def dict(self) -> dict: 25 | """return a dict name->simplebot plugin object mapping.""" 26 | return dict(self._pm.list_name_plugin()) 27 | 28 | def items(self) -> list: 29 | """return (name, plugin obj) list.""" 30 | return self._pm.list_name_plugin() 31 | 32 | 33 | _pm = None 34 | 35 | 36 | def get_global_plugin_manager(): 37 | global _pm 38 | if _pm is None: 39 | _pm = make_plugin_manager() 40 | return _pm 41 | 42 | 43 | def make_plugin_manager(): 44 | from .builtin import admin, cmdline, db, log, settings 45 | 46 | pm = pluggy.PluginManager(SPEC_NAME) 47 | pm.add_hookspecs(DeltaBotSpecs) 48 | 49 | # register builtin modules 50 | pm.register(plugin=admin, name=".builtin.admin") 51 | pm.register(plugin=settings, name=".builtin.settings") 52 | pm.register(plugin=db, name=".builtin.db") 53 | pm.register(plugin=cmdline, name=".builtin.cmdline") 54 | pm.register(plugin=log, name=".builtin.log") 55 | pm.check_pending() 56 | # register setuptools modules 57 | pm.load_setuptools_entrypoints("simplebot.plugins") 58 | return pm 59 | -------------------------------------------------------------------------------- /src/simplebot/pytestplugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from email.utils import parseaddr 4 | from queue import Queue 5 | from typing import Union 6 | 7 | import py 8 | import pytest 9 | from _pytest.pytester import LineMatcher 10 | from deltachat import account_hookimpl 11 | from deltachat.chat import Chat 12 | from deltachat.message import Message 13 | 14 | from .bot import Replies 15 | from .main import make_bot_from_args 16 | from .parser import get_base_parser 17 | from .plugins import make_plugin_manager 18 | 19 | 20 | @pytest.fixture 21 | def mock_stopped_bot(acfactory, request): 22 | account = acfactory.get_pseudo_configured_account() 23 | return make_bot(request, account, request.module, False) 24 | 25 | 26 | @pytest.fixture 27 | def mock_bot(acfactory, request): 28 | account = acfactory.get_pseudo_configured_account() 29 | return make_bot(request, account, request.module) 30 | 31 | 32 | def make_bot(request, account, plugin_module, started=True): 33 | basedir = os.path.dirname(account.db_path) 34 | 35 | # we use a new plugin manager for each test 36 | pm = make_plugin_manager() 37 | 38 | argv = ["simplebot", "--stdlog=debug", "--account", basedir] 39 | 40 | # initialize command line 41 | parser = get_base_parser(pm, argv) 42 | args = parser.main_parse_argv(argv) 43 | 44 | bot = make_bot_from_args(args=args, plugin_manager=pm, account=account) 45 | 46 | # we auto-register the (non-builtin) module 47 | # which contains the test which requested this bot 48 | if not plugin_module.__name__.startswith("simplebot.builtin."): 49 | # don't re-register already registered setuptools plugins 50 | if not pm.is_registered(plugin_module): 51 | bot.plugins.add_module(plugin_module.__name__, plugin_module) 52 | 53 | # startup bot 54 | request.addfinalizer(bot.trigger_shutdown) 55 | if started: 56 | bot.start() 57 | return bot 58 | 59 | 60 | @pytest.fixture 61 | def mocker(mock_bot): 62 | class Mocker: 63 | def __init__(self) -> None: 64 | self.bot = mock_bot 65 | self.account = mock_bot.account 66 | 67 | def make_incoming_message( 68 | self, 69 | text: str = None, 70 | html: str = None, 71 | filename: str = None, 72 | viewtype: str = None, 73 | group: Union[str, Chat] = None, 74 | impersonate: str = None, 75 | addr: str = "Alice ", 76 | quote: Message = None, 77 | ) -> Message: 78 | if filename and not os.path.exists(filename): 79 | filename = os.path.join(self.bot.account.get_blobdir(), filename) 80 | with open(filename, "wb"): 81 | pass 82 | 83 | msg = Replies(self.bot, self.bot.logger)._create_message( 84 | text=text, 85 | html=html, 86 | viewtype=viewtype, 87 | filename=filename, 88 | quote=quote, 89 | sender=impersonate, 90 | ) 91 | 92 | name, routeable_addr = parseaddr(addr) 93 | contact = self.account.create_contact(routeable_addr, name=name) 94 | if isinstance(group, Chat): 95 | chat = group 96 | elif isinstance(group, str): 97 | chat = self.account.create_group_chat(group, contacts=[contact]) 98 | else: 99 | chat = self.account.create_chat(contact) 100 | msg_in = chat.prepare_message(msg) 101 | 102 | class MsgWrapper: 103 | def __init__(self, msg, quote, contact): 104 | self.msg = msg 105 | self.quote = quote 106 | self.get_sender_contact = lambda: contact 107 | 108 | def __getattr__(self, name): 109 | return self.msg.__getattribute__(name) 110 | 111 | def __setattr__(self, name, value): 112 | if name in ("quote", "msg", "error"): 113 | super().__setattr__(name, value) 114 | else: 115 | setattr(self.msg, name, value) 116 | 117 | return MsgWrapper(msg_in, quote, contact) 118 | 119 | def get_one_reply( 120 | self, 121 | text: str = None, 122 | html: str = None, 123 | filename: str = None, 124 | viewtype: str = None, 125 | group: Union[str, Chat] = None, 126 | impersonate: str = None, 127 | addr: str = "Alice ", 128 | quote: Message = None, 129 | filters: str = None, 130 | msg: Message = None, 131 | ) -> Message: 132 | l = self.get_replies( 133 | text=text, 134 | html=html, 135 | filename=filename, 136 | viewtype=viewtype, 137 | group=group, 138 | impersonate=impersonate, 139 | addr=addr, 140 | quote=quote, 141 | filters=filters, 142 | msg=msg, 143 | ) 144 | if not l: 145 | raise ValueError(f"no reply for message {text!r}") 146 | if len(l) > 1: 147 | raise ValueError(f"more than one reply for {text!r}, replies={l}") 148 | return l[0] 149 | 150 | def get_replies( 151 | self, 152 | text: str = None, 153 | html: str = None, 154 | filename: str = None, 155 | viewtype: str = None, 156 | group: Union[str, Chat] = None, 157 | impersonate: str = None, 158 | addr: str = "Alice ", 159 | quote: Message = None, 160 | filters: str = None, 161 | msg: Message = None, 162 | ) -> list: 163 | if filters: 164 | regex = re.compile(filters) 165 | for name in list(self.bot.filters._filter_defs.keys()): 166 | if not regex.match(name): 167 | del self.bot.filters._filter_defs[name] 168 | if not msg: 169 | msg = self.make_incoming_message( 170 | text=text, 171 | html=html, 172 | filename=filename, 173 | viewtype=viewtype, 174 | group=group, 175 | impersonate=impersonate, 176 | addr=addr, 177 | quote=quote, 178 | ) 179 | replies = Replies(msg, self.bot.logger) 180 | self.bot.plugins.hook.deltabot_incoming_message( 181 | message=msg, replies=replies, bot=self.bot 182 | ) 183 | return replies.send_reply_messages() 184 | 185 | return Mocker() 186 | 187 | 188 | @pytest.fixture 189 | def bot_tester(acfactory, request): 190 | ac1, ac2 = acfactory.get_online_accounts(2) 191 | bot = make_bot(request, ac2, request.module) 192 | return BotTester(ac1, bot) 193 | 194 | 195 | class BotTester: 196 | def __init__(self, send_account, bot): 197 | self.send_account = send_account 198 | self.send_account.set_config("displayname", "bot-tester") 199 | self.own_addr = self.send_account.get_config("addr") 200 | self.own_displayname = self.send_account.get_config("displayname") 201 | 202 | self.send_account.add_account_plugin(self) 203 | self.bot = bot 204 | bot_addr = bot.account.get_config("addr") 205 | self.bot_contact = self.send_account.create_contact(bot_addr) 206 | self.bot_chat = self.send_account.create_chat(self.bot_contact) 207 | self._replies = Queue() 208 | 209 | @account_hookimpl 210 | def ac_incoming_message(self, message): 211 | message.get_sender_contact().create_chat() 212 | print(f"queuing ac_incoming message {message}") 213 | self._replies.put(message) 214 | 215 | def send_command(self, text): 216 | self.bot_chat.send_text(text) 217 | return self.get_next_incoming() 218 | 219 | def get_next_incoming(self): 220 | reply = self._replies.get(timeout=30) 221 | print(f"get_next_incoming got reply text: {reply.text}") 222 | return reply 223 | 224 | 225 | @pytest.fixture 226 | def plugin_manager(): 227 | return make_plugin_manager() 228 | 229 | 230 | @pytest.fixture 231 | def examples(request): 232 | p = request.fspath.dirpath().dirpath().join("examples") 233 | if not p.exists(): 234 | pytest.skip(f"could not locate examples dir at {p}") 235 | return p 236 | 237 | 238 | class CmdlineRunner: 239 | def __init__(self): 240 | self._rootargs = ["simplebot"] 241 | 242 | def set_basedir(self, account_dir): 243 | self._rootargs.append(f"--account={account_dir}") 244 | 245 | def invoke(self, args): 246 | # create a new plugin manager for each command line invocation 247 | cap = py.io.StdCaptureFD(mixed=True) 248 | pm = make_plugin_manager() 249 | parser = get_base_parser(pm, argv=self._rootargs) 250 | argv = self._rootargs + args 251 | code, message = 0, None 252 | try: 253 | try: 254 | args = parser.main_parse_argv(argv) 255 | bot = make_bot_from_args(args=args, plugin_manager=pm) 256 | parser.main_run(bot=bot, args=args) 257 | code = 0 258 | except SystemExit as ex: 259 | code = ex.code 260 | message = str(ex) 261 | # pass through unexpected exceptions 262 | # except Exception as ex: 263 | # code = 127 264 | # message = str(ex) 265 | finally: 266 | output, _ = cap.reset() 267 | return InvocationResult(code, message, output) 268 | 269 | def run_ok(self, args, fnl=None): 270 | __tracebackhide__ = True 271 | res = self.invoke(args) 272 | if res.exit_code != 0: 273 | print(res.output) 274 | raise Exception(f"cmd exited with {res.exit_code}: {args}") 275 | return _perform_match(res.output, fnl) 276 | 277 | def run_fail(self, args, fnl=None, code=None): 278 | __tracebackhide__ = True 279 | res = self.invoke(args) 280 | if res.exit_code == 0 or (code is not None and res.exit_code != code): 281 | print(res.output) 282 | raise Exception( 283 | f"got exit code {res.exit_code!r}, expected {code!r}, output: {res.output}" 284 | ) 285 | return _perform_match(res.output, fnl) 286 | 287 | 288 | class InvocationResult: 289 | def __init__(self, code, message, output): 290 | self.exit_code = code 291 | self.message = message 292 | self.output = output 293 | 294 | 295 | def _perform_match(output, fnl): 296 | __tracebackhide__ = True 297 | if fnl: 298 | lm = LineMatcher(output.splitlines()) 299 | lines = [x.strip() for x in fnl.strip().splitlines()] 300 | try: 301 | lm.fnmatch_lines(lines) 302 | except Exception: 303 | print(output) 304 | raise 305 | return output 306 | 307 | 308 | @pytest.fixture 309 | def cmd(): 310 | """invoke a command line subcommand with a unique plugin manager.""" 311 | return CmdlineRunner() 312 | 313 | 314 | @pytest.fixture 315 | def mycmd(cmd, tmpdir): 316 | cmd.set_basedir(tmpdir.mkdir("account").strpath) 317 | return cmd 318 | -------------------------------------------------------------------------------- /src/simplebot/templates/__init__.py: -------------------------------------------------------------------------------- 1 | from jinja2 import Environment, PackageLoader, select_autoescape 2 | 3 | env = Environment( 4 | loader=PackageLoader(__name__.split(".", maxsplit=1)[0], "templates"), 5 | autoescape=select_autoescape(["html", "xml"]), 6 | ) 7 | help_template = env.get_template("help.j2") 8 | -------------------------------------------------------------------------------- /src/simplebot/templates/help.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 13 | 14 | {% if filters %} 15 |
16 |
17 |

Help

18 |
19 |
20 |

21 | {% for f in filters %} 22 | {% if loop.index != 1 %} 23 |


24 | {% endif %} 25 | {% if f.admin %}(🛡️) {% endif %}{{ f.short }} 26 |
27 | {% if f.long %} 28 | {% for line in f.long.split("\n") %} 29 | {{ line }}
30 | {% endfor %} 31 | {% endif %} 32 | {% endfor %} 33 |

34 |
35 |
36 | {% endif %} 37 | 38 | {% if cmds %} 39 |
40 |
41 |

➡️ Commands

42 |
43 |
44 |

45 | {% for c in cmds %} 46 | {% if loop.index != 1 %} 47 |


48 | {% endif %} 49 | {% if c.admin %}(🛡️) {% endif %}{{ c.cmd }} {{ c.short }} 50 |
51 | {% if c.long %} 52 | {% for line in c.long.split("\n") %} 53 | {{ line }}
54 | {% endfor %} 55 | {% endif %} 56 | {% endfor %} 57 |

58 |
59 |
60 | {% endif %} 61 | 62 | {% if plugins %} 63 |
64 |
65 |

🧩 Enabled Plugins

66 |
67 |
68 |
    69 | {% for plugin in plugins %} 70 |
  • {{ plugin }}
  • 71 | {% endfor %} 72 |
73 |
74 |
75 | {% endif %} 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/simplebot/utils.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | import os 4 | import re 5 | from tempfile import NamedTemporaryFile 6 | from types import SimpleNamespace 7 | from typing import List, Optional, Tuple 8 | from urllib.parse import quote, unquote 9 | 10 | from deltachat.message import Message, extract_addr 11 | from PIL import Image 12 | from PIL.ImageColor import getcolor, getrgb 13 | from PIL.ImageOps import grayscale 14 | 15 | # disable Pillow debugging to stdout 16 | logging.getLogger("PIL").setLevel(logging.ERROR) 17 | 18 | 19 | def abspath(path: str) -> str: 20 | return os.path.abspath(os.path.expanduser(path)) 21 | 22 | 23 | def set_builtin_avatar(bot, name: str = "adaptive-default") -> bool: 24 | ext = ".png" 25 | path = os.path.join( 26 | os.path.dirname(os.path.abspath(__file__)), "avatars", name + ext 27 | ) 28 | if not os.path.exists(path): 29 | return False 30 | 31 | if name.startswith("adaptive-"): 32 | color = "#" + hex(bot.get_chat(bot.self_contact).get_color())[2:].zfill(6) 33 | blobdir = bot.account.get_blobdir() 34 | with NamedTemporaryFile( 35 | dir=blobdir, prefix="avatar-", suffix=ext, delete=False 36 | ) as fp: 37 | result_path = fp.name 38 | image_tint(path, color).save(result_path) 39 | path = result_path 40 | 41 | bot.account.set_avatar(path) 42 | return True 43 | 44 | 45 | def get_builtin_avatars() -> list: 46 | avatars = os.listdir( 47 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "avatars") 48 | ) 49 | return [os.path.splitext(name)[0] for name in avatars] 50 | 51 | 52 | def get_config_folder() -> str: 53 | return os.path.join(os.path.expanduser("~"), ".simplebot") 54 | 55 | 56 | def get_account_path(address: str) -> str: 57 | return os.path.join(get_config_folder(), "accounts", quote(address)) 58 | 59 | 60 | def get_accounts() -> List[Tuple[str, str]]: 61 | accounts_dir = os.path.join(get_config_folder(), "accounts") 62 | accounts = [] 63 | if os.path.exists(accounts_dir): 64 | folders = os.listdir(accounts_dir) 65 | else: 66 | folders = [] 67 | for folder in folders: 68 | accounts.append((unquote(folder), os.path.join(accounts_dir, folder))) 69 | return accounts 70 | 71 | 72 | def set_default_account(addr: str) -> None: 73 | config = configparser.ConfigParser() 74 | config["DEFAULT"]["default_account"] = addr 75 | path = os.path.join(get_config_folder(), "global.cfg") 76 | with open(path, "w", encoding="utf-8") as configfile: 77 | config.write(configfile) 78 | 79 | 80 | def get_default_account() -> str: 81 | config = configparser.ConfigParser() 82 | path = os.path.join(get_config_folder(), "global.cfg") 83 | if os.path.exists(path): 84 | config.read(path) 85 | def_account = config["DEFAULT"].get("default_account") 86 | if not def_account: 87 | accounts = get_accounts() 88 | if len(accounts) == 1: 89 | def_account = accounts[0][0] 90 | return def_account 91 | 92 | 93 | def image_tint(path: str, tint: str) -> Image: 94 | src = Image.open(path) 95 | if src.mode not in ("RGB", "RGBA"): 96 | raise TypeError(f"Unsupported source image mode: {src.mode}") 97 | src.load() 98 | 99 | tr, tg, tb = getrgb(tint) 100 | tl = getcolor(tint, "L") # tint color's overall luminosity 101 | if not tl: 102 | tl = 1 # avoid division by zero 103 | tl = float(tl) # compute luminosity preserving tint factors 104 | sr, sg, sb = map(lambda tv: tv / tl, (tr, tg, tb)) # per component 105 | # adjustments 106 | # create look-up tables to map luminosity to adjusted tint 107 | # (using floating-point math only to compute table) 108 | luts = ( 109 | tuple(map(lambda lr: int(lr * sr + 0.5), range(256))) 110 | + tuple(map(lambda lg: int(lg * sg + 0.5), range(256))) 111 | + tuple(map(lambda lb: int(lb * sb + 0.5), range(256))) 112 | ) 113 | l = grayscale(src) # 8-bit luminosity version of whole image 114 | if Image.getmodebands(src.mode) < 4: 115 | merge_args: tuple = (src.mode, (l, l, l)) # for RGB verion of grayscale 116 | else: # include copy of src image's alpha layer 117 | a = Image.new("L", src.size) 118 | a.putdata(src.getdata(3)) 119 | merge_args = (src.mode, (l, l, l, a)) # for RGBA verion of grayscale 120 | luts += tuple(range(256)) # for 1:1 mapping of copied alpha values 121 | 122 | image = Image.merge(*merge_args).point(luts) 123 | new_image = Image.new("RGBA", image.size, "WHITE") # Create a white rgba background 124 | new_image.paste(image, (0, 0), image) 125 | return new_image 126 | 127 | 128 | def parse_system_title_changed(text: str) -> Optional[tuple]: 129 | text = text.lower() 130 | m = re.match(r'group name changed from "(.+)" to ".+" by (.+).', text) 131 | if m: 132 | old_title, actor = m.groups() 133 | return (old_title, extract_addr(actor)) 134 | return None 135 | 136 | 137 | def parse_system_image_changed(text: str) -> Optional[tuple]: 138 | text = text.lower() 139 | m = re.match(r"group image (changed|deleted) by (.+).", text) 140 | if m: 141 | action, actor = m.groups() 142 | return (extract_addr(actor), action == "deleted") 143 | return None 144 | 145 | 146 | class StatusUpdateMessage: 147 | def __init__(self, instance: Message, serial: int, data: dict) -> None: 148 | self._webxdc_instance = instance 149 | self._serial = serial 150 | self._dc_msg = instance._dc_msg 151 | self.id = "UNKNOWN" 152 | self.chat = instance.chat 153 | self.account = instance.account 154 | self.error = "" 155 | self.filename = "" 156 | self.text = data.get("text") or "" 157 | self.html = data.get("html") or "" 158 | 159 | def quote(self) -> None: 160 | return None 161 | 162 | def has_html(self) -> bool: 163 | return False 164 | 165 | def get_sender_contact(self): 166 | return SimpleNamespace(addr="UNKNOWN", name="UNKNOWN") 167 | -------------------------------------------------------------------------------- /tests/test_cmdline.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_general_help(cmd): 5 | cmd.run_ok( 6 | [], 7 | """ 8 | *show-ffi* 9 | *init* 10 | *info* 11 | *serve* 12 | """, 13 | ) 14 | 15 | 16 | class TestSettings: 17 | def test_get_set_list(self, mycmd): 18 | mycmd.run_fail(["db", "--get", "hello"]) 19 | mycmd.run_fail(["db", "--set", "hello", "world"]) 20 | mycmd.run_ok(["db", "--set", "global/hello", "world"]) 21 | mycmd.run_ok( 22 | ["db", "--get", "global/hello"], 23 | """ 24 | world 25 | """, 26 | ) 27 | mycmd.run_ok( 28 | ["db", "--list"], 29 | """ 30 | global/hello: world 31 | """, 32 | ) 33 | mycmd.run_ok( 34 | ["db", "--del", "global/hello"], 35 | """ 36 | *delete* 37 | """, 38 | ) 39 | out = mycmd.run_ok(["db", "--list"]) 40 | assert "hello" not in out 41 | 42 | 43 | class TestPluginManagement: 44 | def test_list_plugins(self, mycmd): 45 | mycmd.run_ok( 46 | ["plugin", "--list"], 47 | """ 48 | *simplebot.builtin.* 49 | """, 50 | ) 51 | 52 | def test_add_del_list_module(self, mycmd, examples): 53 | filename = "quote_reply.py" 54 | path = examples.join(filename).strpath 55 | mycmd.run_ok(["plugin", "--add", path], "*{}*".format(path)) 56 | mycmd.run_ok( 57 | ["plugin", "--list"], 58 | """ 59 | *{}* 60 | """.format( 61 | filename 62 | ), 63 | ) 64 | mycmd.run_ok( 65 | ["plugin", "--del", path], 66 | """ 67 | *removed*1* 68 | """, 69 | ) 70 | out = mycmd.run_ok(["plugin", "--list"]) 71 | assert filename not in out 72 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from simplebot.bot import Replies 4 | from simplebot.commands import parse_command_docstring 5 | 6 | 7 | def test_parse_command_docstring(): 8 | with pytest.raises(ValueError): 9 | parse_command_docstring(lambda: None, None, args=[]) 10 | 11 | def func(replies, command): 12 | """short description. 13 | 14 | long description. 15 | """ 16 | 17 | short, long, args = parse_command_docstring( 18 | func, func.__doc__, args="command replies".split() 19 | ) 20 | assert short == "short description." 21 | assert long == "long description." 22 | assert len(args) == 2 23 | 24 | 25 | def test_run_help(mocker): 26 | reply = mocker.get_one_reply("/help") 27 | assert "/help" in reply.html 28 | 29 | 30 | def test_partial_args(mock_bot): 31 | def my_command(replies): 32 | """this command only needs the "replies" argument""" 33 | 34 | mock_bot.commands.register(name="/example", func=my_command) 35 | 36 | 37 | def test_fail_args(mock_bot): 38 | def my_command(unknown_arg): 39 | """invalid""" 40 | 41 | with pytest.raises(ValueError): 42 | mock_bot.commands.register(name="/example", func=my_command) 43 | 44 | 45 | def test_register(mock_bot): 46 | def my_command(command, replies): 47 | """my commands example.""" 48 | 49 | mock_bot.commands.register(name="/example", func=my_command) 50 | assert "/example" in mock_bot.commands.dict() 51 | with pytest.raises(ValueError): 52 | mock_bot.commands.register(name="/example", func=my_command) 53 | 54 | mock_bot.commands.unregister("/example") 55 | assert "/example" not in mock_bot.commands.dict() 56 | 57 | 58 | class TestArgParsing: 59 | @pytest.fixture 60 | def parse_cmd(self, mocker): 61 | def proc(name, text, group=None): 62 | l = [] 63 | 64 | def my_command(command, replies): 65 | """my commands example.""" 66 | l.append(command) 67 | 68 | mocker.bot.commands.register(name=name, func=my_command) 69 | mocker.replies = mocker.get_replies(text, group=group) 70 | if len(l) == 1: 71 | return l[0] 72 | 73 | return proc 74 | 75 | def test_basic(self, parse_cmd): 76 | command = parse_cmd("/some", "/some 123") 77 | assert command.args == ["123"] 78 | assert command.payload == "123" 79 | 80 | def test_under1(self, parse_cmd): 81 | command = parse_cmd("/some", "/some_123 456") 82 | assert command.args == ["123", "456"] 83 | assert command.payload == "123 456" 84 | 85 | def test_under2(self, parse_cmd): 86 | command = parse_cmd("/some", "/some_123_456") 87 | assert command.args == ["123", "456"] 88 | assert command.payload == "123 456" 89 | 90 | def test_multiline_payload(self, parse_cmd): 91 | command = parse_cmd("/some", "/some_123 456\n789_yes") 92 | assert command.args == ["123", "456", "789_yes"] 93 | assert command.payload == "123 456\n789_yes" 94 | 95 | def test_under_with_under_command(self, parse_cmd): 96 | command = parse_cmd("/some_group", "/some_group_123_456") 97 | assert command.args == ["123", "456"] 98 | assert command.payload == "123 456" 99 | 100 | def test_under_conflict(self, parse_cmd): 101 | parse_cmd("/some", "/some") 102 | with pytest.raises(ValueError): 103 | parse_cmd("/some_group_long", "") 104 | with pytest.raises(ValueError): 105 | parse_cmd("/some_group", "") 106 | 107 | def test_under_conflict2(self, parse_cmd): 108 | parse_cmd("/some_group", "/some_group") 109 | with pytest.raises(ValueError): 110 | parse_cmd("/some", "") 111 | 112 | def test_two_commands_with_different_subparts(self, parse_cmd): 113 | assert parse_cmd("/some_group", "/some_group").cmd_def.cmd == "/some_group" 114 | assert parse_cmd("/some_other", "/some_other").cmd_def.cmd == "/some_other" 115 | 116 | def test_unknown_command(self, parse_cmd, mocker): 117 | parse_cmd("/some_group", "/unknown", group="mockgroup") 118 | assert not mocker.replies 119 | parse_cmd("/some_other", "/unknown", group=None) 120 | assert mocker.replies 121 | 122 | def test_two_commands_with_same_prefix(self, parse_cmd): 123 | assert parse_cmd("/execute", "/execute").cmd_def.cmd == "/execute" 124 | assert parse_cmd("/exec", "/exec").cmd_def.cmd == "/exec" 125 | -------------------------------------------------------------------------------- /tests/test_deltabot.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import pytest 4 | 5 | from simplebot.bot import Replies 6 | 7 | 8 | class TestDeltaBot: 9 | def test_self_contact(self, mock_bot): 10 | contact = mock_bot.self_contact 11 | assert contact.addr 12 | assert contact.display_name 13 | assert contact.id 14 | 15 | def test_get_contact(self, mock_bot): 16 | contact = mock_bot.get_contact("x@example.org") 17 | assert contact.addr == "x@example.org" 18 | assert contact.display_name == "x@example.org" 19 | 20 | contact2 = mock_bot.get_contact(contact) 21 | assert contact2 == contact 22 | 23 | contact3 = mock_bot.get_contact(contact.id) 24 | assert contact3 == contact 25 | 26 | def test_get_chat(self, mock_bot): 27 | chat = mock_bot.get_chat("x@example.org") 28 | contact = mock_bot.get_contact("x@example.org") 29 | assert contact.get_chat() == chat 30 | assert mock_bot.get_chat(contact) == chat 31 | assert mock_bot.get_chat(chat.id) == chat 32 | 33 | msg = chat.send_text("hello") 34 | assert mock_bot.get_chat(msg) == chat 35 | 36 | def test_create_group(self, mock_bot): 37 | members = set(["x{}@example.org".format(i) for i in range(3)]) 38 | chat = mock_bot.create_group("test", contacts=members) 39 | assert chat.get_name() == "test" 40 | assert chat.is_group() 41 | for contact in chat.get_contacts(): 42 | members.discard(contact.addr) 43 | assert not members 44 | 45 | 46 | class TestSettings: 47 | def test_set_get_list(self, mock_bot): 48 | mock_bot.set("a", "1") 49 | mock_bot.set("b", "2") 50 | l = mock_bot.list_settings() 51 | assert len(l) == 2 52 | assert l == [("global/a", "1"), ("global/b", "2")] 53 | 54 | 55 | class TestReplies: 56 | @pytest.fixture 57 | def replies(self, mock_bot, mocker): 58 | incoming_message = mocker.make_incoming_message("0") 59 | return Replies(incoming_message, mock_bot.logger) 60 | 61 | def test_two_text(self, replies): 62 | replies.add(text="hello") 63 | replies.add(text="world") 64 | l = replies.send_reply_messages() 65 | assert len(l) == 2 66 | assert l[0].text == "hello" 67 | assert l[1].text == "world" 68 | 69 | def test_filename(self, replies, tmpdir): 70 | p = tmpdir.join("textfile") 71 | p.write("content") 72 | replies.add(text="hello", filename=p.strpath) 73 | l = replies.send_reply_messages() 74 | assert len(l) == 1 75 | assert l[0].text == "hello" 76 | s = open(l[0].filename).read() 77 | assert s == "content" 78 | 79 | def test_file_content(self, replies): 80 | bytefile = io.BytesIO(b"bytecontent") 81 | replies.add(text="hello", filename="something.txt", bytefile=bytefile) 82 | 83 | l = replies.send_reply_messages() 84 | assert len(l) == 1 85 | assert l[0].text == "hello" 86 | assert l[0].filename.endswith(".txt") 87 | assert "something" in l[0].filename 88 | s = open(l[0].filename, "rb").read() 89 | assert s == b"bytecontent" 90 | 91 | def test_chat_incoming_default(self, replies): 92 | replies.add(text="hello") 93 | l = replies.send_reply_messages() 94 | assert len(l) == 1 95 | assert l[0].text == "hello" 96 | assert l[0].chat == replies.incoming_message.chat 97 | 98 | def test_different_chat(self, replies, mock_bot): 99 | chat = mock_bot.account.create_group_chat("new group") 100 | replies.add(text="this", chat=chat) 101 | l = replies.send_reply_messages() 102 | assert len(l) == 1 103 | assert l[0].text == "this" 104 | assert l[0].chat.id == chat.id 105 | -------------------------------------------------------------------------------- /tests/test_filters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def hitchhiker(message, replies): 5 | """my incoming message filter example.""" 6 | if "42" in message.text: 7 | replies.add(text="correct answer!") 8 | else: 9 | replies.add(text="try again!") 10 | 11 | 12 | def test_register(mock_bot): 13 | mock_bot.filters.register(name="hitchhiker", func=hitchhiker) 14 | with pytest.raises(ValueError): 15 | mock_bot.filters.register(name="hitchhiker", func=hitchhiker) 16 | 17 | mock_bot.filters.unregister("hitchhiker") 18 | assert "hitchhiker" not in mock_bot.filters.dict() 19 | 20 | 21 | def test_simple_filter(bot_tester): 22 | bot_tester.bot.filters.register(name="hitchhiker", func=hitchhiker) 23 | msg_reply = bot_tester.send_command("hello 42") 24 | assert msg_reply.text == "correct answer!" 25 | msg_reply = bot_tester.send_command("hello 10") 26 | assert msg_reply.text == "try again!" 27 | 28 | 29 | def test_filters_not_called_on_commands(bot_tester): 30 | l = [] 31 | 32 | def always_answer(message, replies): 33 | """always""" 34 | l.append(1) 35 | 36 | bot_tester.bot.filters.register(name="always_answer", func=always_answer) 37 | bot_tester.send_command("/help 42") 38 | assert not l 39 | 40 | 41 | def test_pseudo_downloader(bot_tester): 42 | def downloader(message, replies): 43 | """pseudo downloader of https""" 44 | if "https" in message.text: 45 | replies.add(text="downloaded", filename=__file__) 46 | 47 | bot_tester.bot.filters.register(name="downloader", func=downloader) 48 | msg_reply = bot_tester.send_command("this https://qwjkeqwe") 49 | assert msg_reply.text == "downloaded" 50 | assert msg_reply.filename != __file__ # blobdir 51 | with open(msg_reply.filename) as f: 52 | content = f.read() 53 | assert content == open(__file__).read() 54 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import textwrap 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def parser(plugin_manager, tmpdir, monkeypatch): 9 | from simplebot.parser import get_base_parser 10 | 11 | basedir = tmpdir.mkdir("basedir").strpath 12 | argv = ["simplebot", "--account", basedir] 13 | parser = get_base_parser(plugin_manager, argv) 14 | assert parser.basedir == basedir 15 | monkeypatch.setenv("SIMPLEBOT_ACCOUNT", basedir) 16 | return parser 17 | 18 | 19 | @pytest.fixture 20 | def makeini(parser, monkeypatch): 21 | def makeini(source): 22 | s = textwrap.dedent(source) 23 | p = os.path.join(parser.basedir, "bot.ini") 24 | with open(p, "w") as f: 25 | f.write(s) 26 | return p 27 | 28 | return makeini 29 | 30 | 31 | class TestParser: 32 | def test_generic(self, plugin_manager): 33 | from simplebot.parser import get_base_parser 34 | 35 | basedir = "/123" 36 | argv = ["simplebot", "--account", basedir] 37 | parser = get_base_parser(plugin_manager, argv) 38 | 39 | args = parser.main_parse_argv(argv) 40 | assert args.basedir == basedir 41 | 42 | args = parser.main_parse_argv(["simplebot"]) 43 | assert args.command is None 44 | 45 | def test_add_generic(self, parser, makeini): 46 | parser.add_generic_option( 47 | "--example", 48 | choices=["info", "debug", "err", "warn"], 49 | default="info", 50 | help="stdout logging level.", 51 | inipath="section:key", 52 | ) 53 | 54 | makeini( 55 | """ 56 | [section] 57 | key = debug 58 | """ 59 | ) 60 | args = parser.main_parse_argv(["simplebot"]) 61 | assert args.example == "debug" 62 | 63 | 64 | class TestInit: 65 | def test_noargs(self, parser): 66 | with pytest.raises(SystemExit) as ex: 67 | parser.main_parse_argv(["simplebot", "init"]) 68 | assert ex.value.code != 0 69 | 70 | def test_basic_args(self, parser): 71 | args = parser.main_parse_argv(["simplebot", "init", "email@x.org", "123"]) 72 | assert args.command == "init" 73 | assert args.emailaddr == "email@x.org" 74 | assert args.password == "123" 75 | 76 | def test_arg_verification_fails(self, parser): 77 | args = parser.main_parse_argv(["simplebot", "init", "email", "123"]) 78 | assert args.command == "init" 79 | assert args.emailaddr == "email" 80 | assert args.password == "123" 81 | with pytest.raises(SystemExit) as ex: 82 | parser.main_run(bot=None, args=args) 83 | assert ex.value.code != 0 84 | 85 | def test_arg_run_fails(self, parser): 86 | args = parser.main_parse_argv(["simplebot", "init", "email@example.org", "123"]) 87 | l = [] 88 | 89 | class PseudoBot: 90 | def perform_configure_address(self, emailaddr, password): 91 | l.append((emailaddr, password)) 92 | return True 93 | 94 | parser.main_run(bot=PseudoBot(), args=args) 95 | assert l == [("email@example.org", "123")] 96 | -------------------------------------------------------------------------------- /tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | from queue import Queue 2 | 3 | import pluggy 4 | 5 | import simplebot 6 | from simplebot.plugins import get_global_plugin_manager 7 | 8 | 9 | def test_globality_plugin_manager(monkeypatch): 10 | monkeypatch.setattr(simplebot.plugins, "_pm", None) 11 | pm1 = get_global_plugin_manager() 12 | pm2 = get_global_plugin_manager() 13 | assert pm1 == pm2 14 | monkeypatch.undo() 15 | assert pm1 != get_global_plugin_manager() 16 | 17 | 18 | def test_builtin_plugins(mock_bot): 19 | assert ".builtin.db" in mock_bot.plugins.dict() 20 | mock_bot.plugins.remove(name=".builtin.db") 21 | assert ".builtin.db" not in mock_bot.plugins.dict() 22 | 23 | 24 | def test_setuptools_plugin(monkeypatch, request): 25 | l = [] 26 | 27 | def load_setuptools_entrypoints(self, group, name=None): 28 | l.append((group, name)) 29 | 30 | monkeypatch.setattr( 31 | pluggy.PluginManager, 32 | "load_setuptools_entrypoints", 33 | load_setuptools_entrypoints, 34 | ) 35 | _ = request.getfixturevalue("mock_bot") 36 | assert l == [("simplebot.plugins", None)] 37 | 38 | 39 | def test_deltabot_init_hooks(monkeypatch, request): 40 | q = Queue() 41 | 42 | class MyPlugin: 43 | @simplebot.hookimpl 44 | def deltabot_init(self, bot): 45 | # make sure db is ready and initialized 46 | bot.set("hello", "world") 47 | assert bot.get("hello") == "world" 48 | q.put(1) 49 | 50 | @simplebot.hookimpl 51 | def deltabot_start(self, bot): 52 | q.put(2) 53 | 54 | def load_setuptools_entrypoints(self, group, name=None): 55 | self.register(MyPlugin()) 56 | 57 | monkeypatch.setattr( 58 | pluggy.PluginManager, 59 | "load_setuptools_entrypoints", 60 | load_setuptools_entrypoints, 61 | ) 62 | bot = request.getfixturevalue("mock_stopped_bot") 63 | assert q.get(timeout=10) == 1 64 | bot.start() 65 | assert q.get(timeout=10) == 2 66 | --------------------------------------------------------------------------------