Compare commits
88 commits
fix-devenv
...
developmen
Author | SHA1 | Date | |
---|---|---|---|
missytake | 51929f8071 | ||
missytake | c773308a28 | ||
missytake | 7468344d81 | ||
missytake | 061d29325f | ||
missytake | e9eb98dba8 | ||
missytake | ea58f4a150 | ||
missytake | 91e93b3e20 | ||
5bff62487b | |||
26818da430 | |||
missytake | c468543fa5 | ||
missytake | f829bf8694 | ||
missytake | 16c4ebc84b | ||
missytake | f0e15f1d37 | ||
missytake | b876e645de | ||
missytake | 6b6a2777bb | ||
missytake | c1b8ad2984 | ||
missytake | 48e3b6f6bc | ||
missytake | 64715f5aa5 | ||
missytake | f7d9baa8a3 | ||
missytake | 0504d3083f | ||
ec0abb5e24 | |||
d897f369f7 | |||
9c7607b7ca | |||
missytake | 0f4b25fcde | ||
missytake | 4f96dfbee7 | ||
missytake | afd0894952 | ||
missytake | f0757619a7 | ||
missytake | 90469a052a | ||
missytake | 16c325a9cb | ||
missytake | b49c4767a0 | ||
missytake | a61c48e99e | ||
dfd17aa27c | |||
66fff6fd7d | |||
missytake | a548c2febc | ||
missytake | f533efee4f | ||
missytake | cb88c24e2e | ||
missytake | 36638b1c64 | ||
missytake | 7fd716cecc | ||
missytake | 3d482dd5f5 | ||
missytake | fb1e88ab03 | ||
missytake | 5fa5a9f48e | ||
missytake | 9704ed4ddf | ||
missytake | 12935b79cb | ||
missytake | 37f7b98c67 | ||
missytake | d120d718f9 | ||
missytake | b1f8c08d25 | ||
missytake | 4dc4b9cfca | ||
missytake | 07bc5a2686 | ||
missytake | 3ae4a08ad5 | ||
767c92000b | |||
13c20ca245 | |||
fd09b381a6 | |||
35eff0c416 | |||
003a10b273 | |||
dc454800cd | |||
82129b958e | |||
20fdb1ae11 | |||
4c110e2c71 | |||
fb543cffb9 | |||
aed5710da7 | |||
d1ffcf928d | |||
005e4955ae | |||
76dcec3ae2 | |||
038a4bf976 | |||
401b4f32b1 | |||
8486ce8fab | |||
74fca41d66 | |||
c9c1fe029b | |||
cfb48cc0ff | |||
e3f5fbad44 | |||
b256aff872 | |||
d9d75b76f2 | |||
8ad6ce66fc | |||
5976db0cfc | |||
4d5b1ee26e | |||
ac00733847 | |||
c6c67463c9 | |||
1824057e3e | |||
6cb8a46f6c | |||
5df8db657f | |||
400f5af716 | |||
Q | d943e956ab | ||
missytake | 39a21fe34a | ||
missytake | 4029770c90 | ||
missytake | eef6f0c473 | ||
missytake | 1d1eb47fe6 | ||
eaf187513e | |||
missytake | 32a8712805 |
163
CONTRIBUTING.md
163
CONTRIBUTING.md
|
@ -1,41 +1,41 @@
|
|||
# Ticketfrei Contribution HowTo
|
||||
# Kibicara Contribution HowTo
|
||||
|
||||
|
||||
## Setup Development Environment
|
||||
|
||||
### General
|
||||
|
||||
1. Install `python>=3.10` and development packages
|
||||
(`apt install python3-dev g++` on Ubuntu)
|
||||
2. Run `./setup.sh`
|
||||
|
||||
### Backend
|
||||
|
||||
1. Install `python>=3.8`
|
||||
2. Create a virtual environment with `python3 -m venv .venv`
|
||||
3. Activate your dev environment with `source .venv/bin/activate`
|
||||
4. Update pip packages with `pip install -U pip setuptools wheel`
|
||||
5. Install with `pip install .`
|
||||
6. Install development dependencies with `pip install tox black pytest pytest-aiohttp`
|
||||
7. Add git-hook to run test and stylecheck before commmit with
|
||||
`ln -s ../../git-hooks/pre-commit .git/hooks/pre-commit`
|
||||
8. Add git-hook to check commmit message format with
|
||||
`ln -s ../../git-hooks/commit-msg .git/hooks/commit-msg`
|
||||
9. Turn off production mode: `sudo su -c 'echo "production = 0" >> /etc/kibicara.conf'`
|
||||
0. `cd backend`
|
||||
1. Activate your dev environment with `source .venv/bin/activate`
|
||||
2. Install with `pip install .`
|
||||
3. Create a config file: `echo "production = 0" > kibicara.conf`
|
||||
|
||||
#### Cheatsheet
|
||||
|
||||
- Install Ticketfrei with `pip install .`
|
||||
- Execute Ticketfrei with `kibicara` (verbose: `kibicara -vvv`)
|
||||
- Install Kibicara with `pip install .`
|
||||
- Execute Kibicara with `kibicara -f kibicara.conf`
|
||||
(verbose: `kibicara -vvv -f kibicara.conf`)
|
||||
- Interact with Swagger REST-API Documentation: `http://localhost:8000/api/docs`
|
||||
- Test and stylecheck with `tox`
|
||||
- Fix style issues with `black -S kibicara tests`
|
||||
- Fix style issues with `black -S src tests`
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Install node.js (e.g. via
|
||||
[nvm](https://github.com/nvm-sh/nvm#installation-and-update))
|
||||
2. `cd kibicara-frontend`
|
||||
2. `cd frontend`
|
||||
3. Install the dependencies with `npm i`
|
||||
4. Install Angular with `npm i -g @angular/cli`
|
||||
4. Install Angular with `npm i @angular/cli`
|
||||
5. Turn off production mode if you have not already (see above in backend).
|
||||
6. Start the backend in a different terminal
|
||||
7. To serve and open the application, run `ng s -o`. The application will open
|
||||
under [http://127.0.0.1:4200](http://127.0.0.1:4200).
|
||||
7. To serve and open the application, run `node_modules/@angular/cli/bin/ng.js s -o`.
|
||||
The application will open under [http://127.0.0.1:4200](http://127.0.0.1:4200).
|
||||
|
||||
### Creating an account
|
||||
|
||||
|
@ -60,14 +60,9 @@ email address and register via frontend or manually at `http://localhost:8000/ap
|
|||
## Contribution Guidelines
|
||||
### Branches
|
||||
|
||||
- **Master:** The master branch tracks the last stable release.
|
||||
- Releases will be done using release tags.
|
||||
- Force push and pushes without group consent are disallowed.
|
||||
- There never should be a merge commit from development into master!
|
||||
- **Development:** The development branch is used to add new features.
|
||||
- Only rebase of feature branches is allowed.
|
||||
- On Release the development branch will be rebased onto master and a release
|
||||
tag will be created on master
|
||||
- On Release a release tag will be created
|
||||
- **Feature-Branches:**
|
||||
- A feature branch will be used to develop a feature.
|
||||
- It belongs to one developer only and force push is allowed.
|
||||
|
@ -88,8 +83,8 @@ following pattern:
|
|||
|
||||
You can use these tags:
|
||||
|
||||
- [core] Feature for Ticketfrei core
|
||||
- [frontend] Feature for Ticketfrei frontend
|
||||
- [core] Feature for Kibicara core
|
||||
- [frontend] Feature for Kibicara frontend
|
||||
- [$platform] Feature for platforms, e.g.
|
||||
- [twitter]
|
||||
- [telegram]
|
||||
|
@ -133,17 +128,29 @@ development team.
|
|||
|
||||
## How to implement a new Platform/Social Network
|
||||
|
||||
### tl;dr
|
||||
For transferring messages, Kibicara supports a range of platforms/social
|
||||
networks, e.g. Mastodon, E-Mail, and Telegram - but more can be added easily.
|
||||
This guide explains what you need to do to add another platform, e.g. Matrix or
|
||||
XMPP.
|
||||
|
||||
1. Implement the following modules in `platforms/<your-platform>/`:
|
||||
### Overview:
|
||||
|
||||
1. Implement the backend modules in `platforms/<your-platform>/`:
|
||||
- `bot.py`
|
||||
- `model.py`
|
||||
- `webapi.py`
|
||||
2. Import your bot in `kibicara/webapi/__init__.py`.
|
||||
3. Generate the FastAPI stuff
|
||||
4. Generate the angular components for the kibicara-frontend from the FastAPI stuff
|
||||
3. Generate the FastAPI boilerplate code
|
||||
4. Generate the angular boilerplate code
|
||||
5. Copy-paste frontend components from other bots into the angular boilerplate
|
||||
and adjust them to your needs
|
||||
|
||||
### Explanation
|
||||
At the bottom you can find a checklist what your pull request needs to be
|
||||
merged into kibicara.
|
||||
|
||||
### Step by step
|
||||
|
||||
#### Implement the backend modules
|
||||
|
||||
In `kibicara/platforms/<your-platform>/bot.py`, you write the functions through
|
||||
which the platform asks the social network for new messages, and publishes
|
||||
|
@ -159,20 +166,96 @@ You will probably need to store the following things:
|
|||
* platform-specific settings
|
||||
* anything else your platform needs
|
||||
|
||||
In `kibicara/platforms/<your-platform>/webapi.py`, you can define HTTP routes.
|
||||
You will need them to:
|
||||
In `kibicara/platforms/<your-platform>/webapi.py`, you can define REST API
|
||||
routes. You will need them to:
|
||||
|
||||
* let admins authenticate to the social network in the kibicara web interface
|
||||
* update platform-specific settings
|
||||
|
||||
#### Import your bot into the kibicara REST API
|
||||
|
||||
To run the platform, you need to import the bot in
|
||||
`kibicara/webapi/__init__.py`.
|
||||
`kibicara/webapi/__init__.py`. You can see how the other platforms did it.
|
||||
|
||||
#### Generate the FastAPI boilerplate code
|
||||
|
||||
Whenever you changed the REST API in the backend, you need to re-generate the
|
||||
FastAPI boilerplate code:
|
||||
|
||||
1. Start backend with `kibicara > /dev/null 2>&1 &`
|
||||
2. Go to the frontend directory: `cd frontend`
|
||||
3. Use this command to download the openapi.json from the backend and
|
||||
generate the boilerplate: `npm run openapi-generator`
|
||||
4. (Now you can stop the backend again, e.g. with `pkill kibicara`)
|
||||
5. Append `/api` to all relevant URLs:
|
||||
`find src/app/core/api/ -type f -exec sed -i "s#{this.configuration.basePath}#{this.configuration.basePath}/api#g" {} +`
|
||||
6. Check if everything is okay (e.g. all api calls need the `/api` prefix)
|
||||
|
||||
#### Generate the Angular boilerplate code
|
||||
|
||||
##### Generate the platform-specific "public facing" page
|
||||
|
||||
Generate boilerplate "card" for myplatform:
|
||||
|
||||
```
|
||||
ng generate component platforms/myplatform/myplatform-bot-card
|
||||
```
|
||||
|
||||
Generate boilerplate for the popup that shows the users the guide on how to use
|
||||
the bot:
|
||||
|
||||
```
|
||||
ng generate component platforms/myplatform/myplatform-bot-card/myplatform-bot-info-dialog
|
||||
```
|
||||
|
||||
##### Generate the platform-specific "settings" page
|
||||
|
||||
Generate bot card for settings page:
|
||||
|
||||
```
|
||||
ng generate component platforms/myplatform/myplatform-settings
|
||||
```
|
||||
|
||||
Generate popup that will show when you click on "Add" to add the myplatform
|
||||
credentials (e.g. login data, api tokens etc):
|
||||
|
||||
```
|
||||
ng generate component platforms/myplatform/myplatform-settings/mastodon-dialog
|
||||
```
|
||||
|
||||
If something does not work, try to check `platforms.module.ts` and check if the
|
||||
module was imported there. Every module needs to be imported there
|
||||
|
||||
#### Adjust the Angular code for your specific platform
|
||||
|
||||
Every frontend part for a bot has a similar structure. Basically copy the
|
||||
content of the other files e.g. the telegram bot into the generated boilerplate
|
||||
above and search and replace all occurrences with `myplatform`. You can see
|
||||
the UI with `ng s -o`, it will auto-update on code change.
|
||||
|
||||
A component in angular has 3-4 files, only these ones ending with
|
||||
`*.component.ts` (typescript) and `*.component.html`(html) are important for
|
||||
us. Basically the typescript controls what is shown in the html. Please correct
|
||||
every error that stops the angular page build or shows up on the page while you
|
||||
go, otherwise this can become a mess.
|
||||
|
||||
With that in mind, first write the logic to call the /create endpoint:
|
||||
- `src/app/platforms/myplatform/myplatform-settings/myplatform-dialog/myplatform-dialog.component.ts`:
|
||||
implement the form to take the user inputs and the onSubmit() function
|
||||
- `src/app/platforms/myplatform/myplatform-settings/myplatform-dialog/myplatform-dialog.component.html`:
|
||||
implement the html skeleton that takes the form from the user
|
||||
|
||||
Then, fix up the public user facing page:
|
||||
- `src/app/platforms/myplatform/myplatform-bot-card/myplatform-bot-info-dialog/myplatform-bot-info-dialog.component.html`
|
||||
|
||||
Finally, check the other typescript and html pages and adjust e.g. the tutorial
|
||||
text for the users.
|
||||
|
||||
### Acceptance criteria for bots (Checklist)
|
||||
|
||||
A bot should have at least this functionality:
|
||||
|
||||
- Ticketfrei REST API (hood admins):
|
||||
- Kibicara REST API (hood admins):
|
||||
- Endpoint for creating a bot associated with a hood
|
||||
- Endpoint for deleting a bot associated with a hood
|
||||
- Endpoint for updating a bot associated with a hood by id
|
||||
|
@ -205,4 +288,10 @@ A bot should have at least this functionality:
|
|||
- e.g. Telegram via direct message from the bot
|
||||
- e.g. E-Mail via e-mail to the user's address
|
||||
|
||||
- Web Interface (hood admins)
|
||||
- Web Interface (hood admins and users)
|
||||
- A card which allows hood admins to add, configure, start, stop, and
|
||||
delete a platform to their hood
|
||||
- A pop-up which explains to hood admins how to configure the platform
|
||||
- A card which allows users to subscribe on a platform or links to the
|
||||
platform's account
|
||||
- A pop-up which explains to users how to use the platform
|
||||
|
|
3
COPYING
3
COPYING
|
@ -1,9 +1,8 @@
|
|||
Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
|
||||
Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
Copyright (C) 2020 by Maike <maike@systemli.org>
|
||||
Copyright (C) 2022 by missytake <missytake@systemli.org>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
with or without fee is hereby granted.
|
||||
|
|
|
@ -48,7 +48,7 @@ export const environment = {
|
|||
- Build frontend with `cd kibicara/kibicara-frontend && ng build --prod`
|
||||
- Copy the generated frontend to your server to `/home/kibicara/kibicara-frontend`: `scp -r kibicara/kibicara-frontend/dist/kibicara-frontend <your_server>:/home/kibicara`
|
||||
|
||||
### Configure Ticketfrei Core
|
||||
### Configure Kibicara Core
|
||||
- Write config file to `/etc/kibicara.conf` and replace the domain with yours:
|
||||
```
|
||||
database_connection = 'sqlite:////home/kibicara/kibicara.sqlite'
|
||||
|
@ -59,7 +59,7 @@ frontend_url = 'https://kibicara.example.com'
|
|||
#### SSL
|
||||
You can use the SSL stuff provided by hypercorn by generating an SSL Certificate and passing its paths to the config options `certfile` and `keyfile` in `/etc/kibicara.conf`.
|
||||
|
||||
### Configure Ticketfrei platforms
|
||||
### Configure Kibicara platforms
|
||||
|
||||
#### Configure E-Mail (OpenSMTPd + Relay)
|
||||
To send and receive e-mails (necessary for registration confirmation and e-mail bot) you will need an MTA. We use OpenSMTPd:
|
||||
|
@ -105,5 +105,5 @@ consumer_secret = '<your_consumer_secret>'
|
|||
#### Configure Telegram
|
||||
Nothing to do, because telegram has a nice API.
|
||||
|
||||
### Start Ticketfrei
|
||||
### Start Kibicara
|
||||
Run `kibicara` with your kibicara user. To have more verbose output add `-vvv`.
|
||||
|
|
16
README.md
16
README.md
|
@ -1,9 +1,9 @@
|
|||
![Angular Frontend](https://git.0x90.space/ticketfrei/ticketfrei3/workflows/Angular%20Frontend/badge.svg)
|
||||
![Python Backend](https://git.0x90.space/ticketfrei/ticketfrei3/workflows/Python%20Backend/badge.svg)
|
||||
![Angular Frontend](https://github.com/acipm/kibicara/workflows/Angular%20Frontend/badge.svg)
|
||||
![Python Backend](https://github.com/acipm/kibicara/workflows/Python%20Backend/badge.svg)
|
||||
|
||||
# Ticketfrei 3
|
||||
# Kibicara
|
||||
|
||||
Ticketfrei relays messages between different platforms (= social networks).
|
||||
Kibicara relays messages between different platforms (= social networks).
|
||||
|
||||
In its web interface, a hood admin (= registered user) can create a hood to
|
||||
build a connection between different platforms.
|
||||
|
@ -11,18 +11,18 @@ build a connection between different platforms.
|
|||
Users can message a specific hood account on a specific platform (e.g. @xyz on
|
||||
Telegram). This pushes the announcement to all platform accounts of a hood.
|
||||
For example: User A writes a message to @xyz on Telegram (which has been
|
||||
connected to Ticketfrei by a hood admin). This publishes the message on e.g.
|
||||
connected to Kibicara by a hood admin). This publishes the message on e.g.
|
||||
Twitter and other platforms which have been connected to the hood.
|
||||
|
||||
The admin of a hood has to define trigger words and bad words. Messages need to
|
||||
contain a trigger word to be relayed, and must not contain a bad word.
|
||||
|
||||
Ticketfrei needs to be hosted on a server by an instance maintainer. That way,
|
||||
Kibicara needs to be hosted on a server by an instance maintainer. That way,
|
||||
hood admins don't need a server of their own.
|
||||
|
||||
## Deploy Ticketfrei on a production server
|
||||
## Deploy Kibicara on a production server
|
||||
|
||||
Read `DEPLOYMENT.md` to learn how to deploy Ticketfrei.
|
||||
Read `DEPLOYMENT.md` to learn how to deploy Kibicara.
|
||||
|
||||
## Contribute!
|
||||
|
||||
|
|
5
backend/README.md
Normal file
5
backend/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Kibicara backend
|
||||
|
||||
Kibicara relays messages between different platforms (= social networks).
|
||||
This is just the backend. For info about the whole project see [our git
|
||||
repo](https://git.0x90.space/ticketfrei/ticketfrei3).
|
6
backend/pyproject.toml
Normal file
6
backend/pyproject.toml
Normal file
|
@ -0,0 +1,6 @@
|
|||
[build-system]
|
||||
requires = [
|
||||
"setuptools>=42",
|
||||
"wheel"
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
73
backend/setup.cfg
Normal file
73
backend/setup.cfg
Normal file
|
@ -0,0 +1,73 @@
|
|||
[metadata]
|
||||
name = kibicara
|
||||
version = 0.1.0
|
||||
author = 0x90.space
|
||||
author_email = people@schleuder.0x90.space
|
||||
description = distribute messages across different social media
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
url = https://git.0x90.space/ticketfrei/ticketfrei3
|
||||
project_urls =
|
||||
Bug Tracker = https://git.0x90.space/ticketfrei/ticketfrei3/issues
|
||||
classifiers =
|
||||
Programming Language :: Python :: 3
|
||||
License :: Public Domain
|
||||
|
||||
[options]
|
||||
package_dir =
|
||||
= src
|
||||
packages = find:
|
||||
python_requires = >=3.10
|
||||
install_requires =
|
||||
aiofiles
|
||||
aiogram
|
||||
aiosqlite
|
||||
argon2_cffi
|
||||
fastapi
|
||||
httpx
|
||||
hypercorn
|
||||
Mastodon.py
|
||||
passlib
|
||||
peony-twitter[all]
|
||||
pydantic[email]
|
||||
pynacl
|
||||
python-multipart
|
||||
pytoml
|
||||
requests
|
||||
tortoise-orm
|
||||
|
||||
[options.packages.find]
|
||||
where = src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
kibicara = kibicara.kibicara:Main
|
||||
kibicara_mda = kibicara.platforms.email.mda:Main
|
||||
migrate_from_ticketfrei2 = kibicara.migratefromticketfrei:Main
|
||||
|
||||
[tox:tox]
|
||||
envlist = lint, py310
|
||||
isolated_build = True
|
||||
|
||||
[testenv:lint]
|
||||
skip_install = True
|
||||
deps =
|
||||
black
|
||||
flake8
|
||||
commands =
|
||||
black --check --diff src tests
|
||||
flake8 src tests
|
||||
|
||||
[testenv]
|
||||
deps =
|
||||
mypy
|
||||
pytest
|
||||
pytest-asyncio
|
||||
types-requests
|
||||
commands =
|
||||
# not yet
|
||||
#mypy --ignore-missing-imports src tests
|
||||
pytest tests
|
||||
|
||||
[flake8]
|
||||
max_line_length = 88
|
23
backend/src/kibicara/config.py
Normal file
23
backend/src/kibicara/config.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Default configuration.
|
||||
|
||||
The default configuration gets overwritten by a configuration file if one exists.
|
||||
"""
|
||||
config = {
|
||||
"database_connection": "sqlite://:memory:",
|
||||
"frontend_url": "http://localhost:4200", # url of frontend, change in prod
|
||||
# production params
|
||||
"frontend_path": None, # required, path to frontend html/css/js files
|
||||
"production": True,
|
||||
"behind_proxy": False,
|
||||
"keyfile": None, # optional for ssl
|
||||
"certfile": None, # optional for ssl
|
||||
# dev params
|
||||
"root_url": "http://localhost:8000", # url of backend
|
||||
"cors_allow_origin": "http://localhost:4200",
|
||||
}
|
|
@ -15,7 +15,7 @@ from socket import getfqdn
|
|||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def send_email(to, subject, sender='kibicara', body=''):
|
||||
def send_email(to, subject, sender="kibicara", body=""):
|
||||
"""E-Mail sender.
|
||||
|
||||
Sends an E-Mail to a specified recipient with a body
|
||||
|
@ -33,10 +33,10 @@ def send_email(to, subject, sender='kibicara', body=''):
|
|||
body (str): The body of the e-mail
|
||||
"""
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = 'Kibicara <{0}@{1}>'.format(sender, getfqdn())
|
||||
msg['To'] = to
|
||||
msg['Subject'] = '[Kibicara] {0}'.format(subject)
|
||||
msg["From"] = "Kibicara <{0}@{1}>".format(sender, getfqdn())
|
||||
msg["To"] = to
|
||||
msg["Subject"] = "[Kibicara] {0}".format(subject)
|
||||
msg.attach(MIMEText(body))
|
||||
|
||||
with SMTP('localhost') as smtp:
|
||||
with SMTP("localhost") as smtp:
|
||||
smtp.send_message(msg)
|
122
backend/src/kibicara/kibicara.py
Normal file
122
backend/src/kibicara/kibicara.py
Normal file
|
@ -0,0 +1,122 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Entrypoint of Kibicara."""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from asyncio import run as asyncio_run
|
||||
from logging import DEBUG, INFO, WARNING, basicConfig, getLogger
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config
|
||||
from pytoml import load
|
||||
from tortoise import Tortoise
|
||||
|
||||
from kibicara.config import config
|
||||
from kibicara.platformapi import Spawner
|
||||
from kibicara.webapi import router
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Main:
|
||||
"""Entrypoint for Kibicara.
|
||||
|
||||
Initializes the platform bots and starts the hypercorn webserver serving the
|
||||
Kibicara application and the specified frontend on port 8000.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--config",
|
||||
dest="configfile",
|
||||
default="/etc/kibicara.conf",
|
||||
help="path to config file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="count",
|
||||
help="Raise verbosity level",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
with open(args.configfile) as configfile:
|
||||
config.update(load(configfile))
|
||||
except FileNotFoundError:
|
||||
# run with default config
|
||||
pass
|
||||
|
||||
LOGLEVELS = {
|
||||
None: WARNING,
|
||||
1: INFO,
|
||||
2: DEBUG,
|
||||
}
|
||||
basicConfig(
|
||||
level=LOGLEVELS.get(args.verbose, DEBUG),
|
||||
format="%(asctime)s %(name)s %(message)s",
|
||||
)
|
||||
getLogger("aiosqlite").setLevel(WARNING)
|
||||
asyncio_run(self.__run())
|
||||
|
||||
async def __run(self):
|
||||
await Tortoise.init(
|
||||
db_url=config["database_connection"],
|
||||
modules={
|
||||
"models": [
|
||||
"kibicara.model",
|
||||
"kibicara.platforms.email.model",
|
||||
"kibicara.platforms.mastodon.model",
|
||||
"kibicara.platforms.telegram.model",
|
||||
"kibicara.platforms.test.model",
|
||||
"kibicara.platforms.twitter.model",
|
||||
]
|
||||
},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
await Spawner.init_all()
|
||||
await self.__start_webserver()
|
||||
await Tortoise.close_connections()
|
||||
|
||||
async def __start_webserver(self):
|
||||
class SinglePageApplication(StaticFiles):
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
if response.status_code == 404:
|
||||
response = await super().get_response(".", scope)
|
||||
return response
|
||||
|
||||
app = FastAPI()
|
||||
server_config = Config()
|
||||
server_config.accesslog = "-"
|
||||
server_config.behind_proxy = config["behind_proxy"]
|
||||
server_config.keyfile = config["keyfile"]
|
||||
server_config.certfile = config["certfile"]
|
||||
if config["production"]:
|
||||
server_config.bind = ["0.0.0.0:8000", "[::]:8000"]
|
||||
api = FastAPI()
|
||||
api.include_router(router)
|
||||
app.mount("/api", api)
|
||||
if not config["production"] and config["cors_allow_origin"]:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=config["cors_allow_origin"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
if config["frontend_path"] is not None:
|
||||
app.mount(
|
||||
"/",
|
||||
app=SinglePageApplication(directory=config["frontend_path"], html=True),
|
||||
)
|
||||
await serve(app, server_config)
|
225
backend/src/kibicara/migratefromticketfrei.py
Normal file
225
backend/src/kibicara/migratefromticketfrei.py
Normal file
|
@ -0,0 +1,225 @@
|
|||
import sqlite3
|
||||
import argparse
|
||||
from time import time, sleep
|
||||
from os import urandom
|
||||
from tortoise import Tortoise
|
||||
from tortoise.exceptions import DoesNotExist
|
||||
import asyncio
|
||||
from mastodon import Mastodon
|
||||
|
||||
from kibicara.model import Admin, Hood, IncludePattern, ExcludePattern
|
||||
from kibicara.platforms.mastodon.model import MastodonInstance, MastodonAccount
|
||||
from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
|
||||
from kibicara.platforms.email.model import Email, EmailSubscriber
|
||||
from kibicara.platforms.twitter.model import Twitter
|
||||
|
||||
|
||||
class OldDataBase:
|
||||
def __init__(self, old_database_path):
|
||||
self.conn = sqlite3.connect(old_database_path)
|
||||
self.cur = self.conn.cursor()
|
||||
|
||||
def execute(self, *args, **kwargs):
|
||||
return self.cur.execute(*args, **kwargs)
|
||||
|
||||
def commit(self):
|
||||
start_time = time()
|
||||
while 1:
|
||||
try:
|
||||
self.conn.commit()
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
# another thread may be writing, give it a chance to finish
|
||||
sleep(0.1)
|
||||
if time() - start_time > 5:
|
||||
# if it takes this long, something is wrong
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
|
||||
|
||||
class Main:
|
||||
def __init__(self):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"old_database_path", help="path to the ticketfrei2 sqlite3 database"
|
||||
)
|
||||
parser.add_argument(
|
||||
"new_database_path",
|
||||
help="path to the ticketfrei3 sqlite3 database",
|
||||
default="ticketfrei3.sqlite",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# open old database
|
||||
self.old_db = OldDataBase(args.old_database_path)
|
||||
|
||||
# open new database
|
||||
asyncio.run(self.new_database(args.new_database_path))
|
||||
|
||||
async def new_database(self, new_database_path):
|
||||
await Tortoise.init(
|
||||
db_url=f"sqlite://{new_database_path}",
|
||||
modules={
|
||||
"models": [
|
||||
"kibicara.model",
|
||||
"kibicara.platforms.email.model",
|
||||
"kibicara.platforms.mastodon.model",
|
||||
"kibicara.platforms.telegram.model",
|
||||
"kibicara.platforms.test.model",
|
||||
"kibicara.platforms.twitter.model",
|
||||
]
|
||||
},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
|
||||
# read table per table and write it to new database.
|
||||
# mastodon instances
|
||||
old_mastodon_instances = self.old_db.execute(
|
||||
"SELECT * FROM mastodon_instances"
|
||||
).fetchall()
|
||||
for instance in old_mastodon_instances:
|
||||
url = instance[1]
|
||||
client_id = instance[2]
|
||||
client_secret = instance[3]
|
||||
await MastodonInstance.create(
|
||||
name=url, client_id=client_id, client_secret=client_secret
|
||||
)
|
||||
print(f"Created Mastodon Instance: {url}")
|
||||
|
||||
old_users = self.old_db.execute("SELECT * FROM user;").fetchall()
|
||||
for user in old_users:
|
||||
user_id = user[0]
|
||||
user_passhash = user[1]
|
||||
user_enabled = user[2]
|
||||
|
||||
if user_enabled == 0:
|
||||
print(f"skipping user {user_id}, inactive")
|
||||
|
||||
email = self.old_db.execute(
|
||||
"SELECT email FROM email WHERE user_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
try:
|
||||
await Admin.get(email=email)
|
||||
continue # skip if the user already exists.
|
||||
except DoesNotExist:
|
||||
admin = await Admin.create(email=email, passhash=user_passhash)
|
||||
|
||||
city = self.old_db.execute(
|
||||
"SELECT * FROM cities WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
city_name = city[2]
|
||||
city_markdown = city[3]
|
||||
hood = await Hood.create(name=city_name, landingpage=city_markdown)
|
||||
await hood.admins.add(admin)
|
||||
|
||||
print()
|
||||
print(
|
||||
"Migrated user %s with email %s for the city %s "
|
||||
"with the following accounts:" % (user_id, email, city_name)
|
||||
)
|
||||
|
||||
patterns = self.old_db.execute(
|
||||
"SELECT patterns FROM triggerpatterns WHERE user_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
for pattern in patterns.splitlines():
|
||||
await IncludePattern.create(hood=hood, pattern=pattern)
|
||||
|
||||
badwords = self.old_db.execute(
|
||||
"SELECT words FROM badwords WHERE user_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
for badword in badwords.splitlines():
|
||||
await ExcludePattern.create(hood=hood, pattern=badword)
|
||||
|
||||
mastodon_account = self.old_db.execute(
|
||||
"SELECT * FROM mastodon_accounts WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
if mastodon_account:
|
||||
instance_url = self.old_db.execute(
|
||||
"SELECT instance FROM mastodon_instances WHERE id=?",
|
||||
(mastodon_account[3],),
|
||||
).fetchone()[0]
|
||||
new_instance = await MastodonInstance.get(name=instance_url)
|
||||
access_token = mastodon_account[2]
|
||||
mastodon_enabled = mastodon_account[4]
|
||||
await MastodonAccount.create(
|
||||
hood=hood,
|
||||
instance=new_instance,
|
||||
access_token=access_token,
|
||||
enabled=mastodon_enabled,
|
||||
)
|
||||
await dismiss_notifications(hood)
|
||||
print(f"Mastodon: {instance_url}, {access_token}")
|
||||
|
||||
telegram_account = self.old_db.execute(
|
||||
"SELECT apikey, active FROM telegram_accounts WHERE user_id=?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if telegram_account[0] != "":
|
||||
telegram_apikey = telegram_account[0]
|
||||
telegram_enabled = telegram_account[1]
|
||||
telegram = await Telegram.create(
|
||||
hood=hood,
|
||||
api_token=telegram_apikey,
|
||||
enabled=telegram_enabled,
|
||||
welcome_message="",
|
||||
)
|
||||
telegram_subscribers = self.old_db.execute(
|
||||
"SELECT subscriber_id FROM telegram_subscribers WHERE user_id=?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
for subscriber in telegram_subscribers:
|
||||
subscriber_id = subscriber[0]
|
||||
await TelegramSubscriber.create(bot=telegram, user_id=subscriber_id)
|
||||
print(f"Telegram: {len(telegram_subscribers)} subscribers")
|
||||
|
||||
mail_subscribers = self.old_db.execute(
|
||||
"SELECT email FROM mailinglist WHERE user_id=?", (user_id,)
|
||||
).fetchall()
|
||||
if mail_subscribers is not []:
|
||||
email_name = f"kibicara-{city_name}"
|
||||
email = await Email.create(
|
||||
hood=hood, name=email_name, secret=urandom(32).hex()
|
||||
)
|
||||
for subscriber in mail_subscribers:
|
||||
subscriber_email = subscriber[0]
|
||||
await EmailSubscriber.create(email=subscriber_email, hood=hood)
|
||||
print(f"E-Mail: {len(mail_subscribers)} subscribers")
|
||||
|
||||
twitter = self.old_db.execute(
|
||||
"SELECT * FROM twitter_accounts WHERE user_id=?", (user_id,)
|
||||
).fetchone()
|
||||
if twitter:
|
||||
client_id = twitter[2]
|
||||
client_secret = twitter[3]
|
||||
seen_tweet = self.old_db.execute(
|
||||
"SELECT tweet_id FROM seen_tweets WHERE user_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
await Twitter.create(
|
||||
hood=hood,
|
||||
dms_since_id=1, # didn't work in ticketfrei2 anyway
|
||||
mentions_since_id=seen_tweet,
|
||||
access_token=client_id,
|
||||
access_token_secret=client_secret,
|
||||
username=" ",
|
||||
verified=False,
|
||||
enabled=True,
|
||||
)
|
||||
print(f"Twitter: last ID: {seen_tweet}")
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
async def dismiss_notifications(hood):
|
||||
model = await MastodonAccount.get(hood=hood)
|
||||
await model.fetch_related("instance")
|
||||
account = Mastodon(
|
||||
client_id=model.instance.client_id,
|
||||
client_secret=model.instance.client_secret,
|
||||
api_base_url=model.instance.name,
|
||||
access_token=model.access_token,
|
||||
)
|
||||
notifications = account.notifications()
|
||||
for status in notifications:
|
||||
account.notifications_dismiss(status["id"])
|
||||
print(f"Dismissed {len(notifications)} notifications on Mastodon.")
|
57
backend/src/kibicara/model.py
Normal file
57
backend/src/kibicara/model.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""ORM Models for core."""
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
class Admin(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
email = fields.CharField(64, unique=True)
|
||||
passhash = fields.TextField()
|
||||
hoods: fields.ManyToManyRelation["Hood"] = fields.ManyToManyField(
|
||||
"models.Hood", related_name="admins", through="admin_hood_relations"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
table = "admins"
|
||||
|
||||
|
||||
class Hood(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
name = fields.CharField(64, unique=True)
|
||||
landingpage = fields.TextField()
|
||||
email_enabled = fields.BooleanField(default=True)
|
||||
admins: fields.ManyToManyRelation[Admin]
|
||||
include_patterns: fields.ReverseRelation["IncludePattern"]
|
||||
exclude_patterns: fields.ReverseRelation["ExcludePattern"]
|
||||
|
||||
class Meta:
|
||||
table = "hoods"
|
||||
|
||||
|
||||
class IncludePattern(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
|
||||
"models.Hood", related_name="include_patterns"
|
||||
)
|
||||
pattern = fields.TextField()
|
||||
|
||||
class Meta:
|
||||
table = "include_patterns"
|
||||
|
||||
|
||||
class ExcludePattern(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
|
||||
"models.Hood", related_name="exclude_patterns"
|
||||
)
|
||||
pattern = fields.TextField()
|
||||
|
||||
class Meta:
|
||||
table = "exclude_patterns"
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
|
@ -6,12 +6,15 @@
|
|||
|
||||
"""API classes for implementing bots for platforms."""
|
||||
|
||||
from asyncio import Queue, create_task
|
||||
from asyncio import Queue, Task, create_task
|
||||
from enum import Enum, auto
|
||||
from logging import getLogger
|
||||
from re import IGNORECASE, search
|
||||
from typing import Generic, Optional, Type, TypeVar
|
||||
|
||||
from kibicara.model import BadWord, Trigger
|
||||
from tortoise.models import Model
|
||||
|
||||
from kibicara.model import ExcludePattern, Hood, IncludePattern
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
@ -29,7 +32,7 @@ class Message:
|
|||
**kwargs (object, optional): Other platform-specific data.
|
||||
"""
|
||||
|
||||
def __init__(self, text, **kwargs):
|
||||
def __init__(self, text: str, **kwargs) -> None:
|
||||
self.text = text
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
|
@ -71,30 +74,31 @@ class Censor:
|
|||
hood (Hood): A Hood Model object
|
||||
"""
|
||||
|
||||
__instances = {}
|
||||
__instances: dict[int, list["Censor"]] = {}
|
||||
|
||||
def __init__(self, hood):
|
||||
def __init__(self, hood: Hood) -> None:
|
||||
self.hood = hood
|
||||
self.enabled = True
|
||||
self._inbox = Queue()
|
||||
self.__task = None
|
||||
self._inbox: Queue[Message] = Queue()
|
||||
self.__task: Optional[Task[None]] = None
|
||||
self.__hood_censors = self.__instances.setdefault(hood.id, [])
|
||||
self.__hood_censors.append(self)
|
||||
self.status = BotStatus.INSTANTIATED
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
"""Start the bot."""
|
||||
if self.__task is None:
|
||||
self.__task = create_task(self.__run())
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
"""Stop the bot."""
|
||||
if self.__task is not None:
|
||||
self.__task.cancel()
|
||||
|
||||
async def __run(self):
|
||||
await self.hood.load()
|
||||
self.__task.set_name('{0} {1}'.format(self.__class__.__name__, self.hood.name))
|
||||
async def __run(self) -> None:
|
||||
assert self.__task is not None
|
||||
await self.hood.refresh_from_db()
|
||||
self.__task.set_name("{0} {1}".format(self.__class__.__name__, self.hood.name))
|
||||
try:
|
||||
self.status = BotStatus.RUNNING
|
||||
await self.run()
|
||||
|
@ -104,7 +108,7 @@ class Censor:
|
|||
self.__task = None
|
||||
self.status = BotStatus.STOPPED
|
||||
|
||||
async def run(self):
|
||||
async def run(self) -> None:
|
||||
"""Entry point for a bot.
|
||||
|
||||
Note: Override this in the derived bot class.
|
||||
|
@ -112,14 +116,14 @@ class Censor:
|
|||
pass
|
||||
|
||||
@classmethod
|
||||
async def destroy_hood(cls, hood):
|
||||
async def destroy_hood(cls, hood: Hood) -> None:
|
||||
"""Remove all of its database entries.
|
||||
|
||||
Note: Override this in the derived bot class.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def publish(self, message):
|
||||
async def publish(self, message: Message) -> bool:
|
||||
"""Distribute a message to the bots in a hood.
|
||||
|
||||
Args:
|
||||
|
@ -132,33 +136,37 @@ class Censor:
|
|||
await censor._inbox.put(message)
|
||||
return True
|
||||
|
||||
async def receive(self):
|
||||
async def receive(self) -> Message:
|
||||
"""Receive a message.
|
||||
|
||||
Returns (Message): Received message
|
||||
"""
|
||||
return await self._inbox.get()
|
||||
|
||||
async def __is_appropriate(self, message):
|
||||
for badword in await BadWord.objects.filter(hood=self.hood).all():
|
||||
if search(badword.pattern, message.text, IGNORECASE):
|
||||
logger.debug(
|
||||
'Matched bad word - dropped message: {0}'.format(message.text)
|
||||
async def __is_appropriate(self, message: Message) -> bool:
|
||||
for exclude in await ExcludePattern.filter(hood=self.hood):
|
||||
if search(exclude.pattern, message.text, IGNORECASE):
|
||||
logger.info(
|
||||
"Matched bad word - dropped message: {0}".format(message.text)
|
||||
)
|
||||
return False
|
||||
for trigger in await Trigger.objects.filter(hood=self.hood).all():
|
||||
if search(trigger.pattern, message.text, IGNORECASE):
|
||||
logger.debug(
|
||||
'Matched trigger - passed message: {0}'.format(message.text)
|
||||
for include in await IncludePattern.filter(hood=self.hood):
|
||||
if search(include.pattern, message.text, IGNORECASE):
|
||||
logger.info(
|
||||
"Matched trigger - passed message: {0}".format(message.text)
|
||||
)
|
||||
return True
|
||||
logger.debug(
|
||||
'Did not match any trigger - dropped message: {0}'.format(message.text)
|
||||
logger.info(
|
||||
"Did not match any trigger - dropped message: {0}".format(message.text)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class Spawner:
|
||||
ORMClass = TypeVar("ORMClass", bound=Model)
|
||||
BotClass = TypeVar("BotClass", bound=Censor)
|
||||
|
||||
|
||||
class Spawner(Generic[ORMClass, BotClass]):
|
||||
"""Spawns a bot with a specific bot model.
|
||||
|
||||
Examples:
|
||||
|
@ -177,22 +185,22 @@ class Spawner:
|
|||
BotClass (Censor subclass): A Bot Class object
|
||||
"""
|
||||
|
||||
__instances = []
|
||||
__instances: list["Spawner"] = []
|
||||
|
||||
def __init__(self, ORMClass, BotClass):
|
||||
self.ORMClass = ORMClass
|
||||
self.BotClass = BotClass
|
||||
self.__bots = {}
|
||||
def __init__(self, orm_class: Type[ORMClass], bot_class: Type[BotClass]) -> None:
|
||||
self.ORMClass = orm_class
|
||||
self.BotClass = bot_class
|
||||
self.__bots: dict[int, BotClass] = {}
|
||||
self.__instances.append(self)
|
||||
|
||||
@classmethod
|
||||
async def init_all(cls):
|
||||
async def init_all(cls) -> None:
|
||||
"""Instantiate and start a bot for every row in the corresponding ORM model."""
|
||||
for spawner in cls.__instances:
|
||||
await spawner._init()
|
||||
|
||||
@classmethod
|
||||
async def destroy_hood(cls, hood):
|
||||
async def destroy_hood(cls, hood: Hood) -> None:
|
||||
for spawner in cls.__instances:
|
||||
for pk in list(spawner.__bots):
|
||||
bot = spawner.__bots[pk]
|
||||
|
@ -201,16 +209,16 @@ class Spawner:
|
|||
bot.stop()
|
||||
await spawner.BotClass.destroy_hood(hood)
|
||||
|
||||
async def _init(self):
|
||||
for item in await self.ORMClass.objects.all():
|
||||
async def _init(self) -> None:
|
||||
async for item in self.ORMClass.all():
|
||||
self.start(item)
|
||||
|
||||
def start(self, item):
|
||||
def start(self, item: ORMClass) -> None:
|
||||
"""Instantiate and start a bot with the provided ORM object.
|
||||
|
||||
Example:
|
||||
```
|
||||
xyz = await XYZ.objects.create(hood=hood, **values.__dict__)
|
||||
xyz = await XYZ.create(hood=hood, **values.__dict__)
|
||||
spawner.start(xyz)
|
||||
```
|
||||
|
||||
|
@ -221,7 +229,7 @@ class Spawner:
|
|||
if bot.enabled:
|
||||
bot.start()
|
||||
|
||||
def stop(self, item):
|
||||
def stop(self, item: ORMClass) -> None:
|
||||
"""Stop and delete a bot.
|
||||
|
||||
Args:
|
||||
|
@ -231,10 +239,10 @@ class Spawner:
|
|||
if bot is not None:
|
||||
bot.stop()
|
||||
|
||||
def get(self, item):
|
||||
def get(self, item: ORMClass) -> BotClass:
|
||||
"""Get a running bot.
|
||||
|
||||
Args:
|
||||
item (ORM Model object): ORM object corresponding to bot.
|
||||
"""
|
||||
return self.__bots.get(item.pk)
|
||||
return self.__bots[item.pk]
|
|
@ -1,6 +1,6 @@
|
|||
# Copyright (C) 2020 by Maike <maike@systemli.org>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
@ -12,7 +12,7 @@ from kibicara import email
|
|||
from kibicara.config import config
|
||||
from kibicara.model import Hood
|
||||
from kibicara.platformapi import Censor, Spawner
|
||||
from kibicara.platforms.email.model import Email, EmailSubscribers
|
||||
from kibicara.platforms.email.model import Email, EmailSubscriber
|
||||
from kibicara.webapi.admin import to_token
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
@ -26,9 +26,9 @@ class EmailBot(Censor):
|
|||
@classmethod
|
||||
async def destroy_hood(cls, hood):
|
||||
"""Removes all its database entries."""
|
||||
for inbox in await Email.objects.filter(hood=hood).all():
|
||||
for inbox in await Email.filter(hood=hood).all():
|
||||
await inbox.delete()
|
||||
for subscriber in await EmailSubscribers.objects.filter(hood=hood).all():
|
||||
for subscriber in await EmailSubscriber.filter(hood=hood).all():
|
||||
await subscriber.delete()
|
||||
|
||||
async def run(self):
|
||||
|
@ -36,28 +36,26 @@ class EmailBot(Censor):
|
|||
while True:
|
||||
message = await self.receive()
|
||||
logger.debug(
|
||||
'Received message from censor ({0}): {1}'.format(
|
||||
"Received message from censor ({0}): {1}".format(
|
||||
self.hood.name, message.text
|
||||
)
|
||||
)
|
||||
for subscriber in await EmailSubscribers.objects.filter(
|
||||
hood=self.hood
|
||||
).all():
|
||||
for subscriber in await EmailSubscriber.filter(hood=self.hood).all():
|
||||
token = to_token(email=subscriber.email, hood=self.hood.id)
|
||||
body = (
|
||||
'{0}\n\n--\n'
|
||||
+ 'If you want to stop receiving these mails,'
|
||||
+ 'follow this link: {1}/hoods/{2}/email-unsubscribe?token={3}'
|
||||
).format(message.text, config['frontend_url'], self.hood.id, token)
|
||||
"{0}\n\n--\n"
|
||||
+ "If you want to stop receiving these mails,"
|
||||
+ "follow this link: {1}/hoods/{2}/email-unsubscribe?token={3}"
|
||||
).format(message.text, config["frontend_url"], self.hood.id, token)
|
||||
try:
|
||||
logger.debug('Trying to send: \n{0}'.format(body))
|
||||
logger.debug("Trying to send: \n{0}".format(body))
|
||||
email.send_email(
|
||||
subscriber.email,
|
||||
'Kibicara {0}'.format(self.hood.name),
|
||||
"Kibicara {0}".format(self.hood.name),
|
||||
body=body,
|
||||
)
|
||||
except (ConnectionRefusedError, SMTPException):
|
||||
logger.exception('Sending email to subscriber failed.')
|
||||
logger.exception("Sending email to subscriber failed.")
|
||||
|
||||
|
||||
spawner = Spawner(Hood, EmailBot)
|
108
backend/src/kibicara/platforms/email/mda.py
Normal file
108
backend/src/kibicara/platforms/email/mda.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
# Copyright (C) 2020 by Maike <maike@systemli.org>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from asyncio import run as asyncio_run
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
from email.utils import parseaddr
|
||||
from logging import getLogger
|
||||
from re import sub
|
||||
from sys import stdin
|
||||
|
||||
from fastapi import status
|
||||
from ormantic import NoMatch
|
||||
from pytoml import load
|
||||
from requests import post
|
||||
|
||||
from kibicara.config import config
|
||||
from kibicara.platforms.email.model import Email, EmailSubscriber
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Main:
|
||||
def __init__(self):
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--config",
|
||||
dest="configfile",
|
||||
default="/etc/kibicara.conf",
|
||||
help="path to config file",
|
||||
)
|
||||
# the MDA passes the recipient address as command line argument
|
||||
parser.add_argument("recipient")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
with open(args.configfile) as configfile:
|
||||
config.update(load(configfile))
|
||||
except FileNotFoundError:
|
||||
# run with default config
|
||||
pass
|
||||
|
||||
# extract email from the recipient
|
||||
email_name = args.recipient.lower()
|
||||
|
||||
asyncio_run(self.__run(email_name))
|
||||
|
||||
async def __run(self, email_name):
|
||||
try:
|
||||
email = await Email.get(name=email_name)
|
||||
except NoMatch:
|
||||
logger.error("No recipient with this name")
|
||||
exit(1)
|
||||
|
||||
# read mail from STDIN and parse to EmailMessage object
|
||||
message = BytesParser(policy=default).parsebytes(stdin.buffer.read())
|
||||
|
||||
sender = ""
|
||||
if message.get("sender"):
|
||||
sender = message.get("sender")
|
||||
elif message.get("from"):
|
||||
sender = message.get("from")
|
||||
else:
|
||||
logger.error("No Sender of From header")
|
||||
exit(1)
|
||||
|
||||
sender = parseaddr(sender)[1]
|
||||
if not sender:
|
||||
logger.error("Could not parse sender")
|
||||
exit(1)
|
||||
|
||||
maybe_subscriber = await EmailSubscriber.filter(email=sender).all()
|
||||
if len(maybe_subscriber) != 1 or maybe_subscriber[0].hood.id != email.hood.id:
|
||||
logger.error("Not a subscriber")
|
||||
exit(1)
|
||||
|
||||
# extract relevant data from mail
|
||||
text = sub(
|
||||
r"<[^>]*>",
|
||||
"",
|
||||
message.get_body(preferencelist=("plain", "html")).get_content(),
|
||||
)
|
||||
|
||||
response = post(
|
||||
"{0}/api/hoods/{1}/email/messages/".format(
|
||||
config["root_url"], email.hood.pk
|
||||
),
|
||||
json={"text": text, "secret": email.secret},
|
||||
)
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
exit(0)
|
||||
elif response.status_code == status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS:
|
||||
logger.error("Message was't accepted: {0}".format(text))
|
||||
elif response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY:
|
||||
logger.error("Malformed request: {0}".format(response.json()))
|
||||
elif response.status_code == status.HTTP_401_UNAUTHORIZED:
|
||||
logger.error("Wrong API secret. kibicara_mda seems to be misconfigured")
|
||||
else:
|
||||
logger.error(
|
||||
"REST-API failed with response status {0}".format(response.status_code)
|
||||
)
|
||||
exit(1)
|
38
backend/src/kibicara/platforms/email/model.py
Normal file
38
backend/src/kibicara/platforms/email/model.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Copyright (C) 2020 by Maike <maike@systemli.org>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from kibicara.model import Hood
|
||||
|
||||
|
||||
class Email(Model):
|
||||
"""This table is used to track the names. It also stores the token secret."""
|
||||
|
||||
id = fields.IntField(pk=True)
|
||||
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
|
||||
"models.Hood", related_name="platforms_email", unique=True
|
||||
)
|
||||
name = fields.CharField(32, unique=True)
|
||||
secret = fields.TextField()
|
||||
|
||||
class Meta:
|
||||
table = "platforms_email"
|
||||
|
||||
|
||||
class EmailSubscriber(Model):
|
||||
"""This table stores all subscribers, who want to receive messages via email."""
|
||||
|
||||
id = fields.IntField(pk=True)
|
||||
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
|
||||
"models.Hood", related_name="platforms_email_subscribers"
|
||||
)
|
||||
email = fields.CharField(64, unique=True)
|
||||
|
||||
class Meta:
|
||||
table = "platforms_email_subscribers"
|
|
@ -1,6 +1,6 @@
|
|||
# Copyright (C) 2020 by Maike <maike@systemli.org>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
@ -8,18 +8,18 @@
|
|||
from logging import getLogger
|
||||
from os import urandom
|
||||
from smtplib import SMTPException
|
||||
from sqlite3 import IntegrityError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from nacl import exceptions
|
||||
from ormantic.exceptions import NoMatch
|
||||
from pydantic import BaseModel, validator
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara import email
|
||||
from kibicara.config import config
|
||||
from kibicara.model import Hood
|
||||
from kibicara.platformapi import Message
|
||||
from kibicara.platforms.email.bot import spawner
|
||||
from kibicara.platforms.email.model import Email, EmailSubscribers
|
||||
from kibicara.platforms.email.model import Email, EmailSubscriber
|
||||
from kibicara.webapi.admin import from_token, to_token
|
||||
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
|
||||
|
||||
|
@ -29,10 +29,10 @@ logger = getLogger(__name__)
|
|||
class BodyEmail(BaseModel):
|
||||
name: str
|
||||
|
||||
@validator('name')
|
||||
@validator("name")
|
||||
def valid_prefix(cls, value):
|
||||
if not value.startswith('kibicara-'):
|
||||
raise ValueError('Recipient address didn\'t start with kibicara-')
|
||||
if not value.startswith("kibicara-"):
|
||||
raise ValueError("Recipient address didn't start with kibicara-")
|
||||
return value
|
||||
|
||||
|
||||
|
@ -53,7 +53,7 @@ class BodySubscriber(BaseModel):
|
|||
email: str
|
||||
|
||||
|
||||
async def get_email(email_id: int, hood=Depends(get_hood)):
|
||||
async def get_email(email_id: int, hood: Hood = Depends(get_hood)):
|
||||
"""Get Email row by hood.
|
||||
|
||||
You can specify an email_id to nail it down, but it works without as well.
|
||||
|
@ -62,16 +62,16 @@ async def get_email(email_id: int, hood=Depends(get_hood)):
|
|||
:return: Email row of the found email bot.
|
||||
"""
|
||||
try:
|
||||
return await Email.objects.get(id=email_id, hood=hood)
|
||||
except NoMatch:
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await Email.get(id=email_id, hood=hood)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
async def get_subscriber(subscriber_id: int, hood=Depends(get_hood)):
|
||||
async def get_subscriber(subscriber_id: int, hood: Hood = Depends(get_hood)):
|
||||
try:
|
||||
return await EmailSubscribers.objects.get(id=subscriber_id, hood=hood)
|
||||
except NoMatch:
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await EmailSubscriber.get(id=subscriber_id, hood=hood)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
# registers the routes, gets imported in /kibicara/webapi/__init__.py
|
||||
|
@ -79,31 +79,31 @@ router = APIRouter()
|
|||
|
||||
|
||||
@router.get(
|
||||
'/public',
|
||||
"/public",
|
||||
# TODO response_model
|
||||
operation_id='get_emails_public',
|
||||
operation_id="get_emails_public",
|
||||
)
|
||||
async def email_read_all_public(hood=Depends(get_hood_unauthorized)):
|
||||
async def email_read_all_public(hood: Hood = Depends(get_hood_unauthorized)):
|
||||
if hood.email_enabled:
|
||||
emails = await Email.objects.filter(hood=hood).all()
|
||||
emails = await Email.filter(hood=hood)
|
||||
return [BodyEmailPublic(name=email.name) for email in emails]
|
||||
return []
|
||||
|
||||
|
||||
@router.get(
|
||||
'/',
|
||||
"/",
|
||||
# TODO response_model
|
||||
operation_id='get_emails',
|
||||
operation_id="get_emails",
|
||||
)
|
||||
async def email_read_all(hood=Depends(get_hood)):
|
||||
return await Email.objects.filter(hood=hood).select_related('hood').all()
|
||||
async def email_read_all(hood: Hood = Depends(get_hood)):
|
||||
return await Email.filter(hood=hood)
|
||||
|
||||
|
||||
@router.post(
|
||||
'/',
|
||||
"/",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
# TODO response_model
|
||||
operation_id='create_email',
|
||||
operation_id="create_email",
|
||||
)
|
||||
async def email_create(values: BodyEmail, response: Response, hood=Depends(get_hood)):
|
||||
"""Create an Email bot. Call this when creating a hood.
|
||||
|
@ -112,30 +112,30 @@ async def email_create(values: BodyEmail, response: Response, hood=Depends(get_h
|
|||
:return: Email row of the new email bot.
|
||||
"""
|
||||
try:
|
||||
email = await Email.objects.create(
|
||||
email = await Email.create(
|
||||
hood=hood, secret=urandom(32).hex(), **values.__dict__
|
||||
)
|
||||
response.headers['Location'] = str(hood.id)
|
||||
response.headers["Location"] = str(hood.id)
|
||||
return email
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
|
||||
@router.get(
|
||||
'/status',
|
||||
"/status",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id='status_email',
|
||||
operation_id="status_email",
|
||||
)
|
||||
async def email_status(hood=Depends(get_hood)):
|
||||
return {'status': spawner.get(hood).status.name}
|
||||
return {"status": spawner.get(hood).status.name}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/start',
|
||||
"/start",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id='start_email',
|
||||
operation_id="start_email",
|
||||
)
|
||||
async def email_start(hood=Depends(get_hood)):
|
||||
await hood.update(email_enabled=True)
|
||||
|
@ -144,10 +144,10 @@ async def email_start(hood=Depends(get_hood)):
|
|||
|
||||
|
||||
@router.post(
|
||||
'/stop',
|
||||
"/stop",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id='stop_email',
|
||||
operation_id="stop_email",
|
||||
)
|
||||
async def email_stop(hood=Depends(get_hood)):
|
||||
await hood.update(email_enabled=False)
|
||||
|
@ -156,16 +156,16 @@ async def email_stop(hood=Depends(get_hood)):
|
|||
|
||||
|
||||
@router.get(
|
||||
'/{email_id}',
|
||||
"/{email_id}",
|
||||
# TODO response_model
|
||||
operation_id='get_email',
|
||||
operation_id="get_email",
|
||||
)
|
||||
async def email_read(email=Depends(get_email)):
|
||||
return email
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{email_id}', status_code=status.HTTP_204_NO_CONTENT, operation_id='delete_email'
|
||||
"/{email_id}", status_code=status.HTTP_204_NO_CONTENT, operation_id="delete_email"
|
||||
)
|
||||
async def email_delete(email=Depends(get_email)):
|
||||
"""Delete an Email bot.
|
||||
|
@ -179,9 +179,9 @@ async def email_delete(email=Depends(get_email)):
|
|||
|
||||
|
||||
@router.post(
|
||||
'/subscribe/',
|
||||
"/subscribe/",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
operation_id='subscribe',
|
||||
operation_id="subscribe",
|
||||
response_model=BaseModel,
|
||||
)
|
||||
async def email_subscribe(
|
||||
|
@ -194,37 +194,37 @@ async def email_subscribe(
|
|||
:return: Returns status code 200 after sending confirmation email.
|
||||
"""
|
||||
token = to_token(hood=hood.id, email=subscriber.email)
|
||||
confirm_link = '{0}/hoods/{1}/email-confirm?token={2}'.format(
|
||||
config['frontend_url'],
|
||||
confirm_link = "{0}/hoods/{1}/email-confirm?token={2}".format(
|
||||
config["frontend_url"],
|
||||
hood.id,
|
||||
token,
|
||||
)
|
||||
try:
|
||||
subs = await EmailSubscribers.objects.filter(email=subscriber.email).all()
|
||||
subs = await EmailSubscriber.filter(email=subscriber.email).all()
|
||||
if subs:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
email.send_email(
|
||||
subscriber.email,
|
||||
'Subscribe to Kibicara {0}'.format(hood.name),
|
||||
body='To confirm your subscription, follow this link: {0}'.format(
|
||||
"Subscribe to Kibicara {0}".format(hood.name),
|
||||
body="To confirm your subscription, follow this link: {0}".format(
|
||||
confirm_link
|
||||
),
|
||||
)
|
||||
return {}
|
||||
except ConnectionRefusedError:
|
||||
logger.info(token)
|
||||
logger.error('Sending subscription confirmation email failed.', exc_info=True)
|
||||
logger.error("Sending subscription confirmation email failed.", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY)
|
||||
except SMTPException:
|
||||
logger.info(token)
|
||||
logger.error('Sending subscription confirmation email failed.', exc_info=True)
|
||||
logger.error("Sending subscription confirmation email failed.", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
|
||||
@router.post(
|
||||
'/subscribe/confirm/{token}',
|
||||
"/subscribe/confirm/{token}",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
operation_id='confirm_subscriber',
|
||||
operation_id="confirm_subscriber",
|
||||
response_model=BaseModel,
|
||||
)
|
||||
async def email_subscribe_confirm(token, hood=Depends(get_hood_unauthorized)):
|
||||
|
@ -236,19 +236,19 @@ async def email_subscribe_confirm(token, hood=Depends(get_hood_unauthorized)):
|
|||
"""
|
||||
payload = from_token(token)
|
||||
# If token.hood and url.hood are different, raise an error:
|
||||
if hood.id is not payload['hood']:
|
||||
if hood.id is not payload["hood"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
await EmailSubscribers.objects.create(hood=hood.id, email=payload['email'])
|
||||
await EmailSubscriber.create(hood=hood, email=payload["email"])
|
||||
return {}
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/unsubscribe/{token}',
|
||||
"/unsubscribe/{token}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
operation_id='unsubscribe',
|
||||
operation_id="unsubscribe",
|
||||
)
|
||||
async def email_unsubscribe(token, hood=Depends(get_hood_unauthorized)):
|
||||
"""Remove a subscriber from the database when they click on an unsubscribe link.
|
||||
|
@ -257,45 +257,45 @@ async def email_unsubscribe(token, hood=Depends(get_hood_unauthorized)):
|
|||
:param hood: Hood the Email bot belongs to.
|
||||
"""
|
||||
try:
|
||||
logger.warning('token is: {0}'.format(token))
|
||||
logger.warning("token is: {0}".format(token))
|
||||
payload = from_token(token)
|
||||
# If token.hood and url.hood are different, raise an error:
|
||||
if hood.id is not payload['hood']:
|
||||
if hood.id is not payload["hood"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
subscriber = await EmailSubscribers.objects.filter(
|
||||
hood=payload['hood'], email=payload['email']
|
||||
subscriber = await EmailSubscriber.filter(
|
||||
hood=payload["hood"], email=payload["email"]
|
||||
).get()
|
||||
await subscriber.delete()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except NoMatch:
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
except exceptions.CryptoError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@router.get(
|
||||
'/subscribers/',
|
||||
"/subscribers/",
|
||||
# TODO response_model
|
||||
operation_id='get_subscribers',
|
||||
operation_id="get_subscribers",
|
||||
)
|
||||
async def subscribers_read_all(hood=Depends(get_hood)):
|
||||
return await EmailSubscribers.objects.filter(hood=hood).all()
|
||||
return await EmailSubscriber.filter(hood=hood).all()
|
||||
|
||||
|
||||
@router.get(
|
||||
'/subscribers/{subscriber_id}',
|
||||
"/subscribers/{subscriber_id}",
|
||||
# TODO response_model
|
||||
operation_id='get_subscriber',
|
||||
operation_id="get_subscriber",
|
||||
)
|
||||
async def subscribers_read(subscriber=Depends(get_subscriber)):
|
||||
return subscriber
|
||||
|
||||
|
||||
@router.post(
|
||||
'/messages/',
|
||||
"/messages/",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
# TODO response_model
|
||||
operation_id='send_message',
|
||||
operation_id="send_message",
|
||||
)
|
||||
async def email_message_create(
|
||||
message: BodyMessage, hood=Depends(get_hood_unauthorized)
|
||||
|
@ -306,18 +306,18 @@ async def email_message_create(
|
|||
:param hood: Hood the Email bot belongs to.
|
||||
:return: returns status code 201 if the message is accepted by the censor.
|
||||
"""
|
||||
for receiver in await Email.objects.filter(hood=hood).all():
|
||||
for receiver in await Email.filter(hood=hood).all():
|
||||
if message.secret == receiver.secret:
|
||||
# pass message.text to bot.py
|
||||
if await spawner.get(hood).publish(Message(message.text)):
|
||||
logger.warning('Message was accepted: {0}'.format(message.text))
|
||||
logger.warning("Message was accepted: {0}".format(message.text))
|
||||
return {}
|
||||
else:
|
||||
logger.warning('Message wasn\'t accepted: {0}'.format(message.text))
|
||||
logger.warning("Message wasn't accepted: {0}".format(message.text))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS
|
||||
)
|
||||
logger.warning(
|
||||
'Someone is trying to submit an email without the correct API secret'
|
||||
"Someone is trying to submit an email without the correct API secret"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
101
backend/src/kibicara/platforms/mastodon/bot.py
Normal file
101
backend/src/kibicara/platforms/mastodon/bot.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from asyncio import gather, get_event_loop, sleep
|
||||
from logging import getLogger
|
||||
import re
|
||||
|
||||
from mastodon import Mastodon, MastodonError
|
||||
|
||||
from kibicara.platformapi import Censor, Spawner, Message
|
||||
from kibicara.platforms.mastodon.model import MastodonAccount
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class MastodonBot(Censor):
|
||||
def __init__(self, mastodon_account_model):
|
||||
super().__init__(mastodon_account_model.hood)
|
||||
self.model = mastodon_account_model
|
||||
self.enabled = self.model.enabled
|
||||
self.polling_interval_sec = 60
|
||||
|
||||
@classmethod
|
||||
async def destroy_hood(cls, hood):
|
||||
"""Removes all its database entries."""
|
||||
for mastodon in await MastodonAccount.filter(hood=hood).all():
|
||||
await mastodon.delete()
|
||||
|
||||
async def run(self):
|
||||
try:
|
||||
await self.model.fetch_related("instance")
|
||||
self.account = Mastodon(
|
||||
client_id=self.model.instance.client_id,
|
||||
client_secret=self.model.instance.client_secret,
|
||||
api_base_url=self.model.instance.name,
|
||||
access_token=self.model.access_token,
|
||||
)
|
||||
account_details = await get_event_loop().run_in_executor(
|
||||
None, self.account.account_verify_credentials
|
||||
)
|
||||
if username := account_details.get("username"):
|
||||
await self.model.update(username=username)
|
||||
await gather(self.poll(), self.push())
|
||||
except Exception as e:
|
||||
logger.debug("Bot {0} threw an Error: {1}".format(self.model.hood.name, e))
|
||||
finally:
|
||||
logger.debug("Bot {0} stopped.".format(self.model.hood.name))
|
||||
|
||||
async def poll(self):
|
||||
"""Get new mentions and DMs from Mastodon"""
|
||||
while True:
|
||||
try:
|
||||
notifications = await get_event_loop().run_in_executor(
|
||||
None, self.account.notifications
|
||||
)
|
||||
except MastodonError as e:
|
||||
logger.warning("%s in hood %s" % (e, self.model.hood.name))
|
||||
continue
|
||||
for status in notifications:
|
||||
try:
|
||||
status_id = int(status["status"]["id"])
|
||||
except KeyError:
|
||||
self.account.notifications_dismiss(status["id"])
|
||||
continue # ignore notifications which don't have a status
|
||||
text = re.sub(r"<[^>]*>", "", status["status"]["content"])
|
||||
text = re.sub(
|
||||
"(?<=^|(?<=[^a-zA-Z0-9-_.]))@([A-Za-z]+[A-Za-z0-9-_]+)", "", text
|
||||
)
|
||||
logger.debug(
|
||||
"Mastodon in %s received toot #%s: %s"
|
||||
% (self.model.hood.name, status_id, text)
|
||||
)
|
||||
if status["status"]["visibility"] == "public":
|
||||
await self.publish(Message(text, toot_id=status_id))
|
||||
else:
|
||||
await self.publish(Message(text))
|
||||
await get_event_loop().run_in_executor(
|
||||
None, self.account.notifications_dismiss, status["id"]
|
||||
)
|
||||
await sleep(self.polling_interval_sec)
|
||||
|
||||
async def push(self):
|
||||
"""Push new Ticketfrei reports to Mastodon; if source is mastodon, boost it."""
|
||||
while True:
|
||||
message = await self.receive()
|
||||
if hasattr(message, "toot_id"):
|
||||
logger.debug("Boosting post %s: %s" % (message.toot_id, message.text))
|
||||
await get_event_loop().run_in_executor(
|
||||
None, self.account.status_reblog, message.toot_id
|
||||
)
|
||||
else:
|
||||
logger.debug("Posting message: %s" % (message.text,))
|
||||
await get_event_loop().run_in_executor(
|
||||
None, self.account.status_post, message.text
|
||||
)
|
||||
|
||||
|
||||
spawner = Spawner(MastodonAccount, MastodonBot)
|
36
backend/src/kibicara/platforms/mastodon/model.py
Normal file
36
backend/src/kibicara/platforms/mastodon/model.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from kibicara.model import Hood
|
||||
|
||||
|
||||
class MastodonInstance(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
name = fields.TextField()
|
||||
client_id = fields.TextField()
|
||||
client_secret = fields.TextField()
|
||||
accounts: fields.ReverseRelation["MastodonAccount"]
|
||||
|
||||
class Meta:
|
||||
table = "platforms_mastodon_instances"
|
||||
|
||||
|
||||
class MastodonAccount(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
|
||||
"models.Hood", related_name="platforms_mastodon"
|
||||
)
|
||||
instance: fields.ForeignKeyRelation[MastodonInstance] = fields.ForeignKeyField(
|
||||
"models.MastodonInstance", related_name="accounts"
|
||||
)
|
||||
access_token = fields.TextField()
|
||||
username = fields.TextField(null=True)
|
||||
enabled = fields.BooleanField()
|
||||
|
||||
class Meta:
|
||||
table = "platforms_mastodon_accounts"
|
185
backend/src/kibicara/platforms/mastodon/webapi.py
Normal file
185
backend/src/kibicara/platforms/mastodon/webapi.py
Normal file
|
@ -0,0 +1,185 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from asyncio import get_event_loop
|
||||
from logging import getLogger
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from mastodon import Mastodon, MastodonNetworkError
|
||||
from mastodon.errors import MastodonIllegalArgumentError
|
||||
from pydantic import BaseModel, validate_email, validator
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara.config import config
|
||||
from kibicara.model import Hood
|
||||
from kibicara.platforms.mastodon.bot import spawner
|
||||
from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance
|
||||
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BodyMastodonPublic(BaseModel):
|
||||
username: str
|
||||
instance: str
|
||||
|
||||
|
||||
class BodyMastodonAccount(BaseModel):
|
||||
email: str
|
||||
instance_url: str
|
||||
password: str
|
||||
|
||||
@validator("email")
|
||||
def validate_email(cls, value):
|
||||
return validate_email(value)
|
||||
|
||||
|
||||
async def get_mastodon(
|
||||
mastodon_id: int, hood: Hood = Depends(get_hood)
|
||||
) -> MastodonAccount:
|
||||
try:
|
||||
return await MastodonAccount.get(id=mastodon_id, hood=hood)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
async def get_mastodon_instance(instance_url: str) -> MastodonInstance:
|
||||
"""Return a MastodonInstance ORM object with valid client_id and client_secret.
|
||||
|
||||
:param: instance_url: the API base URL of the mastodon server
|
||||
:return the MastodonInstance ORM object
|
||||
"""
|
||||
try:
|
||||
return await MastodonInstance.get(name=instance_url)
|
||||
except DoesNotExist:
|
||||
app_name = config.get("frontend_url")
|
||||
client_id, client_secret = Mastodon.create_app(
|
||||
app_name, api_base_url=instance_url
|
||||
)
|
||||
await MastodonInstance.create(
|
||||
name=instance_url, client_id=client_id, client_secret=client_secret
|
||||
)
|
||||
return await MastodonInstance.get(name=instance_url)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
twitter_callback_router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public",
|
||||
# TODO response_model,
|
||||
operation_id="get_mastodons_public",
|
||||
)
|
||||
async def mastodon_read_all_public(hood=Depends(get_hood_unauthorized)):
|
||||
mbots = []
|
||||
async for mbot in MastodonAccount.filter(hood=hood).prefetch_related("instance"):
|
||||
if mbot.enabled == 1 and mbot.username:
|
||||
mbots.append(
|
||||
BodyMastodonPublic(username=mbot.username, instance=mbot.instance.name)
|
||||
)
|
||||
return mbots
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
# TODO response_model,
|
||||
operation_id="get_mastodons",
|
||||
)
|
||||
async def mastodon_read_all(hood=Depends(get_hood)):
|
||||
return await MastodonAccount.filter(hood=hood).all()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{mastodon_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
# TODO response_model
|
||||
operation_id="delete_mastodon",
|
||||
)
|
||||
async def mastodon_delete(mastodon=Depends(get_mastodon)):
|
||||
spawner.stop(mastodon)
|
||||
await mastodon.fetch_related("instance")
|
||||
object_with_instance = await MastodonAccount.filter(
|
||||
instance=mastodon.instance
|
||||
).all()
|
||||
if len(object_with_instance) == 1 and object_with_instance[0] == mastodon:
|
||||
await mastodon.instance.delete()
|
||||
await mastodon.delete()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{mastodon_id}/status",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id="status_mastodon",
|
||||
)
|
||||
async def mastodon_status(mastodon=Depends(get_mastodon)):
|
||||
return {"status": spawner.get(mastodon).status.name}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{mastodon_id}/start",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id="start_mastodon",
|
||||
)
|
||||
async def mastodon_start(mastodon=Depends(get_mastodon)):
|
||||
await mastodon.update(enabled=True)
|
||||
spawner.get(mastodon).start()
|
||||
return {}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{mastodon_id}/stop",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id="stop_mastodon",
|
||||
)
|
||||
async def mastodon_stop(mastodon=Depends(get_mastodon)):
|
||||
await mastodon.update(enabled=False)
|
||||
spawner.get(mastodon).stop()
|
||||
return {}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
# TODO response_model
|
||||
operation_id="create_mastodon",
|
||||
)
|
||||
async def mastodon_create(values: BodyMastodonAccount, hood=Depends(get_hood)):
|
||||
"""Add a Mastodon Account to a Ticketfrei account.
|
||||
|
||||
open questions:
|
||||
can the instance_url have different ways of writing?
|
||||
|
||||
:param: values: a BodyMastodonAccount object in json
|
||||
:param: hood: the hood ORM object
|
||||
"""
|
||||
try:
|
||||
instance = await get_mastodon_instance(values.instance_url)
|
||||
except MastodonNetworkError:
|
||||
raise HTTPException(422, "Invalid Mastodon Instance")
|
||||
account = Mastodon(
|
||||
instance.client_id, instance.client_secret, api_base_url=values.instance_url
|
||||
)
|
||||
try:
|
||||
access_token = await get_event_loop().run_in_executor(
|
||||
None, account.log_in, values.email, values.password
|
||||
)
|
||||
logger.debug(f"{access_token}")
|
||||
mastodon = await MastodonAccount.create(
|
||||
hood=hood, instance=instance, access_token=access_token, enabled=True
|
||||
)
|
||||
spawner.start(mastodon)
|
||||
return mastodon
|
||||
except MastodonIllegalArgumentError:
|
||||
logger.warning("Login to Mastodon failed.", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_422_INVALID_INPUT)
|
||||
except IntegrityError:
|
||||
logger.warning("Login to Mastodon failed.", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
|
@ -1,17 +1,17 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from asyncio import CancelledError, gather, sleep
|
||||
from logging import getLogger
|
||||
from sqlite3 import IntegrityError
|
||||
|
||||
from aiogram import Bot, Dispatcher, exceptions, types
|
||||
from ormantic.exceptions import NoMatch
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara.platformapi import Censor, Message, Spawner
|
||||
from kibicara.platforms.telegram.model import Telegram, TelegramUser
|
||||
from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
@ -25,16 +25,16 @@ class TelegramBot(Censor):
|
|||
@classmethod
|
||||
async def destroy_hood(cls, hood):
|
||||
"""Removes all its database entries."""
|
||||
for telegram in await Telegram.objects.filter(hood=hood).all():
|
||||
for user in await TelegramUser.objects.filter(bot=telegram).all():
|
||||
for telegram in await Telegram.filter(hood=hood).all():
|
||||
for user in await TelegramSubscriber.filter(bot=telegram).all():
|
||||
await user.delete()
|
||||
await telegram.delete()
|
||||
|
||||
def _create_dispatcher(self):
|
||||
dp = Dispatcher(self.bot)
|
||||
dp.register_message_handler(self._send_welcome, commands=['start'])
|
||||
dp.register_message_handler(self._remove_user, commands=['stop'])
|
||||
dp.register_message_handler(self._send_help, commands=['help'])
|
||||
dp.register_message_handler(self._send_welcome, commands=["start"])
|
||||
dp.register_message_handler(self._remove_user, commands=["stop"])
|
||||
dp.register_message_handler(self._send_help, commands=["help"])
|
||||
dp.register_message_handler(self._receive_message)
|
||||
return dp
|
||||
|
||||
|
@ -42,36 +42,34 @@ class TelegramBot(Censor):
|
|||
try:
|
||||
self.bot = Bot(token=self.telegram_model.api_token)
|
||||
self.dp = self._create_dispatcher()
|
||||
logger.debug('Bot {0} starting.'.format(self.telegram_model.hood.name))
|
||||
logger.debug("Bot {0} starting.".format(self.telegram_model.hood.name))
|
||||
user = await self.bot.get_me()
|
||||
if user.username:
|
||||
await self.telegram_model.update(username=user.username)
|
||||
await gather(self.dp.start_polling(), self._push())
|
||||
except CancelledError:
|
||||
logger.debug(
|
||||
'Bot {0} received Cancellation.'.format(self.telegram_model.hood.name)
|
||||
"Bot {0} received Cancellation.".format(self.telegram_model.hood.name)
|
||||
)
|
||||
self.dp = None
|
||||
raise
|
||||
except exceptions.ValidationError:
|
||||
logger.debug(
|
||||
'Bot {0} has invalid auth token.'.format(self.telegram_model.hood.name)
|
||||
"Bot {0} has invalid auth token.".format(self.telegram_model.hood.name)
|
||||
)
|
||||
await self.telegram_model.update(enabled=False)
|
||||
finally:
|
||||
logger.debug('Bot {0} stopped.'.format(self.telegram_model.hood.name))
|
||||
logger.debug("Bot {0} stopped.".format(self.telegram_model.hood.name))
|
||||
|
||||
async def _push(self):
|
||||
while True:
|
||||
message = await self.receive()
|
||||
logger.debug(
|
||||
'Received message from censor ({0}): {1}'.format(
|
||||
"Received message from censor ({0}): {1}".format(
|
||||
self.telegram_model.hood.name, message.text
|
||||
)
|
||||
)
|
||||
for user in await TelegramUser.objects.filter(
|
||||
bot=self.telegram_model
|
||||
).all():
|
||||
for user in await TelegramSubscriber.filter(bot=self.telegram_model).all():
|
||||
await self._send_message(user.user_id, message.text)
|
||||
|
||||
async def _send_message(self, user_id, message):
|
||||
|
@ -79,34 +77,34 @@ class TelegramBot(Censor):
|
|||
await self.bot.send_message(user_id, message, disable_notification=False)
|
||||
except exceptions.BotBlocked:
|
||||
logger.error(
|
||||
'Target [ID:{0}] ({1}): blocked by user'.format(
|
||||
"Target [ID:{0}] ({1}): blocked by user".format(
|
||||
user_id, self.telegram_model.hood.name
|
||||
)
|
||||
)
|
||||
except exceptions.ChatNotFound:
|
||||
logger.error(
|
||||
'Target [ID:{0}] ({1}): invalid user ID'.format(
|
||||
"Target [ID:{0}] ({1}): invalid user ID".format(
|
||||
user_id, self.telegram_model.hood.name
|
||||
)
|
||||
)
|
||||
except exceptions.RetryAfter as e:
|
||||
logger.error(
|
||||
'Target [ID:{0}] ({1}): Flood limit is exceeded.'.format(
|
||||
"Target [ID:{0}] ({1}): Flood limit is exceeded.".format(
|
||||
user_id, self.telegram_model.hood.name
|
||||
)
|
||||
+ 'Sleep {0} seconds.'.format(e.timeout)
|
||||
+ "Sleep {0} seconds.".format(e.timeout)
|
||||
)
|
||||
await sleep(e.timeout)
|
||||
return await self._send_message(user_id, message)
|
||||
except exceptions.UserDeactivated:
|
||||
logger.error(
|
||||
'Target [ID:{0}] ({1}): user is deactivated'.format(
|
||||
"Target [ID:{0}] ({1}): user is deactivated".format(
|
||||
user_id, self.telegram_model.hood.name
|
||||
)
|
||||
)
|
||||
except exceptions.TelegramAPIError:
|
||||
logger.exception(
|
||||
'Target [ID:{0}] ({1}): failed'.format(
|
||||
"Target [ID:{0}] ({1}): failed".format(
|
||||
user_id, self.telegram_model.hood.name
|
||||
)
|
||||
)
|
||||
|
@ -114,34 +112,34 @@ class TelegramBot(Censor):
|
|||
async def _send_welcome(self, message: types.Message):
|
||||
try:
|
||||
if message.from_user.is_bot:
|
||||
await message.reply('Error: Bots can not join here.')
|
||||
await message.reply("Error: Bots can not join here.")
|
||||
return
|
||||
await TelegramUser.objects.create(
|
||||
await TelegramSubscriber.create(
|
||||
user_id=message.from_user.id, bot=self.telegram_model
|
||||
)
|
||||
await message.reply(self.telegram_model.welcome_message)
|
||||
except IntegrityError:
|
||||
await message.reply('Error: You are already registered.')
|
||||
await message.reply("Error: You are already registered.")
|
||||
|
||||
async def _remove_user(self, message: types.Message):
|
||||
try:
|
||||
telegram_user = await TelegramUser.objects.get(
|
||||
telegram_user = await TelegramSubscriber.get(
|
||||
user_id=message.from_user.id, bot=self.telegram_model
|
||||
)
|
||||
await telegram_user.delete()
|
||||
await message.reply('You were removed successfully from this bot.')
|
||||
except NoMatch:
|
||||
await message.reply('Error: You are not subscribed to this bot.')
|
||||
await message.reply("You were removed successfully from this bot.")
|
||||
except DoesNotExist:
|
||||
await message.reply("Error: You are not subscribed to this bot.")
|
||||
|
||||
async def _send_help(self, message: types.Message):
|
||||
if message.from_user.is_bot:
|
||||
await message.reply('Error: Bots can\'t be helped.')
|
||||
await message.reply("Error: Bots can't be helped.")
|
||||
return
|
||||
await message.reply('Send messages here to broadcast them to your hood')
|
||||
await message.reply("Send messages here to broadcast them to your hood")
|
||||
|
||||
async def _receive_message(self, message: types.Message):
|
||||
if not message.text:
|
||||
await message.reply('Error: Only text messages are allowed.')
|
||||
await message.reply("Error: Only text messages are allowed.")
|
||||
return
|
||||
await self.publish(Message(message.text))
|
||||
|
36
backend/src/kibicara/platforms/telegram/model.py
Normal file
36
backend/src/kibicara/platforms/telegram/model.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from kibicara.model import Hood
|
||||
|
||||
|
||||
class Telegram(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
|
||||
"models.Hood", related_name="platforms_telegram"
|
||||
)
|
||||
api_token = fields.CharField(64, unique=True)
|
||||
welcome_message = fields.TextField()
|
||||
username = fields.TextField(null=True)
|
||||
enabled = fields.BooleanField(default=True)
|
||||
subscribers: fields.ReverseRelation["TelegramSubscriber"]
|
||||
|
||||
class Meta:
|
||||
table = "platforms_telegram"
|
||||
|
||||
|
||||
class TelegramSubscriber(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
bot: fields.ForeignKeyRelation[Telegram] = fields.ForeignKeyField(
|
||||
"models.Telegram", related_name="subscribers"
|
||||
)
|
||||
user_id = fields.IntField()
|
||||
|
||||
class Meta:
|
||||
table = "platforms_telegram_subscribers"
|
|
@ -1,19 +1,19 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from logging import getLogger
|
||||
from sqlite3 import IntegrityError
|
||||
|
||||
from aiogram import exceptions
|
||||
from aiogram.bot.api import check_token
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from ormantic.exceptions import NoMatch
|
||||
from pydantic import BaseModel, validator
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara.platforms.telegram.bot import spawner
|
||||
from kibicara.platforms.telegram.model import Telegram, TelegramUser
|
||||
from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
|
||||
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
@ -21,9 +21,9 @@ logger = getLogger(__name__)
|
|||
|
||||
class BodyTelegram(BaseModel):
|
||||
api_token: str
|
||||
welcome_message: str = 'Welcome!'
|
||||
welcome_message: str = "Welcome!"
|
||||
|
||||
@validator('api_token')
|
||||
@validator("api_token")
|
||||
def valid_api_token(cls, value):
|
||||
try:
|
||||
check_token(value)
|
||||
|
@ -38,8 +38,8 @@ class BodyTelegramPublic(BaseModel):
|
|||
|
||||
async def get_telegram(telegram_id: int, hood=Depends(get_hood)):
|
||||
try:
|
||||
return await Telegram.objects.get(id=telegram_id, hood=hood)
|
||||
except NoMatch:
|
||||
return await Telegram.get(id=telegram_id, hood=hood)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
|
@ -48,12 +48,12 @@ telegram_callback_router = APIRouter()
|
|||
|
||||
|
||||
@router.get(
|
||||
'/public',
|
||||
"/public",
|
||||
# TODO response_model,
|
||||
operation_id='get_telegrams_public',
|
||||
operation_id="get_telegrams_public",
|
||||
)
|
||||
async def telegram_read_all_public(hood=Depends(get_hood_unauthorized)):
|
||||
telegrambots = await Telegram.objects.filter(hood=hood).all()
|
||||
telegrambots = await Telegram.filter(hood=hood).all()
|
||||
return [
|
||||
BodyTelegramPublic(username=telegrambot.username)
|
||||
for telegrambot in telegrambots
|
||||
|
@ -62,59 +62,59 @@ async def telegram_read_all_public(hood=Depends(get_hood_unauthorized)):
|
|||
|
||||
|
||||
@router.get(
|
||||
'/',
|
||||
"/",
|
||||
# TODO response_model,
|
||||
operation_id='get_telegrams',
|
||||
operation_id="get_telegrams",
|
||||
)
|
||||
async def telegram_read_all(hood=Depends(get_hood)):
|
||||
return await Telegram.objects.filter(hood=hood).all()
|
||||
return await Telegram.filter(hood=hood).all()
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{telegram_id}',
|
||||
"/{telegram_id}",
|
||||
# TODO response_model,
|
||||
operation_id='get_telegram',
|
||||
operation_id="get_telegram",
|
||||
)
|
||||
async def telegram_read(telegram=Depends(get_telegram)):
|
||||
return telegram
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{telegram_id}',
|
||||
"/{telegram_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
operation_id='delete_telegram',
|
||||
operation_id="delete_telegram",
|
||||
)
|
||||
async def telegram_delete(telegram=Depends(get_telegram)):
|
||||
spawner.stop(telegram)
|
||||
for user in await TelegramUser.objects.filter(bot=telegram).all():
|
||||
for user in await TelegramSubscriber.filter(bot=telegram).all():
|
||||
await user.delete()
|
||||
await telegram.delete()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
'/',
|
||||
"/",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
# TODO response_model,
|
||||
operation_id='create_telegram',
|
||||
operation_id="create_telegram",
|
||||
)
|
||||
async def telegram_create(
|
||||
response: Response, values: BodyTelegram, hood=Depends(get_hood)
|
||||
):
|
||||
try:
|
||||
telegram = await Telegram.objects.create(hood=hood, **values.__dict__)
|
||||
telegram = await Telegram.create(hood=hood, **values.__dict__)
|
||||
spawner.start(telegram)
|
||||
response.headers['Location'] = str(telegram.id)
|
||||
response.headers["Location"] = str(telegram.id)
|
||||
return telegram
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{telegram_id}',
|
||||
"/{telegram_id}",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
# TODO response_model,
|
||||
operation_id='update_telegram',
|
||||
operation_id="update_telegram",
|
||||
)
|
||||
async def telegram_update(values: BodyTelegram, telegram=Depends(get_telegram)):
|
||||
try:
|
||||
|
@ -127,20 +127,20 @@ async def telegram_update(values: BodyTelegram, telegram=Depends(get_telegram)):
|
|||
|
||||
|
||||
@router.get(
|
||||
'/{telegram_id}/status',
|
||||
"/{telegram_id}/status",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model,
|
||||
operation_id='status_telegram',
|
||||
operation_id="status_telegram",
|
||||
)
|
||||
async def telegram_status(telegram=Depends(get_telegram)):
|
||||
return {'status': spawner.get(telegram).status.name}
|
||||
return {"status": spawner.get(telegram).status.name}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{telegram_id}/start',
|
||||
"/{telegram_id}/start",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model,
|
||||
operation_id='start_telegram',
|
||||
operation_id="start_telegram",
|
||||
)
|
||||
async def telegram_start(telegram=Depends(get_telegram)):
|
||||
await telegram.update(enabled=True)
|
||||
|
@ -149,10 +149,10 @@ async def telegram_start(telegram=Depends(get_telegram)):
|
|||
|
||||
|
||||
@router.post(
|
||||
'/{telegram_id}/stop',
|
||||
"/{telegram_id}/stop",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model,
|
||||
operation_id='stop_telegram',
|
||||
operation_id="stop_telegram",
|
||||
)
|
||||
async def telegram_stop(telegram=Depends(get_telegram)):
|
||||
await telegram.update(enabled=False)
|
|
@ -1,17 +1,17 @@
|
|||
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from kibicara.platformapi import Censor, Spawner
|
||||
from kibicara.platformapi import Censor, Message, Spawner
|
||||
from kibicara.platforms.test.model import Test
|
||||
|
||||
|
||||
class TestBot(Censor):
|
||||
def __init__(self, test):
|
||||
def __init__(self, test: Test):
|
||||
super().__init__(test.hood)
|
||||
self.messages = []
|
||||
self.messages: list[Message] = []
|
||||
|
||||
async def run(self):
|
||||
while True:
|
19
backend/src/kibicara/platforms/test/model.py
Normal file
19
backend/src/kibicara/platforms/test/model.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from kibicara.model import Hood
|
||||
|
||||
|
||||
class Test(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
|
||||
"models.Hood", related_name="platforms_test"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
table = "platforms_test"
|
67
backend/src/kibicara/platforms/test/webapi.py
Normal file
67
backend/src/kibicara/platforms/test/webapi.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara.model import Hood
|
||||
from kibicara.platformapi import Message
|
||||
from kibicara.platforms.test.bot import spawner
|
||||
from kibicara.platforms.test.model import Test
|
||||
from kibicara.webapi.hoods import get_hood
|
||||
|
||||
|
||||
class BodyMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
async def get_test(test_id: int, hood: Hood = Depends(get_hood)) -> Test:
|
||||
try:
|
||||
return await Test.get(id=test_id, hood=hood)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def test_read_all(hood: Hood = Depends(get_hood)):
|
||||
return await Test.filter(hood=hood)
|
||||
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||
async def test_create(response: Response, hood: Hood = Depends(get_hood)):
|
||||
try:
|
||||
test = await Test.create(hood=hood)
|
||||
spawner.start(test)
|
||||
response.headers["Location"] = str(test.id)
|
||||
return test
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
|
||||
@router.get("/{test_id}")
|
||||
async def test_read(test: Test = Depends(get_test)):
|
||||
return test
|
||||
|
||||
|
||||
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def test_delete(test: Test = Depends(get_test)):
|
||||
spawner.stop(test)
|
||||
await test.delete()
|
||||
|
||||
|
||||
@router.get("/{test_id}/messages/")
|
||||
async def test_message_read_all(test: Test = Depends(get_test)):
|
||||
return spawner.get(test).messages
|
||||
|
||||
|
||||
@router.post("/{test_id}/messages/")
|
||||
async def test_message_create(message: BodyMessage, test: Test = Depends(get_test)):
|
||||
await spawner.get(test).publish(Message(message.text))
|
||||
return {}
|
|
@ -1,5 +1,6 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
|
@ -27,58 +28,58 @@ class TwitterBot(Censor):
|
|||
@classmethod
|
||||
async def destroy_hood(cls, hood):
|
||||
"""Removes all its database entries."""
|
||||
for twitter in await Twitter.objects.filter(hood=hood).all():
|
||||
for twitter in await Twitter.filter(hood=hood).all():
|
||||
await twitter.delete()
|
||||
|
||||
async def run(self):
|
||||
try:
|
||||
if not self.twitter_model.verified:
|
||||
raise ValueError('Oauth Handshake not completed')
|
||||
raise ValueError("Oauth Handshake not completed")
|
||||
self.client = PeonyClient(
|
||||
consumer_key=config['twitter']['consumer_key'],
|
||||
consumer_secret=config['twitter']['consumer_secret'],
|
||||
consumer_key=config["twitter"]["consumer_key"],
|
||||
consumer_secret=config["twitter"]["consumer_secret"],
|
||||
access_token=self.twitter_model.access_token,
|
||||
access_token_secret=self.twitter_model.access_token_secret,
|
||||
)
|
||||
if self.twitter_model.mentions_since_id is None:
|
||||
logger.debug('since_id is None in model, fetch newest mention id')
|
||||
logger.debug("since_id is None in model, fetch newest mention id")
|
||||
await self._poll_mentions()
|
||||
if self.twitter_model.dms_since_id is None:
|
||||
logger.debug('since_id is None in model, fetch newest dm id')
|
||||
logger.debug("since_id is None in model, fetch newest dm id")
|
||||
await self._poll_direct_messages()
|
||||
user = await self.client.user
|
||||
if user.screen_name:
|
||||
await self.twitter_model.update(username=user.screen_name)
|
||||
logger.debug(
|
||||
'Starting Twitter bot: {0}'.format(self.twitter_model.__dict__)
|
||||
"Starting Twitter bot: {0}".format(self.twitter_model.__dict__)
|
||||
)
|
||||
await gather(self.poll(), self.push())
|
||||
except CancelledError:
|
||||
logger.debug(
|
||||
'Bot {0} received Cancellation.'.format(self.twitter_model.hood.name)
|
||||
"Bot {0} received Cancellation.".format(self.twitter_model.hood.name)
|
||||
)
|
||||
except exceptions.Unauthorized:
|
||||
logger.debug(
|
||||
'Bot {0} has invalid auth token.'.format(self.twitter_model.hood.name)
|
||||
"Bot {0} has invalid auth token.".format(self.twitter_model.hood.name)
|
||||
)
|
||||
await self.twitter_model.update(enabled=False)
|
||||
self.enabled = self.twitter_model.enabled
|
||||
except (KeyError, ValueError, exceptions.NotAuthenticated):
|
||||
logger.warning('Missing consumer_keys for Twitter in your configuration.')
|
||||
logger.warning("Missing consumer_keys for Twitter in your configuration.")
|
||||
await self.twitter_model.update(enabled=False)
|
||||
self.enabled = self.twitter_model.enabled
|
||||
finally:
|
||||
logger.debug('Bot {0} stopped.'.format(self.twitter_model.hood.name))
|
||||
logger.debug("Bot {0} stopped.".format(self.twitter_model.hood.name))
|
||||
|
||||
async def poll(self):
|
||||
while True:
|
||||
dms = await self._poll_direct_messages()
|
||||
logger.debug(
|
||||
'Polled dms ({0}): {1}'.format(self.twitter_model.hood.name, str(dms))
|
||||
"Polled dms ({0}): {1}".format(self.twitter_model.hood.name, str(dms))
|
||||
)
|
||||
mentions = await self._poll_mentions()
|
||||
logger.debug(
|
||||
'Polled mentions ({0}): {1}'.format(
|
||||
"Polled mentions ({0}): {1}".format(
|
||||
self.twitter_model.hood.name, str(mentions)
|
||||
)
|
||||
)
|
||||
|
@ -135,7 +136,7 @@ class TwitterBot(Censor):
|
|||
remove_indices.update(range(url.indices[0], url.indices[1] + 1))
|
||||
for symbol in entities.symbols:
|
||||
remove_indices.update(range(symbol.indices[0], symbol.indices[1] + 1))
|
||||
filtered_text = ''
|
||||
filtered_text = ""
|
||||
for index, character in enumerate(text):
|
||||
if index not in remove_indices:
|
||||
filtered_text += character
|
||||
|
@ -145,11 +146,11 @@ class TwitterBot(Censor):
|
|||
while True:
|
||||
message = await self.receive()
|
||||
logger.debug(
|
||||
'Received message from censor ({0}): {1}'.format(
|
||||
"Received message from censor ({0}): {1}".format(
|
||||
self.twitter_model.hood.name, message.text
|
||||
)
|
||||
)
|
||||
if hasattr(message, 'twitter_mention_id'):
|
||||
if hasattr(message, "twitter_mention_id"):
|
||||
await self._retweet(message.twitter_mention_id)
|
||||
else:
|
||||
await self._post_tweet(message.text)
|
27
backend/src/kibicara/platforms/twitter/model.py
Normal file
27
backend/src/kibicara/platforms/twitter/model.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from kibicara.model import Hood
|
||||
|
||||
|
||||
class Twitter(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
|
||||
"models.Hood", related_name="platforms_twitter"
|
||||
)
|
||||
dms_since_id = fields.IntField()
|
||||
mentions_since_id = fields.IntField()
|
||||
access_token = fields.TextField()
|
||||
access_token_secret = fields.TextField()
|
||||
username = fields.TextField()
|
||||
verified = fields.BooleanField(default=False)
|
||||
enabled = fields.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
table = "platforms_twitter"
|
|
@ -1,16 +1,16 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from logging import getLogger
|
||||
from sqlite3 import IntegrityError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from ormantic.exceptions import NoMatch
|
||||
from peony.exceptions import NotAuthenticated
|
||||
from peony.oauth_dance import get_access_token, get_oauth_token
|
||||
from pydantic import BaseModel
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara.config import config
|
||||
from kibicara.platforms.twitter.bot import spawner
|
||||
|
@ -26,8 +26,8 @@ class BodyTwitterPublic(BaseModel):
|
|||
|
||||
async def get_twitter(twitter_id: int, hood=Depends(get_hood)):
|
||||
try:
|
||||
return await Twitter.objects.get(id=twitter_id, hood=hood)
|
||||
except NoMatch:
|
||||
return await Twitter.get(id=twitter_id, hood=hood)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
|
@ -36,12 +36,12 @@ twitter_callback_router = APIRouter()
|
|||
|
||||
|
||||
@router.get(
|
||||
'/public',
|
||||
"/public",
|
||||
# TODO response_model,
|
||||
operation_id='get_twitters_public',
|
||||
operation_id="get_twitters_public",
|
||||
)
|
||||
async def twitter_read_all_public(hood=Depends(get_hood_unauthorized)):
|
||||
twitterbots = await Twitter.objects.filter(hood=hood).all()
|
||||
twitterbots = await Twitter.filter(hood=hood).all()
|
||||
return [
|
||||
BodyTwitterPublic(username=twitterbot.username)
|
||||
for twitterbot in twitterbots
|
||||
|
@ -50,28 +50,28 @@ async def twitter_read_all_public(hood=Depends(get_hood_unauthorized)):
|
|||
|
||||
|
||||
@router.get(
|
||||
'/',
|
||||
"/",
|
||||
# TODO response_model,
|
||||
operation_id='get_twitters',
|
||||
operation_id="get_twitters",
|
||||
)
|
||||
async def twitter_read_all(hood=Depends(get_hood)):
|
||||
return await Twitter.objects.filter(hood=hood).all()
|
||||
return await Twitter.filter(hood=hood).all()
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{twitter_id}',
|
||||
"/{twitter_id}",
|
||||
# TODO response_model
|
||||
operation_id='get_twitter',
|
||||
operation_id="get_twitter",
|
||||
)
|
||||
async def twitter_read(twitter=Depends(get_twitter)):
|
||||
return twitter
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{twitter_id}',
|
||||
"/{twitter_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
# TODO response_model
|
||||
operation_id='delete_twitter',
|
||||
operation_id="delete_twitter",
|
||||
)
|
||||
async def twitter_delete(twitter=Depends(get_twitter)):
|
||||
spawner.stop(twitter)
|
||||
|
@ -80,20 +80,20 @@ async def twitter_delete(twitter=Depends(get_twitter)):
|
|||
|
||||
|
||||
@router.get(
|
||||
'/{twitter_id}/status',
|
||||
"/{twitter_id}/status",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id='status_twitter',
|
||||
operation_id="status_twitter",
|
||||
)
|
||||
async def twitter_status(twitter=Depends(get_twitter)):
|
||||
return {'status': spawner.get(twitter).status.name}
|
||||
return {"status": spawner.get(twitter).status.name}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{twitter_id}/start',
|
||||
"/{twitter_id}/start",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id='start_twitter',
|
||||
operation_id="start_twitter",
|
||||
)
|
||||
async def twitter_start(twitter=Depends(get_twitter)):
|
||||
await twitter.update(enabled=True)
|
||||
|
@ -102,10 +102,10 @@ async def twitter_start(twitter=Depends(get_twitter)):
|
|||
|
||||
|
||||
@router.post(
|
||||
'/{twitter_id}/stop',
|
||||
"/{twitter_id}/stop",
|
||||
status_code=status.HTTP_200_OK,
|
||||
# TODO response_model
|
||||
operation_id='stop_twitter',
|
||||
operation_id="stop_twitter",
|
||||
)
|
||||
async def twitter_stop(twitter=Depends(get_twitter)):
|
||||
await twitter.update(enabled=False)
|
||||
|
@ -114,10 +114,10 @@ async def twitter_stop(twitter=Depends(get_twitter)):
|
|||
|
||||
|
||||
@router.post(
|
||||
'/',
|
||||
"/",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
# TODO response_model
|
||||
operation_id='create_twitter',
|
||||
operation_id="create_twitter",
|
||||
)
|
||||
async def twitter_create(response: Response, hood=Depends(get_hood)):
|
||||
"""
|
||||
|
@ -125,24 +125,24 @@ async def twitter_create(response: Response, hood=Depends(get_hood)):
|
|||
"""
|
||||
try:
|
||||
# Purge Twitter corpses
|
||||
for corpse in await Twitter.objects.filter(hood=hood, verified=False).all():
|
||||
for corpse in await Twitter.filter(hood=hood, verified=False).all():
|
||||
await corpse.delete()
|
||||
# Create Twitter
|
||||
request_token = await get_oauth_token(
|
||||
config['twitter']['consumer_key'],
|
||||
config['twitter']['consumer_secret'],
|
||||
callback_uri='{0}/dashboard/twitter-callback?hood={1}'.format(
|
||||
config['frontend_url'], hood.id
|
||||
config["twitter"]["consumer_key"],
|
||||
config["twitter"]["consumer_secret"],
|
||||
callback_uri="{0}/dashboard/twitter-callback?hood={1}".format(
|
||||
config["frontend_url"], hood.id
|
||||
),
|
||||
)
|
||||
if request_token['oauth_callback_confirmed'] != 'true':
|
||||
if request_token["oauth_callback_confirmed"] != "true":
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
twitter = await Twitter.objects.create(
|
||||
twitter = await Twitter.create(
|
||||
hood=hood,
|
||||
access_token=request_token['oauth_token'],
|
||||
access_token_secret=request_token['oauth_token_secret'],
|
||||
access_token=request_token["oauth_token"],
|
||||
access_token_secret=request_token["oauth_token_secret"],
|
||||
)
|
||||
response.headers['Location'] = str(twitter.id)
|
||||
response.headers["Location"] = str(twitter.id)
|
||||
return twitter
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
@ -151,25 +151,25 @@ async def twitter_create(response: Response, hood=Depends(get_hood)):
|
|||
|
||||
|
||||
@twitter_callback_router.get(
|
||||
'/callback',
|
||||
"/callback",
|
||||
# TODO response_model
|
||||
operation_id='callback_twitter',
|
||||
operation_id="callback_twitter",
|
||||
)
|
||||
async def twitter_read_callback(
|
||||
oauth_token: str, oauth_verifier: str, hood=Depends(get_hood)
|
||||
):
|
||||
try:
|
||||
twitter = await Twitter.objects.filter(access_token=oauth_token).get()
|
||||
twitter = await Twitter.filter(access_token=oauth_token).get()
|
||||
access_token = await get_access_token(
|
||||
config['twitter']['consumer_key'],
|
||||
config['twitter']['consumer_secret'],
|
||||
config["twitter"]["consumer_key"],
|
||||
config["twitter"]["consumer_secret"],
|
||||
twitter.access_token,
|
||||
twitter.access_token_secret,
|
||||
oauth_verifier,
|
||||
)
|
||||
await twitter.update(
|
||||
access_token=access_token['oauth_token'],
|
||||
access_token_secret=access_token['oauth_token_secret'],
|
||||
await Twitter.filter(id=twitter).update(
|
||||
access_token=access_token["oauth_token"],
|
||||
access_token_secret=access_token["oauth_token_secret"],
|
||||
verified=True,
|
||||
enabled=True,
|
||||
)
|
||||
|
@ -177,7 +177,7 @@ async def twitter_read_callback(
|
|||
return {}
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
except NoMatch:
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
except (KeyError, ValueError, NotAuthenticated):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
50
backend/src/kibicara/webapi/__init__.py
Normal file
50
backend/src/kibicara/webapi/__init__.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Routing definitions for the REST API.
|
||||
|
||||
A platform bot shall add its API router in this `__init__.py`
|
||||
file to get included into the main application.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from kibicara.platforms.email.webapi import router as email_router
|
||||
from kibicara.platforms.telegram.webapi import router as telegram_router
|
||||
from kibicara.platforms.test.webapi import router as test_router
|
||||
from kibicara.platforms.mastodon.webapi import router as mastodon_router
|
||||
from kibicara.platforms.twitter.webapi import router as twitter_router
|
||||
from kibicara.platforms.twitter.webapi import twitter_callback_router
|
||||
from kibicara.webapi.admin import router as admin_router
|
||||
from kibicara.webapi.hoods import router as hoods_router
|
||||
from kibicara.webapi.hoods.exclude_patterns import router as exclude_patterns_router
|
||||
from kibicara.webapi.hoods.include_patterns import router as include_patterns_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(admin_router, prefix="/admin", tags=["admin"])
|
||||
hoods_router.include_router(
|
||||
include_patterns_router,
|
||||
prefix="/{hood_id}/triggers",
|
||||
tags=["include_patterns"],
|
||||
)
|
||||
hoods_router.include_router(
|
||||
exclude_patterns_router,
|
||||
prefix="/{hood_id}/badwords",
|
||||
tags=["exclude_patterns"],
|
||||
)
|
||||
hoods_router.include_router(test_router, prefix="/{hood_id}/test", tags=["test"])
|
||||
hoods_router.include_router(
|
||||
telegram_router, prefix="/{hood_id}/telegram", tags=["telegram"]
|
||||
)
|
||||
hoods_router.include_router(
|
||||
twitter_router, prefix="/{hood_id}/twitter", tags=["twitter"]
|
||||
)
|
||||
hoods_router.include_router(
|
||||
mastodon_router, prefix="/{hood_id}/mastodon", tags=["mastodon"]
|
||||
)
|
||||
router.include_router(twitter_callback_router, prefix="/twitter", tags=["twitter"])
|
||||
hoods_router.include_router(email_router, prefix="/{hood_id}/email", tags=["email"])
|
||||
router.include_router(hoods_router, prefix="/hoods")
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
|
@ -11,20 +11,20 @@ from datetime import datetime, timedelta
|
|||
from logging import getLogger
|
||||
from pickle import dumps, loads
|
||||
from smtplib import SMTPException
|
||||
from sqlite3 import IntegrityError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from nacl.encoding import URLSafeBase64Encoder
|
||||
from nacl.exceptions import CryptoError
|
||||
from nacl.secret import SecretBox
|
||||
from ormantic.exceptions import NoMatch
|
||||
from nacl.utils import random
|
||||
from passlib.hash import argon2
|
||||
from pydantic import BaseModel, validator
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara import email
|
||||
from kibicara.config import config
|
||||
from kibicara.model import Admin, AdminHoodRelation, Hood
|
||||
from kibicara.model import Admin, Hood
|
||||
from kibicara.webapi.utils import delete_hood
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
@ -37,10 +37,10 @@ class BodyEmail(BaseModel):
|
|||
class BodyPassword(BaseModel):
|
||||
password: str
|
||||
|
||||
@validator('password')
|
||||
@validator("password")
|
||||
def valid_password(cls, value):
|
||||
if len(value) < 8:
|
||||
raise ValueError('Password is too short')
|
||||
raise ValueError("Password is too short")
|
||||
return value
|
||||
|
||||
|
||||
|
@ -50,55 +50,56 @@ class BodyAdmin(BodyEmail, BodyPassword):
|
|||
|
||||
class BodyAccessToken(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = 'bearer'
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/admin/login')
|
||||
secret_box = SecretBox(bytes.fromhex(config['secret']))
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login")
|
||||
secret_box = SecretBox(
|
||||
bytes.fromhex(str(config.get("secret", random(SecretBox.KEY_SIZE).hex())))
|
||||
)
|
||||
|
||||
|
||||
def to_token(**kwargs):
|
||||
def to_token(**kwargs) -> str:
|
||||
return secret_box.encrypt(dumps(kwargs), encoder=URLSafeBase64Encoder).decode(
|
||||
'ascii'
|
||||
"ascii"
|
||||
)
|
||||
|
||||
|
||||
def from_token(token):
|
||||
def from_token(token: str) -> dict:
|
||||
return loads(
|
||||
secret_box.decrypt(token.encode('ascii'), encoder=URLSafeBase64Encoder)
|
||||
secret_box.decrypt(token.encode("ascii"), encoder=URLSafeBase64Encoder)
|
||||
)
|
||||
|
||||
|
||||
async def get_auth(email, password):
|
||||
async def get_auth(email: str, password: str) -> Admin:
|
||||
try:
|
||||
admin = await Admin.objects.get(email=email)
|
||||
admin = await Admin.get(email=email)
|
||||
if argon2.verify(password, admin.passhash):
|
||||
return admin
|
||||
raise ValueError
|
||||
except NoMatch:
|
||||
except DoesNotExist:
|
||||
raise ValueError
|
||||
|
||||
|
||||
async def get_admin(access_token=Depends(oauth2_scheme)):
|
||||
async def get_admin(access_token: str = Depends(oauth2_scheme)) -> Admin:
|
||||
try:
|
||||
admin = await get_auth(**from_token(access_token))
|
||||
return await get_auth(**from_token(access_token))
|
||||
except (CryptoError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid authentication credentials',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return admin
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
'/register/',
|
||||
"/register/",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
response_model=BaseModel,
|
||||
operation_id='register',
|
||||
operation_id="register",
|
||||
)
|
||||
async def admin_register(values: BodyAdmin):
|
||||
"""Sends an email with a confirmation link.
|
||||
|
@ -107,28 +108,28 @@ async def admin_register(values: BodyAdmin):
|
|||
- **password**: Password of new hood admin
|
||||
"""
|
||||
register_token = to_token(**values.__dict__)
|
||||
logger.debug('register_token={0}'.format(register_token))
|
||||
logger.debug("register_token={0}".format(register_token))
|
||||
try:
|
||||
admin = await Admin.objects.filter(email=values.email).all()
|
||||
if admin:
|
||||
if await Admin.exists(email=values.email):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
body = '{0}/confirm?token={1}'.format(config['frontend_url'], register_token)
|
||||
# link goes to frontend. this is not the confirm API endpoint below!
|
||||
body = "{0}/confirm?token={1}".format(config["frontend_url"], register_token)
|
||||
logger.debug(body)
|
||||
email.send_email(
|
||||
to=values.email,
|
||||
subject='Confirm Account',
|
||||
subject="Confirm Account",
|
||||
body=body,
|
||||
)
|
||||
except (ConnectionRefusedError, SMTPException):
|
||||
logger.exception('Email sending failed')
|
||||
logger.exception("Email sending failed")
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY)
|
||||
return {}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/confirm/{register_token}',
|
||||
"/confirm/{register_token}",
|
||||
response_model=BodyAccessToken,
|
||||
operation_id='confirm',
|
||||
operation_id="confirm",
|
||||
)
|
||||
async def admin_confirm(register_token: str):
|
||||
"""Registration confirmation and account creation.
|
||||
|
@ -137,17 +138,18 @@ async def admin_confirm(register_token: str):
|
|||
"""
|
||||
try:
|
||||
values = from_token(register_token)
|
||||
passhash = argon2.hash(values['password'])
|
||||
await Admin.objects.create(email=values['email'], passhash=passhash)
|
||||
passhash = argon2.hash(values["password"])
|
||||
await Admin.create(email=values["email"], passhash=passhash)
|
||||
# XXX login and registration tokens are exchangeable. does this hurt?
|
||||
return BodyAccessToken(access_token=register_token)
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
|
||||
@router.post(
|
||||
'/login/',
|
||||
"/login/",
|
||||
response_model=BodyAccessToken,
|
||||
operation_id='login',
|
||||
operation_id="login",
|
||||
)
|
||||
async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
"""Get an access token.
|
||||
|
@ -160,17 +162,17 @@ async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()):
|
|||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Incorrect email or password',
|
||||
detail="Incorrect email or password",
|
||||
)
|
||||
token = to_token(email=form_data.username, password=form_data.password)
|
||||
return BodyAccessToken(access_token=token)
|
||||
|
||||
|
||||
@router.post(
|
||||
'/reset/',
|
||||
"/reset/",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
response_model=BaseModel,
|
||||
operation_id='reset',
|
||||
operation_id="reset",
|
||||
)
|
||||
async def admin_reset_password(values: BodyEmail):
|
||||
"""Sends an email with a password reset link.
|
||||
|
@ -178,46 +180,45 @@ async def admin_reset_password(values: BodyEmail):
|
|||
- **email**: E-Mail Address of new hood admin
|
||||
- **password**: Password of new hood admin
|
||||
"""
|
||||
register_token = to_token(datetime=datetime.now().isoformat(), **values.__dict__)
|
||||
logger.debug('register_token={0}'.format(register_token))
|
||||
reset_token = to_token(datetime=datetime.now().isoformat(), **values.__dict__)
|
||||
logger.debug("reset_token={0}".format(reset_token))
|
||||
try:
|
||||
admin = await Admin.objects.filter(email=values.email).all()
|
||||
if not admin:
|
||||
if not await Admin.exists(email=values.email):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
body = '{0}/password-reset?token={1}'.format(
|
||||
config['frontend_url'], register_token
|
||||
# link goes to frontend. this is not the reset API endpoint below!
|
||||
body = "{0}/password-reset?token={1}".format(
|
||||
config["frontend_url"], reset_token
|
||||
)
|
||||
logger.debug(body)
|
||||
email.send_email(
|
||||
to=values.email,
|
||||
subject='Reset your password',
|
||||
subject="Reset your password",
|
||||
body=body,
|
||||
)
|
||||
except (ConnectionRefusedError, SMTPException):
|
||||
logger.exception('Email sending failed')
|
||||
logger.exception("Email sending failed")
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY)
|
||||
return {}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/reset/{reset_token}',
|
||||
"/reset/{reset_token}",
|
||||
response_model=BodyAccessToken,
|
||||
operation_id='confirm_reset',
|
||||
operation_id="confirm_reset",
|
||||
)
|
||||
async def admin_confirm_reset(reset_token: str, values: BodyPassword):
|
||||
try:
|
||||
token_values = from_token(reset_token)
|
||||
if (
|
||||
datetime.fromisoformat(token_values['datetime']) + timedelta(hours=3)
|
||||
datetime.fromisoformat(token_values["datetime"]) + timedelta(hours=3)
|
||||
< datetime.now()
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE)
|
||||
passhash = argon2.hash(values.password)
|
||||
admins = await Admin.objects.filter(email=token_values['email']).all()
|
||||
if len(admins) != 1:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
await admins[0].update(passhash=passhash)
|
||||
await Admin.filter(email=token_values["email"]).update(passhash=passhash)
|
||||
return BodyAccessToken(access_token=reset_token)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
except CryptoError:
|
||||
|
@ -225,47 +226,33 @@ async def admin_confirm_reset(reset_token: str, values: BodyPassword):
|
|||
|
||||
|
||||
@router.get(
|
||||
'/hoods/',
|
||||
"/hoods/",
|
||||
# TODO response_model,
|
||||
operation_id='get_hoods_admin',
|
||||
operation_id="get_hoods_admin",
|
||||
)
|
||||
async def admin_hood_read_all(admin=Depends(get_admin)):
|
||||
async def admin_hood_read_all(admin: Admin = Depends(get_admin)):
|
||||
"""Get a list of all hoods of a given admin."""
|
||||
return (
|
||||
await AdminHoodRelation.objects.select_related('hood').filter(admin=admin).all()
|
||||
)
|
||||
return await Hood.filter(admins=admin)
|
||||
|
||||
|
||||
@router.get(
|
||||
'/',
|
||||
"/",
|
||||
# TODO response_model,
|
||||
operation_id='get_admin',
|
||||
operation_id="get_admin",
|
||||
)
|
||||
async def admin_read(admin=Depends(get_admin)):
|
||||
"""Get a list of all hoods of a given admin."""
|
||||
admin = await Admin.objects.filter(email=admin.email).all()
|
||||
if len(admin) != 1:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return BodyEmail(email=admin[0].email)
|
||||
async def admin_read(admin: Admin = Depends(get_admin)):
|
||||
return BodyEmail(email=admin.email)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/',
|
||||
"/",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
operation_id='delete_admin',
|
||||
operation_id="delete_admin",
|
||||
)
|
||||
async def admin_delete(admin=Depends(get_admin)):
|
||||
hood_relations = (
|
||||
await AdminHoodRelation.objects.select_related('hood').filter(admin=admin).all()
|
||||
)
|
||||
for hood in hood_relations:
|
||||
admins = (
|
||||
await AdminHoodRelation.objects.select_related('admin')
|
||||
.filter(hood=hood.id)
|
||||
.all()
|
||||
)
|
||||
if len(admins) == 1 and admins[0].id == admin.id:
|
||||
actual_hood = await Hood.objects.filter(id=hood.id).all()
|
||||
await delete_hood(actual_hood[0])
|
||||
async def admin_delete(admin: Admin = Depends(get_admin)):
|
||||
async for hood in Hood.filter(admins__contains=admin):
|
||||
await hood.admins.remove(admin)
|
||||
await hood.fetch_related("admins")
|
||||
if len(hood.admins) == 0:
|
||||
await delete_hood(hood)
|
||||
await admin.delete()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
120
backend/src/kibicara/webapi/hoods/__init__.py
Normal file
120
backend/src/kibicara/webapi/hoods/__init__.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""REST API Endpoints for managing hoods."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara.model import Admin, Hood, IncludePattern
|
||||
from kibicara.platforms.email.bot import spawner
|
||||
from kibicara.webapi.admin import get_admin
|
||||
from kibicara.webapi.utils import delete_hood
|
||||
|
||||
|
||||
class BodyHood(BaseModel):
|
||||
name: str
|
||||
landingpage: str = "Default Landing Page"
|
||||
|
||||
|
||||
async def get_hood_unauthorized(hood_id: int) -> Hood:
|
||||
try:
|
||||
return await Hood.get(id=hood_id)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
async def get_hood(
|
||||
hood: Hood = Depends(get_hood_unauthorized), admin: Admin = Depends(get_admin)
|
||||
) -> Hood:
|
||||
await hood.fetch_related("admins")
|
||||
if admin in hood.admins:
|
||||
return hood
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
# TODO response_model,
|
||||
operation_id="get_hoods",
|
||||
tags=["hoods"],
|
||||
)
|
||||
async def hood_read_all():
|
||||
"""Get all existing hoods."""
|
||||
return await Hood.all()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
# TODO response_model,
|
||||
operation_id="create_hood",
|
||||
tags=["hoods"],
|
||||
)
|
||||
async def hood_create(
|
||||
values: BodyHood, response: Response, admin: Admin = Depends(get_admin)
|
||||
):
|
||||
"""Creates a hood.
|
||||
|
||||
- **name**: Name of the hood
|
||||
- **landingpage**: Markdown formatted description of the hood
|
||||
"""
|
||||
try:
|
||||
hood = await Hood.create(**values.__dict__)
|
||||
await admin.hoods.add(hood)
|
||||
spawner.start(hood)
|
||||
|
||||
# Initialize Triggers to match all
|
||||
await IncludePattern.create(hood=hood, pattern=".")
|
||||
|
||||
response.headers["Location"] = str(hood.id)
|
||||
return hood
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{hood_id}",
|
||||
# TODO response_model,
|
||||
operation_id="get_hood",
|
||||
tags=["hoods"],
|
||||
)
|
||||
async def hood_read(hood: Hood = Depends(get_hood_unauthorized)):
|
||||
"""Get hood with id **hood_id**."""
|
||||
return hood
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{hood_id}",
|
||||
operation_id="update_hood",
|
||||
tags=["hoods"],
|
||||
)
|
||||
async def hood_update(values: BodyHood, hood: Hood = Depends(get_hood)):
|
||||
"""Updates hood with id **hood_id**.
|
||||
|
||||
- **name**: New name of the hood
|
||||
- **landingpage**: New Markdown formatted description of the hood
|
||||
"""
|
||||
await Hood.filter(id=hood).update(**values.__dict__)
|
||||
return hood
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{hood_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
operation_id="delete_hood",
|
||||
tags=["hoods"],
|
||||
)
|
||||
async def hood_delete(hood=Depends(get_hood)):
|
||||
"""Deletes hood with id **hood_id**."""
|
||||
await delete_hood(hood)
|
116
backend/src/kibicara/webapi/hoods/exclude_patterns.py
Normal file
116
backend/src/kibicara/webapi/hoods/exclude_patterns.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""REST API endpoints for managing exclude_patterns.
|
||||
|
||||
Provides API endpoints for adding, removing and reading regular expressions that block a
|
||||
received message to be dropped by a censor. This provides a message filter customizable
|
||||
by the hood admins.
|
||||
"""
|
||||
|
||||
from re import compile as regex_compile, error as RegexError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara.model import ExcludePattern, Hood
|
||||
from kibicara.webapi.hoods import get_hood
|
||||
|
||||
|
||||
class BodyExcludePattern(BaseModel):
|
||||
pattern: str
|
||||
|
||||
|
||||
async def get_exclude_pattern(
|
||||
exclude_pattern_id: int, hood: Hood = Depends(get_hood)
|
||||
) -> ExcludePattern:
|
||||
try:
|
||||
return await ExcludePattern.get(id=exclude_pattern_id, hood=hood)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
# TODO response_model,
|
||||
operation_id="get_exclude_patterns",
|
||||
)
|
||||
async def exclude_pattern_read_all(hood: Hood = Depends(get_hood)):
|
||||
"""Get all exclude_patterns of hood with id **hood_id**."""
|
||||
return await ExcludePattern.filter(hood=hood)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
# TODO response_model,
|
||||
operation_id="create_exclude_pattern",
|
||||
)
|
||||
async def exclude_pattern_create(
|
||||
values: BodyExcludePattern, response: Response, hood: Hood = Depends(get_hood)
|
||||
):
|
||||
"""Creates a new exclude_pattern for hood with id **hood_id**.
|
||||
|
||||
- **pattern**: Regular expression which is used to match a exclude_pattern.
|
||||
"""
|
||||
try:
|
||||
regex_compile(values.pattern)
|
||||
exclude_pattern = await ExcludePattern.create(hood=hood, **values.__dict__)
|
||||
response.headers["Location"] = str(exclude_pattern.id)
|
||||
return exclude_pattern
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
except RegexError:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{exclude_pattern_id}",
|
||||
# TODO response_model,
|
||||
operation_id="get_exclude_pattern",
|
||||
)
|
||||
async def exclude_pattern_read(
|
||||
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
|
||||
):
|
||||
"""
|
||||
Reads exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
|
||||
"""
|
||||
return exclude_pattern
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{exclude_pattern_id}",
|
||||
operation_id="update_exclude_pattern",
|
||||
)
|
||||
async def exclude_pattern_update(
|
||||
values: BodyExcludePattern,
|
||||
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
|
||||
):
|
||||
"""
|
||||
Updates exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
|
||||
|
||||
- **pattern**: Regular expression which is used to match a exclude_pattern
|
||||
"""
|
||||
await ExcludePattern.filter(id=exclude_pattern).update(**values.__dict__)
|
||||
return exclude_pattern
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{exclude_pattern_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
operation_id="delete_exclude_pattern",
|
||||
)
|
||||
async def exclude_pattern_delete(
|
||||
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
|
||||
):
|
||||
"""
|
||||
Deletes exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
|
||||
"""
|
||||
await exclude_pattern.delete()
|
115
backend/src/kibicara/webapi/hoods/include_patterns.py
Normal file
115
backend/src/kibicara/webapi/hoods/include_patterns.py
Normal file
|
@ -0,0 +1,115 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""REST API endpoints for managing include_patterns.
|
||||
|
||||
Provides API endpoints for adding, removing and reading regular expressions that allow a
|
||||
message to be passed through by a censor. A published message must match one of these
|
||||
regular expressions otherwise it gets dropped by the censor. This provides a message
|
||||
filter customizable by the hood admins.
|
||||
"""
|
||||
|
||||
from re import compile as regex_compile, error as RegexError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
from tortoise.exceptions import DoesNotExist, IntegrityError
|
||||
|
||||
from kibicara.model import IncludePattern, Hood
|
||||
from kibicara.webapi.hoods import get_hood
|
||||
|
||||
|
||||
class BodyIncludePattern(BaseModel):
|
||||
pattern: str
|
||||
|
||||
|
||||
async def get_include_pattern(include_pattern_id: int, hood: Hood = Depends(get_hood)):
|
||||
try:
|
||||
return await IncludePattern.get(id=include_pattern_id, hood=hood)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
# TODO response_model,
|
||||
operation_id="get_include_patterns",
|
||||
)
|
||||
async def include_pattern_read_all(hood: Hood = Depends(get_hood)):
|
||||
"""Get all include_patterns of hood with id **hood_id**."""
|
||||
return await IncludePattern.filter(hood=hood)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
# TODO response_model,
|
||||
operation_id="create_include_pattern",
|
||||
)
|
||||
async def include_pattern_create(
|
||||
values: BodyIncludePattern, response: Response, hood: Hood = Depends(get_hood)
|
||||
):
|
||||
"""Creates a new include_pattern for hood with id **hood_id**.
|
||||
|
||||
- **pattern**: Regular expression which is used to match a include_pattern.
|
||||
"""
|
||||
try:
|
||||
regex_compile(values.pattern)
|
||||
include_pattern = await IncludePattern.create(hood=hood, **values.__dict__)
|
||||
response.headers["Location"] = str(include_pattern.id)
|
||||
return include_pattern
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
except RegexError:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{include_pattern_id}",
|
||||
# TODO response_model,
|
||||
operation_id="get_include_pattern",
|
||||
)
|
||||
async def include_pattern_read(
|
||||
include_pattern: IncludePattern = Depends(get_include_pattern),
|
||||
):
|
||||
"""
|
||||
Reads include_pattern with id **include_pattern_id** for hood with id **hood_id**.
|
||||
"""
|
||||
return include_pattern
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{include_pattern_id}",
|
||||
operation_id="update_include_pattern",
|
||||
)
|
||||
async def include_pattern_update(
|
||||
values: BodyIncludePattern,
|
||||
include_pattern: IncludePattern = Depends(get_include_pattern),
|
||||
):
|
||||
"""
|
||||
Updates include_pattern with id **include_pattern_id** for hood with id **hood_id**.
|
||||
|
||||
- **pattern**: Regular expression which is used to match a include_pattern
|
||||
"""
|
||||
await IncludePattern.filter(id=include_pattern).update(**values.__dict__)
|
||||
return include_pattern
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{include_pattern_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
operation_id="delete_include_pattern",
|
||||
)
|
||||
async def include_pattern_delete(
|
||||
include_pattern: IncludePattern = Depends(get_include_pattern),
|
||||
):
|
||||
"""
|
||||
Deletes include_pattern with id **include_pattern_id** for hood with id **hood_id**.
|
||||
"""
|
||||
await include_pattern.delete()
|
14
backend/src/kibicara/webapi/utils.py
Normal file
14
backend/src/kibicara/webapi/utils.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from kibicara.model import ExcludePattern, Hood, IncludePattern
|
||||
from kibicara.platformapi import Spawner
|
||||
|
||||
|
||||
async def delete_hood(hood: Hood) -> None:
|
||||
await Spawner.destroy_hood(hood)
|
||||
await IncludePattern.filter(hood=hood).delete()
|
||||
await ExcludePattern.filter(hood=hood).delete()
|
||||
await hood.delete()
|
156
backend/tests/conftest.py
Normal file
156
backend/tests/conftest.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import FastAPI, status
|
||||
from httpx import AsyncClient
|
||||
import pytest
|
||||
from tortoise import Tortoise
|
||||
|
||||
from kibicara import email
|
||||
from kibicara.webapi import router
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.mark.anyio
|
||||
async def client():
|
||||
await Tortoise.init(
|
||||
db_url="sqlite://:memory:",
|
||||
modules={
|
||||
"models": [
|
||||
"kibicara.model",
|
||||
"kibicara.platforms.email.model",
|
||||
"kibicara.platforms.mastodon.model",
|
||||
"kibicara.platforms.telegram.model",
|
||||
"kibicara.platforms.test.model",
|
||||
"kibicara.platforms.twitter.model",
|
||||
]
|
||||
},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api")
|
||||
yield AsyncClient(app=app, base_url="http://test")
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def monkeymodule():
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
mpatch = MonkeyPatch()
|
||||
yield mpatch
|
||||
mpatch.undo()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def receive_email(monkeymodule):
|
||||
mailbox = []
|
||||
|
||||
def mock_send_email(to, subject, sender="kibicara", body=""):
|
||||
mailbox.append(dict(to=to, subject=subject, sender=sender, body=body))
|
||||
|
||||
def mock_receive_email():
|
||||
return mailbox.pop()
|
||||
|
||||
monkeymodule.setattr(email, "send_email", mock_send_email)
|
||||
return mock_receive_email
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.mark.anyio
|
||||
async def register_token(client, receive_email):
|
||||
response = await client.post(
|
||||
"/api/admin/register/", json={"email": "user", "password": "password"}
|
||||
)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
return urlparse(receive_email()["body"]).query.split("=", 1)[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.mark.anyio
|
||||
async def register_confirmed(client, register_token):
|
||||
response = await client.post("/api/admin/confirm/{0}".format(register_token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.mark.anyio
|
||||
async def access_token(client, register_confirmed):
|
||||
response = await client.post(
|
||||
"/api/admin/login/", data={"username": "user", "password": "password"}
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def auth_header(access_token):
|
||||
return {"Authorization": "Bearer {0}".format(access_token)}
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.mark.anyio
|
||||
async def hood_id(client, auth_header):
|
||||
response = await client.post(
|
||||
"/api/hoods/", json={"name": "hood"}, headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
hood_id = int(response.headers["Location"])
|
||||
yield hood_id
|
||||
await client.delete("/api/hoods/{0}".format(hood_id), headers=auth_header)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.mark.anyio
|
||||
async def trigger_id(client, hood_id, auth_header):
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/triggers/".format(hood_id),
|
||||
json={"pattern": "test"},
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
trigger_id = int(response.headers["Location"])
|
||||
yield trigger_id
|
||||
await client.delete(
|
||||
"/api/hoods/{0}/triggers/{1}".format(hood_id, trigger_id), headers=auth_header
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.mark.anyio
|
||||
async def badword_id(client, hood_id, auth_header):
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/badwords/".format(hood_id),
|
||||
json={"pattern": ""},
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
badword_id = int(response.headers["Location"])
|
||||
yield badword_id
|
||||
await client.delete(
|
||||
"/api/hoods/{0}/badwords/{1}".format(hood_id, badword_id), headers=auth_header
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.mark.anyio
|
||||
async def test_id(client, hood_id, auth_header):
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/test/".format(hood_id), json={}, headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
test_id = int(response.headers["Location"])
|
||||
yield test_id
|
||||
await client.delete(
|
||||
"/api/hoods/{0}/test/{1}".format(hood_id, test_id), headers=auth_header
|
||||
)
|
18
backend/tests/tests_core/test_api_admin.py
Normal file
18
backend/tests/tests_core/test_api_admin.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hoods_unauthorized(client):
|
||||
response = await client.get("/api/admin/hoods/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hoods_success(client, auth_header):
|
||||
response = await client.get("/api/admin/hoods/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_200_OK
|
109
backend/tests/tests_core/test_api_hoods.py
Normal file
109
backend/tests/tests_core/test_api_hoods.py
Normal file
|
@ -0,0 +1,109 @@
|
|||
# Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
|
||||
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hood_read_all(client):
|
||||
response = await client.get("/api/hoods/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hood_create_unauthorized(client, hood_id):
|
||||
response = await client.post("/api/hoods/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hood_read(client, hood_id):
|
||||
response = await client.get("/api/hoods/{0}".format(hood_id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hood_update_unauthorized(client, hood_id):
|
||||
response = await client.put("/api/hoods/{0}".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hood_delete_unauthorized(client, hood_id):
|
||||
response = await client.delete("/api/hoods/{0}".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trigger_read_all_unauthorized(client, hood_id):
|
||||
response = await client.get("/api/hoods/{0}/triggers/".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trigger_create_unauthorized(client, hood_id):
|
||||
response = await client.post("/api/hoods/{0}/triggers/".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trigger_read_unauthorized(client, hood_id, trigger_id):
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/triggers/{1}".format(hood_id, trigger_id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trigger_update_unauthorized(client, hood_id, trigger_id):
|
||||
response = await client.put(
|
||||
"/api/hoods/{0}/triggers/{1}".format(hood_id, trigger_id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trigger_delete_unauthorized(client, hood_id, trigger_id):
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/triggers/{1}".format(hood_id, trigger_id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_badword_read_all_unauthorized(client, hood_id):
|
||||
response = await client.get("/api/hoods/{0}/badwords/".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_badword_create_unauthorized(client, hood_id):
|
||||
response = await client.post("/api/hoods/{0}/badwords/".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_badword_read_unauthorized(client, hood_id, badword_id):
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/badwords/{1}".format(hood_id, badword_id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_badword_update_unauthorized(client, hood_id, badword_id):
|
||||
response = await client.put(
|
||||
"/api/hoods/{0}/badwords/{1}".format(hood_id, badword_id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_badword_delete_unauthorized(client, hood_id, badword_id):
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/badwords/{1}".format(hood_id, badword_id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
25
backend/tests/tests_email/conftest.py
Normal file
25
backend/tests/tests_email/conftest.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Copyright (C) 2020 by Maike <maike@systemli.org>
|
||||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.mark.anyio
|
||||
async def email_row(client, hood_id, auth_header):
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/email/".format(hood_id),
|
||||
json={"name": "kibicara-test"},
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
email_id = int(response.headers["Location"])
|
||||
yield response.json()
|
||||
await client.delete(
|
||||
"/api/hoods/{0}/email/{1}".format(hood_id, email_id), headers=auth_header
|
||||
)
|
|
@ -8,56 +8,61 @@ from re import findall
|
|||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import status
|
||||
from pytest import skip
|
||||
import pytest
|
||||
|
||||
from kibicara.webapi.admin import to_token
|
||||
|
||||
|
||||
def test_email_subscribe_unsubscribe(client, hood_id, receive_email):
|
||||
response = client.post(
|
||||
'/api/hoods/{0}/email/subscribe/'.format(hood_id),
|
||||
json={'email': 'test@localhost'},
|
||||
@pytest.mark.anyio
|
||||
async def test_email_subscribe_unsubscribe(client, hood_id, receive_email):
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/email/subscribe/".format(hood_id),
|
||||
json={"email": "test@localhost"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mail = receive_email()
|
||||
body = mail['body']
|
||||
body = mail["body"]
|
||||
confirm_url = findall(
|
||||
r'http[s]?://'
|
||||
+ r'(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+',
|
||||
r"http[s]?://"
|
||||
+ r"(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+",
|
||||
body,
|
||||
)[0]
|
||||
start = len('token=')
|
||||
response = client.post(
|
||||
'/api/hoods/{0}/email/subscribe/confirm/{1}'.format(
|
||||
start = len("token=")
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/email/subscribe/confirm/{1}".format(
|
||||
hood_id, urlparse(confirm_url).query[start:]
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
response = client.post(
|
||||
'/api/hoods/{0}/email/subscribe/confirm/{1}'.format(
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/email/subscribe/confirm/{1}".format(
|
||||
hood_id, urlparse(confirm_url).query[start:]
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
token = to_token(email=mail['to'], hood=hood_id)
|
||||
response = client.delete(
|
||||
'/api/hoods/{0}/email/unsubscribe/{1}'.format(hood_id, token)
|
||||
token = to_token(email=mail["to"], hood=hood_id)
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/email/unsubscribe/{1}".format(hood_id, token)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
|
||||
def test_email_message(client, hood_id, trigger_id, email_row):
|
||||
@pytest.mark.anyio
|
||||
async def test_email_message(client, hood_id, trigger_id, email_row):
|
||||
body = {
|
||||
'text': 'test',
|
||||
'author': 'test@localhost',
|
||||
'secret': email_row['secret'],
|
||||
"text": "test",
|
||||
"author": "test@localhost",
|
||||
"secret": email_row["secret"],
|
||||
}
|
||||
response = client.post('/api/hoods/{0}/email/messages/'.format(hood_id), json=body)
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/email/messages/".format(hood_id), json=body
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
|
||||
def test_email_send_mda(trigger_id, email_row):
|
||||
skip('Only works if kibicara is listening on port 8000, and only sometimes')
|
||||
@pytest.mark.anyio
|
||||
async def test_email_send_mda(trigger_id, email_row):
|
||||
pytest.skip("Only works if kibicara is listening on port 8000, and only sometimes")
|
||||
mail = """From test@example.com Tue Jun 16 15:33:19 2020
|
||||
Return-path: <test@example.com>
|
||||
Envelope-to: hood@localhost
|
||||
|
@ -85,6 +90,6 @@ test
|
|||
--AqNPlAX243a8sip3B7kXv8UKD8wuti--
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
['kibicara_mda', 'hood'], stdout=subprocess.PIPE, input=mail, encoding='ascii'
|
||||
["kibicara_mda", "hood"], stdout=subprocess.PIPE, input=mail, encoding="ascii"
|
||||
)
|
||||
assert proc.returncode == 0
|
31
backend/tests/tests_email/test_api_email_unauthorized.py
Normal file
31
backend/tests/tests_email/test_api_email_unauthorized.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Copyright (C) 2020 by Maike <maike@systemli.org>
|
||||
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_create_unauthorized(client, hood_id):
|
||||
response = await client.post("/api/hoods/{0}/email/".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_delete_unauthorized(client, hood_id, email_row):
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/email/{1}".format(hood_id, email_row["id"])
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_message_unauthorized(client, hood_id, email_row):
|
||||
body = {"text": "test", "author": "author", "secret": "wrong"}
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/email/messages/".format(hood_id), json=body
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
67
backend/tests/tests_email/test_api_email_wrong.py
Normal file
67
backend/tests/tests_email/test_api_email_wrong.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
# Copyright (C) 2020 by Maike <maike@systemli.org>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
from nacl.exceptions import CryptoError
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_subscribe_empty(client, hood_id):
|
||||
response = await client.post("/api/hoods/{0}/email/subscribe/".format(hood_id))
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_subscribe_confirm_wrong_token(client, hood_id):
|
||||
try:
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/email/subscribe/confirm/".format(hood_id)
|
||||
+ "asdfasdfasdfasdfasdfasdfasdfasdf"
|
||||
)
|
||||
assert response.status_code is not status.HTTP_201_CREATED
|
||||
except CryptoError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_subscribe_confirm_wrong_hood(client):
|
||||
response = await client.delete(
|
||||
"/api/hoods/99999/email/unsubscribe/asdfasdfasdfasdfasdfasdfasdfasdf"
|
||||
)
|
||||
assert response.json()["detail"] == "Not Found"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_message_wrong(client, hood_id, email_row):
|
||||
body = {
|
||||
"text": "",
|
||||
"author": "test@localhost",
|
||||
"secret": email_row["secret"],
|
||||
}
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/email/messages/".format(hood_id), json=body
|
||||
)
|
||||
assert response.status_code == status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_unsubscribe_wrong_token(client, hood_id):
|
||||
try:
|
||||
await client.delete(
|
||||
"/api/hoods/{0}/email/unsubscribe/asdfasdfasdfasdfasdfasdfasdfasdf".format(
|
||||
hood_id
|
||||
)
|
||||
)
|
||||
except CryptoError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_email_unsubscribe_wrong_hood(client):
|
||||
response = await client.delete(
|
||||
"/api/hoods/99999/email/unsubscribe/asdfasdfasdfasdfasdfasdfasdfasdf"
|
||||
)
|
||||
assert response.json()["detail"] == "Not Found"
|
33
backend/tests/tests_mastodon/conftest.py
Normal file
33
backend/tests/tests_mastodon/conftest.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
import pytest
|
||||
|
||||
from kibicara.model import Hood
|
||||
from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.mark.anyio
|
||||
async def mastodon_instance():
|
||||
return await MastodonInstance.create(
|
||||
name="inst4nce",
|
||||
client_id="cl13nt_id",
|
||||
client_secret="cl13nt_s3cr3t",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.mark.anyio
|
||||
async def mastodon_account(hood_id, mastodon_instance):
|
||||
hood = await Hood.get(id=hood_id)
|
||||
return await MastodonAccount.create(
|
||||
hood=hood,
|
||||
instance=mastodon_instance,
|
||||
access_token="t0k3n",
|
||||
enabled=True,
|
||||
username="us3r",
|
||||
)
|
96
backend/tests/tests_mastodon/test_api_mastodon_create_bot.py
Normal file
96
backend/tests/tests_mastodon/test_api_mastodon_create_bot.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
from mastodon.Mastodon import Mastodon
|
||||
|
||||
from kibicara.platforms import mastodon
|
||||
from kibicara.platforms.mastodon.model import MastodonAccount
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def disable_spawner(monkeypatch):
|
||||
class DoNothing:
|
||||
def start(self, bot):
|
||||
assert bot is not None
|
||||
|
||||
monkeypatch.setattr(mastodon.webapi, "spawner", DoNothing())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{
|
||||
"instance_url": "botsin.space",
|
||||
"email": "test@example.org",
|
||||
"password": "string",
|
||||
}
|
||||
],
|
||||
)
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_create_bot(
|
||||
client,
|
||||
disable_spawner,
|
||||
hood_id,
|
||||
auth_header,
|
||||
monkeypatch,
|
||||
body,
|
||||
):
|
||||
def log_in_mock(self, username, password):
|
||||
return "acc3ss_t0ken"
|
||||
|
||||
monkeypatch.setattr(Mastodon, "log_in", log_in_mock)
|
||||
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/mastodon/".format(hood_id),
|
||||
json=body,
|
||||
headers=auth_header,
|
||||
)
|
||||
print(response.json())
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
bot_id = response.json()["id"]
|
||||
mastodon_obj = await MastodonAccount.get(id=bot_id)
|
||||
assert response.json()["access_token"] == mastodon_obj.access_token
|
||||
assert mastodon_obj.enabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"instance_url": "botsin.space", "email": "notanemail", "password": "asdf1234"},
|
||||
{"instance_url": "wrong", "email": "asdf@example.org", "password": "asdf1234"},
|
||||
],
|
||||
)
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_invalid_input(
|
||||
client,
|
||||
disable_spawner,
|
||||
hood_id,
|
||||
auth_header,
|
||||
monkeypatch,
|
||||
body,
|
||||
):
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/mastodon/".format(hood_id),
|
||||
json=body,
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_create_mastodon_invalid_id(client, auth_header):
|
||||
response = await client.post("/api/hoods/1337/mastodon/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.post("/api/hoods/wrong/mastodon/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_create_unauthorized(client, hood_id):
|
||||
response = await client.post("/api/hoods/{hood_id}/mastodon/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
50
backend/tests/tests_mastodon/test_api_mastodon_delete_bot.py
Normal file
50
backend/tests/tests_mastodon/test_api_mastodon_delete_bot.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
from tortoise.exceptions import DoesNotExist
|
||||
|
||||
from kibicara.platforms.mastodon.model import MastodonAccount
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_delete_bot(client, mastodon_account, auth_header):
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/mastodon/{1}".format(
|
||||
mastodon_account.hood.id, mastodon_account.id
|
||||
),
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
with pytest.raises(DoesNotExist):
|
||||
await MastodonAccount.get(id=mastodon_account.id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_delete_bot_invalid_id(client, auth_header, hood_id):
|
||||
response = await client.delete("/api/hoods/1337/mastodon/123", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.delete("/api/hoods/wrong/mastodon/123", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/mastodon/7331".format(hood_id), headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/mastodon/wrong".format(hood_id), headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_delete_bot_unauthorized(client, mastodon_account):
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/mastodon/{1}".format(
|
||||
mastodon_account.hood.id, mastodon_account.id
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
55
backend/tests/tests_mastodon/test_api_mastodon_get_bots.py
Normal file
55
backend/tests/tests_mastodon/test_api_mastodon_get_bots.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
|
||||
from kibicara.platforms.mastodon.model import MastodonAccount
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_get_bots(
|
||||
client, auth_header, hood_id, mastodon_account, mastodon_instance
|
||||
):
|
||||
mastodon2 = await MastodonAccount.create(
|
||||
hood=mastodon_account.hood,
|
||||
instance=mastodon_instance,
|
||||
access_token="4cc3ss",
|
||||
enabled=True,
|
||||
username="us4r",
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/mastodon/".format(mastodon_account.hood.id), headers=auth_header
|
||||
)
|
||||
print(response.headers)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()[0]["id"] == mastodon_account.id
|
||||
assert response.json()[0]["access_token"] == mastodon_account.access_token
|
||||
assert response.json()[1]["id"] == mastodon2.id
|
||||
assert response.json()[1]["access_token"] == mastodon2.access_token
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_get_bots_invalid_id(client, auth_header, hood_id):
|
||||
response = await client.get("/api/hoods/1337/mastodon/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.get("/api/hoods/wrong/mastodon/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_get_bots_unauthorized(client, hood_id):
|
||||
response = await client.get("/api/hoods/{0}/mastodon/".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mastodon_public(client, mastodon_account, mastodon_instance):
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/mastodon/public".format(mastodon_account.hood.id)
|
||||
)
|
||||
assert response.json()[0]["username"] == mastodon_account.username
|
||||
assert response.json()[0]["instance"] == mastodon_instance.name
|
21
backend/tests/tests_telegram/conftest.py
Normal file
21
backend/tests/tests_telegram/conftest.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
import pytest
|
||||
|
||||
from kibicara.model import Hood
|
||||
from kibicara.platforms.telegram.model import Telegram
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.mark.anyio
|
||||
async def telegram(hood_id, bot):
|
||||
hood = await Hood.get(id=hood_id)
|
||||
return await Telegram.create(
|
||||
hood=hood,
|
||||
api_token=bot["api_token"],
|
||||
welcome_message=bot["welcome_message"],
|
||||
)
|
84
backend/tests/tests_telegram/test_api_telegram_create_bot.py
Normal file
84
backend/tests/tests_telegram/test_api_telegram_create_bot.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
|
||||
from kibicara.platforms import telegram
|
||||
from kibicara.platforms.telegram.model import Telegram
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def disable_spawner(monkeypatch):
|
||||
class DoNothing:
|
||||
def start(self, bot):
|
||||
assert bot is not None
|
||||
|
||||
monkeypatch.setattr(telegram.webapi, "spawner", DoNothing())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [{"api_token": "string", "welcome_message": "string"}])
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_create_bot(
|
||||
client,
|
||||
disable_spawner,
|
||||
hood_id,
|
||||
auth_header,
|
||||
monkeypatch,
|
||||
body,
|
||||
):
|
||||
def check_token_mock(token):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(telegram.webapi, "check_token", check_token_mock)
|
||||
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/telegram/".format(hood_id),
|
||||
json=body,
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
bot_id = response.json()["id"]
|
||||
telegram_obj = await Telegram.get(id=bot_id)
|
||||
assert response.json()["api_token"] == body["api_token"] == telegram_obj.api_token
|
||||
assert (
|
||||
response.json()["welcome_message"]
|
||||
== body["welcome_message"]
|
||||
== telegram_obj.welcome_message
|
||||
)
|
||||
assert telegram_obj.enabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [{"api_token": "string", "welcome_message": "string"}])
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_invalid_api_token(
|
||||
client,
|
||||
disable_spawner,
|
||||
hood_id,
|
||||
auth_header,
|
||||
monkeypatch,
|
||||
body,
|
||||
):
|
||||
response = await client.post(
|
||||
"/api/hoods/{0}/telegram/".format(hood_id),
|
||||
json=body,
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_create_telegram_invalid_id(client, auth_header):
|
||||
response = await client.post("/api/hoods/1337/telegram/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.post("/api/hoods/wrong/telegram/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_create_unauthorized(client, hood_id):
|
||||
response = await client.post("/api/hoods/{hood_id}/telegram/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
56
backend/tests/tests_telegram/test_api_telegram_delete_bot.py
Normal file
56
backend/tests/tests_telegram/test_api_telegram_delete_bot.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
from tortoise.exceptions import DoesNotExist
|
||||
|
||||
from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bot", [{"api_token": "apitoken123", "welcome_message": "msg"}]
|
||||
)
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_delete_bot(client, bot, telegram, auth_header):
|
||||
await TelegramSubscriber.create(user_id=1234, bot=telegram)
|
||||
await TelegramSubscriber.create(user_id=5678, bot=telegram)
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id),
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
with pytest.raises(DoesNotExist):
|
||||
await Telegram.get(id=telegram.id)
|
||||
with pytest.raises(DoesNotExist):
|
||||
await TelegramSubscriber.get(id=telegram.id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_delete_bot_invalid_id(client, auth_header, hood_id):
|
||||
response = await client.delete("/api/hoods/1337/telegram/123", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.delete("/api/hoods/wrong/telegram/123", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/telegram/7331".format(hood_id), headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/telegram/wrong".format(hood_id), headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bot", [{"api_token": "apitoken123", "welcome_message": "msg"}]
|
||||
)
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_delete_bot_unauthorized(client, bot, telegram):
|
||||
response = await client.delete(
|
||||
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
49
backend/tests/tests_telegram/test_api_telegram_get_bot.py
Normal file
49
backend/tests/tests_telegram/test_api_telegram_get_bot.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bot", [{"api_token": "apitoken123", "welcome_message": "msg"}]
|
||||
)
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_get_bot(client, auth_header, bot, telegram):
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id),
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["id"] == telegram.id
|
||||
assert response.json()["api_token"] == telegram.api_token
|
||||
assert response.json()["welcome_message"] == telegram.welcome_message
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_get_bot_invalid_id(client, auth_header, hood_id):
|
||||
response = await client.get("/api/hoods/1337/telegram/123", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.get("/api/hoods/wrong/telegram/123", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/telegram/7331".format(hood_id), headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/telegram/wrong".format(hood_id), headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bot", [{"api_token": "apitoken456", "welcome_message": "msg"}]
|
||||
)
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_get_bot_unauthorized(client, bot, telegram):
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
48
backend/tests/tests_telegram/test_api_telegram_get_bots.py
Normal file
48
backend/tests/tests_telegram/test_api_telegram_get_bots.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
|
||||
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
|
||||
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
|
||||
#
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from fastapi import status
|
||||
import pytest
|
||||
|
||||
from kibicara.model import Hood
|
||||
from kibicara.platforms.telegram.model import Telegram
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_get_bots(client, auth_header, hood_id):
|
||||
hood = await Hood.get(id=hood_id)
|
||||
telegram0 = await Telegram.create(
|
||||
hood=hood,
|
||||
api_token="api_token123",
|
||||
welcome_message="welcome_message123",
|
||||
)
|
||||
telegram1 = await Telegram.create(
|
||||
hood=hood,
|
||||
api_token="api_token456",
|
||||
welcome_message="welcome_message123",
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/hoods/{0}/telegram/".format(telegram0.hood.id), headers=auth_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()[0]["id"] == telegram0.id
|
||||
assert response.json()[0]["api_token"] == telegram0.api_token
|
||||
assert response.json()[1]["id"] == telegram1.id
|
||||
assert response.json()[1]["api_token"] == telegram1.api_token
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_get_bots_invalid_id(client, auth_header, hood_id):
|
||||
response = await client.get("/api/hoods/1337/telegram/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
response = await client.get("/api/hoods/wrong/telegram/", headers=auth_header)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_get_bots_unauthorized(client, hood_id):
|
||||
response = await client.get("/api/hoods/{0}/telegram/".format(hood_id))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
@ -32,6 +32,7 @@ speed-measure-plugin*.json
|
|||
.history/*
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
53
frontend/README.md
Normal file
53
frontend/README.md
Normal file
|
@ -0,0 +1,53 @@
|
|||
# Kibicara Frontend
|
||||
|
||||
## Maintenance
|
||||
### Compatibility List
|
||||
|
||||
** The current compatible nodejs version is nodejs18 **
|
||||
|
||||
To check which nodejs version is required for an angular version, see [this stackoverflow post](https://stackoverflow.com/questions/60248452/is-there-a-compatibility-list-for-angular-angular-cli-and-node-js).
|
||||
|
||||
### Updating Angular to the newest version
|
||||
|
||||
To update Angular to a newer version, please refer to the [official Angular update page](https://update.angular.io/).
|
||||
|
||||
tldr of the update process:
|
||||
0. Check which Angular version this project is currently using by looking at the version of @angular/core in the [package.json](./package.json) file.
|
||||
1. Decide to which version you want to bump (e.g. 9.2 to 15.2). This depends which node version is running on the servers and which one is compatible with the angular version (see stackoverflow post above).
|
||||
2. Add all existing dependencies listed on the update page e.g. `npm run-script ng add @angular/localize`
|
||||
3. Bump the versions: You need to bump to every major version, so from 9.2 to 15.2 you will need to repeat these steps for 9 -> 10 -> 11 -> 12 -> 13 -> 14 -> 15
|
||||
1. Version bump to the next qangular/core and @angular/cli version (e.g. here we do it from version 9 to version 10): `npx @angular/cli@10 update @angular/core@10 @angular/cli@10`
|
||||
2. Version bump material: `npx @angular/cli@10 update @angular/material@10`
|
||||
3. Update ngx-markdown to the next version: `npm install ngx-markdown@10 --save-dev`
|
||||
4. Test if the frontend still works and fix all errors: `npm run-script ng s -o`
|
||||
4. Check the official angular update page for any breaking changes and google how to fix them: `ng generate @angular/material:mdc-migration`
|
||||
|
||||
## Development
|
||||
|
||||
### Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
|
||||
|
||||
### Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
### Updating the openapi frontend part
|
||||
|
||||
The frontend uses openapi-generator to generate the calls to the backend. To regenerate this after an API change, please run: `npm run openapi-generator`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
|
|
@ -22,7 +22,6 @@
|
|||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"aot": true,
|
||||
"assets": ["src/favicon.ico", "src/assets"],
|
||||
"styles": [
|
||||
"./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
|
||||
|
@ -31,7 +30,13 @@
|
|||
"stylePreprocessorOptions": {
|
||||
"includePaths": ["src/sass"]
|
||||
},
|
||||
"scripts": []
|
||||
"scripts": [],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
|
@ -44,7 +49,6 @@
|
|||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
|
@ -62,7 +66,8 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
|
@ -99,17 +104,6 @@
|
|||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": [
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.spec.json",
|
||||
"e2e/tsconfig.json"
|
||||
],
|
||||
"exclude": ["**/node_modules/**", "src/app/core/api/**"]
|
||||
}
|
||||
},
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
|
@ -124,6 +118,5 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultProject": "kibicara-frontend"
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"target": "es2018",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
80
frontend/favicon.svg
Normal file
80
frontend/favicon.svg
Normal file
|
@ -0,0 +1,80 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
sodipodi:docname="favicon.svg"
|
||||
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
|
||||
id="svg964"
|
||||
version="1.1"
|
||||
viewBox="0 0 12.7 12.7"
|
||||
height="48"
|
||||
width="48">
|
||||
<defs
|
||||
id="defs958" />
|
||||
<sodipodi:namedview
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-height="726"
|
||||
inkscape:window-width="1364"
|
||||
units="px"
|
||||
showgrid="true"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:current-layer="layer1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:cy="21.735033"
|
||||
inkscape:cx="23.387597"
|
||||
inkscape:zoom="10.229167"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
id="base">
|
||||
<inkscape:grid
|
||||
empspacing="8"
|
||||
spacingy="0.26458333"
|
||||
spacingx="0.26458333"
|
||||
id="grid1527"
|
||||
type="xygrid" />
|
||||
</sodipodi:namedview>
|
||||
<metadata
|
||||
id="metadata961">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="layer1"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Layer 1">
|
||||
<circle
|
||||
r="6.0854168"
|
||||
cy="6.3499999"
|
||||
cx="6.3499999"
|
||||
id="path1529"
|
||||
style="fill:#673ab7;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;fill-opacity:1" />
|
||||
<text
|
||||
id="text1533"
|
||||
y="10.337475"
|
||||
x="6.1533628"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:10.5833px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:Roboto;text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
|
||||
xml:space="preserve"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Roboto;-inkscape-font-specification:Roboto;fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
|
||||
y="10.337475"
|
||||
x="6.1533628"
|
||||
id="tspan1531"
|
||||
sodipodi:role="line">k</tspan></text>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
26775
frontend/package-lock.json
generated
Normal file
26775
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
54
frontend/package.json
Normal file
54
frontend/package.json
Normal file
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "kibicara-frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"openapi-generator": "wget http://localhost:8000/api/openapi.json && openapi-generator generate -g typescript-angular --additional-properties=providedInRoot=true -o src/app/core/api -i openapi.json && rm openapi.json",
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^15.2.3",
|
||||
"@angular/cdk": "^15.2.3",
|
||||
"@angular/common": "^15.2.3",
|
||||
"@angular/compiler": "^15.2.3",
|
||||
"@angular/core": "^15.2.3",
|
||||
"@angular/forms": "^15.2.3",
|
||||
"@angular/localize": "^15.2.3",
|
||||
"@angular/material": "^15.2.3",
|
||||
"@angular/platform-browser": "^15.2.3",
|
||||
"@angular/platform-browser-dynamic": "^15.2.3",
|
||||
"@angular/router": "^15.2.3",
|
||||
"ng2-search-filter": "^0.5.1",
|
||||
"rxjs": "~6.5.4",
|
||||
"tslib": "^2.0.0",
|
||||
"zone.js": "~0.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^15.2.4",
|
||||
"@angular/cli": "^15.2.4",
|
||||
"@angular/compiler-cli": "^15.2.3",
|
||||
"@angular/language-service": "^15.2.3",
|
||||
"@openapitools/openapi-generator-cli": "^1.0.18-5.0.0-beta2",
|
||||
"@types/jasmine": "~3.6.0",
|
||||
"@types/jasminewd2": "~2.0.3",
|
||||
"@types/node": "^12.12.64",
|
||||
"codelyzer": "^6.0.0",
|
||||
"jasmine-core": "~3.6.0",
|
||||
"jasmine-spec-reporter": "~5.0.0",
|
||||
"karma": "~6.4.1",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage-istanbul-reporter": "~3.0.2",
|
||||
"karma-jasmine": "~4.0.0",
|
||||
"karma-jasmine-html-reporter": "^1.5.0",
|
||||
"ngx-markdown": "^15.1.2",
|
||||
"protractor": "~7.0.0",
|
||||
"ts-node": "~8.3.0",
|
||||
"tslint": "~6.1.0",
|
||||
"typescript": "~4.9.5"
|
||||
}
|
||||
}
|
|
@ -28,7 +28,7 @@ const routes: Routes = [
|
|||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes), SharedModule],
|
||||
imports: [RouterModule.forRoot(routes, {}), SharedModule],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
|
@ -1,9 +1,9 @@
|
|||
import { TestBed, async } from '@angular/core/testing';
|
||||
import { TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
|
@ -1,4 +1,4 @@
|
|||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { ConfirmComponent } from './confirm.component';
|
||||
|
||||
|
@ -6,7 +6,7 @@ describe('ConfirmComponent', () => {
|
|||
let component: ConfirmComponent;
|
||||
let fixture: ComponentFixture<ConfirmComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ ConfirmComponent ]
|
||||
})
|
|
@ -1,5 +1,5 @@
|
|||
<div class="container">
|
||||
<mat-card class="login-form">
|
||||
<mat-card appearance="outlined" class="login-form">
|
||||
<mat-card-header>
|
||||
<h2>Log in as hood admin!</h2>
|
||||
</mat-card-header>
|
|
@ -7,7 +7,7 @@
|
|||
margin-top: 3%;
|
||||
}
|
||||
|
||||
.mat-card:not([class*="mat-elevation-z"]) {
|
||||
.mat-mdc-card:not([class*="mat-elevation-z"]) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { LoginComponent } from './login.component';
|
||||
|
||||
|
@ -6,7 +6,7 @@ describe('LoginComponent', () => {
|
|||
let component: LoginComponent;
|
||||
let fixture: ComponentFixture<LoginComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ LoginComponent ]
|
||||
})
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { LoginService } from '../../core/auth/login.service';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Validators, FormGroup, FormBuilder } from '@angular/forms';
|
||||
import { Validators, UntypedFormGroup, UntypedFormBuilder } from '@angular/forms';
|
||||
import { first } from 'rxjs/operators';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
|
@ -11,7 +11,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
|||
styleUrls: ['./login.component.scss'],
|
||||
})
|
||||
export class LoginComponent implements OnInit {
|
||||
loginForm: FormGroup;
|
||||
loginForm: UntypedFormGroup;
|
||||
returnUrl: string;
|
||||
loading = false;
|
||||
submitted = false;
|
||||
|
@ -21,7 +21,7 @@ export class LoginComponent implements OnInit {
|
|||
private loginService: LoginService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private formBuilder: FormBuilder,
|
||||
private formBuilder: UntypedFormBuilder,
|
||||
private snackBar: MatSnackBar
|
||||
) {
|
||||
if (this.loginService.currentHoodAdminValue) {
|
|
@ -1,5 +1,5 @@
|
|||
<div class="container">
|
||||
<mat-card class="login-form">
|
||||
<mat-card appearance="outlined" class="login-form">
|
||||
<mat-card-header>
|
||||
<h2>Reset password</h2>
|
||||
</mat-card-header>
|
|
@ -6,7 +6,7 @@
|
|||
margin-top: 3%;
|
||||
}
|
||||
|
||||
.mat-card:not([class*="mat-elevation-z"]) {
|
||||
.mat-mdc-card:not([class*="mat-elevation-z"]) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { PasswordResetComponent } from './password-reset.component';
|
||||
|
||||
|
@ -6,7 +6,7 @@ describe('PasswordResetComponent', () => {
|
|||
let component: PasswordResetComponent;
|
||||
let fixture: ComponentFixture<PasswordResetComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [PasswordResetComponent],
|
||||
}).compileComponents();
|
|
@ -1,5 +1,5 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
@ -12,7 +12,7 @@ import { LoginService } from 'src/app/core/auth/login.service';
|
|||
styleUrls: ['./password-reset.component.scss'],
|
||||
})
|
||||
export class PasswordResetComponent implements OnInit {
|
||||
resetForm: FormGroup;
|
||||
resetForm: UntypedFormGroup;
|
||||
returnUrl: string;
|
||||
loading = false;
|
||||
submitted = false;
|
||||
|
@ -23,7 +23,7 @@ export class PasswordResetComponent implements OnInit {
|
|||
private loginService: LoginService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private formBuilder: FormBuilder,
|
||||
private formBuilder: UntypedFormBuilder,
|
||||
private snackBar: MatSnackBar
|
||||
) {
|
||||
if (this.loginService.currentHoodAdminValue) {
|
|
@ -1,5 +1,5 @@
|
|||
<div class="container">
|
||||
<mat-card class="login-form">
|
||||
<mat-card appearance="outlined" class="login-form">
|
||||
<mat-card-header>
|
||||
<h2>Enter your new password</h2>
|
||||
</mat-card-header>
|
|
@ -6,7 +6,7 @@
|
|||
margin-top: 3%;
|
||||
}
|
||||
|
||||
.mat-card:not([class*="mat-elevation-z"]) {
|
||||
.mat-mdc-card:not([class*="mat-elevation-z"]) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { SetPasswordComponent } from './set-password.component';
|
||||
|
||||
|
@ -6,7 +6,7 @@ describe('SetPasswordComponent', () => {
|
|||
let component: SetPasswordComponent;
|
||||
let fixture: ComponentFixture<SetPasswordComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [SetPasswordComponent],
|
||||
}).compileComponents();
|
|
@ -1,5 +1,5 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
@ -12,7 +12,7 @@ import { LoginService } from 'src/app/core/auth/login.service';
|
|||
styleUrls: ['./set-password.component.scss'],
|
||||
})
|
||||
export class SetPasswordComponent implements OnInit {
|
||||
resetForm: FormGroup;
|
||||
resetForm: UntypedFormGroup;
|
||||
returnUrl: string;
|
||||
loading = false;
|
||||
submitted = false;
|
||||
|
@ -24,7 +24,7 @@ export class SetPasswordComponent implements OnInit {
|
|||
private loginService: LoginService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private formBuilder: FormBuilder,
|
||||
private formBuilder: UntypedFormBuilder,
|
||||
private snackBar: MatSnackBar
|
||||
) {
|
||||
this.token = this.route.snapshot.queryParams.token;
|
|
@ -1,6 +1,6 @@
|
|||
<div class="container">
|
||||
<div class="banner"></div>
|
||||
<mat-card class="register-form">
|
||||
<mat-card appearance="outlined" class="register-form">
|
||||
<mat-card-header>
|
||||
<h2>Register as a hood admin!</h2>
|
||||
</mat-card-header>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue