├── .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 | [](https://pypi.org/project/simplebot)
7 | [](https://pypi.org/project/simplebot)
8 | [](https://pepy.tech/project/simplebot)
9 | [](https://pypi.org/project/simplebot)
10 | [](https://github.com/simplebot-org/simplebot/actions/workflows/python-ci.yml)
11 | [](https://github.com/psf/black)
12 | [](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 |
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 |
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 |
--------------------------------------------------------------------------------