First pass at implemenation with pagerduty webhooks

This commit is contained in:
Abel Luck 2022-07-22 12:05:59 +00:00
parent b71b8d95ff
commit 80c6fbd7bb
15 changed files with 1419 additions and 0 deletions

4
.flake8 Normal file
View file

@ -0,0 +1,4 @@
[flake8]
min_python_version = 3.10.0
extend-ignore = E501,E203
ban-relative-imports = true

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
__pycache__
*.egg-info
.*_cache
.venv
devstate
.env*

37
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,37 @@
fail_fast: true
repos:
- repo: local
hooks:
- id: system
name: requirements.txt
entry: poetry export --format=requirements.txt --without-hashes --dev --output=requirements.txt
pass_filenames: false
language: system
- repo: local
hooks:
- id: system
name: black
entry: poetry run black .
pass_filenames: false
language: system
- repo: local
hooks:
- id: system
name: isort
entry: poetry run isort --profile black .
pass_filenames: false
language: system
- repo: local
hooks:
- id: system
name: mypy
entry: poetry run mypy
pass_filenames: false
language: system
- repo: local
hooks:
- id: system
name: flake8
entry: poetry run flake8 ops_bot
pass_filenames: false
language: system

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.9.7

14
Makefile Normal file
View file

@ -0,0 +1,14 @@
POETRY ?= poetry run
SRC := ops_bot
TESTS := tests
fmt:
$(POETRY) black $(SRC)
$(POETRY) isort --profile black $(SRC)
lint:
$(POETRY) flake8 $(SRC)
$(POETRY) bandit --silent --recursive $(SRC)
types:
$(POETRY) mypy $(SRC)
test:
$(POETRY) pytest $(TESTS)

View file

@ -2,8 +2,30 @@
a bot for ops in matrix a bot for ops in matrix
## Dev Setup
`.env`:
```
MATRIX_HOMESERVER=https://matrix.org
MATRIX_USER_ID=@YOURBOT:matrix.org
MATRIX_PASSWORD="changeme"
MATRIX_DEVICE_NAME=my-bot-server
MATRIX_VERIFY_SSL=True
MATRIX_STORE_PATH=/abs/path/to/persistent-store
BOT_ROUTING_KEYS="{\"room1\": \"!XXXX:matrix.org\", \"room2\": \"!YYYYY:matrix.org\"}"
BOT_BEARER_TOKEN="changeme"
```
```
source .env
poetry install
poetry run start
```
## License ## License
```
matrix-ops-bot matrix-ops-bot
Copyright (C) 2022 Abel Luck <abel@guardianproject.info> Copyright (C) 2022 Abel Luck <abel@guardianproject.info>
@ -19,3 +41,4 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
```

1
ops_bot/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "0.1.0"

103
ops_bot/main.py Normal file
View file

@ -0,0 +1,103 @@
import asyncio
from typing import Any, Dict, Optional, cast
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseSettings
from ops_bot import pagerduty
from ops_bot.matrix import MatrixClient, MatrixClientSettings
class BotSettings(BaseSettings):
bearer_token: str
routing_keys: Dict[str, str]
class Config:
env_prefix = "BOT_"
case_sensitive = False
app = FastAPI()
security = HTTPBearer()
async def get_matrix_service(request: Request) -> MatrixClient:
"""A helper to fetch the matrix client from the app state"""
return cast(MatrixClient, request.app.state.matrix_client)
async def matrix_main(matrix_client: MatrixClient) -> None:
"""Execs the matrix client asyncio task"""
workers = [asyncio.create_task(matrix_client.start())]
await asyncio.gather(*workers)
@app.on_event("startup")
async def startup_event() -> None:
bot_settings = BotSettings(_env_file=".env", _env_file_encoding="utf-8")
matrix_settings = MatrixClientSettings(_env_file=".env", _env_file_encoding="utf-8")
matrix_settings.join_rooms = list(bot_settings.routing_keys.values())
c = MatrixClient(settings=matrix_settings)
app.state.matrix_client = c
app.state.bot_settings = bot_settings
asyncio.create_task(matrix_main(c))
@app.on_event("shutdown")
async def shutdown_event() -> None:
await app.state.matrix_client.shutdown()
@app.get("/")
async def root() -> Dict[str, str]:
return {"message": "Hello World"}
def authorize(
request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)
) -> bool:
bearer_token = request.app.state.bot_settings.bearer_token
if credentials.credentials != bearer_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
return True
def get_destination(bot_settings: BotSettings, routing_key: str) -> Optional[str]:
return bot_settings.routing_keys.get(routing_key, None)
@app.post("/hook/pagerduty/{routing_key}")
async def pagerduty_hook(
request: Request,
matrix_client: MatrixClient = Depends(get_matrix_service),
auth: bool = Depends(authorize),
) -> Dict[str, str]:
payload: Any = await request.json()
room_id = get_destination(
request.app.state.bot_settings, request.path_params["routing_key"]
)
if room_id is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Unknown routing key"
)
msg_plain, msg_formatted = pagerduty.parse_pagerduty_event(payload)
await matrix_client.room_send(
room_id,
msg_plain,
message_formatted=msg_formatted,
)
return {"message": msg_plain, "message_formatted": msg_formatted}
def start_dev() -> None:
uvicorn.run("ops_bot.main:app", port=1111, host="127.0.0.1", reload=True)
def start() -> None:
uvicorn.run("ops_bot.main:app", port=1111, host="0.0.0.0") # nosec B104

166
ops_bot/matrix.py Normal file
View file

@ -0,0 +1,166 @@
import json
import logging
import pathlib
import sys
from typing import List, Optional, Protocol
from markdown import markdown
from nio import AsyncClient, AsyncClientConfig, LoginResponse
from pydantic import BaseModel, BaseSettings
class ClientCredentials(BaseModel):
homeserver: str
user_id: str
device_id: str
access_token: str
class CredentialStorage(Protocol):
def save_config(self, config: ClientCredentials) -> None:
"""Save config"""
def read_config(self) -> ClientCredentials:
"""Load config"""
class LocalCredentialStore:
def __init__(self, config_file_path: pathlib.Path):
self.credential_file: pathlib.Path = config_file_path
def save(self, config: ClientCredentials) -> None:
with self.credential_file.open(mode="w") as f:
json.dump(config.dict(), f)
def read(self) -> ClientCredentials:
with self.credential_file.open(mode="r") as f:
return ClientCredentials(**json.load(f))
def exists(self) -> bool:
return self.credential_file.exists()
class MatrixClientSettings(BaseSettings):
homeserver: str
user_id: str
password: str
device_name: str
store_path: str
join_rooms: Optional[List[str]]
verify_ssl: Optional[bool] = True
class Config:
env_prefix = "MATRIX_"
case_sensitive = False
class MatrixClient:
def __init__(self, settings: MatrixClientSettings):
self.settings = settings
self.store_path = pathlib.Path(settings.store_path)
self.credential_store = LocalCredentialStore(
self.store_path.joinpath("credentials.json")
)
self.client: AsyncClient = None
self.client_config = AsyncClientConfig(
max_limit_exceeded=0,
max_timeouts=0,
store_sync_tokens=True,
encryption_enabled=True,
)
self.greeting_sent = False
async def start(self) -> None:
await self.login()
if self.client.should_upload_keys:
await self.client.keys_upload()
if self.settings.join_rooms:
for room in self.settings.join_rooms:
await self.client.join(room)
await self.client.joined_rooms()
await self.client.sync_forever(timeout=300000, full_state=True)
def save_credentials(self, resp: LoginResponse, homeserver: str) -> None:
credentials = ClientCredentials(
homeserver=homeserver,
user_id=resp.user_id,
device_id=resp.device_id,
access_token=resp.access_token,
)
self.credential_store.save(credentials)
async def login_fresh(self) -> None:
self.client = AsyncClient(
self.settings.homeserver,
self.settings.user_id,
self.settings.store_path,
config=self.client_config,
ssl=self.settings.verify_ssl,
)
response = await self.client.login(
password=self.settings.password, device_name=self.settings.device_name
)
if isinstance(response, LoginResponse):
self.save_credentials(response, self.settings.homeserver)
else:
logging.error(
f'Login for "{self.settings.user_id}" via homeserver="{self.settings.homeserver}"'
)
logging.info(f"Login failure response: {response}")
sys.exit(1)
async def login_with_credentials(self) -> None:
credentials = self.credential_store.read()
self.client = AsyncClient(
credentials.homeserver,
credentials.user_id,
device_id=credentials.device_id,
store_path=self.store_path,
config=self.client_config,
ssl=True,
)
self.client.restore_login(
user_id=credentials.user_id,
device_id=credentials.device_id,
access_token=credentials.access_token,
)
async def login(self) -> None:
if self.credential_store.exists():
await self.login_with_credentials()
else:
await self.login_fresh()
async def room_send(
self,
room: str,
message: str,
message_formatted: Optional[str] = None,
) -> None:
content = {
"msgtype": "m.text",
"body": f"{message}",
}
if message_formatted is not None:
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = markdown(
message_formatted, extensions=["extra"]
)
await self.client.room_send(
room_id=room,
message_type="m.room.message",
content=content,
ignore_unverified_devices=True,
)
async def shutdown(self) -> None:
await self.client.close()

41
ops_bot/pagerduty.py Normal file
View file

@ -0,0 +1,41 @@
import json
from typing import Any, Tuple
def urgency_color(urgency: str) -> str:
if urgency == "high":
return "#dc3545"
else:
return "#17a2b8"
def parse_pagerduty_event(payload: Any) -> Tuple[str, str]:
"""
Parses a pagerduty webhook v3 event into a human readable message.
Returns a tuple where the first item is plain text, and the second item is matrix html formatted text
"""
event = payload["event"]
# evt_id = event["id"]
# event_type = event["event_type"]
# resource_type = event["resource_type"]
# occurred_at = event["occurred_at"]
data = event["data"]
data_type = data["type"]
if data_type == "incident":
url = data["html_url"]
status: str = data["status"]
title: str = data["title"]
service_name: str = data["service"]["summary"]
urgency: str = data.get("urgency", "high")
plain = f"{status}: on {service_name}: {title} {url}"
formatted = f"<strong><font color={urgency_color(urgency)}>{status.upper()}</font></strong> on {service_name}: [{title}]({url})"
return plain, formatted
payload_str = json.dumps(payload, sort_keys=True, indent=2)
return (
"unhandled",
f"""**unhandled pager duty event**
<pre><code class="language-json">{payload_str}</code></pre>
""",
)

884
poetry.lock generated Normal file
View file

@ -0,0 +1,884 @@
[[package]]
name = "aiofiles"
version = "0.6.0"
description = "File support for asyncio."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "aiohttp"
version = "3.8.1"
description = "Async http client/server framework (asyncio)"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
aiosignal = ">=1.1.2"
async-timeout = ">=4.0.0a3,<5.0"
attrs = ">=17.3.0"
charset-normalizer = ">=2.0,<3.0"
frozenlist = ">=1.1.1"
multidict = ">=4.5,<7.0"
yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["aiodns", "brotli", "cchardet"]
[[package]]
name = "aiohttp-socks"
version = "0.7.1"
description = "Proxy connector for aiohttp"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
aiohttp = ">=2.3.2"
attrs = ">=19.2.0"
python-socks = {version = ">=2.0.0,<3.0.0", extras = ["asyncio"]}
[[package]]
name = "aiosignal"
version = "1.2.0"
description = "aiosignal: a list of registered asynchronous callbacks"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
frozenlist = ">=1.1.0"
[[package]]
name = "anyio"
version = "3.6.1"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
trio = ["trio (>=0.16)"]
[[package]]
name = "async-timeout"
version = "4.0.2"
description = "Timeout context manager for asyncio programs"
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
version = "21.4.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "bandit"
version = "1.7.4"
description = "Security oriented static analyser for python code."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""}
GitPython = ">=1.0.1"
PyYAML = ">=5.3.1"
stevedore = ">=1.20.0"
[package.extras]
test = ["coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml", "beautifulsoup4 (>=4.8.0)", "pylint (==1.9.4)"]
toml = ["toml"]
yaml = ["pyyaml"]
[[package]]
name = "black"
version = "22.6.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cachetools"
version = "4.2.4"
description = "Extensible memoizing collections and decorators"
category = "main"
optional = false
python-versions = "~=3.5"
[[package]]
name = "cffi"
version = "1.15.1"
description = "Foreign Function Interface for Python calling C code."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
pycparser = "*"
[[package]]
name = "charset-normalizer"
version = "2.1.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = ">=3.6.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.5"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "fastapi"
version = "0.79.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "main"
optional = false
python-versions = ">=3.6.1"
[package.dependencies]
pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0"
starlette = "0.19.1"
[package.extras]
all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"]
dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)", "pre-commit (>=2.17.0,<3.0.0)"]
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<7.0.0)"]
test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==22.3.0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==4.2.1)", "types-orjson (==3.6.2)", "types-dataclasses (==0.6.5)"]
[[package]]
name = "flake8"
version = "4.0.1"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mccabe = ">=0.6.0,<0.7.0"
pycodestyle = ">=2.8.0,<2.9.0"
pyflakes = ">=2.4.0,<2.5.0"
[[package]]
name = "frozenlist"
version = "1.3.0"
description = "A list-like structure which implements collections.abc.MutableSequence"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "future"
version = "0.18.2"
description = "Clean single-source support for Python 3 and 2"
category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "gitdb"
version = "4.0.9"
description = "Git Object Database"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
smmap = ">=3.0.1,<6"
[[package]]
name = "gitpython"
version = "3.1.27"
description = "GitPython is a python library used to interact with Git repositories"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
gitdb = ">=4.0.1,<5"
[[package]]
name = "h11"
version = "0.12.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "h2"
version = "4.1.0"
description = "HTTP/2 State-Machine based protocol implementation"
category = "main"
optional = false
python-versions = ">=3.6.1"
[package.dependencies]
hpack = ">=4.0,<5"
hyperframe = ">=6.0,<7"
[[package]]
name = "hpack"
version = "4.0.0"
description = "Pure-Python HPACK header compression"
category = "main"
optional = false
python-versions = ">=3.6.1"
[[package]]
name = "hyperframe"
version = "6.0.1"
description = "HTTP/2 framing layer for Python"
category = "main"
optional = false
python-versions = ">=3.6.1"
[[package]]
name = "idna"
version = "3.3"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "importlib-metadata"
version = "4.12.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
perf = ["ipython"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
[[package]]
name = "isort"
version = "5.10.1"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
python-versions = ">=3.6.1,<4.0"
[package.extras]
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
requirements_deprecated_finder = ["pipreqs", "pip-api"]
colors = ["colorama (>=0.4.3,<0.5.0)"]
plugins = ["setuptools"]
[[package]]
name = "jsonschema"
version = "3.2.0"
description = "An implementation of JSON Schema validation for Python"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
attrs = ">=17.4.0"
pyrsistent = ">=0.14.0"
six = ">=1.11.0"
[package.extras]
format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"]
format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"]
[[package]]
name = "logbook"
version = "1.5.3"
description = "A logging replacement for Python"
category = "main"
optional = false
python-versions = "*"
[package.extras]
all = ["redis", "brotli", "pytest (>4.0)", "execnet (>=1.0.9)", "cython", "pyzmq", "pytest-cov (>=2.6)", "sqlalchemy", "jinja2"]
compression = ["brotli"]
dev = ["pytest-cov (>=2.6)", "pytest (>4.0)", "cython"]
execnet = ["execnet (>=1.0.9)"]
jinja = ["jinja2"]
redis = ["redis"]
sqlalchemy = ["sqlalchemy"]
test = ["pytest-cov (>=2.6)", "pytest (>4.0)"]
zmq = ["pyzmq"]
[[package]]
name = "markdown"
version = "3.4.1"
description = "Python implementation of Markdown."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
[package.extras]
testing = ["coverage", "pyyaml"]
[[package]]
name = "matrix-nio"
version = "0.19.0"
description = "A Python Matrix client library, designed according to sans I/O principles."
category = "main"
optional = false
python-versions = ">=3.6.1,<4.0.0"
[package.dependencies]
aiofiles = ">=0.6.0,<0.7.0"
aiohttp = ">=3.7.4,<4.0.0"
aiohttp-socks = ">=0.7.0,<0.8.0"
atomicwrites = {version = ">=1.4.0,<2.0.0", optional = true, markers = "extra == \"e2e\""}
cachetools = {version = ">=4.2.1,<5.0.0", optional = true, markers = "extra == \"e2e\""}
future = ">=0.18.2,<0.19.0"
h11 = ">=0.12.0,<0.13.0"
h2 = ">=4.0.0,<5.0.0"
jsonschema = ">=3.2.0,<4.0.0"
logbook = ">=1.5.3,<2.0.0"
peewee = {version = ">=3.14.4,<4.0.0", optional = true, markers = "extra == \"e2e\""}
pycryptodome = ">=3.10.1,<4.0.0"
python-olm = {version = ">=3.1.3,<4.0.0", optional = true, markers = "extra == \"e2e\""}
unpaddedbase64 = ">=2.1.0,<3.0.0"
[package.extras]
e2e = ["python-olm (>=3.1.3,<4.0.0)", "peewee (>=3.14.4,<4.0.0)", "cachetools (>=4.2.1,<5.0.0)", "atomicwrites (>=1.4.0,<2.0.0)"]
[[package]]
name = "mccabe"
version = "0.6.1"
description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "more-itertools"
version = "8.13.0"
description = "More routines for operating on iterables, beyond itertools"
category = "dev"
optional = false
python-versions = ">=3.5"
[[package]]
name = "multidict"
version = "6.0.2"
description = "multidict implementation"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "mypy"
version = "0.971"
description = "Optional static typing for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mypy-extensions = ">=0.4.3"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = ">=3.10"
[package.extras]
dmypy = ["psutil (>=4.0)"]
python2 = ["typed-ast (>=1.4.0,<2)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "pathspec"
version = "0.9.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[[package]]
name = "pbr"
version = "5.9.0"
description = "Python Build Reasonableness"
category = "dev"
optional = false
python-versions = ">=2.6"
[[package]]
name = "peewee"
version = "3.15.1"
description = "a little orm"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "platformdirs"
version = "2.5.2"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
[[package]]
name = "pluggy"
version = "0.13.1"
description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras]
dev = ["pre-commit", "tox"]
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycodestyle"
version = "2.8.0"
description = "Python style guide checker"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycparser"
version = "2.21"
description = "C parser in Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pycryptodome"
version = "3.15.0"
description = "Cryptographic library for Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pydantic"
version = "1.9.1"
description = "Data validation and settings management using python type hints"
category = "main"
optional = false
python-versions = ">=3.6.1"
[package.dependencies]
python-dotenv = {version = ">=0.10.4", optional = true, markers = "extra == \"dotenv\""}
typing-extensions = ">=3.7.4.3"
[package.extras]
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyflakes"
version = "2.4.0"
description = "passive checker of Python programs"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "dev"
optional = false
python-versions = ">=3.6.8"
[package.extras]
diagrams = ["railroad-diagrams", "jinja2"]
[[package]]
name = "pyrsistent"
version = "0.18.1"
description = "Persistent/Functional/Immutable data structures"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "pytest"
version = "5.4.3"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.5"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=17.4.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
more-itertools = ">=4.0.0"
packaging = "*"
pluggy = ">=0.12,<1.0"
py = ">=1.5.0"
wcwidth = "*"
[package.extras]
checkqa-mypy = ["mypy (==v0.761)"]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
[[package]]
name = "python-dotenv"
version = "0.20.0"
description = "Read key-value pairs from a .env file and set them as environment variables"
category = "main"
optional = false
python-versions = ">=3.5"
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "python-olm"
version = "3.1.3"
description = "python CFFI bindings for the olm cryptographic ratchet library"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
cffi = ">=1.0.0"
future = "*"
[[package]]
name = "python-socks"
version = "2.0.3"
description = "Core proxy (SOCKS4, SOCKS5, HTTP tunneling) functionality for Python"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
async-timeout = {version = ">=3.0.1", optional = true, markers = "extra == \"asyncio\""}
[package.extras]
anyio = ["anyio (>=3.3.4)"]
asyncio = ["async-timeout (>=3.0.1)"]
curio = ["curio (>=1.4)"]
trio = ["trio (>=0.16.0)"]
[[package]]
name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "smmap"
version = "5.0.0"
description = "A pure Python implementation of a sliding window memory map manager"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "sniffio"
version = "1.2.0"
description = "Sniff out which async library your code is running under"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "starlette"
version = "0.19.1"
description = "The little ASGI library that shines."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
anyio = ">=3.4.0,<5"
typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
[package.extras]
full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"]
[[package]]
name = "stevedore"
version = "4.0.0"
description = "Manage dynamic plugins for Python applications"
category = "dev"
optional = false
python-versions = ">=3.8"
[package.dependencies]
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
[[package]]
name = "termcolor"
version = "1.1.0"
description = "ANSII Color formatting for output in terminal."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "types-markdown"
version = "3.4.0"
description = "Typing stubs for Markdown"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-termcolor"
version = "1.1.5"
description = "Typing stubs for termcolor"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "typing-extensions"
version = "4.3.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "unpaddedbase64"
version = "2.1.0"
description = "Encode and decode Base64 without \"=\" padding"
category = "main"
optional = false
python-versions = ">=3.6,<4.0"
[[package]]
name = "uvicorn"
version = "0.18.2"
description = "The lightning-fast ASGI server."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchfiles (>=0.13)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
[[package]]
name = "wcwidth"
version = "0.2.5"
description = "Measures the displayed width of unicode strings in a terminal"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "yarl"
version = "1.7.2"
description = "Yet another URL library"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
[[package]]
name = "zipp"
version = "3.8.1"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "294fd35d1328fe80c8d9d4aa1e4eaa86433e03e6166eef0c4f6404919dd455c8"
[metadata.files]
aiofiles = []
aiohttp = []
aiohttp-socks = []
aiosignal = []
anyio = []
async-timeout = []
atomicwrites = []
attrs = []
bandit = []
black = []
cachetools = []
cffi = []
charset-normalizer = []
click = []
colorama = []
fastapi = []
flake8 = []
frozenlist = []
future = []
gitdb = []
gitpython = []
h11 = []
h2 = []
hpack = []
hyperframe = []
idna = []
importlib-metadata = []
isort = []
jsonschema = []
logbook = []
markdown = []
matrix-nio = []
mccabe = []
more-itertools = []
multidict = []
mypy = []
mypy-extensions = []
packaging = []
pathspec = []
pbr = []
peewee = []
platformdirs = []
pluggy = []
py = []
pycodestyle = []
pycparser = []
pycryptodome = []
pydantic = []
pyflakes = []
pyparsing = []
pyrsistent = []
pytest = []
python-dotenv = []
python-olm = []
python-socks = []
pyyaml = []
six = []
smmap = []
sniffio = []
starlette = []
stevedore = []
termcolor = []
tomli = []
types-markdown = []
types-termcolor = []
typing-extensions = []
unpaddedbase64 = []
uvicorn = []
wcwidth = []
yarl = []
zipp = []

63
pyproject.toml Normal file
View file

@ -0,0 +1,63 @@
[tool.poetry]
name = "ops_bot"
version = "0.1.0"
description = ""
authors = ["Abel Luck <abel@guardianproject.info>"]
[tool.poetry.dependencies]
python = "^3.9"
matrix-nio = {extras = ["e2e"], version = "^0.19.0"}
fastapi = "^0.79.0"
uvicorn = "^0.18.2"
termcolor = "^1.1.0"
Markdown = "^3.4.1"
pydantic = {extras = ["dotenv"], version = "^1.9.1"}
[tool.poetry.dev-dependencies]
pytest = "^5.2"
black = "^22.6.0"
isort = "^5.10.1"
mypy = "^0.971"
bandit = "^1.7.4"
flake8 = "^4.0.1"
types-Markdown = "^3.4.0"
types-termcolor = "^1.1.5"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
start = "ops_bot.main:start_dev"
[tool.black]
line-length = 88
target-version = ['py39']
[tool.mypy]
files = "ops_bot,tests"
mypy_path = "ops_bot"
ignore_missing_imports = true
follow_imports = "silent"
# Ensure full coverage
disallow_untyped_calls = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_decorators = true
check_untyped_defs = true
# Restrict dynamic typing
disallow_any_generics = true
disallow_subclassing_any = true
warn_return_any = true
# Know exactly what you're doing
warn_redundant_casts = true
warn_unused_ignores = true
warn_unused_configs = true
warn_unreachable = true
show_error_codes = true
# Explicit is better than implicit
no_implicit_optional = true

71
requirements.txt Normal file
View file

@ -0,0 +1,71 @@
aiofiles==0.6.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
aiohttp-socks==0.7.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
aiohttp==3.8.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6"
aiosignal==1.2.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6"
anyio==3.6.1; python_version >= "3.6" and python_full_version >= "3.6.2"
async-timeout==4.0.2; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6"
atomicwrites==1.4.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and (python_version >= "3.5" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.5" and python_full_version >= "3.4.0")
attrs==21.4.0; python_full_version >= "3.6.1" and python_version >= "3.6" and python_full_version < "4.0.0"
bandit==1.7.4; python_version >= "3.7"
black==22.6.0; python_full_version >= "3.6.2"
cachetools==4.2.4; python_version >= "3.5" and python_version < "4.0" and python_full_version >= "3.6.1" and python_full_version < "4.0.0"
cffi==1.15.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
charset-normalizer==2.1.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6"
click==8.1.3; python_version >= "3.7" and python_full_version >= "3.6.2"
colorama==0.4.5; sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.6.2" and platform_system == "Windows"
fastapi==0.79.0; python_full_version >= "3.6.1"
flake8==4.0.1; python_version >= "3.6"
frozenlist==1.3.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.7"
future==0.18.2; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
gitdb==4.0.9; python_version >= "3.7"
gitpython==3.1.27; python_version >= "3.7"
h11==0.12.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.7"
h2==4.1.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
hpack==4.0.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
hyperframe==6.0.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
idna==3.3; python_full_version >= "3.6.2" and python_full_version < "4.0.0" and python_version >= "3.6"
importlib-metadata==4.12.0; python_version < "3.10" and python_version >= "3.7"
isort==5.10.1; python_full_version >= "3.6.1" and python_version < "4.0"
jsonschema==3.2.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
logbook==1.5.3; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
markdown==3.4.1; python_version >= "3.7"
matrix-nio==0.19.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
mccabe==0.6.1; python_version >= "3.6"
more-itertools==8.13.0; python_version >= "3.5"
multidict==6.0.2; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.7"
mypy-extensions==0.4.3; python_full_version >= "3.6.2" and python_version >= "3.6"
mypy==0.971; python_version >= "3.6"
packaging==21.3; python_version >= "3.6"
pathspec==0.9.0; python_full_version >= "3.6.2"
pbr==5.9.0; python_version >= "3.8"
peewee==3.15.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
platformdirs==2.5.2; python_version >= "3.7" and python_full_version >= "3.6.2"
pluggy==0.13.1; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.5"
py==1.11.0; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.5"
pycodestyle==2.8.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6"
pycparser==2.21; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
pycryptodome==3.15.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
pydantic==1.9.1; python_full_version >= "3.6.1"
pyflakes==2.4.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6"
pyparsing==3.0.9; python_full_version >= "3.6.8" and python_version >= "3.6"
pyrsistent==0.18.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.7"
pytest==5.4.3; python_version >= "3.5"
python-dotenv==0.20.0; python_full_version >= "3.6.1" and python_version >= "3.5"
python-olm==3.1.3; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
python-socks==2.0.3; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
pyyaml==6.0; python_version >= "3.7"
six==1.16.0; python_full_version >= "3.6.1" and python_full_version < "4.0.0"
smmap==5.0.0; python_version >= "3.7"
sniffio==1.2.0; python_version >= "3.6" and python_full_version >= "3.6.2"
starlette==0.19.1; python_version >= "3.6" and python_full_version >= "3.6.1"
stevedore==4.0.0; python_version >= "3.8"
termcolor==1.1.0
tomli==2.0.1; python_full_version < "3.11.0a7" and python_full_version >= "3.6.2" and python_version >= "3.7" and python_version < "3.11"
types-markdown==3.4.0
types-termcolor==1.1.5
typing-extensions==4.3.0; python_version >= "3.7" and python_full_version >= "3.6.2" and python_version < "3.10"
unpaddedbase64==2.1.0; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.6.1" and python_full_version < "4.0.0"
uvicorn==0.18.2; python_version >= "3.7"
wcwidth==0.2.5; python_version >= "3.5"
yarl==1.7.2; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6"
zipp==3.8.1; python_version < "3.10" and python_version >= "3.7"

0
tests/__init__.py Normal file
View file

5
tests/test_ops_bot.py Normal file
View file

@ -0,0 +1,5 @@
from ops_bot import __version__
def test_version() -> None:
assert __version__ == "0.1.0"