Skip to content

Commit

Permalink
feat(organizations): add optional Telegram notifications for detectio…
Browse files Browse the repository at this point in the history
…ns (#381)

* feat(services): add Telegram management

* feat(organizations): add telegram_id field in organizations

* feat(detections): add telegram notifications

* test(services): add test cases for TelegramClient

* docs(contributing): update env arg

* build(deps): add httpx as deps

* fix(models); remove faulty pattern

* test(organizations): fix test cases

* style(lint): fix formatting

* docs(uml): update UML

* test(organizations): update test case

* refactor(telegram): clarified behaviour
  • Loading branch information
frgfm authored Nov 1, 2024
1 parent 0248df7 commit b11d217
Show file tree
Hide file tree
Showing 15 changed files with 156 additions and 28 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ SERVER_NAME=
POSTHOG_HOST='https://eu.posthog.com'
POSTHOG_KEY=
SUPPORT_EMAIL=
TELEGRAM_TOKEN=

# Production-only
ACME_EMAIL=
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ None :)
- `S3_REGION`: your S3 bucket is geographically identified by its location's region
- `S3_ENDPOINT_URL`: the URL providing a S3 endpoint by your cloud provider
- `S3_PROXY_URL`: the url of the proxy to hide the real s3 url behind, do not use proxy if ""

- `TELEGRAM_TOKEN`: the token of your Telegram bot
#### Production-only values
- `ACME_EMAIL`: the email linked to your certificate for HTTPS
- `BACKEND_HOST`: the subdomain where your users will access your API (e.g "api.mydomain.com")
Expand Down
20 changes: 10 additions & 10 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ prometheus-fastapi-instrumentator = "^6.1.0"
python-multipart = "==0.0.7"
python-magic = "^0.4.17"
boto3 = "^1.26.0"
httpx = "^0.24.0"

[tool.poetry.group.quality]
optional = true
Expand Down
17 changes: 9 additions & 8 deletions scripts/dbdiagram.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Table "User" as U {
"id" int [not null]
"organization_id" int [ref: > O.id, not null]
"role" userrole [not null]
"login" text [not null]
"hashed_password" text [not null]
"login" varchar [not null]
"hashed_password" varchar [not null]
"created_at" timestamp [not null]
Indexes {
(id, login) [pk]
Expand All @@ -19,15 +19,15 @@ Table "User" as U {
Table "Camera" as C {
"id" int [not null]
"organization_id" int [ref: > O.id, not null]
"name" text [not null]
"name" varchar [not null]
"angle_of_view" float [not null]
"elevation" float [not null]
"lat" float [not null]
"lon" float [not null]
"is_trustable" bool [not null]
"created_at" timestamp [not null]
"last_active_at" timestamp
"last_image" text
"last_image" varchar
Indexes {
(id) [pk]
}
Expand All @@ -37,8 +37,8 @@ Table "Detection" as D {
"id" int [not null]
"camera_id" int [ref: > C.id, not null]
"azimuth" float [not null]
"bucket_key" text [not null]
"bboxes" text [not null]
"bucket_key" varchar [not null]
"bboxes" varchar [not null]
"is_wildfire" bool
"created_at" timestamp [not null]
"updated_at" timestamp [not null]
Expand All @@ -49,7 +49,8 @@ Table "Detection" as D {

Table "Organization" as O {
"id" int [not null]
"name" text [not null]
"name" varchar [not null]
"telegram_id" varchar
Indexes {
(id) [pk]
}
Expand All @@ -58,7 +59,7 @@ Table "Organization" as O {

Table "Webhook" as W {
"id" int [not null]
"url" text [not null]
"url" varchar [not null]
Indexes {
(id) [pk]
}
Expand Down
20 changes: 17 additions & 3 deletions src/app/api/api_v1/endpoints/detections.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,17 @@
status,
)

from app.api.dependencies import dispatch_webhook, get_camera_crud, get_detection_crud, get_jwt, get_webhook_crud
from app.api.dependencies import (
dispatch_webhook,
get_camera_crud,
get_detection_crud,
get_jwt,
get_organization_crud,
get_webhook_crud,
)
from app.core.config import settings
from app.crud import CameraCRUD, DetectionCRUD, WebhookCRUD
from app.models import Camera, Detection, Role, UserRole
from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, WebhookCRUD
from app.models import Camera, Detection, Organization, Role, UserRole
from app.schemas.detections import (
BOXES_PATTERN,
COMPILED_BOXES_PATTERN,
Expand All @@ -34,6 +41,7 @@
)
from app.schemas.login import TokenPayload
from app.services.storage import s3_service, upload_file
from app.services.telegram import telegram_client
from app.services.telemetry import telemetry_client

router = APIRouter()
Expand All @@ -53,6 +61,7 @@ async def create_detection(
file: UploadFile = File(..., alias="file"),
detections: DetectionCRUD = Depends(get_detection_crud),
webhooks: WebhookCRUD = Depends(get_webhook_crud),
organizations: OrganizationCRUD = Depends(get_organization_crud),
token_payload: TokenPayload = Security(get_jwt, scopes=[Role.CAMERA]),
) -> Detection:
telemetry_client.capture(f"camera|{token_payload.sub}", event="detections-create")
Expand All @@ -74,6 +83,11 @@ async def create_detection(
if any(whs):
for webhook in await webhooks.fetch_all():
background_tasks.add_task(dispatch_webhook, webhook.url, det)
# Telegram notifications
if telegram_client.is_enabled:
org = cast(Organization, await organizations.get(token_payload.organization_id, strict=True))
if org.telegram_id:
background_tasks.add_task(telegram_client.notify, org.telegram_id, det.model_dump_json())
return det


Expand Down
21 changes: 20 additions & 1 deletion src/app/api/api_v1/endpoints/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
from app.crud import OrganizationCRUD
from app.models import Organization, UserRole
from app.schemas.login import TokenPayload
from app.schemas.organizations import OrganizationCreate
from app.schemas.organizations import OrganizationCreate, TelegramChannelId
from app.services.storage import s3_service
from app.services.telegram import telegram_client
from app.services.telemetry import telemetry_client

router = APIRouter()
Expand Down Expand Up @@ -73,3 +74,21 @@ async def delete_organization(
if not (await s3_service.delete_bucket(bucket_name)):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create bucket")
await organizations.delete(organization_id)


@router.patch(
"/{organization_id}", status_code=status.HTTP_200_OK, summary="Update telegram channel ID of an organization"
)
async def update_telegram_id(
payload: TelegramChannelId,
organization_id: int = Path(..., gt=0),
organizations: OrganizationCRUD = Depends(get_organization_crud),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]),
) -> Organization:
telemetry_client.capture(
token_payload.sub, event="organizations-update-telegram-id", properties={"organization_id": organization_id}
)
# Check if the telegram channel ID is valid
if payload.telegram_id and not telegram_client.has_channel_access(payload.telegram_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unable to access Telegram channel")
return await organizations.update(organization_id, payload)
3 changes: 3 additions & 0 deletions src/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ def sqlachmey_uri(cls, v: str) -> str:
S3_PROXY_URL: str = os.environ.get("S3_PROXY_URL", "")
S3_URL_EXPIRATION: int = int(os.environ.get("S3_URL_EXPIRATION") or 24 * 3600)

# Notifications
TELEGRAM_TOKEN: Union[str, None] = os.environ.get("TELEGRAM_TOKEN")

# Error monitoring
SENTRY_DSN: Union[str, None] = os.environ.get("SENTRY_DSN")
SERVER_NAME: str = os.environ.get("SERVER_NAME", socket.gethostname())
Expand Down
4 changes: 2 additions & 2 deletions src/app/crud/crud_organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@

from app.crud.base import BaseCRUD
from app.models import Organization
from app.schemas.organizations import OrganizationCreate, OrganizationUpdate
from app.schemas.organizations import OrganizationCreate, TelegramChannelId

__all__ = ["OrganizationCRUD"]


class OrganizationCRUD(BaseCRUD[Organization, OrganizationCreate, OrganizationUpdate]):
class OrganizationCRUD(BaseCRUD[Organization, OrganizationCreate, TelegramChannelId]):
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, Organization)
1 change: 1 addition & 0 deletions src/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class Organization(SQLModel, table=True):
__tablename__ = "organizations"
id: int = Field(None, primary_key=True)
name: str = Field(..., min_length=5, max_length=100, nullable=False, unique=True)
telegram_id: Union[str, None] = Field(None, nullable=True)


class Webhook(SQLModel, table=True):
Expand Down
6 changes: 6 additions & 0 deletions src/app/schemas/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.

from typing import Union

from pydantic import BaseModel, Field

__all__ = ["OrganizationCreate", "OrganizationUpdate"]
Expand All @@ -26,3 +28,7 @@ class OrganizationUpdate(BaseModel):
description="name of the organization",
json_schema_extra={"examples": ["pyro-org-01"]},
)


class TelegramChannelId(BaseModel):
telegram_id: Union[str, None] = Field(None, pattern=r"^@[a-zA-Z0-9_-]+$")
58 changes: 58 additions & 0 deletions src/app/services/telegram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Copyright (C) 2024, Pyronear.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.

import logging
from typing import Union

import requests

from app.core.config import settings

logger = logging.getLogger("uvicorn.error")

__all__ = ["telegram_client"]


class TelegramClient:
BASE_URL = "https://api.telegram.org/bot{token}"

def __init__(self, token: Union[str, None] = None) -> None:
self.is_enabled = isinstance(token, str)
if isinstance(token, str):
self.token = token
# Validate token
response = requests.get(
f"{self.BASE_URL.format(token=self.token)}/getMe",
timeout=1,
)
if response.status_code != 200:
raise ValueError(f"Invalid Telegram Bot token: {response.text}")
logger.info("Telegram notifications enabled")

def has_channel_access(self, channel_id: str) -> bool:
if not self.is_enabled:
raise AssertionError("Telegram notifications are not enabled")
response = requests.get(
f"{self.BASE_URL.format(token=self.token)}/getChat",
json={"chat_id": channel_id},
timeout=1,
)
return response.status_code == 200

def notify(self, channel_id: str, message: str) -> requests.Response:
if not self.is_enabled:
raise AssertionError("Telegram notifications are not enabled")
response = requests.post(
f"{self.BASE_URL.format(token=self.token)}/sendMessage",
json={"chat_id": channel_id, "text": message},
timeout=2,
)
if response.status_code != 200:
logger.error(f"Failed to send message to Telegram: {response.text}")

return response


telegram_client = TelegramClient(token=settings.TELEGRAM_TOKEN)
2 changes: 2 additions & 0 deletions src/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@
{
"id": 1,
"name": "organization-1",
"telegram_id": None,
},
{
"id": 2,
"name": "organization-2",
"telegram_id": None,
},
]

Expand Down
4 changes: 1 addition & 3 deletions src/tests/endpoints/test_organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ async def test_create_organization(
if isinstance(status_detail, str):
assert response.json()["detail"] == status_detail
if response.status_code // 100 == 2:
assert {
k: v for k, v in response.json().items() if k not in {"id", "created_at", "last_active_at", "is_trustable"}
} == payload
assert {k: v for k, v in response.json().items() if k not in {"id", "telegram_id"}} == payload


@pytest.mark.parametrize(
Expand Down
24 changes: 24 additions & 0 deletions src/tests/services/test_telegram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import pytest

from app.core.config import settings
from app.services.telegram import TelegramClient


def test_telegram_client():
with pytest.raises(ValueError, match="Invalid Telegram Bot token"):
TelegramClient("invalid-token")

client = TelegramClient(None)
assert not client.is_enabled

client = TelegramClient(settings.TELEGRAM_TOKEN)
assert client.is_enabled == isinstance(settings.TELEGRAM_TOKEN, str)

if isinstance(settings.TELEGRAM_TOKEN, str):
assert not client.has_channel_access("invalid-channel-id")
assert client.notify("invalid-channel-id", "test").status_code == 404
else:
with pytest.raises(AssertionError, match="Telegram notifications are not enabled"):
client.has_channel_access("invalid-channel-id")
with pytest.raises(AssertionError, match="Telegram notifications are not enabled"):
client.notify("invalid-channel-id", "test")

0 comments on commit b11d217

Please sign in to comment.